Skip to content

Commit 8310484

Browse files
lbliiiclaude
andauthored
release: prepare v0.8.0 — ?. soft-on-mappings (#110)
Optional chaining (`?.` and `?[...]`) now short-circuits missing keys to `None` on `collections.abc.Mapping` receivers (dict, `MappingProxyType`, `ChainMap`, dict subclasses), mirroring Python's `dict.get(key)` idiom and aligning with the TS/Swift optional-chaining mental model. Object attribute misses on non-Mapping receivers still raise `UndefinedError` under `strict_undefined` — schema violations on objects are almost always typos, and that's the typo-catching win of strict mode. Sequence out-of-range on `?[i]` also still raises. The dispatch rule is `isinstance(obj, collections.abc.Mapping)`: dicts are schema-less data (config, JSON, kwargs) where missing keys are expected; objects have schemas. Python already splits these idioms (`dict.get("k")` vs `obj.x`); Kida now matches. Changes: - `src/kida/template/helpers.py` — widen `getattr_preserve_none` / `strict_getattr_preserve_none` from `dict` to `Mapping`; add `getitem_preserve_none` / `strict_getitem_preserve_none` for `?[...]`. - `src/kida/compiler/expressions.py` — `_compile_optional_getitem` routes through `_getitem_none` instead of emitting direct subscript. - `src/kida/template/core.py` — wire `_getitem_none` into namespace. - `src/kida/exceptions.py` — reword null-safe hint for 0.8 semantics. - Tests: split the v0.7 receiver-only pin into Mapping-soft + object-strict cases; new `tests/test_optional_chaining_semantics.py` covers `MappingProxyType`, `ChainMap`, dict-subclass `__missing__`, Sequence out-of-range, nested chains, lenient mode, and `??` interaction. Update AST baseline hash. - Docs: rewrite "receiver-only" framing in `syntax/variables.md`, `troubleshooting/undefined-variable.md`, and the v0.7 upgrade tutorial (now banners forward to v0.8). Add `tutorials/upgrade-to-v0.8.md`. - Release: bump `pyproject.toml` to 0.8.0, roll pending [Unreleased] entries (warn-once dedup, null-safe hint, v0.7 tutorial) into a unified [0.8.0] CHANGELOG section, add `releases/0.8.0.md`. Motivated by downstream feedback on v0.7.0: the receiver-only short-circuit rule was principled but contradicted the mental model every TS/Swift/JS user imports, and forced `?? ""` on every dict lookup. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 696ccb6 commit 8310484

15 files changed

Lines changed: 513 additions & 54 deletions

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.8.0] - 2026-04-21
11+
12+
### Breaking
13+
14+
- **`?.` and `?[...]` soften on Mapping receivers** — Optional chaining now short-circuits missing keys to `None` on any `collections.abc.Mapping` receiver (dict, `MappingProxyType`, `ChainMap`, dict subclasses), mirroring Python's `dict.get(key)` idiom and aligning with TS/Swift mental models. Object attribute misses still raise `UndefinedError` under `strict_undefined` — schema violations on objects are almost always typos. Sequence out-of-range on `?[i]` also still raises in strict mode. **Migration**: if you were relying on `?.` to raise on a missing dict key (the v0.7 behavior), drop the `?.` and use strict access `{{ user.nickname }}`, or use the `get` filter. Most code using the recommended `{{ x?.y ?? "" }}` pattern is unaffected. See `docs/tutorials/upgrade-to-v0.8.md`.
15+
1016
### Added
1117

