Coverage for src / rendercv / schema / sample_generator.py: 100%

93 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-25 13:49 +0000

1import io 

2import json 

3import pathlib 

4import re 

5from typing import overload 

6 

7import ruamel.yaml 

8import ruamel.yaml.nodes 

9import ruamel.yaml.representer 

10 

11from rendercv import __version__ 

12from rendercv.exception import RenderCVUserError 

13 

14from .models.cv.cv import Cv 

15from .models.design.built_in_design import available_themes, built_in_design_adapter 

16from .models.locale.locale import available_locales, locale_adapter 

17from .models.rendercv_model import RenderCVModel 

18from .rendercv_model_builder import read_yaml 

19 

20 

21def comment_out_section_sub_fields( 

22 yaml_string: str, *, section_header: str, next_section_header: str 

23) -> str: 

24 """Comment out YAML sub-fields between a section header and the next section. 

25 

26 Why: 

27 Sample YAML files show design and locale options as comments so beginners 

28 see the structure without being overwhelmed. This splits on exact section 

29 boundaries, comments the lines between them, then reassembles. 

30 

31 Args: 

32 yaml_string: Full YAML string. 

33 section_header: The section header line(s) to find (e.g., "design:\\n theme: classic\\n"). 

34 next_section_header: The next section header that ends this section's sub-fields. 

35 

36 Returns: 

37 YAML string with sub-fields of the target section commented out. 

38 """ 

39 before_section, _, after_section_header = yaml_string.partition(section_header) 

40 sub_fields, _, rest = after_section_header.partition(next_section_header) 

41 

42 commented_lines = [ 

43 f" {line.replace(' ', '# ', 1)}" 

44 for line in sub_fields.splitlines(keepends=False) 

45 ] 

46 

47 return ( 

48 before_section 

49 + section_header 

50 + "\n".join(commented_lines) 

51 + "\n" 

52 + next_section_header 

53 + rest 

54 ) 

55 

56 

57def expand_nested_bullets(yaml_string: str) -> str: 

58 """Expand inline nested bullets in YAML list items to indented sub-items. 

59 

60 Why: 

61 YAML dumper renders highlights like ``"Main point - Sub point"`` as a 

62 single string. Users expect nested bullets to appear as indented sub-items 

63 in the generated sample file for readability. 

64 

65 Args: 

66 yaml_string: YAML string with potential inline nested bullets. 

67 

68 Returns: 

69 YAML string with nested bullets expanded to indented sub-items. 

70 """ 

71 return "\n".join( 

72 ( 

73 re.sub(r"(?<! ) - (?! )", "\n - ", line) 

74 if re.match(r"\s+- ", line) 

75 else line 

76 ) 

77 for line in yaml_string.split("\n") 

78 ) 

79 

80 

81def dictionary_to_yaml(dictionary: dict) -> str: 

82 """Convert dictionary to formatted YAML string with multiline preservation. 

83 

84 Why: 

85 Sample YAML generation must produce readable output with proper 

86 formatting for multiline strings. Custom representer ensures 

87 bullet points and descriptions use pipe syntax. 

88 

89 Args: 

90 dictionary: Data structure to convert. 

91 

92 Returns: 

93 Formatted YAML string. 

94 """ 

95 

96 # Source: https://gist.github.com/alertedsnake/c521bc485b3805aa3839aef29e39f376 

97 def str_representer( 

98 dumper: ruamel.yaml.representer.Representer, data: str 

99 ) -> ruamel.yaml.nodes.ScalarNode: 

100 if len(data.splitlines()) > 1: # check for multiline string 

101 return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") 

102 return dumper.represent_scalar("tag:yaml.org,2002:str", data) 

103 

104 yaml_object = ruamel.yaml.YAML() 

105 yaml_object.encoding = "utf-8" 

106 yaml_object.width = 9999 

107 yaml_object.indent(mapping=2, sequence=4, offset=2) 

108 yaml_object.representer.add_representer(str, str_representer) 

