Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Drop support for Python 3.9 #130

Merged
merged 6 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ branch = True
source = validataclass
omit =
*/_version.py
plugins =
coverage_conditional_plugin

[report]
show_missing = True
Expand All @@ -25,10 +23,3 @@ exclude_lines =
directory = reports/coverage_html/
skip_covered = False
show_contexts = True

[coverage_conditional_plugin]
# These rules are a bit contraintuitive as they define when to IGNORE code from coverage, instead of when to include it,
# so we prefix the pragmas with "ignore-" to make it more clear.
rules =
"sys_version_info < (3, 10)": ignore-py-lt-310
"sys_version_info >= (3, 10)": ignore-py-gte-310
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ jobs:
steps:
- uses: actions/checkout@v3

# We use Python 3.9 here because it's the minimum Python version supported by this library.
- name: Setup Python 3.9
# We use Python 3.10 here because it's the minimum Python version supported by this library.
- name: Setup Python 3.10
uses: actions/setup-python@v4
with:
python-version: 3.9
python-version: 3.10

- name: Install dependencies
run: pip install --upgrade pip build
Expand All @@ -42,10 +42,10 @@ jobs:
steps:
- uses: actions/checkout@v3

- name: Setup Python 3.9
- name: Setup Python 3.10
uses: actions/setup-python@v4
with:
python-version: 3.9
python-version: 3.10

- name: Download build artifacts
uses: actions/download-artifact@v3
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ jobs:
fail-fast: false
matrix:
python-version:
- '3.9'
- '3.10'
- '3.11'
- '3.12'
Expand Down
13 changes: 4 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ open-coverage:

# Run complete tox test suite in a multi-python Docker container
.PHONY: docker-tox
docker-tox: TOX_ARGS='-e clean,py312,py311,py310,py39,report,flake8,py312-mypy'
docker-tox: TOX_ARGS='-e clean,py312,py311,py310,report,flake8,py312-mypy'
docker-tox:
docker run --rm --tty \
--user $(DOCKER_USER) \
Expand All @@ -67,36 +67,31 @@ docker-tox:
tox run --workdir .tox_docker $(TOX_ARGS)

# Run partial tox test suites in Docker
.PHONY: docker-tox-py312 docker-tox-py311 docker-tox-py310 docker-tox-py39
.PHONY: docker-tox-py312 docker-tox-py311 docker-tox-py310
docker-test-py312: TOX_ARGS="-e clean,py312,py312-report"
docker-test-py312: docker-tox
docker-test-py311: TOX_ARGS="-e clean,py311,py311-report"
docker-test-py311: docker-tox
docker-test-py310: TOX_ARGS="-e clean,py310,py310-report"
docker-test-py310: docker-tox
docker-test-py39: TOX_ARGS="-e clean,py39,py39-report"
docker-test-py39: docker-tox

# Run all tox test suites, but separately to check code coverage individually
.PHONY: docker-test-all
docker-test-all:
make docker-test-py39
make docker-test-py310
make docker-test-py311
make docker-test-py312

# Run mypy using all different (or specific) Python versions in Docker
.PHONY: docker-mypy-all docker-mypy-py312 docker-mypy-py311 docker-mypy-py310 docker-mypy-py39
docker-mypy-all: TOX_ARGS="-e py312-mypy,py311-mypy,py310-mypy,py39-mypy"
.PHONY: docker-mypy-all docker-mypy-py312 docker-mypy-py311 docker-mypy-py310
docker-mypy-all: TOX_ARGS="-e py312-mypy,py311-mypy,py310-mypy"
docker-mypy-all: docker-tox
docker-mypy-py312: TOX_ARGS="-e py312-mypy"
docker-mypy-py312: docker-tox
docker-mypy-py311: TOX_ARGS="-e py311-mypy"
docker-mypy-py311: docker-tox
docker-mypy-py310: TOX_ARGS="-e py310-mypy"
docker-mypy-py310: docker-tox
docker-mypy-py39: TOX_ARGS="-e py39-mypy"
docker-mypy-py39: docker-tox

# Pull the latest image of the multi-python Docker image
.PHONY: docker-pull
Expand Down
40 changes: 17 additions & 23 deletions docs/05-dataclasses.md
JGaukrogers marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,7 @@ still use the defaults stored in the field metadata, but you can now also create
default values in the same way as with a regular dataclass.

