Skip to content

API Reference (Config)

RobustConfig

sciwork.config.config.RobustConfig()

Unified, high-level API for loading, merging, validating and generating INI/JSON configuration files. Internally delegates to sciwork.config.{loader,schema,templates,store}.

Typical flow: rc = RobustConfig() rc.load_ini_configs(["job.ini"]) rc.validate_with_schema_json("config_projects.json", template="data_handler", sections=["main"])

You can also generate skeleton configs from a JSON template: rc.create_ini_from_template("config_projects.json", "out.ini", template="data_handler", sections=["main"]) rc.create_json_from_template("config_projects.json", "out.json", template="data_handler", sections=["main"])

Source code in src/sciwork/config/config.py
29
30
31
def __init__(self) -> None:
	self._data: Dict[str, Dict[str, Any]] = {}
	self._schema_defaults: Dict[str, Dict[str, Any]] = {}

_data = {} instance-attribute

_schema_defaults = {} instance-attribute

__enter__()

Enable with RobustConfig() as rc: ... usage. No resources are acquired here; this returns self for convenience.

Source code in src/sciwork/config/config.py
50
51
52
53
54
55
def __enter__(self) -> "RobustConfig":
	"""
	Enable ``with RobustConfig() as rc: ...`` usage.
	No resources are acquired here; this returns ``self`` for convenience.
	"""
	return self

__exit__(exc_type, exc_val, exc_tb)

Context-manager exit hook. Logs any exception that occurred inside the with block and returns False to let the exception propagate (default Python behavior).

Returns:

Type Description
bool

False - do not suppress exceptions.

Source code in src/sciwork/config/config.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def __exit__(
		self,
		exc_type: Optional[type[BaseException]],
		exc_val: Optional[BaseException],
		exc_tb: Optional[TracebackType]
) -> bool:
	"""
	Context-manager exit hook.
	Logs any exception that occurred inside the ``with`` block and returns
	``False`` to let the exception propagate (default Python behavior).

	:return: ``False`` - do not suppress exceptions.
	"""
	if exc_type is not None:
		LOG.error("Exception inside RobustConfig context: %s", exc_type, exc_info=(exc_type, exc_val, exc_tb))
	return False

__repr__()

Returns string like RobustConfig(sections=['main', 'dev']).

Source code in src/sciwork/config/config.py
33
34
35
36
def __repr__(self) -> str:
	"""Returns string like ``RobustConfig(sections=['main', 'dev'])``."""
	sections = sorted(self._data.keys())
	return f"{self.__class__.__name__}(sections={sections})"

__str__()

Lists sections and how many keys each section contains. Designed for quick diagnostics (not a full dump).

Source code in src/sciwork/config/config.py
38
39
40
41
42
43
44
45
46
47
48
def __str__(self) -> str:
	"""
	Lists sections and how many keys each section contains. Designed for quick
	diagnostics (not a full dump).
	"""
	if not self._data:
		return "RobustConfig with no sections"

	header = f"RobustConfig with {len(self._data)} section(s):"
	lines = [f"[{sec}] ({len(keys)} keys)" for sec, keys in sorted(self._data.items())]
	return header + "\n" + "\n".join(lines)

clear(*, sections=None, keep_defaults=False)

Clear in-memory configuration data (all sections or only selected ones).

When the sections parameter is None (default), all sections are removed. Otherwise, only the provided section names are removed (case-insensitive). By default, any previously loaded schema defaults are kept; pass keep_defaults=False to clear them as well.

Parameters:

Name Type Description Default
sections Optional[Iterable[str]]

Iterable of section names to delete; None to clear all.

None
keep_defaults bool

Keep schema defaults (True) or clear them (False, default).

False

Returns:

Type Description
'RobustConfig'

self (for fluent chaining).

Source code in src/sciwork/config/config.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
def clear(
		self,
		*,
		sections: Optional[Iterable[str]] = None,
		keep_defaults: bool = False
) -> "RobustConfig":
	"""
	Clear in-memory configuration data (all sections or only selected ones).

	When the *sections* parameter is None (default), all sections are removed.
	Otherwise, only the provided section names are removed (case-insensitive).
	By default, any previously loaded schema defaults are kept;
	pass ``keep_defaults=False`` to clear them as well.

	:param sections: Iterable of section names to delete; ``None`` to clear all.
	:param keep_defaults: Keep schema defaults (``True``) or clear them (``False``, default).
	:return: self (for fluent chaining).
	"""
	if sections is None:
		self._data.clear()
		cleared = "all sections"
	else:
		removed = []
		for name in sections:
			key = str(name).lower()
			if key in self._data:
				self._data.pop(key, None)
				removed.append(key)
		cleared = f"sections={removed or '[]'}"

	if not keep_defaults:
		self._schema_defaults.clear()

	LOG.info("Cleared %s%s",
	         cleared,
	         "" if keep_defaults else " (and schema defaults)")
	return self

create_ini_from_template(schema_json_path, dest_path, *, template, sections, project=None, include_defaults=True, placeholder='', header_comment=None, overwrite=False) staticmethod

Generate an INI file by applying a template object to sections.

Parameters:

Name Type Description Default
schema_json_path PathLike

Path to the schema JSON.

required
dest_path PathLike

Where to write the INI file.

required
template str

Template object name (e.g., "data_handler").

required
sections Iterable[str]

Section names to include.

required
project Optional[str]

Optional project name when using "projects" wrapper.

None
include_defaults bool

Insert defaults when present in schema.

True
placeholder Optional[str]

Placeholder value for keys without defaults.

''
header_comment Optional[str]

Optional multi-line text to add at the top as ; comments.

None
overwrite bool

When False and file exist, it raises FileExistsError.

False

Returns:

Type Description
Path

Absolute path to the written INI file.

Raises:

Type Description
FileExistsError

If the destination exists and overwrite=False.

OSError

On write errors.

ConfigError

On schema/template errors.

Source code in src/sciwork/config/config.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
@staticmethod
def create_ini_from_template(
		schema_json_path: PathLike,
		dest_path: PathLike,
		*,
		template: str,
		sections: Iterable[str],
		project: Optional[str] = None,
		include_defaults: bool = True,
		placeholder: Optional[str] = "",
		header_comment: Optional[str] = None,
		overwrite: bool = False,
) -> Path:
	"""
	Generate an INI file by applying a template object to *sections*.

	:param schema_json_path: Path to the schema JSON.
	:param dest_path: Where to write the INI file.
	:param template: Template object name (e.g., ``"data_handler"``).
	:param sections: Section names to include.
	:param project: Optional project name when using ``"projects"`` wrapper.
	:param include_defaults: Insert defaults when present in schema.
	:param placeholder: Placeholder value for keys without defaults.
	:param header_comment: Optional multi-line text to add at the top as ``;`` comments.
	:param overwrite: When False and file exist, it raises ``FileExistsError``.
	:return: Absolute path to the written INI file.
	:raises FileExistsError: If the destination exists and ``overwrite=False``.
	:raises OSError: On write errors.
	:raises ConfigError: On schema/template errors.
	"""
	return templates.write_ini_from_template(
		schema_json_path,
		dest_path,
		template=template,
		sections=sections,
		project=project,
		include_defaults=include_defaults,
		placeholder=placeholder,
		header_comment=header_comment,
		overwrite=overwrite
	)

create_json_from_template(schema_json_path, dest_path, *, template, sections, project=None, include_defaults=True, placeholder='', drop_nulls=False, overwrite=False, indent=2) staticmethod

Generate a JSON configuration by applying a template object to sections.

Parameters:

Name Type Description Default
schema_json_path PathLike

Path to the schema JSON.

required
dest_path PathLike

Destination JSON file path.

required
template str

Template object name.

required
sections Iterable[str]

Section names to include in output.

required
project Optional[str]

Optional project name when using a "projects" wrapper.

None
include_defaults bool

Insert defaults when present in schema.

True
placeholder Optional[str]

Placeholder value for missing defaults.

''
drop_nulls bool

Remove keys with the value None from the output.

False
overwrite bool

When False and file exist, it raises FileExistsError.

False
indent int

JSON indent for readability (default 2).

2

Returns:

Type Description
Path

Absolute path to a written JSON file.

Raises:

Type Description
FileExistsError

If the destination exists and overwrite=False.

OSError

On write errors.

ConfigError

On schema/template errors.

Source code in src/sciwork/config/config.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
@staticmethod
def create_json_from_template(
		schema_json_path: PathLike,
		dest_path: PathLike,
		*,
		template: str,
		sections: Iterable[str],
		project: Optional[str] = None,
		include_defaults: bool = True,
		placeholder: Optional[str] = "",
		drop_nulls: bool = False,
		overwrite: bool = False,
		indent: int = 2
) -> Path:
	"""
	Generate a JSON configuration by applying a template object to *sections*.

	:param schema_json_path: Path to the schema JSON.
	:param dest_path: Destination JSON file path.
	:param template: Template object name.
	:param sections: Section names to include in output.
	:param project: Optional project name when using a ``"projects"`` wrapper.
	:param include_defaults: Insert defaults when present in schema.
	:param placeholder: Placeholder value for missing defaults.
	:param drop_nulls: Remove keys with the value ``None`` from the output.
	:param overwrite: When False and file exist, it raises ``FileExistsError``.
	:param indent: JSON indent for readability (default 2).
	:return: Absolute path to a written JSON file.
	:raises FileExistsError: If the destination exists and ``overwrite=False``.
	:raises OSError: On write errors.
	:raises ConfigError: On schema/template errors.
	"""
	return templates.write_json_from_template(
		schema_json_path,
		dest_path,
		template=template,
		sections=sections,
		project=project,
		include_defaults=include_defaults,
		placeholder=placeholder,
		drop_nulls=drop_nulls,
		overwrite=overwrite,
		indent=indent
	)

load_ini_config(path, *, interpolation='extended', csv_delimiters=None)

Load a single INI file and merge it into the currently loaded data.

Parameters:

Name Type Description Default
path PathLike

INI file path.

required
interpolation str

'extended' for ConfigParser.ExtendedInterpolation, or 'none'.

'extended'
csv_delimiters Optional[Sequence[str]]

Optional list of delimiters used by the loader for CSV-like parsing.

None

Returns:

Type Description
'RobustConfig'

self.

Raises:

Type Description
ConfigError

On parsing/IO errors.

