Flexible property bag functions
Project description
propertybag
A flexible, dict-backed property bag for Python. Access and mutate deeply nested data using attribute syntax, without any upfront schema.
import propertybag as pb
bag = pb.Bag({'a': 'b'}, c='d')
bag.x.y.z = 42 # creates intermediate dicts automatically
print(bag.x.y.z) # 42
print(bag.to_json()) # {"a": "b", "c": "d", "x": {"y": {"z": 42}}}
Table of contents
- Install
- Construction
- Reading values
- Writing values
- Compound-key methods
- Merge and update
- Serialization
- Environment variables
- Traversal
- Diffing
- Immutability
- Validation
- Iteration and dict operations
- Missing-key defaults
- Comparison to similar projects
Install
pip install propertybag
Optional extras for YAML, TOML, and MessagePack support:
pip install pyyaml # enables to_yaml() / from_yaml()
pip install tomli-w # enables to_toml() (reading TOML is built-in on Python 3.11+)
pip install msgpack # enables to_msgpack() / from_msgpack()
Construction
import propertybag as pb
# Empty
bag = pb.Bag()
# From a dict
bag = pb.Bag({'a': 'b', 'c': 'd'})
# From keyword arguments
bag = pb.Bag(a='b', c='d')
# From multiple dicts and keyword arguments (merged left-to-right, kwargs win)
bag = pb.Bag({'a': 1}, {'b': 2}, c=3)
# From another Bag
bag2 = pb.Bag(bag)
# From a JSON string
bag = pb.Bag('{"a": "b", "c": "d"}')
# From a file (.json, .yaml/.yml, .toml)
bag = pb.Bag.from_file('config.json')
# From environment variables
bag = pb.Bag.from_env(prefix='APP_')
Constructor options
| Parameter | Description | Default |
|---|---|---|
_defstr |
Behaviour when a missing key is converted to a string | ValueError (raises) |
_defval |
Behaviour when a missing key is used numerically / as bool | None |
_sep |
Default path separator for all compound-key methods | '.' |
_frozen |
Create an immediately read-only bag | False |
# Custom separator — all methods default to '/' instead of '.'
cfg = pb.Bag({'server': {'host': 'localhost'}}, _sep='/')
cfg.get('server/host') # 'localhost'
cfg.exists('server/host') # True
# Frozen at creation
bag = pb.Bag({'a': 1}, _frozen=True)
bag.b = 2 # raises TypeError: Bag is frozen
See Missing-key defaults for full details on _defstr and _defval.
Reading values
Attribute and item access
bag = pb.Bag({'a': 'b', 'x': {'y': {'z': 42}}})
bag.a # 'b'
bag['a'] # 'b'
bag.x.y.z # 42
bag['x']['y'] # Bag({'z': 42})
Nested dicts are returned as Bag objects, so you can keep chaining. Settings (_defstr,
_defval, _sep, _frozen) are inherited by all sub-bags automatically.
Missing keys
By default, accessing a missing key raises ValueError when the value is used:
bag = pb.Bag({'a': 1})
if bag.missing: # False — no exception yet
...
print(bag.missing) # raises ValueError: No such key : missing
str(bag.missing) # raises ValueError
int(bag.missing) # raises ValueError
# Chaining is safe until you actually use the value
print(bag.a.b.c.d) # raises ValueError: No such key : a.b.c.d
See Missing-key defaults to configure a fallback value instead.
Non-string keys
Integer (and other hashable) keys work via []:
bag = pb.Bag()
bag[42] = 'hello'
bag[42] # 'hello'
bag.get(42) # 'hello'
Writing values
Attribute and item assignment
bag = pb.Bag()
bag.name = 'Alice'
bag['count'] = 0
# Deep assignment — intermediate dicts are created automatically
bag.server.host = 'localhost'
bag.server.port = 8080
print(bag.to_json())
# {"name": "Alice", "count": 0, "server": {"host": "localhost", "port": 8080}}
Deletion
bag = pb.Bag({'a': {'b': 1, 'c': 2}})
del bag.a.b
del bag['a']['c']
'b' in bag.a # False
Compound-key methods
These methods address deeply nested keys with a dotted path string. The separator defaults to
the instance _sep (. unless overridden) and can be overridden per-call with sep=.
get(key_path, default=None, sep=None, cast=None)
bag = pb.Bag({'server': {'host': 'localhost', 'port': '8080'}})
bag.get('server.host') # 'localhost'
bag.get('server.timeout', 30) # 30 (absent key → default)
bag.get('server/host', sep='/') # 'localhost'
# cast applies the callable to the found value (never to the default)
bag.get('server.port', cast=int) # 8080 (int, not str)
bag.get('server.missing', 0, cast=int) # 0 (default, no cast applied)
set(key_path, value, sep=None)
bag = pb.Bag()
bag.set('server.host', 'localhost')
bag.set('db/user', 'admin', sep='/')
# Pass None as the path to replace the entire bag contents
bag.set(None, {'reset': True})
exists(key_path, sep=None)
bag = pb.Bag()
bag.set('a.b.c', 1)
bag.exists('a.b.c') # True
bag.exists('a.b.x') # False
bag.exists(42) # works with non-string keys too
delete(key_path, sep=None)
Returns True if deleted, False if not found.
bag.delete('a.b.c') # True
bag.delete('a.b.c') # False (already gone)
bag.exists('a.b') # True (parents untouched)
bag(key_path, default=None, sep=None)
Like get(), but wraps the result in a Bag. Mutations on the returned sub-bag propagate
back to the original.
bag = pb.Bag({'db': {'host': 'localhost', 'port': 5432}})
db = bag.bag('db')
db.name = 'mydb'
bag.db.name # 'mydb'
pop(key_path, *default, sep=None)
Remove and return a value. Raises KeyError if absent with no default given.
bag = pb.Bag(a=1, b=2)
bag.pop('a') # 1 — key removed
bag.pop('missing', 'fallback') # 'fallback'
bag.pop('a.b.c') # KeyError if not found
setdefault(key_path, default=None, sep=None)
Return the existing value if the key is present; otherwise set it to default and return it.
bag = pb.Bag(a=1)
bag.setdefault('a', 99) # 1 (existing value unchanged)
bag.setdefault('b', 99) # 99 (key created)
bag.setdefault('x.y.z', 0) # 0 (nested path created)
Merge and update
merge(other, overwrite=True, deep=False)
Merges a dict or Bag. overwrite=False preserves existing keys. deep=True recurses into
nested dicts instead of replacing them wholesale.
bag = pb.Bag({'server': {'host': 'localhost', 'port': 8080}})
# Shallow merge — replaces the entire 'server' dict
bag.merge({'server': {'port': 9090}})
bag.server.host # KeyError — 'host' was lost
# Deep merge — merges inside 'server'
bag = pb.Bag({'server': {'host': 'localhost', 'port': 8080}})
bag.merge({'server': {'port': 9090}}, deep=True)
bag.server.host # 'localhost' ← preserved
bag.server.port # 9090 ← updated
update(*args, **kwargs)
Accepts any combination of dicts, Bags, and keyword arguments.
bag = pb.Bag()
bag.update({'a': 1}, pb.Bag(b=2), c=3)
# a=1, b=2, c=3
| and |= operators
result = bag | {'extra': 1} # new Bag, bag unchanged
bag |= {'extra': 1} # in-place merge
result = {'base': 0} | bag # dict on the left also works
copy(deep=False)
bag2 = bag.copy() # shallow — nested dicts share references
bag3 = bag.copy(deep=True) # deep — fully independent clone
Serialization
JSON
bag = pb.Bag(b=2, a=1)
bag.to_json() # '{"b": 2, "a": 1}'
bag.to_json(pretty=True) # indented, sorted keys
bag.toJson() # alias
bag.from_json('{"host": "localhost"}') # replaces contents
bag.fromJson('...') # alias
Bag inherits from dict, so json.dumps(bag) works directly.
YAML (requires pip install pyyaml)
s = bag.to_yaml() # serialize to YAML string
bag.from_yaml(s) # replace contents from YAML string
TOML (reading built-in on Python 3.11+; writing requires pip install tomli-w)
s = bag.to_toml() # serialize to TOML string
bag.from_toml(s) # replace contents from TOML string
Note: TOML requires all keys to be strings.
MessagePack (requires pip install msgpack)
data = bag.to_msgpack() # serialize to bytes
bag.from_msgpack(data) # replace contents from bytes
Files
Bag.from_file() and to_file() dispatch on file extension (.json, .yaml, .yml,
.toml, .msgpack) and forward constructor kwargs:
# Load
cfg = pb.Bag.from_file('config.json')
cfg = pb.Bag.from_file('config.yaml', _defval=0) # kwargs forwarded
cfg = pb.Bag.from_file('config.toml')
cfg = pb.Bag.from_file('config.msgpack')
# Save
bag.to_file('output.json', pretty=True)
bag.to_file('output.yaml')
bag.to_file('output.toml')
bag.to_file('output.msgpack')
Environment variables
Bag.from_env() loads environment variables into a new bag.
# Suppose the environment contains:
# APP__DB__HOST=localhost
# APP__DB__PORT=5432
# APP__DEBUG=true
cfg = pb.Bag.from_env(prefix='APP__')
cfg.db.host # 'localhost'
cfg.db.port # '5432' (all env values are strings)
cfg.debug # 'true'
| Parameter | Description | Default |
|---|---|---|
prefix |
Strip this prefix and skip non-matching vars | None (include all) |
sep |
Split var names on this string to create nested keys | '__' |
lowercase |
Lowercase key names after prefix stripping | True |
# No nesting — treat each var as a flat key
flat = pb.Bag.from_env(prefix='APP_', sep=None)
# Keep original casing
loud = pb.Bag.from_env(prefix='APP_', lowercase=False)
# Constructor kwargs are forwarded
cfg = pb.Bag.from_env(prefix='APP__', _defval=0)
int(cfg.missing_key) # 0
Traversal
flat_items() yields (path, value) pairs for every leaf in the tree, using the instance
separator (default .) to build paths. Use sep= to override per call.
bag = pb.Bag({'a': {'b': {'c': 42}}, 'd': 1})
list(bag.flat_items())
# [('a.b.c', 42), ('d', 1)]
list(bag.flat_items(sep='/'))
# [('a/b/c', 42), ('d', 1)]
list(bag.flat_keys())
# ['a.b.c', 'd']
list(bag.flat_values())
# [42, 1]
Integer keys are converted to strings in the path:
bag = pb.Bag()
bag[42] = 'hello'
list(bag.flat_items()) # [('42', 'hello')]
Diffing
diff(other) compares this bag to another and returns a Bag with three keys:
| Key | Contains |
|---|---|
added |
Keys in other but not in self (with their new values) |
removed |
Keys in self but not in other (with their old values) |
changed |
Keys in both with different values ({'old': …, 'new': …}) |
Comparison is done on flattened leaf paths, so nested changes are detected precisely.
v1 = pb.Bag({'server': {'host': 'old-host', 'port': 8080}})
v2 = pb.Bag({'server': {'host': 'new-host', 'port': 8080}, 'debug': True})
d = v1.diff(v2)
d.added # {'debug': True}
d.removed # {}
d.changed # {'server.host': {'old': 'old-host', 'new': 'new-host'}}
diff also accepts a plain dict:
v1.diff({'server': {'host': 'new-host', 'port': 8080}})
Immutability
freeze()
Makes a bag read-only. All write operations (__setattr__, __setitem__, __delitem__,
set, delete, merge, update, from_json, from_yaml, from_toml, |=, and
PlaceHolder assignments) raise TypeError.
cfg = pb.Bag.from_file('config.json')
cfg.freeze()
cfg.host = 'other' # TypeError: Bag is frozen
cfg.set('host', 'other') # TypeError: Bag is frozen
freeze() returns self for chaining:
cfg = pb.Bag.from_file('config.json').freeze()
Sub-bags returned via attribute or item access inherit the frozen state automatically, so deep paths are also protected:
cfg.server.host = 'x' # TypeError — sub-bag is also frozen
_frozen constructor parameter
# Frozen at creation time
defaults = pb.Bag({'timeout': 30, 'retries': 3}, _frozen=True)
copy() is never frozen
bag.copy() and bag.copy(deep=True) always return an unfrozen copy regardless of the
source bag's state.
Validation
validate(schema) checks that the bag contains the expected keys with the expected types or
values. It raises ValueError listing every failure; it returns True if all checks pass.
cfg = pb.Bag(host='localhost', port=8080, debug=False)
cfg.validate({
'host': str,
'port': int,
'debug': bool,
})
# True
cfg.validate({
'port': lambda v: 1 <= v <= 65535,
})
# True
Schema values can be:
- A type — checked with
isinstance - A callable — called with the value; must return
True
Dotted paths work the same as in get() / exists():
cfg = pb.Bag()
cfg.set('db.host', 'localhost')
cfg.set('db.port', 5432)
cfg.validate({
'db.host': str,
'db.port': lambda v: isinstance(v, int) and v > 0,
})
On failure the exception message lists every problem:
pb.Bag(port='not-a-number').validate({'host': str, 'port': int})
# ValueError: Bag validation failed:
# - missing required key 'host'
# - 'port': expected int, got str ('not-a-number')
Iteration and dict operations
bag = pb.Bag(a=1, b=2, c=3)
# Iterate keys
for k in bag:
print(k) # a, b, c
# Iterate key-value pairs
for k, v in bag.items():
print(k, v)
# Keys and values
list(bag.keys()) # ['a', 'b', 'c']
list(bag.values()) # [1, 2, 3]
# Length
len(bag) # 3
# Membership
'a' in bag # True
'z' in bag # False
# Equality (compare with Bag or plain dict)
bag == {'a': 1, 'b': 2, 'c': 3} # True
pb.Bag(a=1) == pb.Bag(a=1) # True
# Copies
bag2 = bag.copy() # shallow
bag3 = bag.copy(deep=True) # deep (fully independent)
# Get the underlying dict
bag.as_dict() # {'a': 1, 'b': 2, 'c': 3}
Bag[SomeType] is valid in type annotations (Bag.__class_getitem__ is implemented):
def load_config() -> pb.Bag[str]:
return pb.Bag.from_file('config.json')
Missing-key defaults
By default a Bag raises ValueError when a missing key is converted to a string (e.g. via
print) or to a number. Four constructor parameters control this and related behaviour:
| Parameter | Controls | Default |
|---|---|---|
_defstr |
String conversion of missing keys (print, str(), repr()) |
ValueError (raises) |
_defval |
Numeric/bool operations on missing keys (int(), float(), bool(), comparisons) |
None |
_sep |
Default path separator for all compound-key methods | '.' |
_frozen |
Make the bag read-only | False |
Pass an Exception class to _defstr/_defval to raise that exception. Pass a value
to return it silently.
# Raise KeyError instead of ValueError
strict = pb.Bag(_defstr=KeyError, _defval=KeyError)
print(strict.missing) # raises KeyError: No such key : missing
# Return silent defaults (never raises)
lenient = pb.Bag(_defstr='', _defval=0)
str(lenient.missing) # ''
int(lenient.missing) # 0
bool(lenient.missing) # False
lenient.missing == 0 # True
# Mix: silent string default, numeric raises
mixed = pb.Bag(_defstr='N/A', _defval=ValueError)
str(mixed.missing) # 'N/A'
int(mixed.missing) # raises ValueError
Missing keys always return False for bare if tests regardless of _defval, so
if bag.key: is always safe as a presence check:
bag = pb.Bag()
if bag.host:
print(bag.host) # only runs if key exists and is truthy
Sub-bags inherit all four settings automatically, so a bag configured with _defval=0 or
_frozen=True has those properties throughout the whole tree.
Comparison to similar projects
Several Python libraries solve the same "dict with attribute access" problem. Here is an honest breakdown of where each one fits.
python-box
The most fully-featured library in this space. Actively maintained (latest release February 2026) with a large community and extensive documentation.
Key differences:
- python-box ships with built-in serialization for JSON, YAML, TOML, and msgpack with no extra dependencies; propertybag supports the same four formats but YAML, TOML, and MessagePack require optional soft dependencies (
pyyaml,tomli-w,msgpack) - python-box configures missing-key behaviour by selecting a subtype at construction time (
Box,DefaultBox,ConfigBox,FrozenBox, etc.); propertybag uses_defstrand_defvalconstructor parameters that independently control string and numeric fallbacks on any instance - python-box's
box_dots=Truemode allows dotted-string access via[]and attribute lookups; propertybag exposes explicitget(),set(),exists(), anddelete()methods that each accept a path string and an optional separator - python-box is a mature project with a significantly larger user base and issue tracker
Choose python-box if:
- You need YAML, TOML, or msgpack serialization without installing extra packages
- You want a large community and long release history
- You prefer choosing a subtype (DefaultBox, FrozenBox, etc.) to control behaviour
- You are building production software where ecosystem support matters
Choose propertybag if:
- You need standalone
get("a.b.c", default),set("a.b.c", v),exists("a.b.c"), ordelete("a.b.c")path-string methods - You want to configure string and numeric missing-key defaults independently on the same instance
- You prefer a smaller, simpler dependency with no compiled extensions
addict
Conceptually very close to propertybag: attribute access plus deep auto-creation on write. Last released November 2020 with no subsequent activity.
Key differences:
- addict always returns a new empty
Dictfor missing keys; this behaviour is not configurable and cannot be made to raise an exception; propertybag raises by default and lets you choose a fallback - addict has no dotted-path methods (
get,set,exists,delete); propertybag provides all four - addict has had no releases in over four years; propertybag is actively maintained
Choose addict if:
- You have an existing codebase that already depends on it
- The silent empty-dict fallback for every missing key is exactly the behaviour you want
Choose propertybag if:
- You need configurable missing-key behaviour (raise, or return a typed default)
- You need dotted-path methods
- You are starting a new project and want an actively maintained dependency
munch
Formerly known as bunch. Adds attribute-style access to a plain dict without
auto-vivification. Last released July 2023.
Key differences:
- munch does not auto-create intermediate dicts on deep write;
bag.a.b = 1raises ifadoes not exist; propertybag creates intermediate dicts automatically - munch configures missing-key behaviour via subclass (
DefaultMunch,DefaultFactoryMunch); propertybag uses constructor parameters on a plainBaginstance - munch supports YAML serialization with no extra dependencies; propertybag supports YAML via
pyyamland TOML viatomli-w - munch has no dotted-path methods; propertybag provides
get,set,exists, anddelete
Choose munch if:
- You want attribute access on a dict you are reading, not building from scratch
- A missing nested key should be a hard error, not a silently created empty object
- You need YAML serialization alongside attribute access
Choose propertybag if:
- You need deep auto-creation on write (
bag.a.b.c = 42when none of those keys exist yet) - You need dotted-path methods
- You want to configure missing-key defaults without switching to a different subclass
dotmap
Combines attribute access with deep auto-creation, similar to addict. Effectively unmaintained since April 2022.
Key differences:
- dotmap has no dotted-path methods; propertybag provides
get,set,exists, anddelete - dotmap has no built-in merge support; propertybag provides
merge()andupdate() - dotmap's serialization is limited to
toDict(); propertybag providesto_json()andfrom_json() - dotmap has had no releases since April 2022; propertybag is actively maintained
Choose dotmap if:
- An existing codebase already depends on it
Choose propertybag if:
- You are starting a new project
- You need dotted-path methods, merge support, or JSON serialization
Feature comparison
| Feature | propertybag | python-box | addict | munch | dotmap |
|---|---|---|---|---|---|
| Attribute read/write | Yes | Yes | Yes | Yes | Yes |
| Auto-create on deep write | Yes | Yes | Yes | No | Yes |
Compound-key get / set / exists / delete |
Yes | No¹ | No | No | No |
pop() / setdefault() with compound paths |
Yes | No | No | No | No |
| Configurable missing-key behavior | Per-instance (_defstr / _defval), inherited by sub-bags |
Via Box subtypes | No (always returns empty Dict) | Via DefaultMunch subclass |
Partial (_dynamic=False) |
Instance-level path separator (_sep) |
Yes | No² | No | No | No |
| Deep / recursive merge | Yes (merge(deep=True)) |
Yes (merge_update()) |
No | No | No |
| Immutability / freeze | Yes (freeze() / _frozen) |
Yes (FrozenBox) |
No | Partial (ReadOnlyMunch) |
No |
| / |= operators |
Yes (returns Bag) |
Yes (returns Box) |
Via dict³ |
Via dict³ |
Via dict³ |
| JSON serialization | Yes | Yes | Via dict |
Yes | Via dict |
| YAML / TOML / MessagePack serialization | Yes (soft deps⁴) | Yes | No | YAML only | No |
| File load / save (dispatch on extension) | Yes (.json/.yaml/.toml/.msgpack) | Yes (JSON/YAML/TOML/msgpack) | No | Partial⁵ | No |
| Environment variable loading | Yes (from_env()) |
No | No | No | No |
Leaf path traversal (flat_items) |
Yes | No | No | No | No |
Change detection (diff) |
Yes | No | No | No | No |
Schema validation (validate) |
Yes | No | No | No | No |
| Non-string key support | Yes | Yes | Yes | Yes | Partial |
| Latest release | — | Feb 2026 (v7.4.1) | Nov 2020 (v2.4.0) | Jul 2023 (v4.0.0) | Apr 2022 (v1.3.30) |
¹ python-box's box_dots=True mode enables dotted-string access via [] and attribute
lookups, but there are no standalone exists("a.b.c") or delete("a.b.c") methods.
² python-box supports box_dots=True globally but does not allow per-instance separator
customisation.
³ addict, munch, and dotmap inherit | from dict (Python 3.9+); the result may be a
plain dict rather than the subclass type depending on the version.
⁴ propertybag requires pip install pyyaml for YAML, pip install tomli-w for TOML write
support (TOML reading uses the stdlib tomllib on Python 3.11+), and pip install msgpack
for MessagePack.
⁵ munch provides Munch.fromYAML() / Munch.toYAML() and Munch.fromJSON() /
Munch.toJSON() but no single extension-dispatching from_file() / to_file().
Summary: which to choose
- Need a large community, long-term support, or zero-dependency serialization (JSON/YAML/TOML/msgpack built-in) → python-box
- Need compound-key path methods, diff, validation, env-var loading, or fine-grained per-instance missing-key control → propertybag
- Want attribute access on an existing dict without auto-vivification → munch
- Already using addict or dotmap → consider migrating; both are effectively unmaintained
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file propertybag-1.0.1.tar.gz.
File metadata
- Download URL: propertybag-1.0.1.tar.gz
- Upload date:
- Size: 31.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50f7edc460ca129928be4dab9b7bf8f111a9065af69811098fdd7580e61c7d32
|
|
| MD5 |
3e9b5d5f36f37e1132cacc810717e29a
|
|
| BLAKE2b-256 |
adfc9c904e2dc1b7ef9bbf2cc7eb31ba98bf3a6e2d4eaf77f13b3a732a806724
|
File details
Details for the file propertybag-1.0.1-py3-none-any.whl.
File metadata
- Download URL: propertybag-1.0.1-py3-none-any.whl
- Upload date:
- Size: 18.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
374a35d561cd8f020e13960375db4b39b9dbcb622a1271503062061480fc6b5c
|
|
| MD5 |
c216d95300fa1d67d62e4822c7682714
|
|
| BLAKE2b-256 |
55b87b76fd8b58e45258c75d8393626983433e6bb2c027324feea6791c667b0c
|