diff --git a/tests/test_traitlets.py b/tests/test_traitlets.py index d2c755a45..db1c4fb03 100644 --- a/tests/test_traitlets.py +++ b/tests/test_traitlets.py @@ -937,6 +937,32 @@ class A(HasTraits): traits = a.traits(config_key=lambda v: True) self.assertEqual(traits, dict(i=A.i, f=A.f, j=A.j)) + def test_traits_metadata_filter_caching(self): + # metadata-filtered class_traits()/traits() results are memoized per + # class; make sure the cache preserves the "fresh dict" contract and is + # invalidated when metadata is mutated after class creation. + class A(HasTraits): + i = Int().tag(config=True) + j = Int() + + # returned dict is a fresh copy the caller may mutate freely + first = A.class_traits(config=True) + self.assertEqual(first, dict(i=A.i)) + first["injected"] = "oops" + self.assertEqual(A.class_traits(config=True), dict(i=A.i)) + + # tagging a trait after the result was cached must be reflected + A.j.tag(config=True) + self.assertEqual(A.class_traits(config=True), dict(i=A.i, j=A.j)) + self.assertEqual(A().traits(config=True), dict(i=A.i, j=A.j)) + + # a subclass has its own cache and does not pollute the parent's + class B(A): + k = Int().tag(config=True) + + self.assertEqual(B.class_traits(config=True), dict(i=A.i, j=A.j, k=B.k)) + self.assertEqual(A.class_traits(config=True), dict(i=A.i, j=A.j)) + def test_traits_metadata_deprecated(self): with expected_warnings([r"metadata should be set using the \.tag\(\) method"] * 2): diff --git a/traitlets/config/application.py b/traitlets/config/application.py index 5b1f0e6aa..dc80ab123 100644 --- a/traitlets/config/application.py +++ b/traitlets/config/application.py @@ -5,17 +5,14 @@ from __future__ import annotations import functools -import json import logging import os -import pprint import re import sys import typing as t from collections import OrderedDict, defaultdict from contextlib import suppress from copy import deepcopy -from logging.config import dictConfig from textwrap import dedent from traitlets.config.configurable import Configurable, SingletonConfigurable @@ -286,6 +283,8 @@ def _observe_logging_default(self, change: Bunch) -> None: self._configure_logging() def _configure_logging(self) -> None: + from logging.config import dictConfig + config = self.get_default_logging_config() nested_update(config, self.logging_config or {}) dictConfig(config) @@ -483,6 +482,8 @@ def start_show_config(self) -> None: cls_config.pop("show_config_json", None) if self.show_config_json: + import json + json.dump(config, sys.stdout, indent=1, sort_keys=True, default=repr) # add trailing newline sys.stdout.write("\n") @@ -498,6 +499,8 @@ def start_show_config(self) -> None: class_config = config[classname] if not class_config: continue + import pprint + print(classname) pformat_kwargs: StrDict = dict(indent=4, compact=True) # noqa: C408 @@ -929,6 +932,8 @@ def _load_config_files( if log: log.debug("Loaded config file: %s", loader.full_filename) if config: + import json + for filename, earlier_config in zip(filenames, loaded, strict=True): collisions = earlier_config.collisions(config) if collisions and log: diff --git a/traitlets/config/configurable.py b/traitlets/config/configurable.py index d2bafef88..d8acfc24c 100644 --- a/traitlets/config/configurable.py +++ b/traitlets/config/configurable.py @@ -171,12 +171,15 @@ def _load_config( ) -> None: """load traits from a Config object""" + my_config = self._find_my_config(cfg) + if not my_config: + # Nothing in the config applies to this instance; avoid the cost of + # computing traits() and entering hold_trait_notifications() for the + # many leaf Configurables that carry no matching config. + return + if traits is None: traits = self.traits(config=True) - if section_names is None: - section_names = self.section_names() - - my_config = self._find_my_config(cfg) # hold trait notifications until after all config has been loaded with self.hold_trait_notifications(): diff --git a/traitlets/config/loader.py b/traitlets/config/loader.py index 45930cce4..17b4b86f5 100644 --- a/traitlets/config/loader.py +++ b/traitlets/config/loader.py @@ -7,7 +7,6 @@ import argparse import copy import functools -import json import os import re import sys @@ -565,6 +564,8 @@ def load_config(self) -> Config: return self.config def _read_file_as_dict(self) -> dict[str, t.Any]: + import json + with open(self.full_filename) as f: return t.cast("dict[str, t.Any]", json.load(f)) @@ -591,6 +592,8 @@ def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> No configuration to disk. """ self.config.version = 1 + import json + json_config = json.dumps(self.config, indent=2) with open(self.full_filename, "w") as f: f.write(json_config) diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index d149f13c3..e5b370722 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -43,15 +43,12 @@ import contextlib import enum -import inspect import numbers import os -import pathlib import re import sys import types import typing as t -from ast import literal_eval from .utils.bunch import Bunch from .utils.descriptions import add_article, class_of, describe, repr_type @@ -62,7 +59,15 @@ SequenceTypes = (list, tuple, set, frozenset) +# Bumped whenever trait metadata is mutated after class creation (via +# TraitType.tag()/set_metadata()). Used to invalidate the per-class cache of +# metadata-filtered traits kept by HasTraits._traits_matching_metadata. Kept in +# a one-element list so it can be mutated without a module-level `global`. +_trait_metadata_generation = [0] + if t.TYPE_CHECKING: + import pathlib + from typing_extensions import TypeVar else: from typing import TypeVar @@ -185,6 +190,8 @@ def _safe_literal_eval(s: str) -> t.Any: Use only where types are ambiguous. """ + from ast import literal_eval + try: return literal_eval(s) except (NameError, SyntaxError, ValueError): @@ -559,7 +566,7 @@ def __init__( if len(kwargs) > 0: stacklevel = 1 - f = inspect.currentframe() + f: types.FrameType | None = sys._getframe() # count supers to determine stacklevel for warning assert f is not None while f.f_code.co_name == "__init__": @@ -870,6 +877,7 @@ def set_metadata(self, key: str, value: t.Any) -> None: else: msg = "use the instance .metadata dictionary directly, like x.metadata[key] = value" warn("Deprecated in traitlets 4.1, " + msg, DeprecationWarning, stacklevel=2) + _trait_metadata_generation[0] += 1 self.metadata[key] = value def tag(self, **metadata: t.Any) -> Self: @@ -893,6 +901,7 @@ def tag(self, **metadata: t.Any) -> Self: stacklevel=2, ) + _trait_metadata_generation[0] += 1 self.metadata.update(metadata) return self @@ -974,7 +983,7 @@ def __new__( # ---------------------------------------------------------------- # Support of deprecated behavior allowing for TraitType types # to be used instead of TraitType instances. - if inspect.isclass(v) and issubclass(v, TraitType): + if isinstance(v, type) and issubclass(v, TraitType): warn( "Traits should be given as instances, not types (for example, `Int()`, not `Int`)." " Passing types is deprecated in traitlets 4.1.", @@ -993,12 +1002,18 @@ def __init__( super().__init__(name, bases, classdict, **kwds) cls.setup_class(classdict) - def setup_class(cls: MetaHasDescriptors, classdict: dict[str, t.Any]) -> None: + def setup_class( + cls: MetaHasDescriptors, classdict: dict[str, t.Any] + ) -> list[tuple[str, t.Any]]: """Setup descriptor instance on the class This sets the :attr:`this_class` and :attr:`name` attributes of each BaseDescriptor in the class dict of the newly created ``cls`` before calling their :attr:`class_init` method. + + Returns the ``getmembers(cls)`` result so that subclass metaclasses + (e.g. :class:`MetaHasTraits`) can reuse it instead of walking the + class namespace a second time. """ cls._descriptors = [] cls._instance_inits: list[t.Any] = [] @@ -1006,35 +1021,34 @@ def setup_class(cls: MetaHasDescriptors, classdict: dict[str, t.Any]) -> None: if isinstance(v, BaseDescriptor): v.class_init(cls, k) # type:ignore[arg-type] - for _, v in getmembers(cls): + members = getmembers(cls) + for _, v in members: if isinstance(v, BaseDescriptor): v.subclass_init(cls) # type:ignore[arg-type] cls._descriptors.append(v) + return members class MetaHasTraits(MetaHasDescriptors): """A metaclass for HasTraits.""" - def setup_class(cls: MetaHasTraits, classdict: dict[str, t.Any]) -> None: + def setup_class(cls: MetaHasTraits, classdict: dict[str, t.Any]) -> list[tuple[str, t.Any]]: # for only the current class cls._trait_default_generators: dict[str, t.Any] = {} # also looking at base classes cls._all_trait_default_generators = {} cls._traits = {} + # per-class cache for metadata-filtered class_traits()/traits() results + cls._traits_metadata_cache: dict[t.Any, tuple[int, dict[str, t.Any]]] = {} cls._static_immutable_initial_values = {} - super().setup_class(classdict) + # Reuse the members collected by the parent metaclass rather than + # walking the whole class namespace (dir(cls) + getattr) a second time. + members = super().setup_class(classdict) mro = cls.mro() - for name in dir(cls): - # Some descriptors raise AttributeError like zope.interface's - # __provides__ attributes even though they exist. This causes - # AttributeErrors even though they are listed in dir(cls). - try: - value = getattr(cls, name) - except AttributeError: - continue + for name, value in members: if isinstance(value, TraitType): cls._traits[name] = value trait = value @@ -1100,6 +1114,8 @@ def setup_class(cls: MetaHasTraits, classdict: dict[str, t.Any]) -> None: # and then the instance may not have all the _static_immutable_initial_values cls._all_trait_default_generators[name] = trait.default + return members + def observe(*names: Sentinel | str, type: str = "change") -> ObserveHandler: """A decorator which can be used to observe Traits on a class. @@ -1333,6 +1349,7 @@ class HasTraits(HasDescriptors, metaclass=MetaHasTraits): _trait_validators: dict[str | Sentinel, t.Any] _cross_validation_lock: bool _traits: dict[str, t.Any] + _traits_metadata_cache: dict[t.Any, tuple[int, dict[str, TraitType[t.Any, t.Any]]]] _all_trait_default_generators: dict[str, t.Any] def setup_instance(self, /, *args: t.Any, **kwargs: t.Any) -> None: @@ -1377,9 +1394,17 @@ def ignore(change: Bunch) -> None: # notify and cross validate all trait changes that were set in kwargs changed = set(kwargs) & set(self._traits) for key in changed: - value = self._traits[key]._cross_validate(self, getattr(self, key)) - self.set_trait(key, value) - changes[key]["new"] = value + # Only re-run the (relatively expensive) cross-validation + + # set_trait pass for traits that actually have a cross-validator. + # For the common case with none, the value stored by the fast + # loop above is already fully validated; we just need to record + # the (possibly coerced) stored value for the notification. + if key in self._trait_validators or hasattr(self, f"_{key}_validate"): + value = self._traits[key]._cross_validate(self, getattr(self, key)) + self.set_trait(key, value) + changes[key]["new"] = value + else: + changes[key]["new"] = getattr(self, key) self._cross_validation_lock = False # Restore method retrieval from class del self.notify_change @@ -1796,21 +1821,64 @@ def class_traits(cls: type[HasTraits], **metadata: t.Any) -> dict[str, TraitType the output. If a metadata key doesn't exist, None will be passed to the function. """ - traits = cls._traits.copy() - if len(metadata) == 0: - return traits + return cls._traits.copy() + + # Return a copy so callers can freely mutate the result; the underlying + # (cached) dict must not escape by reference. + return cls._traits_matching_metadata(metadata).copy() + + @classmethod + def _traits_matching_metadata( + cls: type[HasTraits], metadata: dict[str, t.Any] + ) -> dict[str, TraitType[t.Any, t.Any]]: + """Return the subset of ``cls._traits`` matching a metadata filter. - result = {} - for name, trait in traits.items(): - for meta_name, meta_eval in metadata.items(): - if not callable(meta_eval): - meta_eval = _SimpleTest(meta_eval) + The result is shared, not copied — callers (``class_traits``/``traits``) + are responsible for copying before returning it to user code. + + For filters whose values are all non-callable and hashable (the hot + path, e.g. ``config=True``), the result is memoized per class. Because + ``cls._traits`` is frozen after class creation, the only way the answer + can change is a post-hoc metadata mutation via ``tag()``/``set_metadata()``, + which bump ``_trait_metadata_generation``; cache entries older than the + current generation are recomputed. + """ + # Build a cache key only for constant (non-callable) filters; callable + # predicates are the cold path and are never cached. + key: t.Any = None + if not any(callable(v) for v in metadata.values()): + try: + key = tuple(sorted(metadata.items())) + hash(key) # ensure the values are hashable before use as a key + except TypeError: + key = None + + generation = _trait_metadata_generation[0] + cache: dict[t.Any, tuple[int, dict[str, TraitType[t.Any, t.Any]]]] | None = ( + cls.__dict__.get("_traits_metadata_cache") + ) + if key is not None and cache is not None: + entry = cache.get(key) + if entry is not None and entry[0] == generation: + return entry[1] + + # Normalize the metadata filters once, rather than rebuilding a + # _SimpleTest for every trait on every call. + checks = [ + (meta_name, meta_eval if callable(meta_eval) else _SimpleTest(meta_eval)) + for meta_name, meta_eval in metadata.items() + ] + result: dict[str, TraitType[t.Any, t.Any]] = {} + for name, trait in cls._traits.items(): + for meta_name, meta_eval in checks: if not meta_eval(trait.metadata.get(meta_name, None)): break else: result[name] = trait + if key is not None and cache is not None: + cache[key] = (generation, result) return result @classmethod @@ -1929,22 +1997,12 @@ def traits(self, **metadata: t.Any) -> dict[str, TraitType[t.Any, t.Any]]: the output. If a metadata key doesn't exist, None will be passed to the function. """ - traits = self._traits.copy() - if len(metadata) == 0: - return traits - - result = {} - for name, trait in traits.items(): - for meta_name, meta_eval in metadata.items(): - if not callable(meta_eval): - meta_eval = _SimpleTest(meta_eval) - if not meta_eval(trait.metadata.get(meta_name, None)): - break - else: - result[name] = trait + return self._traits.copy() - return result + # Delegates to the (cached) class-level implementation; self._traits is + # always type(self)._traits. Return a copy so callers can mutate freely. + return type(self)._traits_matching_metadata(metadata).copy() def trait_metadata(self, traitname: str, key: str, default: t.Any = None) -> t.Any: """Get metadata values for trait by key.""" @@ -2114,7 +2172,7 @@ def __init__( else: klass = default_value - if not (inspect.isclass(klass) or isinstance(klass, str)): + if not isinstance(klass, (type, str)): raise TraitError("A Type trait must specify a class.") self.klass = klass @@ -2278,7 +2336,7 @@ class or its subclasses. Our implementation is quite different if klass is None: klass = self.klass - if (klass is not None) and (inspect.isclass(klass) or isinstance(klass, str)): + if (klass is not None) and isinstance(klass, (type, str)): self.klass = klass else: raise TraitError(f"The klass attribute must be a class not: {klass!r}") @@ -3510,6 +3568,8 @@ def from_string(self, s: str) -> T | None: """Load value from a single string""" if not isinstance(s, str): raise TraitError(f"Expected string, got {s!r}") + from ast import literal_eval + try: test = literal_eval(s) except Exception: @@ -3542,7 +3602,11 @@ def from_string_list(self, s_list: list[str]) -> T | None: DeprecationWarning, stacklevel=2, ) + from ast import literal_eval + return self.klass(literal_eval(r)) # type:ignore[call-arg] + import inspect + sig = inspect.signature(self.item_from_string) if "index" in sig.parameters: item_from_string = self.item_from_string @@ -4069,6 +4133,7 @@ def from_string_list(self, s_list: list[str]) -> t.Any: DeprecationWarning, stacklevel=2, ) + from ast import literal_eval return literal_eval(s_list[0]) @@ -4192,11 +4257,15 @@ class Path(TraitType["pathlib.Path", t.Union["pathlib.Path", str, "os.PathLike[s info_text = "a filesystem path" def validate(self, obj: t.Any, value: t.Any) -> pathlib.Path | None: + import pathlib + if isinstance(value, (str, os.PathLike)): return pathlib.Path(value) self.error(obj, value) def from_string(self, s: str) -> pathlib.Path | None: + import pathlib + if self.allow_none and s == "None": return None return pathlib.Path(s) diff --git a/traitlets/utils/__init__.py b/traitlets/utils/__init__.py index 10d6a80fa..ef5530560 100644 --- a/traitlets/utils/__init__.py +++ b/traitlets/utils/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import pathlib from collections.abc import Sequence @@ -51,6 +50,8 @@ def filefind(filename: str, path_dirs: Sequence[str] | None = None) -> str: if os.path.isabs(filename) and os.path.isfile(filename): return filename + import pathlib + if path_dirs is None: path_dirs = ("",) elif isinstance(path_dirs, str): diff --git a/traitlets/utils/decorators.py b/traitlets/utils/decorators.py index e661e3f63..9ae3bdff2 100644 --- a/traitlets/utils/decorators.py +++ b/traitlets/utils/decorators.py @@ -3,7 +3,6 @@ from __future__ import annotations import copy -from inspect import Parameter, Signature, signature from typing import Any, TypeVar from ..traitlets import HasTraits, Undefined @@ -11,6 +10,8 @@ def _get_default(value: Any) -> Any: """Get default argument value, given the trait default value.""" + from inspect import Parameter + return Parameter.empty if value == Undefined else value @@ -19,6 +20,8 @@ def _get_default(value: Any) -> Any: def signature_has_traits(cls: type[T]) -> type[T]: """Return a decorated class with a constructor signature that contain Trait names as kwargs.""" + from inspect import Parameter, Signature, signature + traits = [ (name, _get_default(value.default_value)) for name, value in cls.class_traits().items() diff --git a/traitlets/utils/descriptions.py b/traitlets/utils/descriptions.py index eefbafb8e..82c258260 100644 --- a/traitlets/utils/descriptions.py +++ b/traitlets/utils/descriptions.py @@ -1,6 +1,5 @@ from __future__ import annotations -import inspect import re import types from typing import Any @@ -71,14 +70,14 @@ class name where an object was defined. if isinstance(article, str): article = article.lower() - if not inspect.isclass(value): + if not isinstance(value, type): typename = type(value).__name__ else: typename = value.__name__ if verbose: typename = _prefix(value) + typename - if article == "the" or (article is None and not inspect.isclass(value)): + if article == "the" or (article is None and not isinstance(value, type)): if name is not None: result = f"{typename} {name}" if article is not None: @@ -87,7 +86,7 @@ class name where an object was defined. return result else: tick_wrap = False - if inspect.isclass(value): + if isinstance(value, type): name = value.__name__ elif isinstance(value, types.FunctionType): name = value.__name__ @@ -123,6 +122,8 @@ def _prefix(value: Any) -> str: if isinstance(value, types.MethodType): name = describe(None, value.__self__, verbose=True) + "." else: + import inspect + module = inspect.getmodule(value) if module is not None and module.__name__ != "builtins": name = module.__name__ + "." @@ -136,7 +137,7 @@ def class_of(value: Any) -> Any: For example 'an Image' or 'a PlotValue'. """ - if inspect.isclass(value): + if isinstance(value, type): return add_article(value.__name__) else: return class_of(type(value)) diff --git a/traitlets/utils/getargspec.py b/traitlets/utils/getargspec.py index 742191be9..f8f86a728 100644 --- a/traitlets/utils/getargspec.py +++ b/traitlets/utils/getargspec.py @@ -10,15 +10,19 @@ from __future__ import annotations -import inspect -from functools import partial -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import inspect # Unmodified from sphinx below this line def getargspec(func: Any) -> inspect.FullArgSpec: """Like inspect.getargspec but supports functools.partial as well.""" + import inspect + from functools import partial + if inspect.ismethod(func): func = func.__func__ if type(func) is partial: diff --git a/traitlets/utils/warnings.py b/traitlets/utils/warnings.py index e4d2c1260..aa99b7dcf 100644 --- a/traitlets/utils/warnings.py +++ b/traitlets/utils/warnings.py @@ -1,6 +1,5 @@ from __future__ import annotations -import inspect import os import typing as t import warnings @@ -20,6 +19,8 @@ def deprecated_method(method: t.Any, cls: t.Any, method_name: str, msg: str) -> Uses warn_explicit to bind warning to method definition instead of triggering code, which isn't relevant. """ + import inspect + warn_msg = f"{cls.__name__}.{method_name} is deprecated in traitlets 4.1: {msg}" for parent in inspect.getmro(cls):