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

117 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-08-30 19:04 +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] = 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 # def GetClasses(cls, scope: Nullable[Type] = None, predicate: Nullable[TAttributeFilter] = None) -> Generator[TAttr, None, None]: 

205 """ 

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

207 

208 The resulting item stream can be filtered by: 

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

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

211 

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

213 class. 

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

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

216 """ 

217 from pyTooling.Common import isnestedclass 

218 

219 if scope is None: 

220 if subclassOf is None: 

221 for c in cls._classes: 

222 yield c 

223 else: 

224 for c in cls._classes: 

225 if issubclass(c, subclassOf): 

226 yield c 

227 elif subclassOf is None: 

228 if isinstance(scope, ModuleType): 

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

230 for c in cls._classes: 

231 if c in elementsInScope: 

232 yield c 

233 else: 

234 for c in cls._classes: 

235 if isnestedclass(c, scope): 

236 yield c 

237 else: 

238 for c in cls._classes: 

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

240 yield c 

241 

242 @classmethod 

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

244 """ 

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

246 

247 The resulting item stream can be filtered by: 

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

249 

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

251 accepts every method. 

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

253 """ 

254 if scope is None: 

255 for c in cls._methods: 

256 yield c 

257 else: 

258 for m in cls._methods: 

259 if m.__classobj__ is scope: 

260 yield m 

261 

262 @classmethod 

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

264 """ 

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

266 

267 :param method: Method to search attributes for. 

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

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

270 :raises TypeError: If the method's attribute field is not a list. 

271 """ 

272 if hasattr(method, ATTRIBUTES_MEMBER_NAME): 

273 attributes = getattr(method, ATTRIBUTES_MEMBER_NAME) 

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

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

276 else: 

277 methodName = getFullyQualifiedName(method) 

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

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

280 raise ex 

281 return tuple() 

282 

283 

284@export 

285class SimpleAttribute(Attribute): 

286 """ 

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

288 

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

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

291 """ 

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

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

294 

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

296 """ 

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

298 

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

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

301 """ 

302 self._args = args 

303 self._kwargs = kwargs 

304 

305 @readonly 

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

307 """ 

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

309 

310 :returns: Tuple of positional parameters. 

311 """ 

312 return self._args 

313 

314 @readonly 

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

316 """ 

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

318 

319 :returns: Dictionary of named parameters. 

320 """ 

321 return self._kwargs