Source code in src/sciwork/config/config.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def load_ini_config(
		self,
		path: PathLike,
		*,
		interpolation: str = "extended",
		csv_delimiters: Optional[Sequence[str]] = None
) -> "RobustConfig":
	"""
	Load a single INI file and merge it into the currently loaded data.

	:param path: INI file path.
	:param interpolation: 'extended' for ConfigParser.ExtendedInterpolation, or 'none'.
	:param csv_delimiters: Optional list of delimiters used by the loader for CSV-like parsing.
	:return: self.
	:raises ConfigError: On parsing/IO errors.
	"""
	data, _loaded = loader.load_ini_files(
		[path],
		interpolation=interpolation,
		csv_delimiters=csv_delimiters
	)
	loader.merge_dicts(self._data, data)
	LOG.info("Loaded INI: %s", Path(path).resolve())
	return self

load_ini_configs(files, *, interpolation='extended', csv_delimiters=None)

Load multiple INI files (later override earlier) and merge into current data.

Parameters:

Name Type Description Default
files Iterable[PathLike]

Iterable of INI paths.

required
interpolation str

'extended' or 'none'.

'extended'
csv_delimiters Optional[Sequence[str]]

Optional list of CSV-like delimiters.

None

Returns:

Type Description
'RobustConfig'

self.

Raises:

Type Description
ConfigError

On parsing/IO errors.

Source code in src/sciwork/config/config.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def load_ini_configs(
		self,
		files: Iterable[PathLike],
		*,
		interpolation: str = "extended",
		csv_delimiters: Optional[Sequence[str]] = None
) -> "RobustConfig":
	"""
	Load multiple INI files (later override earlier) and merge into current data.

	:param files: Iterable of INI paths.
	:param interpolation: 'extended' or 'none'.
	:param csv_delimiters: Optional list of CSV-like delimiters.
	:return: self.
	:raises ConfigError: On parsing/IO errors.
	"""
	data, _loaded = loader.load_ini_files(
		files,
		interpolation=interpolation,
		csv_delimiters=csv_delimiters
	)
	loader.merge_dicts(self._data, data)
	LOG.info("Loaded %d INI file(s).", len(list(files)))
	return self

load_json_config(path)

Merge a single JSON config (shape: section->key->value) into current data.

Parameters:

Name Type Description Default
path PathLike

JSON file path.

required

Returns:

Type Description
'RobustConfig'

self.

Raises:

Type Description
ConfigError

On read/shape errors.

Source code in src/sciwork/config/config.py
125
126
127
128
129
130
131
132
133
134
135
136
def load_json_config(self, path: PathLike) -> "RobustConfig":
	"""
	Merge a single JSON config (shape: section->key->value) into current data.

	:param path: JSON file path.
	:return: self.
	:raises ConfigError: On read/shape errors.
	"""
	merged = loader.load_json_files([path])
	loader.merge_dicts(self._data, merged)
	LOG.info("Loaded JSON: %s", Path(path).resolve())
	return self

load_json_configs(files)

Merge multiple JSON configs (shape: section->key->value) into current data.

Parameters:

Name Type Description Default
files Iterable[PathLike]

Iterable of JSON file paths.

required

Returns:

Type Description
'RobustConfig'

self.

Raises:

Type Description
ConfigError

On read/shape errors.

Source code in src/sciwork/config/config.py
138
139
140
141
142
143
144
145
146
147
148
149
def load_json_configs(self, files: Iterable[PathLike]) -> "RobustConfig":
	"""
	Merge multiple JSON configs (shape: section->key->value) into current data.

	:param files: Iterable of JSON file paths.
	:return: self.
	:raises ConfigError: On read/shape errors.
	"""
	merged = loader.load_json_files(list(files))
	loader.merge_dicts(self._data, merged)
	LOG.info("Loaded %d JSON(s).", len(list(files)))
	return self

section(name, *, missing_ok=False)

Return one section mapping (lowercased name)

Source code in src/sciwork/config/config.py
361
362
363
364
365
366
def section(self, name: str, *, missing_ok: bool = False) -> Dict[str, Any]:
	"""Return one section mapping (lowercased name)"""
	key = name.lower()
	if key not in self._data and not missing_ok:
		raise KeyError(f"Unknown section: {name}")
	return self._data.get(key, {})

to_dict()

Return a deep (but still mutable) dict representation of current data.

Source code in src/sciwork/config/config.py
357
358
359
def to_dict(self) -> Dict[str, Dict[str, Any]]:
	"""Return a deep (but still mutable) dict representation of current data."""
	return self._data

validate(*, schema_map)

Validate current data against a KeySpec mapping.

Parameters:

Name Type Description Default
schema_map Mapping[str, Mapping[str, KeySpec]]

Mapping section -> key -> KeySpec

required

Returns:

Type Description
'RobustConfig'

self.

Raises:

Type Description
ConfigError

On any validation problem.

Source code in src/sciwork/config/config.py
152
153
154
155
156
157
158
159
160
161
162
163
164
def validate(self, *, schema_map: Mapping[str, Mapping[str, schema.KeySpec]]) -> "RobustConfig":
	"""
	Validate current data against a KeySpec mapping.

	:param schema_map: Mapping section -> key -> KeySpec
	:return: self.
	:raises ConfigError: On any validation problem.
	"""
	if self._schema_defaults:
		schema.apply_defaults(self._data, self._schema_defaults)
	schema.validate_data(self._data, schema_map)
	LOG.info("Validation OK")
	return self

validate_with_schema_json(schema_path, *, template=None, project=None, sections=None)

Convenience: load a JSON schema and validate current data.

Behavior: - If template is provided: apply that template to sections (or to all currently loaded sections when sections are None). - If template is omitted: treat JSON as {section -> key -> spec}, or you can add auto-detection later.

Parameters:

Name Type Description Default
schema_path PathLike

Path to JSON schema file.

required
template Optional[str]

Optional template name to apply (e.g. "data_handler").

None
project Optional[str]

Optional project name when schema has a "projects" wrapper.

None
sections Optional[Iterable[str]]

Target sections; defaults to current loaded sections.

None

Returns:

Type Description
'RobustConfig'

self.

Raises:

Type Description
ConfigError

On schema/read/validate errors.

Source code in src/sciwork/config/config.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def validate_with_schema_json(
		self,
		schema_path: PathLike,
		*,
		template: Optional[str] = None,
		project: Optional[str] = None,
		sections: Optional[Iterable[str]] = None
) -> "RobustConfig":
	"""
	Convenience: load a JSON schema and validate current data.

	Behavior:
		- If *template* is provided: apply that template to *sections* (or to all
		currently loaded sections when *sections* are None).
		- If *template* is omitted: treat JSON as {section -> key -> spec}, or
		you can add auto-detection later.

	:param schema_path: Path to JSON schema file.
	:param template: Optional template name to apply (e.g. "data_handler").
	:param project: Optional project name when schema has a "projects" wrapper.
	:param sections: Target sections; defaults to current loaded sections.
	:return: self.
	:raises ConfigError: On schema/read/validate errors.
	"""
	# Default target sections = current sections in memory
	section_list = list(sections) if sections is not None else list(self._data.keys())

	if template:
		spec, defaults = schema.load_schema_template_from_json(
			schema_path,
			template=template,
			project=project,
			sections=section_list
		)
	else:
		raw = schema.load_schema_from_json(schema_path)

		# projects, if they exist
		root = raw
		if isinstance(raw, dict) and "projects" in raw and isinstance(raw["projects"], dict):
			root = raw["projects"].get(project, raw["projects"])

		is_template_like = False
		template_name: Optional[str] = None
		if isinstance(root, dict) and len(root) == 1:
			template_name = next(iter(root.keys()))
			maybe_spec = root[template_name]
			if isinstance(maybe_spec, dict):
				for v in maybe_spec.values():
					if isinstance(v, dict) and any(k in v for k in ("type", "required", "default", "choices")):
						is_template_like = True
						break

		if is_template_like and template_name:
			spec, defaults = schema.load_schema_template_from_json(
				schema_path, template=template_name, project=project, sections=section_list
			)
		else:
			spec, defaults = schema.schema_parse_to_keyspecs(raw)

	self._schema_defaults = defaults or {}
	return self.validate(schema_map=spec)

Schema types

sciwork.config.schema.KeySpec(expected_type, required=False, validator=None) dataclass

Specification for a configuration key used during validation.

Parameters:

Name Type Description Default
expected_type Union[type, Tuple[type, ...]]

Allowed type (or tuple of types) for the key's value. Use Python types (e.g., str, int, list) or a tuple like (int, type(None)) to allow None.

required
required bool

Whether the key must be present in the section.

False
validator Optional[Validator]

Optional callable that receives the parsed value and must raise on invalid content.

None

expected_type instance-attribute

required = False class-attribute instance-attribute

validator = None class-attribute instance-attribute

__post_init__()

Source code in src/sciwork/config/schema.py
32
33
34
def __post_init__(self) -> None:
    if self.validator is not None and not callable(self.validator):
        raise TypeError("KeySpec.validator must be callable or None")

Supporting modules

loader

Functions for reading INI and JSON files

sciwork.config.loader

LOG = logging.getLogger(__name__) module-attribute

PathLike = Union[str, Path] module-attribute

__all__ = ['ConfigError', 'choose_interpolation', 'parse_value', 'merge_layer', 'merge_dicts', 'load_ini_files', '_resolve_inheritance', 'load_json_files'] module-attribute

ConfigError

Bases: Exception

Generic configuration error used by loader utilities.

This module defines its own exception to avoid circular imports. A package-level errors.py can later centralize this if desired.

_cp_to_typed_dict(cp, *, csv_delimiters=None)

Project a ConfigParser into a nested dict with parsed value types.

Parameters:

Name Type Description Default
cp ConfigParser

Prepared ConfigParser (already read).

required
csv_delimiters Optional[Union[str, Iterable[str]]]

Optional CSV delimiters for value parsing.

None

Returns:

Type Description
Dict[str, Dict[str, Any]]

Dict[section]->Dict[key->typed value] (lowercased section/key names).

Source code in src/sciwork/config/loader.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def _cp_to_typed_dict(cp: configparser.ConfigParser, *, csv_delimiters: Optional[Union[str, Iterable[str]]] = None) -> \
		Dict[str, Dict[str, Any]]:
	"""
	Project a ConfigParser into a nested dict with parsed value types.

	:param cp: Prepared ConfigParser (already read).
	:param csv_delimiters: Optional CSV delimiters for value parsing.
	:return: Dict[section]->Dict[key->typed value] (lowercased section/key names).
	"""
	out: Dict[str, Dict[str, Any]] = {}
	for section in cp.sections():
		sec_name = section.lower()
		dest: Dict[str, Any] = {}
		for key, value in cp.items(section):
			dest[key.lower()] = parse_value(value, csv_delimiters=csv_delimiters)
		out[sec_name] = dest
	return out

_resolve_inheritance(data)

Support extends key in sections to mix in parent keys (shallow merge per level).

Example: [dev] extends = base, other

Parameters:

Name Type Description Default
data MutableMapping[str, Dict[str, Any]]

Dict of sections to resolve (modified in place).

required

Raises:

Type Description
ConfigError

When a referenced parent section does not exist.

Source code in src/sciwork/config/loader.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def _resolve_inheritance(data: MutableMapping[str, Dict[str, Any]]) -> None:
	"""
    Support ``extends`` key in sections to mix in parent keys (shallow merge per level).

    Example:
        [dev]
        extends = base, other

    :param data: Dict of sections to resolve (modified in place).
    :raises ConfigError: When a referenced parent section does not exist.
    """
	visited: Dict[str, bool] = {}

	def merge_chain(section: str) -> Dict[str, Any]:
		if section in visited:
			return data.get(section, {})
		visited[section] = True

		current = data.get(section, {})
		parents_raw = current.get("extends")
		if not parents_raw:
			return current

		parents = parents_raw if isinstance(parents_raw, list) else [parents_raw]
		merged: Dict[str, Any] = {}
		for parent in parents:
			parent_name = str(parent).lower()
			if parent_name not in data:
				raise ConfigError(f"[{section}] extends unknown section '{parent_name}'")
			merged.update(merge_chain(parent_name))
		# overlay current (without the 'extends' key)
		merged.update({k: v for k, v in current.items() if k != "extends"})
		data[section] = merged
		return merged

	for sec in list(data.keys()):
		merge_chain(sec)

_split_csv(text, delimiters)

Split text by a set of single-character delimiters, respecting quotes and escapes.

Supports both single and double quotes and the backslash escape inside quoted parts. Delimiters must be single characters (e.g. ',', ';', '\t', ' ').

Parameters:

Name Type Description Default
text str

Input string to split.

required
delimiters Optional[Union[str, Iterable[str]]]

Either a string of delimiter characters or an iterable of single-character strings. If None, no splitting is performed.

required

Returns:

Type Description
List[str]

List of token strings (untrimmed; caller may strip/parse further).

Source code in src/sciwork/config/loader.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def _split_csv(text: str, delimiters: Optional[Union[str, Iterable[str]]]) -> List[str]:
	"""
    Split *text* by a set of single-character delimiters, respecting quotes and escapes.

    Supports both single and double quotes and the backslash escape inside quoted parts.
    Delimiters must be single characters (e.g. ',', ';', '\\t', ' ').

    :param text: Input string to split.
    :param delimiters: Either a string of delimiter characters or an iterable of
                       single-character strings. If None, no splitting is performed.
    :return: List of token strings (untrimmed; caller may strip/parse further).
    """
	if not delimiters:
		return [text]

	if isinstance(delimiters, str):
		delims = set(delimiters)
	else:
		delims = set()
		for d in delimiters:
			if not isinstance(d, str) or len(d) != 1:
				raise ValueError("Only single-character delimiters are supported.")
			delims.add(d)

	out: List[str] = []
	buf: List[str] = []
	quote: Optional[str] = None
	i = 0
	while i < len(text):
		ch = text[i]
		if quote:
			if ch == "\\" and i + 1 < len(text):
				buf.append(text[i + 1])
				i += 2
				continue
			if ch == quote:
				quote = None
			else:
				buf.append(ch)
		else:
			if ch in {"'", '"'}:
				quote = ch
			elif ch in delims:
				out.append("".join(buf))
				buf.clear()
			else:
				buf.append(ch)
		i += 1
	out.append("".join(buf))
	return out

choose_interpolation(interpolation)

Return an interpolation object for configparser based on a textual flag.

If interpolation is one of {"none","no","off","false","f","raw"}, the interpolation is disabled (returns None). Otherwise, ExtendedInterpolation is used.

Parameters:

Name Type Description Default
interpolation Optional[str]

Text flag controlling interpolation behavior.

required

Returns:

Type Description
Optional[Interpolation]

Interpolation object or None.

Source code in src/sciwork/config/loader.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def choose_interpolation(interpolation: Optional[str]) -> Optional[configparser.Interpolation]:
	"""
    Return an interpolation object for configparser based on a textual flag.

    If *interpolation* is one of {"none","no","off","false","f","raw"}, the interpolation
    is disabled (returns None). Otherwise, ExtendedInterpolation is used.

    :param interpolation: Text flag controlling interpolation behavior.
    :return: Interpolation object or None.
    """
	if interpolation is None:
		return configparser.ExtendedInterpolation()
	flag = str(interpolation).lower().strip()
	if flag in {"none", "no", "off", "false", "f", "raw"}:
		return None
	return configparser.ExtendedInterpolation()

load_ini_files(files, *, interpolation='extended', csv_delimiters=None)

Load one or more INI files and return a typed, merged mapping of sections.

Later files override earlier ones (ConfigParser layering). Values are parsed to Python types via :func:parse_value. Sections support inheritance via the extends key (resolved after reading all files).

Parameters:

Name Type Description Default
files Iterable[PathLike]

Iterable of INI file paths.

required
interpolation Optional[str]

Text flag to control interpolation ('extended' or 'none' etc.).

'extended'
csv_delimiters Optional[Union[str, Iterable[str]]]

Optional CSV delimiters to enable CSV-like value parsing.

None

Returns:

Type Description
Tuple[Dict[str, Dict[str, Any]], List[Path]]

(data, loaded_files)

Raises:

Type Description
ConfigError

On missing file(s) or IO errors.

Source code in src/sciwork/config/loader.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
def load_ini_files(files: Iterable[PathLike],
                   *,
                   interpolation: Optional[str] = "extended",
                   csv_delimiters: Optional[Union[str, Iterable[str]]] = None) -> Tuple[
	Dict[str, Dict[str, Any]], List[Path]]:
	"""
    Load one or more INI files and return a typed, merged mapping of sections.

    Later files override earlier ones (ConfigParser layering). Values are parsed to
    Python types via :func:`parse_value`. Sections support inheritance via the
    ``extends`` key (resolved after reading all files).

    :param files: Iterable of INI file paths.
    :param interpolation: Text flag to control interpolation ('extended' or 'none' etc.).
    :param csv_delimiters: Optional CSV delimiters to enable CSV-like value parsing.
    :return: (data, loaded_files)
    :raises ConfigError: On missing file(s) or IO errors.
    """
	paths = [Path(p) for p in files]
	missing = [str(p) for p in paths if not p.exists()]
	if missing:
		raise ConfigError(f"Missing config file(s): {', '.join(missing)}")

	cp = configparser.ConfigParser(interpolation=choose_interpolation(interpolation))
	loaded: List[Path] = []

	for p in paths:
		try:
			with p.open("r", encoding="utf-8") as fh:
				cp.read_file(fh)
			loaded.append(p)
			LOG.info("Loaded INI file: %s", p)
		except Exception as exc:
			raise ConfigError(f"Failed reading '{p}': {exc}") from exc

	data = _cp_to_typed_dict(cp, csv_delimiters=csv_delimiters)
	_resolve_inheritance(data)
	return data, loaded

load_json_files(files)

Load and merge multiple JSON config files into a single mapping.

Each JSON file must be a top-level object with the shape: { "section": { "key": value, ... }, ... }

Later files override earlier ones at section/key granularity.

Parameters:

Name Type Description Default
files Iterable[PathLike]

Iterable of JSON paths.

required

Returns:

Type Description
Dict[str, Dict[str, Any]]

Merged mapping.

Raises:

Type Description
ConfigError

On IO/parse errors or invalid shapes.

Source code in src/sciwork/config/loader.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
def load_json_files(files: Iterable[PathLike]) -> Dict[str, Dict[str, Any]]:
	"""
    Load and merge multiple JSON config files into a single mapping.

    Each JSON file must be a top-level object with the shape:
        { "section": { "key": value, ... }, ... }

    Later files override earlier ones at section/key granularity.

    :param files: Iterable of JSON paths.
    :return: Merged mapping.
    :raises ConfigError: On IO/parse errors or invalid shapes.
    """
	merged: Dict[str, Dict[str, Any]] = {}
	for path_like in files:
		p = Path(path_like)
		if not p.exists():
			raise ConfigError(f"Missing JSON config file: {p}")
		try:
			with p.open("r", encoding="utf-8") as fh:
				obj = json.load(fh)
		except Exception as exc:
			raise ConfigError(f"Failed reading JSON '{p}': {exc}") from exc

		if not isinstance(obj, dict):
			raise ConfigError(f"Top-level JSON in '{p}' must be an object.")

		# Normalize section/key names to the lowercase
		lowered: Dict[str, Dict[str, Any]] = {}
		for sec, mapping in obj.items():
			if not isinstance(mapping, dict):
				raise ConfigError(f"Section '{sec}' in '{p}' must be an object.")
			lowered[sec.lower()] = {str(k).lower(): v for k, v in mapping.items()}

		merge_layer(merged, lowered)
		LOG.info("Merged JSON file: %s", p)
	return merged

merge_dicts(base, *layers)

Deep-merge one or more layers into base at section/key granularity.

Later layers overwrite earlier ones for identical keys.

Parameters:

Name Type Description Default
base MutableMapping[str, Dict[str, Any]]

Destination mapping (modified in place).

required
layers Mapping[str, Mapping[str, Any]]

One or more mappings to overlay.

()

Returns:

Type Description
MutableMapping[str, Dict[str, Any]]

The mutated base (for chaining).

Source code in src/sciwork/config/loader.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def merge_dicts(base: MutableMapping[str, Dict[str, Any]], *layers: Mapping[str, Mapping[str, Any]]) -> MutableMapping[
	str, Dict[str, Any]]:
	"""
	Deep-merge one or more *layers* into *base* at section/key granularity.

	Later layers overwrite earlier ones for identical keys.

	:param base: Destination mapping (modified in place).
	:param layers: One or more mappings to overlay.
	:return: The mutated *base* (for chaining).
	"""
	for layer in layers:
		merge_layer(base, layer)
	return base

merge_layer(base, layer)

Deep-merge layer into base at the section/key level.

Later (right) values overwrite earlier (left) values for identical keys.

Parameters:

Name Type Description Default
base MutableMapping[str, Dict[str, Any]]

Destination mapping (modified in place).

required
layer Mapping[str, Mapping[str, Any]]

Source mapping to overlay.

required
Source code in src/sciwork/config/loader.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def merge_layer(base: MutableMapping[str, Dict[str, Any]], layer: Mapping[str, Mapping[str, Any]]) -> None:
	"""
	Deep-merge *layer* into *base* at the section/key level.

	Later (right) values overwrite earlier (left) values for identical keys.

	:param base: Destination mapping (modified in place).
	:param layer: Source mapping to overlay.
	"""
	for sec, mapping in layer.items():
		if not isinstance(mapping, Mapping):
			raise ConfigError(f"Section '{sec}' must be a mapping, got {type(mapping).__name__}.")
		dest = base.setdefault(sec, {})
		for k, v in mapping.items():
			dest[k] = v