109 

110 with io.StringIO() as string_stream: 

111 yaml_object.dump(dictionary, string_stream) 

112 return string_stream.getvalue() 

113 

114 

115def create_sample_rendercv_pydantic_model( 

116 *, name: str = "John Doe", theme: str = "classic", locale: str = "english" 

117) -> RenderCVModel: 

118 """Build sample CV model from sample content. 

119 

120 Why: 

121 New users need working examples to understand structure. Sample content 

122 provides realistic content that validates successfully and renders 

123 to all output formats without errors. 

124 

125 Args: 

126 name: Person's full name. 

127 theme: Design theme identifier. 

128 locale: Locale language identifier. 

129 

130 Returns: 

131 Validated model with sample content. 

132 """ 

133 sample_content = pathlib.Path(__file__).parent / "sample_content.yaml" 

134 sample_content_dictionary = read_yaml(sample_content)["cv"] 

135 cv = Cv(**sample_content_dictionary) 

136 

137 cv.name = name 

138 

139 design = built_in_design_adapter.validate_python({"theme": theme}) 

140 validated_locale = locale_adapter.validate_python({"language": locale}) 

141 

142 return RenderCVModel(cv=cv, design=design, locale=validated_locale) 

143 

144 

145@overload 

146def create_sample_yaml_input_file( 

147 *, 

148 file_path: None, 

149 name: str = "John Doe", 

150 theme: str = "classic", 

151 locale: str = "english", 

152) -> str: ... 

153@overload 

154def create_sample_yaml_input_file( 

155 *, 

156 file_path: pathlib.Path, 

157 name: str = "John Doe", 

158 theme: str = "classic", 

159 locale: str = "english", 

160) -> None: ... 

161def create_sample_yaml_input_file( 

162 *, 

163 file_path: pathlib.Path | None = None, 

164 name: str = "John Doe", 

165 theme: str = "classic", 

166 locale: str = "english", 

167) -> str | None: 

168 """Generate formatted sample YAML with schema hint and commented design options. 

169 

170 Why: 

171 New command provides users with immediately usable CV templates. 

172 JSON schema hint enables IDE autocompletion, commented design 

173 fields show customization options without overwhelming beginners. 

174 

175 Example: 

176 ```py 

177 yaml_content = create_sample_yaml_input_file( 

178 file_path=pathlib.Path("John_Doe_CV.yaml"), name="John Doe", theme="classic" 

179 ) 

180 # File written with schema hint, cv content, and commented design 

181 ``` 

182 

183 Args: 

184 file_path: Optional path to write file. 

185 name: Person's full name. 

186 theme: Design theme identifier. 

187 locale: Language/date format identifier. 

188 

189 Returns: 

190 YAML string if file_path is None, otherwise None after writing file. 

191 """ 

192 if theme not in available_themes: 

193 message = ( 

194 f"The theme {theme} is not available. The available themes are:" 

195 f" {available_themes}" 

196 ) 

197 raise RenderCVUserError(message) 

198 

199 if locale not in available_locales: 

200 message = ( 

201 f"The locale {locale} is not available. The available locales are:" 

202 f" {available_locales}. \n\nBut you can continue with `English`, and then" 

203 " write your own `locale` field in the input file." 

204 ) 

205 raise RenderCVUserError(message) 

206 

207 data_model = create_sample_rendercv_pydantic_model( 

208 name=name, theme=theme, locale=locale 

209 ) 

210 

211 data_model_as_dictionary = rendercv_model_to_dictionary(data_model) 

212 

213 yaml_string = expand_nested_bullets(dictionary_to_yaml(data_model_as_dictionary)) 

214 

215 # Add a comment to the first line, for JSON Schema: 

216 schema_comment = ( 

217 "# yaml-language-server:" 

218 f" $schema=https://raw.githubusercontent.com/rendercv/rendercv/refs/tags/v{__version__}/schema.json\n" 

219 ) 

