Coverage for configlayer/__init__.py: 99%

124 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-08 11:36 +0000

1"""Config layer main 

2 todo: Remove check_type(s) 'obj_t_check' (annotation provided, himself an evil Pinocchio) 

3 todo: Rework profiles.active_fields to profiles dict with active fields (and it's set method) 

4 todo: Add 'name' param to io.import_section (import single section to config possibility) 

5 todo: Reduce complexity (Flake8 C901 error disabled by changing max-complexity from 10 to 20) 

6 

7 note: Think about pydantic: seems checks can be delegated, and added more possibilities 

8 note: Think about fast is_holder - check only __iter__, without __getitem__ iter detection 

9 

10 note: "type: ignore" (mypy) is better than "typing.cast()", type hinting must stay type hinting 

11 note: "bug mypy" is not necessarily a bug, but that's what it's supposed to be 

12 note: "noqa" is mostly for silencing pycharm bugs or corrected side effects""" 

13from copy import deepcopy 

14from weakref import ref 

15from dataclasses import replace 

16 

17from ._config import ConfigSupport, Options 

18from ._profiles import Profiles 

19from ._io import IO 

20from ._file import File 

21 

22from .types import path_t, Field 

23from .utils import (init_reraise, get_attrs, check_type, check_items, check_types, safe, GetName, 

24 is_dunder, with_type, is_exception) 

25from .constants import DEFAULT_SECTION, DEFAULT_ID 

26from .exceptions import InputError, CheckTypeError, FieldError 

27 

28 

29__all__ = ['ConfigBase', 'LanguageBase', 'Field', 'Options'] 

30__version__ = "0.1.2" 

31 

32 

33class ConfigBase: 

34 """Config base 

35 Must be inherited with providing configuration fields""" 

36 cfg: ConfigSupport 

37 

38 @init_reraise('config', doc=True) 

39 def __init__(self, path: path_t | None = None, *, 

40 profiles: bool | None = None, io: bool | None = None, group: str | None = None, 

41 default_section: str = DEFAULT_SECTION, options: Options | None = None, 

42 type_name: str = 'config'): 

43 """ 

44 :arg path: Current configuration file path to load from or save to 

45 :arg profiles: Enable profiles support for current configuration 

46 :arg io: Enable input/output operations for current configuration 

47 :arg group: Group name for current configuration, if group changes needed 

48 :arg default_section: Default section name for current configuration 

49 :arg options: More precise behavior options for current configuration 

50 :arg type_name: Internal current configuration type name for error message 

51 :raise InitError: If something goes wrong""" 

52 _ = GetName(self, doc=True, full=True) 

53 _name, name = str(_.attrs.cls), str(_) # noqa 

54 

55 # Check that call is inherited 

56 if type(self) == ConfigBase: 

57 raise InputError(must_be='inherited') 

58 

59 # Assign or check options 

60 if options is None: 

61 options = Options() 

62 elif not isinstance(options, Options): 

63 raise InputError('options', must_be='Options type', received=with_type(options)) 

64 

65 # Assign states of profiles if they are not provided and check bound settings 

66 profiles = profiles if profiles is not None else group is not None 

67 if group is not None and not profiles: 

68 raise InputError('profiles', must_be=f"True or unfilled when {group=!r} provided") 

69 

70 # Assign states of io if they are not provided and check bound settings 

71 io = io if io is not None else path is not None 

72 if path is not None and not io: 

73 raise InputError('io', must_be=f'True or unfilled when {path=!r} provided') 

74 

75 # Get fields names with declared types and values, including multiple inherited configs 

76 attrs = get_attrs(self, 1, internal=True, dunder=True) # dunder for merged __annotations__ 

77 cfg_values = {k: v for k, v in attrs.items() if not is_dunder(k)} 

78 cfg_types = attrs.get('__annotations__', {}) 

79 

80 # Check for empty config, reserved 'cfg' field name and that all values/types was provided 

81 if not cfg_types and not cfg_values: 

82 raise InputError(must_be='at least one field', received='empty config') 

83 if 'cfg' in cfg_types | cfg_values: 

84 raise InputError('cfg', item_name='field', 

85 reserved="for ConfigSupport structure, use another field name") 

86 if cfg_values.keys() != cfg_types.keys(): 

87 if wrong_names := [x for x in cfg_types if x not in cfg_values and is_dunder(x)]: 

88 raise InputError(*wrong_names, item_name='field', dunder='names are forbidden') 

89 check_items(cfg_values, cfg_types, 'field', str, input_exc=('',), 

90 extra_template='{} without type: ', 

91 absent_template='{} without factory default: ', 

92 must_be='', received='', fields=cfg_values, types=cfg_types) 

93 

94 # Prepare fields and default values 

95 fields, defaults = {}, {} 

96 for k, v in cfg_values.items(): 

97 

98 # Check that field types is actually types, and set info if possibly shadowing detected 

99 if isinstance(check_type(t := cfg_types[k], type, raw=True), CheckTypeError): 

100 msg = f"Field {k!r} type {with_type(t)} - is not a type" 

101 if t == v: 

102 msg += (", and is equal to a value. " 

103 "If shadowing - regular scoping rules applied (cpython issue #98876)") 

104 raise InputError(msg=msg) 

105 

106 # Fill default values for type checking and fields for usage 

107 if isinstance(v, Field): 

108 defaults[k] = d = deepcopy(v.default) 

109 fields[k] = replace(v, default=d, type=t) 

110 else: 

111 defaults[k] = d = deepcopy(v) 

112 fields[k] = Field(d, type=t) 

113 

114 # Check and set default values 

115 self.__dict__ |= check_types(defaults, cfg_types, item_name='field', obj_t_check=False, 

116 input_exc=('',)) 

117 

118 # Init config support structure with additional functionality (with - unlocks structure) 

119 data = ref(self)() 

120 with ConfigSupport(data, fields, _name, name, default_section, options, type_name) as cfg: 

121 self.cfg, cfg = cfg, ref(cfg)() 

122 self.cfg.profiles = Profiles(cfg, data, group) if profiles else None 

123 self.cfg.io = IO(cfg, data, fields) if io else None 

124 self.cfg.file = File(cfg, path) if path is not None else None 

125 

126 def __del__(self): 

127 """Remove path from used at object deletion by garbage collector 

128 Warning: del keyword deletes only link to object from local area, not object itself!""" 

129 if (cfg := getattr(self, 'cfg', None)) and (file := getattr(cfg, 'file', None)): 

130 file.__del__() 

131 

132 def __repr__(self): 

133 """Class name in code""" 

134 return self.cfg._name # noqa 

135 

136 def __str__(self): 

137 """Config name for user + fields names and values""" 

138 return '\n\t'.join((f'{self.cfg.name!r} {self.cfg.type_name}:', 

139 *(f'{k}: {field.type.__name__} = {getattr(self, k)!r}' 

140 for k, field in self.cfg.get_fields.items()))) 

141 

142 def __eq__(self, other): 

143 """Only fields compared! Profiles and any other functionalities are ignored!""" 

144 if issubclass(type(other), ConfigBase) and self.cfg.get_fields == other.cfg.get_fields: 144 ↛ 146line 144 didn't jump to line 146, because the condition on line 144 was never false

145 return self.cfg.get_data == other.cfg.get_data 

146 return False 

147 

148 def __set_field__(self, key, value, cfg, field, options, *, check=True, revert=False): 

149 if value == DEFAULT_ID: 

150 value = field.default 

151 elif not revert and check and options.typecheck: 

152 value = check_type(value, field.type, options.typecast) 

153 

154 # Set user default value in default profile if default section is active 

155 if profiles := cfg.profiles: 

156 if profiles.active == cfg.def_sect: 

157 field.default = value 

158 if key not in profiles.active_fields: 

