Skip to content
Draft
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
15 changes: 6 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15']

steps:
- uses: actions/checkout@v3
Expand All @@ -37,14 +37,11 @@ jobs:

- name: Run tests
run: |
poetry run flake8 .
poetry run mypy classes ./tests/**/*.py
poetry run codespell classes tests docs typesafety README.md CONTRIBUTING.md CHANGELOG.md
poetry run pytest classes tests docs/pages README.md
poetry run doc8 -q docs
poetry run poetry check
poetry run pip check
poetry run safety check --full-report
make lint
make type-check
make docs
make unit
make package
# We do this to speed up the build:
poetry run pytest typesafety -p no:cov -o addopts="" --mypy-ini-file=setup.cfg

Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,10 @@ fabric.properties
### Custom ###
ex.py
experiments/

# Ruff
.ruff_cache/


### Custom ###
.vscode/
63 changes: 63 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
SHELL := /usr/bin/env bash
POETRY ?= poetry
.DEFAULT_GOAL := help

.PHONY: help
help: ## Show the help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

.PHONY: format
format: ## Format code with ruff
$(POETRY) run ruff check --fix
$(POETRY) run ruff format

.PHONY: lint
lint: ## Run linting checks (ruff, flake8)
$(POETRY) run ruff check --exit-non-zero-on-fix
$(POETRY) run ruff format --check --diff
$(POETRY) run flake8 .

.PHONY: type-check
type-check: ## Run type checking (mypy)
$(POETRY) run mypy classes
$(POETRY) run mypy tests

.PHONY: unit
unit: ## Run unit tests with pytest (incl. doctests in classes, docs/pages and README.md)
$(POETRY) run pytest classes tests docs/pages README.md

.PHONY: typesafety
typesafety: ## Run type-safety tests with pytest-mypy-plugins (PYTEST_EXTRA_ARGS for sharding, etc)
$(POETRY) run pytest typesafety $(PYTEST_EXTRA_ARGS) \
-p no:cov -o addopts="" --mypy-ini-file=setup.cfg

.PHONY: docs
docs: ## Check documentation (doc8, codespell)
$(POETRY) run doc8 -q docs
$(POETRY) run codespell classes tests docs typesafety README.md CONTRIBUTING.md CHANGELOG.md

.PHONY: package
package: ## Check the package integrity (poetry check, pip check, safety)
$(POETRY) check
$(POETRY) run pip check
$(POETRY) run safety check --full-report

.PHONY: install
install: ## Install the project and all dev documentation dependencies
$(POETRY) install

.PHONY: update
update: ## Re-lock and re-install all dependencies
$(POETRY) lock
$(POETRY) install

.PHONY: clean
clean: ## Remove caches, coverage output and build artifacts
rm -rf .pytest_cache .mypy_cache .coverage coverage.xml htmlcov build dist
find . -type d -name __pycache__ -prune -exec rm -rf {} +

.PHONY: test
test: lint type-check unit typesafety docs package ## Run all checks (lint, type-check, unit, typesafety, docs, package)
26 changes: 12 additions & 14 deletions classes/_registry.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
from typing import Callable, Dict, NoReturn, Optional, Tuple
from collections.abc import Callable
from typing import Final, NoReturn

from typing_extensions import Final

TypeRegistry = Dict[type, Callable]
TypeRegistry = dict[type, Callable]

#: We use this to exclude `None` as a default value for `exact_type`.
DefaultValue: Final = type('DefaultValueType', (object,), {})
DefaultValue: Final = type("DefaultValueType", (object,), {})

#: Used both in runtime and during mypy type-checking.
INVALID_ARGUMENTS_MSG: Final = (
'Only a single argument can be applied to `.instance`'
"Only a single argument can be applied to `.instance`"
)


def choose_registry( # noqa: WPS211
# It has multiple arguments, but I don't see an easy and performant way
# to refactor it: I don't want to create extra structures
# and I don't want to create a class with methods.
exact_type: Optional[type],
exact_type: type | None,
protocol: type,
delegate: type,
delegates: TypeRegistry,
exact_types: TypeRegistry,
protocols: TypeRegistry,
) -> Tuple[TypeRegistry, type]:
) -> tuple[TypeRegistry, type]:
"""
Returns the appropriate registry to store the passed type.

Expand All @@ -34,25 +33,24 @@ def choose_registry( # noqa: WPS211
(exact_type, protocol, delegate),
))
if not passed_args:
raise ValueError('At least one argument to `.instance` is required')
raise ValueError("At least one argument to `.instance` is required")
if len(passed_args) > 1:
raise ValueError(INVALID_ARGUMENTS_MSG)

if _is_not_default_argument_value(delegate):
return delegates, delegate
elif _is_not_default_argument_value(protocol):
if _is_not_default_argument_value(protocol):
return protocols, protocol
return exact_types, exact_type if exact_type is not None else type(None)


def default_implementation(instance, *args, **kwargs) -> NoReturn:
"""By default raises an exception."""
raise NotImplementedError(
'Missing matched typeclass instance for type: {0}'.format(
type(instance).__qualname__,
),
"Missing matched typeclass instance for type: "
f"{type(instance).__qualname__}",
)


def _is_not_default_argument_value(arg: Optional[type]) -> bool:
def _is_not_default_argument_value(arg: type | None) -> bool:
return arg is not DefaultValue
85 changes: 39 additions & 46 deletions classes/_typeclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,56 +116,53 @@

See our `official docs <https://classes.readthedocs.io>`_ to learn more!
"""
from collections.abc import Callable
from functools import _find_impl # type: ignore # noqa: WPS450
from typing import ( # noqa: WPS235
TYPE_CHECKING,
Callable,
Dict,
Generic,
Optional,
Type,
TypeGuard,
TypeVar,
Union,
final,
overload,
)
from weakref import WeakKeyDictionary

