diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b4f06..b5e8ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [0.6.1] 2026-07-25 +### Added +- `list_parsing` argument value `"brackets"` to `add_dataclass_fields_to_parser`. +- `pre_fn` and `post_fn` args to `function_alias`, `deprecated_alias` and `deprecated_function`. +- `ModulePlacerholder` and `import_if_available` + +### Fixed +- Minor compatibilities with python 3.8. + + ## [0.6.0] 2026-07-24 ### Added - Add `_fill_all_arguments` option to `filter_and_call`. @@ -15,6 +25,7 @@ All notable changes to this project will be documented in this file. - `Ticker` class in global context. - `is_special_form` and `is_parametrized` now supports objects with custom `__contains__`, like ndarray or DataFrames. + ## [0.5.2] 2026-07-02 ### Modified - `lazy-loader` package is now optional. @@ -23,6 +34,7 @@ All notable changes to this project will be documented in this file. - `Placeholder` and `requires_packages` in global context. - `as_builtin` now support conversions of dataclasses attributes, without converting to dict with `dataclasses.asdict`. + ## [0.5.1] 2026-06-19 ### Added - Argument `by` to `sorted_dict` function. @@ -33,27 +45,33 @@ All notable changes to this project will be documented in this file. ### Modified - Checksum can apply to `date`, `datetime`, parametrized types, collections aliases and special forms. + ## [0.5.0] 2026-03-05 ### Modified - Change default store mode to `"outputs_only"` in disk cache. - Change default `fn_name` to function name instead of fully qualified name in disk cache. - Lazily load imports if `lazy_loader` package is available. + ## [0.4.10] 2026-02-09 ### Fixed - Disk cache custom keywords arguments for cache filename. + ## [0.4.9] 2026-02-06 ### Fixed - `Version` now ignores buildmetadata when comparing versions. + ## [0.4.8] 2026-02-06 ### Added - `checksum_any` now supports `Enum` instances. + ### Fixed - Missing global typing imports. + ## [0.4.7] 2026-02-01 ### Modified - Disk cache functions now detect saving backend when custom load/dump functions are provided. @@ -61,20 +79,24 @@ All notable changes to this project will be documented in this file. ### Fixed - `Callable` in `isinstance_generic` check. + ## [0.4.6] 2026-01-09 ### Added - `cache_fname_fmt` can now be a custom callable formatter. + ## [0.4.5] 2026-01-09 ### Fixed - Documentation build with constraint `sphinx<9.0.0`. + ## [0.4.4] 2026-01-09 ### Added - `SizedGenerator` class wrapper. - `executor_kwds` argument to `ThreadPoolExecutorHelper` class. - `cache_fname_fmt` argument in `disk_cache` now supports inputs arguments values to name the cache file. + ## [0.4.3] 2025-12-13 ### Added - `as_builtin` now supports `datetime.date` instances. @@ -87,10 +109,12 @@ All notable changes to this project will be documented in this file. - `SupportsMatmul` typing class. - `reduce_matmul` function. + ## [0.4.2] 2025-10-16 ### Fixed - `get_argnames` and `filter_and_call` functions when argument `fn` contains arguments with default values. + ## [0.4.1] 2025-09-11 ### Added - `SupportsGetitem` protocol. @@ -104,6 +128,7 @@ All notable changes to this project will be documented in this file. - Default value for `T_Index` typevar for protocol that specify `__getitem__` method. - `reload_editable_submodules` when no editable submodule is imported. + ## [0.4.0] 2025-07-18 ### Added - `duplicate_list` function to duplicate elements in a list. @@ -120,6 +145,7 @@ All notable changes to this project will be documented in this file. - `priority` argument in `register` method of `_FunctionRegistry` class. - `get_argnames` returns when function contains local variables. + ## [0.3.0] 2025-06-29 ### Added - `check_args_type` decorator. @@ -131,6 +157,7 @@ All notable changes to this project will be documented in this file. - `as_builtin` now converts correctly `Mapping` instances. - `register_checksum_fn` now accepts `priority` arg. + ## [0.2.0] 2025-06-25 ### Added - Python 3.8 support. @@ -142,6 +169,7 @@ All notable changes to this project will be documented in this file. ### Modified - `disk_cache_decorator` now supports custom saving backend fns. + ## [0.1.0] 2025-06-21 ### Added - Initial modules from `pyoutil` module contained in [torchoutil](https://github.com/Labbeti/torchoutil) project. diff --git a/CITATION.cff b/CITATION.cff index fabb2ec..7c67993 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -17,5 +17,5 @@ keywords: - tools - utilities license: MIT -version: 0.6.0 -date-released: '2026-07-24' +version: 0.6.1 +date-released: '2026-07-25' diff --git a/docs/update.bash b/docs/update.bash index ea9a6a4..2332ce2 100755 --- a/docs/update.bash +++ b/docs/update.bash @@ -1,6 +1,8 @@ #!/bin/bash # -*- coding: utf-8 -*- +pkg_name="pythonwrench" + docs_dpath=`dirname $0` cd "$docs_dpath" @@ -15,7 +17,7 @@ fi export LANG="$locale_name" export LC_ALL="$locale_name" -rm pythonwrench.*rst 2> /dev/null -uv run sphinx-apidoc -e -M -o . ../src/pythonwrench && uv run make clean && uv run make html +rm ${pkg_name}.*rst 2> /dev/null +uv run sphinx-apidoc -e -M -o . ../src/${pkg_name} && uv run make clean && uv run make html exit $? diff --git a/pyproject.toml b/pyproject.toml index d18c7cd..9b3a498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ Tracker = "https://github.com/Labbeti/pythonwrench/issues" pythonwrench-info = "pythonwrench.entrypoints.info:main_info" pythonwrench-tree = "pythonwrench.entrypoints.tree:main_tree" pythonwrench-safe-rmdir = "pythonwrench.entrypoints.safe_rmdir:main_safe_rmdir" +pw-info = "pythonwrench.entrypoints.info:main_info" +pw-tree = "pythonwrench.entrypoints.tree:main_tree" +pw-safe-rmdir = "pythonwrench.entrypoints.safe_rmdir:main_safe_rmdir" pyw-info = "pythonwrench.entrypoints.info:main_info" pyw-tree = "pythonwrench.entrypoints.tree:main_tree" pyw-safe-rmdir = "pythonwrench.entrypoints.safe_rmdir:main_safe_rmdir" diff --git a/src/pythonwrench/__init__.py b/src/pythonwrench/__init__.py index 28d338d..9b39c44 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.0" +__version__ = "0.6.1" from typing import TYPE_CHECKING @@ -51,6 +51,7 @@ # Global library imports from .abc import Singleton from .argparse import ( + add_dataclass_fields_to_parser, new_parser_from_dataclass, parse_args_using_dataclass, parse_to, @@ -115,7 +116,9 @@ ) from .hashlib import hash_file from .importlib import ( + ModulePlaceholder, Placeholder, + import_if_available, is_available_package, is_editable_package, reload_editable_packages, @@ -264,6 +267,7 @@ ], submod_attrs={ "argparse": [ + "add_dataclass_fields_to_parser", "new_parser_from_dataclass", "parse_args_using_dataclass", "parse_to", @@ -355,6 +359,8 @@ ], "hashlib": ["hash_file"], "importlib": [ + "import_if_available", + "ModulePlaceholder", "is_available_package", "is_editable_package", "reload_editable_packages", diff --git a/src/pythonwrench/_core.py b/src/pythonwrench/_core.py index 58d05cc..f096149 100644 --- a/src/pythonwrench/_core.py +++ b/src/pythonwrench/_core.py @@ -39,10 +39,14 @@ def return_none(*args, **kwargs) -> None: def _decorator_factory( inner_fn: Optional[Callable[P, U]], *, - pre_fn: Callable[..., Any] = return_none, - post_fn: Callable[..., Any] = return_none, + pre_fn: Optional[Callable[..., Any]] = None, + post_fn: Optional[Callable[..., Any]] = None, ) -> Callable[[Callable[P, U]], Callable[P, U]]: """Deprecated decorator for function aliases.""" + if pre_fn is None: + pre_fn = return_none + if post_fn is None: + post_fn = return_none def wrapper_factory(fn: Callable[P, U]) -> Callable[P, U]: if inner_fn is None: diff --git a/src/pythonwrench/argparse.py b/src/pythonwrench/argparse.py index 1f3a6b0..8afcadc 100644 --- a/src/pythonwrench/argparse.py +++ b/src/pythonwrench/argparse.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import re from argparse import ArgumentParser from dataclasses import MISSING, fields from functools import partial @@ -25,13 +26,15 @@ # support older python versions UnionType = Any +from pythonwrench.functools import filter_and_call from pythonwrench.typing.classes import Dataclass, DataclassInstance, NoneType +from pythonwrench.warnings import deprecated_alias T = TypeVar("T") T_Dataclass = TypeVar("T_Dataclass", bound=Dataclass) T_DataclassInstance = TypeVar("T_DataclassInstance", bound=DataclassInstance) -TargetType = Union[Type[T], UnionType, "Type[Literal]"] - +TargetType = Union[Type[T], UnionType, "Type[Literal]", "Type[Optional]"] +ListParsing = Literal["argparse", "brackets"] DEFAULT_TRUE_VALUES = ("True", "t", "yes", "y", "1") DEFAULT_FALSE_VALUES = ("False", "f", "no", "n", "0") @@ -42,24 +45,41 @@ def parse_args_using_dataclass( dataclass_type: Type[T_DataclassInstance], + *, args: Optional[Iterable[str]] = None, + parser: Optional[ArgumentParser] = None, + list_parsing: ListParsing = "argparse", ) -> T_DataclassInstance: """Converts prog args to a typed dataclass using argparse. Currently only supports dataclasses that contains only builtin scalars: str, int, float, None, bool OR list of builtin scalars. """ - parser = ArgumentParser() - parser = new_parser_from_dataclass(dataclass_type, parser) + init_parser = parser + parser = add_dataclass_fields_to_parser( + dataclass_type, + parser=parser, + list_parsing=list_parsing, + ) parsed, argv = parser.parse_known_args(args) if len(argv) > 0: raise ValueError(f"Found {len(argv)} unknown arguments: {argv}.") - instance = dataclass_type(**parsed.__dict__) + + if init_parser is None: + instance = dataclass_type(**parsed.__dict__) + else: + instance = filter_and_call( + dataclass_type, + _fill_all_arguments=True, + **parsed.__dict__, + ) return instance -def new_parser_from_dataclass( +def add_dataclass_fields_to_parser( dataclass_type: Type[T_DataclassInstance], + *, parser: Optional[ArgumentParser], + list_parsing: ListParsing = "argparse", ) -> ArgumentParser: if parser is None: parser = ArgumentParser() @@ -79,13 +99,21 @@ def new_parser_from_dataclass( msg = f"Invalid field {field.name}: found values for default and default_factory." raise ValueError(msg) - kwds.update(_get_kwds_for_type(field.type)) + inner_kwds = _get_kwds_for_type(field.type, list_parsing) + kwds.update(inner_kwds) parser.add_argument(*posargs, **kwds) return parser -def _get_kwds_for_type(field_type: Any) -> Dict[str, Any]: +@deprecated_alias(add_dataclass_fields_to_parser) +def new_parser_from_dataclass(*args, **kwargs): ... + + +def _get_kwds_for_type( + field_type: Any, + list_parsing: ListParsing = "argparse", +) -> Dict[str, Any]: kwds = {} type_origin = get_origin(field_type) @@ -101,17 +129,49 @@ def _get_kwds_for_type(field_type: Any) -> Dict[str, Any]: msg = f"Invalid argument {field_type=}. (expected homogeneous types in {type_origin})" raise TypeError(msg) - if field_type in _SCALARS_TARGET_TYPES or type_origin in ( - Literal, - Optional, - UnionType, - Union, + if ( + (field_type in _SCALARS_TARGET_TYPES) + or ( + type_origin + in ( + Literal, + Optional, + UnionType, + Union, + ) + ) + or (type_origin in (list, Iterable) and list_parsing == "brackets") ): - kwds.update(_get_kwds_for_scalar_type(field_type, field_type)) - elif type_origin is list: + inner_kwds = _get_kwds_for_scalar_type(field_type, field_type, list_parsing) + kwds.update(inner_kwds) + + elif type_origin in (list, Iterable): item_type = type_args[0] - kwds.update(_get_kwds_for_scalar_type(item_type, field_type)) - kwds["nargs"] = "*" + inner_kwds = _get_kwds_for_scalar_type(item_type, field_type, list_parsing) + inner_kwds["nargs"] = "*" + + # TODO: rm + # if list_parsing == "argparse": + # kwds["nargs"] = "*" + # elif list_parsing == "brackets": + # parse_fn = inner_kwds.pop("type") + + # def brackets_parse(x: str) -> Any: + # x = ( + # x.strip() + # .removeprefix("[") + # .removesuffix("]") + # .removesuffix(",") + # .strip() + # ) + # return list(map(parse_fn, x.split(","))) + + # inner_kwds["type"] = brackets_parse + # else: + # msg = f"Invalid argument {list_parsing=}. (expected one of {get_args(ListParsing)})" + # raise ValueError(msg) + + kwds.update(inner_kwds) else: msg = f"Unsupported type {field_type}." raise TypeError(msg) @@ -119,11 +179,20 @@ def _get_kwds_for_type(field_type: Any) -> Dict[str, Any]: return kwds -def _get_kwds_for_scalar_type(type_: Any, from_field_type: Any) -> Dict[str, Any]: +def _get_kwds_for_scalar_type( + type_: Any, from_field_type: Any, list_parsing: ListParsing +) -> Dict[str, Any]: type_origin = get_origin(type_) kwds = {} - if type_ in _SCALARS_TARGET_TYPES or type_origin in (UnionType, Union, Optional): + if ( + type_ in _SCALARS_TARGET_TYPES + or type_origin in (UnionType, Union, Optional) + or ( + get_origin(from_field_type) in (list, Iterable) + and list_parsing == "brackets" + ) + ): pass elif type_origin is Literal: type_args = get_args(type_) @@ -132,7 +201,7 @@ def _get_kwds_for_scalar_type(type_: Any, from_field_type: Any) -> Dict[str, Any msg = f"Unsupported dataclass member type {type_} from {from_field_type}." raise TypeError(msg) - kwds["type"] = parse_to(type_) # type: ignore + kwds["type"] = parse_to(type_, list_parsing=list_parsing) # type: ignore return kwds @@ -143,6 +212,7 @@ def parse_to( true_values: Union[str, Iterable[str]] = DEFAULT_TRUE_VALUES, false_values: Union[str, Iterable[str]] = DEFAULT_FALSE_VALUES, none_values: Union[str, Iterable[str]] = DEFAULT_NONE_VALUES, + list_parsing: ListParsing = "argparse", ) -> Callable[[str], T]: """Returns a callable that convert string value to target type safely. @@ -155,6 +225,7 @@ def parse_to( true_values=true_values, false_values=false_values, none_values=none_values, + list_parsing=list_parsing, ) @@ -166,6 +237,7 @@ def str_to_type( true_values: Union[str, Iterable[str]] = DEFAULT_TRUE_VALUES, false_values: Union[str, Iterable[str]] = DEFAULT_FALSE_VALUES, none_values: Union[str, Iterable[str]] = DEFAULT_NONE_VALUES, + list_parsing: ListParsing = "argparse", ) -> T: """Convert string values to target type safely. Intended for argparse arguments. @@ -181,6 +253,7 @@ def str_to_type( true_values=true_values, false_values=false_values, none_values=none_values, + list_parsing=list_parsing, ) if isinstance(result, Exception): raise result @@ -303,12 +376,14 @@ def _str_to_type_impl( true_values: Union[str, Iterable[str]] = DEFAULT_TRUE_VALUES, false_values: Union[str, Iterable[str]] = DEFAULT_FALSE_VALUES, none_values: Union[str, Iterable[str]] = DEFAULT_NONE_VALUES, + list_parsing: ListParsing = "argparse", ) -> Union[T, Exception]: kwds: Dict[str, Any] = dict( case_sensitive=case_sensitive, true_values=true_values, false_values=false_values, none_values=none_values, + list_parsing=list_parsing, ) if target_type in _SCALARS_TARGET_TYPES: return _str_to_scalar_impl(x, target_type, **kwds) @@ -329,7 +404,31 @@ def _str_to_type_impl( raise ValueError(msg) return scalar - elif getattr(target_type, "__name__", None) == "Optional": + if origin in (list, Iterable): + if list_parsing != "brackets": + raise ValueError + + args = get_args(target_type) + + if len(args) == 0: + target_item_type = str + elif len(args) == 1: + target_item_type = args[0] + else: + raise ValueError + + x = re.sub(r"^\s*\[\s*(|.*[^,\s])(|\s*,)\s*\]\s*$", r"\1", x) + x_list = x.split(",") + + y_list = [] + for xi in x_list: + yi = _str_to_type_impl(xi, target_item_type, **kwds) # type: ignore + if isinstance(yi, Exception): + return yi + y_list.append(yi) + return y_list # type: ignore + + if getattr(target_type, "__name__", None) == "Optional": args = (None,) + get_args(target_type) elif origin == Union or origin.__name__ in ("Union", "UnionType"): # type: ignore args = get_args(target_type) @@ -362,23 +461,31 @@ def _str_to_scalar_impl( true_values: Union[str, Iterable[str]] = DEFAULT_TRUE_VALUES, false_values: Union[str, Iterable[str]] = DEFAULT_FALSE_VALUES, none_values: Union[str, Iterable[str]] = DEFAULT_NONE_VALUES, + list_parsing: ListParsing = "argparse", ) -> Any: + del list_parsing if target_type is str: return x + elif target_type is int: try: return int(x) except ValueError as err: return err + elif target_type is float: try: return float(x) except ValueError as err: return err + elif target_type in (None, NoneType): return _str_to_none_impl( - x, case_sensitive=case_sensitive, none_values=none_values + x, + case_sensitive=case_sensitive, + none_values=none_values, ) + elif target_type is bool: return _str_to_bool_impl( x, @@ -387,7 +494,8 @@ def _str_to_scalar_impl( false_values=false_values, ) else: - raise ValueError(f"Invalid argument {target_type=}. (unsupported type)") + msg = f"Invalid argument {target_type=}. (unsupported type)" + raise ValueError(msg) def _str_to_bool_impl( diff --git a/src/pythonwrench/concurrent.py b/src/pythonwrench/concurrent.py index f0663af..b7ba30b 100644 --- a/src/pythonwrench/concurrent.py +++ b/src/pythonwrench/concurrent.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import copy import logging from concurrent.futures import Future, ThreadPoolExecutor from typing import Any, Callable, Dict, Generic, Iterable, List, Optional, TypeVar @@ -42,8 +43,11 @@ def submit(self, *args: P.args, **kwargs: P.kwargs) -> "Future[T]": executor_kwds = {} self.executor = ThreadPoolExecutor(**executor_kwds) - kwargs = self.default_kwargs | kwargs # type: ignore - future = self.executor.submit(self.fn, *args, **kwargs) + default_kwargs = copy.copy(self.default_kwargs) + default_kwargs.update(kwargs) + del kwargs + + future = self.executor.submit(self.fn, *args, **default_kwargs) self.futures.append(future) return future diff --git a/src/pythonwrench/difflib.py b/src/pythonwrench/difflib.py index dfd2791..f7b382a 100644 --- a/src/pythonwrench/difflib.py +++ b/src/pythonwrench/difflib.py @@ -6,18 +6,16 @@ from typing import Callable, Iterable, Optional -def sequence_matcher_ratio(a: str, b: str) -> float: - """Compute distance ratio of two strings.""" - return SequenceMatcher(None, a, b).ratio() - - def find_closest_in_list( x: str, lst: Iterable[str], - sim_fn: Callable[[str, str], float] = sequence_matcher_ratio, + sim_fn: Optional[Callable[[str, str], float]] = None, higher_is_closer: bool = True, ) -> Optional[str]: """Find closest element in a list based on matches ratio.""" + if sim_fn is None: + sim_fn = sequence_matcher_ratio + best_sim = -int(higher_is_closer) * math.inf closest = None @@ -34,3 +32,8 @@ def find_closest_in_list( raise ValueError(msg) return closest + + +def sequence_matcher_ratio(a: str, b: str) -> float: + """Compute distance ratio of two strings.""" + return SequenceMatcher(None, a, b).ratio() diff --git a/src/pythonwrench/entrypoints/info.py b/src/pythonwrench/entrypoints/info.py index b95359e..35376d8 100644 --- a/src/pythonwrench/entrypoints/info.py +++ b/src/pythonwrench/entrypoints/info.py @@ -6,7 +6,7 @@ import sys import warnings from pathlib import Path -from typing import Dict, Union +from typing import Any, Dict import pythonwrench from pythonwrench.os import get_num_cpus_available @@ -17,27 +17,25 @@ def main_info() -> None: """Show main packages versions.""" - warnings.filterwarnings("ignore", category=UserWarning) - install_info = get_install_info() - warnings.filterwarnings("default", category=UserWarning) + with warnings.catch_warnings(): + install_info = get_install_info() dumped = dumps_json(install_info, to_builtins=True) print(dumped) -def get_install_info() -> Dict[str, Union[str, int]]: +def get_install_info() -> Dict[str, Any]: """Returns current installation information. Meant for debugging.""" - install_info = { - "pythonwrench": pythonwrench.__version__, - "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + return { "os": platform.system(), "architecture": platform.architecture()[0], "num_cpus": get_num_cpus_available(), - "package_path": get_package_repository_path(), + "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "pythonwrench": pythonwrench.__version__, + "pythonwrench_path": get_pythonwrench_repository_path(), } - return install_info -def get_package_repository_path() -> str: +def get_pythonwrench_repository_path() -> str: """Return the absolute path where the source code of this package is installed.""" return str(Path(__file__).parent.parent.parent) diff --git a/src/pythonwrench/functools.py b/src/pythonwrench/functools.py index f30f8d1..343f392 100644 --- a/src/pythonwrench/functools.py +++ b/src/pythonwrench/functools.py @@ -8,6 +8,7 @@ Callable, Generic, Iterable, + Optional, Tuple, TypeVar, overload, @@ -113,7 +114,9 @@ def __len__(self) -> int: def filter_and_call( - fn: Callable[..., T], _fill_all_arguments: bool = False, **kwargs: Any + fn: Callable[..., T], + _fill_all_arguments: bool = False, + **kwargs: Any, ) -> T: """Call object only with the valid keyword arguments. Non-valid arguments are ignored. @@ -158,7 +161,12 @@ def filter_and_call( return result -def function_alias(alternative: Callable[P, U]) -> Callable[..., Callable[P, U]]: +def function_alias( + alternative: Callable[P, U], + *, + pre_fn: Optional[Callable[..., Any]] = None, + post_fn: Optional[Callable[..., Any]] = None, +) -> Callable[..., Callable[P, U]]: """Decorator to wrap function aliases. Example @@ -173,7 +181,7 @@ def function_alias(alternative: Callable[P, U]) -> Callable[..., Callable[P, U]] ... "bbb" """ - return _decorator_factory(alternative) + return _decorator_factory(alternative, pre_fn=pre_fn, post_fn=post_fn) def identity(x: T, **kwargs) -> T: diff --git a/src/pythonwrench/importlib.py b/src/pythonwrench/importlib.py index b856609..b5756d4 100644 --- a/src/pythonwrench/importlib.py +++ b/src/pythonwrench/importlib.py @@ -153,27 +153,6 @@ def reload_editable_packages(*, verbose: int = 0) -> List[ModuleType]: ) -class Placeholder: - """Placeholder object. All instances attributes always returns the object itself.""" - - def __init__(self, *args, **kwargs) -> None: ... - - def __getattr__(self, name: str) -> Any: - return self - - def __call__(self, *args, **kwargs) -> Any: - return self - - def __getitem__(self, *args, **kwargs) -> Any: - return self - - def __eq__(self, other) -> Any: - return self == other - - def __ne__(self, other) -> Any: - return self != other - - def requires_packages( arg0: Union[Iterable[str], str], /, @@ -213,3 +192,42 @@ def _impl(*args: P.args, **kwargs: P.kwargs) -> T: return _impl return _wrap + + +class Placeholder: + """Placeholder object. All instances attributes always returns the object itself.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__() + self.__excluded_self_attrs = [ + "__file__", + "__eq__", + "__ne__", + "__str__", + "__repr__", + ] + + def __getattr__(self, name: str) -> Any: + if name in self.__excluded_self_attrs: + return self.__getattribute__(name) + else: + return self + + def __call__(self, *args, **kwargs) -> Any: + return self + + def __getitem__(self, *args, **kwargs) -> Any: + return self + + +class ModulePlaceholder(ModuleType, Placeholder): + def __init__(self, *args, **kwargs) -> None: + Placeholder.__init__(self, *args, **kwargs) + ModuleType.__init__(self, *args, **kwargs) + + +def import_if_available(name: str) -> ModuleType: + if is_available_package(name): + return __import__(name) + else: + return ModulePlaceholder(name) diff --git a/src/pythonwrench/typing/checks.py b/src/pythonwrench/typing/checks.py index 65e40bf..e166451 100644 --- a/src/pythonwrench/typing/checks.py +++ b/src/pythonwrench/typing/checks.py @@ -199,7 +199,10 @@ def isinstance_generic( return False if check_only_first: - return isinstance_generic(next(iter(obj)), args[0]) + try: + return isinstance_generic(next(iter(obj)), args[0]) + except StopIteration: + return True else: return all(isinstance_generic(xi, args[0]) for xi in obj) diff --git a/src/pythonwrench/warnings.py b/src/pythonwrench/warnings.py index 3385021..f4a04de 100644 --- a/src/pythonwrench/warnings.py +++ b/src/pythonwrench/warnings.py @@ -7,7 +7,7 @@ from typing_extensions import ParamSpec -from pythonwrench._core import _decorator_factory +from pythonwrench._core import _decorator_factory, return_none P = ParamSpec("P") U = TypeVar("U") @@ -46,15 +46,21 @@ def deprecated_alias( alternative: Callable[P, U], msg_fmt: str = "Deprecated call to '{fn_name}', use '{alternative_name}' instead.", warn_fn: Callable[[str], Any] = partial(warn_once, category=DeprecationWarning), + *, + pre_fn: Optional[Callable[..., Any]] = None, + post_fn: Optional[Callable[..., Any]] = None, ) -> Callable[..., Callable[P, U]]: """Decorator to wrap deprecated function aliases.""" alternative_name = alternative.__name__ if alternative is not None else "None" + if pre_fn is None: + pre_fn = return_none - def pre_fn(fn, *args, **kwargs): + def inner_pre_fn(fn, *args, **kwargs) -> None: msg = msg_fmt.format(fn_name=fn.__name__, alternative_name=alternative_name) warn_fn(msg) + pre_fn(fn, *args, **kwargs) - return _decorator_factory(alternative, pre_fn=pre_fn) + return _decorator_factory(alternative, pre_fn=inner_pre_fn, post_fn=post_fn) @overload @@ -83,14 +89,19 @@ def deprecated_function( *, msg_fmt: str = "Deprecated call to '{fn_name}'.", warn_fn: Callable[[str], Any] = partial(warn_once, category=DeprecationWarning), + pre_fn: Optional[Callable[..., Any]] = None, + post_fn: Optional[Callable[..., Any]] = None, ) -> Callable: """Decorator to wrap deprecated functions.""" + if pre_fn is None: + pre_fn = return_none - def pre_fn(fn, *args, **kwargs): + def inner_pre_fn(fn, *args, **kwargs): msg = msg_fmt.format(fn_name=fn.__qualname__) warn_fn(msg) + pre_fn(fn, *args, **kwargs) - decorator = _decorator_factory(None, pre_fn=pre_fn) + decorator = _decorator_factory(None, pre_fn=inner_pre_fn, post_fn=post_fn) if fn is None: return decorator else: diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 146a630..0fcd721 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -130,6 +130,25 @@ class D: with self.assertRaises(SystemExit): _ = parse_args_using_dataclass(D, args=["--arg_d", "1.1"]) + def test_parse_args_using_dataclass_example_4(self) -> None: + @dataclass + class A: + nums: List[int] = field(default_factory=list) + + target = A(nums=[1, -1, 10]) + + nums_args = list(map(str, target.nums)) + result = parse_args_using_dataclass( + A, args=["--nums"] + nums_args, list_parsing="argparse" + ) + assert target == result + + nums_args = [f"[{','.join(map(str, target.nums))}]"] + result = parse_args_using_dataclass( + A, args=["--nums"] + nums_args, list_parsing="brackets" + ) + assert target == result + if __name__ == "__main__": unittest.main() diff --git a/tests/test_typing.py b/tests/test_typing.py index 957ddd7..17f0845 100644 --- a/tests/test_typing.py +++ b/tests/test_typing.py @@ -22,10 +22,10 @@ ) from unittest import TestCase -import pandas as pd import typing_extensions from typing_extensions import NotRequired +from pythonwrench.importlib import import_if_available, is_available_package from pythonwrench.typing import ( NoneType, check_args_types, @@ -39,6 +39,8 @@ isinstance_generic, ) +pd = import_if_available("pandas") + class ExampleDict(TypedDict): a: int @@ -125,7 +127,8 @@ def test_is_special_form(self) -> None: assert is_special_form(Any) assert is_special_form(typing_extensions.Any) - assert not is_special_form(pd.DataFrame({"a": [1, 2, 3]})) + if is_available_package("pandas"): + assert not is_special_form(pd.DataFrame({"a": [1, 2, 3]})) class TestIsInstanceGuard(TestCase): @@ -314,6 +317,12 @@ def test_callable(self) -> None: with self.assertRaises(NotImplementedError): assert isinstance_generic(Path, Callable[[str], Path]) + def test_check_only_first(self) -> None: + assert isinstance_generic([1, 2], List[int], check_only_first=True) + assert isinstance_generic([1, "2", 1], List[int], check_only_first=True) + assert isinstance_generic([], List[int], check_only_first=True) + assert isinstance_generic([1, 2], List, check_only_first=True) + class TestCheckArgsType(TestCase): def test_example_1(self) -> None: