Metadata-Version: 2.4
Name: propertybag
Version: 1.0.1
Summary: Flexible property bag functions
Home-page: https://github.com/wheresjames/propertybag
Author: Robert Umbehant
Author-email: propertybag@wheresjames.com
License: MIT
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: author
Dynamic: author-email
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: summary

# propertybag

A flexible, dict-backed property bag for Python. Access and mutate deeply nested data using
attribute syntax, without any upfront schema.

```python
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](#install)
- [Construction](#construction)
- [Reading values](#reading-values)
- [Writing values](#writing-values)
- [Compound-key methods](#compound-key-methods)
- [Merge and update](#merge-and-update)
- [Serialization](#serialization)
- [Environment variables](#environment-variables)
- [Traversal](#traversal)
- [Diffing](#diffing)
- [Immutability](#immutability)
- [Validation](#validation)
- [Iteration and dict operations](#iteration-and-dict-operations)
- [Missing-key defaults](#missing-key-defaults)
- [Comparison to similar projects](#comparison-to-similar-projects)
  - [python-box](#python-box)
  - [addict](#addict)
  - [munch](#munch)
  - [dotmap](#dotmap)
  - [Feature comparison](#feature-comparison)
  - [Summary: which to choose](#summary-which-to-choose)

---

<a id="install"></a>

## 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()
```

---

<a id="construction"></a>

## Construction

```python
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` |

```python
# 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](#missing-key-defaults) for full details on `_defstr` and `_defval`.

---

<a id="reading-values"></a>

## Reading values

### Attribute and item access

```python
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:

```python
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](#missing-key-defaults) to configure a fallback value instead.

### Non-string keys

Integer (and other hashable) keys work via `[]`:

```python
bag = pb.Bag()
bag[42] = 'hello'
bag[42]             # 'hello'
bag.get(42)         # 'hello'
```

---

<a id="writing-values"></a>

## Writing values

### Attribute and item assignment

```python
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

```python
bag = pb.Bag({'a': {'b': 1, 'c': 2}})

del bag.a.b
del bag['a']['c']
'b' in bag.a    # False
```

---

<a id="compound-key-methods"></a>

## 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)`

```python
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)`

```python
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)`

```python
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.

```python
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.

```python
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.

```python
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.

```python
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)
```

---

<a id="merge-and-update"></a>

## 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.

```python
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.

```python
bag = pb.Bag()
bag.update({'a': 1}, pb.Bag(b=2), c=3)
# a=1, b=2, c=3
```

### `|` and `|=` operators

```python
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)`

```python
bag2 = bag.copy()           # shallow — nested dicts share references
bag3 = bag.copy(deep=True)  # deep — fully independent clone
```

---

<a id="serialization"></a>

## Serialization

### JSON

```python
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`)*

```python
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`)*

```python
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`)*

```python
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:

```python
# 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')
```

---

<a id="environment-variables"></a>

## Environment variables

`Bag.from_env()` loads environment variables into a new bag.

```python
# 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` |

```python
# 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
```

---

<a id="traversal"></a>

## 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.

```python
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:

```python
bag = pb.Bag()
bag[42] = 'hello'
list(bag.flat_items())   # [('42', 'hello')]
```

---

<a id="diffing"></a>

## 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.

```python
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:

```python
v1.diff({'server': {'host': 'new-host', 'port': 8080}})
```

---

<a id="immutability"></a>

## 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`.

```python
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:

```python
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:

```python
cfg.server.host = 'x'   # TypeError — sub-bag is also frozen
```

### `_frozen` constructor parameter

```python
# 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.

---

<a id="validation"></a>

## 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.

```python
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()`:

```python
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:

```python
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')
```

---

<a id="iteration-and-dict-operations"></a>

## Iteration and dict operations

```python
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):

```python
def load_config() -> pb.Bag[str]:
    return pb.Bag.from_file('config.json')
```

---

<a id="missing-key-defaults"></a>

## 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.

```python
# 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:

```python
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.

---

<a id="comparison-to-similar-projects"></a>

## 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.

<a id="python-box"></a>

### [python-box](https://github.com/cdgriffith/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

---

<a id="addict"></a>

### [addict](https://github.com/mewwts/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

---

<a id="munch"></a>

### [munch](https://github.com/Infinidat/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

---

<a id="dotmap"></a>

### [dotmap](https://github.com/drgrib/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

---

<a id="feature-comparison"></a>

### 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()`.

---

<a id="summary-which-to-choose"></a>

### 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