parse_value(raw, *, csv_delimiters=None)

Parse a raw INI string into a typed Python value.

The parser attempts, in order: 1) ast.literal_eval for safe Python literals (numbers, strings, lists, dicts, booleans, None). 2) Common textual None markers: none, null, na, n/a. 3) Booleans: true/yes/onTrue, false/no/offFalse. 4) CSV-like splitting only if csv_delimiters is provided (items parsed recursively). 5) Numeric fallback (int/float). 6) Otherwise the original string.

Parameters:

Name Type Description Default
raw str

Source text as read from ConfigParser.

required
csv_delimiters Optional[Union[str, Iterable[str]]]

Optional set of single-char delimiters to enable CSV splitting.

None

Returns:

Type Description
Any

Best-effort typed value.

Source code in src/sciwork/config/loader.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def parse_value(raw: str, *, csv_delimiters: Optional[Union[str, Iterable[str]]] = None) -> Any:
	"""
    Parse a raw INI string into a typed Python value.

    The parser attempts, in order:
      1) ``ast.literal_eval`` for safe Python literals (numbers, strings, lists, dicts, booleans, None).
      2) Common textual None markers: ``none``, ``null``, ``na``, ``n/a``.
      3) Booleans: ``true/yes/on`` → ``True``, ``false/no/off`` → ``False``.
      4) CSV-like splitting **only if** ``csv_delimiters`` is provided (items parsed recursively).
      5) Numeric fallback (int/float).
      6) Otherwise the original string.

    :param raw: Source text as read from ConfigParser.
    :param csv_delimiters: Optional set of single-char delimiters to enable CSV splitting.
    :return: Best-effort typed value.
    """
	s = raw.strip()

	# 1) Safe Python literals
	try:
		value = ast.literal_eval(s)
		# Normalize tuples to lists for config friendliness
		if isinstance(value, tuple):
			return list(value)
		return value
	except Exception:
		pass

	# 2) None-like markers
	lower = s.lower()
	if lower in {"none", "null", "na", "n/a"}:
		return None

	# 3) Booleans
	if lower in {"true", "yes", "on"}:
		return True
	if lower in {"false", "no", "off"}:
		return False

	# 4) CSV only when explicitly enabled
	if csv_delimiters and any(
			d in s for d in (csv_delimiters if isinstance(csv_delimiters, str) else list(csv_delimiters))):
		parts = _split_csv(s, csv_delimiters)
		# avoid infinite recursion: subitems are parsed with CSV disabled
		return [parse_value(p.strip(), csv_delimiters=None) for p in parts]

	# 5) Numbers
	try:
		if "." in s:
			return float(s)
		return int(s)
	except ValueError:
		# 6) String fallback
		return s

schema

Types and helpers for schema parsing and validation

sciwork.config.schema

PathLike = Union[str, Path] module-attribute

Validator = Callable[[Any], None] module-attribute

_TYPE_MAP = {'str': str, 'string': str, 'int': int, 'integer': int, 'float': float, 'number': float, 'bool': bool, 'boolean': bool, 'null': type(None), 'none': type(None), 'list': list, 'array': list, 'dict': dict, 'object': dict} module-attribute

__all__ = ['KeySpec', 'Validator', '_parse_type_tokens', 'make_choices_validator', 'schema_parse_to_keyspecs', 'load_schema_from_json', 'load_schema_template_from_json', 'apply_defaults', 'validate_data'] module-attribute

ConfigError

Bases: Exception

Generic configuration error used by loader utilities.

This module defines its own exception to avoid circular imports. A package-level errors.py can later centralize this if desired.

KeySpec(expected_type, required=False, validator=None) dataclass

Specification for a configuration key used during validation.

Parameters:

Name Type Description Default
expected_type Union[type, Tuple[type, ...]]

Allowed type (or tuple of types) for the key's value. Use Python types (e.g., str, int, list) or a tuple like (int, type(None)) to allow None.

required
required bool

Whether the key must be present in the section.

False
validator Optional[Validator]

Optional callable that receives the parsed value and must raise on invalid content.

None

_parse_type_tokens(type_field)

Convert a type descriptor into a tuple of Python types for KeySpec.expected_type.

Supported tokens (case-insensitive): - primitives: "str", "int", "float", "bool", "null" (or "none") - containers: "list", "dict" - parametric list: "list[str]" etc. → treated as list for expected_type - union: a JSON list like ["int", "null"]

Unknown tokens degrade gracefully to str (so validation still works).

Parameters:

Name Type Description Default
type_field Union[str, List[Optional[str]]]

String token (e.g., "str") or a list of tokens (e.g., ["int", "null"]). Items may be null in JSON.

required

Returns:

Type Description
Tuple[type, ...]

Tuple of acceptable Python types (e.g., (int, type(None))).

Source code in src/sciwork/config/schema.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def _parse_type_tokens(type_field: Union[str, List[Optional[str]]]) -> Tuple[type, ...]:
    """
    Convert a type descriptor into a tuple of Python types for KeySpec.expected_type.

    Supported tokens (case-insensitive):
      - primitives: ``"str"``, ``"int"``, ``"float"``, ``"bool"``, ``"null"`` (or ``"none"``)
      - containers: ``"list"``, ``"dict"``
      - parametric list: ``"list[str]"`` etc. → treated as ``list`` for *expected_type*
      - union: a JSON list like ``["int", "null"]``

    Unknown tokens degrade gracefully to ``str`` (so validation still works).

    :param type_field: String token (e.g., ``"str"``) or a list of tokens
                       (e.g., ``["int", "null"]``). Items may be ``null`` in JSON.
    :return: Tuple of acceptable Python types (e.g., ``(int, type(None))``).
    """
    def _one(token: Optional[str]) -> type:
        if token is None:
            return type(None)
        t = token.strip().lower()
        if t.startswith("list[") and t.endswith("]"):
            return list
        return _TYPE_MAP.get(t, str)

    if isinstance(type_field, str):
        return (_one(type_field),)
    return tuple(_one(tok) for tok in type_field)

_read_json_object(path_like, what)

Load a JSON file and ensure the top-level is an object.

Parameters:

Name Type Description Default
path_like PathLike

Path to the JSON file.

required
what str

Human label used in error messages (e.g., "schema JSON").

required

Returns:

Type Description
Dict[str, Any]

Parsed top-level object.

Raises:

Type Description
ConfigError

On IO/parse errors or when the JSON is not an object.

Source code in src/sciwork/config/schema.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def _read_json_object(path_like: PathLike, what: str) -> Dict[str, Any]:
    """
    Load a JSON file and ensure the top-level is an object.

    :param path_like: Path to the JSON file.
    :param what: Human label used in error messages (e.g., ``"schema JSON"``).
    :return: Parsed top-level object.
    :raises ConfigError: On IO/parse errors or when the JSON is not an object.
    """
    path = Path(path_like)
    if not path.exists():
        raise ConfigError(f"Missing {what}: {path}")
    try:
        with path.open("r", encoding="utf-8") as fh:
            obj = json.load(fh)
    except Exception as exc:
        raise ConfigError(f"Failed reading {what} '{path}': {exc}") from exc
    if not isinstance(obj, dict):
        raise ConfigError(f"{what} must be a JSON object.")
    return obj

apply_defaults(data, defaults)

Apply per-section defaults into data for keys that are missing.

Parameters:

Name Type Description Default
data Dict[str, Dict[str, Any]]

Configuration values (modified in place).

required
defaults Mapping[str, Mapping[str, Any]]

Mapping section -> key -> default_value.

required
Source code in src/sciwork/config/schema.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def apply_defaults(data: Dict[str, Dict[str, Any]],
                   defaults: Mapping[str, Mapping[str, Any]]) -> None:
    """
    Apply per-section defaults into *data* for keys that are missing.

    :param data: Configuration values (modified in place).
    :param defaults: Mapping ``section -> key -> default_value``.
    """
    for sec, mapping in defaults.items():
        if not isinstance(mapping, Mapping):
            continue
        bucket = data.get(sec)
        if bucket is None:
            continue
        for key, dval in mapping.items():
            if key not in bucket:
                bucket[key] = dval

load_schema_from_json(path)

Load a schema JSON file and return its top-level object.

This does not perform any transformation; it is a thin convenience wrapper for :func:_read_json_object.

Parameters:

Name Type Description Default
path PathLike

Path to JSON schema.

required

Returns:

Type Description
Dict[str, Dict[str, Any]]

Top-level JSON object.

Raises:

Type Description
ConfigError

On IO/parse errors or invalid top-level type.

Source code in src/sciwork/config/schema.py
193
194
195
196
197
198
199
200
201
202
203
204
def load_schema_from_json(path: PathLike) -> Dict[str, Dict[str, Any]]:
    """
    Load a schema JSON file and return its top-level object.

    This does not perform any transformation; it is a thin convenience wrapper
    for :func:`_read_json_object`.

    :param path: Path to JSON schema.
    :return: Top-level JSON object.
    :raises ConfigError: On IO/parse errors or invalid top-level type.
    """
    return _read_json_object(path, "schema JSON")

load_schema_template_from_json(path, *, template, project=None, sections=None)

Load a template schema (e.g. "data_handler") and apply it to many sections.

Accepted JSON shapes: 1) Direct sections (no wrapper): ... code-block:: JSON

    {
      "data_handler": { "...": { "type": "str", "required": true } }
    }

2) With project wrapper: ... code-block:: JSON

    {
      "projects": {
        "my_project": {
          "data_handler": { "...": { "type": "str" } }
        }
      }
    }

The selected template (template) is replicated for each section name in sections. If sections is omitted, the function uses an empty list and still returns parsed (schema, defaults) for possible later application.

Parameters:

Name Type Description Default
path PathLike

Path to the schema JSON.

required
template str

Template object name to use (e.g., "data_handler").

required
project Optional[str]

Optional project name when using a "projects" wrapper.

None
sections Optional[List[str]]

Section names to which to apply the template; if None, an empty list is assumed (you can still use the returned template schema to apply later).

None

Returns:

Type Description
Tuple[Dict[str, Dict[str, KeySpec]], Dict[str, Dict[str, Any]]]

(schema, defaults) for the fabricated root.

Raises:

Type Description
ConfigError

On missing template or invalid shapes.

