Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,217 @@ class ListTemplate:
- You have very large object graphs and want to avoid caching
- You don't need field serializers to work in nested contexts
- You're debugging proxy-related issues

### Snapshotting semantics
When `use_proxy=True` (the default), deigma creates an **immutable snapshot** of your data at template instantiation time. This has important implications for how the proxy behaves with mutable data.

#### Immutability guarantees
The serialized snapshot is frozen to prevent accidental mutations:
- Dicts are wrapped in `MappingProxyType` (read-only dict view)
- Lists are converted to tuples
- Nested collections are recursively frozen

This makes proxies thread-safe for concurrent template rendering:

```python
from dataclasses import dataclass

@template("{{ items }}")
class ItemsTemplate:
items: list[str]

t = ItemsTemplate(items=["a", "b", "c"])

# The snapshot is immutable
str(t) # "['a', 'b', 'c']"

# These won't affect the snapshot:
t.items.append("d") # Mutates the original list
str(t) # Still "['a', 'b', 'c']" (snapshot unchanged)
```

#### Refreshing snapshots
When you mutate the underlying object and want the proxy to reflect those changes, call `refresh()`:

```python
@dataclass
class Data:
value: int

@template("{{ data.value }}")
class DataTemplate:
data: Data

data = Data(value=42)
t = DataTemplate(data=data)

str(t) # "42"

# Mutate the underlying object
data.value = 100

# Before refresh, sees old snapshot
str(t) # "42"

# After refresh, sees new value
t._proxy.refresh()
str(t) # "100"
```

The `refresh()` method:
1. Re-serializes the entire object graph using the `TypeAdapter`
2. Applies all field serializers again
3. Clears the attribute cache
4. Updates the internal version counter for cache coherence

*Thread safety*: `refresh()` uses copy-on-write with version stamping. Any cache entries built before the refresh are invalidated by version mismatch, preventing stale reads.

#### When snapshots matter
Understanding snapshot semantics is crucial when:
- *Working with mutable data*: If your template data changes over time, you need to call `refresh()` to see updates
- *Debugging*: If you're seeing stale values, check if the underlying object was mutated after template instantiation
- *Performance*: Snapshots are computed once at instantiation. For frequently-mutated objects, consider `mode="live"` (see next section)
- *Thread safety*: Snapshots are immutable and safe for concurrent access without locks

### Template rendering modes
Deigma supports three rendering modes that control when serialization happens and how mutations are handled. These modes are configured via the `mode` parameter when using `use_proxy=True`.

#### Snapshot mode (default)
```python
@template("{{ data }}", mode="snapshot") # mode="snapshot" is the default
class MyTemplate:
data: MyData
```

*Behavior*:
- Entire object graph is serialized once at instantiation
- All field serializers run immediately and results are cached
- Field access returns pre-computed values (fast dictionary lookups)
- Mutations require explicit `refresh()` to be visible

*Best for*:
- Immutable or rarely-changing data
- Templates rendered multiple times
- Maximum performance (serialization happens once)
- Thread-safe concurrent rendering

#### Live mode
```python
@template("{{ data }}", mode="live")
class MyTemplate:
data: MyData
```

*Behavior*:
- Root object is serialized once (for keys/length/iteration)
- *Nested objects are re-serialized on every access*
- Field serializers run on every field access
- Mutations are immediately visible (no `refresh()` needed)
- No caching of child proxies

*Best for*:
- Frequently-mutating data where you want to see changes immediately
- Large object graphs where caching all children would use too much memory
- Debugging scenarios where you need live values

*Trade-offs*:
- Higher CPU cost (repeated serialization)
- Lower memory footprint (no cached child proxies)
- Always sees fresh data

*Example*:
```python
from dataclasses import dataclass

@dataclass
class Counter:
count: int

@template("Count: {{ counter.count }}", mode="live")
class CounterTemplate:
counter: Counter

counter = Counter(count=0)
t = CounterTemplate(counter=counter)

str(t) # "Count: 0"

counter.count = 5
str(t) # "Count: 5" (no refresh needed!)

counter.count = 10
str(t) # "Count: 10"
```

#### Hybrid mode
```python
@template("{{ data }}", mode="hybrid")
class MyTemplate:
data: MyData
```

*Behavior*:
- Root object is serialized once (snapshot)
- *Primitives* (str, int, bool, etc.) use cached snapshot
- *Complex objects* (dicts, lists, dataclasses) are re-serialized on access
- Balances performance and freshness

*Best for*:
- Mixed workloads with both static and dynamic data
- Nested structures where only some parts change
- Performance-sensitive code that needs some live data

*Example*:
```python
from dataclasses import dataclass

@dataclass
class Config:
name: str # Static
count: int # Dynamic

@template(
"Name: {{ config.name }}, Count: {{ config.count }}",
mode="hybrid"
)
class ConfigTemplate:
config: Config

config = Config(name="App", count=0)
t = ConfigTemplate(config=config)

str(t) # "Name: App, Count: 0"

# Mutate count (complex object field)
config.count = 5
str(t) # "Name: App, Count: 5" (sees fresh count)

# Note: In hybrid mode, simple fields still snapshot,
# but objects are re-serialized
Comment on lines +549 to +573

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The example for hybrid mode is incorrect and misleading. It claims that mutating an integer field (count) is reflected immediately. However, the implementation (and the documentation text for hybrid mode) states that primitives are snapshotted. Therefore, mutations to config.count will not be visible without calling refresh(). This can lead to confusion for users. I suggest a corrected example that more accurately demonstrates the behavior of hybrid mode with a nested object.

from dataclasses import dataclass

@dataclass
class Status:
    is_active: bool

@dataclass
class Config:
    name: str      # Static primitive
    status: Status # Dynamic object

@template(
    "Name: {{ config.name }}, Active: {{ config.status.is_active }}",
    mode="hybrid"
)
class ConfigTemplate:
    config: Config

config = Config(name="App", status=Status(is_active=False))
t = ConfigTemplate(config=config)

str(t)  # "Name: App, Active: False"

# Mutate a field on the nested object
config.status.is_active = True
str(t)  # "Name: App, Active: True" (sees fresh status)

# Mutate a primitive on the root object
config.name = "NewApp"
str(t) # "Name: App, Active: True" (still sees old name)

```

#### Choosing a mode

| Mode | Serialization | Performance | Memory | Sees mutations | Use case |
|------------|-----------------------------------------|-------------|--------|------------------------|-------------------------------------|
| *snapshot* | Once at init | Fastest | Higher | No (needs `refresh()`) | Immutable/static data, multi-render |
| *live* | On every access | Slowest | Lower | Yes (immediately) | Frequently-changing data, debugging |
| *hybrid* | Mixed (primitives cached, objects live) | Medium | Medium | Partially | Mixed static/dynamic data |

*Additional parameters*:
- `freeze=True` (default): Convert lists to tuples for immutability
- `freeze=False`: Keep lists as-is (useful if you need mutability in templates)
- `version_getter`: Provide a custom function to track mutations for cache invalidation

```python
def get_version(obj):
return obj.version # User-managed version counter

@template("{{ data }}", version_getter=get_version)
class MyTemplate:
data: MyData
```

### Custom serialization
By default, template variables are serialized using `str`. You can inject serializers
into templates in two ways.
Expand Down
Loading