diff --git a/doc/reference/param/parameterized.md b/doc/reference/param/parameterized.md index 6b43de01..8ba53af5 100644 --- a/doc/reference/param/parameterized.md +++ b/doc/reference/param/parameterized.md @@ -101,6 +101,7 @@ of {py:class}`Parameterized` classes and instances. :toctree: generated/ parameterized.batch_call_watchers + parameterized.classlist concrete_descendents depends descendents diff --git a/doc/user_guide/Parameter_Types.ipynb b/doc/user_guide/Parameter_Types.ipynb index 8297114a..12d84e91 100644 --- a/doc/user_guide/Parameter_Types.ipynb +++ b/doc/user_guide/Parameter_Types.ipynb @@ -1150,7 +1150,7 @@ "id": "fbd3c5d3", "metadata": {}, "source": [ - "As you can see, `D` is a type of `Parameterized`, and a `Parameterized` is a type of Python object. Conversely (and typically more usefully), `param.descendents` provides a list of the subclasses of the provided object, including itself:" + "As you can see, `D` is a type of `Parameterized`, and a `Parameterized` is a type of Python object. Conversely (and typically more usefully), `param.descendents(, concrete=True)` provides a list of the concrete subclasses of the provided object, including itself:" ] }, { @@ -1160,7 +1160,14 @@ "metadata": {}, "outputs": [], "source": [ - "param.descendents(param.SelectorBase)" + "class Base(param.Parameterized):\n", + " __abstract = True\n", + "\n", + "class A1(Base): pass\n", + "class A2(Base): pass\n", + "class B1(A1): pass\n", + "\n", + "param.descendents(Base, concrete=True)" ] }, { diff --git a/param/_utils.py b/param/_utils.py index 13b272b5..8781601a 100644 --- a/param/_utils.py +++ b/param/_utils.py @@ -9,7 +9,7 @@ import re import traceback import warnings -from collections import OrderedDict, abc, defaultdict +from collections import Counter, OrderedDict, abc, defaultdict from contextlib import contextmanager from numbers import Real from textwrap import dedent @@ -433,24 +433,30 @@ def _is_abstract(class_: type) -> bool: def descendents(class_: type, concrete: bool = False) -> list[type]: """ - Return a list of all descendant classes of a given class. + Return a list of all descendent classes of a given class. This function performs a breadth-first traversal of the class hierarchy, - collecting all subclasses of `class_`. The result includes `class_` itself - and all of its subclasses. If `concrete=True`, abstract base classes - are excluded from the result. + collecting all subclasses of ``class_``. The result includes ``class_`` itself + and all of its subclasses. If ``concrete=True``, abstract base classes + are excluded from the result, including :class:`Parameterized` abstract + classes declared with ``__abstract = True``. Parameters ---------- class_ : type The base class whose descendants should be found. concrete : bool, optional - If `True`, exclude abstract classes from the result. Default is `False`. + If ``True``, exclude abstract classes from the result. Default is ``False``. + + .. versionadded:: 2.3.0 + + Added to encourage users to use :func:`descendents` in favor of + :func:`concrete_descendents` that clobbers classes sharing the same name. Returns ------- - list of type - A list of descendant classes, ordered from the most base to the most derived. + list[type] + A list of descendent classes, ordered from the most base to the most derived. Examples -------- @@ -481,18 +487,38 @@ def descendents(class_: type, concrete: bool = False) -> list[type]: # Could be a method of ClassSelector. -def concrete_descendents(parentclass): +def concrete_descendents(parentclass: type) -> dict[str, type]: """ Return a dictionary containing all subclasses of the specified - parentclass, including the parentclass. Only classes that are - defined in scripts that have been run or modules that have been - imported are included, so the caller will usually first do ``from - package import *``. + parentclass, including the parentclass (prefer :func:`descendents`). + + Only classes that are defined in scripts that have been run or modules + that have been imported are included, so the caller will usually first + do ``from package import *``. Only non-abstract classes will be included. - """ - return {c.__name__:c for c in descendents(parentclass) - if not _is_abstract(c)} + + Warns + ----- + ParamWarning + ``concrete_descendents`` overrides descendents that share the same + class name. To avoid this, use :func:`descendents` with ``concrete=True``. + """ + desc = descendents(parentclass, concrete=True) + concrete_desc = {c.__name__: c for c in desc} + # Descendents with the same name are clobbered. + if len(desc) != len(concrete_desc): + class_count = Counter([kls.__name__ for kls in desc]) + clobbered = [kls for kls, count in class_count.items() if count > 1] + warnings.warn( + '`concrete_descendents` overrides descendents that share the same ' + 'class name. Other descendents with the same name as the following ' + f'classes exist but were not returned: {clobbered!r}\n' + 'Use `descendents(parentclass, concrete=True)` instead.', + ParamWarning, + ) + return concrete_desc + def _abbreviate_paths(pathspec,named_paths): """ diff --git a/param/depends.py b/param/depends.py index 31e2df75..0cf82370 100644 --- a/param/depends.py +++ b/param/depends.py @@ -42,9 +42,9 @@ def depends( func: CallableT, /, *dependencies: Dependency, watch: bool = False, on_init: bool = False, **kw: Parameter ) -> Callable[[CallableT], DependsFunc[CallableT]]: """ - Annotates a function or Parameterized method to express its dependencies. + Annotates a function or :class:`Parameterized` method to express its dependencies. - The specified dependencies can be either be Parameter instances or if a + The specified dependencies can be either be :class:`Parameter` instances or if a method is supplied they can be defined as strings referring to Parameters of the class, or Parameters of subobjects (Parameterized objects that are values of this object's parameters). Dependencies can either be on @@ -54,10 +54,10 @@ def depends( ---------- watch : bool, optional Whether to invoke the function/method when the dependency is updated, - by default False + by default ``False``. on_init : bool, optional Whether to invoke the function/method when the instance is created, - by default False + by default ``False``. """ dependencies, kw = ( diff --git a/param/parameterized.py b/param/parameterized.py index 982d5892..dec6498a 100644 --- a/param/parameterized.py +++ b/param/parameterized.py @@ -364,11 +364,12 @@ def _batch_call_watchers(parameterized, enable=True, run=True): @contextmanager def batch_call_watchers(parameterized): """ - Context manager to batch events to provide to Watchers on a - parameterized object. This context manager queues any events - triggered by setting a parameter on the supplied parameterized - object, saving them up to dispatch them all at once when the - context manager exits. + Context manager to batch events to provide to :class:`Watchers` on a + parameterized object. + + This context manager queues any events triggered by setting a parameter value + on the supplied :class:`Parameterized` object, saving them up to dispatch + them all at once when the context manager exits. """ BATCH_WATCH = parameterized.param._BATCH_WATCH parameterized.param._BATCH_WATCH = True @@ -394,22 +395,22 @@ def _syncing(parameterized, parameters): def edit_constant(parameterized: 'Parameterized') -> Generator[None, None, None]: """ Context manager to temporarily set parameters on a Parameterized object - to `constant=False` to allow editing them. + to ``constant=False`` to allow editing them. - The `edit_constant` context manager allows temporarily disabling the `constant` - property of all parameters on the given `Parameterized` object, enabling them - to be modified. Once the context exits, the original `constant` states are restored. + The ``edit_constant`` context manager allows temporarily disabling the ``constant`` + property of all parameters on the given :class:`Parameterized` object, enabling them + to be modified. Once the context exits, the original ``constant`` states are restored. Parameters ---------- parameterized : Parameterized - The `Parameterized` object whose parameters will have their `constant` + The :class:`Parameterized` object whose parameters will have their ``constant`` property temporarily disabled. Yields ------ None - A context where all parameters of the `Parameterized` object can be modified. + A context where all parameters of the :class:`Parameterized` object can be modified. Examples -------- @@ -418,7 +419,7 @@ def edit_constant(parameterized: 'Parameterized') -> Generator[None, None, None] ... constant_param = param.Number(default=10, constant=True) >>> p = MyClass() - Use `edit_constant` to modify the constant parameter: + Use ``edit_constant`` to modify the constant parameter: >>> with param.edit_constant(p): ... p.constant_param = 20 @@ -450,8 +451,8 @@ def discard_events(parameterized: 'Parameterized') -> Generator[None, None, None Context manager that discards any events within its scope triggered on the supplied Parameterized object. - The `discard_events` context manager ensures that any events triggered - on the supplied `Parameterized` object during its scope are discarded. + The ``discard_events`` context manager ensures that any events triggered + on the supplied :class:`Parameterized` object during its scope are discarded. This allows for silent changes to dependent parameters, making it useful for initialization or setup phases where changes should not propagate to watchers or dependencies. @@ -464,12 +465,12 @@ def discard_events(parameterized: 'Parameterized') -> Generator[None, None, None Parameters ---------- parameterized : Parameterized - The `Parameterized` object whose events will be suppressed. + The :class:`Parameterized` object whose events will be suppressed. Yields ------ None - A context where events on the supplied `Parameterized` object are discarded. + A context where events on the supplied :class:`Parameterized` object are discarded. References ---------- @@ -483,12 +484,15 @@ def discard_events(parameterized: 'Parameterized') -> Generator[None, None, None >>> import param >>> class MyClass(param.Parameterized): ... a = param.Number(default=1) + ... + ... @param.depends('a', watch=True) + ... def on_a(self): + ... print(self.a) >>> p = MyClass() - >>> param.bind(print, p.param.a, watch=True) - >>> p.a=2 + >>> p.a = 2 2 - Use `discard_events` to suppress events: + Use ``discard_events`` to suppress events: >>> with param.parameterized.discard_events(p): ... p.a = 3 @@ -510,7 +514,7 @@ def classlist(class_): """ Return a list of the class hierarchy above (and including) the given class. - Same as `inspect.getmro(class_)[::-1]` + Same as ``inspect.getmro(class_)[::-1]`` """ return inspect.getmro(class_)[::-1] @@ -641,9 +645,9 @@ def output(func, *output, **kw): """ Annotate a method to declare its outputs with specific types. - The `output` decorator allows annotating a method in a `Parameterized` class + The ``output`` decorator allows annotating a method in a :class:`Parameterized` class to declare the types of the values it returns. This provides metadata for the - method's outputs, which can be queried using the `Parameterized.param.outputs` + method's outputs, which can be queried using the :meth:`~Parameters.outputs` method. Outputs can be declared as unnamed, named, or typed, and the decorator supports multiple outputs. @@ -653,13 +657,15 @@ def output(func, *output, **kw): The method being annotated. *output : tuple or Parameter or type, optional Positional arguments to declare outputs. Can include: - - `Parameter` instances or Python object types (e.g., `int`, `str`). - - Tuples of the form `(name, type)` to declare named outputs. + + - :class:`Parameter` instances or Python object types (e.g., :class:`int`, :class:`str`). + - Tuples of the form ``(name, type)`` to declare named outputs. - Multiple such tuples for declaring multiple outputs. **kw : dict, optional Keyword arguments mapping output names to types. Types can be: - - `Parameter` instances. - - Python object types, which will be converted to `ClassSelector`. + + - :class:`Parameter` instances. + - Python object types, which will be converted to :class:`ClassSelector`. Returns ------- @@ -669,14 +675,13 @@ def output(func, *output, **kw): Raises ------ ValueError - If: - - An invalid type is provided for an output. - - Duplicate names are used for multiple outputs. + If an invalid type is provided for an output or duplicate names are + used for multiple outputs. Notes ----- - Unnamed outputs default to the method name. - - Python types are converted to `ClassSelector` instances. + - Python types are converted to :class:`ClassSelector` instances. - If no arguments are provided, the output is assumed to be an object without a specific type. diff --git a/param/parameters.py b/param/parameters.py index 8b9b0ced..d39d1c15 100644 --- a/param/parameters.py +++ b/param/parameters.py @@ -44,7 +44,8 @@ _produce_value, _get_min_max_value, _is_number, - concrete_descendents, + concrete_descendents, # noqa: F401 + descendents as _descendents, _abbreviate_paths, _to_datetime, ) @@ -2166,16 +2167,19 @@ def get_range(self): """ Return the possible types for this parameter's value. - (I.e. return `{name: }` for all classes that are - concrete_descendents() of `self.class_`.) + (I.e. return ``{name: }`` for all classes that are + :func:`param.parameterized.descendents` of ``self.class_``.) Only classes from modules that have been imported are added - (see concrete_descendents()). + (see :func:`param.parameterized.descendents`). """ classes = self.class_ if isinstance(self.class_, tuple) else (self.class_,) all_classes = {} for cls in classes: - all_classes.update(concrete_descendents(cls)) + desc = _descendents(cls, concrete=True) + # This will clobber separate classes with identical names. + # Known historical issue, see https://github.com/holoviz/param/pull/1035 + all_classes.update({c.__name__: c for c in desc}) d = OrderedDict((name, class_) for name,class_ in all_classes.items()) if self.allow_None: d['None'] = None diff --git a/tests/testdefaults.py b/tests/testdefaults.py index e45b02ca..7adbb7c7 100644 --- a/tests/testdefaults.py +++ b/tests/testdefaults.py @@ -5,7 +5,7 @@ import param -from param import concrete_descendents, Parameter +from param.parameterized import descendents, Parameter # import all parameter types from param import * # noqa @@ -90,7 +90,8 @@ class P(param.Parameterized): return test - for p_name, p_type in concrete_descendents(Parameter).items(): + for p_type in descendents(Parameter): + p_name = p_type.__name__ dict_["test_default_of_unbound_%s"%p_name] = add_test_unbound(p_type) if p_name not in skip else test_skip dict_["test_default_of_class_%s"%p_name] = add_test_class(p_type) if p_name not in skip else test_skip dict_["test_default_of_inst_%s"%p_name] = add_test_inst(p_type) if p_name not in skip else test_skip diff --git a/tests/testsignatures.py b/tests/testsignatures.py index f5fafca8..f962e99a 100644 --- a/tests/testsignatures.py +++ b/tests/testsignatures.py @@ -5,7 +5,7 @@ import param import pytest -from param import concrete_descendents, Parameter +from param import descendents, Parameter SKIP_UPDATED = [ @@ -18,8 +18,8 @@ def custom_concrete_descendents(kls): return { - pname: ptype - for pname, ptype in concrete_descendents(kls).items() + ptype.__name__: ptype + for ptype in descendents(kls) if ptype.__module__.startswith('param') } diff --git a/tests/testutils.py b/tests/testutils.py index 363f1d83..7fd92290 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -10,8 +10,10 @@ from param import guess_param_types, resolve_path from param.parameterized import bothmethod, Parameterized, ParameterizedABC from param._utils import ( + ParamWarning, _is_abstract, _is_mutable_container, + concrete_descendents, descendents, iscoroutinefunction, gen_types, @@ -534,3 +536,26 @@ class B(A): pass assert _is_abstract(A) assert not _is_abstract(B) + + +def test_concrete_descendents(): + assert concrete_descendents(A) == { + 'B': B, + 'C': C, + 'X': X, + 'Y': Y, + } + + +def test_concrete_descendents_same_name_warns(): + class X: pass + class Y(X): pass + y = Y # noqa + class Y(X): pass + with pytest.warns( + ParamWarning, + match=r".*\['Y'\]" + ): + cd = concrete_descendents(X) + # y not returned + assert cd == {'X': X, 'Y': Y}