Coverage for sources/falsifier/__/immutables.py: 100%

113 statements  

« prev     ^ index     » next       coverage.py v7.6.9, created at 2024-12-20 23:26 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' Attribute concealment and immutability. ''' 

22 

23 

24from __future__ import annotations 

25 

26import collections.abc as cabc 

27import types 

28 

29import typing_extensions as typx 

30 

31 

32ClassDecorators: typx.TypeAlias = ( 

33 cabc.Iterable[ cabc.Callable[ [ type ], type ] ] ) 

34 

35 

36behavior_label = 'immutability' 

37 

38 

39def repair_class_reproduction( original: type, reproduction: type ) -> None: 

40 ''' Repairs a class reproduction, if necessary. ''' 

41 from platform import python_implementation 

42 match python_implementation( ): 

43 case 'CPython': # pragma: no branch 

44 _repair_cpython_class_closures( original, reproduction ) 

45 case _: pass # pragma: no cover 

46 

47 

48def _repair_cpython_class_closures( # pylint: disable=too-complex 

49 original: type, reproduction: type 

50) -> None: 

51 # Adapted from https://github.com/python/cpython/pull/124455/files 

52 def try_repair_closure( function: cabc.Callable[ ..., typx.Any ] ) -> bool: 

53 try: index = function.__code__.co_freevars.index( '__class__' ) 

54 except ValueError: return False 

55 if not function.__closure__: return False # pragma: no branch 

56 closure = function.__closure__[ index ] 

57 if original is closure.cell_contents: # pragma: no branch 

58 closure.cell_contents = reproduction 

59 return True 

60 return False # pragma: no cover 

61 

62 from inspect import isfunction, unwrap 

63 for attribute in reproduction.__dict__.values( ): # pylint: disable=too-many-nested-blocks 

64 attribute_ = unwrap( attribute ) 

65 if isfunction( attribute_ ) and try_repair_closure( attribute_ ): 

66 return 

67 if isinstance( attribute_, property ): 

68 for aname in ( 'fget', 'fset', 'fdel' ): 

69 accessor = getattr( attribute_, aname ) 

70 if None is accessor: continue 

71 if try_repair_closure( accessor ): return # pragma: no branch 

72 

73 

74class ImmutableClass( type ): 

75 ''' Concealment and immutability on class attributes. ''' 

76 

77 _class_attribute_visibility_includes_: cabc.Collection[ str ] = ( 

78 frozenset( ) ) 

79 

80 def __new__( 

81 clscls: type[ type ], 

82 name: str, 

83 bases: tuple[ type, ... ], 

84 namespace: dict[ str, typx.Any ], *, 

85 decorators: ClassDecorators = ( ), 

86 **args: typx.Any 

87 ) -> ImmutableClass: 

88 class_ = type.__new__( clscls, name, bases, namespace, **args ) 

89 return _immutable_class__new__( class_, decorators = decorators ) 

90 

91 def __init__( selfclass, *posargs: typx.Any, **nomargs: typx.Any ): 

92 super( ).__init__( *posargs, **nomargs ) 

93 _immutable_class__init__( selfclass ) 

94 

95 def __dir__( selfclass ) -> tuple[ str, ... ]: 

96 default: frozenset[ str ] = frozenset( ) 

97 includes: frozenset[ str ] = frozenset.union( *( # type: ignore 

98 getattr( class_, '_class_attribute_visibility_includes_', default ) 

99 for class_ in selfclass.__mro__ ) ) 

100 return tuple( sorted( 

101 name for name in super( ).__dir__( ) 

102 if not name.startswith( '_' ) or name in includes ) ) 

103 

104 def __delattr__( selfclass, name: str ) -> None: 

105 if not _immutable_class__delattr__( selfclass, name ): 

106 super( ).__delattr__( name ) 

107 

108 def __setattr__( selfclass, name: str, value: typx.Any ) -> None: 

109 if not _immutable_class__setattr__( selfclass, name ): 

110 super( ).__setattr__( name, value ) 

111 

112 

113def _immutable_class__new__( 

114 original: type, 

115 decorators: ClassDecorators = ( ), 

116) -> type: 

117 # Some decorators create new classes, which invokes this method again. 

118 # Short-circuit to prevent recursive decoration and other tangles. 

119 decorators_ = original.__dict__.get( '_class_decorators_', [ ] ) 

120 if decorators_: return original 

121 setattr( original, '_class_decorators_', decorators_ ) 

122 reproduction = original 

123 for decorator in decorators: 

124 decorators_.append( decorator ) 

125 reproduction = decorator( original ) 

126 if original is not reproduction: 

127 repair_class_reproduction( original, reproduction ) 

128 original = reproduction 

129 decorators_.clear( ) # Flag '__init__' to enable immutability 

130 return reproduction 

131 

132 

133def _immutable_class__init__( class_: type ) -> None: 

134 # Some metaclasses add class attributes in '__init__' method. 

135 # So, we wait until last possible moment to set immutability. 

136 if class_.__dict__.get( '_class_decorators_' ): return 

137 del class_._class_decorators_ 

138 if ( class_behaviors := class_.__dict__.get( '_class_behaviors_' ) ): 

139 class_behaviors.add( behavior_label ) 

