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

Support declaring a Parameterized class as an ABC #1031

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions doc/reference/param/parameterized_objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
:nosignatures:

Parameterized
ParameterizedABC
ParameterizedFunction
```
2 changes: 1 addition & 1 deletion doc/user_guide/Parameter_Types.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,7 @@
"\n",
"A ClassSelector has a value that is either an instance or a subclass of a specified Python `class_`. By default, requires an instance of that class, but specifying `is_instance=False` means that a subclass must be selected instead.\n",
"\n",
"Like Selector types, all ClassSelector types implement `get_range()`, in this case providing an introspected list of all the concrete (not abstract) subclasses available for the given class. If you want a class to be treated as abstract so that it does not show up in such a list, you can have it declare `__abstract=True` as a class attribute. In a GUI, the range list allows a user to select a type of object they want to create, and they can then separately edit the new object's parameters (if any) to configure it appropriately."
"Like Selector types, all ClassSelector types implement `get_range()`, in this case providing an introspected list of all the concrete (not abstract) subclasses available for the given class. If you want a class to be treated as abstract so that it does not show up in such a list, you can have it inherit from `ParameterizedABC` or declare `__abstract = True` as a class attribute. In a GUI, the range list allows a user to select a type of object they want to create, and they can then separately edit the new object's parameters (if any) to configure it appropriately."
]
},
{
Expand Down
185 changes: 185 additions & 0 deletions doc/user_guide/Parameters.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,191 @@
"This approach can provide significant speedup and memory savings in certain cases, but should only be used for good reasons, since it can cause confusion for any code expecting instances to be independent as they have been declared."
]
},
{
"cell_type": "markdown",
"id": "14a20588",
"metadata": {},
"source": [
"## Parameterized Abstract Base Class\n",
"\n",
"Param supports two ways of declaring a class as an abstract base class (ABC), which is a good approach to define an interface subclasses should implement:\n",
"\n",
"- Added in version 2.3.0, an abstract Parameterized class can be created by inheriting from `ParameterizedABC`, which is equivalent as inheriting from `ABC` from the Python [abc](https://docs.python.org/3/library/abc.html) module.\n",
"- A Parameterized class can be annotated with the class attribute `__abstract` set to `True` to declare it as abstract.\n",
"\n",
"We recommend adopting the first approach that is more generic and powerful. The second approach is specific to Param (`inspect.isabstract(class)` won't return `True` for example) and preserved for compatibility reasons.\n",
"\n",
"Let's start with an example using the first approach, declaring the `ProcessorABC` interface with a `run` method subclasses must implement."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "94ad7fb9-1719-45a2-96a3-38f1c2b1f97c",
"metadata": {},
"outputs": [],
"source": [
"import abc\n",
"\n",
"class ProcessorABC(param.ParameterizedABC):\n",
" x = param.Number()\n",
" y = param.Number()\n",
"\n",
" @abc.abstractmethod\n",
" def run(self): pass"
]
},
{
"cell_type": "markdown",
"id": "323f7bb4-f153-49ed-bf1d-3429d31cf865",
"metadata": {},
"source": [
"Subclasses that do not implement the interface cannot be instantiated, this is the standard behavior of a Python ABC."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "0db17ea0-ed36-40f9-9e7a-10126e1b3ef3",
"metadata": {},
"outputs": [],
"source": [
"class BadProcessor(ProcessorABC):\n",
" def run_not_implemented(self): pass\n",
"\n",
"with param.exceptions_summarized():\n",
" BadProcessor()"
]
},
{
"cell_type": "markdown",
"id": "d4ab49c6-d1a8-4064-9d6f-dd2c6a1b61a5",
"metadata": {},
"source": [
"A valid subclass can be instantiated and used."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "717f781a-4c2f-446b-b8f1-25a0d95993e0",
"metadata": {},
"outputs": [],
"source": [
"class GoodProcessor(ProcessorABC):\n",
" def run(self):\n",
" return self.x * self.y\n",
"\n",
"GoodProcessor(x=2, y=4).run()"
]
},
{
"cell_type": "markdown",
"id": "7a2d952c-47bc-43cf-bfe0-fc011c02973a",
"metadata": {},
"source": [
"Let's see now how using the second approach differs from the first one by creating a new base class, this time a simple `Parameterized` subclass with a class attribute `__abstract` set to `True` "
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "b757bc37-0641-45da-9e7f-7a1cae97ea99",
"metadata": {},
"outputs": [],
"source": [
"class ProcessorBase(param.Parameterized):\n",
" __abstract = True\n",
"\n",
" x = param.Number()\n",
" y = param.Number()\n",
"\n",
" def run(self): raise NotImplementedError(\"Subclasses must implement the run method\")"
]
},
{
"cell_type": "markdown",
"id": "72ca4f32-6922-4ca4-80c4-0a9be0068877",
"metadata": {},
"source": [
"Param does not validate that subclasses correctly implement the interface. In this example, calling the non-implemented method will execute the method inherited from the base class."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "935b9889-2296-43f3-af70-f2d9da512cf6",
"metadata": {},
"outputs": [],
"source": [
"class BadProcessor(ProcessorBase):\n",
" def run_not_implemented(self): pass\n",
"\n",
"bp = BadProcessor()\n",
"with param.exceptions_summarized():\n",
" bp.run()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f60b383e-a735-453f-b706-513ebb909d41",
"metadata": {},
"outputs": [],
"source": [
"class GoodProcessor(ProcessorBase):\n",
" def run(self):\n",
" return self.x * self.y\n",
"\n",
"GoodProcessor(x=2, y=4).run()"
]
},
{
"cell_type": "markdown",
"id": "ccf36d91-c1d3-4534-949f-bf0d1863c163",
"metadata": {},
"source": [
"Parameterizes classes have an `abstract` property that returns `True` whenever a class is declared as abstract in the two supported approaches."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "16125aec",
"metadata": {},
"outputs": [],
"source": [
"ProcessorABC.abstract, ProcessorBase.abstract, GoodProcessor.abstract"
]
},
{
"cell_type": "markdown",
"id": "e9bbc4a2-3782-4568-99d9-dbdb1b6e0fe2",
"metadata": {},
"source": [
"The `descendents` function returns a list of all the descendents of a class including the parent class. It supports a `concrete` keyword that can be set to `True` to filter out abstract classes. Note that with the first approach, `BadProcessor` isn't returned as it doesn't implement the interface of the abstract class."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "933b1628",
"metadata": {},
"outputs": [],
"source": [
"param.descendents(ProcessorABC, concrete=True)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3b59e470",
"metadata": {},
"outputs": [],
"source": [
"param.descendents(ProcessorBase, concrete=True)"
]
},
{
"cell_type": "markdown",
"id": "678b7a0e",
Expand Down
3 changes: 2 additions & 1 deletion param/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
from .depends import depends
from .parameterized import (
Parameterized, Parameter, Skip, String, ParameterizedFunction,
ParamOverrides, Undefined, get_logger
ParamOverrides, Undefined, get_logger, ParameterizedABC,
)
from .parameterized import (batch_watch, output, script_repr,
discard_events, edit_constant)
Expand Down Expand Up @@ -197,6 +197,7 @@
'ParamOverrides',
'Parameter',
'Parameterized',
'ParameterizedABC',
'ParameterizedFunction',
'Path',
'Range',
Expand Down
5 changes: 3 additions & 2 deletions param/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,12 @@ def _validate_error_prefix(parameter, attribute=None):
- unbound and name can be found: "Number parameter 'x'"
- bound parameter: "Number parameter 'P.x'"
"""
from param.parameterized import ParameterizedMetaclass
from param.parameterized import ParameterizedMetaclass, ParameterizedABCMetaclass

pclass = type(parameter).__name__
if parameter.owner is not None:
if type(parameter.owner) is ParameterizedMetaclass:
otype = type(parameter.owner)
if otype is ParameterizedMetaclass or otype is ParameterizedABCMetaclass:
powner = parameter.owner.__name__
else:
powner = type(parameter.owner).__name__
Expand Down
17 changes: 17 additions & 0 deletions param/parameterized.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
__init__.py (providing specialized Parameter types).
"""

import abc
import asyncio
import copy
import datetime as dt
Expand Down Expand Up @@ -5288,6 +5289,22 @@ def __str__(self):
return f"<{self.__class__.__name__} {self.name}>"


class ParameterizedABCMetaclass(abc.ABCMeta, ParameterizedMetaclass):
"""Metaclass for abstract base classes using Parameterized.

Ensures compatibility between ABCMeta and ParameterizedMetaclass.
"""


class ParameterizedABC(Parameterized, metaclass=ParameterizedABCMetaclass):
"""Base class for user-defined ABCs that extends Parameterized."""

def __init_subclass__(cls, **kwargs):
if cls.__bases__ and cls.__bases__[0] is ParameterizedABC:
setattr(cls, f'_{cls.__name__}__abstract', True)
super().__init_subclass__(**kwargs)


def print_all_param_defaults():
"""Print the default values for all imported Parameters."""
print("_______________________________________________________________________________")
Expand Down
89 changes: 88 additions & 1 deletion tests/testparameterizedobject.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Unit test for Parameterized."""
import abc
import inspect
import re
import unittest
Expand All @@ -16,7 +17,9 @@

from param import parameterized, Parameter
from param.parameterized import (
ParameterizedABC,
ParamOverrides,
ParameterizedMetaclass,
Undefined,
default_label_formatter,
no_instance_params,
Expand Down Expand Up @@ -365,11 +368,28 @@ def test_instantiation_inheritance(self):
assert t.param['instPO'].instantiate is True
assert isinstance(t.instPO,AnotherTestPO)

def test_abstract_class(self):
def test_abstract_class_attribute(self):
"""Check that a class declared abstract actually shows up as abstract."""
self.assertEqual(TestAbstractPO.abstract, True)
self.assertEqual(_AnotherAbstractPO.abstract, True)
self.assertEqual(TestPO.abstract, False)
# Test subclasses are not abstract
class A(param.Parameterized):
__abstract = True
class B(A): pass
class C(A): pass
self.assertEqual(A.abstract, True)
self.assertEqual(B.abstract, False)
self.assertEqual(C.abstract, False)

def test_abstract_class_abc(self):
"""Check that an ABC class actually shows up as abstract."""
class A(ParameterizedABC): pass
class B(A): pass
class C(A): pass
self.assertEqual(A.abstract, True)
self.assertEqual(B.abstract, False)
self.assertEqual(C.abstract, False)

def test_override_class_param_validation(self):
test = TestPOValidation()
Expand Down Expand Up @@ -1800,3 +1820,70 @@ class B(A): pass
):
class C(B):
p = param.ClassSelector(class_=str)


class MyABC(ParameterizedABC):

x = param.Number()

@abc.abstractmethod
def method(self): pass

@property
@abc.abstractmethod
def property(self): pass
# Other methods like abc.abstractproperty are deprecated and can be
# replaced by combining @abc.abstracmethod with other decorators, like
# @property, @classmethod, etc. No need to test them all.


def test_abc_insintance_metaclass():
assert isinstance(MyABC, ParameterizedMetaclass)


def test_abc_param_abstract():
assert MyABC.abstract


def test_abc_error_when_interface_not_implemented():
class Bad(MyABC):
def wrong_method(self): pass

with pytest.raises(TypeError, match="Can't instantiate abstract class Bad"):
Bad()

def test_abc_basic_checks():
# Some very basic tests to check the concrete class works as expected.
class GoodConcrete(MyABC):
l = param.List()

def method(self):
return 'foo'

@property
def property(self):
return 'bar'

@param.depends('x', watch=True, on_init=True)
def on_x(self):
self.l.append(self.x)

assert issubclass(GoodConcrete, param.Parameterized)
assert not GoodConcrete.abstract

assert GoodConcrete.name == 'GoodConcrete'

with pytest.raises(
ValueError,
match=re.escape("Number parameter 'MyABC.x' only takes numeric values, not <class 'str'>."),
):
GoodConcrete(x='bad')

gc = GoodConcrete(x=10)
assert isinstance(gc, param.Parameterized)
assert gc.method() == 'foo'
assert gc.property == 'bar'
assert gc.name.startswith('GoodConcrete0')
assert gc.l == [10]
gc.x += 1
assert gc.l == [10, 11]
Loading
Loading