Coverage for pyTooling/Attributes/__init__.py: 94%
152 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 00:36 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 00:36 +0000
1# ==================================================================================================================== #
2# _____ _ _ _ _ _ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / \ | |_| |_ _ __(_) |__ _ _| |_ ___ ___ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | / _ \| __| __| '__| | '_ \| | | | __/ _ \/ __| #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ / ___ \ |_| |_| | | | |_) | |_| | || __/\__ \ #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_/ \_\__|\__|_| |_|_.__/ \__,_|\__\___||___/ #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany #
15# Copyright 2007-2016 Patrick Lehmann - Dresden, Germany #
16# #
17# Licensed under the Apache License, Version 2.0 (the "License"); #
18# you may not use this file except in compliance with the License. #
19# You may obtain a copy of the License at #
20# #
21# http://www.apache.org/licenses/LICENSE-2.0 #
22# #
23# Unless required by applicable law or agreed to in writing, software #
24# distributed under the License is distributed on an "AS IS" BASIS, #
25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
26# See the License for the specific language governing permissions and #
27# limitations under the License. #
28# #
29# SPDX-License-Identifier: Apache-2.0 #
30# ==================================================================================================================== #
31#
32"""
33This Python module offers the base implementation of .NET-like attributes realized with class-based Python decorators.
34This module comes also with a mixin-class to ease using classes having annotated methods.
36The annotated data is stored as instances of :class:`~pyTooling.Attributes.Attribute` classes in an additional field per
37class, method or function. By default, this field is called ``__pyattr__``.
39.. hint::
41 See :ref:`high-level help <ATTR>` for explanations and usage examples.
43.. seealso::
45 :mod:`pyTooling.Attributes.ArgParse`
46 |rarr| Attributes describing a command line interface.
47 :mod:`pyTooling.MetaClasses`
48 |rarr| The meta-class that collects the attributes attached to a class' methods.
49 :mod:`pyTooling.Decorators`
50 |rarr| Decorators that modify an entity instead of marking it.
51"""
52from __future__ import annotations
54from enum import IntFlag
55from types import MethodType, FunctionType, ModuleType
56from typing import Callable, TypeVar, Any, Iterable, Union, Generator, ClassVar
57from typing import Optional as Nullable
59from pyTooling.Decorators import export, readonly
60from pyTooling.Common import getFullyQualifiedName, getTypeName
61from pyTooling.Exceptions import ToolingException
64__all__ = ["Entity", "TAttr", "TAttributeFilter", "ATTRIBUTES_MEMBER_NAME"]
66Entity = TypeVar("Entity", bound=Union[type, Callable[..., Any]])
67"""A type variable for functions, methods or classes."""
69TAttr = TypeVar("TAttr", bound='Attribute')
70"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
72TAttributeFilter = Union[type[TAttr], Iterable[type[TAttr]], None]
73"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an
74iterable of those."""
76ATTRIBUTES_MEMBER_NAME: str = "__pyattr__"
77"""Field name on entities (function, class, method) to store pyTooling.Attributes."""
80@export
81class AttributeScope(IntFlag):
82 """
83 An enumeration of possible entities an attribute can be applied to.
85 Values of this enumeration can be merged (or-ed) if an attribute can be applied to multiple language entities.
86 Supported language entities are: classes, methods or functions. Class fields or module variables are not supported.
87 """
88 Class = 1 #: Attribute can be applied to classes.
89 Method = 2 #: Attribute can be applied to methods.
90 Function = 4 #: Attribute can be applied to functions.
91 Any = Class + Method + Function #: Attribute can be applied to any language entity.
94@export
95class AttributeScopeError(ToolingException):
96 """
97 An attribute is applied to a language entity its :class:`AttributeScope` doesn't allow.
98 """
100 _appliedAttribute: Nullable[Attribute] #: Attribute applied outside its scope.
101 _entity: Nullable[Callable[..., Any] | type] #: Function, method or class the attribute was applied to.
103 def __init__(
104 self,
105 message: str,
106 /, *,
107 appliedAttribute: Nullable[Attribute] = None,
108 entity: Nullable[Callable[..., Any] | type] = None
109 ) -> None:
110 """
111 Initializes the exception with the attribute and the language entity it was applied to.
113 :param message: The exception's message.
114 :param appliedAttribute: Optional, the attribute applied outside its scope.
115 :param entity: Optional, the language entity (function, method or class) the attribute was applied to.
116 """
117 super().__init__(message)
118 self._appliedAttribute = appliedAttribute
119 self._entity = entity
121 @readonly
122 def AppliedAttribute(self) -> Nullable[Attribute]:
123 """
124 Read-only property to access the attribute applied outside its scope (:attr:`_appliedAttribute`).
126 :returns: The attribute, or ``None`` if it wasn't recorded.
127 """
128 return self._appliedAttribute
130 @readonly
131 def Entity(self) -> Nullable[Callable[..., Any] | type]:
132 """
133 Read-only property to access the language entity the attribute was applied to (:attr:`_entity`).
135 :returns: The entity, or ``None`` if it wasn't recorded.
136 """
137 return self._entity
140@export
141class Attribute: # (metaclass=ExtendedType, slots=True):
142 """Base-class for all pyTooling attributes."""
143# __AttributesMemberName__: ClassVar[str] = "__pyattr__" #: Field name on entities (function, class, method) to store pyTooling.Attributes.
144 _functions: ClassVar[list[Any]] = [] #: List of functions, this Attribute was attached to.
145 _classes: ClassVar[list[Any]] = [] #: List of classes, this Attribute was attached to.
146 _methods: ClassVar[list[Any]] = [] #: List of methods, this Attribute was attached to.
147 _SCOPE: ClassVar[AttributeScope] = AttributeScope.Any #: Allowed language construct this attribute can be used with.
149 # Ensure each derived class has its own instances of class variables.
150 def __init_subclass__(cls, scope: Nullable[AttributeScope] = None, **kwargs: Any) -> None:
151 """
152 Set a derived attribute class' scope, and give it its own registry of annotated entities.
154 The scope is given as class keyword argument: :pycode:`class Hook(Attribute, scope=AttributeScope.Method)`.
155 Without it, the class inherits its base-class' scope. Overriding :attr:`_SCOPE` in the class body works as
156 well, but the keyword takes precedence.
158 The registries :attr:`_functions`, :attr:`_classes` and :attr:`_methods` are class variables, so a derived
159 attribute class would otherwise share the base-class' lists and report entities it was never attached to. Fresh
160 lists are assigned per derived class to prevent that.
162 :param scope: Optional, the language entities the attribute can be applied to. Default: the base-class'
163 scope.
164 :param kwargs: Class keyword arguments forwarded to the base-class.
165 :raises TypeError: If parameter 'scope' is not of type :class:`AttributeScope`.
166 """
167 super().__init_subclass__(**kwargs)
169 if scope is not None:
170 if not isinstance(scope, AttributeScope):
171 ex = TypeError("Parameter 'scope' is not of type 'AttributeScope'.")
172 ex.add_note(f"Got type '{getFullyQualifiedName(scope)}'.")
173 raise ex
175 cls._SCOPE = scope
177 cls._functions = []
178 cls._classes = []
179 cls._methods = []
181 # Make all classes derived from Attribute callable, so they can be used as a decorator.
182 def __call__(self, entity: Entity) -> Entity:
183 """
184 Attributes get attached to an entity (function, class, method) and an index is updated at the attribute for reverse
185 lookups.
187 :param entity: Entity (function, class, method), to attach an attribute to.
188 :returns: Same entity, with attached attribute.
189 :raises TypeError: If parameter 'entity' is not a function, class nor method.
190 :raises AttributeScopeError: If the attribute's :attr:`Scope` doesn't allow the entity's kind.
191 """
192 self._AppendAttribute(entity, self)
194 return entity
196 @staticmethod
197 def _AppendAttribute(entity: Entity, attribute: Attribute) -> None:
198 """
199 Append an attribute to a language entity (class, method, function).
201 .. hint::
203 This method can be used in attribute groups to apply multiple attributes within ``__call__`` method.
205 .. code-block:: Python
207 class GroupAttribute(Attribute):
208 def __call__(self, entity: Entity) -> Entity:
209 self._AppendAttribute(entity, SimpleAttribute(...))
210 self._AppendAttribute(entity, SimpleAttribute(...))
212 return entity
214 A function defined in a class body is a method, although it is a plain function while the decorator runs: its
215 qualified name is ``<Class>.<name>``, while a module's function is named ``<name>`` and a nested function
216 ``<function>.<locals>.<name>``.
218 :param entity: Entity, the attribute is attached to.
219 :param attribute: Attribute to attach.
220 :raises TypeError: If parameter 'entity' is not a class, method or function.
221 :raises AttributeScopeError: If the attribute's :attr:`Scope` doesn't allow the entity's kind.
222 """
223 if isinstance(entity, MethodType): 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 kind = AttributeScope.Method
225 registry = attribute._methods
226 elif isinstance(entity, FunctionType):
227 names = entity.__qualname__.split(".")
228 if len(names) > 1 and names[-2] != "<locals>":
229 kind = AttributeScope.Method
230 registry = attribute._methods
231 else:
232 kind = AttributeScope.Function
233 registry = attribute._functions
234 elif isinstance(entity, type): 234 ↛ 238line 234 didn't jump to line 238 because the condition on line 234 was always true
235 kind = AttributeScope.Class
236 registry = attribute._classes
237 else:
238 ex = TypeError("Parameter 'entity' is not a function, class nor method.")
239 ex.add_note(f"Got type '{getFullyQualifiedName(entity)}'.")
240 raise ex
242 if kind not in attribute._SCOPE:
243 ex = AttributeScopeError(
244 f"Attribute '{getTypeName(attribute)}' can't be applied to {kind.name.lower()} '{entity.__qualname__}'.",
245 appliedAttribute=attribute,
246 entity=entity
247 )
248 ex.add_note(f"Its scope is '{attribute._SCOPE.name}'.")
249 raise ex
251 registry.append(entity)
253 if hasattr(entity, ATTRIBUTES_MEMBER_NAME):
254 getattr(entity, ATTRIBUTES_MEMBER_NAME).insert(0, attribute)
255 else:
256 setattr(entity, ATTRIBUTES_MEMBER_NAME, [attribute, ])
258 @readonly
259 def Scope(self) -> AttributeScope:
260 """
261 Read-only property to access the language entities this attribute can be applied to (:attr:`_SCOPE`).
263 It is an instance property: on the attribute class, :attr:`_SCOPE` is read directly.
265 :returns: The language entities this attribute can be applied to.
266 """
267 return self._SCOPE
269 @classmethod
270 def GetFunctions(cls, scope: Nullable[type | ModuleType] = None) -> Generator[TAttr, None, None]:
271 """
272 Return a generator for all functions, where this attribute is attached to.
274 The resulting item stream can be filtered by:
275 * ``scope`` - when the item is a nested class in scope ``scope``.
277 :param scope: Optional, module the functions have to be defined in; ``None`` accepts every function.
278 :returns: A sequence of functions where this attribute is attached to.
279 :raises NotImplementedError: If this abstract method is not overridden by a derived class.
280 """
281 if scope is None:
282 for c in cls._functions:
283 yield c
284 elif isinstance(scope, ModuleType):
285 elementsInScope = set(c for c in scope.__dict__.values() if isinstance(c, FunctionType))
286 for c in cls._functions:
287 if c in elementsInScope: 287 ↛ 286line 287 didn't jump to line 286 because the condition on line 287 was always true
288 yield c
289 else:
290 raise NotImplementedError("Parameter 'scope' is a class isn't supported yet.")
292 @classmethod
293 def GetClasses(cls, scope: Nullable[type | ModuleType] = None, subclassOf: Nullable[type] = None) -> Generator[TAttr, None, None]:
294 """
295 Return a generator for all classes, where this attribute is attached to.
297 The resulting item stream can be filtered by:
298 * ``scope`` - when the item is a nested class in scope ``scope``.
299 * ``subclassOf`` - when the item is a subclass of ``subclassOf``.
301 :param scope: Optional, class or module the classes have to be nested in or defined in; ``None`` accepts every
302 class.
303 :param subclassOf: Optional, a class or tuple thereof; only annotated classes derived from it are returned.
304 ``None`` accepts every class.
305 :returns: A sequence of classes where this attribute is attached to.
306 """
307 from pyTooling.Common import isnestedclass
309 if scope is None:
310 if subclassOf is None:
311 for c in cls._classes:
312 yield c
313 else:
314 for c in cls._classes:
315 if issubclass(c, subclassOf):
316 yield c
317 elif subclassOf is None:
318 if isinstance(scope, ModuleType):
319 elementsInScope = set(c for c in scope.__dict__.values() if isinstance(c, type))
320 for c in cls._classes:
321 if c in elementsInScope:
322 yield c
323 else:
324 for c in cls._classes:
325 if isnestedclass(c, scope):
326 yield c
327 else:
328 for c in cls._classes:
329 if isnestedclass(c, scope) and issubclass(c, subclassOf):
330 yield c
332 @classmethod
333 def GetMethods(cls, scope: Nullable[type] = None) -> Generator[TAttr, None, None]:
334 """
335 Return a generator for all methods, where this attribute is attached to.
337 The resulting item stream can be filtered by:
338 * ``scope`` - when the item is a nested class in scope ``scope``.
340 :param scope: Optional, class the methods are defined in; ``None`` accepts every method. Only a class built with
341 :class:`~pyTooling.MetaClasses.ExtendedType` links its methods to it, so a method of another class
342 isn't found by scope.
343 :returns: A sequence of methods where this attribute is attached to.
344 """
345 if scope is None:
346 for c in cls._methods:
347 yield c
348 else:
349 for m in cls._methods:
350 if getattr(m, "__classobj__", None) is scope:
351 yield m
353 @classmethod
354 def GetAttributes(cls, method: MethodType, includeSubClasses: bool = True) -> tuple[Attribute, ...]:
355 """
356 Returns attached attributes of this kind for a given method.
358 :param method: Method to search attributes for.
359 :param includeSubClasses: Optional, if ``True``, attributes of derived attribute classes are included too.
360 :returns: Tuple of attached attributes of this kind.
361 :raises TypeError: If the method's attribute field is not a list.
362 """
363 if hasattr(method, ATTRIBUTES_MEMBER_NAME):
364 attributes = getattr(method, ATTRIBUTES_MEMBER_NAME)
365 if isinstance(attributes, list): 365 ↛ 371line 365 didn't jump to line 371 because the condition on line 365 was always true
366 if includeSubClasses:
367 return tuple(attribute for attribute in attributes if isinstance(attribute, cls))
368 else:
369 return tuple(attribute for attribute in attributes if type(attribute) is cls)
370 else:
371 methodName = getFullyQualifiedName(method)
372 ex = TypeError(f"Method '{methodName}' has a '{ATTRIBUTES_MEMBER_NAME}' field, but it's no list.")
373 ex.add_note(f"Got type '{getFullyQualifiedName(attributes)}'.")
374 raise ex
375 return tuple()
378@export
379class SimpleAttribute(Attribute):
380 """
381 A generic attribute preserving the parameters it was applied with.
383 It needs no derived class per use case: whatever is passed to it is available from :attr:`Args` and :attr:`KwArgs`,
384 which makes it the quickest way to mark a class, method or function and read the marking back.
385 """
386 _args: tuple[Any, ...] #: Positional parameters the attribute was applied with.
387 _kwargs: dict[str, Any] #: Named parameters the attribute was applied with.
389 def __init__(self, *args: Any, **kwargs: Any) -> None:
390 """
391 Initialize the attribute, preserving whatever parameters it was applied with.
393 :param args: Positional parameters, readable from :attr:`Args`.
394 :param kwargs: Named parameters, readable from :attr:`KwArgs`.
395 """
396 self._args = args
397 self._kwargs = kwargs
399 @readonly
400 def Args(self) -> tuple[Any, ...]:
401 """
402 Read-only property to access the positional parameters this attribute was created with (:attr:`_args`).
404 :returns: Tuple of positional parameters.
405 """
406 return self._args
408 @readonly
409 def KwArgs(self) -> dict[str, Any]:
410 """
411 Read-only property to access the named parameters this attribute was created with (:attr:`_kwargs`).
413 :returns: Dictionary of named parameters.
414 """
415 return self._kwargs