Coverage for gws-app/gws/config/loader.py: 20%
208 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Configuration loader: configure, save and load the root object."""
3from typing import Optional
4import sys
6import gws
7import gws.spec.runtime
8import gws.lib.osx
9import gws.lib.dynimport
11from . import parser
13_ERROR_PREFIX = 'CONFIGURATION ERROR'
14_WARNING_PREFIX = 'CONFIGURATION WARNING'
16_DEFAULT_CONFIG_PATHS = [
17 '/data/config.cx',
18 '/data/config.json',
19 '/data/config.yaml',
20 '/data/config.py',
21]
23_DEFAULT_MANIFEST_PATHS = [
24 '/data/MANIFEST.json',
25]
28class Object:
29 """Configuration loader.
31 Holds the state of one configuration run: the specs, the parsed config,
32 the root object and the collected errors and warnings.
33 """
35 ctx: gws.ConfigContext
36 """Context with specs, errors, warnings and read paths."""
37 manifestPath: str
38 """Path to the application manifest, or an empty string."""
39 configPath: str
40 """Path to the configuration file, or an empty string."""
41 fallbackConfig: Optional[gws.Config]
42 """Configuration used when the main configuration fails, if the manifest allows it."""
43 withSpecCache: bool
44 """Read and write the specs cache."""
46 def __init__(
47 self,
48 manifest_path='',
49 config_path='',
50 specs=None,
51 raw_config=None,
52 fallback_config=None,
53 with_spec_cache=False,
54 hooks=None,
55 ):
56 """Create a loader.
58 Args:
59 manifest_path: Path to the application manifest. Defaults to ``GWS_MANIFEST`` or a default path.
60 config_path: Path to the configuration file. Defaults to ``GWS_CONFIG`` or a default path.
61 specs: Specs to use. If not given, specs are created from the manifest.
62 raw_config: Configuration dict, used instead of the configuration file.
63 fallback_config: Configuration used when the main configuration fails.
64 with_spec_cache: Read and write the specs cache.
65 hooks: List of ``(event, fn)`` pairs, see ``configure``.
66 """
67 self.tm1 = _time_and_memory()
69 self.ctx = gws.ConfigContext(
70 errors=[],
71 warnings=[],
72 )
74 self.manifestPath = real_manifest_path(manifest_path)
75 if self.manifestPath:
76 gws.log.info(f'using manifest {self.manifestPath!r}...')
78 self.configPath = real_config_path(config_path)
79 self.rawConfig = raw_config
80 self.fallbackConfig = fallback_config
81 self.withSpecCache = with_spec_cache
82 self.hooks = hooks or []
83 self.specs = specs
85 self.config = None
86 self.root = None
88 def configure(self) -> gws.ConfigResult:
89 """Parse the configuration and create the root object.
91 Hooks are called with the loader object at the events ``preConfigure``,
92 ``postConfigure`` (around parsing), ``preInitialize`` and ``postInitialize``
93 (around creating the root). Exceptions in hooks are recorded as errors.
95 If no root can be created, the fallback config is used, provided the
96 manifest sets ``withFallbackConfig``. If there are errors and the manifest
97 sets ``withStrictConfig``, the root is discarded.
99 Returns:
100 The configuration result.
101 """
102 if not self._init_specs():
103 return self._result()
105 self._run_hook('preConfigure')
106 if not self.config:
107 self.config = self._create_config()
108 self._run_hook('postConfigure')
110 if self.config:
111 self._run_hook('preInitialize')
112 if not self.root:
113 self.root = self._create_root(self.config)
114 self._run_hook('postInitialize')
116 if not self.root and self.ctx.specs.manifest.withFallbackConfig and self.fallbackConfig:
117 gws.log.warning(f'using fallback config')
118 self.root = self._create_root(self.fallbackConfig)
120 if not self.root:
121 return self._result()
123 if self.ctx.errors and self.ctx.specs.manifest.withStrictConfig:
124 self.root = None
125 return self._result()
127 self.root.configPaths = list(self.ctx.paths)
128 return self._result()
130 def parse(self) -> gws.ConfigResult:
131 """Parse and validate the configuration without creating objects.
133 Returns:
134 The configuration result, without the root object.
135 """
136 if not self._init_specs():
137 return self._result()
139 self.config = self._create_config()
140 if not self.config:
141 return self._result()
143 return self._result()
145 ##
147 def _init_specs(self):
148 if self.specs:
149 self.ctx.specs = self.specs
150 return True
152 try:
153 self.ctx.specs = gws.spec.runtime.create(
154 manifest_path=self.manifestPath,
155 read_cache=self.withSpecCache,
156 write_cache=self.withSpecCache,
157 )
158 return True
159 except Exception as exc:
160 gws.log.exception()
161 self._error(exc)
162 return False
164 def _create_config(self):
165 if self.rawConfig:
166 return parser.parse_app_dict(self.rawConfig, '', self.ctx)
167 if not self.configPath:
168 self._error(gws.ConfigurationError('no configuration file found'))
169 return
170 gws.log.info(f'using config {self.configPath!r}...')
171 return parser.parse_app_from_path(self.configPath, self.ctx)
173 def _create_root(self, cfg):
174 root = initialize(self.ctx.specs, cfg)
175 if root:
176 for ce in root.configErrors:
177 self.ctx.errors.append(gws.ConfigErrorInfo(ce))
178 for cw in root.configWarnings:
179 self.ctx.warnings.append(gws.ConfigErrorInfo(cw))
180 return root
182 def _run_hook(self, event):
183 for evt, fn in self.hooks:
184 if event != evt:
185 continue
186 try:
187 fn(self)
188 except Exception as exc:
189 gws.log.exception()
190 self._error(exc)
192 def _error(self, exc):
193 cei = gws.ConfigErrorInfo(message=str(exc))
194 if exc.__cause__:
195 cei.cause = repr(exc.__cause__)
196 self.ctx.errors.append(cei)
198 def _result(self):
199 return gws.ConfigResult(
200 errors=self.ctx.errors,
201 warnings=self.ctx.warnings,
202 root=self.root,
203 config=self.config,
204 info=_info_string(self.root, self.tm1),
205 )
208def configure(
209 manifest_path='',
210 config_path='',
211 specs: Optional[gws.SpecRuntime] = None,
212 raw_config: dict | gws.Data = None,
213 fallback_config: dict | gws.Data = None,
214 with_spec_cache=False,
215 hooks: list = None,
216) -> gws.ConfigResult:
217 """Parse the configuration and create the root object.
219 Args:
220 manifest_path: Path to the application manifest.
221 config_path: Path to the configuration file.
222 specs: Specs to use. If not given, specs are created from the manifest.
223 raw_config: Configuration dict, used instead of the configuration file.
224 fallback_config: Configuration used when the main configuration fails.
225 with_spec_cache: Read and write the specs cache.
226 hooks: List of ``(event, fn)`` pairs, see ``Object.configure``.
228 Returns:
229 The configuration result. Its ``root`` is ``None`` if configuration failed.
230 """
232 ldr = Object(
233 manifest_path,
234 config_path,
235 specs,
236 raw_config,
237 fallback_config,
238 with_spec_cache,
239 hooks,
240 )
241 return ldr.configure()
244def parse(
245 manifest_path='',
246 config_path='',
247 specs: Optional[gws.SpecRuntime] = None,
248) -> gws.ConfigResult:
249 """Parse and validate the configuration without creating objects.
251 Args:
252 manifest_path: Path to the application manifest.
253 config_path: Path to the configuration file.
254 specs: Specs to use. If not given, specs are created from the manifest.
256 Returns:
257 The configuration result with the parsed config, errors and warnings.
258 """
260 ldr = Object(
261 manifest_path,
262 config_path,
263 specs,
264 )
265 return ldr.parse()
268def initialize(specs: gws.SpecRuntime, config: gws.Config) -> gws.Root:
269 """Create the root object and the application from a parsed configuration.
271 Args:
272 specs: Specs runtime.
273 config: Parsed application configuration.
275 Returns:
276 The initialized, not yet activated root object.
277 """
278 root = gws.create_root(specs)
279 root.create_application(config)
280 root.post_initialize()
281 return root
284def save(root: gws.Root, path=None) -> str:
285 """Save the root object to a file, see ``gws.save_root``.
287 Args:
288 root: Root object.
289 path: File path. Defaults to ``config.pickle`` in the config directory.
291 Returns:
292 The file path.
294 Raises:
295 ``gws.ConfigurationError``: If the root cannot be saved.
296 """
297 path = path or gws.c.ROOT_PICKLE_PATH
298 gws.log.debug(f'writing config to {path!r}')
299 try:
300 return gws.save_root(root, path)
301 except Exception as exc:
302 raise gws.ConfigurationError('unable to save configuration') from exc
305def load(path=None) -> gws.Root:
306 """Load a saved root object, see ``gws.load_root``, and log the user, time and memory used.
308 Args:
309 path: File path. Defaults to ``config.pickle`` in the config directory.
311 Returns:
312 The root object.
314 Raises:
315 ``gws.ConfigurationError``: If the root cannot be loaded or activated.
316 """
317 ui = gws.lib.osx.user_info()
318 path = path or gws.c.ROOT_PICKLE_PATH
319 gws.log.info(f'loading config from {path!r}, user {ui["pw_name"]} ({ui["pw_uid"]}:{ui["pw_gid"]})')
320 try:
321 tm1 = _time_and_memory()
322 root = gws.load_root(path)
323 info = _info_string(root, tm1)
324 gws.log.info(f'configuration loaded, {info}')
325 return root
326 except Exception as exc:
327 raise gws.ConfigurationError('unable to load configuration') from exc
330def real_config_path(config_path: str) -> str:
331 """Find the configuration file.
333 Args:
334 config_path: Comma-separated list of paths. Defaults to the ``GWS_CONFIG`` environment variable.
335 If neither is given, the default paths are checked.
337 Returns:
338 The first path that is an existing file, or an empty string.
339 """
340 p = config_path or gws.env.GWS_CONFIG
341 if p:
342 for s in p.split(','):
343 s = s.strip()
344 if gws.u.is_file(s):
345 return s
346 return ''
347 for p in _DEFAULT_CONFIG_PATHS:
348 if gws.u.is_file(p):
349 return p
350 return ''
353def real_manifest_path(manifest_path: str) -> str:
354 """Find the application manifest.
356 Args:
357 manifest_path: Manifest path. Defaults to the ``GWS_MANIFEST`` environment variable.
358 If neither is given, the default path ``/data/MANIFEST.json`` is used if it exists.
360 Returns:
361 The manifest path, or an empty string.
362 """
363 p = manifest_path or gws.env.GWS_MANIFEST
364 if p:
365 return p
366 for p in _DEFAULT_MANIFEST_PATHS:
367 if gws.u.is_file(p):
368 return p
369 return ''
372def log_report(cr: gws.ConfigResult):
373 """Log a summary of the configuration result and all errors and warnings.
375 Args:
376 cr: Configuration result.
377 """
378 err_cnt = len(cr.errors) if cr.errors else 0
379 warn_cnt = len(cr.warnings) if cr.warnings else 0
380 ln = '*' * 80
382 if err_cnt == 0 and warn_cnt == 0:
383 gws.log.info(ln)
384 gws.log.info(f'configured: {cr.info}')
385 gws.log.info(ln)
386 return
388 if err_cnt == 0:
389 gws.log.warning(ln)
390 gws.log.warning(f'configured with warnings: {warn_cnt}, {cr.info}')
391 gws.log.warning(ln)
392 else:
393 gws.log.error(ln)
394 gws.log.error(f'configured with errors: {err_cnt}, warnings: {warn_cnt}, {cr.info}')
395 gws.log.error(ln)
397 # cr.errors.sort(key=lambda ce: ce.message)
399 for n, cei in enumerate(cr.errors, 1):
400 gws.log.error(f'{_ERROR_PREFIX}: {n} of {err_cnt}')
401 _log_info(cei, gws.log.error, _ERROR_PREFIX)
402 gws.log.error(f'{_ERROR_PREFIX}: ')
404 for n, cei in enumerate(cr.warnings, 1):
405 gws.log.warning(f'{_WARNING_PREFIX}: {n} of {warn_cnt}')
406 _log_info(cei, gws.log.warning, _WARNING_PREFIX)
407 gws.log.warning(f'{_WARNING_PREFIX}: ')
409 if err_cnt == 0:
410 gws.log.warning(ln)
411 else:
412 gws.log.error(ln)
415def _log_info(cei: gws.ConfigErrorInfo, log_fn, prefix):
416 """Log the details of an error or warning."""
417 ls = []
418 ls.append(cei.message)
419 tab = ' ' * 4
421 if cei.path:
422 ls.append(f'PATH: {cei.path}')
423 if cei.line:
424 ls.append(f'LINE: {cei.line}')
425 if cei.value:
426 ls.append(f'VALUE: {cei.value}')
427 if cei.cause:
428 ls.append(f'CAUSE: {cei.cause}')
429 if cei.stack:
430 for loc in cei.stack:
431 p = [
432 loc.objectType,
433 repr(loc.objectName) if loc.objectName else None,
434 f'uid={loc.objectUid}' if loc.objectUid else None,
435 ]
436 p = '<' + ' '.join(gws.u.compact(p)) + '>'
437 if loc.propName:
438 p = f'{loc.propName!r} {p}'
439 ls.append(f'{tab}in {p}')
440 if cei.contextLines:
441 ls.extend(cei.contextLines)
443 for s in ls:
444 log_fn(f'{prefix}: {s}')
447def _time_and_memory():
448 return gws.u.stime(), gws.lib.osx.process_rss_size()
451def _info_string(root, tm1):
452 tm2 = _time_and_memory()
453 return 'objects: {:d}, time: {:d}s., memory: {:.2f} MB'.format(
454 root.object_count() if root else 0,
455 tm2[0] - tm1[0],
456 tm2[1] - tm1[1],
457 )