Coverage for src / openenv / manifests / writer.py: 74.88%
145 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
1"""Manifest serialization helpers."""
3from __future__ import annotations
5import json
7from openenv.core.models import Manifest
10def render_manifest(manifest: Manifest) -> str:
11 """Serialize a manifest dataclass into TOML."""
12 lines: list[str] = [f"schema_version = {manifest.schema_version}", ""]
14 lines.extend(["[project]"])
15 lines.extend(_render_kv("name", manifest.project.name))
16 lines.extend(_render_kv("version", manifest.project.version))
17 lines.extend(_render_kv("description", manifest.project.description))
18 lines.extend(_render_kv("runtime", manifest.project.runtime))
19 lines.append("")
21 lines.extend(["[runtime]"])
22 lines.extend(_render_kv("base_image", manifest.runtime.base_image))
23 lines.extend(_render_kv("python_version", manifest.runtime.python_version))
24 lines.extend(_render_kv("system_packages", manifest.runtime.system_packages))
25 lines.extend(_render_kv("python_packages", manifest.runtime.python_packages))
26 lines.extend(_render_kv("node_packages", manifest.runtime.node_packages))
27 if manifest.runtime.env:
28 lines.append(f"env = {_render_inline_table(manifest.runtime.env)}")
29 lines.extend(_render_kv("user", manifest.runtime.user))
30 lines.extend(_render_kv("workdir", manifest.runtime.workdir))
31 lines.append("")
32 for secret in manifest.runtime.secret_refs: 32 ↛ 33line 32 didn't jump to line 33 because the loop on line 32 never started
33 lines.extend(["[[runtime.secret_refs]]"])
34 lines.extend(_render_kv("name", secret.name))
35 lines.extend(_render_kv("source", secret.source))
36 lines.extend(_render_kv("required", secret.required))
37 lines.append("")
39 lines.extend(["[agent]"])
40 lines.extend(
41 _render_agent_doc(
42 "agents_md",
43 manifest.agent.agents_md_ref,
44 manifest.agent.agents_md,
45 )
46 )
47 lines.extend(
48 _render_agent_doc(
49 "soul_md",
50 manifest.agent.soul_md_ref,
51 manifest.agent.soul_md,
52 )
53 )
54 lines.extend(
55 _render_agent_doc(
56 "user_md",
57 manifest.agent.user_md_ref,
58 manifest.agent.user_md,
59 )
60 )
61 if manifest.agent.identity_md is not None:
62 lines.extend(
63 _render_agent_doc(
64 "identity_md",
65 manifest.agent.identity_md_ref,
66 manifest.agent.identity_md,
67 )
68 )
69 if manifest.agent.tools_md is not None:
70 lines.extend(
71 _render_agent_doc(
72 "tools_md",
73 manifest.agent.tools_md_ref,
74 manifest.agent.tools_md,
75 )
76 )
77 if manifest.agent.memory_seed_ref is not None:
78 lines.extend(_render_kv("memory_seed", manifest.agent.memory_seed_ref))
79 else:
80 lines.extend(_render_kv("memory_seed", manifest.agent.memory_seed))
81 lines.append("")
83 for skill in manifest.skills:
84 lines.extend(["[[skills]]"])
85 lines.extend(_render_kv("name", skill.name))
86 lines.extend(_render_kv("description", skill.description))
87 if skill.source is not None: 87 ↛ 89line 87 didn't jump to line 89 because the condition on line 87 was always true
88 lines.extend(_render_kv("source", skill.source))
89 if skill.content is not None:
90 lines.extend(_render_kv("content", skill.content))
91 if skill.assets:
92 lines.append(f"assets = {_render_inline_table(skill.assets)}")
93 lines.append("")
95 if manifest.access.websites or manifest.access.databases or manifest.access.notes:
96 lines.extend(["[access]"])
97 lines.extend(_render_kv("websites", manifest.access.websites))
98 lines.extend(_render_kv("databases", manifest.access.databases))
99 lines.extend(_render_kv("notes", manifest.access.notes))
100 lines.append("")
102 lines.extend(["[openclaw]"])
103 lines.extend(_render_kv("agent_id", manifest.openclaw.agent_id))
104 lines.extend(_render_kv("agent_name", manifest.openclaw.agent_name))
105 lines.extend(_render_kv("workspace", manifest.openclaw.workspace))
106 lines.extend(_render_kv("state_dir", manifest.openclaw.state_dir))
107 lines.append("")
109 lines.extend(["[openclaw.sandbox]"])
110 lines.extend(_render_kv("mode", manifest.openclaw.sandbox.mode))
111 lines.extend(_render_kv("scope", manifest.openclaw.sandbox.scope))
112 lines.extend(_render_kv("workspace_access", manifest.openclaw.sandbox.workspace_access))
113 lines.extend(_render_kv("network", manifest.openclaw.sandbox.network))
114 lines.extend(_render_kv("read_only_root", manifest.openclaw.sandbox.read_only_root))
115 lines.append("")
117 lines.extend(["[openclaw.tools]"])
118 lines.extend(_render_kv("allow", manifest.openclaw.tools_allow))
119 lines.extend(_render_kv("deny", manifest.openclaw.tools_deny))
120 lines.append("")
121 if manifest.openclaw.channels:
122 lines.extend(_render_table("openclaw.channels", manifest.openclaw.channels))
123 lines.append("")
124 return "\n".join(lines)
127def _render_agent_doc(key: str, reference: str | None, content: str) -> list[str]:
128 """Serialize one agent markdown field, preferring a file reference when available."""
129 if reference is not None:
130 return _render_kv(key, reference)
131 return _render_kv(key, content)
134def _render_kv(key: str, value: object) -> list[str]:
135 """Render a single TOML key/value pair, including multiline string blocks."""
136 if isinstance(value, str):
137 if "\n" in value:
138 rendered = value.rstrip("\n")
139 return [f'{key} = """', rendered, '"""']
140 return [f"{key} = {json.dumps(value)}"]
141 if isinstance(value, bool):
142 return [f"{key} = {'true' if value else 'false'}"]
143 if isinstance(value, int) and not isinstance(value, bool): 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true
144 return [f"{key} = {value}"]
145 if isinstance(value, float): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 return [f"{key} = {json.dumps(value)}"]
147 if isinstance(value, list): 147 ↛ 152line 147 didn't jump to line 152 because the condition on line 147 was always true
148 if not value:
149 return [f"{key} = []"]
150 rendered_items = ", ".join(json.dumps(item) for item in value)
151 return [f"{key} = [{rendered_items}]"]
152 raise TypeError(f"Unsupported TOML value for {key}: {type(value)!r}")
155def _render_inline_table(values: dict[str, str]) -> str:
156 """Render a deterministic inline TOML table with JSON-style string escaping."""
157 rendered = ", ".join(
158 f"{json.dumps(key)} = {json.dumps(value)}" for key, value in sorted(values.items())
159 )
160 return "{ " + rendered + " }"
163def _render_table(path: str, values: dict[str, object]) -> list[str]:
164 """Render a nested TOML table with support for arrays of tables."""
165 lines = [f"[{path}]"]
166 nested_tables: list[tuple[str, dict[str, object]]] = []
167 table_arrays: list[tuple[str, list[dict[str, object]]]] = []
169 for key, value in values.items():
170 if isinstance(value, dict):
171 nested_tables.append((key, value))
172 continue
173 if _is_table_array(value): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 table_arrays.append((key, value))
175 continue
176 lines.extend(_render_kv(key, value))
178 for key, value in nested_tables:
179 lines.append("")
180 lines.extend(_render_table(f"{path}.{key}", value))
182 for key, entries in table_arrays: 182 ↛ 183line 182 didn't jump to line 183 because the loop on line 182 never started
183 for entry in entries:
184 lines.append("")
185 lines.extend(_render_table_array(f"{path}.{key}", entry))
187 return lines
190def _render_table_array(path: str, values: dict[str, object]) -> list[str]:
191 """Render one TOML array-of-tables entry recursively."""
192 lines = [f"[[{path}]]"]
193 nested_tables: list[tuple[str, dict[str, object]]] = []
194 table_arrays: list[tuple[str, list[dict[str, object]]]] = []
196 for key, value in values.items():
197 if isinstance(value, dict):
198 nested_tables.append((key, value))
199 continue
200 if _is_table_array(value):
201 table_arrays.append((key, value))
202 continue
203 lines.extend(_render_kv(key, value))
205 for key, value in nested_tables:
206 lines.append("")
207 lines.extend(_render_table(f"{path}.{key}", value))
209 for key, entries in table_arrays:
210 for entry in entries:
211 lines.append("")
212 lines.extend(_render_table_array(f"{path}.{key}", entry))
214 return lines
217def _is_table_array(value: object) -> bool:
218 """Return whether a TOML list should be rendered as an array of tables."""
219 return isinstance(value, list) and bool(value) and all(isinstance(item, dict) for item in value)