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

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, getTypeName 

61from pyTooling.Exceptions import ToolingException 

62 

63 

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

65 

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

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

68 

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

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

71 

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

75 

76ATTRIBUTES_MEMBER_NAME: str = "__pyattr__" 

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

78 

79 

80@export 

81class AttributeScope(IntFlag): 

82 """ 

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

84 

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. 

92 

93 

94@export 

95class AttributeScopeError(ToolingException): 

96 """ 

97 An attribute is applied to a language entity its :class:`AttributeScope` doesn't allow. 

98 """ 

99 

100 _appliedAttribute: Nullable[Attribute] #: Attribute applied outside its scope. 

101 _entity: Nullable[Callable[..., Any] | type] #: Function, method or class the attribute was applied to. 

102 

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. 

112 

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 

120 

121 @readonly 

122 def AppliedAttribute(self) -> Nullable[Attribute]: 

123 """ 

124 Read-only property to access the attribute applied outside its scope (:attr:`_appliedAttribute`). 

125 

126 :returns: The attribute, or ``None`` if it wasn't recorded. 

127 """ 

128 return self._appliedAttribute 

129 

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

134 

135 :returns: The entity, or ``None`` if it wasn't recorded. 

136 """ 

137 return self._entity 

138 

139 

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. 

148 

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. 

153 

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. 

157 

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. 

161 

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) 

168 

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 

174 

175 cls._SCOPE = scope 

176 

177 cls._functions = [] 

178 cls._classes = [] 

179 cls._methods = [] 

180 

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. 

186 

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) 

193 

194 return entity 

195 

196 @staticmethod 

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

198 """ 

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

200 

201 .. hint:: 

202 

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

204 

205 .. code-block:: Python 

206 

207 class GroupAttribute(Attribute): 

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

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

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

211 

212 return entity 

213 

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

217 

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 

241 

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 

250 

251 registry.append(entity) 

252 

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, ]) 

257 

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

262 

263 It is an instance property: on the attribute class, :attr:`_SCOPE` is read directly. 

264 

265 :returns: The language entities this attribute can be applied to. 

266 """ 

267 return self._SCOPE 

268 

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. 

273 

274 The resulting item stream can be filtered by: 

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

276 

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

291 

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. 

296 

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

300 

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 

308 

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 

331 

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. 

336 

337 The resulting item stream can be filtered by: 

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

339 

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 

352 

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. 

357 

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() 

376 

377 

378@export 

379class SimpleAttribute(Attribute): 

380 """ 

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

382 

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. 

388 

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

390 """ 

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

392 

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 

398 

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

403 

404 :returns: Tuple of positional parameters. 

405 """ 

406 return self._args 

407 

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

412 

413 :returns: Dictionary of named parameters. 

414 """ 

415 return self._kwargs