Source code in src/sciwork/config/schema.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def load_schema_template_from_json(
    path: PathLike,
    *,
    template: str,
    project: Optional[str] = None,
    sections: Optional[List[str]] = None,
) -> Tuple[Dict[str, Dict[str, KeySpec]], Dict[str, Dict[str, Any]]]:
    """
    Load a *template* schema (e.g. ``"data_handler"``) and apply it to many sections.

    Accepted JSON shapes:
      1) Direct sections (no wrapper):
         ... code-block:: JSON

            {
              "data_handler": { "...": { "type": "str", "required": true } }
            }

      2) With project wrapper:
         ... code-block:: JSON

            {
              "projects": {
                "my_project": {
                  "data_handler": { "...": { "type": "str" } }
                }
              }
            }

    The selected template (``template``) is *replicated* for each section name in
    ``sections``. If ``sections`` is omitted, the function uses an empty list and
    still returns parsed ``(schema, defaults)`` for possible later application.

    :param path: Path to the schema JSON.
    :param template: Template object name to use (e.g., ``"data_handler"``).
    :param project: Optional project name when using a ``"projects"`` wrapper.
    :param sections: Section names to which to apply the template; if ``None``,
                     an empty list is assumed (you can still use the returned
                     template schema to apply later).
    :return: ``(schema, defaults)`` for the fabricated root.
    :raises ConfigError: On missing template or invalid shapes.
    """
    raw = load_schema_from_json(path)

    # Optional projects wrapper
    if "projects" in raw:
        projs = raw.get("projects")
        if not isinstance(projs, Mapping):
            raise ConfigError("'projects' in schema must be an object")
        if project is None:
            # You may choose to raise here instead; we keep it permissive.
            root = projs
        else:
            root = projs.get(project, {})
            if not isinstance(root, Mapping):
                raise ConfigError(f"Project '{project}' not found in schema or invalid type")
    else:
        root = raw

    if not isinstance(root, Mapping):
        raise ConfigError("Schema root must be a JSON object.")

    template_spec = root.get(template)
    if not isinstance(template_spec, Mapping):
        raise ConfigError(f"Template '{template}' not found or not an object in schema.")

    target_sections = sections or []
    fabricated_root: Dict[str, Dict[str, Any]] = {sec: template_spec for sec in target_sections}

    if not fabricated_root:
        return {}, {}

    return schema_parse_to_keyspecs(fabricated_root)

make_choices_validator(choices)

Build a validator that ensures the value is one of the allowed choices.

Parameters:

Name Type Description Default
choices Iterable[Any]

Iterable of allowed values (compared using equality).

required

Returns:

Type Description
Validator

A callable that raises ValueError if the value is not allowed.

Source code in src/sciwork/config/schema.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def make_choices_validator(choices: Iterable[Any]) -> Validator:
    """
    Build a validator that ensures the value is one of the allowed *choices*.

    :param choices: Iterable of allowed values (compared using equality).
    :return: A callable that raises ``ValueError`` if the value is not allowed.
    """
    allowed = set(choices)

    def _validator(value: Any) -> None:
        if value not in allowed:
            raise ValueError(f"value {value!r} not in allowed set {sorted(allowed)!r}")

    return _validator

schema_parse_to_keyspecs(root)

Convert a schema root mapping into (KeySpec mapping, default mapping).

Expected per-key spec shape (all fields optional unless noted): ... code-block:: JSON

 {
   "type": "str | int | float | bool | null | list | dict | list[str] | ...",
   "required": true,
   "choices": ["foo", "bar", 10, null],
   "default": <any JSON value>
 }
Notes
  • type may be a string or a list (logical OR). Parametric lists like "list[str]" are accepted, but the expected type is just list; element typing is out of scope for this straightforward validator.
  • When the choices parameter is given, a validator checking membership is attached.
  • default values are collected separately and not applied here.

Parameters:

Name Type Description Default
root Mapping[str, Mapping[str, Any]]

Mapping of section -> mapping(key -> spec).

required

Returns:

Type Description
Tuple[Dict[str, Dict[str, KeySpec]], Dict[str, Dict[str, Any]]]

(schema, defaults) where: - schema is Dict[section][key] -> KeySpec - defaults param is Dict[section][key] -> value (only keys with defaults)

Raises:

Type Description
ConfigError

On invalid shapes or unsupported field types.

Source code in src/sciwork/config/schema.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def schema_parse_to_keyspecs(root: Mapping[str, Mapping[str, Any]]
                             ) -> Tuple[Dict[str, Dict[str, KeySpec]], Dict[str, Dict[str, Any]]]:
    """
    Convert a *schema root* mapping into (KeySpec mapping, default mapping).

    Expected per-key spec shape (all fields optional unless noted):
      ... code-block:: JSON

         {
           "type": "str | int | float | bool | null | list | dict | list[str] | ...",
           "required": true,
           "choices": ["foo", "bar", 10, null],
           "default": <any JSON value>
         }

    Notes
    -----
    * ``type`` may be a string or a list (logical OR). Parametric lists like
      ``"list[str]"`` are accepted, but the expected type is just ``list``; element
      typing is out of scope for this straightforward validator.
    * When the `` choices `` parameter is given, a validator checking membership is attached.
    * ``default`` values are collected separately and *not* applied here.

    :param root: Mapping of ``section -> mapping(key -> spec)``.
    :return: ``(schema, defaults)`` where:
             - ``schema`` is ``Dict[section][key] -> KeySpec``
             - ``defaults`` param is ``Dict[section][key] -> value`` (only keys with defaults)
    :raises ConfigError: On invalid shapes or unsupported field types.
    """
    schema: Dict[str, Dict[str, KeySpec]] = {}
    defaults: Dict[str, Dict[str, Any]] = {}

    for section_name, spec_map in root.items():
        if not isinstance(spec_map, Mapping):
            raise ConfigError(f"Section '{section_name}' spec must be a mapping.")
        sec = str(section_name).lower()
        sec_schema: Dict[str, KeySpec] = {}
        sec_defaults: Dict[str, Any] = {}

        for key_name, key_spec in spec_map.items():
            if not isinstance(key_spec, Mapping):
                raise ConfigError(f"Key '{section_name}.{key_name}' spec must be a mapping.")

            # type
            type_field = key_spec.get("type", "str")
            expected_type = _parse_type_tokens(type_field)

            # required
            required = bool(key_spec.get("required", False))

            # validator via choices (if present)
            validator: Optional[Validator] = None
            if "choices" in key_spec:
                choices = key_spec.get("choices", [])
                if not isinstance(choices, (list, tuple, set)):
                    raise ConfigError(f"'choices' for '{section_name}.{key_name}' must be an array")
                validator = make_choices_validator(choices)

            # assemble KeySpec
            kn = str(key_name).lower()
            sec_schema[kn] = KeySpec(expected_type=expected_type, required=required, validator=validator)

            # default (optional)
            if "default" in key_spec:
                sec_defaults[kn] = key_spec.get("default", None)

        schema[sec] = sec_schema
        if sec_defaults:
            defaults[sec] = sec_defaults

    return schema, defaults

validate_data(data, schema)

Validate presence, types, and custom constraints for data.

For each section defined in schema the validator checks: * missing required keys, * isinstance(value, expected_type) for present keys, * runs optional validator(value).

All problems are aggregated and raised together as ConfigError.

Parameters:

Name Type Description Default
data Mapping[str, Mapping[str, Any]]

Parsed configuration values (section -> key -> value).

required
schema Mapping[str, Mapping[str, KeySpec]]

Validation schema (section -> key -> KeySpec).

required

Raises:

Type Description
ConfigError

When any validation error occurs.

Source code in src/sciwork/config/schema.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
def validate_data(data: Mapping[str, Mapping[str, Any]],
                  schema: Mapping[str, Mapping[str, KeySpec]]) -> None:
    """
    Validate presence, types, and custom constraints for *data*.

    For each section defined in *schema* the validator checks:
      * missing required keys,
      * ``isinstance(value, expected_type)`` for present keys,
      * runs optional ``validator(value)``.

    All problems are aggregated and raised together as ``ConfigError``.

    :param data: Parsed configuration values (``section -> key -> value``).
    :param schema: Validation schema (``section -> key -> KeySpec``).
    :raises ConfigError: When any validation error occurs.
    """
    errors: List[str] = []

    for section_name, key_specs in (schema or {}).items():
        values = data.get(section_name, {}) or {}
        for key_name, spec in key_specs.items():
            # required?
            if spec.required and key_name not in values:
                errors.append(f"[{section_name}] missing required key '{key_name}'")
                continue
            if key_name not in values:
                continue

            value = values[key_name]
            if not isinstance(value, spec.expected_type):
                errors.append(
                    f"[{section_name}] key '{key_name}' expected {spec.expected_type}, "
                    f"got {type(value)} ({value!r})"
                )
                continue

            if spec.validator is not None:
                try:
                    spec.validator(value)
                except Exception as exc:
                    errors.append(f"[{section_name}] key '{key_name}' failed validation: {exc}")

    if errors:
        hint = "Tip: pretty-print your loaded config to inspect values and fix the configuration."
        raise ConfigError("\n".join(errors) + "\n\n" + hint)

store

Thin in-memory store used by RobustConfig

sciwork.config.store

LOG = logging.getLogger(__name__) module-attribute

PathLike = Union[str, Path] module-attribute

__all__ = ['PathLike', 'user_config_dir', 'project_config_dir', 'resolve_config_path', 'ensure_config_file', 'read_text', 'write_text', 'read_json', 'write_json', 'list_configs'] module-attribute

_atomic_write_json(dest, obj, *, indent=2, backup_ext=None)

Atomically write JSON obj to dest with UTF-8 encoding.

Parameters:

Name Type Description Default
dest Path

Destination file path.

required
obj Any

JSON-serializable object to write.

required
indent int

Indentation for readability.

2
backup_ext Optional[str]

Optional backup extension (e.g., ".bak").

None

Raises:

Type Description
TypeError

If obj is not JSON serializable.

OSError

On I/O errors.

Source code in src/sciwork/config/store.py
131
132
133
134
135
136
137
138
139
140
141
142
143
def _atomic_write_json(dest: Path, obj: Any, *, indent: int = 2, backup_ext: Optional[str] = None) -> None:
	"""
	Atomically write JSON *obj* to *dest* with UTF-8 encoding.

	:param dest: Destination file path.
	:param obj: JSON-serializable object to write.
	:param indent: Indentation for readability.
	:param backup_ext: Optional backup extension (e.g., ``".bak"``).
	:raises TypeError: If *obj* is not JSON serializable.
	:raises OSError: On I/O errors.
	"""
	text = json.dumps(obj, ensure_ascii=False, indent=indent)
	_atomic_write_text(dest, text, encoding="utf-8", backup_ext=backup_ext)

_atomic_write_text(dest, text, *, encoding='utf-8', backup_ext=None)

Atomically write text to dest. Optionally, create a backup of the original.

Strategy: - write to a temporary file in the same directory, - flush + fsync, - optional backup, - os.replace(temp, dest) (atomic on POSIX/NTFS).

