Coverage for sources/accretive/dictionaries.py: 100%
189 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-26 03:08 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-26 03:08 +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''' Accretive dictionaries.
23 Dictionaries which can grow but never shrink. Once an entry is added, it
24 cannot be modified or removed.
26 * :py:class:`AbstractDictionary`:
27 Base class defining the accretive dictionary interface. Implementations
28 must provide ``__getitem__``, ``__iter__``, ``__len__``, and storage
29 methods.
31 * :py:class:`Dictionary`:
32 Standard implementation of an accretive dictionary. Supports all usual
33 dict operations except those that would modify or remove existing
34 entries.
36 * :py:class:`ProducerDictionary`:
37 Automatically generates values for missing keys using a supplied factory
38 function. Similar to :py:class:`collections.defaultdict` but with
39 accretive behavior.
41 * :py:class:`ValidatorDictionary`:
42 Validates entries before addition using a supplied predicate function.
44 * :py:class:`ProducerValidatorDictionary`:
45 Combines producer and validator behaviors. Generated values must pass
46 validation before being added.
48 >>> from accretive import Dictionary
49 >>> d = Dictionary( apples = 12, bananas = 6 )
50 >>> d[ 'cherries' ] = 42 # Add new entry
51 >>> d[ 'apples' ] = 14 # Attempt modification
52 Traceback (most recent call last):
53 ...
54 accretive.exceptions.EntryImmutability: Could not alter or remove existing entry for 'apples'.
55 >>> del d[ 'bananas' ] # Attempt removal
56 Traceback (most recent call last):
57 ...
58 accretive.exceptions.EntryImmutability: Could not alter or remove existing entry for 'bananas'.
60 >>> from accretive import ProducerDictionary
61 >>> d = ProducerDictionary( list ) # list() called for missing keys
62 >>> d[ 'new' ]
63 []
64 >>> d[ 'new' ].append( 1 ) # List is mutable, but entry is fixed
65 >>> d[ 'new' ] = [ ] # Attempt modification
66 Traceback (most recent call last):
67 ...
68 accretive.exceptions.EntryImmutability: Could not alter or remove existing entry for 'new'.
70 >>> from accretive import ValidatorDictionary
71 >>> d = ValidatorDictionary( lambda k, v: isinstance( v, int ) )
72 >>> d[ 'valid' ] = 42 # Passes validation
73 >>> d[ 'invalid' ] = 'str' # Fails validation
74 Traceback (most recent call last):
75 ...
76 accretive.exceptions.EntryInvalidity: Could not add invalid entry with key, 'invalid', and value, 'str', to dictionary.
77''' # noqa: E501
80from . import __
81from . import classes as _classes
84class AbstractDictionary( __.cabc.Mapping[ __.H, __.V ] ):
85 ''' Abstract base class for dictionaries that can grow but not shrink.
87 An accretive dictionary allows new entries to be added but prevents
88 modification or removal of existing entries. This provides a middle
89 ground between immutable and fully mutable mappings.
91 Implementations must provide:
92 - __getitem__, __iter__, __len__
93 - _pre_setitem_ for entry validation/preparation
94 - _store_item_ for storage implementation
95 '''
97 @__.abc.abstractmethod
98 def __iter__( self ) -> __.cabc.Iterator[ __.H ]:
99 raise NotImplementedError # pragma: no coverage
101 @__.abc.abstractmethod
102 def __len__( self ) -> int:
103 raise NotImplementedError # pragma: no coverage
105 @__.abc.abstractmethod
106 def __getitem__( self, key: __.H ) -> __.V:
107 raise NotImplementedError # pragma: no coverage
109 def _pre_setitem_(
110 self, key: __.H, value: __.V
111 ) -> tuple[ __.H, __.V ]:
112 ''' Validates and/or prepares entry before addition.
114 Should raise appropriate exception if entry is invalid.
115 '''
116 return key, value
118 @__.abc.abstractmethod
119 def _store_item_( self, key: __.H, value: __.V ) -> None:
120 ''' Stores entry in underlying storage. '''
121 raise NotImplementedError # pragma: no coverage
123 def __setitem__( self, key: __.H, value: __.V ) -> None:
124 key, value = self._pre_setitem_( key, value )
125 if key in self:
126 from .exceptions import EntryImmutability
127 raise EntryImmutability( key )
128 self._store_item_( key, value )
130 def __delitem__( self, key: __.H ) -> None:
131 from .exceptions import EntryImmutability
132 raise EntryImmutability( key )
134 def setdefault( self, key: __.H, default: __.V ) -> __.V:
135 ''' Returns value for key, setting it to default if missing. '''
136 try: return self[ key ]
137 except KeyError:
138 self[ key ] = default
139 return default
141 def update(
142 self,
143 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
144 **entries: __.DictionaryNominativeArgument[ __.V ],
145 ) -> __.typx.Self:
146 ''' Adds new entries as a batch. Returns self. '''
147 from itertools import chain
148 updates: list[ tuple[ __.H, __.V ] ] = [ ]
149 for indicator, value in chain.from_iterable( map( # pyright: ignore
150 lambda element: ( # pyright: ignore
151 element.items( )
152 if isinstance( element, __.cabc.Mapping )
153 else element
154 ),
155 ( *iterables, entries )
156 ) ):
157 indicator_, value_ = (
158 self._pre_setitem_( indicator, value ) ) # pyright: ignore
159 if indicator_ in self:
160 from .exceptions import EntryImmutability
161 raise EntryImmutability( indicator_ )
162 updates.append( ( indicator_, value_ ) )
163 for indicator, value in updates: self._store_item_( indicator, value )
164 return self
167class _DictionaryOperations( AbstractDictionary[ __.H, __.V ] ):
168 ''' Mix-in providing additional dictionary operations. '''
170 def __init__(
171 self, *posargs: __.typx.Any, **nomargs: __.typx.Any
172 ) -> None:
173 super( ).__init__( *posargs, **nomargs )
175 def __or__( self, other: __.cabc.Mapping[ __.H, __.V ] ) -> __.typx.Self:
176 if not isinstance( other, __.cabc.Mapping ): return NotImplemented
177 conflicts = set( self.keys( ) ) & set( other.keys( ) )
178 if conflicts:
179 from .exceptions import EntryImmutability
180 raise EntryImmutability( next( iter( conflicts ) ) )
181 data = dict( self )
182 data.update( other )
183 return self.with_data( data )
185 def __ror__( self, other: __.cabc.Mapping[ __.H, __.V ] ) -> __.typx.Self:
186 if not isinstance( other, __.cabc.Mapping ): return NotImplemented
187 return self | other
189 def __and__(
190 self,
191 other: __.cabc.Set[ __.H ] | __.cabc.Mapping[ __.H, __.V ]
192 ) -> __.typx.Self:
193 if isinstance( other, __.cabc.Mapping ):
194 return self.with_data( # pyright: ignore
195 ( key, value ) for key, value in self.items( )
196 if key in other and other[ key ] == value )
197 if isinstance( other, ( __.cabc.Set, __.cabc.KeysView ) ):
198 return self.with_data( # pyright: ignore
199 ( key, self[ key ] ) for key in self.keys( ) & other )
200 return NotImplemented
202 def __rand__(
203 self,
204 other: __.cabc.Set[ __.H ] | __.cabc.Mapping[ __.H, __.V ]
205 ) -> __.typx.Self:
206 if not isinstance(
207 other, ( __.cabc.Mapping, __.cabc.Set, __.cabc.KeysView )
208 ): return NotImplemented
209 return self & other
211 @__.abc.abstractmethod
212 def copy( self ) -> __.typx.Self:
213 ''' Provides fresh copy of dictionary. '''
214 raise NotImplementedError # pragma: no coverage
216 @__.abc.abstractmethod
217 def with_data(
218 self,
219 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
220 **entries: __.DictionaryNominativeArgument[ __.V ],
221 ) -> __.typx.Self:
222 ''' Creates new dictionary with same behavior but different data. '''
223 raise NotImplementedError # pragma: no coverage
226class Dictionary(
227 _DictionaryOperations[ __.H, __.V ],
228 metaclass = _classes.AbstractBaseClass,
229 class_mutables = _classes.abc_class_mutables,
230):
231 ''' Accretive dictionary. '''
233 __slots__ = ( '_data_', )
235 _data_: __.AccretiveDictionary[ __.H, __.V ]
236 _dynadoc_fragments_ = ( 'dictionary entries accrete', )
238 def __init__(
239 self,
240 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
241 **entries: __.DictionaryNominativeArgument[ __.V ],
242 ) -> None:
243 self._data_ = __.AccretiveDictionary( *iterables, **entries )
244 super( ).__init__( )
246 __hash__ = None
248 def __iter__( self ) -> __.cabc.Iterator[ __.H ]:
249 return iter( self._data_ )
251 def __len__( self ) -> int:
252 return len( self._data_ )
254 def __repr__( self ) -> str:
255 return "{fqname}( {contents} )".format(
256 fqname = __.ccutils.qualify_class_name( type( self ) ),
257 contents = str( self._data_ ) )
259 def __str__( self ) -> str:
260 return str( self._data_ )
262 def __contains__( self, key: __.typx.Any ) -> bool:
263 return key in self._data_
265 def __getitem__( self, key: __.H ) -> __.V:
266 return self._data_[ key ]
268 def __eq__( self, other: __.typx.Any ) -> __.ComparisonResult:
269 if isinstance( other, __.cabc.Mapping ):
270 return self._data_ == other
271 return NotImplemented
273 def __ne__( self, other: __.typx.Any ) -> __.ComparisonResult:
274 if isinstance( other, __.cabc.Mapping ):
275 return self._data_ != other
276 return NotImplemented
278 def copy( self ) -> __.typx.Self:
279 ''' Provides fresh copy of dictionary. '''
280 return type( self )( self )
282 def get( # pyright: ignore
283 self, key: __.H, default: __.Absential[ __.V ] = __.absent
284 ) -> __.typx.Annotated[
285 __.V,
286 __.typx.Doc(
287 'Value of entry, if it exists. '
288 'Else, supplied default value or ``None``.' )
289 ]:
290 ''' Retrieves entry associated with key, if it exists. '''
291 if __.is_absent( default ):
292 return self._data_.get( key ) # pyright: ignore
293 return self._data_.get( key, default )
295 def keys( self ) -> __.cabc.KeysView[ __.H ]:
296 ''' Provides iterable view over dictionary keys. '''
297 return self._data_.keys( )
299 def items( self ) -> __.cabc.ItemsView[ __.H, __.V ]:
300 ''' Provides iterable view over dictionary items. '''
301 return self._data_.items( )
303 def values( self ) -> __.cabc.ValuesView[ __.V ]:
304 ''' Provides iterable view over dictionary values. '''
305 return self._data_.values( )
307 def with_data(
308 self,
309 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
310 **entries: __.DictionaryNominativeArgument[ __.V ],
311 ) -> __.typx.Self:
312 return type( self )( *iterables, **entries )
314 def _store_item_( self, key: __.H, value: __.V ) -> None:
315 self._data_[ key ] = value
318class ProducerDictionary( Dictionary[ __.H, __.V ] ):
319 ''' Accretive dictionary with default value for missing entries. '''
321 __slots__ = ( '_producer_', )
323 _dynadoc_fragments_ = (
324 'dictionary entries accrete', 'dictionary entries produce' )
325 _producer_: __.DictionaryProducer[ __.V ]
327 def __init__(
328 self,
329 producer: __.DictionaryProducer[ __.V ],
330 /,
331 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
332 **entries: __.DictionaryNominativeArgument[ __.V ],
333 ):
334 # TODO: Validate producer argument.
335 self._producer_ = producer
336 super( ).__init__( *iterables, **entries )
338 def __repr__( self ) -> str:
339 return "{fqname}( {producer}, {contents} )".format(
340 fqname = __.ccutils.qualify_class_name( type( self ) ),
341 producer = self._producer_,
342 contents = str( self._data_ ) )
344 def __getitem__( self, key: __.H ) -> __.V:
345 if key not in self:
346 value = self._producer_( )
347 self[ key ] = value
348 else: value = super( ).__getitem__( key )
349 return value
351 def copy( self ) -> __.typx.Self:
352 ''' Provides fresh copy of dictionary. '''
353 dictionary = type( self )( self._producer_ )
354 return dictionary.update( self )
356 def setdefault( self, key: __.H, default: __.V ) -> __.V:
357 ''' Returns value for key, setting it to default if missing. '''
358 if key not in self:
359 self[ key ] = default
360 return default
361 return self[ key ]
363 def with_data(
364 self,
365 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
366 **entries: __.DictionaryNominativeArgument[ __.V ],
367 ) -> __.typx.Self:
368 return type( self )( self._producer_, *iterables, **entries )
371class ValidatorDictionary( Dictionary[ __.H, __.V ] ):
372 ''' Accretive dictionary with validation of new entries. '''
374 __slots__ = ( '_validator_', )
376 _dynadoc_fragments_ = (
377 'dictionary entries accrete', 'dictionary entries validate' )
378 _validator_: __.DictionaryValidator[ __.H, __.V ]
380 def __init__(
381 self,
382 validator: __.DictionaryValidator[ __.H, __.V ],
383 /,
384 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
385 **entries: __.DictionaryNominativeArgument[ __.V ],
386 ) -> None:
387 self._validator_ = validator
388 super( ).__init__( *iterables, **entries )
390 def __repr__( self ) -> str:
391 return "{fqname}( {validator}, {contents} )".format(
392 fqname = __.ccutils.qualify_class_name( type( self ) ),
393 validator = self._validator_,
394 contents = str( self._data_ ) )
396 def _pre_setitem_( self, key: __.H, value: __.V ) -> tuple[ __.H, __.V ]:
397 if not self._validator_( key, value ):
398 from .exceptions import EntryInvalidity
399 raise EntryInvalidity( key, value )
400 return key, value
402 def copy( self ) -> __.typx.Self:
403 ''' Provides fresh copy of dictionary. '''
404 dictionary = type( self )( self._validator_ )
405 return dictionary.update( self )
407 def with_data(
408 self,
409 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
410 **entries: __.DictionaryNominativeArgument[ __.V ],
411 ) -> __.typx.Self:
412 return type( self )( self._validator_, *iterables, **entries )
415class ProducerValidatorDictionary( Dictionary[ __.H, __.V ] ):
416 ''' Accretive dictionary with defaults and validation. '''
418 __slots__ = ( '_producer_', '_validator_' )
420 _dynadoc_fragments_ = (
421 'dictionary entries accrete',
422 'dictionary entries produce',
423 'dictionary entries validate' )
424 _producer_: __.DictionaryProducer[ __.V ]
425 _validator_: __.DictionaryValidator[ __.H, __.V ]
427 def __init__(
428 self,
429 producer: __.DictionaryProducer[ __.V ],
430 validator: __.DictionaryValidator[ __.H, __.V ],
431 /,
432 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
433 **entries: __.DictionaryNominativeArgument[ __.V ],
434 ) -> None:
435 self._producer_ = producer
436 self._validator_ = validator
437 super( ).__init__( *iterables, **entries )
439 def __repr__( self ) -> str:
440 return "{fqname}( {producer}, {validator}, {contents} )".format(
441 fqname = __.ccutils.qualify_class_name( type( self ) ),
442 producer = self._producer_,
443 validator = self._validator_,
444 contents = str( self._data_ ) )
446 def __getitem__( self, key: __.H ) -> __.V:
447 if key not in self:
448 value = self._producer_( )
449 if not self._validator_( key, value ):
450 from .exceptions import EntryInvalidity
451 raise EntryInvalidity( key, value )
452 self[ key ] = value
453 else: value = super( ).__getitem__( key )
454 return value
456 def _pre_setitem_( self, key: __.H, value: __.V ) -> tuple[ __.H, __.V ]:
457 if not self._validator_( key, value ):
458 from .exceptions import EntryInvalidity
459 raise EntryInvalidity( key, value )
460 return key, value
462 def copy( self ) -> __.typx.Self:
463 ''' Provides fresh copy of dictionary. '''
464 dictionary = type( self )( self._producer_, self._validator_ )
465 return dictionary.update( self )
467 def setdefault( self, key: __.H, default: __.V ) -> __.V:
468 ''' Returns value for key, setting it to default if missing. '''
469 if key not in self:
470 self[ key ] = default
471 return default
472 return self[ key ]
474 def with_data(
475 self,
476 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ],
477 **entries: __.DictionaryNominativeArgument[ __.V ],
478 ) -> __.typx.Self:
479 return type( self )(
480 self._producer_, self._validator_, *iterables, **entries )