diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 52c0f23..be87aec 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,7 +15,8 @@ env: # Cancel workflow if a new push occurs concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # Never interrupt a main-branch publication between PyPI and GitHub. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: build-and-test: @@ -67,3 +68,127 @@ jobs: - name: Show coverage results run: | uv run coverage report -m + + release-check: + name: Check whether a release is needed + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build-and-test + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + version: ${{ steps.release.outputs.version }} + should-publish: ${{ steps.release.outputs.should-publish }} + + steps: + - name: Checkout repository and tags + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Validate release metadata + id: release + shell: python + run: | + import os + import pathlib + import re + + init_text = pathlib.Path("src/pythonwrench/__init__.py").read_text(encoding="utf-8") + version_match = re.search( + r'^__version__\s*=\s*["\'](\d+\.\d+\.\d+)["\']\s*$', + init_text, + flags=re.MULTILINE, + ) + if version_match is None: + raise SystemExit( + "__version__ must be a stable X.Y.Z release in " + "src/pythonwrench/__init__.py" + ) + + version = version_match.group(1) + changelog = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8") + heading = rf"^## \[{re.escape(version)}\](?:[ \t]+[^\r\n]*)?$" + if re.search(heading, changelog, flags=re.MULTILINE) is None: + raise SystemExit(f"CHANGELOG.md has no release section for {version}") + + tag_ref = pathlib.Path(".git/refs/tags") / f"v{version}" + tag_exists = tag_ref.exists() + if not tag_exists: + import subprocess + + tag_exists = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/v{version}"], + check=False, + ).returncode == 0 + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + print(f"version={version}", file=output) + print(f"should-publish={'false' if tag_exists else 'true'}", file=output) + + if tag_exists: + print(f"v{version} already exists; publication will be skipped.") + else: + print(f"v{version} is ready to publish.") + + publish: + name: Publish to PyPI and GitHub Releases + if: needs.release-check.outputs.should-publish == 'true' + needs: release-check + runs-on: ubuntu-24.04 + environment: + name: pypi + url: https://pypi.org/project/pythonwrench/${{ needs.release-check.outputs.version }}/ + permissions: + contents: write + id-token: write + env: + RELEASE_VERSION: ${{ needs.release-check.outputs.version }} + + steps: + - name: Checkout tested commit + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + + - name: Build distributions + run: uv build + + - name: Validate distributions + run: uvx twine check dist/* + + - name: Extract release notes from changelog + shell: python + run: | + import os + import pathlib + import re + + version = os.environ["RELEASE_VERSION"] + changelog = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8") + section = re.search( + rf"^## \[{re.escape(version)}\](?:[ \t]+[^\r\n]*)?\n(.*?)(?=^## \[|\Z)", + changelog, + flags=re.MULTILINE | re.DOTALL, + ) + if section is None: + raise SystemExit(f"Could not extract changelog notes for {version}") + pathlib.Path("release-notes.md").write_text( + section.group(1).strip() + "\n", + encoding="utf-8", + ) + + - name: Publish distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + + - name: Create GitHub Release and tag + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v${RELEASE_VERSION}" dist/* \ + --target "${GITHUB_SHA}" \ + --title "v${RELEASE_VERSION}" \ + --notes-file release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 44225bc..ff461d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.6.4] 2026-08-17 +### Added +- Function `parse_args_using_dataclass` can now handle multiple dataclass at once. +- Function `parse_args_using_dataclass` now has option `bool_action`. + + ## [0.6.3] 2026-08-06 ### Added - Handle `Enum`, `Path`, and add `register_parser_fn` decorator for `parse_to_type`. diff --git a/CITATION.cff b/CITATION.cff index 21d40a0..5b0fcb7 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -17,5 +17,5 @@ keywords: - tools - utilities license: MIT -version: 0.6.3 -date-released: '2026-08-06' +version: 0.6.4 +date-released: '2026-08-17' diff --git a/src/pythonwrench/__init__.py b/src/pythonwrench/__init__.py index 52b8422..2854194 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.3" +__version__ = "0.6.4" from typing import TYPE_CHECKING diff --git a/src/pythonwrench/argparse/dataclass_.py b/src/pythonwrench/argparse/dataclass_.py index 5257dd8..4e8c74d 100644 --- a/src/pythonwrench/argparse/dataclass_.py +++ b/src/pythonwrench/argparse/dataclass_.py @@ -7,69 +7,190 @@ Any, Dict, Iterable, + Literal, Optional, + Tuple, Type, TypeVar, + Union, get_args, - get_origin, + overload, ) from pythonwrench.argparse.parsers import ( ListParsing, - get_parse_fn, -) -from pythonwrench.functools import filter_and_call -from pythonwrench.typing.checks import ( _is_iterable_type_like, _is_literal_type, - _is_optional_type, - _is_union_type, + _search_parse_fn, ) +from pythonwrench.functools import filter_and_call from pythonwrench.typing.classes import ( Dataclass, DataclassInstance, - NoneType, ) from pythonwrench.warnings import deprecated_alias +try: + from argparse import BooleanOptionalAction # type: ignore +except ImportError: + + class BooleanOptionalAction: ... + + T_Dataclass = TypeVar("T_Dataclass", bound=Dataclass) T_DataclassInstance = TypeVar("T_DataclassInstance", bound=DataclassInstance) +T_DataclassInstance_2 = TypeVar("T_DataclassInstance_2", bound=DataclassInstance) +T_DataclassInstance_3 = TypeVar("T_DataclassInstance_3", bound=DataclassInstance) +T_DataclassInstance_4 = TypeVar("T_DataclassInstance_4", bound=DataclassInstance) +T_DataclassInstance_5 = TypeVar("T_DataclassInstance_5", bound=DataclassInstance) + +_BoolActionName = Literal["store", "store_true", "store_false", "bool_optional"] +BoolAction = Union[_BoolActionName, Type[BooleanOptionalAction]] + + +@overload +def parse_args_using_dataclass( + dataclass_type: Type[T_DataclassInstance], + *, + args: Optional[Iterable[str]] = None, + parser: Optional[ArgumentParser] = None, + list_parsing: ListParsing = "argparse", + bool_action: BoolAction = "store", + add_dashed_arg: bool = True, +) -> T_DataclassInstance: ... + + +@overload +def parse_args_using_dataclass( + dataclass_type: Type[T_DataclassInstance], + dataclass_type_2: Type[T_DataclassInstance_2], + /, + *, + args: Optional[Iterable[str]] = None, + parser: Optional[ArgumentParser] = None, + list_parsing: ListParsing = "argparse", + bool_action: BoolAction = "store", + add_dashed_arg: bool = True, +) -> Tuple[ + T_DataclassInstance, + T_DataclassInstance_2, +]: ... -_SCALARS_TARGET_TYPES = (str, int, float, None, NoneType, bool) + +@overload +def parse_args_using_dataclass( + dataclass_type: Type[T_DataclassInstance], + dataclass_type_2: Type[T_DataclassInstance_2], + dataclass_type_3: Type[T_DataclassInstance_3], + /, + *, + args: Optional[Iterable[str]] = None, + parser: Optional[ArgumentParser] = None, + list_parsing: ListParsing = "argparse", + bool_action: BoolAction = "store", + add_dashed_arg: bool = True, +) -> Tuple[ + T_DataclassInstance, + T_DataclassInstance_2, + T_DataclassInstance_3, +]: ... +@overload def parse_args_using_dataclass( dataclass_type: Type[T_DataclassInstance], + dataclass_type_2: Type[T_DataclassInstance_2], + dataclass_type_3: Type[T_DataclassInstance_3], + dataclass_type_4: Type[T_DataclassInstance_4], + /, *, args: Optional[Iterable[str]] = None, parser: Optional[ArgumentParser] = None, list_parsing: ListParsing = "argparse", + bool_action: BoolAction = "store", add_dashed_arg: bool = True, -) -> T_DataclassInstance: +) -> Tuple[ + T_DataclassInstance, + T_DataclassInstance_2, + T_DataclassInstance_3, + T_DataclassInstance_4, +]: ... + + +@overload +def parse_args_using_dataclass( + dataclass_type: Type[T_DataclassInstance], + dataclass_type_2: Type[T_DataclassInstance_2], + dataclass_type_3: Type[T_DataclassInstance_3], + dataclass_type_4: Type[T_DataclassInstance_4], + dataclass_type_5: Type[T_DataclassInstance_5], + /, + *, + args: Optional[Iterable[str]] = None, + parser: Optional[ArgumentParser] = None, + list_parsing: ListParsing = "argparse", + bool_action: BoolAction = "store", + add_dashed_arg: bool = True, +) -> Tuple[ + T_DataclassInstance, + T_DataclassInstance_2, + T_DataclassInstance_3, + T_DataclassInstance_4, + T_DataclassInstance_5, +]: ... + + +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, +) -> Union[ + DataclassInstance, + Tuple[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. """ init_parser = parser - parser = add_dataclass_fields_to_parser( - dataclass_type, - parser=parser, - list_parsing=list_parsing, - add_dashed_arg=add_dashed_arg, - ) + dataclass_types = (dataclass_type,) + dataclass_types + del dataclass_type + + for dataclass_type_i in dataclass_types: + parser = add_dataclass_fields_to_parser( + dataclass_type_i, + parser=parser, + list_parsing=list_parsing, + bool_action=bool_action, + add_dashed_arg=add_dashed_arg, + ) + assert parser is not None + parsed, argv = parser.parse_known_args(args) if len(argv) > 0: - raise ValueError(f"Found {len(argv)} unknown arguments: {argv}.") + msg = f"Found {len(argv)} unknown arguments: {argv}." + raise ValueError(msg) - if init_parser is None: - instance = dataclass_type(**parsed.__dict__) + dataclass_insts = [] + for dataclass_type_i in dataclass_types: + if init_parser is None and len(dataclass_types) == 1: + instance = dataclass_type_i(**parsed.__dict__) + else: + instance = filter_and_call( + dataclass_type_i, + _fill_all_arguments=True, + **parsed.__dict__, + ) + dataclass_insts.append(instance) + + if len(dataclass_insts) == 1: + return dataclass_insts[0] else: - instance = filter_and_call( - dataclass_type, - _fill_all_arguments=True, - **parsed.__dict__, - ) - return instance + return tuple(dataclass_insts) def add_dataclass_fields_to_parser( @@ -77,6 +198,7 @@ def add_dataclass_fields_to_parser( *, parser: Optional[ArgumentParser], list_parsing: ListParsing = "argparse", + bool_action: BoolAction = "store", add_dashed_arg: bool = True, ) -> ArgumentParser: """Perform the add dataclass fields to parser operation.""" @@ -91,7 +213,11 @@ def add_dataclass_fields_to_parser( posargs.append(f"--{dashed_arg_name}") if field.default is MISSING and field.default_factory is MISSING: + if bool_action != "store" and field.type is bool: + msg = f"Invalid arguments: boolean '{field.name}' without default value is incompatible with {bool_action=}." + raise RuntimeError(msg) kwds["required"] = True + elif field.default is not MISSING: kwds["default"] = field.default elif field.default_factory is not MISSING: @@ -101,7 +227,7 @@ def add_dataclass_fields_to_parser( raise ValueError(msg) try: - inner_kwds = _get_kwds_for_type(field.type, list_parsing) + inner_kwds = _get_kwds_for_type(field.type, list_parsing, bool_action) except (ValueError, TypeError, RuntimeError) as err: msg = f"Invalid field {field.name}: field type '{field.type}' is not supported." raise type(err)(msg) from err @@ -114,74 +240,42 @@ def add_dataclass_fields_to_parser( def _get_kwds_for_type( field_type: Any, - list_parsing: ListParsing = "argparse", + list_parsing: Optional[ListParsing], + bool_action: BoolAction, ) -> Dict[str, Any]: """Perform the get kwds for type operation.""" + if bool_action == "bool_optional": + bool_action = BooleanOptionalAction kwds = {} - type_origin = get_origin(field_type) - type_args = get_args(field_type) - - # sanity checks - if _is_literal_type(field_type): - if not all(type(arg) in _SCALARS_TARGET_TYPES for arg in type_args): - msg = f"Invalid argument {field_type=}. (expected homogeneous types in {type_origin})" - raise TypeError(msg) - - if ( - (field_type in _SCALARS_TARGET_TYPES) - or ( - _is_literal_type(field_type) - or _is_optional_type(field_type) - or _is_union_type(field_type) - ) - or (_is_iterable_type_like(type_origin) and list_parsing == "brackets") - ): - inner_kwds = _get_kwds_for_scalar_type(field_type, field_type, list_parsing) - kwds.update(inner_kwds) + if bool_action != "store" and field_type is bool: + kwds["action"] = bool_action + return kwds - elif _is_iterable_type_like(type_origin): - item_type = type_args[0] - inner_kwds = _get_kwds_for_scalar_type(item_type, field_type, list_parsing) - inner_kwds["nargs"] = "*" - kwds.update(inner_kwds) + elif list_parsing == "argparse" and _is_iterable_type_like(field_type): + type_args = get_args(field_type) + if isinstance(type_args, tuple) and len(type_args) == 1: + item_type = type_args[0] + kwds = _get_kwds_for_type( + item_type, list_parsing=None, bool_action=bool_action + ) + kwds["nargs"] = "*" + return kwds - else: - msg = f"Unsupported type {field_type}. (with {type_origin=})" - raise TypeError(msg) + parse_fn = _search_parse_fn(field_type, list_parsing=list_parsing) - return kwds + if parse_fn is not None: + kwds["type"] = parse_fn + if _is_literal_type(field_type): + kwds["choices"] = get_args(field_type) + return kwds - -def _get_kwds_for_scalar_type( - type_: Any, - from_field_type: Any, - list_parsing: ListParsing, -) -> Dict[str, Any]: - """Perform the get kwds for scalar type operation.""" - kwds = {} - - if ( - type_ in _SCALARS_TARGET_TYPES - or _is_optional_type(type_) - or _is_union_type(type_) - or ( - _is_iterable_type_like(get_origin(from_field_type)) - and list_parsing == "brackets" - ) - ): - pass - - elif _is_literal_type(type_): - type_args = get_args(type_) - kwds["choices"] = type_args else: - msg = f"Unsupported dataclass member type {type_} from {from_field_type}." + msg = ( + f"Unsupported type {field_type}. (with {list_parsing=} and {bool_action=})" + ) raise TypeError(msg) - kwds["type"] = get_parse_fn(type_, list_parsing=list_parsing) # type: ignore - return kwds - # ALIASES @deprecated_alias(add_dataclass_fields_to_parser) diff --git a/src/pythonwrench/argparse/parsers.py b/src/pythonwrench/argparse/parsers.py index d7ffb8b..0b27b68 100644 --- a/src/pythonwrench/argparse/parsers.py +++ b/src/pythonwrench/argparse/parsers.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import re +from collections.abc import Iterable as _RuntimeIterable from enum import Enum from functools import partial from pathlib import Path @@ -17,16 +18,11 @@ TypeVar, Union, get_args, + get_origin, overload, ) from pythonwrench._core import Predicate -from pythonwrench.typing.checks import ( - _is_iterable_type_like, - _is_literal_type, - _is_optional_type, - _is_union_type, -) from pythonwrench.typing.classes import NoneType, UnionType from pythonwrench.warnings import deprecated_alias @@ -38,7 +34,6 @@ UnionType, "Type[Literal]", "Type[Optional]", - Tuple[type, ...], ] ListParsing = Literal["argparse", "brackets"] @@ -51,12 +46,9 @@ _PARSER_REGISTRY: List[Tuple[Union[TargetType, Predicate], Callable]] = [] -class ParseError(ValueError): ... - - @overload def register_parser_fn( - type_: Union[TargetType, Predicate, None], + type_: Union[TargetType[T], Predicate, None], fn: None = None, ) -> Callable[[T_Callable], T_Callable]: """Perform the register parser fn operation.""" @@ -65,7 +57,7 @@ def register_parser_fn( @overload def register_parser_fn( - type_: Union[TargetType, Predicate, None], + type_: Union[TargetType[T], Predicate, None], fn: T_Callable, ) -> T_Callable: """Perform the register parser fn operation.""" @@ -73,7 +65,7 @@ def register_parser_fn( def register_parser_fn( - type_: Union[TargetType, Predicate, None], + type_: Union[TargetType[T], Predicate, None], fn: Optional[Callable] = None, ) -> Callable: """Perform the register parser fn operation.""" @@ -107,7 +99,7 @@ def parse_to_type( - True values: 'True', 'T', 'yes', 'y', '1'. - False values: 'False', 'F', 'no', 'n', '0'. - None values: 'None', 'null' - - Other raises ParseError. + - Other raises ValueError. """ parse_fn = get_parse_fn( target_type, @@ -144,6 +136,16 @@ def get_parse_fn( list_parsing=list_parsing, handle_exception=handle_exception, ) + parse_fn = _search_parse_fn(type_, **kwds) + + if parse_fn is None: + msg = f"Invalid argument {type_=}. (no valid type or typing found in registry)" + raise ValueError(msg) + + return parse_fn + + +def _search_parse_fn(type_: TargetType[T], **kwds) -> Optional[Callable[[str], T]]: if type_ is None: type_ = NoneType @@ -161,11 +163,9 @@ def get_parse_fn( msg = f"Invalid argument {type_or_pred_i=}. (excepted type or predicate function)" raise ValueError(msg) - if parse_fn is None: - msg = f"Invalid argument {type_=}. (no valid type or typing found in registry)" - raise ValueError(msg) + if parse_fn is not None: + parse_fn = partial(parse_fn, **kwds) - parse_fn = partial(parse_fn, **kwds) return parse_fn @@ -189,7 +189,7 @@ def parse_to_bool( return False values = tuple(true_values + false_values) - output = ParseError(f"Invalid argument '{x}'. (expected one of {values})") + output = ValueError(f"Invalid argument '{x}'. (expected one of {values})") return _handle_output(x, handle_exception, output) @@ -251,9 +251,39 @@ def _parse_to_str(x: str, **kwds) -> str: return x +def _is_enum_type(x: Any, **kwds) -> bool: + """Perform the is enum type operation.""" + return isinstance(x, type) and issubclass(x, Enum) + + +def _is_iterable_type_like(x: Any) -> bool: + """Perform the is iterable type like operation.""" + return any(xi in (list, Iterable, _RuntimeIterable) for xi in (x, get_origin(x))) + + +def _is_literal_type(x: Any) -> bool: + """Perform the is literal type operation.""" + origin = get_origin(x) + return origin is Literal + + +def _is_optional_type(x: Any) -> bool: + """Perform the is optional type operation.""" + return getattr(x, "__name__", None) == "Optional" + + +def _is_union_type(x: Any) -> bool: + """Perform the is union type operation.""" + origin = get_origin(x) + return origin == Union or getattr(origin, "__name__", None) in ( + "Union", + "UnionType", + ) + + def _is_enum_for_parsing(x: Any, **kwds) -> bool: """Perform the is enum for parsing operation.""" - return isinstance(x, type) and issubclass(x, Enum) + return _is_enum_type(x) def _is_iterable_type_like_for_parsing( diff --git a/src/pythonwrench/checksum.py b/src/pythonwrench/checksum.py index 53c2876..49da321 100644 --- a/src/pythonwrench/checksum.py +++ b/src/pythonwrench/checksum.py @@ -213,9 +213,9 @@ def checksum_dataclass(x: DataclassInstance, **kwargs) -> int: @register_checksum_fn(datetime) def checksum_datetime(x: datetime, **kwargs) -> int: """Return a checksum for datetime.""" + kwargs = _add_type_checksum_to_accumulator(x, kwargs) return _checksum_iterable( [ - x.__class__, x.year, x.month, x.day, @@ -233,7 +233,8 @@ def checksum_datetime(x: datetime, **kwargs) -> int: @register_checksum_fn(date) def checksum_date(x: date, **kwargs) -> int: """Return a checksum for date.""" - return _checksum_iterable([x.__class__, x.year, x.month, x.day], **kwargs) + kwargs = _add_type_checksum_to_accumulator(x, kwargs) + return _checksum_iterable([x.year, x.month, x.day], **kwargs) @register_checksum_fn(dict) @@ -245,21 +246,21 @@ def checksum_dict(x: dict, **kwargs) -> int: @register_checksum_fn(Enum) def checksum_enum(x: Enum, **kwargs) -> int: """Return a checksum for enum.""" - return _checksum_iterable((x.__class__, x.name, x.value), **kwargs) + kwargs = _add_type_checksum_to_accumulator(x, kwargs) + return _checksum_iterable((x.name, x.value), **kwargs) @register_checksum_fn((list, tuple)) def checksum_list_tuple(x: Union[list, tuple], **kwargs) -> int: """Return a checksum for list tuple.""" + kwargs = _add_type_checksum_to_accumulator(x, kwargs) return _checksum_iterable(x, **kwargs) @register_checksum_fn((set, frozenset)) def checksum_set(x: Union[set, frozenset], **kwargs) -> int: """Return a checksum for set.""" - kwargs["accumulator"] = kwargs.get("accumulator", 0) + _cached_checksum_str( - get_fullname(x) - ) + kwargs = _add_type_checksum_to_accumulator(x, kwargs) # Simply use sum here, order does not matter csum = sum(checksum_any(xi, **kwargs) for xi in x) return csum @@ -312,11 +313,10 @@ def checksum_pattern(x: re.Pattern, **kwargs) -> int: @register_checksum_fn(Path) -def checksum_path(x: Path, **kwargs) -> int: +def checksum_path(x: Path, *, resolve_path: bool = False, **kwargs) -> int: """Return a checksum for path.""" + kwargs["resolve_path"] = resolve_path kwargs = _add_type_checksum_to_accumulator(x, kwargs) - - resolve_path = kwargs.get("resolve_path", False) if isinstance(resolve_path, bool) and resolve_path: x = x.expanduser().resolve() return checksum_str(str(x), **kwargs) diff --git a/src/pythonwrench/functools.py b/src/pythonwrench/functools.py index 09eb917..2ffd22c 100644 --- a/src/pythonwrench/functools.py +++ b/src/pythonwrench/functools.py @@ -143,8 +143,7 @@ def filter_and_call( Arguments: fn: Callable to call. _fill_all_arguments: If True, all arguments of fn must be provided in kwargs. defaults to False. - **kwargs: Superset of arguments to pass to fn. - Name that does not match any argument of fn are ignored. + **kwargs: Superset of arguments to pass to fn. Name that does not match any argument of fn are ignored. Examples: --------- diff --git a/src/pythonwrench/re.py b/src/pythonwrench/re.py index 7c41327..9f154dc 100644 --- a/src/pythonwrench/re.py +++ b/src/pythonwrench/re.py @@ -17,7 +17,7 @@ PatternListLike: TypeAlias = Union[PatternLike, Iterable[PatternLike]] MatchFn = Callable[[PatternLike, str], Any] -MatchName = Literal["search", "mactch"] +MatchName = Literal["search", "match"] MatchLike = Union[MatchFn, MatchName] logger = logging.getLogger(__name__) diff --git a/src/pythonwrench/typing/checks.py b/src/pythonwrench/typing/checks.py index 3b3cc80..2c42f92 100644 --- a/src/pythonwrench/typing/checks.py +++ b/src/pythonwrench/typing/checks.py @@ -6,7 +6,6 @@ import sys import typing from collections.abc import Callable as _RuntimeCallable -from collections.abc import Iterable as _RuntimeIterable from dataclasses import is_dataclass from numbers import Integral from types import FunctionType, MethodType @@ -550,28 +549,3 @@ def _safe_isin(x: Any, targets: Iterable) -> bool: def _is_callable_type(x: Any) -> bool: """Perform the is callable type operation.""" return x in (Callable, _RuntimeCallable) - - -def _is_iterable_type_like(x: Any) -> bool: - """Perform the is iterable type like operation.""" - return any(xi in (list, Iterable, _RuntimeIterable) for xi in (x, get_origin(x))) - - -def _is_literal_type(x: Any) -> bool: - """Perform the is literal type operation.""" - origin = get_origin(x) - return origin is Literal - - -def _is_optional_type(x: Any) -> bool: - """Perform the is optional type operation.""" - return getattr(x, "__name__", None) == "Optional" - - -def _is_union_type(x: Any) -> bool: - """Perform the is union type operation.""" - origin = get_origin(x) - return origin == Union or getattr(origin, "__name__", None) in ( - "Union", - "UnionType", - ) diff --git a/tests/test_argparse.py b/tests/test_argparse.py index cd80290..5630c62 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -2,23 +2,23 @@ # -*- coding: utf-8 -*- import unittest -from enum import Enum, auto from argparse import ArgumentParser from dataclasses import dataclass, field +from enum import Enum, auto from pathlib import Path from typing import Iterable, List, Literal, Optional, Tuple, Union from unittest import TestCase from pythonwrench.argparse import ( - parse_args_using_dataclass, get_parse_fn, - parse_to_type, + parse_args_using_dataclass, parse_to_bool, parse_to_none, parse_to_optional_bool, parse_to_optional_float, parse_to_optional_int, parse_to_optional_str, + parse_to_type, ) from pythonwrench.typing import NoneType @@ -88,6 +88,13 @@ def test_literal(self) -> None: def test_enum(self) -> None: assert parse_to_type("SLEEPING", State) == State.SLEEPING + assert parse_to_type("pending", State) == State.PENDING + + with self.assertRaises(ValueError): + assert parse_to_type("pending", State, case_sensitive=True) == State.PENDING + + with self.assertRaises(ValueError): + assert parse_to_type("PENDING2", State) == State.PENDING class TestDataclassParser(TestCase): @@ -128,7 +135,7 @@ class A: assert output == target - def test_parse_args_using_dataclass_example_3(self) -> None: + def test_parse_args_using_dataclass_example_3a(self) -> None: @dataclass class A: arg_a: List[List[int]] = field(default_factory=list) @@ -136,6 +143,7 @@ class A: with self.assertRaises(TypeError): _ = parse_args_using_dataclass(A, args=[]) + def test_parse_args_using_dataclass_example_3b(self) -> None: @dataclass class B: arg_b: Union[str, List[str]] = "" @@ -143,6 +151,7 @@ class B: with self.assertRaises(SystemExit): _ = parse_args_using_dataclass(B, args=[]) + def test_parse_args_using_dataclass_example_3c(self) -> None: @dataclass class C: arg_c: Tuple[str, ...] = () @@ -150,6 +159,7 @@ class C: with self.assertRaises(TypeError): _ = parse_args_using_dataclass(C, args=[]) + def test_parse_args_using_dataclass_example_3d(self) -> None: @dataclass class D: arg_d: int = 0 @@ -263,6 +273,50 @@ class A: ) assert result == A([1]) # type: ignore + def test_parse_args_using_dataclass_example_7(self) -> None: + @dataclass + class Cfg1: + path: Union[str, Path] + + @dataclass + class Cfg2: + a: int = 0 + b: str = "" + + expected_cfg1 = Cfg1(Path("test/a.txt")) + expected_cfg2 = Cfg2(b="b") + + cfg = parse_args_using_dataclass(Cfg1, args=["--path", str(expected_cfg1.path)]) + assert cfg == expected_cfg1 + + cfg2, cfg1 = parse_args_using_dataclass( + Cfg2, + Cfg1, + args=[ + "--path", + str(expected_cfg1.path), + "--b", + expected_cfg2.b, + ], + ) + assert cfg1 == expected_cfg1 + assert cfg2 == expected_cfg2 + + def test_parse_store_bool(self) -> None: + @dataclass + class Cfg: + test: bool = False + num: int = 0 + create: bool = False + + target = Cfg(False, 10, True) + cfg = parse_args_using_dataclass( + Cfg, + args=["--create", "--num", str(target.num)], + bool_action="store_true", + ) + assert cfg == target + if __name__ == "__main__": unittest.main()