220 yaml_string = schema_comment + yaml_string 

221 

222 # Comment out design and locale sub-fields so beginners see the structure 

223 # without being overwhelmed by configuration options. 

224 yaml_string = comment_out_section_sub_fields( 

225 yaml_string, 

226 section_header=f"design:\n theme: {theme}\n", 

227 next_section_header=f"locale:\n language: {locale}\n", 

228 ) 

229 yaml_string = comment_out_section_sub_fields( 

230 yaml_string, 

231 section_header=f"locale:\n language: {locale}\n", 

232 next_section_header="settings:\n", 

233 ) 

234 

235 if file_path is not None: 

236 file_path.write_text(yaml_string, encoding="utf-8") 

237 

238 return yaml_string 

239 

240 

241def rendercv_model_to_dictionary(data_model: RenderCVModel) -> dict: 

242 """Convert a RenderCVModel to a plain dictionary via JSON serialization. 

243 

244 Why: 

245 The YAML library sometimes has problems with the dictionary returned by 

246 Pydantic's model_dump(). Converting through JSON first produces clean 

247 Python dicts that serialize reliably. 

248 

249 Args: 

250 data_model: Validated RenderCV model. 

251 

252 Returns: 

253 Plain dictionary representation of the model. 

254 """ 

255 data_model_as_json = data_model.model_dump_json( 

256 exclude_none=False, 

257 by_alias=True, 

258 ) 

259 return json.loads(data_model_as_json) 

260 

261 

262@overload 

263def create_sample_yaml_file( 

264 *, 

265 dictionary: dict, 

266 file_path: None, 

267) -> str: ... 

268@overload 

269def create_sample_yaml_file( 

270 *, 

271 dictionary: dict, 

272 file_path: pathlib.Path, 

273) -> None: ... 

274def create_sample_yaml_file( 

275 *, 

276 dictionary: dict, 

277 file_path: pathlib.Path | None = None, 

278) -> str | None: 

279 """Convert a dictionary to formatted YAML and optionally write to file. 

280 

281 Why: 

282 Multiple sample file generators share the same conversion and 

283 file-writing logic. Centralizing it avoids duplication across 

284 create_sample_cv_file, create_sample_design_file, etc. 

285 

286 Args: 

287 dictionary: Data structure to convert to YAML. 

288 file_path: Optional path to write file. 

289 

290 Returns: 

291 YAML string if file_path is None, otherwise None after writing file. 

292 """ 

293 yaml_string = expand_nested_bullets(dictionary_to_yaml(dictionary)) 

294 

295 if file_path is not None: 

296 file_path.write_text(yaml_string, encoding="utf-8") 

297 

298 return yaml_string 

299 

300 

301@overload 

302def create_sample_cv_file( 

303 *, 

304 file_path: None, 

305 name: str = "John Doe", 

306) -> str: ... 

307@overload 

308def create_sample_cv_file( 

309 *, 

310 file_path: pathlib.Path, 

311 name: str = "John Doe", 

312) -> None: ... 

313def create_sample_cv_file( 

314 *, 

315 file_path: pathlib.Path | None = None, 

316 name: str = "John Doe", 

317) -> str | None: 

318 """Generate a sample YAML file containing only the CV section. 

319 

320 Why: 

321 Standalone CV files let users focus on content separately from 

322 design, locale, and settings configuration. 

323 

324 Args: 

325 file_path: Optional path to write file. 

326 name: Person's full name. 

327 

328 Returns: 

329 YAML string if file_path is None, otherwise None after writing file. 

330 """ 

331 data_model = create_sample_rendercv_pydantic_model(name=name) 

332 dictionary = rendercv_model_to_dictionary(data_model) 

333 return create_sample_yaml_file( 

334 dictionary={"cv": dictionary["cv"]}, file_path=file_path 

335 ) 

336 

337 

338@overload 

339def create_sample_design_file( 

340 *, 

341 file_path: None, 

342 theme: str = "classic", 

343) -> str: ... 

