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

102 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-25 23:15 +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''' Immutable dictionaries. 

22 

23 Dictionaries which cannot be modified after creation. 

24 

25 .. note:: 

26 

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`: 

30 

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. 

37 

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. 

42 

43 * :py:class:`AbstractDictionary`: 

44 Base class defining the immutable dictionary interface. Implementations 

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

46 

47 * :py:class:`Dictionary`: 

48 Standard implementation of an immutable dictionary. Supports all usual 

49 dict read operations but prevents any modifications. 

50 

51 * :py:class:`ValidatorDictionary`: 

52 Validates entries before addition using a supplied predicate function. 

53 

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''' 

69 

70 

71from . import __ 

72from . import classes as _classes 

73 

74 

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

76 ''' Abstract base class for immutable dictionaries. 

77 

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. 

81 

82 Implementations must provide __getitem__, __iter__, __len__. 

83 ''' 

84 

85 @__.abc.abstractmethod 

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

87 raise NotImplementedError # pragma: no coverage 

88 

89 @__.abc.abstractmethod 

90 def __len__( self ) -> int: 

91 raise NotImplementedError # pragma: no coverage 

92 

93 @__.abc.abstractmethod 

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

95 raise NotImplementedError # pragma: no coverage 

96 

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

98 from .exceptions import EntryImmutability 

99 raise EntryImmutability( key ) 

100 

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

102 from .exceptions import EntryImmutability 

103 raise EntryImmutability( key ) 

104 

105 

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

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

108 

109 # TODO? Common __init__. 

110 

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 ) 

120 

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

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

123 return self | other 

124 

125 def __and__( 

126 self, 

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

128 ) -> __.typx.Self: 

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

130 return self.with_data( 

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

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

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

134 return self.with_data( 

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

136 return NotImplemented 

137 

138 def __rand__( 

139 self, 

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

141 ) -> __.typx.Self: 

142 if not isinstance( 

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

144 ): return NotImplemented 

145 return self & other 

146 

147 @__.abc.abstractmethod 

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

149 ''' Provides fresh copy of dictionary. ''' 

150 raise NotImplementedError # pragma: no coverage 

151 

152 @__.abc.abstractmethod 

153 def with_data( 

154 self, 

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

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

157 ) -> __.typx.Self: 

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

159 raise NotImplementedError # pragma: no coverage 

160 

161 

162class Dictionary( # noqa: PLW1641 

163 _DictionaryOperations[ __.H, __.V ], 

164 metaclass = _classes.AbstractBaseClass, 

165 class_mutables = _classes.abc_class_mutables, 

166): 

167 ''' Immutable dictionary. ''' 

168 

169 __slots__ = ( '_data_', ) 

170 

171 _data_: __.ImmutableDictionary[ __.H, __.V ] 

172 _dynadoc_fragments_ = ( 'dictionary entries protect', ) 

173 

174 def __init__( 

175 self, 

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

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

178 ) -> None: 

179 self._data_ = __.ImmutableDictionary( *iterables, **entries ) 

180 super( ).__init__( ) 

181 

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

183 return iter( self._data_ ) 

184 

185 def __len__( self ) -> int: 

186 return len( self._data_ ) 

187 

188 def __repr__( self ) -> str: 

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

190 fqname = __.ccutils.qualify_class_name( type( self ) ), 

191 contents = self._data_.__repr__( ) ) 

192 

193 def __str__( self ) -> str: 

194 return str( self._data_ ) 

195 

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

197 return key in self._data_ 

198 

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

200 return self._data_[ key ] 

201 

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

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

204 return self._data_ == other 

205 return NotImplemented 

206 

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

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

209 return self._data_ != other 

210 return NotImplemented 

211 

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

213 ''' Provides fresh copy of dictionary. ''' 

214 return type( self )( self ) 

215 

216 def get( # pyright: ignore 

217 self, key: __.H, default: __.Absential[ __.V ] = __.absent 

218 ) -> __.typx.Annotated[ 

219 __.V, 

220 __.typx.Doc( 

221 'Value of entry, if it exists. ' 

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

223 ]: 

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

225 if __.is_absent( default ): 

226 return self._data_.get( key ) # pyright: ignore 

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

228 

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

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

231 return self._data_.keys( ) 

232 

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

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

235 return self._data_.items( ) 

236 

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

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

239 return self._data_.values( ) 

240 

241 def with_data( 

242 self, 

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

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

245 ) -> __.typx.Self: 

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

247 

248 

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

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

251 

252 __slots__ = ( '_validator_', ) 

253 

254 _dynadoc_fragments_ = ( 

255 'dictionary entries protect', 'dictionary entries validate' ) 

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

257 

258 def __init__( 

259 self, 

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

261 /, 

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

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

264 ) -> None: 

265 self._validator_ = validator 

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

267 from itertools import chain 

268 # Collect entries in case an iterable is a generator 

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

270 for key, value in chain.from_iterable( map( # pyright: ignore 

271 lambda element: ( # pyright: ignore 

272 element.items( ) 

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

274 else element 

275 ), 

276 ( *iterables, entries ) 

277 ) ): 

278 if not self._validator_( key, value ): # pyright: ignore 

279 from .exceptions import EntryInvalidity 

280 raise EntryInvalidity( key, value ) 

281 entries_.append( ( key, value ) ) # pyright: ignore 

282 super( ).__init__( entries_ ) 

283 

284 def __repr__( self ) -> str: 

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

286 fqname = __.ccutils.qualify_class_name( type( self ) ), 

287 validator = self._validator_.__repr__( ), 

288 contents = self._data_.__repr__( ) ) 

289 

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

291 ''' Provides fresh copy of dictionary. ''' 

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

293 

294 def with_data( 

295 self, 

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

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

298 ) -> __.typx.Self: 

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

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