diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f858fc1..233578a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: hooks: - id: vermin-all # specify your target version here, OR in a Vermin config file as usual: - args: ['-t=3.8-', '--no-tips', '--violations', '--exclude', 'enum.StrEnum', '.'] + args: ['-t=3.8-', '--no-tips', '--violations', '--exclude', 'enum.StrEnum', '--exclude', 'types.UnionType', '.'] - repo: https://github.com/commitizen-tools/commitizen rev: v3.20.0 hooks: diff --git a/TODO.md b/TODO.md index b6e8048..c2339f5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,16 +1,30 @@ # TODO -## v0.4.0 +## v0.3.1 +- `ConfigDataclass`: rename `configure` to `as_config`? - Github Actions for automated testing - - Coverage badge + - Special branch prefix to not run pipeline + - Badges (shields.io) + - Version + - Coverage + - CI status + - Read the Docs + - PyPI version - Auto-publish when new tag is pushed (see: https://pypi.org/manage/project/fancy-dataclass/settings/publishing/) - Require tag to match version? -- `DictConfig` subclass of `Config` (unstructured configs loaded from JSON/TOML) + - Do "hatch version" and check if it's a prefix of "git describe --tags" or matches "git describe --tags --abbrev=0" + - PyPI Links + - Changelog - Release - CHANGELOG update + - pre-push hook to ensure it contains an entry for the latest tag - Tag new version +## v0.4.0 + +- `DictConfig` subclass of `Config` (unstructured configs loaded from JSON/TOML) + ## v0.4.1 - documentation @@ -51,6 +65,7 @@ - PEP 712 (`converter` argument for dataclass fields) - Allow `Annotated` as an alternative to `field.metadata` - Esp. with `Doc`: this could auto-populate JSON schema, argparse description +- Tiered configs? - Improve `mkdocs` documentation - Auto-generate per-field descriptions via PEP 727? - Auto-generate dataclass field docs? diff --git a/fancy_dataclass/dict.py b/fancy_dataclass/dict.py index f00f5d6..f96d9e4 100644 --- a/fancy_dataclass/dict.py +++ b/fancy_dataclass/dict.py @@ -8,7 +8,7 @@ from typing_extensions import Self, _AnnotatedAlias from fancy_dataclass.mixin import DataclassMixin, DataclassMixinSettings, FieldSettings -from fancy_dataclass.utils import TypeConversionError, _flatten_dataclass, check_dataclass, fully_qualified_class_name, issubclass_safe, obj_class_name, safe_dict_insert, type_is_optional +from fancy_dataclass.utils import TypeConversionError, _flatten_dataclass, check_dataclass, fully_qualified_class_name, issubclass_safe, obj_class_name, safe_dict_insert if TYPE_CHECKING: @@ -250,10 +250,6 @@ def err() -> TypeConversionError: return tuple(convert_val(subtype, elt) for elt in val) return tuple(convert_val(subtype, elt) for (subtype, elt) in zip(args, val)) elif origin_type == Union: - if type_is_optional(tp): - assert len(args) == 2 - assert args[1] is type(None) - args = args[::-1] # check None first for subtype in args: try: # NB: will resolve to the first valid type in the Union diff --git a/fancy_dataclass/utils.py b/fancy_dataclass/utils.py index cdba414..9ae4897 100644 --- a/fancy_dataclass/utils.py +++ b/fancy_dataclass/utils.py @@ -9,6 +9,7 @@ import importlib from pathlib import Path import re +import types from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Dict, ForwardRef, Generic, Iterable, Iterator, List, Optional, Sequence, Set, TextIO, Tuple, Type, TypeVar, Union, get_args, get_origin, get_type_hints from typing_extensions import TypeGuard @@ -55,7 +56,10 @@ def type_is_optional(tp: type) -> bool: True if the type is Optional""" origin_type = get_origin(tp) args = get_args(tp) - return (origin_type == Union) and (len(args) == 2) and (type(None) in args) + union_types: List[Any] = [Union] + if hasattr(types, 'UnionType'): # Python >= 3.10 + union_types.append(types.UnionType) + return (origin_type in union_types) and (type(None) in args) def safe_dict_insert(d: Dict[Any, Any], key: str, val: Any) -> None: """Inserts a (key, value) pair into a dict, if the key is not already present. diff --git a/pyproject.toml b/pyproject.toml index 0e93f60..ca11a43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "fancy-dataclass" dynamic = ["version"] -description = "Spiff up your dataclasses with additional features." +description = "Spiff up your dataclasses with extra features." readme = "docs/README.md" requires-python = ">=3.8" license = "MIT" @@ -62,7 +62,7 @@ dependencies = [ [tool.hatch.envs.lint.scripts] run-ruff = "ruff check" -run-vermin = "vermin {args:-t=3.8- --eval-annotations --no-tips --violations --exclude 'tests.test_serializable.StrEnum' fancy_dataclass}" +run-vermin = "vermin {args:-t=3.8- --eval-annotations --no-tips --violations --exclude enum.StrEnum --exclude types.UnionType .}" run-mypy = "mypy --install-types --non-interactive {args:fancy_dataclass tests}" run-loc-summary = "./summarize.sh" all = ["run-ruff", "run-vermin", "run-mypy", "run-loc-summary"] diff --git a/tests/test_utils.py b/tests/test_utils.py index 60d1b87..5b633c7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, is_dataclass +import sys from typing import Any, ClassVar, Dict, List, Optional, Sequence, Union import pytest @@ -9,16 +10,26 @@ @pytest.mark.parametrize(['tp', 'is_optional'], [ (int, False), + (type(None), False), (Optional[int], True), (Optional[Optional[int]], True), - (Union[int, Optional[float]], False), - (Union[Optional[float], int], False), + (Union[int, Optional[float]], True), + (Union[Optional[float], int], True), (Optional[type(None)], False), # Optional[NoneType] -> NoneType (List[Optional[int]], False), ]) def test_type_is_optional(tp, is_optional): assert type_is_optional(tp) == is_optional +@pytest.mark.skipif(sys.version_info[:2] < (3, 10), reason='Py3.10 union type') +def test_type_is_optional_py39(): + assert type_is_optional(int | None) + assert type_is_optional(None | int) + assert type_is_optional(None | int | str) + assert type_is_optional(int | str | None) + assert type_is_optional(Optional[int | str]) + assert type_is_optional(Optional[int] | str) + @pytest.mark.parametrize(['obj', 'tp', 'output'], [ (1, int, True), ('a', int, False),