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

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 attribute class gets its own registry of annotated entities. 

106 

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. 

110 

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 = [] 

117 

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. 

123 

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) 

129 

130 return entity 

131 

132 @staticmethod 

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

134 """ 

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

136 

137 .. hint:: 

138 

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

140 

141 .. code-block:: Python 

142 

143 class GroupAttribute(Attribute): 

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

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

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

147 

148 return entity 

149 

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 

164 

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

169 

170 @readonly 

171 def Scope(cls) -> AttributeScope: 

172 """ 

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

174 

175 :returns: The scope this attribute searches in. 

176 """ 

177 return cls._scope 

178 

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. 

183 

184 The resulting item stream can be filtered by: 

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

186 

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

201 

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. 

206 

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

210 

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 

217 

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 

240 

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. 

245 

246 The resulting item stream can be filtered by: 

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

248 

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 

260 

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. 

265 

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

281 

282 

283@export 

284class SimpleAttribute(Attribute): 

285 """ 

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

287 

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. 

293 

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

295 """ 

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

297 

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 

303 

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

308 

309 :returns: Tuple of positional parameters. 

310 """ 

311 return self._args 

312 

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

317 

318 :returns: Dictionary of named parameters. 

319 """ 

320 return self._kwargs