1218
- **v0.7 upgrade tutorial** — New `docs/tutorials/upgrade-to-v0.7.md` collects the `strict_undefined=True` migration patterns in one page: TL;DR, the three fix patterns (`is defined`, `??`, `| default`), the `strict_undefined=False` escape hatch, and the preferred `?.` / `?[` / `| get` idioms. Cross-linked from `README.md`, the tutorials index, and the `UndefinedError` troubleshooting page.
13-
- **Null-safe hint on `UndefinedError`** — When `kind="attribute/key"`, the error message now includes a second `Hint:` line pointing users at `x?.y`, `x?["y"]`, `x.y ?? ''`, and `x | get("y", '')` so they stop reaching for `.get("k", "")` under strict mode. Variable-kind errors are unchanged.
19+
- **v0.8 upgrade tutorial** — New `docs/tutorials/upgrade-to-v0.8.md` documents the `?.` / `?[...]` Mapping-soft semantics, the rationale, and the migration path.
20+
- **Null-safe hint on `UndefinedError`** — When `kind="attribute/key"`, the error message now includes a second `Hint:` line pointing users at `x?.y`, `x?.y ?? ""`, and `x | get("y", '')` so they stop reaching for `.get("k", "")` under strict mode. Variable-kind errors are unchanged.
1421
- **Docs: optional chaining surfaced in `docs/syntax/variables.md`** — Added dedicated `?.` and `?[...]` subsections under Attribute Access and Index Access. These operators were previously mentioned only in pipeline examples.
22+
- **`getitem_preserve_none` / `strict_getitem_preserve_none`** — New runtime helpers powering the `?[...]` Mapping-soft semantics. `?[...]` now routes through these helpers (previously emitted direct subscript); Mapping misses return `None`, Sequence out-of-range raises under strict.
1523

1624
### Fixed
1725

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "kida-templates"
7-
version = "0.7.0"
7+
version = "0.8.0"
88
description = "Python component framework for HTML — typed props, named slots, scoped state, error boundaries, zero JavaScript"
99
readme = "README.md"
1010
requires-python = ">=3.14"

site/content/docs/syntax/variables.md

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,23 +59,33 @@ For non-dict objects (dataclasses, custom classes), dot notation uses `getattr`
5959

6060
> **Jinja2 difference**: Jinja2 always tries `getattr` first regardless of type, so `{{ data.items }}` resolves to the `dict.items` method. Kida handles this correctly for dicts.
6161
62-
### Optional Chaining — `?.`
62+
### Optional Chaining — `?.` and `?[...]`
6363

64-
`?.` short-circuits to `None` (which renders as `""`) when the **receiver** is `None` or undefined. It does not suppress `UndefinedError` on a missing attribute/key when the receiver is defined — that is by design under strict mode.
64+
`?.` and `?[...]` short-circuit missing lookups to `None` (which renders as `""`) when:
65+
66+
- The **receiver** is `None` or undefined, **or**
67+
- The receiver is a **Mapping** (`dict` or `collections.abc.Mapping` subclass) and the key is missing.
68+
69+
Missing attributes on a non-Mapping **object** still raise `UndefinedError` under strict mode — preserving typo detection against object schemas. Missing indices on a **Sequence** (list, tuple) also still raise.
6570

6671
```kida
67-
{{ user?.nickname }} {# user = None → "" #}
68-
{{ user?.nickname }} {# user = {} → UndefinedError (key missing) #}
69-
{{ user?.nickname ?? "Guest" }} {# safe in both directions #}
70-
{{ page?.author?.avatar }} {# chain short-circuits on first None #}
72+
{{ user?.nickname }} {# user = None → "" #}
73+
{{ user?.nickname }} {# user = {} → "" (Mapping miss) #}
74+
{{ user?.nickname }} {# user = User() → UndefinedError (object attr) #}
75+
{{ cfg?["theme"] }} {# cfg = {} → "" (Mapping miss) #}
76+
{{ items?[5] }} {# items = [1,2,3] → UndefinedError (out-of-range) #}
77+
{{ page?.author?.avatar }} {# chain short-circuits at any None or missing Mapping key #}
7178
```
7279

73-
For a receiver-AND-key safe lookup, use `?? default`, `| default("")`, or `| get("key", "")`:
80+
Rule of thumb: `?.` on Mappings behaves like Python's `dict.get(key)`. On objects, it's a null-guard on the receiver only; combine with `?? ""` when the attribute itself may be missing:
7481

7582
```kida
76-
{{ user | get("nickname", "") }} {# closest to dict.get("nickname", "") #}
83+
{{ user?.nickname ?? "Guest" }} {# safe for both object-attr misses and None receivers #}
84+
{{ user | get("nickname", "") }} {# filter form; also works for dict-like get #}
7785
```
7886

