diff --git a/CHANGELOG.md b/CHANGELOG.md index ff461d9..f015b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [0.6.5] 2026-08-20 +### Added +- `return_single_namespace` option for `parse_args_using_dataclass` function. +- Context manager for `Ticker` class. + +### Modified +- Allow argument `stream` to be None or root_logging_stream customize handlers set in setup_logging functions. + + ## [0.6.4] 2026-08-17 ### Added - Function `parse_args_using_dataclass` can now handle multiple dataclass at once. diff --git a/CITATION.cff b/CITATION.cff index 5b0fcb7..325b383 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -17,5 +17,5 @@ keywords: - tools - utilities license: MIT -version: 0.6.4 -date-released: '2026-08-17' +version: 0.6.5 +date-released: '2026-08-20' diff --git a/src/pythonwrench/__init__.py b/src/pythonwrench/__init__.py index 2854194..12a5f82 100644 --- a/src/pythonwrench/__init__.py +++ b/src/pythonwrench/__init__.py @@ -9,7 +9,7 @@ __license__ = "MIT" __maintainer__ = "Étienne Labbé (Labbeti)" __status__ = "Development" -__version__ = "0.6.4" +__version__ = "0.6.5" from typing import TYPE_CHECKING diff --git a/src/pythonwrench/argparse/dataclass_.py b/src/pythonwrench/argparse/dataclass_.py index 4e8c74d..093f703 100644 --- a/src/pythonwrench/argparse/dataclass_.py +++ b/src/pythonwrench/argparse/dataclass_.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from argparse import ArgumentParser +from argparse import ArgumentParser, Namespace from dataclasses import MISSING, fields from typing import ( Any, @@ -48,6 +48,19 @@ class BooleanOptionalAction: ... BoolAction = Union[_BoolActionName, Type[BooleanOptionalAction]] +@overload +def parse_args_using_dataclass( + dataclass_type: Type[DataclassInstance], + *dataclass_types: Type[DataclassInstance], + args: Optional[Iterable[str]] = None, + parser: Optional[ArgumentParser] = None, + list_parsing: ListParsing = "argparse", + bool_action: BoolAction = "store", + add_dashed_arg: bool = True, + return_single_namespace: Literal[True], +) -> Namespace: ... + + @overload def parse_args_using_dataclass( dataclass_type: Type[T_DataclassInstance], @@ -57,6 +70,7 @@ def parse_args_using_dataclass( list_parsing: ListParsing = "argparse", bool_action: BoolAction = "store", add_dashed_arg: bool = True, + return_single_namespace: Literal[False] = False, ) -> T_DataclassInstance: ... @@ -71,6 +85,7 @@ def parse_args_using_dataclass( list_parsing: ListParsing = "argparse", bool_action: BoolAction = "store", add_dashed_arg: bool = True, + return_single_namespace: Literal[False] = False, ) -> Tuple[ T_DataclassInstance, T_DataclassInstance_2, @@ -89,6 +104,7 @@ def parse_args_using_dataclass( list_parsing: ListParsing = "argparse", bool_action: BoolAction = "store", add_dashed_arg: bool = True, + return_single_namespace: Literal[False] = False, ) -> Tuple[ T_DataclassInstance, T_DataclassInstance_2, @@ -109,6 +125,7 @@ def parse_args_using_dataclass( list_parsing: ListParsing = "argparse", bool_action: BoolAction = "store", add_dashed_arg: bool = True, + return_single_namespace: Literal[False] = False, ) -> Tuple[ T_DataclassInstance, T_DataclassInstance_2, @@ -131,6 +148,7 @@ def parse_args_using_dataclass( list_parsing: ListParsing = "argparse", bool_action: BoolAction = "store", add_dashed_arg: bool = True, + return_single_namespace: Literal[False] = False, ) -> Tuple[ T_DataclassInstance, T_DataclassInstance_2, @@ -148,9 +166,11 @@ def parse_args_using_dataclass( list_parsing: ListParsing = "argparse", bool_action: BoolAction = "store", add_dashed_arg: bool = True, + return_single_namespace: bool = False, ) -> Union[ DataclassInstance, Tuple[DataclassInstance, ...], + Namespace, ]: """Converts prog args to a typed dataclass using argparse. @@ -175,6 +195,9 @@ def parse_args_using_dataclass( msg = f"Found {len(argv)} unknown arguments: {argv}." raise ValueError(msg) + if return_single_namespace: + return parsed + dataclass_insts = [] for dataclass_type_i in dataclass_types: if init_parser is None and len(dataclass_types) == 1: diff --git a/src/pythonwrench/logging.py b/src/pythonwrench/logging.py index 4d4b5b3..c3dda87 100644 --- a/src/pythonwrench/logging.py +++ b/src/pythonwrench/logging.py @@ -8,13 +8,13 @@ from logging import FileHandler, Formatter, Logger, StreamHandler from pathlib import Path from types import ModuleType -from typing import IO, List, Literal, Optional, TypeVar, Union +from typing import IO, Iterable, List, Literal, Optional, TypeVar, Union from typing_extensions import TypeAlias from pythonwrench.importlib import reload_submodules from pythonwrench.semver import Version -from pythonwrench.typing import SupportsIterLen +from pythonwrench.typing import SupportsIterLen, isinstance_generic T = TypeVar("T", covariant=True) @@ -29,6 +29,9 @@ PackageOrLogger, SupportsIterLen[PackageOrLogger], ] +TargetStream = Union[ + IO[str], Iterable[IO[str]], Literal["auto", "root_logger_streams"], None +] _PARENT_FILE_KEY = "__parent_file__" DEFAULT_FMT = "[%(asctime)s][%(name)s][%(levelname)s] - %(message)s" @@ -58,7 +61,7 @@ def setup_logging_verbose( verbose: Optional[int] = VERBOSE_INFO, *, fmt: Union[str, None, Formatter] = DEFAULT_FMT, - stream: Union[IO[str], Literal["auto"]] = "auto", + stream: TargetStream = "auto", set_fmt: bool = True, capture_warnings: bool = True, autoreload: bool = True, @@ -88,7 +91,7 @@ def setup_logging_level( level: Optional[int] = logging.INFO, *, fmt: Union[str, None, Formatter] = DEFAULT_FMT, - stream: Union[IO[str], Literal["auto"]] = "auto", + stream: TargetStream = "auto", set_fmt: bool = True, capture_warnings: bool = True, autoreload: bool = True, @@ -105,28 +108,59 @@ def setup_logging_level( if stream == "auto": if running_on_interpreter(): - stream = sys.stdout + streams = [sys.stdout] else: - stream = sys.stderr + streams = [sys.stderr] + elif stream == "root_logger_streams": + streams = [ + handler.stream + for handler in logging.getLogger().handlers + if isinstance(handler, StreamHandler) + ] + elif isinstance(stream, IO): + streams = [stream] + elif stream is None: + streams = stream + elif isinstance_generic(stream, Iterable[IO]): + streams = list(stream) + else: + raise TypeError(f"Invalid argument type {type(stream)=}.") + del stream for logger in logger_lst: - if set_fmt: - found = False + if level is not None: + logger.setLevel(level) + if not set_fmt: + continue + + if streams is None: for handler in logger.handlers: - if isinstance(handler, StreamHandler) and handler.stream is stream: - handler.setFormatter(fmt) - found = True - break + handler.setFormatter(fmt) + continue - if not found: - handler = StreamHandler(stream) # type: ignore + found = [False] * len(streams) + + for handler in logger.handlers: + if not isinstance(handler, StreamHandler): + continue + + try: + index = streams.index(handler.stream) + except IndexError: + continue + + handler.setFormatter(fmt) + found[index] = True + + if not all(found): + for stream_i, found_i in zip(streams, found): + if found_i: + continue + handler = StreamHandler(stream_i) handler.setFormatter(fmt) logger.addHandler(handler) - if level is not None: - logger.setLevel(level) - if autoreload: for logger in logger_lst: if logger.name not in sys.modules: diff --git a/src/pythonwrench/time.py b/src/pythonwrench/time.py index da13f8e..2213e94 100644 --- a/src/pythonwrench/time.py +++ b/src/pythonwrench/time.py @@ -4,6 +4,8 @@ import time from typing import Callable, Optional +from typing_extensions import Self + class Ticker: def __init__( @@ -32,3 +34,18 @@ def set_prev_tick(self, prev_tick: Optional[float] = None) -> None: if prev_tick is None: prev_tick = self._get_time_fn() self._prev_tick = prev_tick + + def get_elapsed_duration(self) -> float: + """Returns elapsed time since last tick. If no tick has been set, returns 0.""" + if self._prev_tick is None: + return 0.0 + now = self._get_time_fn() + duration = now - self._prev_tick + return duration + + def __enter__(self) -> Self: + self.tick() + return self + + def __exit__(self, exc_type, exc, tb): + pass diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 5630c62..2d5b930 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -2,8 +2,8 @@ # -*- coding: utf-8 -*- import unittest -from argparse import ArgumentParser -from dataclasses import dataclass, field +from argparse import ArgumentParser, Namespace +from dataclasses import asdict, dataclass, field from enum import Enum, auto from pathlib import Path from typing import Iterable, List, Literal, Optional, Tuple, Union @@ -302,6 +302,20 @@ class Cfg2: assert cfg1 == expected_cfg1 assert cfg2 == expected_cfg2 + args = parse_args_using_dataclass( + Cfg2, + Cfg1, + args=[ + "--path", + str(expected_cfg1.path), + "--b", + expected_cfg2.b, + ], + return_single_namespace=True, + ) + expected_args = Namespace(**asdict(expected_cfg1), **asdict(expected_cfg2)) + assert args == expected_args + def test_parse_store_bool(self) -> None: @dataclass class Cfg: