Coverage for pyTooling/Attributes/__init__.py: 93%
117 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 07:08 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-29 07:08 +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
63__all__ = ["Entity", "TAttr", "TAttributeFilter", "ATTRIBUTES_MEMBER_NAME"]
65Entity = TypeVar("Entity", bound=Union[type, Callable[..., Any]])
66"""A type variable for functions, methods or classes."""
68TAttr = TypeVar("TAttr", bound='Attribute')
69"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
71TAttributeFilter = Union[type[TAttr], Iterable[type[TAttr]], None]
72"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an
73iterable of those."""
75ATTRIBUTES_MEMBER_NAME: str = "__pyattr__"
76"""Field name on entities (function, class, method) to store pyTooling.Attributes."""
79@export
80class AttributeScope(IntFlag):
81 """
82 An enumeration of possible entities an attribute can be applied to.
84 Values of this enumeration can be merged (or-ed) if an attribute can be applied to multiple language entities.
85 Supported language entities are: classes, methods or functions. Class fields or module variables are not supported.
86 """
87 Class = 1 #: Attribute can be applied to classes.
88 Method = 2 #: Attribute can be applied to methods.
89 Function = 4 #: Attribute can be applied to functions.
90 Any = Class + Method + Function #: Attribute can be applied to any language entity.
93@export
94class Attribute: # (metaclass=ExtendedType, slots=True):
95 """Base-class for all pyTooling attributes."""
96# __AttributesMemberName__: ClassVar[str] = "__pyattr__" #: Field name on entities (function, class, method) to store pyTooling.Attributes.
97 _functions: ClassVar[list[Any]] = [] #: List of functions, this Attribute was attached to.
98 _classes: ClassVar[list[Any]] = [] #: List of classes, this Attribute was attached to.
99 _methods: ClassVar[list[Any]] = [] #: List of methods, this Attribute was attached to.
100 _scope: ClassVar[AttributeScope] = AttributeScope.Any #: Allowed language construct this attribute can be used with.
102 # Ensure each derived class has its own instances of class variables.
103 def __init_subclass__(cls, **kwargs: Any) -> None:
104 """
105 Ensure each derived attribute class gets its own registry of annotated entities.
107 The registries :attr:`_functions`, :attr:`_classes` and :attr:`_methods` are class variables, so a derived
108 attribute class would otherwise share the base-class' lists and report entities it was never attached to. Fresh
109 lists are assigned per derived class to prevent that.
111 :param kwargs: Class keyword arguments forwarded to the base-class.
112 """
113 super().__init_subclass__(**kwargs)
114 cls._functions = []
115 cls._classes = []
116 cls._methods = []
118 # Make all classes derived from Attribute callable, so they can be used as a decorator.
119 def __call__(self, entity: Entity) -> Entity:
120 """
121 Attributes get attached to an entity (function, class, method) and an index is updated at the attribute for reverse
122 lookups.
124 :param entity: Entity (function, class, method), to attach an attribute to.
125 :returns: Same entity, with attached attribute.
126 :raises TypeError: If parameter 'entity' is not a function, class nor method.
127 """
128 self._AppendAttribute(entity, self)
130 return entity
132 @staticmethod
133 def _AppendAttribute(entity: Entity, attribute: Attribute) -> None:
134 """
135 Append an attribute to a language entity (class, method, function).
137 .. hint::
139 This method can be used in attribute groups to apply multiple attributes within ``__call__`` method.
141 .. code-block:: Python
143 class GroupAttribute(Attribute):
144 def __call__(self, entity: Entity) -> Entity:
145 self._AppendAttribute(entity, SimpleAttribute(...))
146 self._AppendAttribute(entity, SimpleAttribute(...))
148 return entity
150 :param entity: Entity, the attribute is attached to.
151 :param attribute: Attribute to attach.
152 :raises TypeError: If parameter 'entity' is not a class, method or function.
153 """
154 if isinstance(entity, MethodType): 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true
155 attribute._methods.append(entity)
156 elif isinstance(entity, FunctionType):
157 attribute._functions.append(entity)
158 elif isinstance(entity, type): 158 ↛ 161line 158 didn't jump to line 161 because the condition on line 158 was always true
159 attribute._classes.append(entity)
160 else:
161 ex = TypeError("Parameter 'entity' is not a function, class nor method.")
162 ex.add_note(f"Got type '{getFullyQualifiedName(entity)}'.")
163 raise ex
165 if hasattr(entity, ATTRIBUTES_MEMBER_NAME):
166 getattr(entity, ATTRIBUTES_MEMBER_NAME).insert(0, attribute)
167 else:
168 setattr(entity, ATTRIBUTES_MEMBER_NAME, [attribute, ])
170 @readonly
171 def Scope(cls) -> AttributeScope:
172 """
173 Read-only property to access the scope this attribute searches in (:attr:`_scope`).
175 :returns: The scope this attribute searches in.
176 """
177 return cls._scope
179 @classmethod
180 def GetFunctions(cls, scope: Nullable[type | ModuleType] = None) -> Generator[TAttr, None, None]:
181 """
182 Return a generator for all functions, where this attribute is attached to.
184 The resulting item stream can be filtered by:
185 * ``scope`` - when the item is a nested class in scope ``scope``.
187 :param scope: Optional, module the functions have to be defined in; ``None`` accepts every function.
188 :returns: A sequence of functions where this attribute is attached to.
189 :raises NotImplementedError: If this abstract method is not overridden by a derived class.
190 """
191 if scope is None:
192 for c in cls._functions:
193 yield c
194 elif isinstance(scope, ModuleType):
195 elementsInScope = set(c for c in scope.__dict__.values() if isinstance(c, FunctionType))
196 for c in cls._functions:
197 if c in elementsInScope: 197 ↛ 196line 197 didn't jump to line 196 because the condition on line 197 was always true
198 yield c
199 else:
200 raise NotImplementedError("Parameter 'scope' is a class isn't supported yet.")
202 @classmethod
203 def GetClasses(cls, scope: Nullable[type | ModuleType] = None, subclassOf: Nullable[type] = None) -> Generator[TAttr, None, None]:
204 """
205 Return a generator for all classes, where this attribute is attached to.
207 The resulting item stream can be filtered by:
208 * ``scope`` - when the item is a nested class in scope ``scope``.
209 * ``subclassOf`` - when the item is a subclass of ``subclassOf``.
211 :param scope: Optional, class or module the classes have to be nested in or defined in; ``None`` accepts every
212 class.
213 :param subclassOf: Optional, an attribute class or tuple thereof, to filter for that attribute type or subtype.
214 :returns: A sequence of classes where this attribute is attached to.
215 """
216 from pyTooling.Common import isnestedclass
218 if scope is None:
219 if subclassOf is None:
220 for c in cls._classes:
221 yield c
222 else:
223 for c in cls._classes:
224 if issubclass(c, subclassOf):
225 yield c
226 elif subclassOf is None:
227 if isinstance(scope, ModuleType):
228 elementsInScope = set(c for c in scope.__dict__.values() if isinstance(c, type))
229 for c in cls._classes:
230 if c in elementsInScope:
231 yield c
232 else:
233 for c in cls._classes:
234 if isnestedclass(c, scope):
235 yield c
236 else:
237 for c in cls._classes:
238 if isnestedclass(c, scope) and issubclass(c, subclassOf):
239 yield c
241 @classmethod
242 def GetMethods(cls, scope: Nullable[type] = None) -> Generator[TAttr, None, None]:
243 """
244 Return a generator for all methods, where this attribute is attached to.
246 The resulting item stream can be filtered by:
247 * ``scope`` - when the item is a nested class in scope ``scope``.
249 :param scope: Optional, class or module the methods' classes have to be nested in or defined in; ``None``
250 accepts every method.
251 :returns: A sequence of methods where this attribute is attached to.
252 """
253 if scope is None:
254 for c in cls._methods:
255 yield c
256 else:
257 for m in cls._methods:
258 if m.__classobj__ is scope:
259 yield m
261 @classmethod
262 def GetAttributes(cls, method: MethodType, includeSubClasses: bool = True) -> tuple[Attribute, ...]:
263 """
264 Returns attached attributes of this kind for a given method.
266 :param method: Method to search attributes for.
267 :param includeSubClasses: Optional, if ``True``, attributes of derived attribute classes are included too.
268 :returns: Tuple of attached attributes of this kind.
269 :raises TypeError: If the method's attribute field is not a list.
270 """
271 if hasattr(method, ATTRIBUTES_MEMBER_NAME):
272 attributes = getattr(method, ATTRIBUTES_MEMBER_NAME)
273 if isinstance(attributes, list): 273 ↛ 276line 273 didn't jump to line 276 because the condition on line 273 was always true
274 return tuple(attribute for attribute in attributes if isinstance(attribute, cls))
275 else:
276 methodName = getFullyQualifiedName(method)
277 ex = TypeError(f"Method '{methodName}' has a '{ATTRIBUTES_MEMBER_NAME}' field, but it's no list.")
278 ex.add_note(f"Got type '{getFullyQualifiedName(attributes)}'.")
279 raise ex
280 return tuple()
283@export
284class SimpleAttribute(Attribute):
285 """
286 A generic attribute preserving the parameters it was applied with.
288 It needs no derived class per use case: whatever is passed to it is available from :attr:`Args` and :attr:`KwArgs`,
289 which makes it the quickest way to mark a class, method or function and read the marking back.
290 """
291 _args: tuple[Any, ...] #: Positional parameters the attribute was applied with.
292 _kwargs: dict[str, Any] #: Named parameters the attribute was applied with.
294 def __init__(self, *args: Any, **kwargs: Any) -> None:
295 """
296 Initialize the attribute, preserving whatever parameters it was applied with.
298 :param args: Positional parameters, readable from :attr:`Args`.
299 :param kwargs: Named parameters, readable from :attr:`KwArgs`.
300 """
301 self._args = args
302 self._kwargs = kwargs
304 @readonly
305 def Args(self) -> tuple[Any, ...]:
306 """
307 Read-only property to access the positional parameters this attribute was created with (:attr:`_args`).
309 :returns: Tuple of positional parameters.
310 """
311 return self._args
313 @readonly
314 def KwArgs(self) -> dict[str, Any]:
315 """
316 Read-only property to access the named parameters this attribute was created with (:attr:`_kwargs`).
318 :returns: Dictionary of named parameters.
319 """
320 return self._kwargs