Parameters:

Name Type Description Default
dest Path

Destination file path.

required
text str

Text content to write.

required
encoding str

Target encoding.

'utf-8'
backup_ext Optional[str]

If provided (e.g., ".bak"), make a backup of dest when it exists.

None

Raises:

Type Description
OSError

On I/O errors.

Source code in src/sciwork/config/store.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def _atomic_write_text(dest: Path, text: str, *, encoding: str = "utf-8", backup_ext: Optional[str] = None) -> None:
	"""
	Atomically write *text* to *dest*. Optionally, create a backup of the original.

	Strategy:
		- write to a temporary file in the same directory,
		- flush + fsync,
		- optional backup,
		- os.replace(temp, dest) (atomic on POSIX/NTFS).

	:param dest: Destination file path.
	:param text: Text content to write.
	:param encoding: Target encoding.
	:param backup_ext: If provided (e.g., ``".bak"``), make a backup of *dest* when it exists.
	:raises OSError: On I/O errors.
	"""
	_ensure_parent(dest)
	tmp_fd, tmp_path = tempfile.mkstemp(prefix=dest.name + ".", dir=str(dest.parent))
	try:
		with os.fdopen(tmp_fd, "w", encoding=encoding, newline="\n") as fh:
			fh.write(text)
			fh.flush()
			os.fsync(fh.fileno())

		if backup_ext and dest.exists():
			backup = dest.with_suffix(dest.suffix + backup_ext)
			try:
				if backup.exists():
					backup.unlink()
				dest.replace(backup)
			except Exception:
				LOG.warning("Failed to create backup for %s", dest, exc_info=True)

		os.replace(tmp_path, dest)
	except Exception:
		# best-effort cleanup
		try:
			if os.path.exists(tmp_path):
				os.remove(tmp_path)
		except Exception:
			pass
		raise

_ensure_parent(path)

Create parent directories for path if missing.

Source code in src/sciwork/config/store.py
82
83
84
def _ensure_parent(path: Path) -> None:
	"""Create parent directories for *path* if missing."""
	path.parent.mkdir(parents=True, exist_ok=True)

ensure_config_file(path, *, initial=None, overwrite=False)

Ensure a text config file exists at path. If missing (or overwrite=True), write initial.

Parameters:

Name Type Description Default
path PathLike

Target config path.

required
initial Optional[str]

Initial file content to write (empty string when None).

None
overwrite bool

Rewrite even if the file already exists.

False

Returns:

Type Description
Path

The absolute path to the file.

Raises:

Type Description
OSError

On I/O errors.

Source code in src/sciwork/config/store.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def ensure_config_file(path: PathLike, *, initial: Optional[str] = None, overwrite: bool = False) -> Path:
	"""
	Ensure a text config file exists at *path*. If missing (or ``overwrite=True``), write *initial*.

	:param path: Target config path.
	:param initial: Initial file content to write (empty string when None).
	:param overwrite: Rewrite even if the file already exists.
	:return: The absolute path to the file.
	:raises OSError: On I/O errors.
	"""
	dest = Path(path).expanduser().resolve()
	if dest.exists() and not overwrite:
		return dest
	_atomic_write_text(dest, initial or "", encoding="utf-8", backup_ext=None)
	LOG.info("Created config file at %s", dest)
	return dest

list_configs(directory, pattern='*.json')

Return a list of config files in directory matching pattern.

Parameters:

Name Type Description Default
directory PathLike

Directory to search for config files.

required
pattern str

Glob pattern (default *.json).

'*.json'

Returns:

Type Description
list[Path]

List of absolute Paths.

Source code in src/sciwork/config/store.py
252
253
254
255
256
257
258
259
260
261
def list_configs(directory: PathLike, pattern: str = "*.json") -> list[Path]:
	"""
	Return a list of config files in *directory* matching *pattern*.

	:param directory: Directory to search for config files.
	:param pattern: Glob pattern (default ``*.json``).
	:return: List of absolute Paths.
	"""
	d = Path(directory).expanduser().resolve()
	return sorted(p.resolve() for p in d.glob(pattern))

project_config_dir(project_root=None, app='sciwork')

Return a project-local configuration directory <project_root>/<app>/configs.

Parameters:

Name Type Description Default
project_root Optional[PathLike]

Project root directory. If None, uses Path.cwd().

None
app str

Application namespace directory name.

'sciwork'

Returns:

Type Description
Path

Absolute path to the project config directory (not guaranteed to exist).

Source code in src/sciwork/config/store.py
32
33
34
35
36
37
38
39
40
41
def project_config_dir(project_root: Optional[PathLike] = None, app: str = "sciwork") -> Path:
	"""
	Return a project-local configuration directory ``<project_root>/<app>/configs``.

	:param project_root: Project root directory. If ``None``, uses ``Path.cwd()``.
	:param app: Application namespace directory name.
	:return: Absolute path to the project config directory (not guaranteed to exist).
	"""
	root = Path(project_root) if project_root is not None else Path.cwd()
	return (root / app / "configs").resolve()

read_json(path)

Read and parse JSON from path.

Parameters:

Name Type Description Default
path PathLike

JSON file path.

required

Returns:

Type Description
Any

Parsed Python object.

Raises:

Type Description
FileNotFoundError

If the file does not exist.

json.JSONDecodeError

For invalid JSON.

OSError

On I/O errors.

Source code in src/sciwork/config/store.py
208
209
210
211
212
213
214
215
216
217
218
219
220
def read_json(path: PathLike) -> Any:
	"""
	Read and parse JSON from *path*.

	:param path: JSON file path.
	:return: Parsed Python object.
	:raises FileNotFoundError: If the file does not exist.
	:raises json.JSONDecodeError: For invalid JSON.
	:raises OSError: On I/O errors.
	"""
	p = Path(path).expanduser().resolve()
	with p.open("r", encoding="utf-8") as fh:
		return json.load(fh)

read_text(path, *, encoding='utf-8')

Read a text file.

Parameters:

Name Type Description Default
path PathLike

File path.

required
encoding str

Text encoding.

'utf-8'

Returns:

Type Description
str

File contents as a string.

Raises:

Type Description
FileNotFoundError

If the file does not exist.

OSError

On I/O errors.

Source code in src/sciwork/config/store.py
165
166
167
168
169
170
171
172
173
174
175
176
177
def read_text(path: PathLike, *, encoding: str = "utf-8") -> str:
	"""
	Read a text file.

	:param path: File path.
	:param encoding: Text encoding.
	:return: File contents as a string.
	:raises FileNotFoundError: If the file does not exist.
	:raises OSError: On I/O errors.
	"""
	p = Path(path).expanduser().resolve()
	with p.open("r", encoding=encoding) as fh:
		return fh.read()

resolve_config_path(name, *, prefer='user', project_root=None, env_var=None, app='sciwork')

Resolve an absolute path for a config file name with a clear precedence.

Precedence: 1) If env_var is provided and the environment variable is set → use that path. 2) If prefer == 'project'project_config_dir(project_root)/name. 3) Otherwise → user_config_dir(app)/name.

Parameters:

Name Type Description Default
name str

File name (e.g., "ansi_colors.json").

required
prefer Literal['user', 'project']

Either 'user' or 'project'.

'user'
project_root Optional[PathLike]

Optional project root for project-local resolution.

None
env_var Optional[str]

Optional environment variable that can override the path.

None
app str

Application namespace directory.

'sciwork'

Returns:

Type Description
Path

The absolute path (may or may not exist yet).

Raises:

Type Description
ValueError

If prefer is not 'user' or 'project'.

Source code in src/sciwork/config/store.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def resolve_config_path(
		name: str,
		*,
		prefer: Literal['user', 'project'] = "user",
		project_root: Optional[PathLike] = None,
		env_var: Optional[str] = None,
		app: str = "sciwork",
) -> Path:
	"""
	Resolve an absolute path for a config file *name* with a clear precedence.

	Precedence:
		1) If ``env_var`` is provided and the environment variable is set → use that path.
		2) If ``prefer == 'project'`` → ``project_config_dir(project_root)/name``.
		3) Otherwise → ``user_config_dir(app)/name``.

	:param name: File name (e.g., ``"ansi_colors.json"``).
	:param prefer: Either ``'user'`` or ``'project'``.
	:param project_root: Optional project root for project-local resolution.
	:param env_var: Optional environment variable that can override the path.
	:param app: Application namespace directory.
	:return: The absolute path (may or may not exist yet).
	:raises ValueError: If ``prefer`` is not ``'user'`` or ``'project'``.
	"""
	if env_var:
		override_var = os.getenv(env_var)
		if override_var:
			return Path(override_var).expanduser().resolve()

	if prefer == 'project':
		return (project_config_dir(project_root, app) / name).resolve()
	if prefer == 'user':
		return (user_config_dir(app) / name).resolve()

	raise ValueError(f"prefer must be 'user' or 'project', not {prefer}")

user_config_dir(app='sciwork')

Return a per-user configuration directory.

On Windows this is %APPDATA%/<app>, on POSIX $XDG_CONFIG_HOME/<app> or ~/.config/<app>.

Parameters:

Name Type Description Default
app str

Application namespace directory name.

'sciwork'

Returns:

Type Description
Path

Absolute path to the user config directory (not guaranteed to exist).

Source code in src/sciwork/config/store.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def user_config_dir(app: str = "sciwork") -> Path:
	"""
	Return a per-user configuration directory.

	On Windows this is ``%APPDATA%/<app>``, on POSIX ``$XDG_CONFIG_HOME/<app>`` or ``~/.config/<app>``.

	:param app: Application namespace directory name.
	:return: Absolute path to the user config directory (not guaranteed to exist).
	"""
	if os.name == "nt":
		base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Roaming"))
	else:
		base = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
	return (base / app).resolve()

write_json(path, obj, *, indent=2, overwrite=True, backup_ext='.bak')

Write JSON atomically, optionally creating a backup.

Parameters:

Name Type Description Default
path PathLike

Destination JSON path.

required
obj Any

JSON-serializable object to write.

required
indent int

Indent for pretty output.

2
overwrite bool

If False and the file exist, raise FileExistsError.

True
backup_ext Optional[str]

Backup extension (None disables backups).

'.bak'

Returns:

Type Description
Path

The absolute path of the file.

Raises:

Type Description
FileExistsError

If destination exists and overwrite=False.

TypeError

If the object is not JSON-serializable.

OSError

On I/O errors.

