diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a1247..f014958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,20 @@ All notable changes to this project will be documented in this file. +## [0.6.7] 2026-09-07 +### Added +- `on_error` and `default` arguments to `load_json`, `load_pickle`, `load_jsonl` functions. +- `posonly_for_required` argument for `parse_args_using_dataclass`. + +### Fixed +- `setup_logging_level` when stderr is not set in root logger. + + ## [0.6.6] 2026-08-20 ### Fixed - `setup_logging_level` isinstance check. + ## [0.6.5] 2026-08-20 ### Added - `return_single_namespace` option for `parse_args_using_dataclass` function. diff --git a/CITATION.cff b/CITATION.cff index d361872..5814155 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -17,5 +17,5 @@ keywords: - tools - utilities license: MIT -version: 0.6.6 -date-released: '2026-08-20' +version: 0.6.7 +date-released: '2026-09-07' diff --git a/src/pythonwrench/__init__.py b/src/pythonwrench/__init__.py index 7075833..1a0b78c 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.6" +__version__ = "0.6.7" from typing import TYPE_CHECKING diff --git a/src/pythonwrench/argparse/dataclass_.py b/src/pythonwrench/argparse/dataclass_.py index 093f703..b9c10a2 100644 --- a/src/pythonwrench/argparse/dataclass_.py +++ b/src/pythonwrench/argparse/dataclass_.py @@ -58,6 +58,7 @@ def parse_args_using_dataclass( bool_action: BoolAction = "store", add_dashed_arg: bool = True, return_single_namespace: Literal[True], + posonly_for_required: bool = False, ) -> Namespace: ... @@ -71,6 +72,7 @@ def parse_args_using_dataclass( bool_action: BoolAction = "store", add_dashed_arg: bool = True, return_single_namespace: Literal[False] = False, + posonly_for_required: bool = False, ) -> T_DataclassInstance: ... @@ -86,6 +88,7 @@ def parse_args_using_dataclass( bool_action: BoolAction = "store", add_dashed_arg: bool = True, return_single_namespace: Literal[False] = False, + posonly_for_required: bool = False, ) -> Tuple[ T_DataclassInstance, T_DataclassInstance_2, @@ -105,6 +108,7 @@ def parse_args_using_dataclass( bool_action: BoolAction = "store", add_dashed_arg: bool = True, return_single_namespace: Literal[False] = False, + posonly_for_required: bool = False, ) -> Tuple[ T_DataclassInstance, T_DataclassInstance_2, @@ -126,6 +130,7 @@ def parse_args_using_dataclass( bool_action: BoolAction = "store", add_dashed_arg: bool = True, return_single_namespace: Literal[False] = False, + posonly_for_required: bool = False, ) -> Tuple[ T_DataclassInstance, T_DataclassInstance_2, @@ -149,6 +154,7 @@ def parse_args_using_dataclass( bool_action: BoolAction = "store", add_dashed_arg: bool = True, return_single_namespace: Literal[False] = False, + posonly_for_required: bool = False, ) -> Tuple[ T_DataclassInstance, T_DataclassInstance_2, @@ -167,6 +173,7 @@ def parse_args_using_dataclass( bool_action: BoolAction = "store", add_dashed_arg: bool = True, return_single_namespace: bool = False, + posonly_for_required: bool = False, ) -> Union[ DataclassInstance, Tuple[DataclassInstance, ...], @@ -187,6 +194,7 @@ def parse_args_using_dataclass( list_parsing=list_parsing, bool_action=bool_action, add_dashed_arg=add_dashed_arg, + posonly_for_required=posonly_for_required, ) assert parser is not None @@ -223,17 +231,23 @@ def add_dataclass_fields_to_parser( list_parsing: ListParsing = "argparse", bool_action: BoolAction = "store", add_dashed_arg: bool = True, + posonly_for_required: bool = False, ) -> ArgumentParser: """Perform the add dataclass fields to parser operation.""" + if posonly_for_required: + prefix = "" + else: + prefix = "--" + if parser is None: parser = ArgumentParser() for field in fields(dataclass_type): kwds = {} - posargs = [f"--{field.name}"] + posargs = [f"{prefix}{field.name}"] if add_dashed_arg and "_" in field.name: dashed_arg_name = field.name.replace("_", "-") - posargs.append(f"--{dashed_arg_name}") + posargs.append(f"{prefix}{dashed_arg_name}") if field.default is MISSING and field.default_factory is MISSING: if bool_action != "store" and field.type is bool: diff --git a/src/pythonwrench/logging.py b/src/pythonwrench/logging.py index 2835f5d..c3cf5c9 100644 --- a/src/pythonwrench/logging.py +++ b/src/pythonwrench/logging.py @@ -148,7 +148,7 @@ def setup_logging_level( try: index = streams.index(handler.stream) - except IndexError: + except ValueError: continue handler.setFormatter(fmt) diff --git a/src/pythonwrench/serialization/_core.py b/src/pythonwrench/serialization/_core.py index d593194..a28bde0 100644 --- a/src/pythonwrench/serialization/_core.py +++ b/src/pythonwrench/serialization/_core.py @@ -5,10 +5,13 @@ from io import TextIOWrapper from pathlib import Path from typing import ( + Literal, Union, overload, ) +OnError = Literal["raise", "default"] + @overload def _setup_output_fpath( diff --git a/src/pythonwrench/serialization/csv.py b/src/pythonwrench/serialization/csv.py index 97871e0..d235d1a 100644 --- a/src/pythonwrench/serialization/csv.py +++ b/src/pythonwrench/serialization/csv.py @@ -73,7 +73,9 @@ def dump_csv( **csv_writer_kwds, ) - if isinstance(file, (str, Path, PathLike)): + if not overwrite and isinstance(file, (str, Path, PathLike)): + return content + elif isinstance(file, (str, Path, PathLike)): file = _setup_output_fpath(file, overwrite=overwrite, make_parents=make_parents) with open(file, "w") as opened_file: opened_file.write(content) @@ -152,7 +154,9 @@ def save_csv( replace_newline_by: Replace newline character to avoid newline in CSV content. defaults to "\\n". \*\*csv_writer_kwds: Others optional arguments passed to CSV writer object. """ - if isinstance(file, (str, Path, PathLike)): + if not overwrite and isinstance(file, (str, Path, PathLike)): + return None + elif isinstance(file, (str, Path, PathLike)): file = _setup_output_fpath(file, overwrite=overwrite, make_parents=make_parents) file = open(file, "w") close = True diff --git a/src/pythonwrench/serialization/json.py b/src/pythonwrench/serialization/json.py index c759d1e..d5c6d75 100644 --- a/src/pythonwrench/serialization/json.py +++ b/src/pythonwrench/serialization/json.py @@ -3,13 +3,14 @@ import json from io import StringIO, TextIOBase +from json import JSONDecodeError from os import PathLike from pathlib import Path -from typing import Any, Optional, Union +from typing import Any, Optional, Union, get_args from pythonwrench.cast import as_builtin from pythonwrench.functools import function_alias -from pythonwrench.serialization._core import _setup_output_fpath +from pythonwrench.serialization._core import OnError, _setup_output_fpath # -- Dump / Save / Serialize content to JSON -- @@ -50,7 +51,9 @@ def dump_json( **json_dumps_kwds, ) - if isinstance(file, (str, Path, PathLike)): + if not overwrite and isinstance(file, (str, Path, PathLike)): + return content + elif isinstance(file, (str, Path, PathLike)): file = _setup_output_fpath(file, overwrite=overwrite, make_parents=make_parents) with open(file, "w") as opened_file: opened_file.write(content) @@ -146,6 +149,9 @@ def _serialize_json( def load_json( file: Union[str, Path, PathLike, TextIOBase], /, + *, + on_error: OnError = "raise", + default: Any = None, **json_loads_kwds, ) -> Any: """Load json.""" @@ -155,16 +161,33 @@ def load_json( else: close = False - data = _parse_json(file, **json_loads_kwds) + data = _parse_json( + file, + on_error=on_error, + default=default, + **json_loads_kwds, + ) if close: file.close() return data -def loads_json(content: str, /, **json_loads_kwds) -> Any: - """Load s json.""" +def loads_json( + content: str, + /, + *, + on_error: OnError = "raise", + default: Any = None, + **json_loads_kwds, +) -> Any: + """Parse JSON string to object.""" with StringIO(content) as buffer: - return _parse_json(buffer, **json_loads_kwds) + return _parse_json( + buffer, + on_error=on_error, + default=default, + **json_loads_kwds, + ) @function_alias(load_json) @@ -173,6 +196,21 @@ def read_json(*args, **kwargs): ... -def _parse_json(buffer: TextIOBase, **json_loads_kwds) -> Any: +def _parse_json( + buffer: TextIOBase, + *, + on_error: OnError, + default: Any = None, + **json_loads_kwds, +) -> Any: """Parse json.""" - return json.load(buffer, **json_loads_kwds) + try: + return json.load(buffer, **json_loads_kwds) + except JSONDecodeError as err: + if on_error == "raise": + raise err + elif on_error == "default": + return default + else: + msg = f"Invalid argument {on_error=}. (expected one of {get_args(OnError)})" + raise ValueError(msg) diff --git a/src/pythonwrench/serialization/jsonl.py b/src/pythonwrench/serialization/jsonl.py index 578c8e7..7444ba9 100644 --- a/src/pythonwrench/serialization/jsonl.py +++ b/src/pythonwrench/serialization/jsonl.py @@ -4,12 +4,12 @@ from io import StringIO, TextIOBase from os import PathLike from pathlib import Path -from typing import Union +from typing import Any, Union from pythonwrench.cast import as_builtin from pythonwrench.functools import function_alias from pythonwrench.semver import Version -from pythonwrench.serialization._core import _setup_output_fpath +from pythonwrench.serialization._core import OnError, _setup_output_fpath from pythonwrench.serialization.json import ( _serialize_json, dumps_json, @@ -63,7 +63,9 @@ def dump_jsonl( **json_dumps_kwds, ) - if isinstance(file, (str, Path, PathLike)): + if not overwrite and isinstance(file, (str, Path, PathLike)): + return content + elif isinstance(file, (str, Path, PathLike)): file = _setup_output_fpath(file, overwrite=overwrite, make_parents=make_parents) with open(file, "w") as opened_file: opened_file.write(content) @@ -113,7 +115,9 @@ def save_jsonl( **json_dumps_kwds, ) -> None: """Save jsonl.""" - if isinstance(file, (str, Path, PathLike)): + if not overwrite and isinstance(file, (str, Path, PathLike)): + return None + elif isinstance(file, (str, Path, PathLike)): file = _setup_output_fpath(file, overwrite=overwrite, make_parents=make_parents) file = open(file, "w") close = True @@ -163,6 +167,9 @@ def _serialize_jsonl( def load_jsonl( file: Union[str, Path, PathLike, TextIOBase], /, + *, + on_error: OnError = "raise", + default: Any = None, **json_loads_kwds, ) -> list: """Load jsonl.""" @@ -172,16 +179,25 @@ def load_jsonl( else: close = False - data = _parse_jsonl(file, **json_loads_kwds) + data = _parse_jsonl(file, on_error=on_error, default=default, **json_loads_kwds) if close: file.close() return data -def loads_jsonl(content: str, /, **json_loads_kwds) -> list: +def loads_jsonl( + content: str, + /, + *, + on_error: OnError = "raise", + default: Any = None, + **json_loads_kwds, +) -> list: """Load s jsonl.""" with StringIO(content) as buffer: - return _parse_jsonl(buffer, **json_loads_kwds) + return _parse_jsonl( + buffer, on_error=on_error, default=default, **json_loads_kwds + ) @function_alias(load_json) @@ -190,7 +206,14 @@ def read_jsonl(*args, **kwargs): ... -def _parse_jsonl(buffer: TextIOBase, **json_loads_kwds) -> list: +def _parse_jsonl( + buffer: TextIOBase, + /, + *, + on_error: OnError = "raise", + default: Any = None, + **json_loads_kwds, +) -> list: """Parse jsonl.""" data_lst = [] while True: @@ -198,7 +221,9 @@ def _parse_jsonl(buffer: TextIOBase, **json_loads_kwds) -> list: if content == "": break content = _removesuffix(content, "\n") - data = loads_json(content, **json_loads_kwds) + data = loads_json( + content, on_error=on_error, default=default, **json_loads_kwds + ) data_lst.append(data) return data_lst diff --git a/src/pythonwrench/serialization/pickle.py b/src/pythonwrench/serialization/pickle.py index 296ce4c..efdcad5 100644 --- a/src/pythonwrench/serialization/pickle.py +++ b/src/pythonwrench/serialization/pickle.py @@ -6,11 +6,12 @@ from io import BytesIO from os import PathLike from pathlib import Path -from typing import Any, BinaryIO, Union +from pickle import UnpicklingError +from typing import Any, BinaryIO, Union, get_args from pythonwrench.cast import as_builtin from pythonwrench.functools import function_alias -from pythonwrench.serialization._core import _setup_output_fpath +from pythonwrench.serialization._core import OnError, _setup_output_fpath # -- Dump / Save / Serialize content to PICKLE -- @@ -44,7 +45,9 @@ def dump_pickle( **pkl_dumps_kwds, ) - if isinstance(file, (str, Path, PathLike)): + if not overwrite and isinstance(file, (str, Path, PathLike)): + return content + elif isinstance(file, (str, Path, PathLike)): file = _setup_output_fpath(file, overwrite=overwrite, make_parents=make_parents) with open(file, "wb") as opened_file: opened_file.write(content) @@ -107,7 +110,9 @@ def save_pickle( to_builtins: If True, converts data to builtin equivalent before saving. defaults to False. \*\*pkl_dumps_kwds: Other args passed to `pickle.dumps`. """ - if isinstance(file, (str, Path, PathLike)): + if not overwrite and isinstance(file, (str, Path, PathLike)): + return None + elif isinstance(file, (str, Path, PathLike)): file = _setup_output_fpath(file, overwrite=overwrite, make_parents=make_parents) file = open(file, "wb") close = True @@ -145,7 +150,14 @@ def _serialize_pickle( # -- Load / Read / Parse PICKLE content -- -def load_pickle(file: Union[str, Path, BinaryIO], /, **pkl_loads_kwds) -> Any: +def load_pickle( + file: Union[str, Path, BinaryIO], + /, + *, + on_error: OnError = "raise", + default: Any = None, + **pkl_loads_kwds, +) -> Any: r"""Load content from PICKLE file. Args: @@ -158,13 +170,20 @@ def load_pickle(file: Union[str, Path, BinaryIO], /, **pkl_loads_kwds) -> Any: else: close = False - data = _parse_pickle(file, **pkl_loads_kwds) + data = _parse_pickle(file, on_error=on_error, default=default, **pkl_loads_kwds) if close: file.close() return data -def loads_pickle(content: bytes, /, **pkl_loads_kwds) -> Any: +def loads_pickle( + content: bytes, + /, + *, + on_error: OnError = "raise", + default: Any = None, + **pkl_loads_kwds, +) -> Any: r"""Load content from raw bytes. Args: @@ -172,7 +191,12 @@ def loads_pickle(content: bytes, /, **pkl_loads_kwds) -> Any: \*\*pkl_loads_kwds: Other args passed to `pickle.loads`. """ with BytesIO(content) as buffer: - return _parse_pickle(buffer, **pkl_loads_kwds) + return _parse_pickle( + buffer, + on_error=on_error, + default=default, + **pkl_loads_kwds, + ) @function_alias(load_pickle) @@ -181,6 +205,22 @@ def read_pickle(*args, **kwargs): ... -def _parse_pickle(buffer: BinaryIO, **pkl_loads_kwds) -> Any: +def _parse_pickle( + buffer: BinaryIO, + /, + *, + on_error: OnError = "raise", + default: Any = None, + **pkl_loads_kwds, +) -> Any: """Parse pickle.""" - return pickle.load(buffer, **pkl_loads_kwds) + try: + return pickle.load(buffer, **pkl_loads_kwds) + except UnpicklingError as err: + if on_error == "raise": + raise err + elif on_error == "default": + return default + else: + msg = f"Invalid argument {on_error=}. (expected one of {get_args(OnError)})" + raise ValueError(msg) diff --git a/src/pythonwrench/warnings.py b/src/pythonwrench/warnings.py index 6db0653..0d62b56 100644 --- a/src/pythonwrench/warnings.py +++ b/src/pythonwrench/warnings.py @@ -20,7 +20,7 @@ def warn_once( stacklevel: int = 1, source: Any = None, ) -> None: - """Perform the warn once operation.""" + """Warn message once using warnings module.""" ... @@ -31,7 +31,7 @@ def warn_once( stacklevel: int = 1, source: Any = None, ) -> None: - """Perform the warn once operation.""" + """Warn message once using warnings module.""" ... diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 2d5b930..9521f8b 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -331,6 +331,16 @@ class Cfg: ) assert cfg == target + def test_parse_list(self) -> None: + @dataclass + class Cfg: + constraints: Iterable[Optional[str]] + + cfg = parse_args_using_dataclass( + Cfg, args=["--constraints", "[none]"], list_parsing="brackets" + ) + assert cfg == Cfg([None]) + if __name__ == "__main__": unittest.main()