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

102 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-07-02 16:24 +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): 

166 ''' Immutable dictionary. ''' 

167 

168 __slots__ = ( '_data_', ) 

169 

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

171 _dynadoc_fragments_ = ( 'dictionary entries protect', ) 

172 

173 def __init__( 

174 self, 

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

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

177 ) -> None: 

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

179 super( ).__init__( ) 

180 

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

182 return iter( self._data_ ) 

183 

184 def __len__( self ) -> int: 

185 return len( self._data_ ) 

186 

187 def __repr__( self ) -> str: 

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

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

190 contents = self._data_.__repr__( ) ) 

191 

192 def __str__( self ) -> str: 

193 return str( self._data_ ) 

194 

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

196 return key in self._data_ 

197 

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

199 return self._data_[ key ] 

200 

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

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

203 return self._data_ == other 

204 return NotImplemented 

205 

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

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

208 return self._data_ != other 

209 return NotImplemented 

210 

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

212 ''' Provides fresh copy of dictionary. ''' 

213 return type( self )( self ) 

214 

215 def get( # pyright: ignore 

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

217 ) -> __.typx.Annotated[ 

218 __.V, 

219 __.typx.Doc( 

220 'Value of entry, if it exists. ' 

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

222 ]: 

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

224 if __.is_absent( default ): 

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

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

227 

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

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

230 return self._data_.keys( ) 

231 

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

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

234 return self._data_.items( ) 

235 

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

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

238 return self._data_.values( ) 

239 

240 def with_data( 

241 self, 

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

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

244 ) -> __.typx.Self: 

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

246 

247 

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

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

250 

251 __slots__ = ( '_validator_', ) 

252 

253 _dynadoc_fragments_ = ( 

254 'dictionary entries protect', 'dictionary entries validate' ) 

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

256 

257 def __init__( 

258 self, 

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

260 /, 

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

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

263 ) -> None: 

264 self._validator_ = validator 

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

266 from itertools import chain 

267 # Collect entries in case an iterable is a generator 

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

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

270 lambda element: ( # pyright: ignore 

271 element.items( ) 

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

273 else element 

274 ), 

275 ( *iterables, entries ) 

276 ) ): 

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

278 from .exceptions import EntryInvalidity 

279 raise EntryInvalidity( key, value ) 

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

281 super( ).__init__( entries_ ) 

282 

283 def __repr__( self ) -> str: 

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

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

286 validator = self._validator_.__repr__( ), 

287 contents = self._data_.__repr__( ) ) 

288 

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

290 ''' Provides fresh copy of dictionary. ''' 

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

292 

293 def with_data( 

294 self, 

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

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

297 ) -> __.typx.Self: 

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

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