diff --git a/argclass/__init__.py b/argclass/__init__.py index 3fe0150..360184b 100644 --- a/argclass/__init__.py +++ b/argclass/__init__.py @@ -21,6 +21,14 @@ UnexpectedConfigValue, ValueKind, ) +from .exceptions import ( + ArgclassError, + ArgumentDefinitionError, + ComplexTypeError, + ConfigurationError, + EnumValueError, + TypeConversionError, +) from .factory import ( Argument, ArgumentSequence, @@ -44,7 +52,14 @@ StoreMeta, TypedArgument, ) -from .types import Actions, ConverterType, LogLevelEnum, Nargs, NargsType +from .types import ( + Actions, + ConverterType, + LogLevelEnum, + MetavarType, + Nargs, + NargsType, +) from .utils import parse_bool, read_ini_configs # Alias for backward compatibility @@ -54,11 +69,20 @@ EnumType = EnumMeta __all__ = [ + # Exceptions + "ArgclassError", + "ArgumentDefinitionError", + "ComplexTypeError", + "ConfigurationError", + "EnumValueError", + "TypeConversionError", + "UnexpectedConfigValue", # Types and enums "Actions", "Nargs", "LogLevelEnum", "ConverterType", + "MetavarType", "NargsType", # Classes "SecretString", diff --git a/argclass/defaults.py b/argclass/defaults.py index 0a9002a..c8b13b8 100644 --- a/argclass/defaults.py +++ b/argclass/defaults.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any, Dict, Iterable, Mapping, Optional, Tuple, Union +from .exceptions import ConfigurationError + try: import tomllib @@ -30,16 +32,18 @@ class ValueKind(IntEnum): BOOL = 2 # boolean value -class UnexpectedConfigValue(ValueError): +class UnexpectedConfigValue(ConfigurationError): """Config value doesn't match expected type.""" def __init__(self, key: str, expected: ValueKind, value: Any): self.value = repr(value) self.expected = expected - self.key = key + self.key = key # Backward compatibility alias super().__init__( - f"Config key '{key}' expected {expected.name!r}, " - f"got {type(value)!r}: {self.value}" + f"expected {expected.name}, " + f"got {type(value).__name__}: {self.value}", + field_name=key, + hint=f"Provide a value of type {expected.name}", ) diff --git a/argclass/exceptions.py b/argclass/exceptions.py new file mode 100644 index 0000000..9be759d --- /dev/null +++ b/argclass/exceptions.py @@ -0,0 +1,300 @@ +"""Exception classes for argclass with rich context for debugging.""" + +from typing import Any, Optional, Tuple + + +class ArgclassError(Exception): + """Base exception for all argclass errors. + + Provides structured context for debugging configuration issues. + All argclass exceptions inherit from this class. + + Attributes: + message: The error message describing what went wrong. + field_name: The name of the field that caused the error, if applicable. + hint: A suggestion for how to fix the error, if available. + + Example:: + + try: + # ... argclass operation that may fail + pass + except ArgclassError as e: + print(f"Error: {e}") + if e.field_name: + print(f"Field: {e.field_name}") + if e.hint: + print(f"Hint: {e.hint}") + """ + + def __init__( + self, + message: str, + *, + field_name: Optional[str] = None, + hint: Optional[str] = None, + ): + self.message = message + self.field_name = field_name + self.hint = hint + super().__init__(self._format_message()) + + def _format_message(self) -> str: + parts = [] + if self.field_name: + parts.append(f"[{self.field_name}]") + parts.append(self.message) + if self.hint: + parts.append(f"Hint: {self.hint}") + return " ".join(parts) + + +class ArgumentDefinitionError(ArgclassError): + """Error in argument definition or registration with argparse. + + Raised when an argument cannot be added to the parser due to + invalid configuration (conflicting options, invalid types, etc.). + + Attributes: + aliases: The conflicting aliases, e.g., ``("-v", "--verbose")``. + kwargs: The kwargs passed to argparse when the error occurred. + + Example:: + + # This exception is typically raised during parser construction + # when argument definitions conflict: + try: + class Parser(argclass.Parser): + verbose: bool = argclass.Argument("-h") # conflicts with --help + except ArgumentDefinitionError as e: + print(f"Conflict: {e.aliases}") + """ + + def __init__( + self, + message: str, + *, + field_name: Optional[str] = None, + aliases: Optional[Tuple[str, ...]] = None, + kwargs: Optional[dict] = None, + hint: Optional[str] = None, + ): + self.aliases = aliases + self.kwargs = kwargs + super().__init__(message, field_name=field_name, hint=hint) + + def _format_message(self) -> str: + parts = [] + if self.field_name: + parts.append(f"[{self.field_name}]") + parts.append(self.message) + if self.aliases: + parts.append(f"(aliases: {', '.join(self.aliases)})") + if self.hint: + parts.append(f"Hint: {self.hint}") + return " ".join(parts) + + +class TypeConversionError(ArgclassError): + """Error during type conversion of argument values. + + Raised when a value cannot be converted to the expected type, + either by the type function or a custom converter. + + Attributes: + value: The original value that failed conversion. + target_type: The type that the value was being converted to. + + Example:: + + try: + # When a custom converter fails: + def strict_port(value: str) -> int: + port = int(value) + if not (1 <= port <= 65535): + raise TypeConversionError( + "Port must be between 1 and 65535", + field_name="port", + value=value, + target_type=int, + hint="Use a valid port number (1-65535)", + ) + return port + except TypeConversionError as e: + print(f"Invalid value: {e.value!r} for type {e.target_type}") + """ + + def __init__( + self, + message: str, + *, + field_name: Optional[str] = None, + value: Any = None, + target_type: Optional[type] = None, + hint: Optional[str] = None, + ): + self.value = value + self.target_type = target_type + super().__init__(message, field_name=field_name, hint=hint) + + def _format_message(self) -> str: + parts = [] + if self.field_name: + parts.append(f"[{self.field_name}]") + parts.append(self.message) + if self.value is not None: + parts.append(f"(got {self.value!r})") + if self.target_type is not None: + type_name = getattr( + self.target_type, "__name__", str(self.target_type) + ) + parts.append(f"expected type: {type_name}") + if self.hint: + parts.append(f"Hint: {self.hint}") + return " ".join(parts) + + +class ConfigurationError(ArgclassError): + """Error loading or parsing configuration files. + + Raised when a configuration file cannot be parsed or contains + invalid values for the expected argument types. + + Attributes: + file_path: Path to the configuration file that caused the error. + section: The config section (e.g., INI section) where error occurred. + + Example:: + + try: + parser = Parser( + config_files=["config.ini"], + config_parser_class=argclass.INIDefaultsParser, + ) + except ConfigurationError as e: + print(f"Config error in {e.file_path}") + if e.section: + print(f"Section: [{e.section}]") + """ + + def __init__( + self, + message: str, + *, + field_name: Optional[str] = None, + file_path: Optional[str] = None, + section: Optional[str] = None, + hint: Optional[str] = None, + ): + self.file_path = file_path + self.section = section + super().__init__(message, field_name=field_name, hint=hint) + + def _format_message(self) -> str: + parts = [] + if self.file_path: + location = self.file_path + if self.section: + location = f"{self.file_path}:[{self.section}]" + parts.append(f"({location})") + if self.field_name: + parts.append(f"[{self.field_name}]") + parts.append(self.message) + if self.hint: + parts.append(f"Hint: {self.hint}") + return " ".join(parts) + + +class EnumValueError(ArgclassError): + """Error with enum argument value or default. + + Raised when an enum default or value is not a valid member + of the specified enum class. + + Attributes: + enum_class: The enum class that was expected. + valid_values: Tuple of valid enum member names. + + Example:: + + from enum import Enum + + class Color(Enum): + RED = "red" + GREEN = "green" + + try: + class Parser(argclass.Parser): + # "YELLOW" is not a valid Color member + color: Color = argclass.EnumArgument(Color, default="YELLOW") + except EnumValueError as e: + print(f"Invalid value for {e.enum_class.__name__}") + print(f"Valid options: {', '.join(e.valid_values)}") + """ + + def __init__( + self, + message: str, + *, + field_name: Optional[str] = None, + enum_class: Optional[type] = None, + valid_values: Optional[Tuple[str, ...]] = None, + hint: Optional[str] = None, + ): + self.enum_class = enum_class + self.valid_values = valid_values + super().__init__(message, field_name=field_name, hint=hint) + + def _format_message(self) -> str: + parts = [] + if self.field_name: + parts.append(f"[{self.field_name}]") + parts.append(self.message) + if self.valid_values: + parts.append(f"Valid values: {', '.join(self.valid_values)}") + if self.hint: + parts.append(f"Hint: {self.hint}") + return " ".join(parts) + + +class ComplexTypeError(ArgclassError): + """Error with complex type annotations. + + Raised when a type annotation is too complex to be automatically + handled and requires explicit converter specification. + + Attributes: + typespec: The type annotation that could not be handled. + + Example:: + + try: + class Parser(argclass.Parser): + # Union types (other than T | None) are not supported + value: str | int # Raises ComplexTypeError + except ComplexTypeError as e: + print(f"Cannot handle type: {e.typespec}") + print("Provide an explicit converter with type=...") + """ + + def __init__( + self, + message: str, + *, + field_name: Optional[str] = None, + typespec: Any = None, + hint: Optional[str] = None, + ): + self.typespec = typespec + super().__init__(message, field_name=field_name, hint=hint) + + def _format_message(self) -> str: + parts = [] + if self.field_name: + parts.append(f"[{self.field_name}]") + parts.append(self.message) + if self.typespec is not None: + parts.append(f"(type: {self.typespec!r})") + if self.hint: + parts.append(f"Hint: {self.hint}") + return " ".join(parts) diff --git a/argclass/factory.py b/argclass/factory.py index 20331bf..c0922b7 100644 --- a/argclass/factory.py +++ b/argclass/factory.py @@ -7,6 +7,7 @@ Callable, Iterable, List, + Literal, Optional, Type, TypeVar, @@ -17,8 +18,16 @@ from argparse import Action +from .exceptions import EnumValueError from .store import ConfigArgument, INIConfig, TypedArgument -from .types import Actions, ConverterType, LogLevelEnum, Nargs, NargsType +from .types import ( + Actions, + ConverterType, + LogLevelEnum, + MetavarType, + Nargs, + NargsType, +) T = TypeVar("T") @@ -34,7 +43,7 @@ def ArgumentSingle( default: Optional[T] = None, env_var: Optional[str] = None, help: Optional[str] = None, - metavar: Optional[str] = None, + metavar: Optional[MetavarType] = None, required: Optional[bool] = None, secret: bool = False, ) -> T: @@ -81,7 +90,7 @@ def ArgumentSequence( default: Optional[List[T]] = None, env_var: Optional[str] = None, help: Optional[str] = None, - metavar: Optional[str] = None, + metavar: Optional[MetavarType] = None, required: Optional[bool] = None, secret: bool = False, ) -> List[T]: @@ -123,26 +132,105 @@ class Parser(argclass.Parser): ) -# Overload: type + nargs (sequence) → List[T] +# Overload: type + nargs + converter → converter's return type (must be first!) +R = TypeVar("R") + + +@overload +def Argument( + *aliases: str, + type: ConverterType, + nargs: NargsType, + converter: Callable[..., R], + action: Union[Actions, Type[Action]] = ..., + choices: Optional[Iterable[str]] = ..., + const: Optional[Any] = ..., + default: Optional[Any] = ..., + env_var: Optional[str] = ..., + help: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., + required: Optional[bool] = ..., + secret: bool = ..., +) -> R: ... + + +# Overload: Type[T] + nargs="?" (optional single) → T @overload def Argument( *aliases: str, type: Type[T], - nargs: Optional[NargsType], + nargs: Literal["?"], action: Union[Actions, Type[Action]] = ..., choices: Optional[Iterable[str]] = ..., const: Optional[Any] = ..., - converter: Optional[ConverterType] = ..., + converter: None = ..., default: Optional[Any] = ..., env_var: Optional[str] = ..., help: Optional[str] = ..., - metavar: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., + required: Optional[bool] = ..., + secret: bool = ..., +) -> T: ... + + +# Overload: Callable type + nargs="?" (optional single) → T +@overload +def Argument( + *aliases: str, + type: Callable[[str], T], + nargs: Literal["?"], + action: Union[Actions, Type[Action]] = ..., + choices: Optional[Iterable[str]] = ..., + const: Optional[Any] = ..., + converter: None = ..., + default: Optional[Any] = ..., + env_var: Optional[str] = ..., + help: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., + required: Optional[bool] = ..., + secret: bool = ..., +) -> T: ... + + +# Overload: Type[T] + nargs (sequence) → List[T] +@overload +def Argument( + *aliases: str, + type: Type[T], + nargs: Union[Literal["*", "+"], int, Nargs], + action: Union[Actions, Type[Action]] = ..., + choices: Optional[Iterable[str]] = ..., + const: Optional[Any] = ..., + converter: None = ..., + default: Optional[Any] = ..., + env_var: Optional[str] = ..., + help: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., + required: Optional[bool] = ..., + secret: bool = ..., +) -> List[T]: ... + + +# Overload: Callable type + nargs (sequence) → List[T] +@overload +def Argument( + *aliases: str, + type: Callable[[str], T], + nargs: Union[Literal["*", "+"], int, Nargs], + action: Union[Actions, Type[Action]] = ..., + choices: Optional[Iterable[str]] = ..., + const: Optional[Any] = ..., + converter: None = ..., + default: Optional[Any] = ..., + env_var: Optional[str] = ..., + help: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., required: Optional[bool] = ..., secret: bool = ..., ) -> List[T]: ... -# Overload: type without nargs (single) → T +# Overload: Type[T] without nargs (single) → T @overload def Argument( *aliases: str, @@ -150,11 +238,30 @@ def Argument( action: Union[Actions, Type[Action]] = ..., choices: Optional[Iterable[str]] = ..., const: Optional[Any] = ..., - converter: Optional[ConverterType] = ..., + converter: None = ..., default: Optional[T] = ..., env_var: Optional[str] = ..., help: Optional[str] = ..., - metavar: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., + nargs: None = ..., + required: Optional[bool] = ..., + secret: bool = ..., +) -> T: ... + + +# Overload: Callable type without nargs (single) → T +@overload +def Argument( + *aliases: str, + type: Callable[[str], T], + action: Union[Actions, Type[Action]] = ..., + choices: Optional[Iterable[str]] = ..., + const: Optional[Any] = ..., + converter: None = ..., + default: Optional[T] = ..., + env_var: Optional[str] = ..., + help: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., nargs: None = ..., required: Optional[bool] = ..., secret: bool = ..., @@ -172,7 +279,7 @@ def Argument( default: Optional[Any] = ..., env_var: Optional[str] = ..., help: Optional[str] = ..., - metavar: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., nargs: Optional[NargsType] = ..., required: Optional[bool] = ..., secret: bool = ..., @@ -180,7 +287,7 @@ def Argument( ) -> T: ... -# Overload: fallback +# Overload: fallback for dynamic/optional parameters (used by Secret, etc.) @overload def Argument( *aliases: str, @@ -191,11 +298,11 @@ def Argument( default: Optional[Any] = ..., env_var: Optional[str] = ..., help: Optional[str] = ..., - metavar: Optional[str] = ..., + metavar: Optional[MetavarType] = ..., nargs: Optional[NargsType] = ..., required: Optional[bool] = ..., secret: bool = ..., - type: None = ..., + type: Optional[ConverterType] = ..., ) -> Any: ... @@ -209,7 +316,7 @@ def Argument( default: Optional[Any] = None, env_var: Optional[str] = None, help: Optional[str] = None, - metavar: Optional[str] = None, + metavar: Optional[MetavarType] = None, nargs: Optional[NargsType] = None, required: Optional[bool] = None, secret: bool = False, @@ -265,30 +372,50 @@ class Parser(argclass.Parser): """ # Dispatch to typed functions when type is provided if type is not None: + # Cast type to Type[Any] since we've verified it's not None + # The actual type could be Type[T] or Callable[[str], T] + type_func = cast(Type[Any], type) if nargs in ("+", "*") or isinstance(nargs, (int, Nargs)): return ArgumentSequence( *aliases, - type=type, # type: ignore[arg-type] - nargs=nargs, # type: ignore[arg-type] + type=type_func, + nargs=nargs, + action=action, + choices=choices, + const=const, + converter=cast(Optional[Callable[[List[Any]], Any]], converter), + default=default, + env_var=env_var, + help=help, + metavar=metavar, + required=required, + secret=secret, + ) + elif nargs == "?" or nargs == Nargs.ZERO_OR_ONE: + # nargs="?" needs special handling - creates TypedArgument directly + return TypedArgument( action=action, + aliases=aliases, choices=choices, const=const, - converter=converter, # type: ignore[arg-type] + converter=converter, default=default, env_var=env_var, help=help, metavar=metavar, + nargs=nargs, required=required, secret=secret, + type=type, ) else: return ArgumentSingle( *aliases, - type=type, # type: ignore[arg-type] + type=type_func, action=action, choices=choices, const=const, - converter=converter, # type: ignore[arg-type] + converter=converter, default=default, env_var=env_var, help=help, @@ -395,18 +522,23 @@ def EnumArgument( elif isinstance(default, str): # Validate string is a valid enum member name check_name = default.upper() if lowercase else default - if check_name not in [e.name for e in enum_class]: - valid = ", ".join(e.name for e in enum_class) - raise ValueError( - f"Default {default!r} is not a valid {enum_class.__name__} " - f"member. Valid values: {valid}" + valid_names = tuple(e.name for e in enum_class) + if check_name not in valid_names: + raise EnumValueError( + f"default {default!r} is not a valid {enum_class.__name__} " + f"member", + enum_class=enum_class, + valid_values=valid_names, ) # Convert string default to enum member default = enum_class[check_name] else: - raise TypeError( - f"Default must be {enum_class.__name__} member or string, " - f"got {type(default).__name__}" + raise EnumValueError( + f"default must be {enum_class.__name__} member or string, " + f"got {type(default).__name__}", + enum_class=enum_class, + valid_values=tuple(e.name for e in enum_class), + hint="Pass an enum member or its string name", ) def converter(x: Any) -> Any: @@ -487,12 +619,12 @@ class Parser(argclass.Parser): # Access actual value connect(api_key=str(parser.api_key)) """ - return Argument( # type: ignore[misc,call-overload] + return Argument( *aliases, action=action, choices=choices, const=const, - converter=converter, # type: ignore[arg-type] + converter=converter, default=default, env_var=env_var, help=help, diff --git a/argclass/parser.py b/argclass/parser.py index 223b2b7..f6a44e8 100644 --- a/argclass/parser.py +++ b/argclass/parser.py @@ -3,7 +3,7 @@ import ast import os from abc import ABCMeta -from argparse import Action, ArgumentError, ArgumentParser +from argparse import Action, ArgumentParser from collections import defaultdict from enum import EnumMeta from pathlib import Path @@ -30,6 +30,7 @@ INIDefaultsParser, ValueKind, ) +from .exceptions import ArgumentDefinitionError, TypeConversionError from .secret import SecretString from .store import AbstractGroup, AbstractParser, TypedArgument from .types import Actions, Nargs @@ -399,11 +400,41 @@ def _add_argument( ): kwargs["default"] = parse_bool(default) + # Safety net: env vars are read above, so default may have changed. + # If we now have a default, remove the required flag. + # Note: positional arguments don't support "required" in argparse. + # Check actual aliases (not argument.aliases which may be empty). + is_optional = any(a.startswith("-") for a in aliases) + + # Positional arguments don't support "required" in argparse + if not is_optional and "required" in kwargs: + raise ArgumentDefinitionError( + "positional arguments do not support 'required' parameter", + field_name=dest, + aliases=tuple(aliases), + hint="Remove 'required' from positional argument, or add '--' " + "prefix to make it optional", + ) + default = kwargs.get("default") - if default is not None and default is not ...: + if ( + is_optional + and default is not None + and default is not ... + and "required" in kwargs + ): kwargs["required"] = False - return dest, parser.add_argument(*aliases, **kwargs) + try: + return dest, parser.add_argument(*aliases, **kwargs) + except Exception as e: + raise ArgumentDefinitionError( + str(e), + field_name=dest, + aliases=tuple(aliases), + kwargs=kwargs, + hint="Check that argument options are compatible with argparse", + ) from e @staticmethod def get_cli_name(name: str) -> str: @@ -556,7 +587,9 @@ def _fill_arguments( default=default, ) - if default is not None and default is not ... and argument.required: + # Check if this will be an optional argument (has -- prefix) + is_optional = any(a.startswith("-") for a in aliases) + if is_optional and argument.has_default and argument.required: argument = argument.copy(required=False) dest, action = self._add_argument(parser, argument, name, *aliases) @@ -624,6 +657,12 @@ def _fill_groups( default=default, env_var=self.get_env_var(dest, argument), ) + + # Check if this will be an optional argument (has -- prefix) + is_optional = any(a.startswith("-") for a in aliases) + if is_optional and argument.has_default and argument.required: + argument = argument.copy(required=False) + dest, action = self._add_argument( group_parser, argument, @@ -716,8 +755,13 @@ def parse_args( try: parsed_value = argument.converter(parsed_value) except Exception as e: - msg = f"failed to convert {parsed_value!r}: {e}" - raise ArgumentError(action, msg) from e + raise TypeConversionError( + f"converter {argument.converter!r} failed: {e}", + field_name=name, + value=parsed_value, + hint="Check that the converter function " + "handles this value type", + ) from e # Ensure current_subparsers is always a tuple, not None if name == "current_subparsers" and parsed_value is None: diff --git a/argclass/store.py b/argclass/store.py index 7158fc4..1264a22 100644 --- a/argclass/store.py +++ b/argclass/store.py @@ -157,6 +157,15 @@ def is_nargs(self) -> bool: return self.nargs > 1 return True + @property + def has_default(self) -> bool: + """Check if the argument has a meaningful default value. + + Returns False if default is None or Ellipsis + (the "no default" sentinel). + """ + return self.default is not None and self.default is not ... + class ConfigArgument(TypedArgument): """Argument for configuration file loading.""" diff --git a/argclass/types.py b/argclass/types.py index 4360db4..88785f1 100644 --- a/argclass/types.py +++ b/argclass/types.py @@ -6,6 +6,7 @@ # Type aliases ConverterType = Callable[[Any], Any] NargsType = Union[int, str, "Nargs", Literal["?", "*", "+"]] +MetavarType = Union[str, Tuple[str, ...]] NoneType = type(None) UnionClass = Union[None, int].__class__ diff --git a/argclass/utils.py b/argclass/utils.py index 1e3af2b..4596d10 100644 --- a/argclass/utils.py +++ b/argclass/utils.py @@ -17,6 +17,7 @@ get_origin, ) +from .exceptions import ComplexTypeError from .types import ( CONTAINER_TYPES, TEXT_TRUE_VALUES, @@ -91,9 +92,7 @@ def _is_union_type(typespec: Any) -> bool: if typespec.__class__ == UnionClass: return True # PEP 604: float | None creates types.UnionType in Python 3.10+ - if hasattr(types, "UnionType") and isinstance(typespec, types.UnionType): - return True - return False + return hasattr(types, "UnionType") and isinstance(typespec, types.UnionType) def unwrap_optional(typespec: Any) -> Optional[Any]: @@ -104,9 +103,12 @@ def unwrap_optional(typespec: Any) -> Optional[Any]: union_args = [a for a in typespec.__args__ if a is not NoneType] if len(union_args) != 1: - raise TypeError( - "Complex types mustn't be used in short form. You have to " - "specify argclass.Argument with converter or type function.", + raise ComplexTypeError( + "Union types with multiple non-None members " + "cannot be used directly", + typespec=typespec, + hint="Use argclass.Argument() with an explicit " + "converter or type function", ) return union_args[0] @@ -135,9 +137,12 @@ def _unwrap_container_type(typespec: Any) -> Optional[Tuple[type, type]]: origin = get_origin(typespec) args = get_args(typespec) + # We know origin is not None because _is_container_type checks this + assert origin is not None + if not args: # list without type parameter - use str as default - return (origin, str) # type: ignore + return (origin, str) # For tuple, we handle specially - just use the first type for now # (full tuple handling would need nargs=N for Tuple[int, str, bool]) @@ -148,7 +153,7 @@ def _unwrap_container_type(typespec: Any) -> Optional[Tuple[type, type]]: if optional_inner is not None: element_type = optional_inner - return (origin, element_type) # type: ignore + return (origin, element_type) def unwrap_literal(typespec: Any) -> Optional[Tuple[type, Tuple[Any, ...]]]: diff --git a/docs/api.md b/docs/api.md index e104106..3130337 100644 --- a/docs/api.md +++ b/docs/api.md @@ -356,10 +356,9 @@ Number of arguments constants. | Value | Meaning | |-------|---------| -| `OPTIONAL` (`?`) | Zero or one argument | +| `ZERO_OR_ONE` (`?`) | Zero or one argument | | `ZERO_OR_MORE` (`*`) | Zero or more arguments | | `ONE_OR_MORE` (`+`) | One or more arguments | -| `REMAINDER` | All remaining arguments | ```{eval-rst} .. autoclass:: argclass.Nargs @@ -404,6 +403,79 @@ Read and merge multiple configuration files. --- +## Exceptions + +argclass provides a hierarchy of typed exceptions for debugging configuration +and parsing errors. All exceptions include structured context attributes. + +### ArgclassError + +Base exception for all argclass errors. Provides `field_name` and `hint` +attributes for debugging context. + +```{eval-rst} +.. autoclass:: argclass.ArgclassError + :members: + :show-inheritance: +``` + +### ArgumentDefinitionError + +Raised when an argument cannot be added to the parser due to invalid +configuration, such as conflicting option strings or invalid argparse kwargs. + +```{eval-rst} +.. autoclass:: argclass.ArgumentDefinitionError + :members: + :show-inheritance: +``` + +### TypeConversionError + +Raised when a value cannot be converted to the expected type. Includes the +original `value` and `target_type` for debugging. + +```{eval-rst} +.. autoclass:: argclass.TypeConversionError + :members: + :show-inheritance: +``` + +### ConfigurationError + +Raised when a configuration file cannot be parsed or contains values that +don't match expected types. Includes `file_path` and `section` attributes. + +```{eval-rst} +.. autoclass:: argclass.ConfigurationError + :members: + :show-inheritance: +``` + +### EnumValueError + +Raised when an enum default or parsed value is not a valid member of the +specified enum class. Includes `enum_class` and `valid_values` for diagnostics. + +```{eval-rst} +.. autoclass:: argclass.EnumValueError + :members: + :show-inheritance: +``` + +### ComplexTypeError + +Raised when a type annotation is too complex to be automatically handled +(e.g., `Union[str, int]`) and requires an explicit converter. + +```{eval-rst} +.. autoclass:: argclass.ComplexTypeError + :members: + :show-inheritance: +``` + +--- + ## Advanced / Internal These classes are primarily for advanced use cases or extending argclass. diff --git a/docs/errors.md b/docs/errors.md index de6024c..965b75c 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -157,3 +157,109 @@ finally: ``` For pytest, use `capsys` fixture instead of manual stderr capture. + +--- + +## argclass Exceptions + +argclass provides typed exceptions with structured context for debugging +configuration and type errors at definition time. + +### Exception Hierarchy + +All exceptions inherit from `ArgclassError`, which provides common attributes +for debugging: + +| Exception | Raised When | Key Attributes | +|-----------|-------------|----------------| +| `ArgclassError` | Base exception for all argclass errors | `field_name`, `hint` | +| `ArgumentDefinitionError` | Argument conflicts with argparse or invalid configuration | `aliases`, `kwargs` | +| `TypeConversionError` | Converter function fails during parsing | `value`, `target_type` | +| `ConfigurationError` | Config file cannot be parsed or contains invalid values | `file_path`, `section` | +| `EnumValueError` | Invalid enum default or value provided | `enum_class`, `valid_values` | +| `ComplexTypeError` | Unsupported type annotation that requires explicit converter | `typespec` | + +### Catching Exceptions + +Use specific exception types to handle different error categories: + + +```python +import argclass + +class Parser(argclass.Parser): + count: int = 1 + +parser = Parser() + +try: + parser.parse_args(["--count", "abc"]) +except SystemExit: + # argparse handles type conversion errors with SystemExit + pass +``` + +For definition-time errors (raised when the parser class is constructed): + + +```python +import argclass +from enum import Enum + +class Color(Enum): + RED = "red" + GREEN = "green" + +try: + # This would raise EnumValueError if "yellow" is not a valid Color + class Parser(argclass.Parser): + color: Color = argclass.EnumArgument(Color, default=Color.RED) +except argclass.EnumValueError as e: + print(f"Invalid enum: {e.valid_values}") +``` + +### Exception Attributes + +Each exception type includes contextual attributes for debugging: + +```python +import argclass + +# ArgclassError base attributes (available on all exceptions): +# - field_name: str | None - The field that caused the error +# - hint: str | None - Suggestion for fixing the error +# - message: str - The error message + +# ArgumentDefinitionError adds: +# - aliases: tuple[str, ...] | None - Argument aliases that conflicted +# - kwargs: dict | None - The kwargs passed to argparse + +# TypeConversionError adds: +# - value: Any - The value that failed conversion +# - target_type: type | None - The type we tried to convert to + +# ConfigurationError adds: +# - file_path: str | None - Path to the config file +# - section: str | None - Config section with the error + +# EnumValueError adds: +# - enum_class: type | None - The enum class +# - valid_values: tuple[str, ...] | None - Valid enum member names + +# ComplexTypeError adds: +# - typespec: Any - The type annotation that couldn't be handled +``` + +### When Exceptions Are Raised + +| Phase | Exception Types | Example | +|-------|-----------------|---------| +| Class definition | `ArgumentDefinitionError`, `EnumValueError`, `ComplexTypeError` | Invalid default for enum | +| Config loading | `ConfigurationError` | Malformed INI file | +| Argument parsing | `TypeConversionError` (wrapped by argparse) | `--count abc` for `int` field | + +:::{note} +During argument parsing, most type conversion errors are caught by argparse +and converted to `SystemExit(2)`. The `TypeConversionError` is primarily +raised during config file value conversion or custom converter failures. +::: diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 528d04f..e755e5d 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -242,3 +242,153 @@ Use `cli.current_subparsers` to check which subcommand was selected, or implement `__call__` on each subcommand and call `cli()` to dispatch automatically to the selected command. ::: + +--- + +## Exception-Raising Patterns + +These patterns will raise specific argclass exceptions at parser definition +or parsing time. + +### ComplexTypeError: Unsupported Union Types + +Union types like `str | int` cannot be automatically converted because argclass +doesn't know which type to try first. You must provide an explicit converter. + +| Pattern | Result | +|---------|--------| +| `field: str \| int` | `ComplexTypeError` at definition time | +| `field: str \| None` | OK — `None` is handled specially | +| `field: list[str] \| None` | OK — `None` is handled specially | + + +```python +import argclass + +# This works - Optional types are supported +class WorkingParser(argclass.Parser): + name: str | None # OK: Union with None + +parser = WorkingParser() +parser.parse_args([]) +assert parser.name is None +``` + +To fix union types, provide an explicit converter: + +```python +import argclass + +def flexible_int(value: str) -> int | str: + try: + return int(value) + except ValueError: + return value + +class Parser(argclass.Parser): + count: int | str = argclass.Argument(type=flexible_int, default=0) +``` + +### EnumValueError: Invalid Enum Defaults + +When using `EnumArgument`, the default must be a valid enum member or its +string name. Providing an invalid default raises `EnumValueError`. + + +```python +import argclass +from enum import Enum + +class Color(Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + +# Correct: default is a valid enum member name +class Parser(argclass.Parser): + color: Color = argclass.EnumArgument(Color, default="RED") + +parser = Parser() +parser.parse_args([]) +assert parser.color == Color.RED +``` + +### ArgumentDefinitionError: Conflicting Aliases + +If you define an alias that conflicts with another argument or a reserved +argparse option, `ArgumentDefinitionError` is raised. + + +```python +import argclass + +# This works - no conflicts +class Parser(argclass.Parser): + verbose: bool = argclass.Argument("-v", default=False) + output: str = argclass.Argument("-o", default="out.txt") + +parser = Parser() +parser.parse_args(["-v", "-o", "result.txt"]) +assert parser.verbose is True +assert parser.output == "result.txt" +``` + +### TypeConversionError: Converter Failures + +When a custom converter raises an exception, argclass wraps it in +`TypeConversionError` with context about what value failed and the target type. + + +```python +import argclass + +def positive_int(value: str) -> int: + num = int(value) + if num <= 0: + raise ValueError(f"{value} must be positive") + return num + +class Parser(argclass.Parser): + count: int = argclass.Argument(type=positive_int, default=1) + +parser = Parser() +parser.parse_args(["--count", "5"]) +assert parser.count == 5 +``` + +### ConfigurationError: Invalid Config Files + +When loading config files with `config_files` parameter, malformed files or +type mismatches raise `ConfigurationError`. + +| Issue | Result | +|-------|--------| +| Malformed INI/JSON/TOML | `ConfigurationError` with file path | +| Value doesn't match type | `ConfigurationError` with field and section | +| Missing file | Silently ignored (unless `strict_config=True`) | + + +```python +import argclass +from pathlib import Path +from tempfile import NamedTemporaryFile + +class Parser(argclass.Parser): + host: str = "localhost" + port: int = 8080 + +# Create a valid config file +with NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write('{"host": "example.com", "port": 9000}') + config_path = f.name + +parser = Parser( + config_files=[config_path], + config_parser_class=argclass.JSONDefaultsParser, +) +parser.parse_args([]) +assert parser.host == "example.com" +assert parser.port == 9000 + +Path(config_path).unlink() +``` diff --git a/pyproject.toml b/pyproject.toml index d84d954..272459a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "argclass" -version = "1.5.2" +version = "1.6.0" description = "Declarative CLI parser with type hints, config files, and environment variables - zero dependencies" authors = [{ name = "Dmitry Orlov", email = "me@mosquito.su" }] keywords = [ @@ -81,7 +81,7 @@ default-groups = [ ] [tool.hatch.build.targets.sdist] -include = ["argclass"] +include = ["argclass", "tests"] [tool.hatch.build.targets.wheel] include = ["argclass"] @@ -117,4 +117,9 @@ strict_optional = true warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = false -files = "argclass" +files = ["argclass", "tests"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py new file mode 100644 index 0000000..6174de0 --- /dev/null +++ b/tests/test_edge_cases.py @@ -0,0 +1,675 @@ +"""Tests for edge cases in positional arguments, required flag handling, +and exceptions.""" + +import pytest +from typing import Optional, List + +import argclass +from argclass.store import TypedArgument +from argclass.utils import _is_union_type + + +class TestUnionTypeDetection: + """Test PEP 604 union type detection for coverage.""" + + def test_pep604_union_detected(self): + """PEP 604 union (int | str) should be detected as union type.""" + assert _is_union_type(int | str) is True + + def test_pep604_optional_detected(self): + """PEP 604 optional (int | None) should be detected as union type.""" + assert _is_union_type(int | None) is True + + def test_non_union_not_detected(self): + """Non-union types should not be detected as union.""" + assert _is_union_type(int) is False + assert _is_union_type(str) is False + assert _is_union_type(list) is False + + +class TestHasDefaultProperty: + """Test TypedArgument.has_default property edge cases.""" + + def test_has_default_with_none(self): + """default=None should return has_default=False.""" + arg = TypedArgument(default=None) + assert arg.has_default is False + + def test_has_default_with_ellipsis(self): + """default=... should return has_default=False.""" + arg = TypedArgument(default=...) + assert arg.has_default is False + + def test_has_default_with_zero(self): + """default=0 should return has_default=True (falsy but valid).""" + arg = TypedArgument(default=0) + assert arg.has_default is True + + def test_has_default_with_empty_string(self): + """default='' should return has_default=True (falsy but valid).""" + arg = TypedArgument(default="") + assert arg.has_default is True + + def test_has_default_with_false(self): + """default=False should return has_default=True.""" + arg = TypedArgument(default=False) + assert arg.has_default is True + + def test_has_default_with_empty_list(self): + """default=[] should return has_default=True.""" + arg = TypedArgument(default=[]) + assert arg.has_default is True + + +class TestIsPositionalProperty: + """Test TypedArgument.is_positional property edge cases.""" + + def test_is_positional_empty_aliases(self): + """Empty aliases should return is_positional=True.""" + arg = TypedArgument(aliases=frozenset()) + assert arg.is_positional is True + + def test_is_positional_with_dash_prefix(self): + """Alias with -- prefix should return is_positional=False.""" + arg = TypedArgument(aliases=["--flag"]) + assert arg.is_positional is False + + def test_is_positional_single_dash(self): + """Single-dash alias like -v should return is_positional=False.""" + arg = TypedArgument(aliases=["-v"]) + assert arg.is_positional is False + + def test_is_positional_no_dash(self): + """Alias without dash should return is_positional=True.""" + arg = TypedArgument(aliases=["name"]) + assert arg.is_positional is True + + +class TestPositionalArgumentWithRequired: + """Test positional arguments with required flag + (argparse doesn't support this).""" + + def test_positional_with_required_true_raises(self): + """Positional args with required=True should raise + ArgumentDefinitionError.""" + + class Parser(argclass.Parser): + name: str = argclass.Argument("name", required=True) + + with pytest.raises(argclass.ArgumentDefinitionError) as exc_info: + Parser().parse_args(["test"]) + + assert "name" in str(exc_info.value) + assert "positional" in str(exc_info.value).lower() + + def test_positional_with_required_false_raises(self): + """Positional args with required=False should also raise error.""" + + class Parser(argclass.Parser): + name: str = argclass.Argument("name", required=False) + + with pytest.raises(argclass.ArgumentDefinitionError) as exc_info: + Parser().parse_args(["test"]) + + assert "name" in str(exc_info.value) + + +class TestPositionalArgumentWithNargs: + """Test positional arguments with various nargs values.""" + + def test_positional_nargs_question_without_value(self): + """Positional with nargs='?' uses None when not provided.""" + + class Parser(argclass.Parser): + name: Optional[str] = argclass.Argument("name", nargs="?") + + parser = Parser() + parser.parse_args([]) + assert parser.name is None + + def test_positional_nargs_question_with_value(self): + """Positional with nargs='?' uses provided value.""" + + class Parser(argclass.Parser): + name: Optional[str] = argclass.Argument("name", nargs="?") + + parser = Parser() + parser.parse_args(["hello"]) + assert parser.name == "hello" + + def test_positional_nargs_question_with_default(self): + """Positional with nargs='?' and default uses default + when not provided.""" + + class Parser(argclass.Parser): + name: str = argclass.Argument("name", nargs="?", default="world") + + parser = Parser() + parser.parse_args([]) + assert parser.name == "world" + + def test_positional_nargs_star_without_values(self): + """Positional with nargs='*' returns empty list when not provided.""" + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument("files", nargs="*") + + parser = Parser() + parser.parse_args([]) + assert parser.files == [] + + def test_positional_nargs_star_with_values(self): + """Positional with nargs='*' collects all values.""" + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument("files", nargs="*") + + parser = Parser() + parser.parse_args(["a.txt", "b.txt", "c.txt"]) + assert parser.files == ["a.txt", "b.txt", "c.txt"] + + def test_positional_nargs_plus_requires_value(self): + """Positional with nargs='+' requires at least one value.""" + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument("files", nargs="+") + + parser = Parser() + with pytest.raises(SystemExit): + parser.parse_args([]) + + def test_positional_nargs_plus_with_values(self): + """Positional with nargs='+' collects all values.""" + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument("files", nargs="+") + + parser = Parser() + parser.parse_args(["a.txt", "b.txt"]) + assert parser.files == ["a.txt", "b.txt"] + + def test_positional_nargs_fixed(self): + """Positional with nargs=2 requires exactly 2 values.""" + + class Parser(argclass.Parser): + coords: List[int] = argclass.Argument("coords", type=int, nargs=2) + + parser = Parser() + parser.parse_args(["10", "20"]) + assert parser.coords == [10, 20] + + def test_positional_nargs_fixed_wrong_count(self): + """Positional with nargs=2 fails with wrong count.""" + + class Parser(argclass.Parser): + coords: List[int] = argclass.Argument("coords", type=int, nargs=2) + + parser = Parser() + with pytest.raises(SystemExit): + parser.parse_args(["10"]) + + +class TestPositionalWithDefault: + """Test positional arguments with default values.""" + + def test_positional_with_default_is_optional(self): + """Positional with default can be omitted.""" + + class Parser(argclass.Parser): + name: str = argclass.Argument("name", nargs="?", default="world") + + parser = Parser() + parser.parse_args([]) + assert parser.name == "world" + + def test_positional_with_default_can_be_overridden(self): + """Positional with default can still accept values.""" + + class Parser(argclass.Parser): + name: str = argclass.Argument("name", nargs="?", default="world") + + parser = Parser() + parser.parse_args(["universe"]) + assert parser.name == "universe" + + +class TestRequiredFlagAutoRemoval: + """Test automatic removal of required flag when default is provided.""" + + def test_required_removed_with_config_default(self, tmp_path): + """required=True is auto-removed when config provides value.""" + config = tmp_path / "config.ini" + config.write_text("[DEFAULT]\nvalue = 42\n") + + class Parser(argclass.Parser): + value: int = argclass.Argument(required=True) + + parser = Parser(config_files=[str(config)]) + parser.parse_args([]) + assert parser.value == 42 + + def test_required_removed_with_env_var(self, monkeypatch): + """required=True is auto-removed when env var provides value.""" + monkeypatch.setenv("TEST_VALUE", "123") + + class Parser(argclass.Parser): + value: int = argclass.Argument(env_var="TEST_VALUE", required=True) + + parser = Parser() + parser.parse_args([]) + assert parser.value == 123 + + def test_required_removed_with_zero_default(self): + """required=True is auto-removed when default=0.""" + + class Parser(argclass.Parser): + count: int = argclass.Argument(default=0, required=True) + + parser = Parser() + parser.parse_args([]) + assert parser.count == 0 + + def test_required_removed_with_empty_string_default(self): + """required=True is auto-removed when default=''.""" + + class Parser(argclass.Parser): + name: str = argclass.Argument(default="", required=True) + + parser = Parser() + parser.parse_args([]) + assert parser.name == "" + + +class TestEnvVarInteraction: + """Test environment variable and required flag interaction.""" + + def test_env_var_empty_string_counts_as_value(self, monkeypatch): + """Empty string env var should count as having a value.""" + monkeypatch.setenv("TEST_VAL", "") + + class Parser(argclass.Parser): + val: str = argclass.Argument(env_var="TEST_VAL", required=True) + + parser = Parser() + parser.parse_args([]) + assert parser.val == "" + + def test_env_var_zero_for_int(self, monkeypatch): + """Env var '0' should parse to int 0.""" + monkeypatch.setenv("TEST_COUNT", "0") + + class Parser(argclass.Parser): + count: int = argclass.Argument(env_var="TEST_COUNT", required=True) + + parser = Parser() + parser.parse_args([]) + assert parser.count == 0 + + +class TestGroupRequiredRemoval: + """Test required flag removal in argument groups.""" + + def test_group_member_required_removed_with_default(self): + """Group member required=True is removed when default provided.""" + + class ServerGroup(argclass.Group): + port: int = argclass.Argument(required=True) + + class Parser(argclass.Parser): + server: ServerGroup = ServerGroup(defaults={"port": 8080}) + + parser = Parser() + parser.parse_args([]) + assert parser.server.port == 8080 + + def test_group_member_required_removed_with_config(self, tmp_path): + """Group member required=True is removed when config provides value.""" + config = tmp_path / "config.ini" + config.write_text("[server]\nport = 9000\n") + + class ServerGroup(argclass.Group): + port: int = argclass.Argument(required=True) + + class Parser(argclass.Parser): + server: ServerGroup = ServerGroup() + + parser = Parser(config_files=[str(config)]) + parser.parse_args([]) + assert parser.server.port == 9000 + + +class TestOptionalNargsQuestionWithConst: + """Test optional arguments with nargs='?' and const (3-state behavior). + + With optional args and nargs='?', there are THREE states: + 1. Flag not present -> uses default + 2. Flag present without value -> uses const + 3. Flag present with value -> uses the value + """ + + def test_flag_not_present_uses_default(self): + """When flag is not provided, default value is used.""" + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "--output", + nargs="?", + const="stdout", + default="file.txt", + ) + + parser = Parser() + parser.parse_args([]) + assert parser.output == "file.txt" + + def test_flag_present_without_value_uses_const(self): + """When flag is present without value, const is used.""" + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "--output", + nargs="?", + const="stdout", + default="file.txt", + ) + + parser = Parser() + parser.parse_args(["--output"]) + assert parser.output == "stdout" + + def test_flag_present_with_value_uses_value(self): + """When flag is present with value, that value is used.""" + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "--output", + nargs="?", + const="stdout", + default="file.txt", + ) + + parser = Parser() + parser.parse_args(["--output", "custom.txt"]) + assert parser.output == "custom.txt" + + def test_flag_with_equals_syntax(self): + """Flag with --flag=value syntax works correctly.""" + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "--output", + nargs="?", + const="stdout", + default="file.txt", + ) + + parser = Parser() + parser.parse_args(["--output=custom.txt"]) + assert parser.output == "custom.txt" + + def test_short_flag_without_value_uses_const(self): + """Short flag without value uses const.""" + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "-o", + "--output", + nargs="?", + const="stdout", + default="file.txt", + ) + + parser = Parser() + parser.parse_args(["-o"]) + assert parser.output == "stdout" + + def test_flag_followed_by_another_flag_uses_const(self): + """Flag followed by another flag uses const for first flag.""" + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "--output", + nargs="?", + const="stdout", + default="file.txt", + ) + verbose: bool = False + + parser = Parser() + parser.parse_args(["--output", "--verbose"]) + assert parser.output == "stdout" + assert parser.verbose is True + + def test_nargs_question_with_type_converter(self): + """Type converter is applied to the value.""" + + class Parser(argclass.Parser): + count: int = argclass.Argument( + "--count", + nargs="?", + type=int, + const=10, + default=0, + ) + + parser = Parser() + parser.parse_args(["--count", "42"]) + assert parser.count == 42 + assert isinstance(parser.count, int) + + def test_nargs_question_const_used_when_flag_alone(self): + """Const value is used when flag is present without value.""" + + class Parser(argclass.Parser): + count: int = argclass.Argument( + "--count", + nargs="?", + type=int, + const=10, + default=0, + ) + + parser = Parser() + parser.parse_args(["--count"]) + assert parser.count == 10 + + +class TestOptionalNargsWithEnvVar: + """Test nargs with environment variables.""" + + def test_nargs_question_env_var_as_scalar(self, monkeypatch): + """Env var for nargs='?' should be treated as scalar value.""" + monkeypatch.setenv("TEST_OUTPUT", "from_env") + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "--output", + nargs="?", + env_var="TEST_OUTPUT", + const="stdout", + default="file.txt", + ) + + parser = Parser() + parser.parse_args([]) + assert parser.output == "from_env" + + def test_nargs_question_cli_overrides_env_var(self, monkeypatch): + """CLI value should override env var.""" + monkeypatch.setenv("TEST_OUTPUT", "from_env") + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "--output", + nargs="?", + env_var="TEST_OUTPUT", + const="stdout", + default="file.txt", + ) + + parser = Parser() + parser.parse_args(["--output", "from_cli"]) + assert parser.output == "from_cli" + + def test_nargs_star_env_var_as_list(self, monkeypatch): + """Env var for nargs='*' should be parsed as list.""" + monkeypatch.setenv("TEST_FILES", '["a.txt", "b.txt"]') + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument( + "--files", + nargs="*", + env_var="TEST_FILES", + default=[], + ) + + parser = Parser() + parser.parse_args([]) + assert parser.files == ["a.txt", "b.txt"] + + def test_nargs_plus_env_var_as_list(self, monkeypatch): + """Env var for nargs='+' should be parsed as list.""" + monkeypatch.setenv("TEST_ITEMS", '["x", "y", "z"]') + + class Parser(argclass.Parser): + items: List[str] = argclass.Argument( + "--items", + nargs="+", + env_var="TEST_ITEMS", + ) + + parser = Parser() + parser.parse_args([]) + assert parser.items == ["x", "y", "z"] + + def test_nargs_int_env_var_as_list(self, monkeypatch): + """Env var for nargs=2 should be parsed as list.""" + monkeypatch.setenv("TEST_COORDS", "[10, 20]") + + class Parser(argclass.Parser): + coords: List[int] = argclass.Argument( + "--coords", + nargs=2, + type=int, + env_var="TEST_COORDS", + ) + + parser = Parser() + parser.parse_args([]) + assert parser.coords == [10, 20] + + def test_nargs_star_env_var_empty_list(self, monkeypatch): + """Env var with empty list for nargs='*' should work.""" + monkeypatch.setenv("TEST_FILES", "[]") + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument( + "--files", + nargs="*", + env_var="TEST_FILES", + default=["default.txt"], + ) + + parser = Parser() + parser.parse_args([]) + assert parser.files == [] + + +class TestNargsOneVsNone: + """Test behavior difference between nargs=1 and nargs=None (default).""" + + def test_nargs_none_returns_scalar(self): + """Without nargs, single value is returned as scalar.""" + + class Parser(argclass.Parser): + value: str = argclass.Argument("--value") + + parser = Parser() + parser.parse_args(["--value", "test"]) + assert parser.value == "test" + assert isinstance(parser.value, str) + + def test_nargs_one_returns_list(self): + """With nargs=1, single value is returned as list.""" + + class Parser(argclass.Parser): + value: List[str] = argclass.Argument("--value", nargs=1) + + parser = Parser() + parser.parse_args(["--value", "test"]) + assert parser.value == ["test"] + assert isinstance(parser.value, list) + + def test_nargs_one_with_default_scalar(self): + """nargs=1 with scalar default - default used as-is + when not provided.""" + + class Parser(argclass.Parser): + value: List[str] = argclass.Argument( + "--value", nargs=1, default=["default"] + ) + + parser = Parser() + parser.parse_args([]) + assert parser.value == ["default"] + + +class TestNargsWithConfig: + """Test nargs with config file values.""" + + def test_nargs_question_config_scalar(self, tmp_path): + """Config value for nargs='?' should be scalar.""" + config = tmp_path / "config.ini" + config.write_text("[DEFAULT]\noutput = from_config\n") + + class Parser(argclass.Parser): + output: Optional[str] = argclass.Argument( + "--output", + nargs="?", + const="stdout", + default="file.txt", + ) + + parser = Parser(config_files=[str(config)]) + parser.parse_args([]) + assert parser.output == "from_config" + + def test_nargs_star_config_list(self, tmp_path): + """Config value for nargs='*' should be parsed as list.""" + config = tmp_path / "config.ini" + config.write_text('[DEFAULT]\nfiles = ["a.txt", "b.txt"]\n') + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument( + "--files", nargs="*", default=[] + ) + + parser = Parser(config_files=[str(config)]) + parser.parse_args([]) + assert parser.files == ["a.txt", "b.txt"] + + def test_nargs_plus_config_list(self, tmp_path): + """Config value for nargs='+' should be parsed as list.""" + config = tmp_path / "config.ini" + config.write_text('[DEFAULT]\nitems = ["x", "y"]\n') + + class Parser(argclass.Parser): + items: List[str] = argclass.Argument("--items", nargs="+") + + parser = Parser(config_files=[str(config)]) + parser.parse_args([]) + assert parser.items == ["x", "y"] + + def test_nargs_cli_overrides_config(self, tmp_path): + """CLI values should override config file values.""" + config = tmp_path / "config.ini" + config.write_text('[DEFAULT]\nfiles = ["from_config.txt"]\n') + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument( + "--files", nargs="*", default=[] + ) + + parser = Parser(config_files=[str(config)]) + parser.parse_args(["--files", "from_cli.txt"]) + assert parser.files == ["from_cli.txt"] diff --git a/tests/test_error_messages.py b/tests/test_error_messages.py new file mode 100644 index 0000000..adf9641 --- /dev/null +++ b/tests/test_error_messages.py @@ -0,0 +1,381 @@ +"""Tests for error message clarity and field name visibility. + +These tests ensure that when users make mistakes in their argclass definitions, +the error messages clearly identify WHICH field is problematic and provide +helpful hints for fixing the issue. +""" + +import json +import re +from enum import Enum, IntEnum +from typing import Union + +import pytest + +import argclass + + +class TestArgumentDefinitionErrors: + """Test that argument definition errors show field names clearly.""" + + def test_duplicate_aliases_shows_field_name(self): + """When two fields use the same alias, error shows which field.""" + with pytest.raises(argclass.ArgumentDefinitionError) as exc_info: + + class Parser(argclass.Parser): + verbose: bool = argclass.Argument( + "-v", "--verbose", default=False + ) + version: str = argclass.Argument( + "-v", "--version", default="1.0" + ) + + Parser().parse_args([]) + + error_msg = str(exc_info.value) + # Error should identify the conflicting field + assert "version" in error_msg or "verbose" in error_msg + assert "-v" in error_msg + + def test_duplicate_short_alias_shows_field_name(self): + """Duplicate short aliases should show which field caused conflict.""" + with pytest.raises(argclass.ArgumentDefinitionError) as exc_info: + + class Parser(argclass.Parser): + debug: bool = argclass.Argument("-d", default=False) + delete: bool = argclass.Argument("-d", default=False) + + Parser().parse_args([]) + + error_msg = str(exc_info.value) + assert "-d" in error_msg + # Should show field name in brackets + assert "[" in error_msg and "]" in error_msg + + def test_incompatible_nargs_and_action_shows_field(self): + """Incompatible nargs+action combo should show field name.""" + with pytest.raises(argclass.ArgumentDefinitionError) as exc_info: + + class Parser(argclass.Parser): + # store_true cannot have nargs + flag: bool = argclass.Argument( + action=argclass.Actions.STORE_TRUE, + nargs="+", + default=False, + ) + + Parser().parse_args([]) + + error_msg = str(exc_info.value) + assert "[flag]" in error_msg + assert "nargs" in error_msg.lower() + + +class TestEnumValueErrors: + """Test that enum errors show field context and valid values.""" + + def test_invalid_enum_default_string_shows_valid_values(self): + """Invalid enum string default should list valid options.""" + + class Color(Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + with pytest.raises(argclass.EnumValueError) as exc_info: + argclass.EnumArgument(Color, default="purple") + + error_msg = str(exc_info.value) + assert "purple" in error_msg + assert "RED" in error_msg + assert "GREEN" in error_msg + assert "BLUE" in error_msg + + def test_invalid_enum_default_type_shows_expected(self): + """Wrong type for enum default should show what's expected.""" + + class Priority(IntEnum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + + with pytest.raises(argclass.EnumValueError) as exc_info: + argclass.EnumArgument(Priority, default=42) # type: ignore[call-overload] + + error_msg = str(exc_info.value) + assert "int" in error_msg + assert "Priority" in error_msg + assert "LOW" in error_msg or "Valid values" in error_msg + + def test_wrong_enum_class_default_shows_mismatch(self): + """Using member from different enum should show the mismatch.""" + + class LogLevel(IntEnum): + DEBUG = 10 + INFO = 20 + + class Priority(IntEnum): + LOW = 1 + HIGH = 2 + + with pytest.raises(argclass.EnumValueError) as exc_info: + argclass.EnumArgument(LogLevel, default=Priority.LOW) + + error_msg = str(exc_info.value) + assert "LogLevel" in error_msg + assert "Priority" in error_msg or "int" in error_msg + + +class TestComplexTypeErrors: + """Test that complex type errors show the problematic type.""" + + def test_union_multiple_types_shows_typespec(self): + """Union with multiple non-None types should show the type.""" + from argclass.utils import unwrap_optional + + with pytest.raises(argclass.ComplexTypeError) as exc_info: + unwrap_optional(Union[str, int, None]) + + error_msg = str(exc_info.value) + assert "Union" in error_msg or "str" in error_msg + assert "Hint" in error_msg + + def test_union_two_types_shows_hint(self): + """Union error should hint at using Argument with converter.""" + from argclass.utils import unwrap_optional + + with pytest.raises(argclass.ComplexTypeError) as exc_info: + unwrap_optional(Union[str, int]) + + error_msg = str(exc_info.value) + assert "converter" in error_msg.lower() or "Argument" in error_msg + + +class TestTypeConversionErrors: + """Test that converter errors show field name and value.""" + + def test_converter_failure_shows_field_name(self): + """Failed converter should show which field failed.""" + + def strict_int(value): + if not value.isdigit(): + raise ValueError(f"'{value}' is not a valid integer") + return int(value) + + class Parser(argclass.Parser): + count: int = argclass.Argument(converter=strict_int) + + parser = Parser() + with pytest.raises(argclass.TypeConversionError) as exc_info: + parser.parse_args(["--count", "abc"]) + + error_msg = str(exc_info.value) + assert "[count]" in error_msg + assert "abc" in error_msg + + def test_converter_failure_shows_value(self): + """Failed converter should show the problematic value.""" + + def parse_json(value): + return json.loads(value) + + class Parser(argclass.Parser): + config: dict = argclass.Argument(converter=parse_json, default="{}") + + parser = Parser() + with pytest.raises(argclass.TypeConversionError) as exc_info: + parser.parse_args(["--config", "not valid json"]) + + error_msg = str(exc_info.value) + assert "not valid json" in error_msg + assert "config" in error_msg + + def test_converter_division_error_shows_context(self): + """Math errors in converter should show field and value.""" + + def inverse(value): + return 1 / int(value) + + class Parser(argclass.Parser): + ratio: float = argclass.Argument(converter=inverse) + + parser = Parser() + with pytest.raises(argclass.TypeConversionError) as exc_info: + parser.parse_args(["--ratio", "0"]) + + error_msg = str(exc_info.value) + assert "[ratio]" in error_msg + assert "division" in error_msg.lower() or "zero" in error_msg.lower() + + +class TestBoolFieldErrors: + """Test that bool field errors are clear about requirements.""" + + def test_bool_without_default_shows_field_name(self): + """Bool without default should show which field needs fixing.""" + with pytest.raises(TypeError, match="verbose"): + + class Parser(argclass.Parser): + verbose: bool # Missing default + + def test_bool_invalid_default_shows_value(self): + """Bool with invalid default should show the bad value.""" + with pytest.raises(TypeError, match="yes"): + + class Parser(argclass.Parser): + flag: bool = "yes" # type: ignore[assignment] + + def test_bool_error_suggests_optional(self): + """Bool error should mention Optional[bool] as alternative.""" + with pytest.raises(TypeError) as exc_info: + + class Parser(argclass.Parser): + enabled: bool + + error_msg = str(exc_info.value) + assert "Optional[bool]" in error_msg or "tri-state" in error_msg + + +class TestConfigurationErrors: + """Test that config file errors show field and file context.""" + + def test_config_wrong_type_for_list_shows_field(self, tmp_path): + """Config string for list field should show field name.""" + config = tmp_path / "config.ini" + config.write_text("[DEFAULT]\nitems = not_a_list\n") + + class Parser(argclass.Parser): + items: list[str] = [] + + with pytest.raises(argclass.UnexpectedConfigValue) as exc_info: + parser = Parser(config_files=[config]) + parser.parse_args([]) + + error_msg = str(exc_info.value) + assert "items" in error_msg + assert "SEQUENCE" in error_msg + + def test_config_wrong_type_shows_expected_and_actual(self, tmp_path): + """Config type error should show expected vs actual type.""" + config = tmp_path / "config.json" + config.write_text('{"count": "not_an_int"}') + + class Parser(argclass.Parser): + count: list[int] = [] + + with pytest.raises(argclass.UnexpectedConfigValue) as exc_info: + parser = Parser( + config_files=[config], + config_parser_class=argclass.JSONDefaultsParser, + ) + parser.parse_args([]) + + error_msg = str(exc_info.value) + assert "count" in error_msg + assert "SEQUENCE" in error_msg + assert "str" in error_msg + + +class TestErrorMessageFormat: + """Test that error messages follow consistent format.""" + + def test_field_name_in_brackets(self): + """Field names should appear in [brackets] for easy scanning.""" + + def bad_converter(x): + raise ValueError("always fails") + + class Parser(argclass.Parser): + value: str = argclass.Argument(converter=bad_converter) + + parser = Parser() + with pytest.raises(argclass.TypeConversionError) as exc_info: + parser.parse_args(["--value", "test"]) + + error_msg = str(exc_info.value) + # Field name should be in brackets + assert re.search(r"\[value\]", error_msg) + + def test_hint_provides_actionable_guidance(self): + """Hints should tell user what to do, not just what went wrong.""" + + def bad_converter(x): + raise ValueError("cannot convert") + + class Parser(argclass.Parser): + data: str = argclass.Argument(converter=bad_converter) + + parser = Parser() + with pytest.raises(argclass.TypeConversionError) as exc_info: + parser.parse_args(["--data", "x"]) + + error_msg = str(exc_info.value) + assert "Hint" in error_msg + + +class TestMultipleFieldErrors: + """Test behavior when multiple fields could have errors.""" + + def test_first_error_identifies_specific_field(self): + """With multiple potential errors, identify the specific one.""" + + class Color(Enum): + RED = 1 + BLUE = 2 + + class Size(Enum): + SMALL = 1 + LARGE = 2 + + # Only one has invalid default + with pytest.raises(argclass.EnumValueError) as exc_info: + argclass.EnumArgument(Color, default="INVALID") + + error_msg = str(exc_info.value) + assert "Color" in error_msg + assert "INVALID" in error_msg + + +class TestInheritanceErrors: + """Test error handling with class inheritance.""" + + def test_child_class_error_doesnt_blame_parent(self): + """Errors in child class should identify child field.""" + + class BaseParser(argclass.Parser): + debug: bool = False + + with pytest.raises(argclass.ArgumentDefinitionError) as exc_info: + + class ChildParser(BaseParser): + # Conflicts with inherited debug's auto-generated --debug + other: str = argclass.Argument("--debug", default="") + + ChildParser().parse_args([]) + + error_msg = str(exc_info.value) + assert "--debug" in error_msg + + +class TestGroupErrors: + """Test error handling within argument groups.""" + + def test_group_field_error_shows_full_context(self): + """Errors in group fields should show group context.""" + + def bad_converter(x): + raise ValueError("group converter failed") + + class ServerGroup(argclass.Group): + port: int = argclass.Argument(converter=bad_converter) + + class Parser(argclass.Parser): + server: ServerGroup = ServerGroup() + + parser = Parser() + with pytest.raises(argclass.TypeConversionError) as exc_info: + parser.parse_args(["--server-port", "8080"]) + + error_msg = str(exc_info.value) + # Should show the prefixed name + assert "server_port" in error_msg or "port" in error_msg diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..d1fc125 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,407 @@ +"""Synthetic tests for exception formatting to achieve full coverage.""" + +from argclass.exceptions import ( + ArgclassError, + ArgumentDefinitionError, + TypeConversionError, + ConfigurationError, + EnumValueError, + ComplexTypeError, +) + + +class TestArgclassErrorFormatting: + """Test ArgclassError message formatting branches.""" + + def test_message_only(self): + """Message without optional fields.""" + exc = ArgclassError("something went wrong") + assert str(exc) == "something went wrong" + + def test_message_with_field_name(self): + """Message with field_name adds brackets.""" + exc = ArgclassError("invalid value", field_name="count") + assert str(exc) == "[count] invalid value" + + def test_message_with_hint(self): + """Message with hint adds Hint: prefix.""" + exc = ArgclassError("invalid value", hint="try a number") + assert str(exc) == "invalid value Hint: try a number" + + def test_message_with_all_fields(self): + """Message with all optional fields.""" + exc = ArgclassError( + "invalid value", + field_name="count", + hint="try a number", + ) + assert str(exc) == "[count] invalid value Hint: try a number" + + def test_attributes_accessible(self): + """Verify attributes are stored correctly.""" + exc = ArgclassError("msg", field_name="f", hint="h") + assert exc.message == "msg" + assert exc.field_name == "f" + assert exc.hint == "h" + + +class TestArgumentDefinitionErrorFormatting: + """Test ArgumentDefinitionError message formatting branches.""" + + def test_message_only(self): + """Message without optional fields.""" + exc = ArgumentDefinitionError("conflicting option") + assert str(exc) == "conflicting option" + + def test_message_with_field_name(self): + """Message with field_name.""" + exc = ArgumentDefinitionError( + "conflicting option", field_name="verbose" + ) + assert str(exc) == "[verbose] conflicting option" + + def test_message_with_aliases(self): + """Message with aliases tuple.""" + exc = ArgumentDefinitionError( + "conflicting option", + aliases=("-v", "--verbose"), + ) + assert str(exc) == "conflicting option (aliases: -v, --verbose)" + + def test_message_with_hint(self): + """Message with hint.""" + exc = ArgumentDefinitionError( + "conflicting option", + hint="remove the duplicate", + ) + assert str(exc) == "conflicting option Hint: remove the duplicate" + + def test_message_with_all_fields(self): + """Message with all optional fields.""" + exc = ArgumentDefinitionError( + "conflicting option", + field_name="verbose", + aliases=("-v", "--verbose"), + kwargs={"action": "store_true"}, + hint="remove the duplicate", + ) + assert "[verbose]" in str(exc) + assert "conflicting option" in str(exc) + assert "(aliases: -v, --verbose)" in str(exc) + assert "Hint: remove the duplicate" in str(exc) + + def test_attributes_accessible(self): + """Verify attributes are stored correctly.""" + exc = ArgumentDefinitionError( + "msg", + field_name="f", + aliases=("-a",), + kwargs={"k": "v"}, + hint="h", + ) + assert exc.message == "msg" + assert exc.field_name == "f" + assert exc.aliases == ("-a",) + assert exc.kwargs == {"k": "v"} + assert exc.hint == "h" + + +class TestTypeConversionErrorFormatting: + """Test TypeConversionError message formatting branches.""" + + def test_message_only(self): + """Message without optional fields.""" + exc = TypeConversionError("conversion failed") + assert str(exc) == "conversion failed" + + def test_message_with_field_name(self): + """Message with field_name.""" + exc = TypeConversionError("conversion failed", field_name="port") + assert str(exc) == "[port] conversion failed" + + def test_message_with_value(self): + """Message with value shows repr.""" + exc = TypeConversionError("conversion failed", value="abc") + assert str(exc) == "conversion failed (got 'abc')" + + def test_message_with_value_none_not_shown(self): + """Value=None is not shown in message.""" + exc = TypeConversionError("conversion failed", value=None) + assert "(got" not in str(exc) + + def test_message_with_target_type_has_name(self): + """Target type with __name__ attribute.""" + exc = TypeConversionError("conversion failed", target_type=int) + assert str(exc) == "conversion failed expected type: int" + + def test_message_with_target_type_no_name(self): + """Target type without __name__ uses str().""" + + # Use an object instance that doesn't have __name__ + class CustomType: + def __str__(self): + return "CustomTypeStr" + + target = CustomType() # Instance doesn't have __name__ + exc = TypeConversionError( + "conversion failed", + target_type=target, # type: ignore[arg-type] + ) + assert "expected type: CustomTypeStr" in str(exc) + + def test_message_with_hint(self): + """Message with hint.""" + exc = TypeConversionError("conversion failed", hint="use integer") + assert str(exc) == "conversion failed Hint: use integer" + + def test_message_with_all_fields(self): + """Message with all optional fields.""" + exc = TypeConversionError( + "conversion failed", + field_name="port", + value="abc", + target_type=int, + hint="use integer", + ) + assert "[port]" in str(exc) + assert "conversion failed" in str(exc) + assert "(got 'abc')" in str(exc) + assert "expected type: int" in str(exc) + assert "Hint: use integer" in str(exc) + + def test_attributes_accessible(self): + """Verify attributes are stored correctly.""" + exc = TypeConversionError( + "msg", + field_name="f", + value="v", + target_type=str, + hint="h", + ) + assert exc.message == "msg" + assert exc.field_name == "f" + assert exc.value == "v" + assert exc.target_type is str + assert exc.hint == "h" + + +class TestConfigurationErrorFormatting: + """Test ConfigurationError message formatting branches.""" + + def test_message_only(self): + """Message without optional fields.""" + exc = ConfigurationError("parse error") + assert str(exc) == "parse error" + + def test_message_with_file_path(self): + """Message with file_path only.""" + exc = ConfigurationError("parse error", file_path="/etc/config.ini") + assert str(exc) == "(/etc/config.ini) parse error" + + def test_message_with_file_path_and_section(self): + """Message with file_path and section.""" + exc = ConfigurationError( + "parse error", + file_path="/etc/config.ini", + section="database", + ) + assert str(exc) == "(/etc/config.ini:[database]) parse error" + + def test_message_with_field_name(self): + """Message with field_name.""" + exc = ConfigurationError("parse error", field_name="port") + assert str(exc) == "[port] parse error" + + def test_message_with_hint(self): + """Message with hint.""" + exc = ConfigurationError("parse error", hint="check syntax") + assert str(exc) == "parse error Hint: check syntax" + + def test_message_with_all_fields(self): + """Message with all optional fields.""" + exc = ConfigurationError( + "invalid value", + file_path="/etc/config.ini", + section="database", + field_name="port", + hint="use integer", + ) + assert "(/etc/config.ini:[database])" in str(exc) + assert "[port]" in str(exc) + assert "invalid value" in str(exc) + assert "Hint: use integer" in str(exc) + + def test_attributes_accessible(self): + """Verify attributes are stored correctly.""" + exc = ConfigurationError( + "msg", + file_path="f.ini", + section="s", + field_name="f", + hint="h", + ) + assert exc.message == "msg" + assert exc.file_path == "f.ini" + assert exc.section == "s" + assert exc.field_name == "f" + assert exc.hint == "h" + + +class TestEnumValueErrorFormatting: + """Test EnumValueError message formatting branches.""" + + def test_message_only(self): + """Message without optional fields.""" + exc = EnumValueError("invalid enum") + assert str(exc) == "invalid enum" + + def test_message_with_field_name(self): + """Message with field_name.""" + exc = EnumValueError("invalid enum", field_name="color") + assert str(exc) == "[color] invalid enum" + + def test_message_with_valid_values(self): + """Message with valid_values tuple.""" + exc = EnumValueError( + "invalid enum", + valid_values=("RED", "GREEN", "BLUE"), + ) + assert str(exc) == "invalid enum Valid values: RED, GREEN, BLUE" + + def test_message_with_hint(self): + """Message with hint.""" + exc = EnumValueError("invalid enum", hint="use uppercase") + assert str(exc) == "invalid enum Hint: use uppercase" + + def test_message_with_all_fields(self): + """Message with all optional fields.""" + from enum import Enum + + class Color(Enum): + RED = 1 + + exc = EnumValueError( + "invalid enum", + field_name="color", + enum_class=Color, + valid_values=("RED", "GREEN"), + hint="use uppercase", + ) + assert "[color]" in str(exc) + assert "invalid enum" in str(exc) + assert "Valid values: RED, GREEN" in str(exc) + assert "Hint: use uppercase" in str(exc) + + def test_attributes_accessible(self): + """Verify attributes are stored correctly.""" + from enum import Enum + + class Color(Enum): + RED = 1 + + exc = EnumValueError( + "msg", + field_name="f", + enum_class=Color, + valid_values=("R",), + hint="h", + ) + assert exc.message == "msg" + assert exc.field_name == "f" + assert exc.enum_class == Color + assert exc.valid_values == ("R",) + assert exc.hint == "h" + + +class TestComplexTypeErrorFormatting: + """Test ComplexTypeError message formatting branches.""" + + def test_message_only(self): + """Message without optional fields.""" + exc = ComplexTypeError("unsupported type") + assert str(exc) == "unsupported type" + + def test_message_with_field_name(self): + """Message with field_name.""" + exc = ComplexTypeError("unsupported type", field_name="value") + assert str(exc) == "[value] unsupported type" + + def test_message_with_typespec(self): + """Message with typespec shows repr.""" + exc = ComplexTypeError("unsupported type", typespec="str | int") + assert str(exc) == "unsupported type (type: 'str | int')" + + def test_message_with_typespec_none_not_shown(self): + """typespec=None is not shown in message.""" + exc = ComplexTypeError("unsupported type", typespec=None) + assert "(type:" not in str(exc) + + def test_message_with_hint(self): + """Message with hint.""" + exc = ComplexTypeError("unsupported type", hint="use converter") + assert str(exc) == "unsupported type Hint: use converter" + + def test_message_with_all_fields(self): + """Message with all optional fields.""" + exc = ComplexTypeError( + "unsupported type", + field_name="value", + typespec="str | int", + hint="use converter", + ) + assert "[value]" in str(exc) + assert "unsupported type" in str(exc) + assert "(type: 'str | int')" in str(exc) + assert "Hint: use converter" in str(exc) + + def test_attributes_accessible(self): + """Verify attributes are stored correctly.""" + exc = ComplexTypeError( + "msg", + field_name="f", + typespec="t", + hint="h", + ) + assert exc.message == "msg" + assert exc.field_name == "f" + assert exc.typespec == "t" + assert exc.hint == "h" + + +class TestExceptionInheritance: + """Test that all exceptions inherit from ArgclassError.""" + + def test_argument_definition_error_is_argclass_error(self): + exc = ArgumentDefinitionError("test") + assert isinstance(exc, ArgclassError) + + def test_type_conversion_error_is_argclass_error(self): + exc = TypeConversionError("test") + assert isinstance(exc, ArgclassError) + + def test_configuration_error_is_argclass_error(self): + exc = ConfigurationError("test") + assert isinstance(exc, ArgclassError) + + def test_enum_value_error_is_argclass_error(self): + exc = EnumValueError("test") + assert isinstance(exc, ArgclassError) + + def test_complex_type_error_is_argclass_error(self): + exc = ComplexTypeError("test") + assert isinstance(exc, ArgclassError) + + def test_can_catch_all_with_argclass_error(self): + """All exception types can be caught with ArgclassError.""" + exceptions = [ + ArgumentDefinitionError("test"), + TypeConversionError("test"), + ConfigurationError("test"), + EnumValueError("test"), + ComplexTypeError("test"), + ] + for exc in exceptions: + try: + raise exc + except ArgclassError: + pass # Should be caught diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..e93791f --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,1716 @@ +""" +Integration tests simulating a complex DevOps CLI tool ("infractl"). + +This module exercises all argclass features together: +- Subparsers (single and nested levels) +- Reusable groups +- All argument types (str, int, float, bool, Path, enums, lists, etc.) +- Configuration priority chain (default < config < env < CLI) +- Nested commands +- Secret arguments +- Nargs variations +- Custom type converters +- Config file formats (INI, JSON) +- Environment variables +- Error handling +""" + +import json +import logging +from enum import IntEnum +from pathlib import Path +from typing import Any, List, Optional +from unittest.mock import patch + +import pytest + +import argclass + + +# ============================================================================== +# Reusable Groups (4 groups) +# ============================================================================== + + +class ConnectionGroup(argclass.Group): + """Reusable connection settings.""" + + host: str = "localhost" + port: int = 8080 + timeout: int = 30 + ssl: bool = False + + +class LoggingGroup(argclass.Group): + """Reusable logging settings.""" + + level: int = argclass.LogLevel + file: Optional[Path] = None + format: str = argclass.Argument( + choices=["json", "text", "compact"], + default="text", + ) + + +class OutputGroup(argclass.Group): + """Reusable output formatting.""" + + format: str = argclass.Argument( + choices=["json", "yaml", "table", "plain"], + default="table", + ) + verbose: bool = False + quiet: bool = False + + +class RetryGroup(argclass.Group): + """Reusable retry settings.""" + + max_retries: int = 3 + delay: float = 1.0 + backoff: float = 2.0 + + +# ============================================================================== +# Nested Subcommands for ServerCommand +# ============================================================================== + + +class ServerStartCommand(argclass.Parser): + """Start a server.""" + + daemon: bool = False + workers: int = 4 + bind: str = "0.0.0.0" + + +class ServerStopCommand(argclass.Parser): + """Stop a server.""" + + force: bool = False + timeout: int = 10 + + +class ServerStatusCommand(argclass.Parser): + """Check server status.""" + + detailed: bool = False + + +class ServerRestartCommand(argclass.Parser): + """Restart a server.""" + + graceful: bool = True + delay: int = 5 + + +# ============================================================================== +# Nested Subcommands for DatabaseCommand +# ============================================================================== + + +class DatabaseBackupCommand(argclass.Parser): + """Backup database.""" + + output: Optional[Path] = None + compress: bool = True + tables: Optional[List[str]] = argclass.Argument(nargs="*") + + +class DatabaseRestoreCommand(argclass.Parser): + """Restore database.""" + + input_file: Optional[Path] = None + drop_existing: bool = False + + +class DatabaseMigrateCommand(argclass.Parser): + """Run database migrations.""" + + target: Optional[str] = None + dry_run: bool = False + + +# ============================================================================== +# Nested Subcommands for UserCommand +# ============================================================================== + + +class UserCreateCommand(argclass.Parser): + """Create a user.""" + + username: str + email: str + admin: bool = False + groups: Optional[List[str]] = argclass.Argument(nargs="*") + + +class UserDeleteCommand(argclass.Parser): + """Delete a user.""" + + username: str + force: bool = False + + +class UserListCommand(argclass.Parser): + """List users.""" + + filter: Optional[str] = None + limit: int = 100 + + +# ============================================================================== +# Nested Subcommands for ConfigCommand +# ============================================================================== + + +class ConfigShowCommand(argclass.Parser): + """Show configuration.""" + + section: Optional[str] = None + include_defaults: bool = True + + +class ConfigSetCommand(argclass.Parser): + """Set configuration value.""" + + key: str + value: str + + +class ConfigGetCommand(argclass.Parser): + """Get configuration value.""" + + key: str + + +# ============================================================================== +# Top-Level Subparsers (5 commands) +# ============================================================================== + + +class ServerCommand(argclass.Parser): + """Server management.""" + + connection: ConnectionGroup = ConnectionGroup(title="Connection settings") + logging: LoggingGroup = LoggingGroup(title="Logging settings") + + start: Optional[ServerStartCommand] = ServerStartCommand() + stop: Optional[ServerStopCommand] = ServerStopCommand() + status: Optional[ServerStatusCommand] = ServerStatusCommand() + restart: Optional[ServerRestartCommand] = ServerRestartCommand() + + +class DatabaseCommand(argclass.Parser): + """Database operations.""" + + connection: ConnectionGroup = ConnectionGroup( + title="Connection settings", + defaults={"host": "db.localhost", "port": 5432}, + ) + retry: RetryGroup = RetryGroup(title="Retry settings") + + backup: Optional[DatabaseBackupCommand] = DatabaseBackupCommand() + restore: Optional[DatabaseRestoreCommand] = DatabaseRestoreCommand() + migrate: Optional[DatabaseMigrateCommand] = DatabaseMigrateCommand() + + +class UserCommand(argclass.Parser): + """User management.""" + + output: OutputGroup = OutputGroup(title="Output settings") + + create: Optional[UserCreateCommand] = UserCreateCommand() + delete: Optional[UserDeleteCommand] = UserDeleteCommand() + list: Optional[UserListCommand] = UserListCommand() + + +class ConfigCommand(argclass.Parser): + """Configuration management.""" + + output: OutputGroup = OutputGroup(title="Output settings") + + show: Optional[ConfigShowCommand] = ConfigShowCommand() + set: Optional[ConfigSetCommand] = ConfigSetCommand() + get: Optional[ConfigGetCommand] = ConfigGetCommand() + + +class DeployEnvironment(IntEnum): + """Deployment environment.""" + + DEV = 1 + STAGING = 2 + PROD = 3 + + +class DeployCommand(argclass.Parser): + """Deployment operations.""" + + connection: ConnectionGroup = ConnectionGroup(title="Connection settings") + logging: LoggingGroup = LoggingGroup(title="Logging settings") + retry: RetryGroup = RetryGroup(title="Retry settings") + + api_key: str = argclass.Secret(env_var="INFRACTL_API_KEY") + targets: List[str] = argclass.Argument( + "--targets", + nargs="+", + metavar="TARGET", + ) + environment: DeployEnvironment = argclass.EnumArgument( + DeployEnvironment, + default=DeployEnvironment.DEV, + ) + dry_run: bool = False + parallel: int = 4 + + +# ============================================================================== +# Main Parser +# ============================================================================== + + +class InfractlParser(argclass.Parser): + """Infrastructure control CLI.""" + + # Global options + debug: bool = False + config_file = argclass.Config(config_class=argclass.INIConfig) + + # Subcommands + server: Optional[ServerCommand] = ServerCommand() + database: Optional[DatabaseCommand] = DatabaseCommand() + user: Optional[UserCommand] = UserCommand() + config: Optional[ConfigCommand] = ConfigCommand() + deploy: Optional[DeployCommand] = DeployCommand() + + +# ============================================================================== +# Fixtures +# ============================================================================== + + +@pytest.fixture +def ini_config(tmp_path: Path) -> Path: + """Create test INI config file.""" + config = tmp_path / "infractl.ini" + config.write_text( + """[DEFAULT] +debug = true + +[server] +host = config-server.example.com +port = 9000 +ssl = yes + +[database] +host = db.example.com +port = 5432 + +[logging] +level = debug +format = json +""" + ) + return config + + +@pytest.fixture +def json_config(tmp_path: Path) -> Path: + """Create test JSON config file.""" + config = tmp_path / "infractl.json" + config.write_text( + json.dumps( + { + "debug": True, + "server": {"host": "json-server.example.com", "port": 8888}, + "database": {"host": "json-db.example.com"}, + } + ) + ) + return config + + +# ============================================================================== +# Test Categories +# ============================================================================== + + +class TestParserConstruction: + """Test parser instantiation and structure.""" + + def test_parser_instantiation_no_args(self): + """Test parser can be instantiated without args.""" + parser = InfractlParser() + assert parser is not None + + def test_parser_repr(self): + """Test parser repr shows correct counts.""" + parser = InfractlParser() + r = repr(parser) + assert "Parser" in r + assert "arguments" in r + assert "subparsers" in r + + def test_help_text_generation(self, capsys: pytest.CaptureFixture[str]): + """Test help text is generated correctly.""" + parser = InfractlParser() + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + captured = capsys.readouterr() + assert "server" in captured.out + assert "database" in captured.out + assert "user" in captured.out + assert "config" in captured.out + assert "deploy" in captured.out + + def test_all_subparsers_accessible(self): + """Test all subparsers are defined.""" + parser = InfractlParser() + parser.parse_args(["server", "start"]) + assert parser.server is not None + assert parser.server.start is not None # type: ignore[union-attr] + + def test_nested_subparser_help(self, capsys: pytest.CaptureFixture[str]): + """Test nested subparser help text.""" + parser = ServerCommand() + with pytest.raises(SystemExit): + parser.parse_args(["start", "--help"]) + captured = capsys.readouterr() + assert "--daemon" in captured.out + assert "--workers" in captured.out + + +class TestReusableGroups: + """Test reusable argument groups.""" + + def test_group_defaults_applied(self): + """Test group default values are applied.""" + parser = ServerCommand() + parser.parse_args(["start"]) + assert parser.connection.host == "localhost" + assert parser.connection.port == 8080 + assert parser.connection.timeout == 30 + assert parser.connection.ssl is False + + def test_group_prefix_in_option_names( + self, capsys: pytest.CaptureFixture[str] + ): + """Test group prefix appears in option names.""" + parser = ServerCommand() + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + captured = capsys.readouterr() + assert "--connection-host" in captured.out + assert "--connection-port" in captured.out + + def test_group_values_from_cli(self): + """Test group values can be set from CLI.""" + parser = ServerCommand() + parser.parse_args( + [ + "--connection-host=example.com", + "--connection-port=9000", + "--connection-ssl", + "start", + ] + ) + assert parser.connection.host == "example.com" + assert parser.connection.port == 9000 + assert parser.connection.ssl is True + + def test_same_group_class_reused(self): + """Test same group class can be used in different commands.""" + server = ServerCommand() + database = DatabaseCommand() + + server.parse_args(["--connection-host=server.com", "start"]) + database.parse_args(["--connection-host=db.com", "backup"]) + + assert server.connection.host == "server.com" + assert database.connection.host == "db.com" + + def test_group_with_custom_defaults(self): + """Test group with custom defaults parameter.""" + parser = DatabaseCommand() + parser.parse_args(["backup"]) + assert parser.connection.host == "db.localhost" + assert parser.connection.port == 5432 + + def test_logging_group_with_log_level(self): + """Test LoggingGroup with LogLevel argument.""" + parser = ServerCommand() + parser.parse_args(["--logging-level=debug", "start"]) + assert parser.logging.level == logging.DEBUG + + def test_logging_group_format_choices(self): + """Test LoggingGroup format choices.""" + parser = ServerCommand() + parser.parse_args(["--logging-format=json", "start"]) + assert parser.logging.format == "json" + + def test_output_group_multiple_flags(self): + """Test OutputGroup with multiple flags.""" + parser = UserCommand() + parser.parse_args(["--output-format=yaml", "--output-verbose", "list"]) + assert parser.output.format == "yaml" + assert parser.output.verbose is True + assert parser.output.quiet is False + + def test_retry_group_float_values(self): + """Test RetryGroup with float values.""" + parser = DatabaseCommand() + parser.parse_args( + [ + "--retry-max-retries=5", + "--retry-delay=2.5", + "--retry-backoff=1.5", + "backup", + ] + ) + assert parser.retry.max_retries == 5 + assert parser.retry.delay == 2.5 + assert parser.retry.backoff == 1.5 + + def test_multiple_groups_in_one_parser(self): + """Test multiple groups in one parser.""" + parser = DeployCommand() + parser.parse_args( + [ + "--connection-host=deploy.example.com", + "--logging-level=warning", + "--retry-max-retries=10", + "--api-key=secret", + "--targets=target1", + ] + ) + assert parser.connection.host == "deploy.example.com" + assert parser.logging.level == logging.WARNING + assert parser.retry.max_retries == 10 + + +class TestSubparsers: + """Test subparser functionality.""" + + def test_nested_subparsers(self): + """Test nested subparsers (e.g., server start).""" + parser = InfractlParser() + parser.parse_args(["server", "start", "--daemon", "--workers=8"]) + assert parser.server.start.daemon is True # type: ignore[union-attr] + assert parser.server.start.workers == 8 # type: ignore[union-attr] + + def test_current_subparser_property(self): + """Test current_subparser returns deepest selected subparser.""" + parser = InfractlParser() + parser.parse_args(["server", "start"]) + # current_subparser returns the deepest selected subparser + assert parser.current_subparser is parser.server.start # type: ignore[union-attr] + + # Check nested current_subparser + assert parser.server.current_subparser is parser.server.start # type: ignore[union-attr] + + def test_current_subparser_none_when_not_used(self): + """Test current_subparser is None when no subparser used.""" + parser = InfractlParser() + parser.parse_args(["--debug"]) + assert parser.current_subparser is None + + def test_subparser_specific_arguments(self): + """Test subparser-specific arguments.""" + parser = InfractlParser() + parser.parse_args( + [ + "database", + "backup", + "--output=/tmp/backup.sql", + "--tables", + "users", + "orders", + ] + ) + db = parser.database + assert db.backup.output == Path("/tmp/backup.sql") # type: ignore[union-attr] + assert db.backup.compress is True # type: ignore[union-attr] + assert db.backup.tables == ["users", "orders"] # type: ignore[union-attr] + + def test_unselected_subparser_raises_attribute_error(self): + """Test accessing unselected subparser's attrs raises error.""" + parser = InfractlParser() + parser.parse_args(["server", "start"]) + # Accessing nested subparser attribute that wasn't selected + with pytest.raises(AttributeError): + _ = parser.server.stop.force # type: ignore[union-attr] + + def test_all_server_nested_commands(self): + """Test all nested server commands work.""" + parser = ServerCommand() + + parser.parse_args(["start", "--daemon"]) + assert parser.start.daemon is True # type: ignore[union-attr] + + parser = ServerCommand() + parser.parse_args(["stop", "--force"]) + assert parser.stop.force is True # type: ignore[union-attr] + + parser = ServerCommand() + parser.parse_args(["status", "--detailed"]) + assert parser.status.detailed is True # type: ignore[union-attr] + + parser = ServerCommand() + parser.parse_args(["restart", "--graceful", "--delay=10"]) + # --graceful toggles default True + assert parser.restart.graceful is False # type: ignore[union-attr] + assert parser.restart.delay == 10 # type: ignore[union-attr] + + def test_all_database_nested_commands(self): + """Test all nested database commands work.""" + parser = DatabaseCommand() + parser.parse_args(["backup", "--compress"]) + # --compress toggles default True + assert parser.backup.compress is False # type: ignore[union-attr] + + parser = DatabaseCommand() + parser.parse_args( + ["restore", "--input-file=/tmp/backup.sql", "--drop-existing"] + ) + assert parser.restore.input_file == Path("/tmp/backup.sql") # type: ignore[union-attr] + assert parser.restore.drop_existing is True # type: ignore[union-attr] + + parser = DatabaseCommand() + parser.parse_args(["migrate", "--target=v2.0", "--dry-run"]) + assert parser.migrate.target == "v2.0" # type: ignore[union-attr] + assert parser.migrate.dry_run is True # type: ignore[union-attr] + + def test_all_user_nested_commands(self): + """Test all nested user commands work.""" + parser = UserCommand() + parser.parse_args( + [ + "create", + "--username=john", + "--email=john@example.com", + "--admin", + "--groups", + "developers", + "admins", + ] + ) + assert parser.create.username == "john" # type: ignore[union-attr] + assert parser.create.email == "john@example.com" # type: ignore[union-attr] + assert parser.create.admin is True # type: ignore[union-attr] + assert parser.create.groups == ["developers", "admins"] # type: ignore[union-attr] + + parser = UserCommand() + parser.parse_args(["delete", "--username=john", "--force"]) + assert parser.delete.username == "john" # type: ignore[union-attr] + assert parser.delete.force is True # type: ignore[union-attr] + + parser = UserCommand() + parser.parse_args(["list", "--filter=admin", "--limit=50"]) + assert parser.list.filter == "admin" # type: ignore[union-attr] + assert parser.list.limit == 50 # type: ignore[union-attr] + + def test_all_config_nested_commands(self): + """Test all nested config commands work.""" + parser = ConfigCommand() + parser.parse_args(["show", "--section=database"]) + assert parser.show.section == "database" # type: ignore[union-attr] + + parser = ConfigCommand() + parser.parse_args(["set", "--key=debug", "--value=true"]) + assert parser.set.key == "debug" # type: ignore[union-attr] + assert parser.set.value == "true" # type: ignore[union-attr] + + parser = ConfigCommand() + parser.parse_args(["get", "--key=timeout"]) + assert parser.get.key == "timeout" # type: ignore[union-attr] + + def test_deep_nesting_chain(self): + """Test parser chain with deep nesting.""" + parser = InfractlParser() + parser.parse_args(["server", "start"]) + + # Get chain from deepest parser + chain = list(parser.server.start._get_chain()) # type: ignore[union-attr] + assert len(chain) == 3 + assert chain[0] is parser.server.start # type: ignore[union-attr] + assert chain[1] is parser.server + assert chain[2] is parser + + +class TestConfigurationPriority: + """Test configuration priority: Default < Config < Env < CLI.""" + + def test_default_value_used(self): + """Test default value is used when nothing else specified.""" + parser = ServerCommand() + parser.parse_args(["start"]) + assert parser.connection.host == "localhost" + + def test_config_overrides_default(self, ini_config: Path): + """Test config file overrides default value.""" + + class Parser(argclass.Parser): + host: str = "localhost" + + parser = Parser(config_files=[ini_config]) + parser.parse_args([]) + # INI has [server] section but host is in DEFAULT section + # When not in a specific section, the config won't apply + # Let's test with a specific config + + config_file = ini_config.parent / "test.ini" + config_file.write_text("[DEFAULT]\nhost = config.example.com\n") + + parser = Parser(config_files=[config_file]) + parser.parse_args([]) + assert parser.host == "config.example.com" + + def test_env_overrides_config( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test env var overrides config file.""" + config_file = tmp_path / "config.ini" + config_file.write_text("[DEFAULT]\nhost = config.example.com\n") + + monkeypatch.setenv("TEST_HOST", "env.example.com") + + class Parser(argclass.Parser): + host: str = "localhost" + + parser = Parser(config_files=[config_file], auto_env_var_prefix="TEST_") + parser.parse_args([]) + assert parser.host == "env.example.com" + + def test_cli_overrides_env( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test CLI argument overrides env var.""" + config_file = tmp_path / "config.ini" + config_file.write_text("[DEFAULT]\nhost = config.example.com\n") + + monkeypatch.setenv("TEST_HOST", "env.example.com") + + class Parser(argclass.Parser): + host: str = "localhost" + + parser = Parser(config_files=[config_file], auto_env_var_prefix="TEST_") + parser.parse_args(["--host=cli.example.com"]) + assert parser.host == "cli.example.com" + + def test_full_priority_chain( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test full priority chain: Default < Config < Env < CLI.""" + # Create config file + config_file = tmp_path / "config.ini" + config_file.write_text( + "[DEFAULT]\n" + "default_only = config\n" + "config_only = config\n" + "env_only = config\n" + "cli_only = config\n" + ) + + # Set env vars + monkeypatch.setenv("TEST_ENV_ONLY", "env") + monkeypatch.setenv("TEST_CLI_ONLY", "env") + + class Parser(argclass.Parser): + default_only: str = "default" + config_only: str = "default" + env_only: str = "default" + cli_only: str = "default" + + parser = Parser(config_files=[config_file], auto_env_var_prefix="TEST_") + parser.parse_args(["--cli-only=cli"]) + + assert parser.default_only == "config" # config overrides default + assert parser.config_only == "config" # config is highest + assert parser.env_only == "env" # env overrides config + assert parser.cli_only == "cli" # CLI overrides env + + def test_priority_with_groups( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test priority chain with groups.""" + config_file = tmp_path / "config.ini" + config_file.write_text("[connection]\nhost = config.example.com\n") + + monkeypatch.setenv("TEST_CONNECTION_HOST", "env.example.com") + + class Parser(argclass.Parser): + connection: ConnectionGroup = ConnectionGroup() + + parser = Parser(config_files=[config_file], auto_env_var_prefix="TEST_") + parser.parse_args(["--connection-host=cli.example.com"]) + assert parser.connection.host == "cli.example.com" + + def test_partial_override_in_priority( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test partial overrides in priority chain.""" + config_file = tmp_path / "config.ini" + config_file.write_text( + "[DEFAULT]\nhost = config.example.com\nport = 9000\n" + ) + + # Only override host in env + monkeypatch.setenv("TEST_HOST", "env.example.com") + + class Parser(argclass.Parser): + host: str = "localhost" + port: int = 8080 + + parser = Parser(config_files=[config_file], auto_env_var_prefix="TEST_") + parser.parse_args([]) + + # host from env, port from config + assert parser.host == "env.example.com" + assert parser.port == 9000 + + def test_bool_priority_chain( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test bool type through priority chain.""" + config_file = tmp_path / "config.ini" + config_file.write_text("[DEFAULT]\ndebug = true\n") + + class Parser(argclass.Parser): + debug: bool = False + + # Config should override default + parser = Parser(config_files=[config_file]) + parser.parse_args([]) + assert parser.debug is True + + # Env should override config + monkeypatch.setenv("TEST_DEBUG", "false") + parser = Parser(config_files=[config_file], auto_env_var_prefix="TEST_") + parser.parse_args([]) + assert parser.debug is False + + +class TestSecretArguments: + """Test secret argument handling.""" + + def test_secret_masking_in_repr(self, monkeypatch: pytest.MonkeyPatch): + """Test secret values are masked in repr.""" + monkeypatch.setenv("INFRACTL_API_KEY", "super-secret-key") + + parser = DeployCommand() + parser.parse_args(["--targets=target1"]) + + # repr should show masked value + assert repr(parser.api_key) == repr(argclass.SecretString.PLACEHOLDER) + + def test_secret_value_accessible(self, monkeypatch: pytest.MonkeyPatch): + """Test secret actual value is accessible.""" + monkeypatch.setenv("INFRACTL_API_KEY", "super-secret-key") + + parser = DeployCommand() + parser.parse_args(["--targets=target1"]) + + # Actual value should be accessible via str.__str__ + assert str.__str__(parser.api_key) == "super-secret-key" + + def test_secret_from_env_var(self, monkeypatch: pytest.MonkeyPatch): + """Test secret value from environment variable.""" + monkeypatch.setenv("INFRACTL_API_KEY", "env-api-key") + + parser = DeployCommand() + parser.parse_args(["--targets=target1"]) + + assert str.__str__(parser.api_key) == "env-api-key" + + def test_secret_from_cli(self): + """Test secret value from CLI.""" + parser = DeployCommand() + parser.parse_args(["--api-key=cli-api-key", "--targets=target1"]) + + assert str.__str__(parser.api_key) == "cli-api-key" + + def test_sanitize_env_removes_secret(self, monkeypatch: pytest.MonkeyPatch): + """Test sanitize_env removes secret env vars.""" + with patch("os.environ", new={}): + import os + + os.environ["TEST_SECRET"] = "secret-value" + + class Parser(argclass.Parser): + secret: str = argclass.Secret(env_var="TEST_SECRET") + + parser = Parser() + parser.parse_args([]) + + assert os.environ.get("TEST_SECRET") == "secret-value" + parser.sanitize_env(only_secrets=True) + assert os.environ.get("TEST_SECRET") is None + + def test_secret_not_in_help( + self, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + ): + """Test secret values don't appear in help.""" + monkeypatch.setenv("INFRACTL_API_KEY", "should-not-appear") + + parser = DeployCommand() + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + captured = capsys.readouterr() + assert "should-not-appear" not in captured.out + + +class TestNargsVariations: + """Test nargs parameter variations.""" + + def test_nargs_optional_with_const(self): + """Test nargs='?' with const value.""" + + class Parser(argclass.Parser): + config_path: Optional[str] = argclass.Argument( + nargs="?", + const="default.conf", + default=None, + ) + + parser = Parser() + parser.parse_args([]) + assert parser.config_path is None + + parser = Parser() + parser.parse_args(["--config-path"]) + assert parser.config_path == "default.conf" + + parser = Parser() + parser.parse_args(["--config-path=custom.conf"]) + assert parser.config_path == "custom.conf" + + def test_nargs_zero_or_more(self): + """Test nargs='*' (zero or more).""" + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument(nargs="*", default=[]) + + parser = Parser() + parser.parse_args([]) + assert parser.files == [] + + parser = Parser() + parser.parse_args(["--files", "a.txt", "b.txt"]) + assert parser.files == ["a.txt", "b.txt"] + + def test_nargs_one_or_more(self): + """Test nargs='+' (one or more).""" + parser = DeployCommand() + parser.parse_args( + ["--api-key=key", "--targets", "target1", "target2", "target3"] + ) + assert parser.targets == ["target1", "target2", "target3"] + + def test_nargs_one_or_more_required(self): + """Test nargs='+' requires at least one value when used.""" + + class Parser(argclass.Parser): + # Use str type without List to make it required + targets: str = argclass.Argument(nargs="+", required=True) + + parser = Parser() + with pytest.raises(SystemExit): + parser.parse_args([]) # No targets - required=True + + def test_nargs_exact_count(self): + """Test nargs=N (exact count).""" + + class Parser(argclass.Parser): + coordinates: List[float] = argclass.Argument( + nargs=3, + type=float, + metavar=("X", "Y", "Z"), + ) + + parser = Parser() + parser.parse_args(["--coordinates", "1.0", "2.0", "3.0"]) + assert parser.coordinates == [1.0, 2.0, 3.0] + + def test_nargs_with_type_conversion(self): + """Test nargs with type conversion.""" + + class Parser(argclass.Parser): + ports: List[int] = argclass.Argument( + nargs="+", + type=int, + ) + + parser = Parser() + parser.parse_args(["--ports", "80", "443", "8080"]) + assert parser.ports == [80, 443, 8080] + assert all(isinstance(p, int) for p in parser.ports) + + def test_nargs_with_converter(self): + """Test nargs with converter to transform result.""" + + class Parser(argclass.Parser): + unique_tags: frozenset = argclass.Argument( # type: ignore[type-arg] + nargs="+", + type=str, + converter=frozenset, + ) + + parser = Parser() + parser.parse_args(["--unique-tags", "a", "b", "a", "c"]) + assert parser.unique_tags == frozenset(["a", "b", "c"]) + + def test_nargs_in_group(self): + """Test nargs argument in a group.""" + + class TargetsGroup(argclass.Group): + hosts: List[str] = argclass.Argument(nargs="+") + ports: List[int] = argclass.Argument( + nargs="*", type=int, default=[] + ) + + class Parser(argclass.Parser): + targets = TargetsGroup() + + parser = Parser() + parser.parse_args( + ["--targets-hosts", "h1", "h2", "--targets-ports", "80", "443"] + ) + assert parser.targets.hosts == ["h1", "h2"] + assert parser.targets.ports == [80, 443] + + def test_nargs_positional_with_multiple_values(self): + """Test positional argument with nargs.""" + + class Parser(argclass.Parser): + files: List[str] = argclass.Argument("files", nargs="+") + + parser = Parser() + parser.parse_args(["file1.txt", "file2.txt", "file3.txt"]) + assert parser.files == ["file1.txt", "file2.txt", "file3.txt"] + + def test_nargs_enum_variations(self): + """Test nargs using Nargs enum.""" + + class Parser(argclass.Parser): + items: List[str] = argclass.Argument( + nargs=argclass.Nargs.ONE_OR_MORE, + ) + optional_items: List[str] = argclass.Argument( + nargs=argclass.Nargs.ZERO_OR_MORE, + default=[], + ) + + parser = Parser() + parser.parse_args(["--items", "a", "b"]) + assert parser.items == ["a", "b"] + assert parser.optional_items == [] + + def test_database_backup_tables_nargs(self): + """Test database backup tables with nargs='*'.""" + parser = DatabaseCommand() + parser.parse_args(["backup", "--tables", "users", "orders", "products"]) + assert parser.backup.tables == ["users", "orders", "products"] # type: ignore[union-attr] + + def test_user_create_groups_nargs(self): + """Test user create groups with nargs='*'.""" + parser = UserCommand() + parser.parse_args( + [ + "create", + "--username=test", + "--email=test@test.com", + "--groups", + "admin", + "users", + ] + ) + assert parser.create.groups == ["admin", "users"] # type: ignore[union-attr] + + +class TestTypeConversion: + """Test type conversion functionality.""" + + def test_path_type_conversion(self): + """Test Path type conversion.""" + parser = DatabaseCommand() + parser.parse_args(["backup", "--output=/var/backups/db.sql"]) + assert parser.backup.output == Path("/var/backups/db.sql") # type: ignore[union-attr] + assert isinstance(parser.backup.output, Path) # type: ignore[union-attr] + + def test_custom_type_converter(self): + """Test custom type converter function.""" + + def parse_size(s: str) -> int: + """Parse size string like '1K', '2M', '3G'.""" + multipliers = {"K": 1024, "M": 1024**2, "G": 1024**3} + if s[-1] in multipliers: + return int(s[:-1]) * multipliers[s[-1]] + return int(s) + + class Parser(argclass.Parser): + buffer_size: int = argclass.Argument(type=parse_size) + + parser = Parser() + parser.parse_args(["--buffer-size=4K"]) + assert parser.buffer_size == 4096 + + parser = Parser() + parser.parse_args(["--buffer-size=2M"]) + assert parser.buffer_size == 2 * 1024 * 1024 + + def test_enum_argument(self): + """Test enum argument type.""" + parser = DeployCommand() + parser.parse_args( + ["--api-key=key", "--environment=PROD", "--targets=target1"] + ) + assert parser.environment == DeployEnvironment.PROD + + def test_enum_argument_invalid_value(self): + """Test enum argument with invalid value.""" + parser = DeployCommand() + with pytest.raises(SystemExit): + parser.parse_args( + ["--api-key=key", "--environment=INVALID", "--targets=target1"] + ) + + def test_bool_conversion_from_cli(self): + """Test bool conversion from CLI flags.""" + parser = ServerCommand() + parser.parse_args(["start", "--daemon"]) + assert parser.start.daemon is True # type: ignore[union-attr] + + def test_bool_default_true_toggled(self): + """Test bool with default=True toggled by flag.""" + parser = ServerCommand() + parser.parse_args(["restart", "--graceful"]) + # Toggled from True + assert parser.restart.graceful is False # type: ignore[union-attr] + + def test_int_type_conversion(self): + """Test int type conversion.""" + parser = ServerCommand() + parser.parse_args(["start", "--workers=16"]) + assert parser.start.workers == 16 # type: ignore[union-attr] + assert isinstance(parser.start.workers, int) # type: ignore[union-attr] + + def test_float_type_conversion(self): + """Test float type conversion.""" + parser = DatabaseCommand() + parser.parse_args(["--retry-delay=0.5", "backup"]) + assert parser.retry.delay == 0.5 + assert isinstance(parser.retry.delay, float) + + +class TestConfigFiles: + """Test configuration file handling.""" + + def test_ini_config_loading(self, tmp_path: Path): + """Test INI config file loading.""" + config_file = tmp_path / "config.ini" + config_file.write_text( + "[DEFAULT]\nhost = ini.example.com\nport = 9000\n" + ) + + class Parser(argclass.Parser): + host: str = "localhost" + port: int = 8080 + + parser = Parser(config_files=[config_file]) + parser.parse_args([]) + + assert parser.host == "ini.example.com" + assert parser.port == 9000 + + def test_json_config_loading(self, tmp_path: Path): + """Test JSON config file loading.""" + config_file = tmp_path / "config.json" + config_file.write_text('{"host": "json.example.com", "port": 9000}') + + class Parser(argclass.Parser): + host: str = "localhost" + port: int = 8080 + + parser = Parser( + config_files=[config_file], + config_parser_class=argclass.JSONDefaultsParser, + ) + parser.parse_args([]) + + assert parser.host == "json.example.com" + assert parser.port == 9000 + + def test_multiple_config_files_merge(self, tmp_path: Path): + """Test multiple config files are merged correctly.""" + global_config = tmp_path / "global.ini" + global_config.write_text( + "[DEFAULT]\nhost = global.com\nport = 8080\ndebug = false\n" + ) + + user_config = tmp_path / "user.ini" + user_config.write_text("[DEFAULT]\nhost = user.com\ndebug = true\n") + + class Parser(argclass.Parser): + host: str = "localhost" + port: int = 80 + debug: bool = False + + parser = Parser(config_files=[global_config, user_config]) + parser.parse_args([]) + + # Later config overrides earlier + assert parser.host == "user.com" + # Preserved from global config + assert parser.port == 8080 + # Overridden by user config + assert parser.debug is True + + def test_config_with_groups(self, tmp_path: Path): + """Test config file with group sections.""" + config_file = tmp_path / "config.ini" + config_file.write_text( + "[connection]\nhost = config.example.com\nport = 9000\nssl = true\n" + ) + + class Parser(argclass.Parser): + connection: ConnectionGroup = ConnectionGroup() + + parser = Parser(config_files=[config_file]) + parser.parse_args([]) + + assert parser.connection.host == "config.example.com" + assert parser.connection.port == 9000 + assert parser.connection.ssl is True + + def test_missing_config_file_ignored(self, tmp_path: Path): + """Test missing config files are gracefully ignored.""" + existing_config = tmp_path / "existing.ini" + existing_config.write_text("[DEFAULT]\nhost = existing.com\n") + + missing_config = tmp_path / "missing.ini" + + class Parser(argclass.Parser): + host: str = "default" + + parser = Parser(config_files=[existing_config, missing_config]) + parser.parse_args([]) + + assert parser.host == "existing.com" + + def test_config_action_runtime_loading(self, tmp_path: Path): + """Test Config action for runtime config loading.""" + config_file = tmp_path / "runtime.ini" + config_file.write_text("[app]\nname = myapp\nversion = 1.0\n") + + class Parser(argclass.Parser): + config = argclass.Config(config_class=argclass.INIConfig) + + parser = Parser() + parser.parse_args(["--config", str(config_file)]) + + assert parser.config["app"]["name"] == "myapp" + assert parser.config["app"]["version"] == "1.0" + + def test_json_config_action_runtime_loading(self, tmp_path: Path): + """Test JSON Config action for runtime loading.""" + config_file = tmp_path / "runtime.json" + config_file.write_text('{"app": {"name": "myapp", "version": "2.0"}}') + + class Parser(argclass.Parser): + config = argclass.Config(config_class=argclass.JSONConfig) + + parser = Parser() + parser.parse_args(["--config", str(config_file)]) + + assert parser.config["app"]["name"] == "myapp" + assert parser.config["app"]["version"] == "2.0" + + def test_config_list_values(self, tmp_path: Path): + """Test config file with list values.""" + config_file = tmp_path / "config.ini" + config_file.write_text("[DEFAULT]\nports = [80, 443, 8080]\n") + + class Parser(argclass.Parser): + ports: List[int] = argclass.Argument( + nargs="+", + type=int, + default=[], + ) + + parser = Parser(config_files=[config_file]) + parser.parse_args([]) + + assert parser.ports == [80, 443, 8080] + + def test_json_config_with_nested_groups(self, tmp_path: Path): + """Test JSON config with nested group data.""" + config_file = tmp_path / "config.json" + config_file.write_text( + json.dumps( + { + "connection": { + "host": "json.example.com", + "port": 9000, + }, + "retry": { + "max_retries": 10, + "delay": 2.5, + }, + } + ) + ) + + class Parser(argclass.Parser): + connection: ConnectionGroup = ConnectionGroup() + retry: RetryGroup = RetryGroup() + + parser = Parser( + config_files=[config_file], + config_parser_class=argclass.JSONDefaultsParser, + ) + parser.parse_args([]) + + assert parser.connection.host == "json.example.com" + assert parser.connection.port == 9000 + assert parser.retry.max_retries == 10 + assert parser.retry.delay == 2.5 + + +class TestEnvironmentVariables: + """Test environment variable handling.""" + + def test_auto_env_var_prefix(self, monkeypatch: pytest.MonkeyPatch): + """Test auto_env_var_prefix for automatic env var binding.""" + monkeypatch.setenv("MYAPP_HOST", "env.example.com") + monkeypatch.setenv("MYAPP_PORT", "9000") + + class Parser(argclass.Parser): + host: str = "localhost" + port: int = 8080 + + parser = Parser(auto_env_var_prefix="MYAPP_") + parser.parse_args([]) + + assert parser.host == "env.example.com" + assert parser.port == 9000 + + def test_explicit_env_var_parameter(self, monkeypatch: pytest.MonkeyPatch): + """Test explicit env_var parameter on argument.""" + monkeypatch.setenv("CUSTOM_HOST", "custom.example.com") + + class Parser(argclass.Parser): + host: str = argclass.Argument( + env_var="CUSTOM_HOST", + default="localhost", + ) + + parser = Parser() + parser.parse_args([]) + + assert parser.host == "custom.example.com" + + def test_env_var_in_groups(self, monkeypatch: pytest.MonkeyPatch): + """Test env var with group prefix.""" + monkeypatch.setenv("APP_CONNECTION_HOST", "group-env.example.com") + monkeypatch.setenv("APP_CONNECTION_PORT", "9999") + + class Parser(argclass.Parser): + connection: ConnectionGroup = ConnectionGroup() + + parser = Parser(auto_env_var_prefix="APP_") + parser.parse_args([]) + + assert parser.connection.host == "group-env.example.com" + assert parser.connection.port == 9999 + + def test_env_var_bool_conversion(self, monkeypatch: pytest.MonkeyPatch): + """Test bool conversion from env var.""" + monkeypatch.setenv("APP_DEBUG", "true") + monkeypatch.setenv("APP_VERBOSE", "yes") + monkeypatch.setenv("APP_QUIET", "1") + + class Parser(argclass.Parser): + debug: bool = False + verbose: bool = False + quiet: bool = False + + parser = Parser(auto_env_var_prefix="APP_") + parser.parse_args([]) + + assert parser.debug is True + assert parser.verbose is True + assert parser.quiet is True + + def test_env_var_list_syntax(self, monkeypatch: pytest.MonkeyPatch): + """Test list value from env var.""" + monkeypatch.setenv("APP_PORTS", "[80, 443, 8080]") + + class Parser(argclass.Parser): + ports: List[int] = argclass.Argument( + nargs="+", + type=int, + converter=list, + ) + + parser = Parser(auto_env_var_prefix="APP_") + parser.parse_args([]) + + assert parser.ports == [80, 443, 8080] + + def test_cli_overrides_env_var(self, monkeypatch: pytest.MonkeyPatch): + """Test CLI argument overrides env var.""" + monkeypatch.setenv("APP_HOST", "env.example.com") + + class Parser(argclass.Parser): + host: str = "localhost" + + parser = Parser(auto_env_var_prefix="APP_") + parser.parse_args(["--host=cli.example.com"]) + + assert parser.host == "cli.example.com" + + def test_sanitize_env_all_vars(self, monkeypatch: pytest.MonkeyPatch): + """Test sanitize_env removes all bound env vars.""" + with patch("os.environ", new={}): + import os + + os.environ["APP_HOST"] = "example.com" + os.environ["APP_PORT"] = "9000" + + class Parser(argclass.Parser): + host: str = "localhost" + port: int = 8080 + + parser = Parser(auto_env_var_prefix="APP_") + parser.parse_args([]) + + parser.sanitize_env() + + assert "APP_HOST" not in os.environ + assert "APP_PORT" not in os.environ + + def test_parse_args_sanitize_secrets(self, monkeypatch: pytest.MonkeyPatch): + """Test parse_args with sanitize_secrets=True.""" + with patch("os.environ", new={}): + import os + + os.environ["APP_SECRET"] = "secret-value" + os.environ["APP_PUBLIC"] = "public-value" + + class Parser(argclass.Parser): + secret: str = argclass.Secret() + public: str = "default" + + parser = Parser(auto_env_var_prefix="APP_") + parser.parse_args([], sanitize_secrets=True) + + assert "APP_SECRET" not in os.environ + assert os.environ.get("APP_PUBLIC") == "public-value" + + +class TestErrorHandling: + """Test error handling and validation.""" + + def test_invalid_subcommand(self): + """Test invalid subcommand raises error.""" + parser = InfractlParser() + with pytest.raises(SystemExit): + parser.parse_args(["invalid-command"]) + + def test_missing_required_argument(self): + """Test missing required argument raises error.""" + parser = UserCommand() + with pytest.raises(SystemExit): + parser.parse_args(["create"]) # Missing --username and --email + + def test_invalid_type_conversion(self): + """Test invalid type conversion raises error.""" + parser = ServerCommand() + with pytest.raises(SystemExit): + parser.parse_args(["--connection-port=not-a-number", "start"]) + + def test_invalid_enum_value(self): + """Test invalid enum value raises error.""" + parser = DeployCommand() + with pytest.raises(SystemExit): + parser.parse_args( + ["--api-key=key", "--environment=INVALID", "target1"] + ) + + def test_invalid_choice(self): + """Test invalid choice value raises error.""" + parser = ServerCommand() + with pytest.raises(SystemExit): + parser.parse_args(["--logging-format=invalid", "start"]) + + def test_accessing_unparsed_attribute(self): + """Test accessing unparsed required attribute raises error.""" + parser = InfractlParser() + with pytest.raises(AttributeError): + _ = parser.debug # Not parsed yet + + def test_missing_nested_subcommand(self): + """Test server command without nested subcommand still works.""" + parser = ServerCommand() + # server without nested command should work (all nested are Optional) + parser.parse_args([]) + assert parser.current_subparser is None + + def test_type_conversion_error_with_details(self): + """Test type conversion error includes useful details.""" + + def bad_converter(x: str) -> str: + raise ValueError(f"cannot parse: {x}") + + class Parser(argclass.Parser): + value: str = argclass.Argument(converter=bad_converter) + + parser = Parser() + with pytest.raises(argclass.TypeConversionError) as exc_info: + parser.parse_args(["--value", "test"]) + + error_msg = str(exc_info.value) + assert "value" in error_msg + assert "test" in error_msg + + +class TestActionsVariations: + """Test various action types.""" + + def test_store_true_action(self): + """Test store_true action.""" + parser = ServerCommand() + parser.parse_args(["start", "--daemon"]) + assert parser.start.daemon is True # type: ignore[union-attr] + + def test_store_false_action(self): + """Test bool with default=True acts as store_false.""" + parser = ServerCommand() + # graceful defaults to True, flag toggles it + parser.parse_args(["restart", "--graceful"]) + assert parser.restart.graceful is False # type: ignore[union-attr] + + def test_store_const_action(self): + """Test store_const action.""" + + class Parser(argclass.Parser): + value = argclass.Argument( + "--set-value", + action=argclass.Actions.STORE_CONST, + const=42, + default=0, + ) + + parser = Parser() + parser.parse_args([]) + assert parser.value == 0 + + parser = Parser() + parser.parse_args(["--set-value"]) + assert parser.value == 42 + + def test_append_action(self): + """Test append action accumulates values across multiple flags.""" + + class Parser(argclass.Parser): + # Don't use List[str] type - APPEND handles the list creation + items = argclass.Argument( + "-i", + "--items", + action=argclass.Actions.APPEND, + ) + + parser = Parser() + parser.parse_args(["-i", "a", "-i", "b", "--items", "c"]) + assert parser.items == ["a", "b", "c"] + + def test_count_action(self): + """Test count action.""" + + class Parser(argclass.Parser): + verbosity: int = argclass.Argument( + "-v", + "--verbose", + action=argclass.Actions.COUNT, + default=0, + ) + + parser = Parser() + parser.parse_args(["-vvv"]) + assert parser.verbosity == 3 + + parser = Parser() + parser.parse_args(["-v", "-v", "--verbose"]) + assert parser.verbosity == 3 + + +class TestComplexScenarios: + """Test complex real-world scenarios.""" + + def test_full_deploy_command(self, monkeypatch: pytest.MonkeyPatch): + """Test a complete deploy command with all options.""" + monkeypatch.setenv("INFRACTL_API_KEY", "prod-api-key") + + parser = InfractlParser() + parser.parse_args( + [ + "--debug", + "deploy", + "--connection-host=deploy.example.com", + "--connection-port=443", + "--connection-ssl", + "--logging-level=info", + "--logging-format=json", + "--retry-max-retries=5", + "--retry-delay=2.0", + "--environment=PROD", + "--parallel=8", + "--targets", + "server1", + "server2", + "server3", + ] + ) + + deploy = parser.deploy + assert parser.debug is True + assert deploy.connection.host == "deploy.example.com" # type: ignore[union-attr] + assert deploy.connection.port == 443 # type: ignore[union-attr] + assert deploy.connection.ssl is True # type: ignore[union-attr] + assert deploy.logging.level == logging.INFO # type: ignore[union-attr] + assert deploy.logging.format == "json" # type: ignore[union-attr] + assert deploy.retry.max_retries == 5 # type: ignore[union-attr] + assert deploy.retry.delay == 2.0 # type: ignore[union-attr] + assert deploy.environment == DeployEnvironment.PROD # type: ignore[union-attr] + assert deploy.parallel == 8 # type: ignore[union-attr] + assert deploy.targets == ["server1", "server2", "server3"] # type: ignore[union-attr] + assert str.__str__(deploy.api_key) == "prod-api-key" # type: ignore[union-attr] + + def test_database_backup_with_config( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """Test database backup with config file and env override.""" + config_file = tmp_path / "db.ini" + config_file.write_text( + "[connection]\n" + "host = config-db.example.com\n" + "port = 5432\n" + "[retry]\n" + "max_retries = 5\n" + ) + + monkeypatch.setenv("DB_CONNECTION_HOST", "env-db.example.com") + + class TestDatabaseCommand(argclass.Parser): + connection: ConnectionGroup = ConnectionGroup() + retry: RetryGroup = RetryGroup() + backup: Optional[DatabaseBackupCommand] = DatabaseBackupCommand() + + parser = TestDatabaseCommand( + config_files=[config_file], auto_env_var_prefix="DB_" + ) + parser.parse_args( + [ + "backup", + "--output=/backups/db.sql", + "--tables", + "users", + "orders", + ] + ) + + # Env overrides config + assert parser.connection.host == "env-db.example.com" + # Config value preserved + assert parser.connection.port == 5432 + assert parser.retry.max_retries == 5 + # CLI values + assert parser.backup.output == Path("/backups/db.sql") # type: ignore[union-attr] + assert parser.backup.tables == ["users", "orders"] # type: ignore[union-attr] + + def test_subparser_dispatch_pattern(self): + """Test common subparser dispatch pattern.""" + from functools import singledispatch + + @singledispatch + def handle_command(cmd: Any) -> str: + raise NotImplementedError(f"Unknown command: {type(cmd)}") + + @handle_command.register(ServerStartCommand) + def handle_start(cmd: ServerStartCommand) -> str: + return f"Starting with {cmd.workers} workers" + + @handle_command.register(ServerStopCommand) + def handle_stop(cmd: ServerStopCommand) -> str: + return f"Stopping (force={cmd.force})" + + @handle_command.register(type(None)) + def handle_none(_: None) -> str: + return "No command" + + parser = ServerCommand() + + parser.parse_args(["start", "--workers=8"]) + result = handle_command(parser.current_subparser) + assert result == "Starting with 8 workers" + + parser = ServerCommand() + parser.parse_args(["stop", "--force"]) + result = handle_command(parser.current_subparser) + assert result == "Stopping (force=True)" + + parser = ServerCommand() + parser.parse_args([]) + result = handle_command(parser.current_subparser) + assert result == "No command" + + def test_parser_inheritance(self): + """Test parser class inheritance.""" + + class BaseCommand(argclass.Parser): + verbose: bool = False + output: str = "stdout" + + class ExtendedCommand(BaseCommand): + format: str = argclass.Argument( + choices=["json", "text"], + default="text", + ) + + parser = ExtendedCommand() + parser.parse_args(["--verbose", "--output=file.txt", "--format=json"]) + + assert parser.verbose is True + assert parser.output == "file.txt" + assert parser.format == "json" + + def test_group_inheritance(self): + """Test group class inheritance.""" + + class BaseConnectionGroup(argclass.Group): + host: str = "localhost" + port: int = 80 + + class SecureConnectionGroup(BaseConnectionGroup): + ssl: bool = True + cert_file: Optional[Path] = None + + class Parser(argclass.Parser): + connection: SecureConnectionGroup = SecureConnectionGroup() + + parser = Parser() + parser.parse_args( + [ + "--connection-host=secure.example.com", + "--connection-port=443", + "--connection-cert-file=/etc/ssl/cert.pem", + ] + ) + + assert parser.connection.host == "secure.example.com" + assert parser.connection.port == 443 + assert parser.connection.ssl is True + assert parser.connection.cert_file == Path("/etc/ssl/cert.pem") diff --git a/tests/test_simple.py b/tests/test_simple.py index 4822baa..29f7a1c 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -3,7 +3,6 @@ import os import re import uuid -from argparse import ArgumentError from enum import IntEnum from typing import FrozenSet, List, Literal, Optional, Set, Tuple from unittest.mock import patch @@ -832,19 +831,19 @@ class Parser(argclass.Parser): with pytest.raises(SystemExit): parser.parse_args(["--option=3"]) - class Parser(argclass.Parser): + class Parser2(argclass.Parser): option: Options - parser = Parser() + parser2 = Parser2() - parser.parse_args(["--option=ONE"]) - assert parser.option is Options.ONE + parser2.parse_args(["--option=ONE"]) + assert parser2.option is Options.ONE - parser.parse_args(["--option=TWO"]) - assert parser.option is Options.TWO + parser2.parse_args(["--option=TWO"]) + assert parser2.option is Options.TWO with pytest.raises(SystemExit): - parser.parse_args(["--option=3"]) + parser2.parse_args(["--option=3"]) def test_enum_without_default(): @@ -866,25 +865,27 @@ class Parser(argclass.Parser): def test_enum_invalid_default_type(): - """Test EnumArgument raises TypeError for invalid default type.""" + """Test EnumArgument raises EnumValueError for invalid default type.""" class Options(IntEnum): ONE = 1 TWO = 2 # Invalid type (not enum member or string) - with pytest.raises(TypeError, match="must be .* member or string"): - argclass.EnumArgument(Options, default=123) + with pytest.raises( + argclass.EnumValueError, match="must be .* member or string" + ): + argclass.EnumArgument(Options, default=123) # type: ignore[call-overload] def test_enum_invalid_string_default(): - """Test EnumArgument raises ValueError for invalid string default.""" + """Test EnumArgument raises EnumValueError for invalid string default.""" class Options(IntEnum): ONE = 1 TWO = 2 - with pytest.raises(ValueError, match="not a valid .* member"): + with pytest.raises(argclass.EnumValueError, match="not a valid .* member"): argclass.EnumArgument(Options, default="INVALID") @@ -932,7 +933,7 @@ class Parser(argclass.Parser): value: str = argclass.Argument(converter=bad_converter) parser = Parser() - with pytest.raises(ArgumentError) as exc_info: + with pytest.raises(argclass.TypeConversionError) as exc_info: parser.parse_args(["--value", "test"]) error_msg = str(exc_info.value) @@ -965,9 +966,9 @@ class ImplicitSubGroup(ImplicitBaseGroup): class Parser2(argclass.Parser): group = ImplicitSubGroup() - parser = Parser2() + parser2 = Parser2() with pytest.raises(SystemExit): - parser.parse_args([]) + parser2.parse_args([]) def test_json_action(tmp_path): @@ -1456,7 +1457,7 @@ def test_bool_with_invalid_default_raises(self): with pytest.raises(TypeError, match="Can not set default"): class Parser(argclass.Parser): - flag: bool = "invalid" + flag: bool = "invalid" # type: ignore[assignment] Parser() @@ -1609,7 +1610,7 @@ def test_config_action_invalid_type_raises(self): ConfigAction( option_strings=["--config"], dest="config", - type="invalid", + type="invalid", # type: ignore[arg-type] ) def test_config_action_parse_file_not_implemented(self): @@ -1660,7 +1661,7 @@ def test_toml_config_action_raises_when_unavailable(self, tmp_path): toml_file.write_text('[section]\nkey = "value"') original = actions_module.toml_load - actions_module.toml_load = None + actions_module.toml_load = None # type: ignore[assignment] try: action = TOMLConfigAction( @@ -1682,7 +1683,7 @@ def test_toml_defaults_parser_raises_when_unavailable(self, tmp_path): toml_file.write_text('[section]\nkey = "value"') original = defaults_module.toml_load - defaults_module.toml_load = None + defaults_module.toml_load = None # type: ignore[assignment] try: parser = TOMLDefaultsParser([toml_file]) @@ -1792,7 +1793,7 @@ def test_toml_non_dict_skipped(self, tmp_path): def mock_load(fp): return ["not", "a", "dict"] - defaults_module.toml_load = mock_load + defaults_module.toml_load = mock_load # type: ignore[assignment] try: result = parser.parse() assert result == {} @@ -1809,12 +1810,12 @@ def test_abstract_parse_raises(self): # Create a concrete subclass that calls super().parse() class TestParser(AbstractDefaultsParser): - def parse(self): - return super().parse() + def parse(self): # type: ignore[override] + return super().parse() # type: ignore[safe-super] parser = TestParser([]) with pytest.raises(NotImplementedError): - parser.parse() + parser.parse() # type: ignore[no-untyped-call] class TestUnwrapOptionalComplexTypes: @@ -1825,7 +1826,7 @@ def test_complex_union_raises(self): from argclass.utils import unwrap_optional from typing import Union - with pytest.raises(TypeError, match="Complex types"): + with pytest.raises(argclass.ComplexTypeError, match="Union types"): unwrap_optional(Union[str, int, None]) @@ -2214,7 +2215,7 @@ class Parser(argclass.Parser): parser = Parser(config_files=[config]) - with pytest.raises((ValueError, SystemExit)): + with pytest.raises(argclass.UnexpectedConfigValue): parser.parse_args([]) def test_config_string_for_set_fails(self, tmp_path): @@ -2227,7 +2228,7 @@ class Parser(argclass.Parser): parser = Parser(config_files=[config]) - with pytest.raises((ValueError, SystemExit)): + with pytest.raises(argclass.UnexpectedConfigValue): parser.parse_args([]) diff --git a/tests/test_subparsers.py b/tests/test_subparsers.py index fe19a53..826c1a6 100644 --- a/tests/test_subparsers.py +++ b/tests/test_subparsers.py @@ -70,7 +70,7 @@ class Parser(argclass.Parser): commit: Optional[CommitCommand] = CommitCommand() push: Optional[PushCommand] = PushCommand() - state = {} + state: dict = {} @singledispatch def handle_subparser(subparser: Any) -> None: @@ -136,11 +136,13 @@ class Parser(argclass.Parser): ] ) + assert args.sub is not None assert args.sub.val == "lol" + assert args.sub.subsub is not None assert args.sub.subsub.str_value == "kek" assert args.sub.subsub.group.value == 2 with pytest.raises(AttributeError): - print(args.sub2.val) + print(args.sub2.val) # type: ignore[union-attr] def test_call() -> None: diff --git a/uv.lock b/uv.lock index f01c284..a106089 100644 --- a/uv.lock +++ b/uv.lock @@ -30,7 +30,7 @@ wheels = [ [[package]] name = "argclass" -version = "1.5.2" +version = "1.6.0" source = { editable = "." } [package.dev-dependencies] @@ -45,13 +45,17 @@ dev = [ ] docs = [ { name = "furo" }, - { name = "myst-parser" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx-autodoc-typehints", version = "3.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx-autodoc-typehints", version = "3.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-copybutton" }, - { name = "sphinx-design" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] mypy = [ { name = "mypy" }, @@ -345,11 +349,27 @@ sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf57 name = "docutils" version = "0.21.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -371,7 +391,8 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "pygments" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-basic-ng" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } @@ -420,89 +441,108 @@ wheels = [ [[package]] name = "librt" -version = "0.7.7" +version = "0.7.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/84/2cfb1f3b9b60bab52e16a220c931223fc8e963d0d7bb9132bef012aafc3f/librt-0.7.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4836c5645f40fbdc275e5670819bde5ab5f2e882290d304e3c6ddab1576a6d0", size = 54709, upload-time = "2026-01-01T23:50:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/19/a1/3127b277e9d3784a8040a54e8396d9ae5c64d6684dc6db4b4089b0eedcfb/librt-0.7.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae8aec43117a645a31e5f60e9e3a0797492e747823b9bda6972d521b436b4e8", size = 56658, upload-time = "2026-01-01T23:50:49.74Z" }, - { url = "https://files.pythonhosted.org/packages/3a/e9/b91b093a5c42eb218120445f3fef82e0b977fa2225f4d6fc133d25cdf86a/librt-0.7.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:aea05f701ccd2a76b34f0daf47ca5068176ff553510b614770c90d76ac88df06", size = 161026, upload-time = "2026-01-01T23:50:50.853Z" }, - { url = "https://files.pythonhosted.org/packages/c7/cb/1ded77d5976a79d7057af4a010d577ce4f473ff280984e68f4974a3281e5/librt-0.7.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b16ccaeff0ed4355dfb76fe1ea7a5d6d03b5ad27f295f77ee0557bc20a72495", size = 169529, upload-time = "2026-01-01T23:50:52.24Z" }, - { url = "https://files.pythonhosted.org/packages/da/6e/6ca5bdaa701e15f05000ac1a4c5d1475c422d3484bd3d1ca9e8c2f5be167/librt-0.7.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48c7e150c095d5e3cea7452347ba26094be905d6099d24f9319a8b475fcd3e0", size = 183271, upload-time = "2026-01-01T23:50:55.287Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2d/55c0e38073997b4bbb5ddff25b6d1bbba8c2f76f50afe5bb9c844b702f34/librt-0.7.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4dcee2f921a8632636d1c37f1bbdb8841d15666d119aa61e5399c5268e7ce02e", size = 179039, upload-time = "2026-01-01T23:50:56.807Z" }, - { url = "https://files.pythonhosted.org/packages/33/4e/3662a41ae8bb81b226f3968426293517b271d34d4e9fd4b59fc511f1ae40/librt-0.7.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14ef0f4ac3728ffd85bfc58e2f2f48fb4ef4fa871876f13a73a7381d10a9f77c", size = 173505, upload-time = "2026-01-01T23:50:58.291Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5d/cf768deb8bdcbac5f8c21fcb32dd483d038d88c529fd351bbe50590b945d/librt-0.7.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e4ab69fa37f8090f2d971a5d2bc606c7401170dbdae083c393d6cbf439cb45b8", size = 193570, upload-time = "2026-01-01T23:50:59.546Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/ee70effd13f1d651976d83a2812391f6203971740705e3c0900db75d4bce/librt-0.7.7-cp310-cp310-win32.whl", hash = "sha256:4bf3cc46d553693382d2abf5f5bd493d71bb0f50a7c0beab18aa13a5545c8900", size = 42600, upload-time = "2026-01-01T23:51:00.694Z" }, - { url = "https://files.pythonhosted.org/packages/f0/eb/dc098730f281cba76c279b71783f5de2edcba3b880c1ab84a093ef826062/librt-0.7.7-cp310-cp310-win_amd64.whl", hash = "sha256:f0c8fe5aeadd8a0e5b0598f8a6ee3533135ca50fd3f20f130f9d72baf5c6ac58", size = 48977, upload-time = "2026-01-01T23:51:01.726Z" }, - { url = "https://files.pythonhosted.org/packages/f0/56/30b5c342518005546df78841cb0820ae85a17e7d07d521c10ef367306d0d/librt-0.7.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a487b71fbf8a9edb72a8c7a456dda0184642d99cd007bc819c0b7ab93676a8ee", size = 54709, upload-time = "2026-01-01T23:51:02.774Z" }, - { url = "https://files.pythonhosted.org/packages/72/78/9f120e3920b22504d4f3835e28b55acc2cc47c9586d2e1b6ba04c3c1bf01/librt-0.7.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f4d4efb218264ecf0f8516196c9e2d1a0679d9fb3bb15df1155a35220062eba8", size = 56663, upload-time = "2026-01-01T23:51:03.838Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ea/7d7a1ee7dfc1151836028eba25629afcf45b56bbc721293e41aa2e9b8934/librt-0.7.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b8bb331aad734b059c4b450cd0a225652f16889e286b2345af5e2c3c625c3d85", size = 161705, upload-time = "2026-01-01T23:51:04.917Z" }, - { url = "https://files.pythonhosted.org/packages/45/a5/952bc840ac8917fbcefd6bc5f51ad02b89721729814f3e2bfcc1337a76d6/librt-0.7.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467dbd7443bda08338fc8ad701ed38cef48194017554f4c798b0a237904b3f99", size = 171029, upload-time = "2026-01-01T23:51:06.09Z" }, - { url = "https://files.pythonhosted.org/packages/fa/bf/c017ff7da82dc9192cf40d5e802a48a25d00e7639b6465cfdcee5893a22c/librt-0.7.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50d1d1ee813d2d1a3baf2873634ba506b263032418d16287c92ec1cc9c1a00cb", size = 184704, upload-time = "2026-01-01T23:51:07.549Z" }, - { url = "https://files.pythonhosted.org/packages/77/ec/72f3dd39d2cdfd6402ab10836dc9cbf854d145226062a185b419c4f1624a/librt-0.7.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e5070cf3ec92d98f57574da0224f8c73faf1ddd6d8afa0b8c9f6e86997bc74", size = 180719, upload-time = "2026-01-01T23:51:09.062Z" }, - { url = "https://files.pythonhosted.org/packages/78/86/06e7a1a81b246f3313bf515dd9613a1c81583e6fd7843a9f4d625c4e926d/librt-0.7.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bdb9f3d865b2dafe7f9ad7f30ef563c80d0ddd2fdc8cc9b8e4f242f475e34d75", size = 174537, upload-time = "2026-01-01T23:51:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/83/08/f9fb2edc9c7a76e95b2924ce81d545673f5b034e8c5dd92159d1c7dae0c6/librt-0.7.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8185c8497d45164e256376f9da5aed2bb26ff636c798c9dabe313b90e9f25b28", size = 195238, upload-time = "2026-01-01T23:51:11.762Z" }, - { url = "https://files.pythonhosted.org/packages/ba/56/ea2d2489d3ea1f47b301120e03a099e22de7b32c93df9a211e6ff4f9bf38/librt-0.7.7-cp311-cp311-win32.whl", hash = "sha256:44d63ce643f34a903f09ff7ca355aae019a3730c7afd6a3c037d569beeb5d151", size = 42939, upload-time = "2026-01-01T23:51:13.192Z" }, - { url = "https://files.pythonhosted.org/packages/58/7b/c288f417e42ba2a037f1c0753219e277b33090ed4f72f292fb6fe175db4c/librt-0.7.7-cp311-cp311-win_amd64.whl", hash = "sha256:7d13cc340b3b82134f8038a2bfe7137093693dcad8ba5773da18f95ad6b77a8a", size = 49240, upload-time = "2026-01-01T23:51:14.264Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/738eb33a6c1516fdb2dfd2a35db6e5300f7616679b573585be0409bc6890/librt-0.7.7-cp311-cp311-win_arm64.whl", hash = "sha256:983de36b5a83fe9222f4f7dcd071f9b1ac6f3f17c0af0238dadfb8229588f890", size = 42613, upload-time = "2026-01-01T23:51:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" }, - { url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" }, - { url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" }, - { url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" }, - { url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" }, - { url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, - { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, - { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, - { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, - { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, - { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, - { url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" }, - { url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" }, - { url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" }, - { url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" }, - { url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" }, - { url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" }, - { url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" }, - { url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" }, - { url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" }, - { url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" }, - { url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" }, - { url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" }, - { url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" }, - { url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" }, - { url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" }, - { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, + { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, ] [[package]] name = "markdown-it-py" version = "3.0.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version < '3.11'" }, ] 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 = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markdown-pytest" version = "0.3.2" @@ -605,7 +645,8 @@ name = "mdit-py-plugins" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } wheels = [ @@ -680,27 +721,51 @@ wheels = [ name = "myst-parser" version = "4.0.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ - { name = "docutils" }, - { name = "jinja2" }, - { name = "markdown-it-py" }, - { name = "mdit-py-plugins" }, - { name = "pyyaml" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" }, + { name = "pyyaml", marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, ] +[[package]] +name = "myst-parser" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, +] + [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] @@ -863,51 +928,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] -[[package]] -name = "roman-numerals-py" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "roman-numerals", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, -] - [[package]] name = "ruff" -version = "0.14.11" +version = "0.14.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" }, - { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" }, - { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" }, - { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" }, - { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" }, - { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" }, - { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" }, - { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" }, - { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" }, - { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" }, - { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] [[package]] name = "setuptools" -version = "80.9.0" +version = "80.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" }, ] [[package]] @@ -921,11 +974,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.1" +version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] [[package]] @@ -939,7 +992,7 @@ dependencies = [ { name = "alabaster", marker = "python_full_version < '3.11'" }, { name = "babel", marker = "python_full_version < '3.11'" }, { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "imagesize", marker = "python_full_version < '3.11'" }, { name = "jinja2", marker = "python_full_version < '3.11'" }, { name = "packaging", marker = "python_full_version < '3.11'" }, @@ -961,34 +1014,64 @@ wheels = [ [[package]] name = "sphinx" -version = "8.2.3" +version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] @@ -1008,18 +1091,32 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.5.2" +version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] dependencies = [ - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/4f/4fd5583678bb7dc8afa69e9b309e6a99ee8d79ad3a4728f4e52fd7cb37c7/sphinx_autodoc_typehints-3.5.2.tar.gz", hash = "sha256:5fcd4a3eb7aa89424c1e2e32bedca66edc38367569c9169a80f4b3e934171fdb", size = 37839, upload-time = "2025-10-16T00:50:15.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/f6/bdd93582b2aaad2cfe9eb5695a44883c8bc44572dd3c351a947acbb13789/sphinx_autodoc_typehints-3.6.1.tar.gz", hash = "sha256:fa0b686ae1b85965116c88260e5e4b82faec3687c2e94d6a10f9b36c3743e2fe", size = 37563, upload-time = "2026-01-02T15:23:46.543Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/f2/9657c98a66973b7c35bfd48ba65d1922860de9598fbb535cd96e3f58a908/sphinx_autodoc_typehints-3.5.2-py3-none-any.whl", hash = "sha256:0accd043619f53c86705958e323b419e41667917045ac9215d7be1b493648d8c", size = 21184, upload-time = "2025-10-16T00:50:13.973Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6a/c0360b115c81d449b3b73bf74b64ca773464d5c7b1b77bda87c5e874853b/sphinx_autodoc_typehints-3.6.1-py3-none-any.whl", hash = "sha256:dd818ba31d4c97f219a8c0fcacef280424f84a3589cedcb73003ad99c7da41ca", size = 20869, upload-time = "2026-01-02T15:23:45.194Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/51/6603ed3786a2d52366c66f49bc8afb31ae5c0e33d4a156afcb38d2bac62c/sphinx_autodoc_typehints-3.6.2.tar.gz", hash = "sha256:3d37709a21b7b765ad6e20a04ecefcb229b9eb0007cb24f6ebaa8a4576ea7f06", size = 37574, upload-time = "2026-01-02T21:25:28.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6a/877e8a6ea52fc86d88ce110ebcfe4f8474ff590d8a8d322909673af3da7b/sphinx_autodoc_typehints-3.6.2-py3-none-any.whl", hash = "sha256:9e70bee1f487b087c83ba0f4949604a4630bee396e263a324aae1dc4268d2c0f", size = 20853, upload-time = "2026-01-02T21:25:26.853Z" }, ] [[package]] @@ -1028,7 +1125,8 @@ version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } wheels = [ @@ -1041,7 +1139,8 @@ version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } wheels = [ @@ -1052,15 +1151,34 @@ wheels = [ name = "sphinx-design" version = "0.6.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, ] +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0"