This is accomplished by creating dataclasses with the option `kw_only=True` (which modifies the auto-generated class
constructor to only accept keyword arguments, so that the order of arguments doesn't matter anymore). This option was
only introduced in Python 3.10, though, so for older versions of Python a slightly hacky workaround was implemented
(take a look at the code of `validataclass_field()` if you're curious).
constructor to only accept keyword arguments, so that the order of arguments doesn't matter anymore).

In general, all fields that do **not** have any default value are required fields (i.e. `DataclassValidator` will reject
any input where one of these fields is missing). To define an optional field **without** a default value, you can use
Expand Down Expand Up @@ -441,9 +439,10 @@ in your code to distinguish it from other values like `None`.

For this you can use the `DefaultUnset` object, which is a shortcut for `Default(UnsetValue)`.

Remember to adjust the type hints in your dataclass though. There is a type alias `OptionalUnset[T]` which you can use for this, for
example: `some_var: OptionalUnset[int]`, which is equivalent to `Union[int, UnsetValueType]`. For fields that can be both `None` and
`UnsetValue`, there is also the type alias `OptionalUnsetNone[T]` as a shortcut for `OptionalUnset[Optional[T]]`.
Remember to adjust the type hints in your dataclass though. There is a type alias `OptionalUnset[T]` which you can use
for this, for example: `some_var: OptionalUnset[int]`, which is equivalent to `int | UnsetValueType`. For fields that
can be both `None` and `UnsetValue`, there is also the type alias `OptionalUnsetNone[T]` as a shortcut for
`OptionalUnset[Optional[T]]` or `T | UnsetValueType | None`.


#### NoDefault
Expand Down Expand Up @@ -495,16 +494,17 @@ When a field is optional, this means that the field is allowed to be **omitted c
does **not** automatically mean that the input value is allowed to have the value `None`. A field with a default value would still raise
a `RequiredValueError` if the input value is `None`. This is, unless a field validator that explicitly allows `None` as value is used.

For example, imagine a dataclass with only one field: `some_var: Optional[int] = IntegerValidator(), Default(None)`. An empty input
dictionary `{}` would result in an object with the default value `some_var = None`, but the input dictionary `{"some_var": None}` itself
would **not** be valid at all.
For example, imagine a dataclass with only one field: `some_var: int | None = IntegerValidator(), Default(None)`. An
empty input dictionary `{}` would result in an object with the default value `some_var = None`, but the input
dictionary `{"some_var": None}` itself would **not** be valid at all.

Instead, to explicitly allow `None` as value, you can use the `Noneable` wrapper (introduced [earlier](03-basic-validators.md)),
e.g. `some_var: Optional[int] = Noneable(IntegerValidator())`. This however does **not** make the field optional, so an input dictionary
with the value `None` would be allowed, but omitting the field in an input dictionary would be invalid.
Instead, to explicitly allow `None` as value, you can use the `Noneable` wrapper (introduced
[earlier](03-basic-validators.md)), e.g. `some_var: int | None = Noneable(IntegerValidator())`. This however does
**not** make the field optional, so an input dictionary with the value `None` would be allowed, but omitting the field
in an input dictionary would be invalid.

To make a field both optional **and** allow `None` as value, you can simply combine `Noneable()` and a `Default` value. \
For example: `some_var: Optional[int] = Noneable(IntegerValidator()), Default(None)`.
For example: `some_var: int | None = Noneable(IntegerValidator()), Default(None)`.

You can also configure the `Noneable` wrapper to use a different default value than `None`. For example, to always use `0` as the
default value, regardless of whether the field is missing in the input dictionary or whether the field has the input value `None`: \
Expand Down Expand Up @@ -554,7 +554,6 @@ the "modify" dataclass from the "create" dataclass and change all field defaults

```python
from decimal import Decimal
from typing import Optional

from validataclass.dataclasses import validataclass, Default, DefaultUnset
from validataclass.helpers import OptionalUnset, OptionalUnsetNone
Expand All @@ -564,7 +563,7 @@ from validataclass.validators import IntegerValidator, StringValidator, DecimalV
class CreateStuffRequest:
name: str = StringValidator()
some_value: int = IntegerValidator()
some_decimal: Optional[Decimal] = DecimalValidator(), Default(None)
some_decimal: Decimal | None = DecimalValidator(), Default(None)

@validataclass
class ModifyStuffRequest(CreateStuffRequest):
Expand Down Expand Up @@ -845,8 +844,6 @@ dictionary:
Here is another code example for a dataclass with conditionally required fields:

```python
from typing import Optional

from validataclass.dataclasses import validataclass, Default
from validataclass.exceptions import RequiredValueError, DataclassPostValidationError
from validataclass.validators import DataclassValidator, BooleanValidator, IntegerValidator
Expand All @@ -857,7 +854,7 @@ class ExampleClass:
enable_something: bool = BooleanValidator()

# This field is required only if enable_something is True. Otherwise it will be ignored.
some_value: Optional[int] = IntegerValidator(), Default(None)
some_value: int | None = IntegerValidator(), Default(None)

def __post_validate__(self):
# If enable_something is True, ensure that some_value is set!
Expand Down Expand Up @@ -917,16 +914,14 @@ The `DataclassValidator` will make sure to only pass the arguments that the meth
Example:

```python
from typing import Optional

from validataclass.dataclasses import validataclass, Default
from validataclass.exceptions import RequiredValueError, DataclassPostValidationError
from validataclass.validators import DataclassValidator, IntegerValidator

@validataclass
class ContextSensitiveExampleClass:
# This field is optional, unless the context says otherwise.
some_value: Optional[int] = IntegerValidator(), Default(None)
some_value: int | None = IntegerValidator(), Default(None)

# Note: You can also specify **kwargs here to get all context arguments.
def __post_validate__(self, *, require_some_value: bool = False):
Expand Down Expand Up @@ -1046,7 +1041,6 @@ In conclusion, let's take a look at a final example. This one is a bit more exte
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional

from validataclass.dataclasses import validataclass, Default
from validataclass.exceptions import ValidationError, DataclassPostValidationError
Expand All @@ -1063,7 +1057,7 @@ class Color(Enum):
class OrderItem:
name: str = StringValidator()
price: Decimal = DecimalValidator()
color: Optional[Color] = EnumValidator(Color), Default(None)
color: Color | None = EnumValidator(Color), Default(None)

@validataclass
class Order:
Expand Down
16 changes: 7 additions & 9 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ classifiers =
License :: OSI Approved :: MIT License
Operating System :: OS Independent
Programming Language :: Python :: 3
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: 3.12
Expand All @@ -29,18 +28,17 @@ classifiers =
package_dir =
= src
packages = find:
python_requires = ~=3.9
python_requires = ~=3.10
install_requires =
typing-extensions ~= 4.3
typing-extensions ~= 4.12

[options.packages.find]
where = src

[options.extras_require]
testing =
pytest ~= 7.2
pytest-cov
coverage ~= 6.5
coverage-conditional-plugin ~= 0.5
flake8 ~= 7.0
mypy ~= 1.9
pytest ~= 8.3
pytest-cov ~= 6.0
coverage ~= 7.6
flake8 ~= 7.1
mypy ~= 1.13
28 changes: 12 additions & 16 deletions src/validataclass/dataclasses/validataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
"""

import dataclasses
import sys
from collections import namedtuple
from collections.abc import Callable
from typing import Any, Optional, TypeVar, Union, overload
from typing import Any, TypeVar, overload

from typing_extensions import dataclass_transform

Expand Down Expand Up @@ -41,10 +40,10 @@ def validataclass(cls: None = None, /, **kwargs: Any) -> Callable[[type[_T]], ty
field_specifiers=(dataclasses.field, dataclasses.Field, validataclass_field),
)
def validataclass(
cls: Optional[type[_T]] = None,
cls: type[_T] | None = None,
/,
**kwargs: Any,
) -> Union[type[_T], Callable[[type[_T]], type[_T]]]:
) -> type[_T] | Callable[[type[_T]], type[_T]]:
"""
Decorator that turns a normal class into a `DataclassValidator`-compatible dataclass.

Expand Down Expand Up @@ -85,19 +84,17 @@ class ExampleDataclass:
variables. The only difference to real InitVars is that this field will still exist after initialization.

Optional parameters to the decorator will be passed directly to the `@dataclass` decorator. In most cases no
parameters are necessary. In Python 3.10 and upwards, the argument `kw_only=True` will be used by default.
parameters are necessary. By default, the argument `kw_only=True` will be used for validataclasses.
"""

def decorator(_cls: type[_T]) -> type[_T]:
# In Python 3.10 and higher, we use kw_only=True to allow both required and optional fields in any order.
# In older Python versions, we use a workaround by setting default_factory to a function that raises an
# exception for required fields.
if sys.version_info >= (3, 10): # pragma: ignore-py-lt-310
kwargs.setdefault('kw_only', True)
else: # pragma: ignore-py-gte-310
pass
# Set kw_only=True as the default to allow required and optional fields in any order
kwargs.setdefault('kw_only', True)

# Prepare class to become a validataclass
_prepare_dataclass_metadata(_cls)

# Use @dataclass decorator to turn class into a dataclass
return dataclasses.dataclass(**kwargs)(_cls)

# Wrap actual decorator if called with parentheses
Expand Down Expand Up @@ -164,9 +161,8 @@ def _prepare_dataclass_metadata(cls: type[_T]) -> None:
if not isinstance(field_default, Default):
field_default = NoDefault

# Create dataclass field (_name is only needed for generating the default_factory for required fields for
# compatibility with Python < 3.10)
setattr(cls, name, validataclass_field(validator=field_validator, default=field_default, _name=name))
# Create dataclass field
setattr(cls, name, validataclass_field(validator=field_validator, default=field_default))


def _get_existing_validator_fields(cls: type[_T]) -> dict[str, _ValidatorField]:
Expand Down Expand Up @@ -197,7 +193,7 @@ def _get_existing_validator_fields(cls: type[_T]) -> dict[str, _ValidatorField]:
return validator_fields


def _parse_validator_tuple(args: Union[tuple[Any, ...], Validator, Default, None]) -> _ValidatorField:
def _parse_validator_tuple(args: tuple[Any, ...] | Validator | Default | None) -> _ValidatorField:
"""
Parses field arguments (the value of a field in a dataclass that has not been parsed by `@dataclass` yet) to a
tuple of a Validator and a Default object.
Expand Down
23 changes: 2 additions & 21 deletions src/validataclass/dataclasses/validataclass_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
"""

import dataclasses
import sys
from typing import Any, NoReturn, Optional
from typing import Any

from validataclass.validators import Validator
from .defaults import Default, NoDefault
Expand All @@ -20,8 +19,7 @@ def validataclass_field(
validator: Validator,
default: Any = NoDefault,
*,
metadata: Optional[dict[str, Any]] = None,
_name: Optional[str] = None, # noqa (undocumented parameter, only used internally)
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> Any:
"""
Expand Down Expand Up @@ -74,22 +72,5 @@ def validataclass_field(
else:
kwargs['default'] = default.get_value()

# Compatibility for Python 3.9 and older: Use a workaround to allow required and optional fields to be defined in
# any order. (In Python 3.10 the kw_only=True option for dataclasses is introduced, which can be used instead.)
if default is NoDefault and sys.version_info < (3, 10): # pragma: ignore-py-gte-310
# Use a default_factory that raises an exception for required fields.
kwargs['default_factory'] = lambda: _raise_field_required(_name)

# Create a dataclass field with our metadata
return dataclasses.field(metadata=metadata, **kwargs)


def _raise_field_required(name: Optional[str]) -> NoReturn: # pragma: ignore-py-gte-310
"""
Raises a TypeError exception. Used for required fields (only in Python 3.9 or lower where the kw_only option is not
supported yet).
"""
raise TypeError(
f"Missing required keyword-only argument: '{name}'"
if name else 'Missing required keyword-only argument'
)
8 changes: 4 additions & 4 deletions src/validataclass/exceptions/base_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Use of this source code is governed by an MIT-style license that can be found in the LICENSE file.
"""

from typing import Any, Optional
from typing import Any

__all__ = [
'ValidationError',
Expand All @@ -28,10 +28,10 @@ class ValidationError(Exception):
Use `exception.to_dict()` to get a dictionary suitable for generating JSON responses.
"""
code: str = 'unknown_error'
reason: Optional[str] = None
extra_data: Optional[dict[str, Any]] = None
reason: str | None = None
extra_data: dict[str, Any] | None = None

def __init__(self, *, code: Optional[str] = None, reason: Optional[str] = None, **kwargs: Any):
def __init__(self, *, code: str | None = None, reason: str | None = None, **kwargs: Any):
if code is not None:
self.code = code
if reason is not None:
Expand Down
Loading
Loading