Coverage for sources/frigid/dictionaries.py: 100%

104 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-05 03:33 +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# pylint: disable=line-too-long 

22''' Immutable dictionaries. 

23 

24 Dictionaries which cannot be modified after creation. 

25 

26 .. note:: 

27 

28 While :py:class:`types.MappingProxyType` also provides a read-only view 

29 of a dictionary, it has important differences from 

30 :py:class:`Dictionary`: 

31 

32 * A ``MappingProxyType`` is a view over a mutable dictionary, so its 

33 contents can still change if the underlying dictionary is modified. 

34 * ``Dictionary`` owns its data and guarantees that it will never 

35 change. 

36 * ``Dictionary`` provides set operations (union, intersection) that 

37 maintain immutability guarantees. 

38 

39 Use ``MappingProxyType`` when you want to expose a read-only view of a 

40 dictionary that might need to change. Use ``Dictionary`` when you want 

41 to ensure that the data can never change, such as for configuration 

42 objects or other cases requiring strong immutability guarantees. 

43 

44 * :py:class:`AbstractDictionary`: 

45 Base class defining the immutable dictionary interface. Implementations 

46 must provide ``__getitem__``, ``__iter__``, and ``__len__``. 

47 

48 * :py:class:`Dictionary`: 

49 Standard implementation of an immutable dictionary. Supports all usual 

50 dict read operations but prevents any modifications. 

51 

52 * :py:class:`ValidatorDictionary`: 

53 Validates entries before addition using a supplied predicate function. 

54 

55 >>> from frigid import Dictionary 

56 >>> d = Dictionary( x = 1, y = 2 ) 

57 >>> d[ 'z' ] = 3 # Attempt to add entry 

58 Traceback (most recent call last): 

59 ... 

60 frigid.exceptions.EntryImmutabilityError: Cannot assign or delete entry for 'z'. 

61 >>> d[ 'x' ] = 4 # Attempt modification 

62 Traceback (most recent call last): 

63 ... 

64 frigid.exceptions.EntryImmutabilityError: Cannot assign or delete entry for 'x'. 

65 >>> del d[ 'y' ] # Attempt removal 

66 Traceback (most recent call last): 

67 ... 

68 frigid.exceptions.EntryImmutabilityError: Cannot assign or delete entry for 'y'. 

69''' 

70# pylint: enable=line-too-long 

71 

72 

73from . import __ 

74from . import classes as _classes 

75from . import objects as _objects 

76 

77 

78class AbstractDictionary( __.cabc.Mapping[ __.H, __.V ] ): 

79 ''' Abstract base class for immutable dictionaries. 

80 

81 An immutable dictionary prevents modification or removal of entries 

82 after creation. This provides a clean interface for dictionaries 

83 that should never change. 

84 

85 Implementations must provide __getitem__, __iter__, __len__. 

86 ''' 

87 

88 @__.abc.abstractmethod 

89 def __iter__( self ) -> __.cabc.Iterator[ __.H ]: 

90 raise NotImplementedError # pragma: no coverage 

91 

92 @__.abc.abstractmethod 

93 def __len__( self ) -> int: 

94 raise NotImplementedError # pragma: no coverage 

95 

96 @__.abc.abstractmethod 

97 def __getitem__( self, key: __.H ) -> __.V: 

98 raise NotImplementedError # pragma: no coverage 

99 

100 def __setitem__( self, key: __.H, value: __.V ) -> None: 

101 from .exceptions import EntryImmutabilityError 

102 raise EntryImmutabilityError( key ) 

103 

104 def __delitem__( self, key: __.H ) -> None: 

105 from .exceptions import EntryImmutabilityError 

106 raise EntryImmutabilityError( key ) 

107 

108 

109class _DictionaryOperations( AbstractDictionary[ __.H, __.V ] ): 

110 ''' Mix-in providing additional dictionary operations. ''' 

111 

112 # TODO? Common __init__. 

