framesdkpy 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
framesdkpy/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """FRAME Python SDK v0.3.0.
2
+
3
+ The stable Python interface for reading FRAME project context files.
4
+ Tools should import from this package root when they only need the public API.
5
+ """
6
+
7
+ from framesdkpy.loaders import FrameLoadError, load_frame
8
+ from framesdkpy.models import FRAME, FrameActs, FrameExpect, FrameFacts, FrameMap, FrameRules
9
+ from framesdkpy.translators import (
10
+ translate_directory,
11
+ translate_file,
12
+ translate_to_dict,
13
+ translate_to_json_string,
14
+ )
15
+ from framesdkpy.validators import ValidationResult, validate_file, validate_frame
16
+
17
+ __all__ = [
18
+ "FRAME",
19
+ "FrameFacts",
20
+ "FrameRules",
21
+ "FrameMap",
22
+ "FrameExpect",
23
+ "FrameActs",
24
+ "load_frame",
25
+ "FrameLoadError",
26
+ "validate_frame",
27
+ "validate_file",
28
+ "ValidationResult",
29
+ "translate_file",
30
+ "translate_directory",
31
+ "translate_to_dict",
32
+ "translate_to_json_string",
33
+ ]
@@ -0,0 +1,79 @@
1
+ """FRAME loader -- reads YAML from a directory and returns a typed FRAME object.
2
+
3
+ The full pipeline: discover → parse → normalize → validate → assemble → return.
4
+
5
+ Public API:
6
+ load_frame(".haxaml/") → FRAME
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from framesdkpy.loaders.yaml_reader import discover_frame_dir, read_raw_yaml
14
+ from framesdkpy.loaders.assembler import assemble_frame, FrameLoadError
15
+ from framesdkpy.translators.normalizer import normalize_dict
16
+ from framesdkpy.validators import validate_against_schema, validate_limits
17
+
18
+
19
+ def load_frame(dir_path: str | Path) -> "FRAME":
20
+ """Load all 5 FRAME YAML files from a directory and return a typed FRAME object.
21
+
22
+ Pipeline:
23
+ 1. Discover 5 files in the directory (strict -- all must be present)
24
+ 2. Parse raw YAML into dicts
25
+ 3. Normalize each dict (YAML quirks → clean types)
26
+ 4. Validate each dict against JSON Schema + character limits
27
+ 5. Assemble into a typed FRAME object (cross-file checks, model construction)
28
+
29
+ Raises:
30
+ FileNotFoundError: Directory doesn't exist or a file is missing.
31
+ FrameLoadError: Validation failed (schema, limits, or cross-file).
32
+
33
+ Returns:
34
+ FRAME with facts populated and optional other parts.
35
+ """
36
+ from framesdkpy.models.frame_model import FRAME
37
+
38
+ # 1. Discover
39
+ files = discover_frame_dir(dir_path)
40
+
41
+ # 2. Parse raw YAML
42
+ raw = read_raw_yaml(files)
43
+
44
+ # 3-4. Normalize + validate each file
45
+ validated: dict[str, dict] = {}
46
+ all_errors: list = []
47
+ all_warnings: list = []
48
+
49
+ for stem, raw_dict in raw.items():
50
+ clean = normalize_dict(raw_dict)
51
+
52
+ # Schema validation
53
+ schema_result = validate_against_schema(clean, stem)
54
+ if not schema_result.is_valid():
55
+ all_errors.extend(schema_result.errors)
56
+ all_warnings.extend(schema_result.warnings)
57
+
58
+ # Character limit validation
59
+ limits_result = validate_limits(clean, stem)
60
+ if not limits_result.is_valid():
61
+ all_errors.extend(limits_result.errors)
62
+ all_warnings.extend(limits_result.warnings)
63
+
64
+ validated[stem] = clean
65
+
66
+ # Block on any validation errors
67
+ if all_errors:
68
+ raise FrameLoadError(
69
+ f"Validation failed with {len(all_errors)} error(s): "
70
+ + "; ".join(e.message for e in all_errors[:5]),
71
+ errors=all_errors,
72
+ warnings=all_warnings,
73
+ )
74
+
75
+ # 5. Assemble (includes cross-file check)
76
+ return assemble_frame(validated)
77
+
78
+
79
+ __all__ = ["load_frame", "FrameLoadError"]
@@ -0,0 +1,217 @@
1
+ """Assembler -- builds a typed FRAME object from 5 validated and normalized dicts.
2
+
3
+ Runs cross-file consistency checks, then constructs the five typed models
4
+ and composes them into one FRAME object.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from framesdkpy.models.frame_model import FRAME
10
+ from framesdkpy.models.facts_model import FrameFacts, Profile, Architecture, Technology, Source, Quirk, OpenQuestion
11
+ from framesdkpy.models.rules_model import FrameRules, Policy, CoreRule, Command, Dont, AskFirst, Hint
12
+ from framesdkpy.models.map_model import FrameMap, Group, PathEntry, Entrypoint, ManagedPath, UnmappedPath
13
+ from framesdkpy.models.expect_model import FrameExpect, MustHold, Check, Proof
14
+ from framesdkpy.models.acts_model import FrameActs, Run, RunCheck, Blocker
15
+
16
+ from framesdkpy.validators.cross_file_validator import validate_cross_file
17
+
18
+
19
+ class FrameLoadError(ValueError):
20
+ """Raised when a validated FRAME directory cannot be assembled.
21
+
22
+ Contains the validation result with all errors that prevented assembly.
23
+ """
24
+
25
+ def __init__(self, message: str, errors: list, warnings: list):
26
+ self.errors = errors
27
+ self.warnings = warnings
28
+ super().__init__(message)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Sub-model constructors -- each converts a normalized dict to a typed model
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ from dataclasses import fields as dc_fields
37
+
38
+ def _strip_extra(data: dict, model_class) -> dict:
39
+ """Keep only keys that match the model's field names."""
40
+ field_names = {f.name for f in dc_fields(model_class)}
41
+ return {k: v for k, v in data.items() if k in field_names}
42
+
43
+
44
+ def _build_facts(data: dict) -> FrameFacts:
45
+ prof = data.get("profile", {})
46
+ arch = data.get("architecture", {})
47
+ tech = data.get("technology")
48
+
49
+ return FrameFacts(
50
+ frame=data["frame"],
51
+ profile=Profile(
52
+ name=prof["name"],
53
+ summary=prof["summary"],
54
+ repo_shape=prof.get("repo_shape"),
55
+ delivery_family=prof.get("delivery_family"),
56
+ ),
57
+ architecture=Architecture(
58
+ summary=arch["summary"],
59
+ backend_layers=arch.get("backend_layers"),
60
+ frontend_layers=arch.get("frontend_layers"),
61
+ data_flow=arch.get("data_flow"),
62
+ deployment_topology=arch.get("deployment_topology"),
63
+ ),
64
+ technology=Technology(
65
+ language=tech.get("language") if tech else None,
66
+ framework=tech.get("framework") if tech else None,
67
+ database=tech.get("database") if tech else None,
68
+ extensions=tech.get("extensions") if tech else None,
69
+ ) if tech else None,
70
+ sources=[Source(**_strip_extra(s, Source)) for s in data.get("sources", [])],
71
+ quirks=[Quirk(**_strip_extra(q, Quirk)) for q in data.get("quirks", [])],
72
+ open_questions=[OpenQuestion(**_strip_extra(o, OpenQuestion)) for o in data.get("open_questions", [])],
73
+ classification=data.get("classification"),
74
+ environments=data.get("environments"),
75
+ persistence=data.get("persistence"),
76
+ evidence=data.get("evidence", []),
77
+ links=data.get("links", []),
78
+ )
79
+
80
+
81
+ def _build_rules(data: dict) -> FrameRules:
82
+ commands_dict: dict[str, Command] = {}
83
+
84
+ for name, cmd_data in data.get("commands", {}).items():
85
+ commands_dict[name] = Command(**_strip_extra(cmd_data, Command))
86
+
87
+ return FrameRules(
88
+ frame=data["frame"],
89
+ governance_level=data.get("governance_level", "normal"),
90
+ rules=[CoreRule(**_strip_extra(r, CoreRule)) for r in data.get("rules", [])],
91
+ policies=[Policy(**_strip_extra(p, Policy)) for p in data.get("policies", [])],
92
+ commands=commands_dict,
93
+ donts=[Dont(**_strip_extra(d, Dont)) for d in data.get("donts", [])],
94
+ ask_first=[AskFirst(**_strip_extra(a, AskFirst)) for a in data.get("ask_first", [])],
95
+ hints=[Hint(**_strip_extra(h, Hint)) for h in data.get("hints", [])],
96
+ code_style=data.get("code_style"),
97
+ git=data.get("git"),
98
+ evidence=data.get("evidence", []),
99
+ links=data.get("links", []),
100
+ )
101
+
102
+
103
+ def _build_map(data: dict) -> FrameMap:
104
+ """Build FrameMap from a normalized map dict."""
105
+ return FrameMap(
106
+ frame=data["frame"],
107
+ structure=data.get("structure"),
108
+ roots=data.get("roots"),
109
+ groups=[Group(**_strip_extra(g, Group)) for g in data.get("groups", [])],
110
+ paths=[PathEntry(**_strip_extra(p, PathEntry)) for p in data.get("paths", [])],
111
+ entrypoints=[Entrypoint(**_strip_extra(e, Entrypoint)) for e in data.get("entrypoints", [])],
112
+ managed_paths=[ManagedPath(**_strip_extra(m, ManagedPath)) for m in data.get("managed_paths", [])],
113
+ unmapped_paths=[UnmappedPath(**_strip_extra(u, UnmappedPath)) for u in data.get("unmapped_paths", [])],
114
+ evidence=data.get("evidence", []),
115
+ links=data.get("links", []),
116
+ )
117
+
118
+
119
+ def _build_expect(data: dict) -> FrameExpect:
120
+ """Build FrameExpect from a normalized expect dict."""
121
+ checks_dict: dict[str, Check] = {}
122
+ for name, chk_data in data.get("checks", {}).items():
123
+ checks_dict[name] = Check(**_strip_extra(chk_data, Check))
124
+
125
+ return FrameExpect(
126
+ frame=data["frame"],
127
+ outcomes=data.get("outcomes"),
128
+ must_hold=[MustHold(**_strip_extra(m, MustHold)) for m in data.get("must_hold", [])],
129
+ checks=checks_dict,
130
+ done_when=data.get("done_when"),
131
+ proof=[Proof(**_strip_extra(p, Proof)) for p in data.get("proof", [])],
132
+ handoff=data.get("handoff"),
133
+ evidence=data.get("evidence", []),
134
+ links=data.get("links", []),
135
+ )
136
+
137
+
138
+ def _build_acts(data: dict) -> FrameActs:
139
+ """Build FrameActs from a normalized acts dict."""
140
+ runs: list[Run] = []
141
+ for run_data in data.get("runs", []):
142
+ # Build nested RunCheck objects
143
+ checks_list: list[RunCheck] = []
144
+ for rc_data in run_data.get("checks", []) or []:
145
+ checks_list.append(RunCheck(**_strip_extra(rc_data, RunCheck)))
146
+
147
+ run_data_copy = {k: v for k, v in run_data.items() if k != "checks"}
148
+ runs.append(Run(checks=checks_list if checks_list else None, **_strip_extra(run_data_copy, Run)))
149
+
150
+ return FrameActs(
151
+ frame=data["frame"],
152
+ summary=data.get("summary"),
153
+ runs=runs,
154
+ blockers=[Blocker(**_strip_extra(b, Blocker)) for b in data.get("blockers", [])],
155
+ handoff=data.get("handoff"),
156
+ evidence=data.get("evidence", []),
157
+ links=data.get("links", []),
158
+ )
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Main assembly function
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ _BUILDERS = {
167
+ "facts": _build_facts,
168
+ "rules": _build_rules,
169
+ "map": _build_map,
170
+ "expect": _build_expect,
171
+ "acts": _build_acts,
172
+ }
173
+
174
+
175
+ def assemble_frame(parts: dict[str, dict]) -> FRAME:
176
+ """Build a typed FRAME object from 5 validated and normalized dicts.
177
+
178
+ Runs cross-file consistency check first. Raises FrameLoadError if
179
+ schema versions don't match or file/role fields are inconsistent.
180
+
181
+ Args:
182
+ parts: Dict mapping file stem to normalized dict.
183
+ e.g., {"facts": {...}, "rules": {...}, ...}
184
+
185
+ Returns:
186
+ FRAME object with all five typed parts.
187
+ """
188
+ required_parts = {"facts", "rules", "map", "expect", "acts"}
189
+ missing_parts = sorted(required_parts - set(parts))
190
+ if missing_parts:
191
+ raise FrameLoadError(
192
+ f"Missing FRAME part(s): {', '.join(missing_parts)}",
193
+ errors=[],
194
+ warnings=[],
195
+ )
196
+
197
+ # Cross-file consistency must pass before assembly
198
+ cross_result = validate_cross_file(parts)
199
+ if not cross_result.is_valid():
200
+ raise FrameLoadError(
201
+ f"Cross-file validation failed: {cross_result.summary()}",
202
+ errors=cross_result.errors,
203
+ warnings=cross_result.warnings,
204
+ )
205
+
206
+ built = {}
207
+ for stem in ["facts", "rules", "map", "expect", "acts"]:
208
+ builder = _BUILDERS[stem]
209
+ built[stem] = builder(parts[stem])
210
+
211
+ return FRAME(
212
+ facts=built["facts"],
213
+ rules=built["rules"],
214
+ map=built["map"],
215
+ expect=built["expect"],
216
+ acts=built["acts"],
217
+ )
@@ -0,0 +1,57 @@
1
+ """YAML reader -- discovers and parses FRAME YAML files from a directory.
2
+
3
+ Strict single-directory discovery: exactly 5 files must be present.
4
+ No fuzzy matching, no parent/sibling search, no scattered file pickup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+
14
+ # All 5 are discovered. Facts and Rules are required for any project.
15
+ # Map is required if the repo has structure; empty repos can omit it.
16
+ # Expect and Acts grow over time.
17
+ _EXPECTED_FILES = ["facts", "rules", "map", "expect", "acts"]
18
+
19
+
20
+ def discover_frame_dir(dir_path: str | Path) -> dict[str, Path]:
21
+ """Verify exactly 5 FRAME YAML files exist in a directory.
22
+
23
+ Returns a dict mapping file stem to resolved Path. Raises FileNotFoundError
24
+ if the directory doesn't exist or any expected file is missing.
25
+ """
26
+ directory = Path(dir_path).resolve()
27
+ if not directory.is_dir():
28
+ raise FileNotFoundError(f"FRAME directory not found: {directory}")
29
+
30
+ found: dict[str, Path] = {}
31
+ for stem in _EXPECTED_FILES:
32
+ yaml_path = directory / f"{stem}.yaml"
33
+ if not yaml_path.is_file():
34
+ raise FileNotFoundError(
35
+ f"Missing FRAME file: {stem}.yaml in {directory}. "
36
+ f"All 5 files (facts, rules, map, expect, acts) must be in the same directory."
37
+ )
38
+ found[stem] = yaml_path
39
+
40
+ return found
41
+
42
+
43
+ def read_raw_yaml(file_stems: dict[str, Path]) -> dict[str, dict]:
44
+ """Parse YAML files into raw Python dicts.
45
+
46
+ Returns a dict mapping file stem to raw parsed dict. YAML quirks (yes→True,
47
+ ~→None) are handled by the normalizer downstream -- this is raw parsing only.
48
+ """
49
+ raw: dict[str, dict] = {}
50
+ for stem, path in file_stems.items():
51
+ data = yaml.safe_load(path.read_text())
52
+ if data is None:
53
+ data = {}
54
+ if not isinstance(data, dict):
55
+ raise ValueError(f"Expected YAML object in {stem}.yaml, got {type(data).__name__}")
56
+ raw[stem] = data
57
+ return raw
@@ -0,0 +1,95 @@
1
+ """FRAME models -- typed data carriers for all five FRAME parts.
2
+
3
+ Primary import for downstream tools:
4
+ from framesdkpy.models import FRAME, FrameFacts, FrameRules, FrameMap, FrameExpect, FrameActs
5
+
6
+ Sub-models for deeper access:
7
+ from framesdkpy.models import Profile, Command, Check, Run, Blocker, etc.
8
+ """
9
+
10
+ from framesdkpy.models.frame_model import FRAME
11
+
12
+ from framesdkpy.models.facts_model import (
13
+ FrameFacts,
14
+ Profile,
15
+ Architecture,
16
+ Technology,
17
+ Source,
18
+ Quirk,
19
+ OpenQuestion,
20
+ )
21
+
22
+ from framesdkpy.models.rules_model import (
23
+ FrameRules,
24
+ Policy,
25
+ CoreRule,
26
+ Command,
27
+ Dont,
28
+ AskFirst,
29
+ Hint,
30
+ )
31
+
32
+ from framesdkpy.models.map_model import (
33
+ FrameMap,
34
+ Group,
35
+ PathEntry,
36
+ Entrypoint,
37
+ ManagedPath,
38
+ UnmappedPath,
39
+ )
40
+
41
+ from framesdkpy.models.expect_model import (
42
+ FrameExpect,
43
+ MustHold,
44
+ Check,
45
+ Proof,
46
+ )
47
+
48
+ from framesdkpy.models.acts_model import (
49
+ FrameActs,
50
+ Run,
51
+ RunCheck,
52
+ Blocker,
53
+ )
54
+
55
+ from framesdkpy.models.base import FrameBaseModel
56
+
57
+ # Everything a downstream tool needs, one import away
58
+ __all__ = [
59
+ # Top-level
60
+ "FRAME",
61
+ "FrameBaseModel",
62
+ # Facts
63
+ "FrameFacts",
64
+ "Profile",
65
+ "Architecture",
66
+ "Technology",
67
+ "Source",
68
+ "Quirk",
69
+ "OpenQuestion",
70
+ # Rules
71
+ "FrameRules",
72
+ "Policy",
73
+ "CoreRule",
74
+ "Command",
75
+ "Dont",
76
+ "AskFirst",
77
+ "Hint",
78
+ # Map
79
+ "FrameMap",
80
+ "Group",
81
+ "PathEntry",
82
+ "Entrypoint",
83
+ "ManagedPath",
84
+ "UnmappedPath",
85
+ # Expect
86
+ "FrameExpect",
87
+ "MustHold",
88
+ "Check",
89
+ "Proof",
90
+ # Acts
91
+ "FrameActs",
92
+ "Run",
93
+ "RunCheck",
94
+ "Blocker",
95
+ ]
@@ -0,0 +1,139 @@
1
+ """FrameActs model and sub-models -- checked activity record.
2
+
3
+ Mirrors schemas/json/acts.schema.json exactly.
4
+ Acts is run history -- what went in, what came out, what changed, what checks ran.
5
+ Size cap: 50KB. Exceeded → oldest runs auto-rotated to acts_archive/.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from framesdkpy.models.base import FrameBaseModel
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Sub-models
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class RunCheck(FrameBaseModel):
22
+ """A check considered or executed during a single run.
23
+
24
+ Collapsed from the old checks_seen/checks_ran split into one field with status.
25
+ status: ran (check was executed) or skipped (considered but not applicable).
26
+ result: only present when status=ran. pass or fail.
27
+ reason: only present when status=skipped. Why it was skipped.
28
+ """
29
+
30
+ id: str
31
+ """Ref to expect.checks.<name>. This is the check that was considered or executed."""
32
+
33
+ status: str
34
+ """Enum: ran, skipped. ran = executed, skipped = considered but not applicable."""
35
+
36
+ result: str | None = None
37
+ """Enum: pass, fail. Only set when status=ran."""
38
+
39
+ reason: str | None = None
40
+ """Why skipped. Only set when status=skipped. maxLength: 200."""
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class Run(FrameBaseModel):
45
+ """A single agent session run. Has an id for cross-referencing.
46
+
47
+ work_kind: tags for what kind of work was done (code, test, review, docs, deploy).
48
+ status: the overall run outcome. Pass means all checks passed.
49
+ Pass_with_risks means passed but with warnings. Fail means something broke.
50
+ Needs_clarification means the agent couldn't determine the outcome.
51
+ """
52
+
53
+ id: str
54
+ """Stable identifier. Used for cross-referencing from other Acts entries. maxLength: 100."""
55
+
56
+ actor: str
57
+ """Who or what did the work (agent name, model, or human). maxLength: 100."""
58
+
59
+ goal: str
60
+ """What the run was trying to accomplish. maxLength: 300."""
61
+
62
+ status: str
63
+ """Enum: pass, pass_with_risks, fail, needs_clarification."""
64
+
65
+ work_kind: list[str] | None = None
66
+ """Tags: code, test, review, docs, deploy."""
67
+
68
+ keywords: list[str] | None = None
69
+ """Topic tags for searchable retrieval."""
70
+
71
+ input_summary: str | None = None
72
+ """What went into the run. maxLength: 300."""
73
+
74
+ output_summary: str | None = None
75
+ """What came out. maxLength: 300."""
76
+
77
+ touched: list[str] | None = None
78
+ """Files, paths, or surfaces touched during the run."""
79
+
80
+ changed_facts: list[str] | None = None
81
+ """Facts that were modified during the run."""
82
+
83
+ rules_followed: list[str] | None = None
84
+ """Rule or policy ids that were applied."""
85
+
86
+ checks: list[RunCheck] | None = None
87
+ """Checks considered or executed this run. Each has status and optional result/reason."""
88
+
89
+ links: list[dict] = field(default_factory=list)
90
+ """Typed links from this entry to other FRAME refs."""
91
+
92
+
93
+ @dataclass(slots=True)
94
+ class Blocker(FrameBaseModel):
95
+ """Something preventing progress. Has an id for cross-referencing."""
96
+
97
+ id: str
98
+ """Stable identifier. maxLength: 100."""
99
+
100
+ description: str
101
+ """What is blocking progress. maxLength: 300."""
102
+
103
+ links: list[dict] = field(default_factory=list)
104
+ """Typed links from this entry to other FRAME refs."""
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Main model
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @dataclass(slots=True)
113
+ class FrameActs(FrameBaseModel):
114
+ """Run history -- what happened across agent sessions.
115
+
116
+ Populated from acts.yaml. Grows over time as agents work on the project.
117
+ Older runs auto-rotate to acts_archive/ when the file exceeds 50KB.
118
+ """
119
+
120
+ frame: dict = field(default_factory=dict)
121
+ """Shared FRAME header block. Required by every FRAME file."""
122
+
123
+ summary: str | None = None
124
+ """Quick overview of recent activity. maxLength: 500."""
125
+
126
+ runs: list[Run] = field(default_factory=list)
127
+ """Per-session run records. Each has a stable id for cross-referencing."""
128
+
129
+ blockers: list[Blocker] = field(default_factory=list)
130
+ """Things preventing progress. Each has a stable id."""
131
+
132
+ handoff: dict | None = None
133
+ """What the next agent needs to know. Free-form."""
134
+
135
+ evidence: list[dict] = field(default_factory=list)
136
+ """Evidence entries supporting Acts claims."""
137
+
138
+ links: list[dict] = field(default_factory=list)
139
+ """Typed links from this file to other FRAME refs."""
@@ -0,0 +1,64 @@
1
+ """Shared base class for all FRAME models.
2
+
3
+ Provides serialization (to_dict, to_json) and developer-friendly repr.
4
+ Every FRAME model inherits from FrameBaseModel. This ensures uniform
5
+ output format across all five parts regardless of which tool consumes them.
6
+
7
+ Null preservation: to_dict() keeps nulls in the output so the JSON shape
8
+ is always complete. Cross-language tools (frame-js, frame-cpp) can rely
9
+ on every key existing even if its value is null.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from dataclasses import dataclass, fields, is_dataclass
16
+ from typing import Any
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class FrameBaseModel:
21
+ """All FRAME models inherit this for uniform serialization.
22
+
23
+ Serialization preserves nulls -- if a field is None, the key appears in
24
+ the dict with a null value. This keeps the JSON shape consistent across
25
+ tools and languages.
26
+ """
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ """Recursive serialization to a JSON-compatible dict. Preserves nulls.
30
+
31
+ Nested FrameBaseModel instances and lists of models are handled
32
+ recursively. Regular dicts, lists, strings, and primitives pass through.
33
+ """
34
+ result: dict[str, Any] = {}
35
+ for field in fields(self):
36
+ value = getattr(self, field.name)
37
+ result[field.name] = self._serialize_value(value)
38
+ return result
39
+
40
+ def to_json(self, indent: int = 2) -> str:
41
+ """Serialize to a JSON string. Passes through to_dict() first."""
42
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
43
+
44
+ @staticmethod
45
+ def _serialize_value(value: Any) -> Any:
46
+ """Recursively convert a value to a JSON-safe representation.
47
+
48
+ - FrameBaseModel → call to_dict()
49
+ - list of models → list of dicts
50
+ - dict → recurse into values
51
+ - None, str, int, float, bool → pass through
52
+ """
53
+ if isinstance(value, FrameBaseModel):
54
+ return value.to_dict()
55
+ if isinstance(value, list):
56
+ return [FrameBaseModel._serialize_value(item) for item in value]
57
+ if isinstance(value, dict):
58
+ return {k: FrameBaseModel._serialize_value(v) for k, v in value.items()}
59
+ return value # Primitive: str, int, float, bool, None
60
+
61
+ def __repr__(self) -> str:
62
+ """Developer-friendly display showing class name and field count."""
63
+ field_names = [f.name for f in fields(self)]
64
+ return f"{type(self).__name__}({', '.join(field_names)})"