Coverage for sources/frigid/dictionaries.py: 100%
108 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 04:27 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 04:27 +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''' Immutable dictionaries.
23 Dictionaries which cannot be modified after creation.
25 .. note::
27 While :py:class:`types.MappingProxyType` also provides a read-only view
28 of a dictionary, it has important differences from
29 :py:class:`Dictionary`:
31 * A ``MappingProxyType`` is a view over a mutable dictionary, so its
32 contents can still change if the underlying dictionary is modified.
33 * ``Dictionary`` owns its data and guarantees that it will never
34 change.
35 * ``Dictionary`` provides set operations (union, intersection) that
36 maintain immutability guarantees.
38 Use ``MappingProxyType`` when you want to expose a read-only view of a
39 dictionary that might need to change. Use ``Dictionary`` when you want
40 to ensure that the data can never change, such as for configuration
41 objects or other cases requiring strong immutability guarantees.
43 * :py:class:`AbstractDictionary`:
44 Base class defining the immutable dictionary interface. Implementations
45 must provide ``__getitem__``, ``__iter__``, and ``__len__``.
47 * :py:class:`Dictionary`:
48 Standard implementation of an immutable dictionary. Supports all usual
49 dict read operations but prevents any modifications.
51 * :py:class:`ValidatorDictionary`:
52 Validates entries before addition using a supplied predicate function.
54 >>> from frigid import Dictionary
55 >>> d = Dictionary( x = 1, y = 2 )
56 >>> d[ 'z' ] = 3 # Attempt to add entry
57 Traceback (most recent call last):
58 ...
59 frigid.exceptions.EntryImmutability: Cannot assign or delete entry for 'z'.
60 >>> d[ 'x' ] = 4 # Attempt modification
61 Traceback (most recent call last):
62 ...
63 frigid.exceptions.EntryImmutability: Cannot assign or delete entry for 'x'.
64 >>> del d[ 'y' ] # Attempt removal
65 Traceback (most recent call last):
66 ...
67 frigid.exceptions.EntryImmutability: Cannot assign or delete entry for 'y'.
68'''
71from . import __
72from . import classes as _classes
75class AbstractDictionary( __.cabc.Mapping[ __.H, __.V ] ):
76 ''' Abstract base class for immutable dictionaries.
78 An immutable dictionary prevents modification or removal of entries
79 after creation. This provides a clean interface for dictionaries
80 that should never change.
82 Implementations must provide __getitem__, __iter__, __len__.
83 '''
85 @__.abc.abstractmethod
86 def __iter__( self ) -> __.cabc.Iterator[ __.H ]:
87 raise NotImplementedError # pragma: no coverage
89 @__.abc.abstractmethod
90 def __len__( self ) -> int:
91 raise NotImplementedError # pragma: no coverage
93 @__.abc.abstractmethod
94 def __getitem__( self, key: __.H ) -> __.V:
95 raise NotImplementedError # pragma: no coverage
97 def __setitem__( self, key: __.H, value: __.V ) -> None:
98 from .exceptions import EntryImmutability
99 raise EntryImmutability( key )
101 def __delitem__( self, key: __.H ) -> None:
102 from .exceptions import EntryImmutability
103 raise EntryImmutability( key )
106class _DictionaryOperations( AbstractDictionary[ __.H, __.V ] ):
107 ''' Mix-in providing additional dictionary operations. '''
109 # TODO? Common __init__.
111 def __or__( self, other: __.cabc.Mapping[ __.H, __.V ] ) -> __.typx.Self:
112 if not isinstance( other, __.cabc.Mapping ): return NotImplemented
113 conflicts = set( self.keys( ) ) & set( other.keys( ) )
114 if conflicts:
115 from .exceptions import EntryImmutability
116 raise EntryImmutability( next( iter( conflicts ) ) )
117 data = dict( self )
118 data.update( other )
119 return self.with_data( data )
121 def __ror__( self, other: __.cabc.Mapping[ __.H, __.V ] ) -> __.typx.Self:
122 if not isinstance( other, __.cabc.Mapping ): return NotImplemented
123 conflicts = set( self.keys( ) ) & set( other.keys( ) )
124 if conflicts:
125 from .exceptions import EntryImmutability
126 raise EntryImmutability( next( iter( conflicts ) ) )
127 data = dict( other )
128 data.update( self )
129 return self.with_data( data )
131 def __and__(
132 self,
133 other: __.cabc.Set[ __.H ] | __.cabc.Mapping[ __.H, __.V ]
134 ) -> __.typx.Self:
135 if isinstance( other, __.cabc.Mapping ):
136 return self.with_data(
137 ( key, value ) for key, value in self.items( )
138 if key in other and other[ key ] == value )
139 if isinstance( other, ( __.cabc.Set, __.cabc.KeysView ) ):
140 return self.with_data(
141 ( key, self[ key ] ) for key in self.keys( ) & other )
142 return NotImplemented
144 def __rand__(
145 self,
146 other: __.cabc.Set[ __.H ] | __.cabc.Mapping[ __.H, __.V ]
147 ) -> __.typx.Self:
148 if not isinstance(
149 other, ( __.cabc.Mapping, __.cabc.Set, __.cabc.KeysView )
150 ): return NotImplemented
151 return self & other
153 @__.abc.abstractmethod
154 def copy( self ) -> __.typx.Self:
155 ''' Provides fresh copy of dictionary. '''
156 raise NotImplementedError # pragma: no coverage
158 @__.abc.abstractmethod
159 def with_data(
160 self,
161 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
162 **entries: __.DictionaryNominativeArgument[ __.V ],
163 ) -> __.typx.Self:
164 ''' Creates new dictionary with same behavior but different data. '''
165 raise NotImplementedError # pragma: no coverage
168class Dictionary( # noqa: PLW1641
169 _DictionaryOperations[ __.H, __.V ],
170 metaclass = _classes.AbstractBaseClass,
171 class_mutables = _classes.abc_class_mutables,
172):
173 ''' Immutable dictionary. '''
175 __slots__ = ( '_data_', )
177 _data_: __.ImmutableDictionary[ __.H, __.V ]
178 _dynadoc_fragments_ = ( 'dictionary entries protect', )
180 def __init__(
181 self,
182 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
183 **entries: __.DictionaryNominativeArgument[ __.V ],
184 ) -> None:
185 self._data_ = __.ImmutableDictionary( *iterables, **entries )
186 super( ).__init__( )
188 def __iter__( self ) -> __.cabc.Iterator[ __.H ]:
189 return iter( self._data_ )
191 def __len__( self ) -> int:
192 return len( self._data_ )
194 def __repr__( self ) -> str:
195 return "{fqname}( {contents} )".format(
196 fqname = __.ccutils.qualify_class_name( type( self ) ),
197 contents = self._data_.__repr__( ) )
199 def __str__( self ) -> str:
200 return str( self._data_ )
202 def __contains__( self, key: __.typx.Any ) -> bool:
203 return key in self._data_
205 def __getitem__( self, key: __.H ) -> __.V:
206 return self._data_[ key ]
208 def __eq__( self, other: __.typx.Any ) -> __.ComparisonResult:
209 if isinstance( other, __.cabc.Mapping ):
210 return self._data_ == other
211 return NotImplemented
213 def __ne__( self, other: __.typx.Any ) -> __.ComparisonResult:
214 if isinstance( other, __.cabc.Mapping ):
215 return self._data_ != other
216 return NotImplemented
218 def copy( self ) -> __.typx.Self:
219 ''' Provides fresh copy of dictionary. '''
220 return type( self )( self )
222 def get( # pyright: ignore
223 self, key: __.H, default: __.Absential[ __.V ] = __.absent
224 ) -> __.typx.Annotated[
225 __.V,
226 __.typx.Doc(
227 'Value of entry, if it exists. '
228 'Else, supplied default value or ``None``.' )
229 ]:
230 ''' Retrieves entry associated with key, if it exists. '''
231 if __.is_absent( default ):
232 return self._data_.get( key ) # pyright: ignore
233 return self._data_.get( key, default )
235 def keys( self ) -> __.cabc.KeysView[ __.H ]:
236 ''' Provides iterable view over dictionary keys. '''
237 return self._data_.keys( )
239 def items( self ) -> __.cabc.ItemsView[ __.H, __.V ]:
240 ''' Provides iterable view over dictionary items. '''
241 return self._data_.items( )
243 def values( self ) -> __.cabc.ValuesView[ __.V ]:
244 ''' Provides iterable view over dictionary values. '''
245 return self._data_.values( )
247 def with_data(
248 self,
249 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
250 **entries: __.DictionaryNominativeArgument[ __.V ],
251 ) -> __.typx.Self:
252 return type( self )( *iterables, **entries )
255class ValidatorDictionary( Dictionary[ __.H, __.V ] ):
256 ''' Immutable dictionary with validation of entries on initialization. '''
258 __slots__ = ( '_validator_', )
260 _dynadoc_fragments_ = (
261 'dictionary entries protect', 'dictionary entries validate' )
262 _validator_: __.DictionaryValidator[ __.H, __.V ]
264 def __init__(
265 self,
266 validator: __.DictionaryValidator[ __.H, __.V ],
267 /,
268 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
269 **entries: __.DictionaryNominativeArgument[ __.V ],
270 ) -> None:
271 self._validator_ = validator
272 entries_: list[ tuple[ __.H, __.V ] ] = [ ]
273 from itertools import chain
274 # Collect entries in case an iterable is a generator
275 # which would be consumed during validation, before initialization.
276 for key, value in chain.from_iterable( map( # pyright: ignore
277 lambda element: ( # pyright: ignore
278 element.items( )
279 if isinstance( element, __.cabc.Mapping )
280 else element
281 ),
282 ( *iterables, entries )
283 ) ):
284 if not self._validator_( key, value ): # pyright: ignore
285 from .exceptions import EntryInvalidity
286 raise EntryInvalidity( key, value )
287 entries_.append( ( key, value ) ) # pyright: ignore
288 super( ).__init__( entries_ )
290 def __repr__( self ) -> str:
291 return "{fqname}( {validator}, {contents} )".format(
292 fqname = __.ccutils.qualify_class_name( type( self ) ),
293 validator = self._validator_.__repr__( ),
294 contents = self._data_.__repr__( ) )
296 def copy( self ) -> __.typx.Self:
297 ''' Provides fresh copy of dictionary. '''
298 return type( self )( self._validator_, self )
300 def with_data(
301 self,
302 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
303 **entries: __.DictionaryNominativeArgument[ __.V ],
304 ) -> __.typx.Self:
305 ''' Creates new dictionary with same behavior but different data. '''
306 return type( self )( self._validator_, *iterables, **entries )