from typing_extensions import TypeGuard, final

from classes._registry import (
DefaultValue,
TypeRegistry,
choose_registry,
default_implementation,
)

_InstanceType = TypeVar('_InstanceType')
_SignatureType = TypeVar('_SignatureType', bound=Callable)
_AssociatedType = TypeVar('_AssociatedType')
_Fullname = TypeVar('_Fullname', bound=str) # Literal value
_InstanceType = TypeVar("_InstanceType")
_SignatureType = TypeVar("_SignatureType", bound=Callable)
_AssociatedType = TypeVar("_AssociatedType")
_Fullname = TypeVar("_Fullname", bound=str) # Literal value

_NewInstanceType = TypeVar('_NewInstanceType', bound=Type)
_NewInstanceType = TypeVar("_NewInstanceType", bound=type)

_AssociatedTypeDef = TypeVar('_AssociatedTypeDef', contravariant=True)
_TypeClassType = TypeVar('_TypeClassType', bound='_TypeClass')
_ReturnType = TypeVar('_ReturnType')
_AssociatedTypeDef_contra = TypeVar(
"_AssociatedTypeDef_contra",
contravariant=True
)
_TypeClassType = TypeVar("_TypeClassType", bound="_TypeClass")
_ReturnType = TypeVar("_ReturnType")


@overload
def typeclass(
definition: Type[_AssociatedType],
) -> '_TypeClassDef[_AssociatedType]':
"""Function to created typeclasses with associated types."""

definition: type[_AssociatedType],
) -> "_TypeClassDef[_AssociatedType]":
...

@overload
def typeclass(
signature: _SignatureType,
# By default almost all variables are `nothing`,
# but we enhance them via mypy plugin later:
) -> '_TypeClass[_InstanceType, _SignatureType, _AssociatedType, _Fullname]':
"""Function to define typeclasses with just functions."""

) -> "_TypeClass[_InstanceType, _SignatureType, _AssociatedType, _Fullname]":
...

def typeclass(signature):
"""General case function to create typeclasses."""
Expand Down Expand Up @@ -246,7 +243,7 @@ def __class_getitem__(cls, type_params) -> type:


@final
class Supports(Generic[_AssociatedTypeDef]):
class Supports(Generic[_AssociatedTypeDef_contra]):
"""
Used to specify that some value is a part of a typeclass.

Expand Down Expand Up @@ -310,21 +307,22 @@ class _TypeClass( # noqa: WPS214
"""

__slots__ = (
# Str:
'_signature',
'_associated_type',
"_associated_type",

# Registry:
'_delegates',
'_exact_types',
'_protocols',
"_delegates",

# Cache:
'_dispatch_cache',
"_dispatch_cache",
"_exact_types",
"_protocols",

# Str:
"_signature",
)

_dispatch_cache: Dict[type, Callable]
_cache_token: Optional[object]
_dispatch_cache: dict[type, Callable]
_cache_token: object | None

def __init__(
self,
Expand Down Expand Up @@ -373,10 +371,7 @@ def __init__(

def __call__(
self,
instance: Union[ # type: ignore
_InstanceType,
Supports[_AssociatedType],
],
instance: _InstanceType | Supports[_AssociatedType], # type: ignore
*args,
**kwargs,
) -> _ReturnType:
Expand Down Expand Up @@ -447,14 +442,11 @@ def __call__(
def __str__(self) -> str:
"""Converts typeclass to a string."""
associated_type = (
': "{0}"'.format(self._associated_type.__qualname__)
f': "{self._associated_type.__qualname__}"'
if self._associated_type
else ''
)
return '<typeclass "{0}"{1}>'.format(
self._signature.__name__,
associated_type,
else ""
)
return f'<typeclass "{self._signature.__name__}"{associated_type}>'

def supports(
self,
Expand Down Expand Up @@ -527,11 +519,11 @@ def supports(

def instance(
self,
exact_type: Optional[_NewInstanceType] = DefaultValue, # type: ignore
exact_type: _NewInstanceType | None = DefaultValue, # type: ignore
*,
protocol: type = DefaultValue,
delegate: type = DefaultValue,
) -> '_TypeClassInstanceDef[_NewInstanceType, _TypeClassType]':
) -> "_TypeClassInstanceDef[_NewInstanceType, _TypeClassType]":
"""
We use this method to store implementation for each specific type.

Expand All @@ -553,6 +545,7 @@ def instance(
because ``@overload`` functions do not
work well with ``ctx.api.fail`` inside the plugin.
They start to try other overloads, which produces wrong results.

"""
# This might seem like a strange line at first, let's dig into it:
#
Expand Down Expand Up @@ -582,7 +575,7 @@ def decorator(implementation):
return implementation
return decorator

def _dispatch(self, instance, instance_type: type) -> Optional[Callable]:
def _dispatch(self, instance, instance_type: type) -> Callable | None:
"""
Dispatches a function by its type.

Expand All @@ -601,7 +594,7 @@ def _dispatch(self, instance, instance_type: type) -> Optional[Callable]:

return _find_impl(instance_type, self._exact_types)

def _dispatch_delegate(self, instance) -> Optional[Callable]:
def _dispatch_delegate(self, instance) -> Callable | None:
for delegate, callback in self._delegates.items():
if isinstance(instance, delegate):
return callback
Expand Down
1 change: 0 additions & 1 deletion classes/contrib/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
# -*- coding: utf-8 -*-
1 change: 0 additions & 1 deletion classes/contrib/mypy/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
# -*- coding: utf-8 -*-
Loading