87+
> **Changed in v0.8.0**: `?.` and `?[...]` on Mapping receivers now short-circuit missing keys to `None` (previously raised under `strict_undefined`). See the [v0.8.0 upgrade tutorial]({{< relref "tutorials/upgrade-to-v0.8.md" >}}).
88+
7989
## Index Access
8090

8191
Access sequence items by index:

site/content/docs/troubleshooting/undefined-variable.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,30 +160,38 @@ Under the default **strict mode**, missing attributes raise `UndefinedError`. Us
160160

161161
Under `strict_undefined=True`, reaching for Python's `.get("k", "")` inside templates adds noise at every call site. Kida ships with first-class null-safe operators — prefer these:
162162

163-
### Optional Chaining — `?.` and `?[...]` (receiver-only short-circuit)
163+
### Optional Chaining — `?.` and `?[...]` (Mapping-soft, object-strict)
164164

165-
`?.` and `?[...]` short-circuit when the **receiver** is `None` or undefined. They do not suppress missing-key / missing-attribute errors on a defined receiver:
165+
Since v0.8.0, `?.` and `?[...]` short-circuit to `None` when either:
166+
167+
- The **receiver** is `None` or undefined, **or**
168+
- The receiver is a **Mapping** (`dict` or `Mapping` subclass) and the key is missing.
169+
170+
Missing attributes on a non-Mapping **object** still raise under strict mode — that's the typo-detection value of `strict_undefined`:
166171

167172
```kida
168-
{# Receiver-None case — yields "" #}
173+
{# Receiver-None — yields "" #}
169174
{{ config?.theme }} {# config = None → "" #}
170175
171-
{# Defined receiver, missing key — still raises under strict mode #}
172-
{{ config?.theme }} {# config = {} → UndefinedError #}
176+
{# Mapping miss — yields "" (dict.get() idiom) #}
177+
{{ config?.theme }} {# config = {} → "" #}
178+
179+
{# Object attr miss — still raises, combine with ?? for safety #}
180+
{{ user?.nickname ?? "" }} {# user = User() with no .nickname → "" #}
173181
174-
{# Safe in both directions — add ?? or | default for missing keys #}
175-
{{ config?.theme ?? "" }}
182+
{# Safe patterns #}
183+
{{ config?.theme ?? "dark" }}
176184
{{ settings?["theme"] ?? "light" }}
177185
```
178186

179187
### `| get(key, default)` Filter — drop-in for `dict.get`
180188

181189
```kida
182-
{# Closest to Python's dict.get("k", default) — handles None receiver AND missing key #}
190+
{# Handles None receiver, missing dict key, AND missing object attr uniformly #}
183191
{{ config | get("theme", "light") | upper }}
184192
```
185193

186-
Handles dicts, objects, and `None` uniformly — and, unlike `?.` alone, also catches missing keys.
194+
Use `| get` when the lookup must be safe across all receiver shapes in one expression.
187195

188196
### Chaining
189197

site/content/docs/tutorials/upgrade-to-v0.7.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,18 +122,23 @@ Flip `strict_undefined=False` once to unblock the release, then fix sites one at
122122

123123
0.7.x ships with first-class null-safe operators. Prefer these over `.get("key", "")` chains:
124124

125-
### `?.` — optional attribute access (receiver-only)
125+
### `?.` — optional attribute access (receiver-only in v0.7)
126126

127-
`?.` short-circuits when the **receiver** is `None` or undefined. It does **not** suppress `UndefinedError` for a missing attribute/key on a *defined* receiver — strict mode still raises, by design.
127+
:::note[v0.8.0 changed this]
128+
The rules below describe v0.7.x semantics. In v0.8.0, `?.` and `?[...]` also short-circuit **missing keys on Mapping receivers** to `None`, aligning with `dict.get()`. See the [v0.8.0 upgrade tutorial]({{< relref "upgrade-to-v0.8.md" >}}).
129+
:::
130+
131+
In v0.7.x, `?.` short-circuits when the **receiver** is `None` or undefined. It does **not** suppress `UndefinedError` for a missing attribute/key on a *defined* receiver — strict mode still raises.
128132

