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
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
189 changes: 189 additions & 0 deletions doc/user_guide/Parameters.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,195 @@
"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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it is worth adding a follow on sentence to strongly recommend not mixing approaches? Having two inconsistent ways of checking if a class is abstract sounds like a recipe for confusion!

That said, maybe param could have a wrapper utility that tries inspect.isabstract(class) first and if that returns False, checks again the param way?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment added in ce27b13

That said, maybe param could have a wrapper utility that tries inspect.isabstract(class) first and if that returns False, checks again the param way?

Internally, we have the _is_abstract function that does exactly that. I don't really want to make it public though:

  • Parameterized classes have an abstract property (that's documented too, I checked) that accounts for both approaches
  • The less public API the better
  • Ultimately, the goal would be to deprecate and remove the old abstract way

If people feel strongly about it, we can add it in another release anyway.

"\n",
":::{warning}\n",
"It is recommended not to mix the two approaches.\n",
":::\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
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 copy
import datetime as dt
import inspect
Expand Down Expand Up @@ -5314,6 +5315,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)


# As of Python 2.6+, a fn's **args no longer has to be a
# dictionary. This might allow us to use a decorator to simplify using
# ParamOverrides (if that does indeed make them simpler to use).
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,
edit_constant,
Expand Down Expand Up @@ -377,11 +380,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 @@ -1843,3 +1863,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