Skip to content

Commit e6d7ae6

Browse files
maximltphilippjfr
andauthored
Support declaring a Parameterized class as an ABC (#1031)
Co-authored-by: Philipp Rudiger <[email protected]>
1 parent 4bc8482 commit e6d7ae6

File tree

7 files changed

+325
-4
lines changed

7 files changed

+325
-4
lines changed

doc/reference/param/parameterized_objects.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
:nosignatures:
1111
1212
Parameterized
13+
ParameterizedABC
1314
ParameterizedFunction
1415
```

doc/user_guide/Parameter_Types.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,7 @@
969969
"\n",
970970
"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",
971971
"\n",
972-
"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."
972+
"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."
973973
]
974974
},
975975
{

doc/user_guide/Parameters.ipynb

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,195 @@
786786
"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."
787787
]
788788
},
789+
{
790+
"cell_type": "markdown",
791+
"id": "14a20588",
792+
"metadata": {},
793+
"source": [
794+
"## Parameterized Abstract Base Class\n",
795+
"\n",
796+
"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",
797+
"\n",
798+
"- 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",
799+
"- A Parameterized class can be annotated with the class attribute `__abstract` set to `True` to declare it as abstract.\n",
800+
"\n",
801+
"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",
802+
"\n",
803+
":::{warning}\n",
804+
"It is recommended not to mix the two approaches.\n",
805+
":::\n",
806+
"\n",
807+
"Let's start with an example using the first approach, declaring the `ProcessorABC` interface with a `run` method subclasses must implement."
808+
]
809+
},
810+
{
811+
"cell_type": "code",
812+
"execution_count": null,
813+
"id": "94ad7fb9-1719-45a2-96a3-38f1c2b1f97c",
814+
"metadata": {},
815+
"outputs": [],
816+
"source": [
817+
"import abc\n",
818+
"\n",
819+
"class ProcessorABC(param.ParameterizedABC):\n",
820+
" x = param.Number()\n",
821+
" y = param.Number()\n",
822+
"\n",
823+
" @abc.abstractmethod\n",
824+
" def run(self): pass"
825+
]
826+
},
827+
{
828+
"cell_type": "markdown",
829+
"id": "323f7bb4-f153-49ed-bf1d-3429d31cf865",
830+
"metadata": {},
831+
"source": [
832+
"Subclasses that do not implement the interface cannot be instantiated, this is the standard behavior of a Python ABC."
833+
]
834+
},
835+
{
836+
"cell_type": "code",
837+
"execution_count": null,
838+
"id": "0db17ea0-ed36-40f9-9e7a-10126e1b3ef3",
839+
"metadata": {},
840+
"outputs": [],
841+
"source": [
842+
"class BadProcessor(ProcessorABC):\n",
843+
" def run_not_implemented(self): pass\n",
844+
"\n",
845+
"with param.exceptions_summarized():\n",
846+
" BadProcessor()"
847+
]
848+
},
849+
{
850+
"cell_type": "markdown",
851+
"id": "d4ab49c6-d1a8-4064-9d6f-dd2c6a1b61a5",
852+
"metadata": {},
853+
"source": [
854+
"A valid subclass can be instantiated and used."
855+
]
856+
},
857+
{
858+
"cell_type": "code",
859+
"execution_count": null,
860+
"id": "717f781a-4c2f-446b-b8f1-25a0d95993e0",
861+
"metadata": {},
862+
"outputs": [],
863+
"source": [
864+
"class GoodProcessor(ProcessorABC):\n",
865+
" def run(self):\n",
866+
" return self.x * self.y\n",
867+
"\n",
868+
"GoodProcessor(x=2, y=4).run()"
869+
]
870+
},
871+
{
872+
"cell_type": "markdown",
873+
"id": "7a2d952c-47bc-43cf-bfe0-fc011c02973a",
874+
"metadata": {},
875+
"source": [
876+
"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` "
877+
]
878+
},
879+
{
880+
"cell_type": "code",
881+
"execution_count": null,
882+
"id": "b757bc37-0641-45da-9e7f-7a1cae97ea99",
883+
"metadata": {},
884+
"outputs": [],
885+
"source": [
886+
"class ProcessorBase(param.Parameterized):\n",
887+
" __abstract = True\n",
888+
"\n",
889+
" x = param.Number()\n",
890+
" y = param.Number()\n",
891+
"\n",
892+
" def run(self): raise NotImplementedError(\"Subclasses must implement the run method\")"
893+
]
894+
},
895+
{
896+
"cell_type": "markdown",
897+
"id": "72ca4f32-6922-4ca4-80c4-0a9be0068877",
898+
"metadata": {},
899+
"source": [
900+
"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."
901+
]
902+
},
903+
{
904+
"cell_type": "code",
905+
"execution_count": null,
906+
"id": "935b9889-2296-43f3-af70-f2d9da512cf6",
907+
"metadata": {},
908+
"outputs": [],
909+
"source": [
910+
"class BadProcessor(ProcessorBase):\n",
911+
" def run_not_implemented(self): pass\n",
912+
"\n",
913+
"bp = BadProcessor()\n",
914+
"with param.exceptions_summarized():\n",
915+
" bp.run()"
916+
]
917+
},
918+
{
919+
"cell_type": "code",
920+
"execution_count": null,
921+
"id": "f60b383e-a735-453f-b706-513ebb909d41",
922+
"metadata": {},
923+
"outputs": [],
924+
"source": [
925+
"class GoodProcessor(ProcessorBase):\n",
926+
" def run(self):\n",
927+
" return self.x * self.y\n",
928+
"\n",
929+
"GoodProcessor(x=2, y=4).run()"
930+
]
931+
},
932+
{
933+
"cell_type": "markdown",
934+
"id": "ccf36d91-c1d3-4534-949f-bf0d1863c163",
935+
"metadata": {},
936+
"source": [
937+
"Parameterizes classes have an `abstract` property that returns `True` whenever a class is declared as abstract in the two supported approaches."
938+
]
939+
},
940+
{
941+
"cell_type": "code",
942+
"execution_count": null,
943+
"id": "16125aec",
944+
"metadata": {},
945+
"outputs": [],
946+
"source": [
947+
"ProcessorABC.abstract, ProcessorBase.abstract, GoodProcessor.abstract"
948+
]
949+
},
950+
{
951+
"cell_type": "markdown",
952+
"id": "e9bbc4a2-3782-4568-99d9-dbdb1b6e0fe2",
953+
"metadata": {},
954+
"source": [
955+
"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."
956+
]
957+
},
958+
{
959+
"cell_type": "code",
960+
"execution_count": null,
961+
"id": "933b1628",
962+
"metadata": {},
963+
"outputs": [],
964+
"source": [
965+
"param.descendents(ProcessorABC, concrete=True)"
966+
]
967+
},
968+
{
969+
"cell_type": "code",
970+
"execution_count": null,
971+
"id": "3b59e470",
972+
"metadata": {},
973+
"outputs": [],
974+
"source": [
975+
"param.descendents(ProcessorBase, concrete=True)"
976+
]
977+
},
789978
{
790979
"cell_type": "markdown",
791980
"id": "678b7a0e",

param/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from .depends import depends
4747
from .parameterized import (
4848
Parameterized, Parameter, Skip, String, ParameterizedFunction,
49-
ParamOverrides, Undefined, get_logger
49+
ParamOverrides, Undefined, get_logger, ParameterizedABC,
5050
)
5151
from .parameterized import (batch_watch, output, script_repr,
5252
discard_events, edit_constant)
@@ -197,6 +197,7 @@
197197
'ParamOverrides',
198198
'Parameter',
199199
'Parameterized',
200+
'ParameterizedABC',
200201
'ParameterizedFunction',
201202
'Path',
202203
'Range',

param/parameterized.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
__init__.py (providing specialized Parameter types).
99
"""
1010

11+
import abc
1112
import copy
1213
import datetime as dt
1314
import inspect
@@ -5720,6 +5721,22 @@ def __str__(self):
57205721
return f"<{self.__class__.__name__} {self.name}>"
57215722

57225723

5724+
class ParameterizedABCMetaclass(abc.ABCMeta, ParameterizedMetaclass):
5725+
"""Metaclass for abstract base classes using Parameterized.
5726+
5727+
Ensures compatibility between ABCMeta and ParameterizedMetaclass.
5728+
"""
5729+
5730+
5731+
class ParameterizedABC(Parameterized, metaclass=ParameterizedABCMetaclass):
5732+
"""Base class for user-defined ABCs that extends Parameterized."""
5733+
5734+
def __init_subclass__(cls, **kwargs):
5735+
if cls.__bases__ and cls.__bases__[0] is ParameterizedABC:
5736+
setattr(cls, f'_{cls.__name__}__abstract', True)
5737+
super().__init_subclass__(**kwargs)
5738+
5739+
57235740
# As of Python 2.6+, a fn's **args no longer has to be a
57245741
# dictionary. This might allow us to use a decorator to simplify using
57255742
# ParamOverrides (if that does indeed make them simpler to use).

tests/testparameterizedobject.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Unit test for Parameterized."""
2+
import abc
23
import inspect
34
import re
45
import unittest
@@ -17,7 +18,9 @@
1718

1819
from param import parameterized, Parameter
1920
from param.parameterized import (
21+
ParameterizedABC,
2022
ParamOverrides,
23+
ParameterizedMetaclass,
2124
Undefined,
2225
default_label_formatter,
2326
edit_constant,
@@ -378,11 +381,28 @@ def test_instantiation_inheritance(self):
378381
assert t.param['instPO'].instantiate is True
379382
assert isinstance(t.instPO,AnotherTestPO)
380383

381-
def test_abstract_class(self):
384+
def test_abstract_class_attribute(self):
382385
"""Check that a class declared abstract actually shows up as abstract."""
383386
self.assertEqual(TestAbstractPO.abstract, True)
384387
self.assertEqual(_AnotherAbstractPO.abstract, True)
385388
self.assertEqual(TestPO.abstract, False)
389+
# Test subclasses are not abstract
390+
class A(param.Parameterized):
391+
__abstract = True
392+
class B(A): pass
393+
class C(A): pass
394+
self.assertEqual(A.abstract, True)
395+
self.assertEqual(B.abstract, False)
396+
self.assertEqual(C.abstract, False)
397+
398+
def test_abstract_class_abc(self):
399+
"""Check that an ABC class actually shows up as abstract."""
400+
class A(ParameterizedABC): pass
401+
class B(A): pass
402+
class C(A): pass
403+
self.assertEqual(A.abstract, True)
404+
self.assertEqual(B.abstract, False)
405+
self.assertEqual(C.abstract, False)
386406

387407
def test_override_class_param_validation(self):
388408
test = TestPOValidation()
@@ -1850,3 +1870,70 @@ class B(A): pass
18501870
):
18511871
class C(B):
18521872
p = param.ClassSelector(class_=str)
1873+
1874+
1875+
class MyABC(ParameterizedABC):
1876+
1877+
x = param.Number()
1878+
1879+
@abc.abstractmethod
1880+
def method(self): pass
1881+
1882+
@property
1883+
@abc.abstractmethod
1884+
def property(self): pass
1885+
# Other methods like abc.abstractproperty are deprecated and can be
1886+
# replaced by combining @abc.abstracmethod with other decorators, like
1887+
# @property, @classmethod, etc. No need to test them all.
1888+
1889+
1890+
def test_abc_insintance_metaclass():
1891+
assert isinstance(MyABC, ParameterizedMetaclass)
1892+
1893+
1894+
def test_abc_param_abstract():
1895+
assert MyABC.abstract
1896+
1897+
1898+
def test_abc_error_when_interface_not_implemented():
1899+
class Bad(MyABC):
1900+
def wrong_method(self): pass
1901+
1902+
with pytest.raises(TypeError, match="Can't instantiate abstract class Bad"):
1903+
Bad()
1904+
1905+
def test_abc_basic_checks():
1906+
# Some very basic tests to check the concrete class works as expected.
1907+
class GoodConcrete(MyABC):
1908+
l = param.List()
1909+
1910+
def method(self):
1911+
return 'foo'
1912+
1913+
@property
1914+
def property(self):
1915+
return 'bar'
1916+
1917+
@param.depends('x', watch=True, on_init=True)
1918+
def on_x(self):
1919+
self.l.append(self.x)
1920+
1921+
assert issubclass(GoodConcrete, param.Parameterized)
1922+
assert not GoodConcrete.abstract
1923+
1924+
assert GoodConcrete.name == 'GoodConcrete'
1925+
1926+
with pytest.raises(
1927+
ValueError,
1928+
match=re.escape("Number parameter 'MyABC.x' only takes numeric values, not <class 'str'>."),
1929+
):
1930+
GoodConcrete(x='bad')
1931+
1932+
gc = GoodConcrete(x=10)
1933+
assert isinstance(gc, param.Parameterized)
1934+
assert gc.method() == 'foo'
1935+
assert gc.property == 'bar'
1936+
assert gc.name.startswith('GoodConcrete0')
1937+
assert gc.l == [10]
1938+
gc.x += 1
1939+
assert gc.l == [10, 11]

0 commit comments

Comments
 (0)