159 raise FieldError('Set', cfg.name, key, value, getattr(self, key), 

160 type_name=cfg.type_name, 

161 reason=f'it is fixed by {profiles.active!r} profile. ' 

162 f'Available fields: {", ".join(profiles.active_fields)}') 

163 

164 # Get previous and set current value 

165 prev_value = getattr(self, key) 

166 object.__setattr__(self, key, value) 

167 

168 # Run on_set handlers 

169 errors = [] 

170 for name, (f_name, run_if_equal, partial_func) in cfg.get_on_set.items(): 

171 if f_name is None or f_name == key: 

172 if run_if_equal or prev_value != value: 

173 if is_exception(error := safe(partial_func, key, prev_value, value)): 

174 errors.append(f'{name!r} handler ({GetName(partial_func.func)}): {error}') 

175 errors = '\n\t'.join(('on_set handlers errors:', *errors)) if errors else '' 

176 

177 # Return revert if called with it (must be only internal call) 

178 if revert: 

179 return f"Revert completed{f', but {errors}' if errors else ''}" 

180 

181 # Handle on_set errors 

182 if errors: 

183 if options.revert_fails: 

184 add_msg = self.__set_field__(key, prev_value, cfg, field, options, revert=True) 

185 else: 

186 add_msg = 'Revert option is disabled - field value is left changed' 

187 raise FieldError('Set', cfg.name, key, value, prev_value, type_name=cfg.type_name, 

188 reason=f'{errors}\n{add_msg}', failed=False) 

189 

190 def __setattr__(self, key, value): 

191 """Set field (or attribute if config is not initialized yet)""" 

192 # RAW write if ConfigSupport is not inited yet 

193 if (cfg := getattr(self, 'cfg', None)) is None: 

194 return super().__setattr__(key, value) 

195 

196 # Check for exists field name 

197 if key not in (fields := cfg.get_fields): 

198 raise FieldError('Set', cfg.name, key, value, type_name=cfg.type_name, 

199 reason=f"it is not field. Available: {', '.join(fields)}") 

200 

201 # Set field value 

202 self.__set_field__(key, value, cfg, fields[key], cfg.options) 

203 

204 def __delattr__(self, key): 

205 """Clear field (replaces field value to user default)""" 

206 cfg = self.cfg 

207 if key not in cfg.get_fields: 

208 raise FieldError('Delete', cfg.name, key, type_name=cfg.type_name, 

209 reason='it is not field. Attributes cannot be deleted') 

210 self.__setattr__(key, DEFAULT_ID) 

211 

212 

213class LanguageBase(ConfigBase): 

214 """Language base 

215 Must be inherited with providing language str fields""" 

216 @init_reraise('language', doc=True) 

217 def __init__(self, *args, **kwargs): 

218 if type(self) == LanguageBase: 

219 raise InputError(must_be='inherited') 

220 

221 # Get all fields from multiple inherited configs 

222 attrs = get_attrs(self, 2, internal=True, dunder=True) # dunder for merged __annotations__ 

223 

224 # Language fields must not have types provided 

225 if cfg_types := attrs.get('__annotations__', {}): 

226 msg = 'No need to annotate language fields, only str type allowed' 

227 raise InputError(*cfg_types, item_name='field', msg=msg) 

228 

229 # Force all fields to str type 

230 self.__annotations__ = {k: str for k in attrs if not is_dunder(k)} 

231 

232 # Group is fixed for language config 

233 if (group := kwargs.get('group', None)) is not None: 

234 raise InputError('group', reserved=f"for LanguageBase ({group!r} will be 'Language')") 

235 

236 # Init config as language group for synchronized translations changing 

237 super().__init__(*args, **kwargs, group='Language', type_name='language') 

238 

239 def __str__(self): 

240 """Language name for user + fields names and values""" 

241 return '\n\t'.join((f'{self.cfg.name!r} {self.cfg.type_name}:', 

242 *(f'{k}: {getattr(self, k)!r}' for k in self.cfg.get_fields)))