140 else: setattr( class_, '_class_behaviors_', { behavior_label } ) 

141 

142 

143def _immutable_class__delattr__( class_: type, name: str ) -> bool: 

144 # Consult class attributes dictionary to ignore immutable base classes. 

145 if behavior_label not in class_.__dict__.get( 

146 '_class_behaviors_', ( ) 

147 ): return False 

148 raise AttributeError( 

149 "Cannot delete attribute {name!r} " 

150 "on class {class_fqname!r}.".format( 

151 name = name, 

152 class_fqname = calculate_class_fqname( class_ ) ) ) 

153 

154 

155def _immutable_class__setattr__( class_: type, name: str ) -> bool: 

156 # Consult class attributes dictionary to ignore immutable base classes. 

157 if behavior_label not in class_.__dict__.get( 

158 '_class_behaviors_', ( ) 

159 ): return False 

160 raise AttributeError( 

161 "Cannot assign attribute {name!r} " 

162 "on class {class_fqname!r}.".format( 

163 name = name, 

164 class_fqname = calculate_class_fqname( class_ ) ) ) 

165 

166 

167class ConcealerExtension: 

168 ''' Conceals instance attributes according to some criteria. 

169 

170 By default, public attributes are displayed. 

171 ''' 

172 

173 _attribute_visibility_includes_: cabc.Collection[ str ] = frozenset( ) 

174 

175 def __dir__( self ) -> tuple[ str, ... ]: 

176 return tuple( sorted( 

177 name for name in super( ).__dir__( ) 

178 if not name.startswith( '_' ) 

179 or name in self._attribute_visibility_includes_ ) ) 

180 

181 

182class ImmutableModule( 

183 ConcealerExtension, types.ModuleType, metaclass = ImmutableClass 

184): 

185 ''' Concealment and immutability on module attributes. ''' 

186 

187 def __delattr__( self, name: str ) -> None: 

188 raise AttributeError( # noqa: TRY003 

189 f"Cannot delete attribute {name!r} " 

190 f"on module {self.__name__!r}." ) # pylint: disable=no-member 

191 

192 def __setattr__( self, name: str, value: typx.Any ) -> None: 

193 raise AttributeError( # noqa: TRY003 

194 f"Cannot assign attribute {name!r} " 

195 f"on module {self.__name__!r}." ) # pylint: disable=no-member 

196 

197 

198class ImmutableObject( ConcealerExtension, metaclass = ImmutableClass ): 

199 ''' Concealment and immutability on instance attributes. ''' 

200 

201 def __delattr__( self, name: str ) -> None: 

202 raise AttributeError( 

203 "Cannot delete attribute {name!r} on instance " 

204 "of class {class_fqname!r}.".format( 

205 name = name, class_fqname = calculate_fqname( self ) ) ) 

206 

207 def __setattr__( self, name: str, value: typx.Any ) -> None: 

208 raise AttributeError( 

209 "Cannot assign attribute {name!r} on instance " 

210 "of class {class_fqname!r}.".format( 

211 name = name, class_fqname = calculate_fqname( self ) ) ) 

212 

213 

214def calculate_class_fqname( class_: type ) -> str: 

215 ''' Calculates fully-qualified name for class. ''' 

216 return f"{class_.__module__}.{class_.__qualname__}" 

217 

218 

219def calculate_fqname( obj: object ) -> str: 

220 ''' Calculates fully-qualified name for class of object. ''' 

221 class_ = type( obj ) 

222 return f"{class_.__module__}.{class_.__qualname__}" 

223 

224 

225def discover_public_attributes( 

226 attributes: cabc.Mapping[ str, typx.Any ] 

227) -> tuple[ str, ... ]: 

228 ''' Discovers public attributes of certain types from dictionary. 

229 

230 By default, callables, including classes, are discovered. 

231 ''' 

232 return tuple( sorted( 

233 name for name, attribute in attributes.items( ) 

234 if not name.startswith( '_' ) and callable( attribute ) ) ) 

235 

236def reclassify_modules( 

237 attributes: typx.Annotated[ 

238 cabc.Mapping[ str, typx.Any ] | types.ModuleType | str, 

239 typx.Doc( 'Module, module name, or dictionary of object attributes.' ), 

240 ], 

241 recursive: typx.Annotated[ 

242 bool, 

243 typx.Doc( 'Recursively reclassify package modules?' ), 

244 ] = False, 

245) -> None: 

246 ''' Reclassifies modules to be immutable. ''' 

247 from inspect import ismodule 

248 from sys import modules 

249 if isinstance( attributes, str ): 

250 attributes = modules[ attributes ] 

251 if isinstance( attributes, types.ModuleType ): 

252 module = attributes 

253 attributes = attributes.__dict__ 

254 else: module = None 

255 package_name = ( 

256 attributes.get( '__package__' ) or attributes.get( '__name__' ) ) 

257 if not package_name: return 

258 for value in attributes.values( ): 

259 if not ismodule( value ): continue 

260 if not value.__name__.startswith( f"{package_name}." ): continue 

261 if recursive: reclassify_modules( value, recursive = True ) 

262 if isinstance( value, ImmutableModule ): continue 

263 value.__class__ = ImmutableModule 

264 if module and not isinstance( module, ImmutableModule ): 

265 module.__class__ = ImmutableModule