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

1"""Manifest serialization helpers.""" 

2 

3from __future__ import annotations 

4 

5import json 

6 

7from openenv.core.models import Manifest 

8 

9 

10def render_manifest(manifest: Manifest) -> str: 

11 """Serialize a manifest dataclass into TOML.""" 

12 lines: list[str] = [f"schema_version = {manifest.schema_version}", ""] 

13 

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("") 

20 

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("") 

38 

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("") 

82 

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("") 

94 

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("") 

101 

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("") 

108 

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("") 

116 

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) 

125 

126 

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) 

132 

133 

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}") 

153 

154 

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 + " }" 

161 

162 

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]]]] = [] 

168 

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)) 

177 

178 for key, value in nested_tables: 

179 lines.append("") 

180 lines.extend(_render_table(f"{path}.{key}", value)) 

181 

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)) 

186 

187 return lines 

188 

189 

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]]]] = [] 

195 

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)) 

204 

205 for key, value in nested_tables: 

206 lines.append("") 

207 lines.extend(_render_table(f"{path}.{key}", value)) 

208 

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)) 

213 

214 return lines 

215 

216 

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)