⬅ configlayer/__init__.py source

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"""
13 from copy import deepcopy
14 from weakref import ref
15 from dataclasses import replace
16  
17 from ._config import ConfigSupport, Options
18 from ._profiles import Profiles
19 from ._io import IO
20 from ._file import File
21  
22 from .types import path_t, Field
23 from .utils import (init_reraise, get_attrs, check_type, check_items, check_types, safe, GetName,
24 is_dunder, with_type, is_exception)
25 from .constants import DEFAULT_SECTION, DEFAULT_ID
26 from .exceptions import InputError, CheckTypeError, FieldError
27  
28  
29 __all__ = ['ConfigBase', 'LanguageBase', 'Field', 'Options']
30 __version__ = "0.1.2"
31  
32  
33 class 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
  • E721 Do not compare types, for exact checks use `is` / `is not`, for instance checks use `isinstance()`
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:
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  
213 class 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):
  • E721 Do not compare types, for exact checks use `is` / `is not`, for instance checks use `isinstance()`
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)))