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

Add py3.11 + pyright and pyroma to test matrix. #28

Merged
merged 1 commit into from Jan 14, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/publish.yml
@@ -1,9 +1,9 @@
name: Publish

on:
create:
push:
tags:
- v*
- 'v*'

jobs:
build-n-publish:
Expand All @@ -17,10 +17,10 @@ jobs:
- name: Install pypa/build
run: |
python -m pip install --upgrade pip
python -m pip install build
python -m pip install tox
- name: Build a binary wheel and a source tarball
run: python -m build --outdir dist/ .
run: tox -e build
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@master
uses: pypa/gh-action-pypi-publish@v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
7 changes: 1 addition & 6 deletions .github/workflows/tests.yml
Expand Up @@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ '3.7', '3.8', '3.9', '3.10', 'pypy3.7', 'pypy3.8', 'pypy3.9' ]
python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy3.7', 'pypy3.8', 'pypy3.9' ]

steps:
- uses: actions/checkout@v3
Expand Down Expand Up @@ -50,8 +50,3 @@ jobs:
name: coverage-report
- name: Prepare coverage report
run: tox -e coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: true
files: ./coverage.xml
9 changes: 6 additions & 3 deletions README.md
@@ -1,6 +1,6 @@
# dataslots
![Build status](https://github.com/starhel/dataslots/actions/workflows/tests.yml/badge.svg)
[![codecov](https://codecov.io/gh/starhel/dataslots/branch/master/graph/badge.svg)](https://codecov.io/gh/starhel/dataslots)
[![coverage](https://img.shields.io/badge/coverage-100%25-success)](https://github.com/starhel/dataslots/actions)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/dataslots.svg)](https://pypi.org/project/dataslots/)
[![PyPI - Status](https://img.shields.io/pypi/status/dataslots.svg)](https://pypi.org/project/dataslots/)
![license](https://img.shields.io/github/license/starhel/dataslots.svg)
Expand Down Expand Up @@ -87,15 +87,18 @@ Check example directory for basic usage.
_Added in 1.1.0_

### Typing support (PEP 561)
The package is PEP 561 compliant, so you can easily use it with mypy.
The package is PEP 561 compliant, so you can easily use it with mypy<sup>1</sup> and pyright.

<sup>1</sup> Due to some issues in mypy not all features are supported correctly (like [dataclass alike
interface](https://github.com/python/mypy/issues/14293) or [descriptors](https://github.com/python/mypy/issues/13856)).

_Added in 1.2.0_

### Backport
If you prefer using the newest `dataclasses.dataclass` interface you can use `dataslots.dataclass` wrapper
to provide a consistent interface regardless of the python version.

Notice: Wrapper always uses `dataslots` to make all additional features available.
Notice: Wrapper always uses `dataslots` to make all additional features available and `slots=True` is obligatory.

_Added in 1.2.0_

Expand Down
12 changes: 12 additions & 0 deletions pyproject.toml
Expand Up @@ -8,3 +8,15 @@ requires = [
"setuptools_scm[toml]>=3.4"
]
build-backend = "setuptools.build_meta"


[tool.pyright]
include = ["src", "tests"]
strictListInference = true
strictDictionaryInference = true
strictSetInference = true
reportConstantRedefinition = "error"
reportDuplicateImport = "error"
reportImportCycles = "error"
reportIncompatibleMethodOverride = "error"
reportUntypedClassDecorator = "error"
7 changes: 5 additions & 2 deletions setup.cfg
Expand Up @@ -19,17 +19,20 @@ classifiers =
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: Implementation :: CPython
Programming Language :: Python :: Implementation :: PyPy
Intended Audience :: Developers
Typing :: Typed
keywords = dataslots slots dataclasses

[options]
python_requires = >=3.7
install_requires =
typing_extensions>=3.7.4;python_version<"3.8"
typing_extensions>=4.2.0;python_version<"3.11"
package_dir =
=src
packages = find:
include_package_data = True

[options.packages.find]
where=src
Expand Down
44 changes: 26 additions & 18 deletions src/dataslots/__init__.py
@@ -1,32 +1,39 @@
from __future__ import annotations

from abc import ABCMeta, abstractmethod
from collections import ChainMap
from contextlib import contextmanager
from dataclasses import fields, is_dataclass
from dataclasses import dataclass as cpy_dataclass
from inspect import isdatadescriptor

from typing import overload
from typing import overload, Optional, Dict, Tuple, Any, TypeVar, Callable, Type

try:
from typing import final # type: ignore
from typing import final, dataclass_transform # type: ignore
except ImportError:
from typing_extensions import final # type: ignore
from typing_extensions import final, dataclass_transform # type: ignore

__all__ = ['dataslots', 'dataclass', 'DataslotsDescriptor', 'DataDescriptor']

_DATASLOTS_DESCRIPTOR = '_dataslots_'


# State is always tuple of two items if __slots__ are defined
StateType = Tuple[Optional[Dict[str, Any]], Dict[str, Any]]
DC = TypeVar('DC')


def _get_data_descriptor_name(var_name: str) -> str:
return _DATASLOTS_DESCRIPTOR + var_name


@overload
def dataslots(_cls): ...
def dataslots(_cls: Type[DC]) -> Type[DC]: ...


@overload
def dataslots(*, add_dict: bool = ..., add_weakref: bool = ...): ...
def dataslots(*, add_dict: bool = ..., add_weakref: bool = ...) -> Callable[[Type[DC]], Type[DC]]: ...


def dataslots(_cls=None, *, add_dict=False, add_weakref=False):
Expand All @@ -35,7 +42,7 @@ def dataslots(_cls=None, *, add_dict=False, add_weakref=False):
to add __slots__ after class creation.
"""

def _slots_setstate(self, state):
def _slots_setstate(self, state: StateType):
for param_dict in filter(None, state):
for slot, value in param_dict.items():
object.__setattr__(self, slot, value)
Expand All @@ -44,7 +51,7 @@ def wrap(cls):
if not is_dataclass(cls):
raise TypeError('dataslots can be used only with dataclass')

cls_dict = dict(cls.__dict__)
cls_dict: Dict[str, Any] = dict(cls.__dict__)
if '__slots__' in cls_dict:
raise TypeError('do not define __slots__ if dataslots decorator is used')

Expand All @@ -54,11 +61,11 @@ def wrap(cls):

# Create slots list + space for data descriptors
field_names = set()
for f in fields(cls):
if isinstance(mro_dict.get(f.name), DataDescriptor):
field_names.add(mro_dict[f.name].slot_name)
elif not isdatadescriptor(mro_dict.get(f.name)):
field_names.add(f.name)
for field in fields(cls):
if isinstance(mro_dict.get(field.name), DataDescriptor):
field_names.add(mro_dict[field.name].slot_name)
elif not isdatadescriptor(mro_dict.get(field.name)):
field_names.add(field.name)

if add_dict:
field_names.add('__dict__')
Expand All @@ -68,8 +75,8 @@ def wrap(cls):
cls_dict['__slots__'] = tuple(field_names - inherited_slots)

# Erase filed names from class __dict__
for f in field_names:
cls_dict.pop(f, None)
for field_name in field_names:
cls_dict.pop(field_name, None)

# Erase __dict__ and __weakref__
cls_dict.pop('__dict__', None)
Expand All @@ -91,22 +98,23 @@ def wrap(cls):


@overload
def dataclass(_cls): ...
def dataclass(_cls: Type[DC]) -> Type[DC]: ...


@overload
def dataclass(*, slots: bool = ..., weakref_slot: bool = ..., **kwargs): ...
def dataclass(*, slots: bool = ..., weakref_slot: bool = ..., **kwargs) -> Callable[[Type[DC]], Type[DC]]: ...


def dataclass(cls=None, *, slots=False, weakref_slot=False, **kwargs):
@dataclass_transform()
def dataclass(_cls=None, *, slots=False, weakref_slot=False, **kwargs):
if not slots:
raise TypeError('slots is False, use dataclasses.dataclass instead')

def wrap(cls):
cls = cpy_dataclass(**kwargs)(cls)
return dataslots(add_weakref=weakref_slot)(cls)

return wrap if cls is None else wrap(cls)
return wrap if _cls is None else wrap(_cls)


class DataDescriptor(metaclass=ABCMeta):
Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Expand Up @@ -22,9 +22,9 @@ def assert_slots(class_or_instance, slots):
assert sorted(class_or_instance.__slots__) == sorted(slots)

@staticmethod
def assert_init_raises(cls, *args, exception, msg):
def assert_init_raises(datacls, *args, exception, msg):
with pytest.raises(exception) as exc_info:
cls(*args)
datacls(*args)
assert exc_info.match(msg)


Expand Down
2 changes: 1 addition & 1 deletion tests/test_backport.py
Expand Up @@ -18,7 +18,7 @@ class A:
assertions.assert_not_member('__weakref__', instance)

with pytest.raises(AttributeError):
instance.new_prop = 15
instance.new_prop = 15 # type: ignore

assert A(10) > A(5)

Expand Down
25 changes: 19 additions & 6 deletions tests/test_dataslots.py
Expand Up @@ -24,7 +24,7 @@ class A:
assertions.assert_not_member('__weakref__', instance)

with pytest.raises(AttributeError):
instance.new_prop = 15
instance.new_prop = 15 # type: ignore


def test_skip_init_var(assertions):
Expand Down Expand Up @@ -157,7 +157,7 @@ class A:
a = A(10)
assert a.y == 5
with pytest.raises(AttributeError):
a.y = 20
a.y = 20 # type: ignore

b = A(5)
a.z.add(10)
Expand All @@ -181,9 +181,10 @@ def test_qualname():
class A:
x: int

qualname = f'{inspect.currentframe().f_code.co_name}.<locals>.A'
frame = inspect.currentframe()

assert A.__qualname__ == qualname
assert frame is not None, "Running implementation without Python stack frame."
assert A.__qualname__ == f'{frame.f_code.co_name}.<locals>.A'


def test_slots_inheritance(assertions):
Expand Down Expand Up @@ -270,9 +271,9 @@ def test_generic_typing(assertions):
@dataclass
class A(Generic[T]):
x: T
y: T = 10
y: T

instance = A[int](x=5)
instance = A[int](x=5, y=10)
assertions.assert_slots(A, ('x', 'y'))
assert 10 == instance.y
assertions.assert_not_member('__dict__', instance)
Expand All @@ -297,3 +298,15 @@ class A:
with pytest.raises(TypeError) as exc_info:
dataslots(A)
assert exc_info.match('dataslots can be used only with dataclass')


def test_add_custom_function():
@dataslots
@dataclass(frozen=True, eq=True)
class A:
x: int

def __add__(self, other: 'A') -> 'A':
return A(self.x + other.x)

assert A(x=5) + A(x=7) == A(x=12)