Skip to content

Commit

Permalink
Copier Template: Support for immutable modules.
Browse files Browse the repository at this point in the history
Also:

* Fix EditorConfig file matching for Jinja templates.

* Widen visbility attribute in 'Omniexception' to be compatible with
  possible 'ImmutableObject' base class.
  • Loading branch information
emcd committed Dec 15, 2024
1 parent 7cfa0bc commit dd7731b
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 13 deletions.
8 changes: 4 additions & 4 deletions template/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,19 @@ trim_trailing_whitespace = true
[*.md]
max_line_length = 79

[*.py,*.py.jinja]
[*.{py,py.jinja}]
max_line_length = 79
insert_final_newline = true

[*.rs,*.rs.jinja]
[*.{rs,rs.jinja}]
max_line_length = 79
insert_final_newline = true

[*.rst,*.rst.jinja]
[*.{rst,rst.jinja}]
indent_size = 2
max_line_length = 79

[*.toml,*.toml.jinja]
[*.{toml,toml.jinja}]
indent_size = 2

[*.yaml]
Expand Down
2 changes: 2 additions & 0 deletions template/documentation/sphinx/conf.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ nitpick_ignore = [
( 'py:class',
"v, remove specified key and return the corresponding value." ),
# Type annotation weirdnesses.
( 'py:class', "Doc" ),
( 'py:class', "types.Annotated" ),
( 'py:class', "typing_extensions.Any" ),
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@

''' Attribute concealment and immutability. '''

# pylint: disable=unused-wildcard-import,wildcard-import
# ruff: noqa: F403,F405


from __future__ import annotations

from .imports import *
import collections.abc as cabc
import types

import typing_extensions as typx


ClassDecorators: typx.TypeAlias = (
Expand Down Expand Up @@ -179,6 +179,22 @@ class ConcealerExtension:
or name in self._attribute_visibility_includes_ ) )


class ImmutableModule(
ConcealerExtension, types.ModuleType, metaclass = ImmutableClass
):
''' Concealment and immutability on module attributes. '''

def __delattr__( self, name: str ) -> None:
raise AttributeError( # noqa: TRY003
f"Cannot delete attribute {name!r} "
f"on module {self.__name__!r}." ) # pylint: disable=no-member

def __setattr__( self, name: str, value: typx.Any ) -> None:
raise AttributeError( # noqa: TRY003
f"Cannot assign attribute {name!r} "
f"on module {self.__name__!r}." ) # pylint: disable=no-member


class ImmutableObject( ConcealerExtension, metaclass = ImmutableClass ):
''' Concealment and immutability on instance attributes. '''

