Coverage for src/configuraptor/dump.py: 100%

49 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2026-05-01 17:14 +0200

1""" 

2Method to dump classes to other formats. 

3""" 

4 

5import json 

6import typing 

7 

8import tomli_w 

9import yaml 

10 

11from .helpers import camel_to_snake, instance_of_custom_class, is_custom_class 

12from .loaders.register import register_dumper 

13 

14if typing.TYPE_CHECKING: # pragma: no cover 

15 from .binary_config import BinaryConfig 

16 

17PUBLIC = 0 # class.variable 

18PROTECTED = 1 # class._variable 

19PRIVATE = 2 # class.__variable 

20 

21T_Scope = typing.Literal[0, 1, 2] | bool 

22 

23 

24@register_dumper("dict") 

25def asdict( 

26 inst: typing.Any, _level: int = 0, /, with_top_level_key: bool = True, exclude_internals: T_Scope = 0 

27) -> dict[str, typing.Any]: 

28 """ 

29 Dump a config instance to a dictionary (recursively). 

30 """ 

31 data: dict[str, typing.Any] = {} 

32 

33 if not hasattr(inst, "__dict__"): 

34 # weird type - skip 

35 return {} 

36 

37 internals_prefix = f"_{inst.__class__.__name__}__" 

38 for key, value in inst.__dict__.items(): 

39 if exclude_internals == PROTECTED and key.startswith(internals_prefix): 

40 # skip _ and __ on level 2 

41 continue 

42 elif exclude_internals == PRIVATE and key.startswith("_"): 

43 # skip __ on level 1 

44 continue 

45 # else: skip nothing 

46 

47 cls = value.__class__ 

48 if is_custom_class(cls): 

49 value = asdict(value, _level + 1, exclude_internals=exclude_internals) 

50 elif isinstance(value, list): 

51 value = [ 

52 asdict(_, _level + 1, exclude_internals=exclude_internals) if instance_of_custom_class(_) else _ 

53 for _ in value 

54 ] 

55 elif isinstance(value, dict): 

56 value = { 

57 k: asdict(v, _level + 1, exclude_internals=exclude_internals) if instance_of_custom_class(v) else v 

58 for k, v in value.items() 

59 } 

60 

61 data[key] = value 

62 

63 if _level == 0 and with_top_level_key: 

64 # top-level: add an extra key indicating the class' name 

65 cls_name = camel_to_snake(inst.__class__.__name__) 

66 return {cls_name: data} 

67 

68 return data 

69 

70 

71@register_dumper("toml") 

72def astoml(inst: typing.Any, multiline_strings: bool = False, **kw: typing.Any) -> str: 

73 """ 

74 Dump a config instance to toml (recursively). 

75 """ 

76 data = asdict( 

77 inst, 

78 with_top_level_key=kw.pop("with_top_level_key", True), 

79 exclude_internals=kw.pop("exclude_internals", False), 

80 ) 

81 return tomli_w.dumps(data, multiline_strings=multiline_strings) 

82 

83 

84@register_dumper("json") 

85def asjson(inst: typing.Any, **kw: typing.Any) -> str: 

86 """ 

87 Dump a config instance to json (recursively). 

88 """ 

89 data = asdict( 

90 inst, 

91 with_top_level_key=kw.pop("with_top_level_key", True), 

92 exclude_internals=kw.pop("exclude_internals", False), 

93 ) 

94 return json.dumps(data, **kw) 

95 

96 

97@register_dumper("yaml") 

98def asyaml(inst: typing.Any, **kw: typing.Any) -> str: 

99 """ 

100 Dump a config instance to yaml (recursively). 

101 """ 

102 data = asdict( 

103 inst, 

104 with_top_level_key=kw.pop("with_top_level_key", True), 

105 exclude_internals=kw.pop("exclude_internals", False), 

106 ) 

107 output = yaml.dump(data, encoding=None, **kw) 

108 # output is already a str but mypy doesn't know that 

109 return typing.cast(str, output) 

110 

111 

112@register_dumper("bytes") 

113def asbytes(inst: "BinaryConfig", **_: typing.Any) -> bytes: 

114 """ 

115 Dumper for binary config to 'pack' into a bytestring. 

116 """ 

117 return inst._pack()