344@overload 

345def create_sample_design_file( 

346 *, 

347 file_path: pathlib.Path, 

348 theme: str = "classic", 

349) -> None: ... 

350def create_sample_design_file( 

351 *, 

352 file_path: pathlib.Path | None = None, 

353 theme: str = "classic", 

354) -> str | None: 

355 """Generate a sample YAML file containing only the design section. 

356 

357 Why: 

358 Standalone design files let users explore all theme options 

359 without mixing in CV content or locale settings. 

360 

361 Args: 

362 file_path: Optional path to write file. 

363 theme: Design theme identifier. 

364 

365 Returns: 

366 YAML string if file_path is None, otherwise None after writing file. 

367 """ 

368 if theme not in available_themes: 

369 message = ( 

370 f"The theme {theme} is not available. The available themes are:" 

371 f" {available_themes}" 

372 ) 

373 raise RenderCVUserError(message) 

374 

375 data_model = create_sample_rendercv_pydantic_model(theme=theme) 

376 dictionary = rendercv_model_to_dictionary(data_model) 

377 return create_sample_yaml_file( 

378 dictionary={"design": dictionary["design"]}, file_path=file_path 

379 ) 

380 

381 

382@overload 

383def create_sample_locale_file( 

384 *, 

385 file_path: None, 

386 locale: str = "english", 

387) -> str: ... 

388@overload 

389def create_sample_locale_file( 

390 *, 

391 file_path: pathlib.Path, 

392 locale: str = "english", 

393) -> None: ... 

394def create_sample_locale_file( 

395 *, 

396 file_path: pathlib.Path | None = None, 

397 locale: str = "english", 

398) -> str | None: 

399 """Generate a sample YAML file containing only the locale section. 

400 

401 Why: 

402 Standalone locale files let users customize language and date 

403 formatting independently from CV content and design. 

404 

405 Args: 

406 file_path: Optional path to write file. 

407 locale: Language/date format identifier. 

408 

409 Returns: 

410 YAML string if file_path is None, otherwise None after writing file. 

411 """ 

412 if locale not in available_locales: 

413 message = ( 

414 f"The locale {locale} is not available. The available locales are:" 

415 f" {available_locales}. \n\nBut you can continue with `English`, and then" 

416 " write your own `locale` field in the input file." 

417 ) 

418 raise RenderCVUserError(message) 

419 

420 data_model = create_sample_rendercv_pydantic_model(locale=locale) 

421 dictionary = rendercv_model_to_dictionary(data_model) 

422 return create_sample_yaml_file( 

423 dictionary={"locale": dictionary["locale"]}, file_path=file_path 

424 ) 

425 

426 

427@overload 

428def create_sample_settings_file( 

429 *, 

430 file_path: None, 

431 omitted_fields: list[str] | None = None, 

432) -> str: ... 

433@overload 

434def create_sample_settings_file( 

435 *, 

436 file_path: pathlib.Path, 

437 omitted_fields: list[str] | None = None, 

438) -> None: ... 

439def create_sample_settings_file( 

440 *, 

441 file_path: pathlib.Path | None = None, 

442 omitted_fields: list[str] | None = None, 

443) -> str | None: 

444 """Generate a sample YAML file containing only the settings section. 

445 

446 Why: 

447 Standalone settings files let users configure render options 

448 independently from CV content, design, and locale. 

449 

450 Args: 

451 file_path: Optional path to write file. 

452 

453 Returns: 

454 YAML string if file_path is None, otherwise None after writing file. 

455 """ 

456 data_model = create_sample_rendercv_pydantic_model() 

457 dictionary = rendercv_model_to_dictionary(data_model) 

458 if omitted_fields is not None: 

459 for field in omitted_fields: 

460 dictionary["settings"].pop(field, None) 

461 return create_sample_yaml_file( 

462 dictionary={"settings": dictionary["settings"]}, file_path=file_path 

463 )