Coverage for sources/falsifier/__/immutables.py: 100%
113 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-21 00:29 +0000
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-21 00:29 +0000
1# vim: set filetype=python fileencoding=utf-8:
2# -*- coding: utf-8 -*-
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#============================================================================#
21''' Attribute concealment and immutability. '''
24from __future__ import annotations
26import collections.abc as cabc
27import types
29import typing_extensions as typx
32ClassDecorators: typx.TypeAlias = (
33 cabc.Iterable[ cabc.Callable[ [ type ], type ] ] )
36behavior_label = 'immutability'
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
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
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
74class ImmutableClass( type ):
75 ''' Concealment and immutability on class attributes. '''
77 _class_attribute_visibility_includes_: cabc.Collection[ str ] = (
78 frozenset( ) )
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 )
91 def __init__( selfclass, *posargs: typx.Any, **nomargs: typx.Any ):
92 super( ).__init__( *posargs, **nomargs )
93 _immutable_class__init__( selfclass )
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 ) )
104 def __delattr__( selfclass, name: str ) -> None:
105 if not _immutable_class__delattr__( selfclass, name ):
106 super( ).__delattr__( name )
108 def __setattr__( selfclass, name: str, value: typx.Any ) -> None:
109 if not _immutable_class__setattr__( selfclass, name ):
110 super( ).__setattr__( name, value )
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
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 } )
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_ ) ) )
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_ ) ) )
167class ConcealerExtension:
168 ''' Conceals instance attributes according to some criteria.
170 By default, public attributes are displayed.
171 '''
173 _attribute_visibility_includes_: cabc.Collection[ str ] = frozenset( )
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_ ) )
182class ImmutableModule(
183 ConcealerExtension, types.ModuleType, metaclass = ImmutableClass
184):
185 ''' Concealment and immutability on module attributes. '''
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
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
198class ImmutableObject( ConcealerExtension, metaclass = ImmutableClass ):
199 ''' Concealment and immutability on instance attributes. '''
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 ) ) )
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 ) ) )
214def calculate_class_fqname( class_: type ) -> str:
215 ''' Calculates fully-qualified name for class. '''
216 return f"{class_.__module__}.{class_.__qualname__}"
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__}"
225def discover_public_attributes(
226 attributes: cabc.Mapping[ str, typx.Any ]
227) -> tuple[ str, ... ]:
228 ''' Discovers public attributes of certain types from dictionary.
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 ) ) )
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