Skip to content

Commit da0b86c

Browse files
chore(plan): add plan 16 - dynamic typing hardening
Plan to harden codebase against dynamic-typing pitfalls including magic numbers, stringly-typed patterns, unnamed tuples, and plain dicts with known schemas. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ec8c654 commit da0b86c

7 files changed

Lines changed: 700 additions & 0 deletions

.ai/task-manager/plans/16--dynamic-typing-hardening/plan-16--dynamic-typing-hardening.md

Lines changed: 271 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
id: 1
3+
group: "dynamic-typing-hardening"
4+
dependencies: []
5+
status: "pending"
6+
created: 2026-03-01
7+
skills:
8+
- "python-typing"
9+
- "refactoring"
10+
---
11+
# Add Named Constants for Magic Literals
12+
13+
## Objective
14+
Eliminate magic numbers scattered across the codebase by adding named constants to `const.py` and replacing all occurrences.
15+
16+
## Skills Required
17+
Python typing, refactoring across multiple modules.
18+
19+
## Acceptance Criteria
20+
- [ ] All constants added to `const.py` with correct types and values
21+
- [ ] All magic number occurrences replaced in `client.py`, `cli.py`, and TUI modules
22+
- [ ] New constants added to `__all__` in `__init__.py`
23+
- [ ] `uv run ruff check .` passes
24+
- [ ] `uv run mypy src/` passes
25+
- [ ] `uv run pytest` passes
26+
27+
## Technical Requirements
28+
29+
Add these constants to `src/flameconnect/const.py`:
30+
- `DEFAULT_TARGET_TEMPERATURE: float = 22.0`
31+
- `MAX_FLAME_SPEED: int = 5`
32+
- `MIN_FLAME_SPEED: int = 1`
33+
- `MAX_TIMER_DURATION: int = 480`
34+
- `MAX_BOOST_DURATION: int = 20`
35+
- `MIN_BOOST_DURATION: int = 1`
36+
- `MIN_TEMP_CELSIUS: float = 5.0`
37+
- `MAX_TEMP_CELSIUS: float = 35.0`
38+
- `MIN_TEMP_FAHRENHEIT: float = 40.0`
39+
- `MAX_TEMP_FAHRENHEIT: float = 95.0`
40+
- `DEFAULT_TIMER_DURATION: int = 60`
41+
42+
Replace occurrences at these locations:
43+
- `22.0` in `client.py:296`, `client.py:325`, `cli.py:480`
44+
- `5`/`1` (flame speed) in `cli.py:492`, `tui/widgets.py:169`, `tui/flame_speed_screen.py:73`
45+
- `480` in `tui/timer_screen.py:85,100`
46+
- `20`/`1` (boost) in `cli.py:543`, `tui/heat_mode_screen.py:101,138`
47+
- `5.0/35.0` in `tui/temperature_screen.py:89,119`
48+
- `40.0/95.0` in `tui/temperature_screen.py:89,121`
49+
- `60` in `tui/app.py:851`
50+
51+
Add each new constant to `__all__` in `__init__.py`.
52+
53+
## Input Dependencies
54+
None — this is a standalone task.
55+
56+
## Output Artifacts
57+
Named constants in `const.py` that other tasks can import.
58+
59+
## Implementation Notes
60+
- Be careful with `5` — only replace where it refers to max flame speed, not other uses of the number 5.
61+
- The `60` in `tui/app.py:851` is `current.duration or 60` — replace `60` with `DEFAULT_TIMER_DURATION`.
62+
- Line numbers are approximate; search for the patterns rather than relying on exact lines.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
id: 2
3+
group: "dynamic-typing-hardening"
4+
dependencies: []
5+
status: "pending"
6+
created: 2026-03-01
7+
skills:
8+
- "python-typing"
9+
- "refactoring"
10+
---
11+
# Add kebab_name() and Display Name Overrides to models.py
12+
13+
## Objective
14+
Add the `kebab_name()` utility and centralized display-name overrides to `models.py`, then update `display_name()` to use the overrides. This provides the foundation for Components 2 and 6 of the plan.
15+
16+
## Skills Required
17+
Python IntEnum utilities, refactoring.
18+
19+
## Acceptance Criteria
20+
- [ ] `kebab_name(value: IntEnum) -> str` added to `models.py` — converts member name to lowercase kebab-case (e.g., `YELLOW_RED``"yellow-red"`)
21+
- [ ] `_DISPLAY_OVERRIDES` dict added to `models.py` with entries for `FireMode.MANUAL``"On"`, `FlameColor.YELLOW_RED``"Yellow/Red"`, `FlameColor.YELLOW_BLUE``"Yellow/Blue"`, `FlameColor.BLUE_RED``"Blue/Red"`
22+
- [ ] `display_name()` updated to check `_DISPLAY_OVERRIDES` first, then fall back to existing logic
23+
- [ ] Both `kebab_name` and updated `display_name` added/kept in `__all__` in `__init__.py`
24+
- [ ] `uv run ruff check .` passes
25+
- [ ] `uv run mypy src/` passes
26+
- [ ] `uv run pytest` passes (existing tests for `display_name()` must still pass; some may need updating if they test overridden values)
27+
28+
## Technical Requirements
29+
30+
In `src/flameconnect/models.py`:
31+
32+
1. Add `kebab_name()`:
33+
- Signature: `def kebab_name(value: IntEnum) -> str`
34+
- Implementation: `return value.name.lower().replace("_", "-")`
35+
36+
2. Add `_DISPLAY_OVERRIDES: dict[IntEnum, str]` (private, not exported):
37+
- `FireMode.MANUAL: "On"`
38+
- `FlameColor.YELLOW_RED: "Yellow/Red"`
39+
- `FlameColor.YELLOW_BLUE: "Yellow/Blue"`
40+
- `FlameColor.BLUE_RED: "Blue/Red"`
41+
42+
3. Update `display_name()` to:
43+
- Check `_DISPLAY_OVERRIDES.get(value)` first
44+
- Fall back to `value.name.replace("_", " ").title()`
45+
46+
4. Add `kebab_name` to `__all__` in `__init__.py`.
47+
48+
## Input Dependencies
49+
None — this is a standalone task.
50+
51+
## Output Artifacts
52+
- `kebab_name()` function for use by Task 03 (auto-generated lookups)
53+
- Updated `display_name()` with overrides for use by Task 05 (display consolidation)
54+
55+
## Implementation Notes
56+
- `display_name` is already in `__all__`, just add `kebab_name`.
57+
- Existing tests for `display_name()` may test values like `display_name(FireMode.MANUAL)` expecting `"Manual"` — these tests must be updated to expect `"On"` since the override now applies.
58+
- The `_DISPLAY_OVERRIDES` dict uses `IntEnum` as the key type to satisfy mypy strict.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
id: 3
3+
group: "dynamic-typing-hardening"
4+
dependencies: [2]
5+
status: "pending"
6+
created: 2026-03-01
7+
skills:
8+
- "python-typing"
9+
- "refactoring"
10+
---
11+
# Auto-Generate String-to-Enum Lookups in CLI
12+
13+
## Objective
14+
Replace the 5 hand-maintained `dict[str, EnumType]` lookup tables in `cli.py` with auto-generated equivalents using `kebab_name()` from `models.py`.
15+
16+
## Skills Required
17+
Python IntEnum, dict comprehensions, refactoring.
18+
19+
## Acceptance Criteria
20+
- [ ] All 5 lookup dicts replaced with `kebab_name()`-based generation
21+
- [ ] `_HEAT_MODE_LOOKUP` uses explicit member list (NORMAL, BOOST, ECO only)
22+
- [ ] `_PULSATING_LOOKUP`, `_FLAME_COLOR_LOOKUP`, `_MEDIA_THEME_LOOKUP`, `_TEMP_UNIT_LOOKUP` are fully auto-generated
23+
- [ ] CLI behavior is identical (same valid string values accepted, same errors on invalid)
24+
- [ ] `uv run ruff check .` passes
25+
- [ ] `uv run mypy src/` passes
26+
- [ ] `uv run pytest` passes
27+
28+
## Technical Requirements
29+
30+
In `src/flameconnect/cli.py`, replace each lookup dict:
31+
32+
1. `_HEAT_MODE_LOOKUP``{kebab_name(m): m for m in (HeatMode.NORMAL, HeatMode.BOOST, HeatMode.ECO)}`
33+
- Must NOT include `FAN_ONLY` or `SCHEDULE`
34+
35+
2. `_PULSATING_LOOKUP``{kebab_name(m): m for m in PulsatingEffect}`
36+
37+
3. `_FLAME_COLOR_LOOKUP``{kebab_name(m): m for m in FlameColor}`
38+
39+
4. `_MEDIA_THEME_LOOKUP``{kebab_name(m): m for m in MediaTheme}`
40+
41+
5. `_TEMP_UNIT_LOOKUP``{kebab_name(m): m for m in TempUnit}`
42+
43+
Import `kebab_name` from `flameconnect.models`.
44+
45+
## Input Dependencies
46+
Task 02 must be complete — provides `kebab_name()` in `models.py`.
47+
48+
## Output Artifacts
49+
Auto-generated lookup dicts in `cli.py`.
50+
51+
## Implementation Notes
52+
- Verify that the auto-generated keys exactly match the current manual keys. For example, `FlameColor.YELLOW_RED``kebab_name()``"yellow-red"` which matches the existing `"yellow-red"` key.
53+
- The `_PULSATING_LOOKUP` currently has keys `"on"` and `"off"` which match `PulsatingEffect.ON` and `PulsatingEffect.OFF` via `kebab_name()`.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
id: 4
3+
group: "dynamic-typing-hardening"
4+
dependencies: []
5+
status: "pending"
6+
created: 2026-03-01
7+
skills:
8+
- "python-typing"
9+
- "refactoring"
10+
---
11+
# Replace Unnamed Tuples with NamedTuples and Add TypedDicts
12+
13+
## Objective
14+
Replace all unnamed tuples used as structured data with NamedTuples, and replace plain dicts with known schemas with TypedDicts/NamedTuples. Also narrow the `_request()` method parameter type. Covers Components 3 and 4 of the plan.
15+
16+
## Skills Required
17+
Python NamedTuple, TypedDict, Literal typing, refactoring.
18+
19+
## Acceptance Criteria
20+
- [ ] `_FlameEffectSetter` NamedTuple in `cli.py` replaces `tuple[str, dict[str, object], str]`
21+
- [ ] `_ControlCommand` NamedTuple in `tui/app.py` replaces `tuple[str, str, str]`
22+
- [ ] `FormattedParam` NamedTuple in `tui/widgets.py` replaces `tuple[str, str, str | None]` return type
23+
- [ ] `_ThemeLabel` NamedTuple in `tui/media_theme_screen.py` replaces `tuple[str, str]`
24+
- [ ] `_ColorLabel` NamedTuple in `tui/flame_color_screen.py` replaces `tuple[str, str]`
25+
- [ ] `_PresetCol` NamedTuple in `tui/color_screen.py` replaces `tuple[str, str, str, str]`
26+
- [ ] `_B2CLoginFields` NamedTuple in `b2c_login.py` replaces dict return of `_parse_login_page()`
27+
- [ ] `_WireParam` TypedDict in `client.py` replaces `dict[str, Any]`
28+
- [ ] `_request()` method parameter changed to `Literal["GET", "POST"]`
29+
- [ ] All callers updated (attribute access instead of dict key access for B2CLoginFields)
30+
- [ ] `uv run ruff check .` passes
31+
- [ ] `uv run mypy src/` passes
32+
- [ ] `uv run pytest` passes
33+
34+
## Technical Requirements
35+
36+
### NamedTuples to add:
37+
38+
**In `cli.py`:**
39+
```
40+
class _FlameEffectSetter(NamedTuple):
41+
field: str
42+
lookup: dict[str, object]
43+
label: str
44+
```
45+
Update `_FLAME_EFFECT_SETTERS` dict values to use `_FlameEffectSetter(...)`.
46+
47+
**In `tui/app.py`:**
48+
```
49+
class _ControlCommand(NamedTuple):
50+
name: str
51+
help_text: str
52+
action: str
53+
```
54+
Update `_CONTROL_COMMANDS` list entries.
55+
56+
**In `tui/widgets.py`:**
57+
```
58+
class FormattedParam(NamedTuple):
59+
label: str
60+
value: str
61+
action: str | None
62+
```
63+
Update `format_parameters()` return type annotation and all return points.
64+
65+
**In `tui/media_theme_screen.py`:**
66+
```
67+
class _ThemeLabel(NamedTuple):
68+
label: str
69+
hotkey: str
70+
```
71+
Update `_THEME_LABELS` dict values.
72+
73+
**In `tui/flame_color_screen.py`:**
74+
```
75+
class _ColorLabel(NamedTuple):
76+
label: str
77+
hotkey: str
78+
```
79+
Update `_COLOR_LABELS` dict values.
80+
81+
**In `tui/color_screen.py`:**
82+
```
83+
class _PresetCol(NamedTuple):
84+
key: str
85+
label: str
86+
dark_name: str
87+
light_name: str
88+
```
89+
Update `_PRESET_COLS` list entries.
90+
91+
### TypedDict and NamedTuple for dicts:
92+
93+
**In `b2c_login.py`:**
94+
```
95+
class _B2CLoginFields(NamedTuple):
96+
csrf: str
97+
tx: str
98+
p: str
99+
post_url: str
100+
confirmed_url: str
101+
```
102+
Update `_parse_login_page()` to return `_B2CLoginFields(...)` and all callers to use attribute access (`fields.csrf` instead of `fields["csrf"]`).
103+
104+
**In `client.py`:**
105+
```
106+
class _WireParam(TypedDict):
107+
ParameterId: int
108+
Value: str
109+
```
110+
Update `wire_params` annotation to `list[_WireParam]`.
111+
112+
**In `client.py`:**
113+
Change `_request(self, method: str, ...)` to `_request(self, method: Literal["GET", "POST"], ...)`. Import `Literal` from `typing`.
114+
115+
## Input Dependencies
116+
None — this is a standalone task.
117+
118+
## Output Artifacts
119+
NamedTuples and TypedDicts in place across multiple modules.
120+
121+
## Implementation Notes
122+
- NamedTuples support positional unpacking, so `for label, value, action in fields` still works.
123+
- NamedTuples support indexing, so `result[0][1]` in tests still works.
124+
- `_B2CLoginFields` callers in `b2c_login.py` currently use `fields["csrf"]`, `fields["tx"]`, etc. — switch to `fields.csrf`, `fields.tx`, etc.
125+
- `_WireParam` is a `TypedDict`, which is a plain dict at runtime, so JSON serialization is unaffected.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
id: 5
3+
group: "dynamic-typing-hardening"
4+
dependencies: [2, 4]
5+
status: "pending"
6+
created: 2026-03-01
7+
skills:
8+
- "python-typing"
9+
- "refactoring"
10+
---
11+
# Consolidate Display Overrides and Add Field Validation Asserts
12+
13+
## Objective
14+
Remove duplicate display-name override dicts from `cli.py` and `tui/widgets.py`, replacing them with the centralized `display_name()` overrides from Task 02. Add module-level asserts to validate stringly-typed field access patterns. Covers Components 5 and 6 of the plan.
15+
16+
## Skills Required
17+
Python dataclasses introspection, refactoring.
18+
19+
## Acceptance Criteria
20+
- [ ] `_FIRE_MODE_DISPLAY` removed from `cli.py`; callers use `display_name()` directly
21+
- [ ] `_FLAME_COLOR_DISPLAY` removed from `cli.py`; callers use `display_name()` directly
22+
- [ ] `_MODE_DISPLAY` removed from `tui/widgets.py`; callers use `display_name()` directly
23+
- [ ] Module-level assert in `cli.py` validates `_FEATURE_LABELS` field names against `FireFeatures`
24+
- [ ] Module-level assert in `cli.py` validates `_FLAME_EFFECT_SETTERS` field names against `FlameEffectParam`
25+
- [ ] `uv run ruff check .` passes
26+
- [ ] `uv run mypy src/` passes
27+
- [ ] `uv run pytest` passes
28+
29+
## Technical Requirements
30+
31+
### Display Override Consolidation (Component 6)
32+
33+
**In `cli.py`:**
34+
- Remove `_FIRE_MODE_DISPLAY` dict (currently `{FireMode.MANUAL: "On"}`)
35+
- Remove `_FLAME_COLOR_DISPLAY` dict (currently maps 3 slash-separated FlameColor names)
36+
- Find all places that use these dicts (e.g., `_FIRE_MODE_DISPLAY.get(mode, display_name(mode))`) and replace with just `display_name(mode)` since the overrides are now baked into `display_name()`.
37+
38+
**In `tui/widgets.py`:**
39+
- Remove `_MODE_DISPLAY` dict (currently `{FireMode.STANDBY: "Standby", FireMode.MANUAL: "On"}`)
40+
- Find all places that use `_MODE_DISPLAY.get(...)` and replace with `display_name(...)`.
41+
42+
### Field Validation Asserts (Component 5)
43+
44+
**In `cli.py`, after `_FEATURE_LABELS` definition:**
45+
```python
46+
assert {fn for fn, _ in _FEATURE_LABELS} == {
47+
f.name for f in dataclasses.fields(FireFeatures)
48+
}, "_FEATURE_LABELS field names do not match FireFeatures fields"
49+
```
50+
This requires importing `dataclasses` and `FireFeatures`.
51+
52+
**In `cli.py`, after `_FLAME_EFFECT_SETTERS` definition:**
53+
```python
54+
assert all(
55+
setter.field in {f.name for f in dataclasses.fields(FlameEffectParam)}
56+
for setter in _FLAME_EFFECT_SETTERS.values()
57+
), "_FLAME_EFFECT_SETTERS contains invalid FlameEffectParam field names"
58+
```
59+
This requires importing `FlameEffectParam`. Note: after Task 04, `_FLAME_EFFECT_SETTERS` uses `_FlameEffectSetter` NamedTuple, so access is `setter.field`.
60+
61+
## Input Dependencies
62+
- Task 02: Provides centralized `display_name()` with overrides in `models.py`
63+
- Task 04: Provides `_FlameEffectSetter` NamedTuple (so asserts use `.field` attribute)
64+
65+
## Output Artifacts
66+
- Cleaned up `cli.py` and `tui/widgets.py` without duplicate override dicts
67+
- Module-level validation asserts for field-name strings
68+
69+
## Implementation Notes
70+
- Search for all uses of `_FIRE_MODE_DISPLAY`, `_FLAME_COLOR_DISPLAY`, and `_MODE_DISPLAY` to ensure none are missed.
71+
- The assert for `_FEATURE_LABELS` uses set equality (`==`) to also catch extra fields in `_FEATURE_LABELS` that don't exist in `FireFeatures`.
72+
- The assert for `_FLAME_EFFECT_SETTERS` uses subset check (`in`) since the setters only cover a subset of `FlameEffectParam` fields (not all fields have setters).

0 commit comments

Comments
 (0)