Coverage for pyTooling/Attributes/__init__.py: 93%

117 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +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. 

35 

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__``. 

38 

39.. hint:: 

40 

41 See :ref:`high-level help <ATTR>` for explanations and usage examples. 

42 

43.. seealso:: 

44 

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 

53 

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 

58 

59from pyTooling.Decorators import export, readonly 

60from pyTooling.Common import getFullyQualifiedName 

61 

62 

63__all__ = ["Entity", "TAttr", "TAttributeFilter", "ATTRIBUTES_MEMBER_NAME"] 

64 

65Entity = TypeVar("Entity", bound=Union[type, Callable[..., Any]]) 

66"""A type variable for functions, methods or classes.""" 

67 

68TAttr = TypeVar("TAttr", bound='Attribute') 

69"""A type variable for :class:`~pyTooling.Attributes.Attribute`.""" 

70 

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.""" 

74 

75ATTRIBUTES_MEMBER_NAME: str = "__pyattr__" 

76"""Field name on entities (function, class, method) to store pyTooling.Attributes.""" 

77 

78 

79@export 

80class AttributeScope(IntFlag): 

81 """ 

82 An enumeration of possible entities an attribute can be applied to. 

83 

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. 

91 

92 

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. 

101 

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 class has its own instance of ``_functions``, ``_classes`` and ``_methods`` to register the 

106 usage of that Attribute. 

107 """ 

108 super().__init_subclass__(**kwargs) 

109 cls._functions = [] 

110 cls._classes = [] 

111 cls._methods = [] 

112 

113 # Make all classes derived from Attribute callable, so they can be used as a decorator. 

114 def __call__(self, entity: Entity) -> Entity: 

115 """ 

116 Attributes get attached to an entity (function, class, method) and an index is updated at the attribute for reverse 

117 lookups. 

118 

119 :param entity: Entity (function, class, method), to attach an attribute to. 

120 :returns: Same entity, with attached attribute. 

121 :raises TypeError: If parameter 'entity' is not a function, class nor method. 

122 """ 

123 self._AppendAttribute(entity, self) 

124 

125 return entity 

126 

127 @staticmethod 

128 def _AppendAttribute(entity: Entity, attribute: Attribute) -> None: 

129 """ 

130 Append an attribute to a language entity (class, method, function). 

131 

132 .. hint:: 

133 

134 This method can be used in attribute groups to apply multiple attributes within ``__call__`` method. 

135 

136 .. code-block:: Python 

137 

138 class GroupAttribute(Attribute): 

139 def __call__(self, entity: Entity) -> Entity: 

140 self._AppendAttribute(entity, SimpleAttribute(...)) 

141 self._AppendAttribute(entity, SimpleAttribute(...)) 

142 

143 return entity 

144 

145 :param entity: Entity, the attribute is attached to. 

146 :param attribute: Attribute to attach. 

147 :raises TypeError: If parameter 'entity' is not a class, method or function. 

148 """ 

149 if isinstance(entity, MethodType): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 attribute._methods.append(entity) 

151 elif isinstance(entity, FunctionType): 

152 attribute._functions.append(entity) 

153 elif isinstance(entity, type): 153 ↛ 156line 153 didn't jump to line 156 because the condition on line 153 was always true

154 attribute._classes.append(entity) 

155 else: 

156 ex = TypeError(f"Parameter 'entity' is not a function, class nor method.") 

157 ex.add_note(f"Got type '{getFullyQualifiedName(entity)}'.") 

158 raise ex 

159 

160 if hasattr(entity, ATTRIBUTES_MEMBER_NAME): 

161 getattr(entity, ATTRIBUTES_MEMBER_NAME).insert(0, attribute) 

162 else: 

163 setattr(entity, ATTRIBUTES_MEMBER_NAME, [attribute, ]) 

164 

165 @readonly 

166 def Scope(cls) -> AttributeScope: 

167 """ 

168 Read-only property to access the scope this attribute searches in (:attr:`_scope`). 

169 

170 :returns: The scope this attribute searches in. 

171 """ 

172 return cls._scope 

173 

174 @classmethod 

175 def GetFunctions(cls, scope: Nullable[type] = None) -> Generator[TAttr, None, None]: 

176 """ 

177 Return a generator for all functions, where this attribute is attached to. 

178 

179 The resulting item stream can be filtered by: 

180 * ``scope`` - when the item is a nested class in scope ``scope``. 

181 

182 :param scope: Optional, module the functions have to be defined in; ``None`` accepts every function. 

183 :returns: A sequence of functions where this attribute is attached to. 

184 :raises NotImplementedError: If this abstract method is not overridden by a derived class. 

185 """ 

186 if scope is None: 

187 for c in cls._functions: 

188 yield c 

189 elif isinstance(scope, ModuleType): 

190 elementsInScope = set(c for c in scope.__dict__.values() if isinstance(c, FunctionType)) 

191 for c in cls._functions: 

192 if c in elementsInScope: 192 ↛ 191line 192 didn't jump to line 191 because the condition on line 192 was always true

193 yield c 

194 else: 

195 raise NotImplementedError(f"Parameter 'scope' is a class isn't supported yet.") 

196 

197 @classmethod 

198 def GetClasses(cls, scope: Nullable[type | ModuleType] = None, subclassOf: Nullable[type] = None) -> Generator[TAttr, None, None]: 

199 # def GetClasses(cls, scope: Nullable[Type] = None, predicate: Nullable[TAttributeFilter] = None) -> Generator[TAttr, None, None]: 

200 """ 