Source code in src/sciwork/config/store.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def write_json(
		path: PathLike,
		obj: Any,
		*,
		indent: int = 2,
		overwrite: bool = True,
		backup_ext: Optional[str] = ".bak"
) -> Path:
	"""
	Write JSON atomically, optionally creating a backup.

	:param path: Destination JSON path.
	:param obj: JSON-serializable object to write.
	:param indent: Indent for pretty output.
	:param overwrite: If ``False`` and the file exist, raise ``FileExistsError``.
	:param backup_ext: Backup extension (``None`` disables backups).
	:return: The absolute path of the file.
	:raises FileExistsError: If destination exists and ``overwrite=False``.
	:raises TypeError: If the object is not JSON-serializable.
	:raises OSError: On I/O errors.
	"""
	dest = Path(path).expanduser().resolve()
	if dest.exists() and not overwrite:
		raise FileExistsError(f"Destination file already exists at {dest}")
	_atomic_write_json(dest, obj, indent=indent, backup_ext=backup_ext)
	LOG.info("Wrote JSON to %s", dest)
	return dest

write_text(path, text, *, encoding='utf-8', overwrite=True, backup_ext='.bak')

Write a text file atomically, optionally creating a backup of the previous content.

Parameters:

Name Type Description Default
path PathLike

Destination path.

required
text str

Content to write.

required
encoding str

Target encoding.

'utf-8'
overwrite bool

If False and the file exist, raise FileExistsError.

True
backup_ext Optional[str]

Backup extension; set None to disable backups.

'.bak'

Returns:

Type Description
Path

Absolute path written.

Raises:

Type Description
FileExistsError

When destination exists and overwrite=False.

OSError

On I/O errors.

Source code in src/sciwork/config/store.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def write_text(
		path: PathLike,
		text: str,
		*,
		encoding: str = "utf-8",
		overwrite: bool = True,
		backup_ext: Optional[str] = ".bak"
) -> Path:
	"""
	Write a text file atomically, optionally creating a backup of the previous content.

	:param path: Destination path.
	:param text: Content to write.
	:param encoding: Target encoding.
	:param overwrite: If ``False`` and the file exist, raise ``FileExistsError``.
	:param backup_ext: Backup extension; set ``None`` to disable backups.
	:return: Absolute path written.
	:raises FileExistsError: When destination exists and ``overwrite=False``.
	:raises OSError: On I/O errors.
	"""
	dest = Path(path).expanduser().resolve()
	if dest.exists() and not overwrite:
		raise FileExistsError(f"Destination file already exists at {dest}")
	_atomic_write_text(dest, text, encoding=encoding, backup_ext=backup_ext)
	LOG.info("Wrote text to %s", dest)
	return dest

templates

Utilities for simple template applications

sciwork.config.templates

LOG = logging.getLogger(__name__) module-attribute

PathLike = Union[str, Path] module-attribute

KeySpec(expected_type, required=False, validator=None) dataclass

Specification for a configuration key used during validation.

Parameters:

Name Type Description Default
expected_type Union[type, Tuple[type, ...]]

Allowed type (or tuple of types) for the key's value. Use Python types (e.g., str, int, list) or a tuple like (int, type(None)) to allow None.

required
required bool

Whether the key must be present in the section.

False
validator Optional[Validator]

Optional callable that receives the parsed value and must raise on invalid content.

None

_build_mapping_from_schema(schema, defaults, *, sections=None, include_defaults=True, placeholder='')

Build a section→key→value mapping from a parsed schema and defaults.

If sections are provided, only those sections are included. Otherwise, all sections found in schema are used.

Parameters:

Name Type Description Default
schema Mapping[str, Mapping[str, KeySpec]]

Section → key → KeySpec.

required
defaults Mapping[str, Mapping[str, Any]]

Section → key → default (optional keys only).

required
sections Optional[Iterable[str]]

Optional subset of section names to include.

None
include_defaults bool

When True, use defaults where available.

True
placeholder Optional[str]

Value used for keys without defaults (can be '', None, or text).

''

Returns:

Type Description
Dict[str, Dict[str, Any]]

Dictionary ready to be dumped as INI/JSON content.

Source code in src/sciwork/config/templates.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def _build_mapping_from_schema(
		schema: Mapping[str, Mapping[str, KeySpec]],
		defaults: Mapping[str, Mapping[str, Any]],
		*,
		sections: Optional[Iterable[str]] = None,
		include_defaults: bool = True,
		placeholder: Optional[str] = ""
) -> Dict[str, Dict[str, Any]]:
	"""
	Build a section→key→value mapping from a parsed schema and defaults.

	If *sections* are provided, only those sections are included. Otherwise,
	all sections found in *schema* are used.

	:param schema: Section → key → KeySpec.
	:param defaults: Section → key → default (optional keys only).
	:param sections: Optional subset of section names to include.
	:param include_defaults: When True, use defaults where available.
	:param placeholder: Value used for keys without defaults (can be '', None, or text).
	:return: Dictionary ready to be dumped as INI/JSON content.
	"""
	target_sections = list(sections) if sections else list(schema.keys())
	out: Dict[str, Dict[str, Any]] = {}

	for sec in target_sections:
		keyspecs = schema.get(sec, {})
		sec_defaults = defaults.get(sec, {}) if include_defaults else {}
		bucket: Dict[str, Any] = {}

		for key, spec in keyspecs.items():
			if include_defaults and key in sec_defaults:
				bucket[key] = sec_defaults[key]
			else:
				bucket[key] = placeholder
		out[sec] = bucket

	return out

_ensure_parent(path)

Create parent directories for path if missing.

Source code in src/sciwork/config/templates.py
18
19
20
def _ensure_parent(path: Path) -> None:
	"""Create parent directories for *path* if missing."""
	path.parent.mkdir(parents=True, exist_ok=True)

_to_ini_scalar(value)

Convert a Python value to a string suitable for INI emission.

Strategy: * None -> "null" * bool -> "true"/"false" * numbers/strings -> str(value) * lists/dicts/other -> JSON

This matches our parser in that: - "null" is recognized as None - "true"/"false" -> booleans, - JSON-like for complex types is safely parseable again.

Parameters:

Name Type Description Default
value Any

Python value to convert.

required

Returns:

Type Description
str

String suitable for INI emission.

Source code in src/sciwork/config/templates.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def _to_ini_scalar(value: Any) -> str:
	"""
	Convert a Python value to a string suitable for INI emission.

	Strategy:
		* None -> "null"
		* bool -> "true"/"false"
		* numbers/strings -> str(value)
		* lists/dicts/other -> JSON

	This matches our parser in that:
		- "null" is recognized as None
		- "true"/"false" -> booleans,
		- JSON-like for complex types is safely parseable again.

	:param value: Python value to convert.
	:return: String suitable for INI emission.
	"""
	if value is None:
		return "null"
	if isinstance(value, bool):
		return "true" if value else "false"
	if isinstance(value, (int, float, str)):
		return str(value)
	# lists, dicts, tuples... -> JSON
	try:
		return json.dumps(value, ensure_ascii=False)
	except Exception:
		return str(value)

load_schema_template_from_json(path, *, template, project=None, sections=None)

Load a template schema (e.g. "data_handler") and apply it to many sections.

Accepted JSON shapes: 1) Direct sections (no wrapper): ... code-block:: JSON

    {
      "data_handler": { "...": { "type": "str", "required": true } }
    }

2) With project wrapper: ... code-block:: JSON

    {
      "projects": {
        "my_project": {
          "data_handler": { "...": { "type": "str" } }
        }
      }
    }

The selected template (template) is replicated for each section name in sections. If sections is omitted, the function uses an empty list and still returns parsed (schema, defaults) for possible later application.

Parameters:

Name Type Description Default
path PathLike

Path to the schema JSON.

required
template str

Template object name to use (e.g., "data_handler").

required
project Optional[str]

Optional project name when using a "projects" wrapper.

None
sections Optional[List[str]]

Section names to which to apply the template; if None, an empty list is assumed (you can still use the returned template schema to apply later).

None

Returns:

Type Description
Tuple[Dict[str, Dict[str, KeySpec]], Dict[str, Dict[str, Any]]]

(schema, defaults) for the fabricated root.

Raises:

Type Description
ConfigError

On missing template or invalid shapes.

Source code in src/sciwork/config/schema.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def load_schema_template_from_json(
    path: PathLike,
    *,
    template: str,
    project: Optional[str] = None,
    sections: Optional[List[str]] = None,
) -> Tuple[Dict[str, Dict[str, KeySpec]], Dict[str, Dict[str, Any]]]:
    """
    Load a *template* schema (e.g. ``"data_handler"``) and apply it to many sections.

    Accepted JSON shapes:
      1) Direct sections (no wrapper):
         ... code-block:: JSON

            {
              "data_handler": { "...": { "type": "str", "required": true } }
            }

      2) With project wrapper:
         ... code-block:: JSON

            {
              "projects": {
                "my_project": {
                  "data_handler": { "...": { "type": "str" } }
                }
              }
            }

    The selected template (``template``) is *replicated* for each section name in
    ``sections``. If ``sections`` is omitted, the function uses an empty list and
    still returns parsed ``(schema, defaults)`` for possible later application.

    :param path: Path to the schema JSON.
    :param template: Template object name to use (e.g., ``"data_handler"``).
    :param project: Optional project name when using a ``"projects"`` wrapper.
    :param sections: Section names to which to apply the template; if ``None``,
                     an empty list is assumed (you can still use the returned
                     template schema to apply later).
    :return: ``(schema, defaults)`` for the fabricated root.
    :raises ConfigError: On missing template or invalid shapes.
    """
    raw = load_schema_from_json(path)

    # Optional projects wrapper
    if "projects" in raw:
        projs = raw.get("projects")
        if not isinstance(projs, Mapping):
            raise ConfigError("'projects' in schema must be an object")
        if project is None:
            # You may choose to raise here instead; we keep it permissive.
            root = projs
        else:
            root = projs.get(project, {})
            if not isinstance(root, Mapping):
                raise ConfigError(f"Project '{project}' not found in schema or invalid type")
    else:
        root = raw

    if not isinstance(root, Mapping):
        raise ConfigError("Schema root must be a JSON object.")

    template_spec = root.get(template)
    if not isinstance(template_spec, Mapping):
        raise ConfigError(f"Template '{template}' not found or not an object in schema.")

    target_sections = sections or []
    fabricated_root: Dict[str, Dict[str, Any]] = {sec: template_spec for sec in target_sections}

    if not fabricated_root:
        return {}, {}

    return schema_parse_to_keyspecs(fabricated_root)

render_ini_from_template(schema_json_path, *, template, sections, project=None, include_defaults=True, placeholder='')

Render an INI text from a JSON schema template applied to many sections.

This function: 1) loads a schema JSON file (optionally inside a projects wrapper), 2) extracts the chosen template object, 3) applies it to the provided sections, 4) returns an INI-formatted string and the intermediate mapping used.

Parameters:

