Skip to content
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,5 @@ cython_debug/

# PyPI configuration file
.pypirc

.vscode/
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ repos:
hooks:
- id: pyright
exclude: ^tests/
additional_dependencies: ["pytest"]
additional_dependencies: ["pytest", "equinox>=0.13.0"]
11 changes: 11 additions & 0 deletions ihoop/eqx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import equinox as eqx

from .strict import _StrictMeta, Strict


class _StrictEqxMeta(_StrictMeta, eqx._module._module._ModuleMeta):
pass


class AbstractStrictModule(eqx.Module, Strict, metaclass=_StrictEqxMeta):
pass
63 changes: 40 additions & 23 deletions ihoop/strict.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,42 +139,59 @@ def __new__(

cls = super().__new__(mcs, name, bases, namespace, **kwargs)

has_abstract_name = name.startswith("Abstract") or name.startswith("_Abstract")

# A base contributes abstract members if it carries any abstract methods,
# attributes, or classvars.
base_had_abstract_members = any(
bool(
getattr(b, "__abstractmethods__", None)
or getattr(b, "_strict_abstract_attributes_", None)
or getattr(b, "_strict_abstract_classvars_", None)
)
for b in bases
)

cls._strict_abstract_attributes_ = current_abstract_attributes
cls._strict_abstract_classvars_ = current_abstract_classvars
cls._strict_final_classvars_ = frozenset(
resolved_classvars_here | inherited_resolved_classvars
)
has_remaining_abstracts = bool(
cls.__abstractmethods__
or cls._strict_abstract_attributes_
or cls._strict_abstract_classvars_
)
cls._strict_is_abstract_ = (
bool(
cls.__abstractmethods__
or cls._strict_abstract_attributes_
or cls._strict_abstract_classvars_
)
has_remaining_abstracts
or (has_abstract_name and not base_had_abstract_members)
or is_defining_strict_itself
)

# Skip checks for the base strict class itself
if is_defining_strict_itself:
return cls

has_abstract_name = name.startswith("Abstract") or name.startswith("_Abstract")
if cls._strict_is_abstract_:
if not has_abstract_name:
abs_methods = list(cls.__abstractmethods__)
abs_attrs = list(cls._strict_abstract_attributes_)
abs_classvars = list(cls._strict_abstract_classvars_)
raise TypeError(
f"Abstract class '{cls.__module__}.{name}' must have a name "
"starting with 'Abstract' or '_Abstract'. Abstract elements:"
f" methods={abs_methods}, attributes={abs_attrs}, "
f"classvars={abs_classvars}"
)
else: # Concrete class
if has_abstract_name:
raise TypeError(
f"Concrete (final) class '{cls.__module__}.{name}' must not "
"have a name starting with 'Abstract' or '_Abstract'."
)
if has_remaining_abstracts and not has_abstract_name:
abs_methods = list(cls.__abstractmethods__)
abs_attrs = list(cls._strict_abstract_attributes_)
abs_classvars = list(cls._strict_abstract_classvars_)
raise TypeError(
f"Class '{cls.__module__}.{name}' has abstract elements but its "
"name does not start with 'Abstract' or '_Abstract'. Abstract "
f"elements: methods={abs_methods}, attributes={abs_attrs}, "
f"classvars={abs_classvars}"
)

if (
has_abstract_name
and not has_remaining_abstracts
and base_had_abstract_members
):
raise TypeError(
f"Concrete (final) class '{cls.__module__}.{name}' must not "
"have a name starting with 'Abstract' or '_Abstract'."
)

for base in bases:
if not _is_strict_subclass(base):
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ihoop"
version = "0.1.2"
version = "0.1.3"
description = "Abstract/Final Patterns in Python"
readme = "README.md"
requires-python = ">=3.9" # todo: check versions
Expand All @@ -23,13 +23,17 @@ requires = ["setuptools >= 61.0"]
build-backend = "setuptools.build_meta"

[project.optional-dependencies]
equinox = [
"equinox>=0.13.0"
]
examples = [
"equinox>=0.13.0"
]
testing = [
"pytest>=7.4.0",
"nbmake>=1.5.0",
"pyright==1.1.408",
"equinox>=0.13.0",
]

[tool.setuptools]
Expand Down
11 changes: 6 additions & 5 deletions tests/test_classvar.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ class AbstractBase(Strict):
# Without a value, it remains abstract
with self.assertRaisesRegex(
TypeError,
r"Abstract class '.*\.AlmostConcrete' must have a name "
"starting with 'Abstract'",
r"Class '.*\.AlmostConcrete' has abstract elements but its name "
r"does not start with 'Abstract'",
):

class AlmostConcrete(AbstractBase):
Expand Down Expand Up @@ -88,8 +88,8 @@ class AbstractConfig(Strict):
# Partial resolution still abstract
with self.assertRaisesRegex(
TypeError,
r"Abstract class '.*\.PartialConfig' must have a name starting with "
"'Abstract'",
r"Class '.*\.PartialConfig' has abstract elements but its name "
r"does not start with 'Abstract'",
):

class PartialConfig(AbstractConfig):
Expand Down Expand Up @@ -158,7 +158,8 @@ class Public(_AbstractPrivate):

with self.assertRaisesRegex(
TypeError,
r"Abstract class '.*\.BadName' must have a name starting with 'Abstract'",
r"Class '.*\.BadName' has abstract elements but its name does not "
r"start with 'Abstract'",
):

class BadName(Strict):
Expand Down
55 changes: 55 additions & 0 deletions tests/test_eqx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from abc import abstractmethod
from unittest import TestCase

from ihoop.eqx import AbstractStrictModule


class TestEqxIntegration(TestCase):
def test_marker_base_is_not_instantiable(self):
with self.assertRaises(TypeError):
AbstractStrictModule()

def test_subclassing_marker_base_works(self):
class AbstractFoo(AbstractStrictModule):
@abstractmethod
def bar(self):
raise NotImplementedError

class Foo(AbstractFoo):
x: float

def __init__(self, x: float):
self.x = x

def bar(self):
return self.x + 1

f = Foo(2.0)
self.assertEqual(f.bar(), 3.0)

def test_concrete_is_final(self):
class AbstractFoo(AbstractStrictModule):
@abstractmethod
def bar(self):
raise NotImplementedError

class Foo(AbstractFoo):
def bar(self):
return 42

with self.assertRaises(TypeError):

class SubFoo(Foo): # noqa: F841
pass

def test_no_synthetic_strict_base_method(self):
class AbstractFoo(AbstractStrictModule):
@abstractmethod
def bar(self):
raise NotImplementedError

class Foo(AbstractFoo):
def bar(self):
return 1

self.assertFalse(hasattr(Foo, "_strict_base_"))
17 changes: 17 additions & 0 deletions tests/test_strict.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ class AbstractThing(_AbstractBase):
def __init__(self, v: int):
self.value = v

def test_abstract_marker_base_without_abstract_members(self):
"""An Abstract*-named class with no abstract members anywhere is
treated as an abstract base: not instantiable, but subclassable."""

class AbstractMarker(Strict):
pass

with self.assertRaises(TypeError):
AbstractMarker()

class Final(AbstractMarker):
def __init__(self, v: int):
self.value = v

f = Final(7)
self.assertEqual(f.value, 7)

def test_concrete_subclassing_forbidden(self):
class AbstractBase(Strict):
value: AbstractAttribute[int]
Expand Down
Loading