113 

114 def __or__( self, other: __.cabc.Mapping[ __.H, __.V ] ) -> __.typx.Self: 

115 if not isinstance( other, __.cabc.Mapping ): return NotImplemented 

116 conflicts = set( self.keys( ) ) & set( other.keys( ) ) 

117 if conflicts: 

118 from .exceptions import EntryImmutabilityError 

119 raise EntryImmutabilityError( next( iter( conflicts ) ) ) 

120 data = dict( self ) 

121 data.update( other ) 

122 return self.with_data( data ) 

123 

124 def __ror__( self, other: __.cabc.Mapping[ __.H, __.V ] ) -> __.typx.Self: 

125 if not isinstance( other, __.cabc.Mapping ): return NotImplemented 

126 return self | other 

127 

128 def __and__( 

129 self, 

130 other: __.cabc.Set[ __.H ] | __.cabc.Mapping[ __.H, __.V ] 

131 ) -> __.typx.Self: 

132 if isinstance( other, __.cabc.Mapping ): 

133 return self.with_data( 

134 ( key, value ) for key, value in self.items( ) 

135 if key in other and other[ key ] == value ) 

136 if isinstance( other, ( __.cabc.Set, __.cabc.KeysView ) ): 

137 return self.with_data( 

138 ( key, self[ key ] ) for key in self.keys( ) & other ) 

139 return NotImplemented 

140 

141 def __rand__( 

142 self, 

143 other: __.cabc.Set[ __.H ] | __.cabc.Mapping[ __.H, __.V ] 

144 ) -> __.typx.Self: 

145 if not isinstance( 

146 other, ( __.cabc.Mapping, __.cabc.Set, __.cabc.KeysView ) 

147 ): return NotImplemented 

148 return self & other 

149 

150 @__.abc.abstractmethod 

151 def copy( self ) -> __.typx.Self: 

152 ''' Provides fresh copy of dictionary. ''' 

153 raise NotImplementedError # pragma: no coverage 

154 

155 @__.abc.abstractmethod 

156 def with_data( 

157 self, 

158 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ], 

159 **entries: __.DictionaryNominativeArgument[ __.V ], 

160 ) -> __.typx.Self: 

161 ''' Creates new dictionary with same behavior but different data. ''' 

162 raise NotImplementedError # pragma: no coverage 

163 

164 

165class _Dictionary( 

166 __.ImmutableDictionary[ __.H, __.V ], metaclass = _classes.Class 

167): pass 

168 

169 

170class Dictionary( # pylint: disable=eq-without-hash 

171 _objects.Object, _DictionaryOperations[ __.H, __.V ] 

172): 

173 ''' Immutable dictionary. ''' 

174 # TODO: version 2.0: Do not subclass from 'Object'. 

175 

176 __slots__ = ( '_data_', ) 

177 

178 _data_: _Dictionary[ __.H, __.V ] 

179 

180 def __init__( 

181 self, 

182 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ], 

183 **entries: __.DictionaryNominativeArgument[ __.V ], 

184 ) -> None: 

185 self._data_ = _Dictionary( *iterables, **entries ) 

186 super( ).__init__( ) 

187 

188 def __iter__( self ) -> __.cabc.Iterator[ __.H ]: 

189 return iter( self._data_ ) 

190 

191 def __len__( self ) -> int: 

192 return len( self._data_ ) 

193 

194 def __repr__( self ) -> str: 

195 return "{fqname}( {contents} )".format( 

196 fqname = __.calculate_fqname( self ), 

197 contents = self._data_.__repr__( ) ) 

198 

199 def __str__( self ) -> str: 

200 return str( self._data_ ) 

201 

202 def __contains__( self, key: __.typx.Any ) -> bool: 

203 return key in self._data_ 

204 

205 def __getitem__( self, key: __.H ) -> __.V: 

206 return self._data_[ key ] 

207 

