Simple Dictionary

Simple immutable dictionaries have an interface similar to dict, but prevent any modifications after creation. They are useful for configuration objects, constants, and other scenarios where data should never change after initialization.

>>> from frigid import Dictionary

Let us illustrate this with a configuration dictionary for a hypothetical application:

>>> config = Dictionary(
...     database = Dictionary(
...         host = 'localhost',
...         port = 5432,
...         name = 'myapp',
...     ),
...     cache = Dictionary(
...         enabled = True,
...         ttl = 3600,
...     ),
...     debug = False,
... )

Initialization

Immutable dictionaries can be initialized from zero or more other dictionaries or iterables over key-value pairs and zero or more keyword arguments.

>>> # From dictionary
>>> d1 = Dictionary( { 'x': 1, 'y': 2 } )
>>> # From key-value pairs
>>> d2 = Dictionary( [ ( 'a', 1 ), ( 'b', 2 ) ] )
>>> # From keywords
>>> d3 = Dictionary( foo = 'bar', baz = 42 )
>>> # Mixed initialization
>>> d4 = Dictionary( { 'x': 1 }, [ ( 'y', 2 ) ], z = 3 )

Immutability

Once created, a namespace becomes completely immutable. Attempts to modify existing attributes will raise an error:

>>> config['debug'] = True  # Attempt to modify
Traceback (most recent call last):
...
frigid.exceptions.EntryImmutabilityError: Cannot assign entry for 'debug'.

Attempts to delete attributes are also prevented:

>>> del config['cache']  # Attempt to delete
Traceback (most recent call last):
...
frigid.exceptions.EntryImmutabilityError: Cannot assign entry for 'cache'.

Copies

Copies can be made which preserve behavior and data.

>>> original = Dictionary( x = 1, y = 2 )
>>> copy = original.copy( )
>>> copy
frigid.dictionaries.Dictionary( {'x': 1, 'y': 2} )

Copies can also be made which preserve behavior but replace data. These are made using the with_data method, which creates a new dictionary of the same type but with different data. This is particularly useful with validator dictionaries (see below) as it preserves their behavior:

>>> new = original.with_data( a = 3, b = 4 )
>>> new
frigid.dictionaries.Dictionary( {'a': 3, 'b': 4} )

Comparison

The copies are equivalent to their originals.

>>> original == copy
True

And to instances of other registered subclasses of collections.abc.Mapping which have equivalent data.

>>> original == { 'x': 1, 'y': 2 }
True

Access of Absent Entries

Like dict, the get method allows for “soft” accesses which provide a default value if an entry is missing.

>>> d = Dictionary( x = 1, y = 2, z = 3 )
>>> d.get( 'x' )
1
>>> d.get( 'missing' )  # Returns None for missing keys
>>> d.get( 'missing', 'default' )  # Custom default value
'default'

Views

Standard dictionary views are available:

>>> list( d.keys( ) )
['x', 'y', 'z']
>>> list( d.values( ) )
[1, 2, 3]
>>> list( d.items( ) )
[('x', 1), ('y', 2), ('z', 3)]

Unions

The union operator (|) combines entries from two dictionaries or a dictionary and a mapping, creating a new dictionary. The operation prevents duplicate keys:

>>> auth = Dictionary( user = 'admin', password = 'secret' )
>>> extra = Dictionary( password = 'newpass', token = 'abc123' )
>>> # Union creates new dictionary with combined entries
>>> combined = auth | Dictionary( token = 'xyz789' )
>>> combined
frigid.dictionaries.Dictionary( {'user': 'admin', 'password': 'secret', 'token': 'xyz789'} )

When operands have overlapping keys, an error is raised:

>>> auth | extra
Traceback (most recent call last):
...
frigid.exceptions.EntryImmutabilityError: Cannot assign entry for 'password'.

Intersections

The intersection operator (&) can be used in two ways:

  1. With another mapping to keep entries with matching key-value pairs:

>>> d1 = Dictionary( a = 1, b = 2, c = 3 )
>>> d2 = { 'a': 1, 'b': 5, 'd': 4 }
>>> d1 & d2  # Only entries matching in both key and value
frigid.dictionaries.Dictionary( {'a': 1} )
  1. With a set or keys view to filter entries by keys:

>>> sorted( ( d1 & { 'a', 'c' } ).items( ) )  # Only entries with matching keys
[('a', 1), ('c', 3)]

Validator Dictionary

Validator dictionaries ensure that all entries satisfy specified criteria at creation time. The first argument must be a callable which accepts a key and value and returns a boolean indicating whether the entry is valid.

>>> from frigid import ValidatorDictionary

Here’s an example of a dictionary that only accepts string keys and integer values:

>>> def validate_int_values( key, value ):
...     return isinstance( key, str ) and isinstance( value, int )
...
>>> numbers = ValidatorDictionary(
...     validate_int_values,
...     count = 42,
...     items = 10,
... )
>>> numbers
frigid.dictionaries.ValidatorDictionary( <function validate_int_values at 0x...>, {'count': 42, 'items': 10} )

Invalid entries are rejected during creation:

>>> # Invalid value type
>>> ValidatorDictionary( validate_int_values, count = '42' )
Traceback (most recent call last):
...
frigid.exceptions.EntryValidityError: Cannot add invalid entry with key, 'count', and value, '42', to dictionary.
>>> # Invalid key type
>>> ValidatorDictionary( validate_int_values, { 42: 42 } )
Traceback (most recent call last):
...
frigid.exceptions.EntryValidityError: Cannot add invalid entry with key, 42, and value, 42, to dictionary.

When copying validator dictionaries, both copy and with_data preserve the validator:

>>> # Both copies maintain validation
>>> copy = numbers.copy( )
>>> new = numbers.with_data( total = 100 )
>>> # Invalid data still rejected
>>> numbers.with_data( total = '100' )
Traceback (most recent call last):
...
frigid.exceptions.EntryValidityError: Cannot add invalid entry with key, 'total', and value, '100', to dictionary.