201 Return a generator for all classes, where this attribute is attached to. 

202 

203 The resulting item stream can be filtered by: 

204 * ``scope`` - when the item is a nested class in scope ``scope``. 

205 * ``subclassOf`` - when the item is a subclass of ``subclassOf``. 

206 

207 :param scope: Optional, class or module the classes have to be nested in or defined in; ``None`` accepts every 

208 class. 

209 :param subclassOf: Optional, an attribute class or tuple thereof, to filter for that attribute type or subtype. 

210 :returns: A sequence of classes where this attribute is attached to. 

211 """ 

212 from pyTooling.Common import isnestedclass 

213 

214 if scope is None: 

215 if subclassOf is None: 

216 for c in cls._classes: 

217 yield c 

218 else: 

219 for c in cls._classes: 

220 if issubclass(c, subclassOf): 

221 yield c 

222 elif subclassOf is None: 

223 if isinstance(scope, ModuleType): 

224 elementsInScope = set(c for c in scope.__dict__.values() if isinstance(c, type)) 

225 for c in cls._classes: 

226 if c in elementsInScope: 

227 yield c 

228 else: 

229 for c in cls._classes: 

230 if isnestedclass(c, scope): 

231 yield c 

232 else: 

233 for c in cls._classes: 

234 if isnestedclass(c, scope) and issubclass(c, subclassOf): 

235 yield c 

236 

237 @classmethod 

238 def GetMethods(cls, scope: Nullable[type] = None) -> Generator[TAttr, None, None]: 

239 """ 

240 Return a generator for all methods, where this attribute is attached to. 

241 

242 The resulting item stream can be filtered by: 

243 * ``scope`` - when the item is a nested class in scope ``scope``. 

244 

245 :param scope: Optional, class or module the methods' classes have to be nested in or defined in; ``None`` 

246 accepts every method. 

247 :returns: A sequence of methods where this attribute is attached to. 

248 """ 

249 if scope is None: 

250 for c in cls._methods: 

251 yield c 

252 else: 

253 for m in cls._methods: 

254 if m.__classobj__ is scope: 

255 yield m 

256 

257 @classmethod 

258 def GetAttributes(cls, method: MethodType, includeSubClasses: bool = True) -> tuple[Attribute, ...]: 

259 """ 

260 Returns attached attributes of this kind for a given method. 

261 

262 :param method: Method to search attributes for. 

263 :param includeSubClasses: Optional, if ``True``, attributes of derived attribute classes are included too. 

264 :returns: Tuple of attached attributes of this kind. 

265 :raises TypeError: 

266 """ 

267 if hasattr(method, ATTRIBUTES_MEMBER_NAME): 

268 attributes = getattr(method, ATTRIBUTES_MEMBER_NAME) 

269 if isinstance(attributes, list): 269 ↛ 272line 269 didn't jump to line 272 because the condition on line 269 was always true

270 return tuple(attribute for attribute in attributes if isinstance(attribute, cls)) 

271 else: 

272 methodName = getFullyQualifiedName(method) 

273 ex = TypeError(f"Method '{methodName}' has a '{ATTRIBUTES_MEMBER_NAME}' field, but it's no list.") 

274 ex.add_note(f"Got type '{getFullyQualifiedName(attributes)}'.") 

275 raise ex 

276 return tuple() 

277 

278 

279@export 

280class SimpleAttribute(Attribute): 

281 """ 

282 A generic attribute preserving the parameters it was applied with. 

283 

284 It needs no derived class per use case: whatever is passed to it is available from :attr:`Args` and :attr:`KwArgs`, 

285 which makes it the quickest way to mark a class, method or function and read the marking back. 

286 """ 

287 _args: tuple[Any, ...] #: Positional parameters the attribute was applied with. 

288 _kwargs: dict[str, Any] #: Named parameters the attribute was applied with. 

289 

290 def __init__(self, *args: Any, **kwargs: Any) -> None: 

291 """ 

292 Initialize the attribute, preserving whatever parameters it was applied with. 

293 

294 :param args: Positional parameters, readable from :attr:`Args`. 

295 :param kwargs: Named parameters, readable from :attr:`KwArgs`. 

296 """ 

297 self._args = args 

298 self._kwargs = kwargs 

299 

300 @readonly 

301 def Args(self) -> tuple[Any, ...]: 

302 """ 

303 Read-only property to access the positional parameters this attribute was created with (:attr:`_args`). 

304 

305 :returns: Tuple of positional parameters. 

306 """ 

307 return self._args 

308 

309 @readonly 

310 def KwArgs(self) -> dict[str, Any]: 

311 """ 

312 Read-only property to access the named parameters this attribute was created with (:attr:`_kwargs`). 

313 

314 :returns: Dictionary of named parameters. 

315 """ 

316 return self._kwargs