A Python list subclass that provides convenient attribute-based access to dictionary items, with seamless synchronization between active dict properties and object attributes. Includes JSON serialization, datatype conversion utilities, and full type validation.
- Attribute-based access: Access active dictionary keys as object attributes
- Active index tracking: Track and switch between items in the list with
active_indexproperty - Active row access: Get the current active dictionary directly with the
active_rowproperty - Callback functions: Register callbacks to react to active index changes
- Automatic syncing: Changes to attributes sync to the active dictionary
- Type validation: All list items must be dictionaries
- JSON serialization: Serialize to/from JSON with metadata support
- Datatype conversion: Convert between Python objects (float, datetime) and database-safe types (Decimal, ISO strings)
- Class attribute exclusion: Subclass variables are automatically excluded from instance data
- 100% test coverage: Comprehensive test suite with pytest
pip install listofdictsOr install from source:
git clone https://github.com/Stephen-Hilton/listofdicts.git
cd listofdicts
pip install -e .For development, install with test dependencies:
pip install -e ".[dev]"from listofdicts import ListOfDicts
# Create a new ListOfDicts
lod = ListOfDicts()
# Add dictionaries
lod.append({'a': 1, 'b': 2})
lod.append({'a': 3, 'b': 4})
# Access via active index
lod.active_index = 0
print(lod.a) # Output: 1
print(lod.b) # Output: 2
# Switch active index
lod.active_index = 1
print(lod.a) # Output: 3
# Modify via attribute (syncs to dict)
lod.b = 99
print(lod[1]['b']) # Output: 99from listofdicts import ListOfDicts
# Empty list
lod = ListOfDicts()
# Initialize with dictionaries (variadic args or lists)
lod = ListOfDicts(
{'name': 'Alice', 'age': 30},
{'name': 'Bob', 'age': 25},
{'name': 'Charlie', 'age': 35}
)
# Standard list operations work normally
lod.append({'name': 'Diana', 'age': 28})
lod.insert(1, {'name': 'Eve', 'age': 27})
lod.extend([{'name': 'Frank', 'age': 32}])
# Type validation
try:
lod.append("not a dict") # Raises TypeError
except TypeError as e:
print(f"Error: {e}")lod = ListOfDicts(
{'x': 1, 'y': 2},
{'x': 10, 'y': 20},
{'x': 100, 'y': 200}
)
# Set active index to access dict as attributes
lod.active_index = 0
print(lod.x, lod.y) # Output: 1 2
# Switch active index
lod.active_index = 1
print(lod.x, lod.y) # Output: 10 20
# Negative indices wrap around
lod.active_index = -1
print(lod.x, lod.y) # Output: 100 200 (last item)
# Modify attributes (syncs to active dict)
lod.y = 999
print(lod[2]['y']) # Output: 999
# Add new keys via attribute assignment
lod.z = 42
print(lod[2]['z']) # Output: 42
print('z' in lod[2]) # Output: TrueThe active_row property provides convenient access to the currently active dictionary:
lod = ListOfDicts(
{'id': 1, 'name': 'Alice'},
{'id': 2, 'name': 'Bob'},
{'id': 3, 'name': 'Charlie'}
)
# Access the active row directly
print(lod.active_row) # Output: {'id': 1, 'name': 'Alice'}
# Switch active index and get the new row
lod.active_index = 1
print(lod.active_row) # Output: {'id': 2, 'name': 'Bob'}
# Empty list returns None
lod.clear()
print(lod.active_row) # Output: None
# Useful for quick access to active dict data
lod = ListOfDicts({'a': 1, 'b': 2}, {'a': 10, 'b': 20})
lod.active_index = 0
print(lod.active_row['a'], lod.active_row['b']) # Output: 1 2
# active_row is a dict object (not a ListOfDicts) but provides
# an alternate way to get to currently active data:
print(lod.active_row['a'] == lod.a) # True
print(lod.active_row['b'] == lod.b) # True
lod.c = 3 # add to the data
print(lod.active_row['c'] == lod.c) # TrueRegister a callback function to be invoked whenever the active index changes. This is useful for reactive programming, logging, or triggering side effects:
lod = ListOfDicts(
{'id': 1, 'status': 'not_started'},
{'id': 2, 'status': 'pending'},
{'id': 3, 'status': 'complete'}
)
# Define a callback function (receives the ListOfDicts instance)
def log_active_change(lod_instance):
print(f" Active index changed to {lod_instance.active_index}")
print(f" Active row: {lod_instance.active_row}")
# Register the callback
lod.callback_on_change(log_active_change)
# Change active index - callback is invoked
lod.active_index = 0
# Output:
# Active index changed to 0
# Active row: {'id': 1, 'status': 'not_started'}
lod.active_index = 1
# Output:
# Active index changed to 1
# Active row: {'id': 2, 'status': 'pending'}
lod.active_index = 2
# Output:
# Active index changed to 2
# Active row: {'id': 3, 'status': 'complete'}Advanced Example: Reactive Updates
lod = ListOfDicts(
{'value': 10},
{'value': 20},
{'value': 30}
)
result = None
def square_active_value(lod_instance):
global result
result = lod_instance.active_row['value'] ** 2
lod.callback_on_change(square_active_value)
lod.active_index = 0
print(result) # Output: 100
lod.active_index = 1
print(result) # Output: 400
lod.active_index = 2
print(result) # Output: 900
lod.active_index = 1
print(result) # Output: 400
lod.active_index = 0
print(result) # Output: 100Example: Enforcing Active Index Constraints
lod = ListOfDicts({'x': 1}, {'x': 2}, {'x': 3}, {'x': 4}, {'x': 5})
def always_stay_on_index_2(lod_instance):
# Important: Check before reassigning to avoid infinite loops
if lod_instance.active_index != 2:
lod_instance.active_index = 2
lod.callback_on_change(always_stay_on_index_2)
lod.active_index = 0 # Callback enforces active_index = 2
print(lod.active_index) # Output: 2
lod.active_index = 4 # Callback enforces active_index = 2
print(lod.active_index) # Output: 2lod = ListOfDicts(
{'id': 1, 'name': 'Alice'},
{'id': 2, 'name': 'Bob'},
{'id': 3} # Missing 'status' key
)
# Add missing keys to all dicts
lod.addkey_if_missing('status', value_if_missing='active')
print(lod[0]) # {'id': 1, 'name': 'Alice', 'status': 'active'}
print(lod[2]) # {'id': 3, 'status': 'active'}
# Add multiple keys at once
lod.addkey_if_missing(['email', 'verified'], value_if_missing=None)from datetime import datetime
import json
lod = ListOfDicts({'id': 1, 'created': datetime.now()})
lod.metadata = {'version': '1.0', 'author': 'Alice'}
# Serialize to JSON (datetimes become ISO strings, floats become Decimals)
json_string = lod.to_json()
print(json_string)
# Output:
# {
# "metadata": {
# "version": "1.0",
# "author": "Alice"
# },
# "data": [
# {
# "id": 1,
# "created": "2025-01-15T10:30:45.123456"
# }
# ]
# }
# Deserialize from JSON
loaded = ListOfDicts().from_json(json_string)
print(isinstance(loaded[0]['created'], datetime)) # Output: True
print(loaded.metadata) # Output: {'version': '1.0', 'author': 'Alice'}Convert between Python objects and database-safe types:
from datetime import datetime
from decimal import Decimal
lod = ListOfDicts({
'price': 19.99,
'quantity': 5,
'timestamp': datetime(2025, 1, 15, 10, 30, 45)
})
# Convert to database-safe types (float→Decimal, datetime→ISO string)
db_safe = lod.make_datatypes_dbsafe()
print(type(db_safe[0]['price'])) # Output: <class 'decimal.Decimal'>
print(type(db_safe[0]['timestamp'])) # Output: <class 'str'>
# Convert back to Python objects
py_obj = db_safe.make_datatypes_pyobj()
print(type(py_obj[0]['price'])) # Output: <class 'float'>
print(type(py_obj[0]['timestamp'])) # Output: <class 'datetime.datetime'>
# In-place conversion (modifies original)
lod.make_datatypes_dbsafe(inplace=True)
print(type(lod[0]['price'])) # Output: <class 'decimal.Decimal'>class Person(ListOfDicts):
# Class-level attributes are excluded from instance data
species: str = 'Homo sapiens'
def __init__(self, *args):
super().__init__(*args)
self.status = 'active' # Instance attribute (appears in data)
# Create instance
people = Person(
{'name': 'Alice', 'age': 30},
{'name': 'Bob', 'age': 25}
)
people.active_index = 0
print(people.name) # Output: 'Alice'
print(people.species) # Output: 'Homo sapiens' (class var, not in data)
print(people.status) # Output: 'active' (instance var, in data)
print('species' in people[0]) # Output: False
print('status' in people[0]) # Output: TrueListOfDicts(*args)Create a new ListOfDicts. Args can be:
- Individual dicts:
ListOfDicts({'a': 1}, {'b': 2}) - A list/tuple:
ListOfDicts([{'a': 1}, {'b': 2}]) - Mixed:
ListOfDicts({'a': 1}, [{'b': 2}])
Get or set the active dictionary index. Setting triggers synchronization.
lod.active_index = 0 # Set active index
idx = lod.active_index # Get current active indexRaises:
TypeError: If value is not an integerIndexError: If index is out of range- Negative indices wrap (e.g.,
-1is the last item) - Returns
Noneif the list is empty
Get the currently active dictionary without needing to use indexing.
current_dict = lod.active_row # Returns self[active_index]
lod.active_index = 1
next_dict = lod.active_row # Returns self[1]Returns: The dictionary at active_index, or None if the list is empty
Register a callback function to be invoked whenever active_index changes.
def my_callback(lod_instance):
print(f"Active index: {lod_instance.active_index}")
lod.callback_on_change(my_callback)
lod.active_index = 1 # Invokes callbackParameters:
func: A callable that accepts one parameter (the ListOfDicts instance)
Notes:
- The callback is called after
active_indexis set and synchronization occurs - Callbacks can modify the ListOfDicts instance (e.g., enforce index constraints)
- To avoid infinite loops, always check the condition before modifying
active_indexwithin a callback
Add a dictionary to the end of the list. First item added sets active_index to 0.
lod.append({'key': 'value'})Raises: TypeError if item is not a dict
Insert a dictionary at the given index. Adjusts active_index if needed.
lod.insert(0, {'key': 'value'})Raises: TypeError if item is not a dict
Insert a dictionary at the given index. Adjusts active_index if needed.
lod.insert(0, {'key': 'value'})Raises: TypeError if item is not a dict
Add multiple dictionaries from an iterable.
lod.extend([{'a': 1}, {'b': 2}])Raises: TypeError if any item is not a dict
Add keys to all dictionaries in the list if they don't exist.
lod.addkey_if_missing(['status', 'verified'], value_if_missing=None)
lod.addkey_if_missing('email', value_if_missing='')Returns: self (chainable)
Convert float→Decimal and datetime→ISO string for database storage.
db_safe = lod.make_datatypes_dbsafe()
lod.make_datatypes_dbsafe(inplace=True) # Modify originalReturns: New ListOfDicts with converted types (or self if inplace=True, making it chainable). Note, new ListOfDicts does NOT carry over class properties, like metadata.
Convert Decimal→float and ISO strings→datetime for Python use.
py_obj = lod.make_datatypes_pyobj()
lod.make_datatypes_pyobj(inplace=True) # Modify originalReturns: New ListOfDicts with converted types (or self if inplace=True, making it chainable). Note, new ListOfDicts does NOT carry over class properties, like metadata.
Serialize to JSON string with metadata.
json_str = lod.to_json()Returns: JSON string
Load ListOfDicts from a JSON string. This is an instance method (not class method), and will replace all existing instance data and metadata.
lod = ListOfDicts().from_json(json_str)Parameters:
json_content: JSON string in the format{"data":[...], "metadata":{...} }(bothdataandmetadatakeys optional)
Returns: ListOfDicts with deserialized data
Remove all items and reset active_index to None.
lod.clear()Remove and return item at index (default: last item).
last = lod.pop()
first = lod.pop(0)The package includes 100% test coverage with pytest.
# Install test dependencies
pip install pytest coverage
# Run all tests
pytest test/test_listofdicts.py -v
# Run with coverage report
python -m coverage run -m pytest test/test_listofdicts.py
python -m coverage report -mSee test/test_listofdicts.py for comprehensive examples. Key test functions:
test_example_usage()- Complete usage walkthroughtest_append_and_active_sync()- Attribute synchronizationtest_make_datatypes_dbsafe_and_pyobj()- Datatype conversiontest_to_json_and_from_json()- JSON serializationtest_inheritance_and_classvar_exclusion()- Subclassing patterns
Current test coverage: 100%
Name Stmts Miss Cover
------------------------------------------------------
src/ListOfDicts/__init__.py 0 0 100%
src/ListOfDicts/listofdicts.py 163 0 100%
test/test_listofdicts.py 224 0 100%
------------------------------------------------------
TOTAL 387 0 100%
lod = ListOfDicts(
{'name': 'Alice', 'age': 30},
{'name': 'Bob', 'age': 25},
{'name': 'Charlie', 'age': 35}
)
# Iterate over all items
for item in lod:
print(item['name'], item['age'])
# Filter by condition
adults = [item for item in lod if item['age'] >= 30]
# Map with active index
for i, item in enumerate(lod):
lod.active_index = i
print(f"{lod.name} is {lod.age}")lod = ListOfDicts(
{'id': 1, 'status': 'pending'},
{'id': 2, 'status': 'pending'},
{'id': 3, 'status': 'pending'}
)
# Update all items at once
for i in range(len(lod)):
lod.active_index = i
lod.status = 'approved'Metadata is a class variable, meaning it will never appear in the 'data' of the ListOfDicts. This also means that when creating a LOD object from data, the metadata isn't implicitly carried forward. For example:
lod = ListOfDicts(
{'id': 1, 'status': 'pending'},
{'id': 2, 'status': 'pending'},
{'id': 3, 'status': 'pending'}
)
lod.metadata = {'version':1.0}
newlod = lod[:]
assert newlod.metadata == {}The lod[:] command creates a new data object, but doesn't contain the metadata (or any other class variable), so it's not carried over.
Metadata is the only class variable that is included in the .to_json() and .from_json() methods. This does allow you to serialize / deserialize using JSON, and persist metadata.
lod = ListOfDicts({'data': 'value'})
lod.metadata = {
'version': '1.0',
'created_by': 'Alice',
'timestamp': '2025-01-15T10:30:45'
}
# Access metadata
print(lod.metadata['version'])
# Persist with metadata
json_str = lod.to_json()
restored = ListOfDicts().from_json(json_str)
print(restored.metadata)MIT License — see LICENSE file for details
Contributions welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure 100% test coverage
- Submit a pull request
For issues and questions, please open a GitHub issue at: https://github.com/Stephen-Hilton/listofdicts/issues