Skip to main content

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

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 _defstr and _defval constructor parameters that independently control string and numeric fallbacks on any instance
  • python-box's box_dots=True mode allows dotted-string access via [] and attribute lookups; propertybag exposes explicit get(), set(), exists(), and delete() 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"), or delete("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 Dict for 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 = 1 raises if a does not exist; propertybag creates intermediate dicts automatically
  • munch configures missing-key behaviour via subclass (DefaultMunch, DefaultFactoryMunch); propertybag uses constructor parameters on a plain Bag instance
  • munch supports YAML serialization with no extra dependencies; propertybag supports YAML via pyyaml and TOML via tomli-w
  • munch has no dotted-path methods; propertybag provides get, set, exists, and delete

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 = 42 when 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, and delete
  • dotmap has no built-in merge support; propertybag provides merge() and update()
  • dotmap's serialization is limited to toDict(); propertybag provides to_json() and from_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 controlpropertybag
  • Want attribute access on an existing dict without auto-vivificationmunch
  • Already using addict or dotmap → consider migrating; both are effectively unmaintained

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

propertybag-1.0.1.tar.gz (31.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

propertybag-1.0.1-py3-none-any.whl (18.0 kB view details)

Uploaded Python 3

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

Hashes for propertybag-1.0.1.tar.gz
Algorithm Hash digest
SHA256 50f7edc460ca129928be4dab9b7bf8f111a9065af69811098fdd7580e61c7d32
MD5 3e9b5d5f36f37e1132cacc810717e29a
BLAKE2b-256 adfc9c904e2dc1b7ef9bbf2cc7eb31ba98bf3a6e2d4eaf77f13b3a732a806724

See more details on using hashes here.

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

Hashes for propertybag-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 374a35d561cd8f020e13960375db4b39b9dbcb622a1271503062061480fc6b5c
MD5 c216d95300fa1d67d62e4822c7682714
BLAKE2b-256 55b87b76fd8b58e45258c75d8393626983433e6bb2c027324feea6791c667b0c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page