129133
```kida
130134
{# Receiver is None — yields "" #}
131135
{{ config?.theme }} {# config = None → "" #}
132136
133-
{# Defined receiver, missing key — still raises under strict mode #}
134-
{{ config?.theme }} {# config = {} → UndefinedError #}
137+
{# v0.7: defined receiver, missing key — raises #}
138+
{# v0.8: Mapping miss → "" (new behavior) #}
139+
{{ config?.theme }} {# config = {} → UndefinedError (v0.7) / "" (v0.8) #}
135140
136-
{# Safe-in-both-directions forms: #}
141+
{# Safe-in-both-directions forms (work on both 0.7 and 0.8): #}
137142
{{ config?.theme ?? "" }} {# catch missing key with ?? #}
138143
{{ config | get("theme", "") }} {# or the get filter #}
139144
```
@@ -145,9 +150,9 @@ Chains short-circuit at the first `None`:
145150
{{ page?.author?.avatar ?? "/default.png" }} {# with a named fallback #}
146151
```
147152

148-
### `?[...]` — optional item access (receiver-only)
153+
### `?[...]` — optional item access (receiver-only in v0.7)
149154

150-
Mirror of `?.`. Short-circuits only on a `None`/undefined receiver:
155+
Mirror of `?.`. In v0.7, short-circuits only on a `None`/undefined receiver:
151156

152157
```kida
153158
{{ settings?["theme"] }} {# settings is None → "" #}
@@ -162,7 +167,7 @@ Mirror of `?.`. Short-circuits only on a `None`/undefined receiver:
162167
{{ config | get("theme", "light") | upper }}
163168
```
164169

165-
The `get` filter handles dicts, objects, and `None` uniformly, and — unlike `?.` alone — also catches missing keys. Prefer it when the value is a key lookup that may be missing, rather than a variable that may be `None`.
170+
The `get` filter handles dicts, objects, and `None` uniformly. Use it when a single expression must be safe across all receiver shapes.
166171

167172
### Combining operators
168173

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
---
2+
title: Upgrade to 0.8
3+
description: Optional chaining now treats missing Mapping keys like dict.get()
4+
draft: false
5+
weight: 14
6+
lang: en
7+
type: doc
8+
tags:
9+
- migration
10+
- upgrade
11+
- tutorial
12+
- optional-chaining
13+
keywords:
14+
- upgrade
15+
- 0.8
16+
- optional chaining
17+
- ?.
18+
- Mapping
19+
- dict.get
20+
icon: arrow-up-circle
21+
---
22+
23+
# Upgrade to 0.8
24+
25+
Guide for moving a codebase from Kida 0.7.x to 0.8.0.
26+
27+
:::note[Why this tutorial exists]
28+
Kida 0.8.0 changes the semantics of `?.` and `?[...]` on Mapping receivers: missing keys now short-circuit to `None` instead of raising `UndefinedError` under `strict_undefined`. This aligns `?.` with the mental model every TS/Swift/JS user imports (and with Python's own `dict.get()` idiom), at the cost of one breaking semantic. Object-attribute strictness is preserved — `?.nickname` on an object without that attribute still raises.
29+
:::
30+
31+
## TL;DR
32+
33+
```kida
34+
{# v0.7.x — raises UndefinedError when key is missing #}
35+
{{ user?.nickname }} {# user = {} → UndefinedError #}
36+
37+
{# v0.8.0 — returns None, renders as "" (like dict.get("nickname")) #}
38+
{{ user?.nickname }} {# user = {} → "" #}
39+
40+
{# Object attribute access is unchanged — still strict #}
41+
{{ user?.nickname }} {# user = User() without .nickname → UndefinedError #}
42+
```
43+
44+
If this breaks code that **relied on** the v0.7 behavior (catching a missing dict key via `?.`), the migration is one of:
45+
46+
1. Drop the `?.` and use strict access: `{{ user.nickname }}` — raises on missing dict key, explicit.
47+
2. Use the `get` filter: `{{ user | get("nickname") }}` — same soft behavior as new `?.`, more explicit.
48+
3. Pin to 0.7: `pip install 'kida-templates==0.7.*'`.
49+
50+
## What changed
51+
52+
### Old rule (v0.7): receiver-only short-circuit
53+
54+
`?.` short-circuited only when the receiver was `None`. A missing key on a defined receiver raised.
55+
56+
| Expression | Receiver | v0.7 behavior |
57+
|---|---|---|
58+
| `user?.nickname` | `None` | `""` (short-circuit) |
59+
| `user?.nickname` | `{}` | `UndefinedError` |
60+
| `user?.nickname` | `User()` (no attr) | `UndefinedError` |
61+
| `items?[5]` | `[1, 2, 3]` | `IndexError` |
62+
63+
### New rule (v0.8): Mapping-soft, object-strict
64+
65+
| Expression | Receiver | v0.8 behavior |
66+
|---|---|---|
67+
| `user?.nickname` | `None` | `""` (unchanged) |
68+
| `user?.nickname` | `{}` | **`""` (new — Mapping miss → `None`)** |
69+
| `user?.nickname` | `User()` (no attr) | `UndefinedError` (unchanged, strict mode) |
70+
| `items?[5]` | `[1, 2, 3]` | `UndefinedError` (unchanged — Sequence out-of-range) |
71+
| `cfg?["theme"]` | `{}` | **`""` (new — Mapping miss)** |
72+
| `cfg?["theme"]` | `MappingProxyType({})` | **`""` (new)** |
73+
74+
The dispatch rule: `isinstance(obj, collections.abc.Mapping)` decides. This covers `dict`, dict subclasses, `MappingProxyType`, `ChainMap`, and any user-defined `Mapping` ABC. `__missing__` on dict subclasses is still honored (the slow path calls `obj[key]`).
75+
76+
## Why this change
77+
78+
The v0.7 "receiver-only" rule was principled but out of step with every other language's optional-chaining mental model. TypeScript's `foo?.bar` returns `undefined` for a missing property — not because it short-circuits the *access*, but because JS treats missing properties as `undefined`. Swift similarly has no "missing key raises" concept. Users reaching for `?.` in Kida expected TS/Swift semantics on dict-shaped data and got strict errors instead.
79+
80+
The v0.8 split respects both intuitions:
81+
82+
- **Dicts are schema-less** (config, JSON, kwargs). Missing keys are expected. `?.``None` matches `dict.get()`.
83+
- **Objects have schemas.** A missing `.nickname` on a `User` object is almost always a typo. `strict_undefined` still catches it.
84+
85+
You still get typo protection where it matters (object attributes, list out-of-range), with none of the `?? ""` noise on every dict lookup.
86+
87+
## Migration
88+
89+
### Most code does not break
90+
91+
If you were following the v0.7 "recommended pattern" of combining `?.` with `??` (e.g. `{{ user?.nickname ?? "" }}`), your code works unchanged in 0.8 — it just became less noisy in its intent.
92+
93+
### Code that relied on the raise
94+
95+
Search for bare `?.` on dict access without a `??` fallback:
96+
97+
```bash
98+
rg '\?\.[a-zA-Z_]+(?!\s*\?\?)' templates/
99+
```
100+
101+
For any hit where the receiver is a dict and you *wanted* a loud error on a missing key, change to strict access:
102+
103+
```kida
104+
{# Before (v0.7 — raised, perhaps intentionally) #}
105+
{{ user?.nickname }}
106+
107+
{# v0.8 explicit strict access #}
108+
{{ user.nickname }}
109+
```
110+
111+
### Pinning
112+
113+
If you're not ready to upgrade:
114+
115+
```toml
116+
# pyproject.toml
117+
dependencies = [
118+
"kida-templates>=0.7,<0.8",
119+
]
120+
```
121+
122+
## Verification
123+
124+
Your existing `?? "fallback"` patterns still work. Your existing object-attr templates still raise on typos. The only surface-level difference you should see is: dict templates that previously threw under strict mode now render empty string.
125+
126+
Run your test suite. If it passes, you're done.

0 commit comments

Comments
 (0)