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

105 statements  

« prev     ^ index     » next       coverage.py v7.6.8, created at 2024-12-05 03:26 +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: 

86 - __getitem__, __iter__, __len__ 

87 ''' 

88 

89 @__.abstract_member_function 

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

91 raise NotImplementedError # pragma: no coverage 

92 

93 @__.abstract_member_function 

94 def __len__( self ) -> int: 

95 raise NotImplementedError # pragma: no coverage 

96 

97 @__.abstract_member_function 

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

99 raise NotImplementedError # pragma: no coverage 

100 

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

102 from .exceptions import EntryImmutabilityError 

103 raise EntryImmutabilityError( key ) 

104 

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

106 from .exceptions import EntryImmutabilityError 

107 raise EntryImmutabilityError( key ) 

108 

109 

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

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

112 

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

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

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

116 if conflicts: 

117 from .exceptions import EntryImmutabilityError 

118 raise EntryImmutabilityError( next( iter( conflicts ) ) ) 

119 data = dict( self ) 

120 data.update( other ) 

121 return self.with_data( data ) 

122 

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

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

125 return self | other 

126 

127 def __and__( 

128 self, 

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

130 ) -> __.a.Self: 

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

132 return self.with_data( 

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

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

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

136 return self.with_data( 

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

138 return NotImplemented 

139 

140 def __rand__( 

141 self, 

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

143 ) -> __.a.Self: 

144 if not isinstance( 

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

146 ): return NotImplemented 

147 return self & other 

148 

149 @__.abstract_member_function 

150 def copy( self ) -> __.a.Self: 

151 ''' Provides fresh copy of dictionary. ''' 

152 raise NotImplementedError # pragma: no coverage 

153 

154 @__.abstract_member_function 

155 def with_data( 

156 self, 

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

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

159 ) -> __.a.Self: 

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

161 raise NotImplementedError # pragma: no coverage 

162 

163 

164class _Dictionary( 

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

166): pass 

167 

168 

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

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

171): 

172 ''' Immutable dictionary. ''' 

173 

174 __slots__ = ( '_data_', ) 

175 

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

177 

178 def __init__( 

179 self, 

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

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

182 ) -> None: 

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

184 super( ).__init__( ) 

185 

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

187 return iter( self._data_ ) 

188 

189 def __len__( self ) -> int: 

190 return len( self._data_ ) 

191 

192 def __repr__( self ) -> str: 

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

194 fqname = __.calculate_fqname( self ), 

195 contents = self._data_.__repr__( ) ) 

196 

197 def __str__( self ) -> str: 

198 return str( self._data_ ) 

199 

200 def __contains__( self, key: __.a.Any ) -> bool: 

201 return key in self._data_ 

202 

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

204 return self._data_[ key ] 

205 

206 def __eq__( self, other: __.a.Any ) -> __.ComparisonResult: 

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

208 return self._data_ == other 

209 return NotImplemented 

210 

211 def __ne__( self, other: __.a.Any ) -> __.ComparisonResult: 

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

213 return self._data_ != other 

214 return NotImplemented 

215 

216 def copy( self ) -> __.a.Self: 

217 ''' Provides fresh copy of dictionary. ''' 

218 return type( self )( self ) 

219 

220 def get( 

221 self, key: __.H, default: __.Optional[ __.V ] = __.absent 

222 ) -> __.a.Annotation[ 

223 __.V, 

224 __.a.Doc( 

225 'Value of entry, if it exists. ' 

226 'Else, supplied default value or ``None``.' ) 

227 ]: 

228 ''' Retrieves entry associated with key, if it exists. ''' 

229 if __.is_absent( default ): 

230 return self._data_.get( key ) # type: ignore 

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

232 

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

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

235 return self._data_.keys( ) 

236 

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

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

239 return self._data_.items( ) 

240 

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

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

243 return self._data_.values( ) 

244 

245 def with_data( 

246 self, 

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

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

249 ) -> __.a.Self: 

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

251 

252Dictionary.__doc__ = __.generate_docstring( 

253 Dictionary, 'dictionary entries immutability' ) 

254# Register as subclass of Mapping rather than use it as mixin. 

255# We directly implement, for the sake of efficiency, the methods which the 

256# mixin would provide. 

257__.cabc.Mapping.register( Dictionary ) # type: ignore 

258 

259 

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

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

262 

263 __slots__ = ( '_validator_', ) 

264 

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

266 

267 def __init__( 

268 self, 

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

270 /, 

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

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

273 ) -> None: 

274 self._validator_ = validator 

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

276 from itertools import chain 

277 # Collect entries in case an iterable is a generator 

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

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

280 lambda element: ( # type: ignore 

281 element.items( ) 

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

283 else element 

284 ), 

285 ( *iterables, entries ) 

286 ) ): 

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

288 from .exceptions import EntryValidityError 

289 raise EntryValidityError( key, value ) 

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

291 super( ).__init__( entries_ ) 

292 

293 def __repr__( self ) -> str: 

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

295 fqname = __.calculate_fqname( self ), 

296 validator = self._validator_.__repr__( ), 

297 contents = self._data_.__repr__( ) ) 

298 

299 def copy( self ) -> __.a.Self: 

300 ''' Provides fresh copy of dictionary. ''' 

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

302 

303 def with_data( 

304 self, 

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

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

307 ) -> __.a.Self: 

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

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

310 

311ValidatorDictionary.__doc__ = __.generate_docstring( 

312 ValidatorDictionary, 

313 'dictionary entries immutability', 

314 'dictionary entries validation', 

315)