208 def __eq__( self, other: __.typx.Any ) -> __.ComparisonResult: 

209 if isinstance( other, __.cabc.Mapping ): 

210 return self._data_ == other 

211 return NotImplemented 

212 

213 def __ne__( self, other: __.typx.Any ) -> __.ComparisonResult: 

214 if isinstance( other, __.cabc.Mapping ): 

215 return self._data_ != other 

216 return NotImplemented 

217 

218 def copy( self ) -> __.typx.Self: 

219 ''' Provides fresh copy of dictionary. ''' 

220 return type( self )( self ) 

221 

222 def get( 

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 ) # type: ignore 

233 return self._data_.get( key, default ) 

234 

235 def keys( self ) -> __.cabc.KeysView[ __.H ]: 

236 ''' Provides iterable view over dictionary keys. ''' 

237 return self._data_.keys( ) 

238 

239 def items( self ) -> __.cabc.ItemsView[ __.H, __.V ]: 

240 ''' Provides iterable view over dictionary items. ''' 

241 return self._data_.items( ) 

242 

243 def values( self ) -> __.cabc.ValuesView[ __.V ]: 

244 ''' Provides iterable view over dictionary values. ''' 

245 return self._data_.values( ) 

246 

247 def with_data( 

248 self, 

249 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ], 

250 **entries: __.DictionaryNominativeArgument[ __.V ], 

251 ) -> __.typx.Self: 

252 return type( self )( *iterables, **entries ) 

253 

254Dictionary.__doc__ = __.generate_docstring( 

255 Dictionary, 'dictionary entries immutability' ) 

256 

257 

258class ValidatorDictionary( Dictionary[ __.H, __.V ] ): 

259 ''' Immutable dictionary with validation of entries on initialization. ''' 

260 

261 __slots__ = ( '_validator_', ) 

262 

263 _validator_: __.DictionaryValidator[ __.H, __.V ] 

264 

265 def __init__( 

266 self, 

267 validator: __.DictionaryValidator[ __.H, __.V ], 

268 /, 

269 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ], 

270 **entries: __.DictionaryNominativeArgument[ __.V ], 

271 ) -> None: 

272 self._validator_ = validator 

273 entries_: list[ tuple[ __.H, __.V ] ] = [ ] 

274 from itertools import chain 

275 # Collect entries in case an iterable is a generator 

276 # which would be consumed during validation, before initialization. 

277 for key, value in chain.from_iterable( map( # type: ignore 

278 lambda element: ( # type: ignore 

279 element.items( ) 

280 if isinstance( element, __.cabc.Mapping ) 

281 else element 

282 ), 

283 ( *iterables, entries ) 

284 ) ): 

285 if not self._validator_( key, value ): # type: ignore 

286 from .exceptions import EntryValidityError 

287 raise EntryValidityError( key, value ) 

288 entries_.append( ( key, value ) ) # type: ignore 

289 super( ).__init__( entries_ ) 

290 

291 def __repr__( self ) -> str: 

292 return "{fqname}( {validator}, {contents} )".format( 

293 fqname = __.calculate_fqname( self ), 

294 validator = self._validator_.__repr__( ), 

295 contents = self._data_.__repr__( ) ) 

296 

297 def copy( self ) -> __.typx.Self: 

298 ''' Provides fresh copy of dictionary. ''' 

299 return type( self )( self._validator_, self ) 

300 

301 def with_data( 

302 self, 

303 *iterables: __.DictionaryPositionalArgument[ __.H, __.V ], 

304 **entries: __.DictionaryNominativeArgument[ __.V ], 

305 ) -> __.typx.Self: 

306 ''' Creates new dictionary with same behavior but different data. ''' 

307 return type( self )( self._validator_, *iterables, **entries ) 

308 

309ValidatorDictionary.__doc__ = __.generate_docstring( 

310 ValidatorDictionary, 

311 'dictionary entries immutability', 

312 'dictionary entries validation', 

313)