Name Type Description Default
schema_json_path PathLike

Path to the schema JSON.

required
template str

Template object name in the schema (e.g., "data_handler").

required
sections Iterable[str]

Section names to include in the generated INI.

required
project Optional[str]

Optional project name when schema uses a "projects" wrapper.

None
include_defaults bool

Insert defaults from schema when available.

True
placeholder Optional[str]

Value for keys without defaults (e.g., "" or "<fill>").

''

Returns:

Type Description
Tuple[str, Dict[str, Dict[str, Any]]]

(ini_text, mapping) where mapping is section -> key -> value.

Raises:

Type Description
ConfigError

On IO/parse errors or invalid schema shapes.

Source code in src/sciwork/config/templates.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def render_ini_from_template(
		schema_json_path: PathLike,
		*,
		template: str,
		sections: Iterable[str],
		project: Optional[str] = None,
		include_defaults: bool = True,
		placeholder: Optional[str] = ""
) -> Tuple[str, Dict[str, Dict[str, Any]]]:
	"""
	Render an INI text from a JSON schema **template** applied to many sections.

	This function:
		1) loads a schema JSON file (optionally inside a ``projects`` wrapper),
		2) extracts the chosen ``template`` object,
		3) applies it to the provided *sections*,
		4) returns an INI-formatted string and the intermediate mapping used.

	:param schema_json_path: Path to the schema JSON.
	:param template: Template object name in the schema (e.g., ``"data_handler"``).
	:param sections: Section names to include in the generated INI.
	:param project: Optional project name when schema uses a ``"projects"`` wrapper.
	:param include_defaults: Insert defaults from schema when available.
	:param placeholder: Value for keys without defaults (e.g., ``""`` or ``"<fill>"``).
	:return: ``(ini_text, mapping)`` where *mapping* is ``section -> key -> value``.
	:raises ConfigError: On IO/parse errors or invalid schema shapes.
	"""
	# Parse schema template → (KeySpec mapping, defaults)
	parsed_schema, defaults = load_schema_template_from_json(
		schema_json_path,
		template=template,
		project=project,
		sections=list(sections)
	)
	mapping = _build_mapping_from_schema(
		parsed_schema,
		defaults=defaults,
		sections=sections,
		include_defaults=include_defaults,
		placeholder=placeholder
	)

	# Compose INI text manually (we avoid extra interpolation side effects)
	lines: list[str] = []
	for sec in mapping:
		lines.append(f"[{sec}]")
		for key, val in mapping[sec].items():
			lines.append(f"{key} = {_to_ini_scalar(val)}")
		lines.append("")  # blank line between sections

	ini_text = "\n".join(lines).rstrip() + "\n"
	return ini_text, mapping

render_json_from_template(schema_json_path, *, template, sections, project=None, include_defaults=True, placeholder='', drop_nulls=False)

Render a JSON-serializable mapping from a schema template.

Parameters:

Name Type Description Default
schema_json_path PathLike

Path to the schema JSON.

required
template str

Template object name (e.g., "data_handler").

required
sections Iterable[str]

Section names to include.

required
project Optional[str]

Optional project name when schema uses "projects" wrapper.

None
include_defaults bool

Insert defaults from schema when available.

True
placeholder Optional[str]

Value for keys without defaults (e.g., "").

''
drop_nulls bool

If True, omit keys whose value is None.

False

Returns:

Type Description
Dict[str, Dict[str, Any]]

A mapping section -> {key: value} is ready for JSON dumping.

Raises:

Type Description
ConfigError

On schema/template errors.

Source code in src/sciwork/config/templates.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def render_json_from_template(
		schema_json_path: PathLike,
		*,
		template: str,
		sections: Iterable[str],
		project: Optional[str] = None,
		include_defaults: bool = True,
		placeholder: Optional[str] = "",
		drop_nulls: bool = False
) -> Dict[str, Dict[str, Any]]:
	"""
	Render a JSON-serializable mapping from a schema **template**.

	:param schema_json_path: Path to the schema JSON.
	:param template: Template object name (e.g., ``"data_handler"``).
	:param sections: Section names to include.
	:param project: Optional project name when schema uses ``"projects"`` wrapper.
	:param include_defaults: Insert defaults from schema when available.
	:param placeholder: Value for keys without defaults (e.g., ``""``).
	:param drop_nulls: If True, omit keys whose value is ``None``.
	:return: A mapping ``section -> {key: value}`` is ready for JSON dumping.
	:raises ConfigError: On schema/template errors.
	"""
	parsed_schema, defaults = load_schema_template_from_json(
		schema_json_path,
		template=template,
		project=project,
		sections=list(sections),
	)
	mapping = _build_mapping_from_schema(
		parsed_schema,
		defaults,
		sections=sections,
		include_defaults=include_defaults,
		placeholder=placeholder,
	)

	if drop_nulls:
		for sec, kv in list(mapping.items()):
			mapping[sec] = {k: v for k, v in kv.items() if v is not None}
	return mapping

write_ini_from_template(schema_json_path, dest_path, *, template, sections, project=None, include_defaults=True, placeholder='', header_comment=None, overwrite=False)

Generate and write an INI file from a schema template.

Parameters:

Name Type Description Default
schema_json_path PathLike

Path to the schema JSON.

required
dest_path PathLike

Where to write the INI file.

required
template str

Template object name (e.g., "data_handler").

required
sections Iterable[str]

Section names to include.

required
project Optional[str]

Optional project name when using "projects" wrapper.

None
include_defaults bool

Insert defaults when present in schema.

True
placeholder Optional[str]

Placeholder value for keys without defaults.

''
header_comment Optional[str]

Optional multi-line text to add at the top as ; comments.

None
overwrite bool

When False and file exist, it raises FileExistsError.

False

Returns:

Type Description
Path

Absolute path to the written INI file.

Raises:

Type Description
FileExistsError

If the destination exists and overwrite=False.

OSError

On write errors.

ConfigError

On schema/template errors.

Source code in src/sciwork/config/templates.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def write_ini_from_template(
		schema_json_path: PathLike,
		dest_path: PathLike,
		*,
		template: str,
		sections: Iterable[str],
		project: Optional[str] = None,
		include_defaults: bool = True,
		placeholder: Optional[str] = "",
		header_comment: Optional[str] = None,
		overwrite: bool = False,
) -> Path:
	"""
	Generate and write an INI file from a schema **template**.

	:param schema_json_path: Path to the schema JSON.
	:param dest_path: Where to write the INI file.
	:param template: Template object name (e.g., ``"data_handler"``).
	:param sections: Section names to include.
	:param project: Optional project name when using ``"projects"`` wrapper.
	:param include_defaults: Insert defaults when present in schema.
	:param placeholder: Placeholder value for keys without defaults.
	:param header_comment: Optional multi-line text to add at the top as ``;`` comments.
	:param overwrite: When False and file exist, it raises ``FileExistsError``.
	:return: Absolute path to the written INI file.
	:raises FileExistsError: If the destination exists and ``overwrite=False``.
	:raises OSError: On write errors.
	:raises ConfigError: On schema/template errors.
	"""
	dest = Path(dest_path).resolve()
	if dest.exists() and not overwrite:
		raise FileExistsError(f"Destination already exists: {dest}")

	ini_text, _ = render_ini_from_template(
		schema_json_path,
		template=template,
		sections=sections,
		project=project,
		include_defaults=include_defaults,
		placeholder=placeholder
	)

	_ensure_parent(dest)
	try:
		with dest.open("w", encoding="utf-8", newline="\n") as fh:
			if header_comment:
				for line in header_comment.strip("\n").splitlines():
					fh.write(f";{line}\n")
				fh.write("\n")
			fh.write(ini_text)
	except Exception as exc:
		LOG.exception("Failed writing INI to %s: %s", dest, exc)
		raise
	LOG.info("Wrote INI template to %s", dest)
	return dest

write_json_from_template(schema_json_path, dest_path, *, template, sections, project=None, include_defaults=True, placeholder='', drop_nulls=False, overwrite=False, indent=2)

Generate and write a JSON configuration from a schema template.

Parameters:

Name Type Description Default
schema_json_path PathLike

Path to the schema JSON.

required
dest_path PathLike

Destination JSON file path.

required
template str

Template object name.

required
sections Iterable[str]

Section names to include in output.

required
project Optional[str]

Optional project name when using a "projects" wrapper.

None
include_defaults bool

Insert defaults when present in schema.

True
placeholder Optional[str]

Placeholder value for missing defaults.

''
drop_nulls bool

Remove keys with the value None from the output.

False
overwrite bool

When False and file exist, it raises FileExistsError.

False
indent int

JSON indent for readability (default 2).

2

Returns:

Type Description
Path

Absolute path to a written JSON file.

Raises:

Type Description
FileExistsError

If the destination exists and overwrite=False.

OSError

On write errors.

ConfigError

On schema/template errors.

Source code in src/sciwork/config/templates.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def write_json_from_template(
		schema_json_path: PathLike,
		dest_path: PathLike,
		*,
		template: str,
		sections: Iterable[str],
		project: Optional[str] = None,
		include_defaults: bool = True,
		placeholder: Optional[str] = "",
		drop_nulls: bool = False,
		overwrite: bool = False,
		indent: int = 2
) -> Path:
	"""
	Generate and write a JSON configuration from a schema **template**.

	:param schema_json_path: Path to the schema JSON.
	:param dest_path: Destination JSON file path.
	:param template: Template object name.
	:param sections: Section names to include in output.
	:param project: Optional project name when using a ``"projects"`` wrapper.
	:param include_defaults: Insert defaults when present in schema.
	:param placeholder: Placeholder value for missing defaults.
	:param drop_nulls: Remove keys with the value ``None`` from the output.
	:param overwrite: When False and file exist, it raises ``FileExistsError``.
	:param indent: JSON indent for readability (default 2).
	:return: Absolute path to a written JSON file.
	:raises FileExistsError: If the destination exists and ``overwrite=False``.
	:raises OSError: On write errors.
	:raises ConfigError: On schema/template errors.
	"""
	dest = Path(dest_path).resolve()
	if dest.exists() and not overwrite:
		raise FileExistsError(f"Destination already exists: {dest}")

	payload = render_json_from_template(
		schema_json_path,
		template=template,
		sections=sections,
		project=project,
		include_defaults=include_defaults,
		placeholder=placeholder,
		drop_nulls=drop_nulls,
	)

	_ensure_parent(dest)
	try:
		with dest.open("w", encoding="utf-8") as fh:
			json.dump(payload, fh, ensure_ascii=False, indent=indent)
	except Exception as exc:
		LOG.exception("Failed writing JSON to %s: %s", dest, exc)
		raise
	LOG.info("Wrote JSON template to %s", dest)
	return dest