Expand Down Expand Up @@ -216,3 +232,34 @@ def discover_public_attributes(
return tuple( sorted(
name for name, attribute in attributes.items( )
if not name.startswith( '_' ) and callable( attribute ) ) )

def reclassify_modules(
attributes: typx.Annotated[
cabc.Mapping[ str, typx.Any ] | types.ModuleType | str,
typx.Doc( 'Module, module name, or dictionary of object attributes.' ),
],
recursive: typx.Annotated[
bool,
typx.Doc( 'Recursively reclassify package modules?' ),
] = False,
) -> None:
''' Reclassifies modules to be immutable. '''
from inspect import ismodule
from sys import modules
if isinstance( attributes, str ):
attributes = modules[ attributes ]
if isinstance( attributes, types.ModuleType ):
module = attributes
attributes = attributes.__dict__
else: module = None
package_name = (
attributes.get( '__package__' ) or attributes.get( '__name__' ) )
if not package_name: return
for value in attributes.values( ):
if not ismodule( value ): continue
if not value.__name__.startswith( f"{package_name}." ): continue
if recursive: reclassify_modules( value, recursive = True )
if isinstance( value, ImmutableModule ): continue
value.__class__ = ImmutableModule
if module and not isinstance( module, ImmutableModule ):
module.__class__ = ImmutableModule
7 changes: 5 additions & 2 deletions template/sources/{{ package_name }}/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@
'''


from . import __


class Omniexception( BaseException ):
''' Base for all exceptions raised by package API. '''
# TODO: Class and instance attribute immutability.
# TODO: Class and instance attribute concealment and immutability.

_attribute_visibility_includes_: frozenset[ str ] = (
_attribute_visibility_includes_: __.cabc.Collection[ str ] = (
frozenset( ( '__cause__', '__context__', ) ) )


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,135 @@ def test_222_immutable_class_replacement_super_property( ):
Example.field1 = 'changed'


def test_300_immutable_object_init( ):
# pylint: disable=no-member

def test_300_module_reclassification_by_name( ):
''' Module reclassification works with module name. '''
module = cache_import_module( MODULE_QNAME )
from types import ModuleType
test_module = ModuleType( f"{PACKAGE_NAME}.test" )
test_module.__package__ = PACKAGE_NAME
from sys import modules
modules[ test_module.__name__ ] = test_module
module.reclassify_modules( test_module.__name__ )
assert isinstance( test_module, module.ImmutableModule )
with pytest.raises( AttributeError ):
test_module.new_attr = 42


def test_301_module_reclassification_by_object( ):
''' Module reclassification works with module object. '''
module = cache_import_module( MODULE_QNAME )
from types import ModuleType
test_module = ModuleType( f"{PACKAGE_NAME}.test" )
test_module.__package__ = PACKAGE_NAME
module.reclassify_modules( test_module )
assert isinstance( test_module, module.ImmutableModule )
with pytest.raises( AttributeError ):
test_module.new_attr = 42


def test_302_recursive_module_reclassification( ):
''' Recursive module reclassification works. '''
module = cache_import_module( MODULE_QNAME )
from types import ModuleType
root = ModuleType( f"{PACKAGE_NAME}.test" )
root.__package__ = PACKAGE_NAME
sub1 = ModuleType( f"{PACKAGE_NAME}.test.sub1" )
sub2 = ModuleType( f"{PACKAGE_NAME}.test.sub2" )
root.sub1 = sub1
root.sub2 = sub2
module.reclassify_modules( root, recursive = True )
assert isinstance( root, module.ImmutableModule )
assert isinstance( sub1, module.ImmutableModule )
assert isinstance( sub2, module.ImmutableModule )
with pytest.raises( AttributeError ):
root.new_attr = 42
with pytest.raises( AttributeError ):
sub1.new_attr = 42
with pytest.raises( AttributeError ):
sub2.new_attr = 42


def test_303_module_reclassification_respects_package( ):
''' Module reclassification only affects package modules. '''
module = cache_import_module( MODULE_QNAME )
from types import ModuleType
root = ModuleType( f"{PACKAGE_NAME}.test" )
root.__package__ = PACKAGE_NAME
external = ModuleType( "other_package.module" )
other_pkg = ModuleType( "other_package" )
root.external = external
root.other_pkg = other_pkg
module.reclassify_modules( root, recursive = True )
assert isinstance( root, module.ImmutableModule )
assert not isinstance( external, module.ImmutableModule )
assert not isinstance( other_pkg, module.ImmutableModule )
with pytest.raises( AttributeError ):
root.new_attr = 42
external.new_attr = 42 # Should work
assert 42 == external.new_attr


def test_304_module_reclassification_by_dict( ):
''' Module reclassification works with attribute dictionary. '''
module = cache_import_module( MODULE_QNAME )
from types import ModuleType
m1 = ModuleType( f"{PACKAGE_NAME}.test1" )
m2 = ModuleType( f"{PACKAGE_NAME}.test2" )
m3 = ModuleType( "other.module" )
attrs = {
'__package__': PACKAGE_NAME,
'module1': m1,
'module2': m2,
'external': m3,
'other': 42,
}
module.reclassify_modules( attrs )
assert isinstance( m1, module.ImmutableModule )
assert isinstance( m2, module.ImmutableModule )
assert not isinstance( m3, module.ImmutableModule )
with pytest.raises( AttributeError ):
m1.new_attr = 42
with pytest.raises( AttributeError ):
m2.new_attr = 42
m3.new_attr = 42 # Should work


def test_305_module_reclassification_requires_package( ):
''' Module reclassification requires package name. '''
module = cache_import_module( MODULE_QNAME )
from types import ModuleType
m1 = ModuleType( f"{PACKAGE_NAME}.test1" )
attrs = { 'module1': m1 }
module.reclassify_modules( attrs )
assert not isinstance( m1, module.ImmutableModule )
m1.new_attr = 42
assert 42 == m1.new_attr


def test_306_module_attribute_operations( ):
''' Module prevents attribute deletion and modification. '''
module = cache_import_module( MODULE_QNAME )
from types import ModuleType
test_module = ModuleType( f"{PACKAGE_NAME}.test" )
test_module.__package__ = PACKAGE_NAME
test_module.existing = 42
module.reclassify_modules( test_module )
with pytest.raises( AttributeError ) as exc_info:
del test_module.existing
assert "Cannot delete attribute 'existing'" in str( exc_info.value )
assert test_module.__name__ in str( exc_info.value )
with pytest.raises( AttributeError ) as exc_info:
test_module.existing = 24
assert "Cannot assign attribute 'existing'" in str( exc_info.value )
assert test_module.__name__ in str( exc_info.value )
assert 42 == test_module.existing

# pylint: enable=no-member


def test_400_immutable_object_init( ):
''' Object prevents modification after initialization. '''
module = cache_import_module( MODULE_QNAME )

Expand All @@ -177,7 +305,7 @@ def test_300_immutable_object_init( ):
with pytest.raises( AttributeError ): del obj.value


def test_400_name_calculation( ):
def test_500_name_calculation( ):
''' Name calculation functions work correctly. '''
module = cache_import_module( MODULE_QNAME )
assert 'builtins.NoneType' == module.calculate_fqname( None )
Expand All @@ -197,7 +325,7 @@ def test_400_name_calculation( ):
),
)
)
def test_500_attribute_discovery( provided, expected ):
def test_600_attribute_discovery( provided, expected ):
''' Public attributes are discovered from dictionary. '''
module = cache_import_module( MODULE_QNAME )
assert expected == module.discover_public_attributes( provided )

0 comments on commit dd7731b

Please sign in to comment.