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

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

130 

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 

143 

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 

152 

153 @__.abc.abstractmethod 

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

155 ''' Provides fresh copy of dictionary. ''' 

156 raise NotImplementedError # pragma: no coverage 

157 

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 

166 

167 

168class Dictionary( # noqa: PLW1641 

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

170 metaclass = _classes.AbstractBaseClass, 

171 class_mutables = _classes.abc_class_mutables, 

172): 

173 ''' Immutable dictionary. ''' 

174 

175 __slots__ = ( '_data_', ) 

176 

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

178 _dynadoc_fragments_ = ( 'dictionary entries protect', ) 

179 

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__( ) 

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 = __.ccutils.qualify_class_name( type( 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( # 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 ) 

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 

254 

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

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

257 

258 __slots__ = ( '_validator_', ) 

259 

260 _dynadoc_fragments_ = ( 

261 'dictionary entries protect', 'dictionary entries validate' ) 

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

263 

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_ ) 

289 

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__( ) ) 

295 

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

297 ''' Provides fresh copy of dictionary. ''' 

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

299 

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 )