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

30 statements  

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

1import pathlib 

2 

3import ruamel.yaml 

4from ruamel.yaml.comments import CommentedMap 

5from ruamel.yaml.scanner import RoundTripScanner 

6 

7from rendercv.exception import RenderCVInternalError, RenderCVUserError 

8 

9 

10def read_yaml(file_path_or_contents: pathlib.Path | str) -> CommentedMap: 

11 """Parse YAML/JSON content from file path or string. 

12 

13 Why: 

14 Validation errors must point to exact YAML locations. CommentedMap 

15 preserves source coordinates that map Pydantic errors back to input 

16 lines, enabling user-friendly error tables showing exactly where 

17 mistakes occur in the input file. 

18 

19 Example: 

20 ```py 

21 data = read_yaml(pathlib.Path("cv.yaml")) 

22 name = data["cv"]["name"] # Regular dict access 

23 # Line info also available: data.lc.data["cv"][0] = (line, col) 

24 ``` 

25 

26 Args: 

27 file_path_or_contents: File path or raw YAML string. 

28 

29 Returns: 

30 Dictionary with line/column metadata for error reporting. 

31 """ 

32 if isinstance(file_path_or_contents, pathlib.Path): 

33 # Check if the file exists: 

34 if not file_path_or_contents.exists(): 

35 message = f"The input file `{file_path_or_contents}` doesn't exist!" 

36 raise RenderCVUserError(message) 

37 

38 # Check the file extension: 

39 accepted_extensions = [".yaml", ".yml", ".json", ".json5"] 

40 if file_path_or_contents.suffix not in accepted_extensions: 

41 message = ( 

42 "The input file should have one of the following extensions:" 

43 f" {', '.join(accepted_extensions)}. The input file is" 

44 f" {file_path_or_contents.name}." 

45 ) 

46 raise RenderCVUserError(message) 

47 

48 file_content = file_path_or_contents.read_text(encoding="utf-8") 

49 else: 

50 file_content = file_path_or_contents 

51 

52 yaml_as_dictionary: CommentedMap = yaml.load(file_content) 

53 

54 if yaml_as_dictionary is None: 

55 message = "The input file is empty!" 

56 raise RenderCVUserError(message) 

57 

58 if isinstance(yaml_as_dictionary, str): 

59 message = ( 

60 "You probably meant to pass a path to the YAML file, but you passed as a" 

61 " string and RenderCV interpreted it as the contents of the YAML file." 

62 f" Pass the path using `pathlib.Path({file_path_or_contents})`." 

63 ) 

64 raise RenderCVInternalError(message) 

65 

66 return yaml_as_dictionary 

67 

68 

69class ScannerNoAlias(RoundTripScanner): 

70 """Custom Scanner that treats * as a regular character instead of alias syntax. 

71 

72 Why: 

73 CV content frequently contains literal * characters (e.g., in Markdown bold 

74 syntax). Standard YAML interprets * as an alias indicator, causing parse 

75 errors. This subclass overrides alias handling to treat * as plain text. 

76 """ 

77 

78 def fetch_alias(self) -> None: 

79 """Treat * as a plain scalar character instead of alias syntax.""" 

80 self.fetch_plain() 

81 

82 

83yaml = ruamel.yaml.YAML() 

84yaml.Scanner = ScannerNoAlias 

85 

86# Disable ISO date parsing, keep it as a string: 

87yaml.constructor.yaml_constructors["tag:yaml.org,2002:timestamp"] = ( 

88 lambda loader, node: loader.construct_scalar(node) 

89)