tigrcorn-config 0.3.16.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,46 @@
1
+ from .audit import parser_public_defaults, resolve_effective_defaults
2
+ from .env import load_env_config
3
+ from .files import load_config_file
4
+ from .load import build_config, build_config_from_namespace, build_config_from_sources, config_to_dict
5
+ from .model import (
6
+ AppConfig,
7
+ HTTPConfig,
8
+ ListenerConfig,
9
+ LoggingConfig,
10
+ MetricsConfig,
11
+ ProcessConfig,
12
+ ProxyConfig,
13
+ QUICConfig,
14
+ SchedulerConfig,
15
+ ServerConfig,
16
+ TLSConfig,
17
+ WebSocketConfig,
18
+ )
19
+ from .profiles import get_profile_spec, list_blessed_profiles, resolve_effective_profile_mapping, resolve_profile_spec
20
+
21
+ __all__ = [
22
+ "AppConfig",
23
+ "build_config",
24
+ "build_config_from_namespace",
25
+ "build_config_from_sources",
26
+ "config_to_dict",
27
+ "get_profile_spec",
28
+ "HTTPConfig",
29
+ "ListenerConfig",
30
+ "list_blessed_profiles",
31
+ "load_config_file",
32
+ "load_env_config",
33
+ "LoggingConfig",
34
+ "MetricsConfig",
35
+ "ProcessConfig",
36
+ "ProxyConfig",
37
+ "QUICConfig",
38
+ "parser_public_defaults",
39
+ "resolve_effective_defaults",
40
+ "SchedulerConfig",
41
+ "ServerConfig",
42
+ "TLSConfig",
43
+ "WebSocketConfig",
44
+ "resolve_effective_profile_mapping",
45
+ "resolve_profile_spec",
46
+ ]
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import dataclasses
5
+ import re
6
+ from typing import Any
7
+
8
+ from .defaults import default_config
9
+ from .load import build_config, config_to_dict
10
+ from .model import ServerConfig
11
+ from .profiles import get_profile_spec, list_blessed_profiles
12
+
13
+
14
+ _RUNTIME_RANDOMIZED = '<runtime-randomized>'
15
+ _RUNTIME_RANDOMIZED_PATTERNS = (
16
+ re.compile(r'^listeners\[\d+\]\.quic_secret$'),
17
+ )
18
+
19
+
20
+ def _jsonable(value: Any) -> Any:
21
+ if dataclasses.is_dataclass(value):
22
+ return {field.name: _jsonable(getattr(value, field.name)) for field in dataclasses.fields(value)}
23
+ if isinstance(value, dict):
24
+ return {str(key): _jsonable(item) for key, item in value.items()}
25
+ if isinstance(value, list):
26
+ return [_jsonable(item) for item in value]
27
+ if isinstance(value, tuple):
28
+ return [_jsonable(item) for item in value]
29
+ if isinstance(value, bytes):
30
+ return value.decode('latin1')
31
+ return value
32
+
33
+
34
+ def _sanitize_runtime_audit_values(value: Any, *, prefix: str = '') -> Any:
35
+ if isinstance(value, dict):
36
+ return {
37
+ key: _sanitize_runtime_audit_values(item, prefix=f'{prefix}.{key}' if prefix else str(key))
38
+ for key, item in value.items()
39
+ }
40
+ if isinstance(value, list):
41
+ return [
42
+ _sanitize_runtime_audit_values(item, prefix=f'{prefix}[{index}]')
43
+ for index, item in enumerate(value)
44
+ ]
45
+ if any(pattern.match(prefix) for pattern in _RUNTIME_RANDOMIZED_PATTERNS) and value is not None:
46
+ return _RUNTIME_RANDOMIZED
47
+ return value
48
+
49
+
50
+ def _flatten(value: Any, *, prefix: str = '') -> dict[str, Any]:
51
+ result: dict[str, Any] = {}
52
+ if isinstance(value, dict):
53
+ for key, item in value.items():
54
+ child_prefix = f'{prefix}.{key}' if prefix else str(key)
55
+ result.update(_flatten(item, prefix=child_prefix))
56
+ return result
57
+ if isinstance(value, list):
58
+ for index, item in enumerate(value):
59
+ child_prefix = f'{prefix}[{index}]'
60
+ result.update(_flatten(item, prefix=child_prefix))
61
+ if not value and prefix:
62
+ result[prefix] = []
63
+ return result
64
+ result[prefix] = value
65
+ return result
66
+
67
+
68
+ def _diff(before: Any, after: Any) -> Any:
69
+ if isinstance(before, dict) and isinstance(after, dict):
70
+ diff: dict[str, Any] = {}
71
+ for key in sorted(set(before) | set(after)):
72
+ if key not in before:
73
+ diff[key] = {'from': None, 'to': after[key]}
74
+ continue
75
+ if key not in after:
76
+ diff[key] = {'from': before[key], 'to': None}
77
+ continue
78
+ child = _diff(before[key], after[key])
79
+ if child not in ({}, None):
80
+ diff[key] = child
81
+ return diff
82
+ if before != after:
83
+ return {'from': before, 'to': after}
84
+ return {}
85
+
86
+
87
+ def parser_public_defaults() -> list[dict[str, Any]]:
88
+ from tigrcorn_runtime.cli import build_parser
89
+
90
+ parser = build_parser()
91
+ rows: list[dict[str, Any]] = []
92
+ for action in parser._actions:
93
+ if isinstance(action, argparse._HelpAction):
94
+ continue
95
+ if action.help == argparse.SUPPRESS:
96
+ continue
97
+ for flag in action.option_strings:
98
+ rows.append(
99
+ {
100
+ 'flag': flag,
101
+ 'dest': action.dest,
102
+ 'parser_default': _jsonable(action.default),
103
+ 'help': action.help,
104
+ 'choices': list(action.choices) if action.choices is not None else None,
105
+ 'required': bool(action.required),
106
+ }
107
+ )
108
+ return rows
109
+
110
+
111
+ def resolve_effective_defaults(profile: str = 'default') -> dict[str, Any]:
112
+ raw_dataclass = _jsonable(dataclasses.asdict(ServerConfig()))
113
+ normalized = _jsonable(config_to_dict(default_config()))
114
+ profile_kwargs: dict[str, Any] = {}
115
+ for item in get_profile_spec(profile).get('required_overrides', []):
116
+ if item == 'tls.certfile':
117
+ profile_kwargs['ssl_certfile'] = 'cert.pem'
118
+ elif item == 'tls.keyfile':
119
+ profile_kwargs['ssl_keyfile'] = 'key.pem'
120
+ elif item == 'tls.ca_certs':
121
+ profile_kwargs['ssl_ca_certs'] = 'ca.pem'
122
+ elif item == 'static.mount':
123
+ profile_kwargs['static_path_mount'] = '/srv/static'
124
+ effective = _sanitize_runtime_audit_values(_jsonable(config_to_dict(build_config(profile=profile, **profile_kwargs))))
125
+ parser_rows = parser_public_defaults()
126
+ base_profile_kwargs: dict[str, Any] = {}
127
+ for item in get_profile_spec('default').get('required_overrides', []):
128
+ if item == 'static.mount':
129
+ base_profile_kwargs['static_path_mount'] = '/srv/static'
130
+ base_effective = _sanitize_runtime_audit_values(_jsonable(config_to_dict(build_config(profile='default', **base_profile_kwargs))))
131
+ return {
132
+ 'profile': profile,
133
+ 'blessed_profiles': list(list_blessed_profiles()),
134
+ 'dataclass_model_defaults': raw_dataclass,
135
+ 'dataclass_model_defaults_flat': _flatten(raw_dataclass),
136
+ 'cli_parser_defaults': parser_rows,
137
+ 'cli_parser_defaults_by_flag': {row['flag']: row['parser_default'] for row in parser_rows},
138
+ 'normalization_backfills': _diff(raw_dataclass, normalized),
139
+ 'normalization_backfills_flat': _flatten(_diff(raw_dataclass, normalized)),
140
+ 'profile_overlays': _diff(base_effective, effective),
141
+ 'profile_overlays_flat': _flatten(_diff(base_effective, effective)),
142
+ 'effective_defaults': effective,
143
+ 'effective_defaults_flat': _flatten(effective),
144
+ }
@@ -0,0 +1,8 @@
1
+ from .model import ServerConfig
2
+ from .normalize import normalize_config
3
+
4
+
5
+ def default_config() -> ServerConfig:
6
+ config = ServerConfig()
7
+ normalize_config(config)
8
+ return config
tigrcorn_config/env.py ADDED
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shlex
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+ from .merge import merge_config_dicts
10
+
11
+ _FLAT_ENV_MAP = {
12
+ "APP": ("app", "target"),
13
+ "APP_INTERFACE": ("app", "interface"),
14
+ "FACTORY": ("app", "factory"),
15
+ "PROFILE": ("app", "profile"),
16
+ "APP_DIR": ("app", "app_dir"),
17
+ "LIFESPAN": ("app", "lifespan"),
18
+ "ENV_FILE": ("app", "env_file"),
19
+ "HOST": ("listeners", 0, "host"),
20
+ "PORT": ("listeners", 0, "port"),
21
+ "UDS": ("listeners", 0, "path"),
22
+ "TRANSPORT": ("listeners", 0, "kind"),
23
+ "USER": ("listeners", 0, "user"),
24
+ "GROUP": ("listeners", 0, "group"),
25
+ "UMASK": ("listeners", 0, "umask"),
26
+ "LOG_LEVEL": ("logging", "level"),
27
+ "ACCESS_LOG": ("logging", "access_log"),
28
+ "USE_COLORS": ("logging", "use_colors"),
29
+ "SSL_CERTFILE": ("tls", "certfile"),
30
+ "SSL_KEYFILE": ("tls", "keyfile"),
31
+ "SSL_KEYFILE_PASSWORD": ("tls", "keyfile_password"),
32
+ "SSL_CA_CERTS": ("tls", "ca_certs"),
33
+ "SSL_REQUIRE_CLIENT_CERT": ("tls", "require_client_cert"),
34
+ "HTTP": ("http", "http_versions"),
35
+ "PROTOCOL": ("listeners", 0, "protocols"),
36
+ "MAX_BODY_SIZE": ("http", "max_body_size"),
37
+ "MAX_HEADER_SIZE": ("http", "max_header_size"),
38
+ "HTTP1_MAX_INCOMPLETE_EVENT_SIZE": ("http", "http1_max_incomplete_event_size"),
39
+ "HTTP1_BUFFER_SIZE": ("http", "http1_buffer_size"),
40
+ "HTTP1_HEADER_READ_TIMEOUT": ("http", "http1_header_read_timeout"),
41
+ "HTTP1_KEEP_ALIVE": ("http", "http1_keep_alive"),
42
+ "HTTP2_MAX_CONCURRENT_STREAMS": ("http", "http2_max_concurrent_streams"),
43
+ "HTTP2_MAX_HEADERS_SIZE": ("http", "http2_max_headers_size"),
44
+ "HTTP2_MAX_FRAME_SIZE": ("http", "http2_max_frame_size"),
45
+ "HTTP2_ADAPTIVE_WINDOW": ("http", "http2_adaptive_window"),
46
+ "HTTP2_INITIAL_CONNECTION_WINDOW_SIZE": ("http", "http2_initial_connection_window_size"),
47
+ "HTTP2_INITIAL_STREAM_WINDOW_SIZE": ("http", "http2_initial_stream_window_size"),
48
+ "HTTP2_KEEP_ALIVE_INTERVAL": ("http", "http2_keep_alive_interval"),
49
+ "HTTP2_KEEP_ALIVE_TIMEOUT": ("http", "http2_keep_alive_timeout"),
50
+ "WEBSOCKET_MAX_MESSAGE_SIZE": ("websocket", "max_message_size"),
51
+ "WEBSOCKET_MAX_QUEUE": ("websocket", "max_queue"),
52
+ "WEBSOCKET_PING_INTERVAL": ("websocket", "ping_interval"),
53
+ "WEBSOCKET_PING_TIMEOUT": ("websocket", "ping_timeout"),
54
+ "WEBSOCKET_COMPRESSION": ("websocket", "compression"),
55
+ "PROXY_HEADERS": ("proxy", "proxy_headers"),
56
+ "FORWARDED_ALLOW_IPS": ("proxy", "forwarded_allow_ips"),
57
+ "ROOT_PATH": ("proxy", "root_path"),
58
+ "SERVER_HEADER": ("proxy", "server_header"),
59
+ "DATE_HEADER": ("proxy", "include_date_header"),
60
+ "HEADER": ("proxy", "default_headers"),
61
+ "SERVER_NAME": ("proxy", "server_names"),
62
+ "SSL_ALPN": ("tls", "alpn_protocols"),
63
+ "SSL_OCSP_MODE": ("tls", "ocsp_mode"),
64
+ "SSL_OCSP_CACHE_SIZE": ("tls", "ocsp_cache_size"),
65
+ "SSL_OCSP_MAX_AGE": ("tls", "ocsp_max_age"),
66
+ "SSL_CRL_MODE": ("tls", "crl_mode"),
67
+ "SSL_CRL": ("tls", "crl"),
68
+ "SSL_REVOCATION_FETCH": ("tls", "revocation_fetch"),
69
+ "CONNECT_POLICY": ("http", "connect_policy"),
70
+ "CONNECT_ALLOW": ("http", "connect_allow"),
71
+ "TRAILER_POLICY": ("http", "trailer_policy"),
72
+ "CONTENT_CODING_POLICY": ("http", "content_coding_policy"),
73
+ "CONTENT_CODINGS": ("http", "content_codings"),
74
+ "ENABLE_H2C": ("http", "enable_h2c"),
75
+ "QUIC_REQUIRE_RETRY": ("quic", "require_retry"),
76
+ "QUIC_MAX_DATAGRAM_SIZE": ("quic", "max_datagram_size"),
77
+ "QUIC_IDLE_TIMEOUT": ("quic", "idle_timeout"),
78
+ "QUIC_EARLY_DATA_POLICY": ("quic", "early_data_policy"),
79
+ "WEBTRANSPORT_MAX_SESSIONS": ("webtransport", "max_sessions"),
80
+ "WEBTRANSPORT_MAX_STREAMS": ("webtransport", "max_streams"),
81
+ "WEBTRANSPORT_MAX_DATAGRAM_SIZE": ("webtransport", "max_datagram_size"),
82
+ "WEBTRANSPORT_ORIGIN": ("webtransport", "origins"),
83
+ "WEBTRANSPORT_PATH": ("webtransport", "path"),
84
+ "TIMEOUT_KEEP_ALIVE": ("http", "keep_alive_timeout"),
85
+ "READ_TIMEOUT": ("http", "read_timeout"),
86
+ "WRITE_TIMEOUT": ("http", "write_timeout"),
87
+ "TIMEOUT_GRACEFUL_SHUTDOWN": ("http", "shutdown_timeout"),
88
+ "IDLE_TIMEOUT": ("http", "idle_timeout"),
89
+ "ALT_SVC": ("http", "alt_svc_headers"),
90
+ "ALT_SVC_AUTO": ("http", "alt_svc_auto"),
91
+ "ALT_SVC_MAX_AGE": ("http", "alt_svc_max_age"),
92
+ "ALT_SVC_PERSIST": ("http", "alt_svc_persist"),
93
+ "STATIC_PATH_ROUTE": ("static", "route"),
94
+ "STATIC_PATH_MOUNT": ("static", "mount"),
95
+ "STATIC_PATH_DIR_TO_FILE": ("static", "dir_to_file"),
96
+ "STATIC_PATH_INDEX_FILE": ("static", "index_file"),
97
+ "STATIC_PATH_EXPIRES": ("static", "expires"),
98
+ "RUNTIME": ("process", "runtime"),
99
+ "WORKER_HEALTHCHECK_TIMEOUT": ("process", "worker_healthcheck_timeout"),
100
+ "LIMIT_CONCURRENCY": ("scheduler", "limit_concurrency"),
101
+ "MAX_CONNECTIONS": ("scheduler", "max_connections"),
102
+ "MAX_TASKS": ("scheduler", "max_tasks"),
103
+ "MAX_STREAMS": ("scheduler", "max_streams"),
104
+ }
105
+
106
+
107
+ class EnvFileError(RuntimeError):
108
+ pass
109
+
110
+
111
+ def _decode_scalar(value: str) -> Any:
112
+ lowered = value.strip().lower()
113
+ if lowered in {"true", "false"}:
114
+ return lowered == "true"
115
+ if lowered in {"none", "null"}:
116
+ return None
117
+ try:
118
+ return int(value)
119
+ except ValueError:
120
+ pass
121
+ try:
122
+ return float(value)
123
+ except ValueError:
124
+ pass
125
+ if value and value[0] in "[{":
126
+ try:
127
+ return json.loads(value)
128
+ except Exception:
129
+ pass
130
+ if "," in value:
131
+ return [part.strip() for part in value.split(",") if part.strip()]
132
+ return value
133
+
134
+
135
+ def _nested_set(data: dict[str, Any], path: tuple[str | int, ...], value: Any) -> None:
136
+ cursor: Any = data
137
+ for index, segment in enumerate(path[:-1]):
138
+ nxt = path[index + 1]
139
+ if isinstance(segment, int):
140
+ while len(cursor) <= segment:
141
+ cursor.append({} if not isinstance(nxt, int) else [])
142
+ cursor = cursor[segment]
143
+ continue
144
+ if segment not in cursor:
145
+ cursor[segment] = [] if isinstance(nxt, int) else {}
146
+ cursor = cursor[segment]
147
+ last = path[-1]
148
+ if isinstance(last, int):
149
+ while len(cursor) <= last:
150
+ cursor.append(None)
151
+ cursor[last] = value
152
+ else:
153
+ cursor[last] = value
154
+
155
+
156
+ def load_env_file(path: str | Path | None) -> dict[str, str]:
157
+ if not path:
158
+ return {}
159
+ env_path = Path(path)
160
+ if not env_path.exists():
161
+ raise EnvFileError(f'env file does not exist: {env_path}')
162
+ result: dict[str, str] = {}
163
+ for line_no, raw_line in enumerate(env_path.read_text(encoding='utf-8').splitlines(), 1):
164
+ line = raw_line.strip()
165
+ if not line or line.startswith('#'):
166
+ continue
167
+ if line.startswith('export '):
168
+ line = line[7:].strip()
169
+ if '=' not in line:
170
+ raise EnvFileError(f'invalid env file line {line_no}: {raw_line!r}')
171
+ key, raw_value = line.split('=', 1)
172
+ key = key.strip()
173
+ if not key:
174
+ raise EnvFileError(f'invalid env file line {line_no}: empty key')
175
+ value = raw_value.strip()
176
+ if value and value[0] in {'"', "'"}:
177
+ try:
178
+ parsed = shlex.split(f'VALUE={value}', posix=True)
179
+ value = parsed[0].split('=', 1)[1]
180
+ except Exception as exc:
181
+ raise EnvFileError(f'invalid quoted value on line {line_no}') from exc
182
+ elif ' #' in value:
183
+ value = value.split(' #', 1)[0].rstrip()
184
+ result[key] = value
185
+ return result
186
+
187
+
188
+ def load_env_config(prefix: str = "TIGRCORN", *, environ: Mapping[str, str] | None = None) -> dict[str, Any]:
189
+ env = dict(os.environ if environ is None else environ)
190
+ normalized_prefix = prefix.upper().replace("-", "_")
191
+ nested_prefix = f"{normalized_prefix}__"
192
+ flat_prefix = f"{normalized_prefix}_"
193
+ nested: dict[str, Any] = {}
194
+ flat: dict[str, Any] = {}
195
+
196
+ for key, raw_value in env.items():
197
+ if key.startswith(nested_prefix):
198
+ path_bits = key[len(nested_prefix):].split("__")
199
+ path: list[str | int] = []
200
+ for bit in path_bits:
201
+ if bit.isdigit():
202
+ path.append(int(bit))
203
+ else:
204
+ path.append(bit.lower())
205
+ _nested_set(nested, tuple(path), _decode_scalar(raw_value))
206
+ elif key.startswith(flat_prefix):
207
+ name = key[len(flat_prefix):]
208
+ path = _FLAT_ENV_MAP.get(name)
209
+ if path is not None:
210
+ _nested_set(flat, path, _decode_scalar(raw_value))
211
+ return merge_config_dicts(flat, nested)
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import importlib
5
+ import json
6
+ import runpy
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ try: # pragma: no cover
11
+ import tomllib # type: ignore[attr-defined]
12
+ except Exception: # pragma: no cover
13
+ tomllib = None # type: ignore[assignment]
14
+
15
+ try: # pragma: no cover
16
+ import yaml # type: ignore[import-not-found]
17
+ except Exception: # pragma: no cover
18
+ yaml = None # type: ignore[assignment]
19
+
20
+
21
+ class ConfigFileError(RuntimeError):
22
+ pass
23
+
24
+
25
+ def _object_to_mapping(value: Any) -> dict[str, Any]:
26
+ if isinstance(value, Mapping):
27
+ return dict(value)
28
+ if dataclasses.is_dataclass(value):
29
+ return dataclasses.asdict(value)
30
+ if callable(value):
31
+ return _object_to_mapping(value())
32
+ if hasattr(value, '__dict__'):
33
+ return {key: item for key, item in vars(value).items() if not key.startswith('_')}
34
+ raise ConfigFileError(f'config object did not resolve to a mapping: {value!r}')
35
+
36
+
37
+ def _extract_python_payload(payload: Mapping[str, Any], *, label: str) -> dict[str, Any]:
38
+ for key in ('CONFIG', 'config', 'TIGRCORN_CONFIG'):
39
+ if key in payload:
40
+ return _object_to_mapping(payload[key])
41
+ raise ConfigFileError(f'{label} did not expose CONFIG/config/TIGRCORN_CONFIG')
42
+
43
+
44
+ def load_config_file(path: str | Path | None) -> dict[str, Any]:
45
+ if not path:
46
+ return {}
47
+ config_path = Path(path)
48
+ if not config_path.exists():
49
+ raise ConfigFileError(f'config file does not exist: {config_path}')
50
+ suffix = config_path.suffix.lower()
51
+ if suffix == '.json':
52
+ return json.loads(config_path.read_text(encoding='utf-8'))
53
+ if suffix in {'.yaml', '.yml'}:
54
+ if yaml is None:
55
+ raise ConfigFileError(
56
+ 'YAML loading is unavailable on this Python runtime; '
57
+ 'install tigrcorn[config-yaml] to enable .yaml/.yml config files'
58
+ )
59
+ loaded = yaml.safe_load(config_path.read_text(encoding='utf-8'))
60
+ return _object_to_mapping(loaded or {})
61
+ if suffix == '.toml':
62
+ if tomllib is None:
63
+ raise ConfigFileError('TOML loading is unavailable on this Python runtime')
64
+ return tomllib.loads(config_path.read_text(encoding='utf-8'))
65
+ if suffix == '.py':
66
+ payload = runpy.run_path(str(config_path))
67
+ return _extract_python_payload(payload, label=f'python config {config_path}')
68
+ raise ConfigFileError(f'unsupported config file type: {config_path.suffix!r}')
69
+
70
+
71
+ def load_config_module(module_name: str) -> dict[str, Any]:
72
+ module = importlib.import_module(module_name)
73
+ return _extract_python_payload(vars(module), label=f'config module {module_name}')
74
+
75
+
76
+ def load_config_object(spec: str) -> dict[str, Any]:
77
+ if ':' not in spec:
78
+ raise ConfigFileError('object config references must use module:object syntax')
79
+ module_name, object_name = spec.split(':', 1)
80
+ module = importlib.import_module(module_name)
81
+ if not hasattr(module, object_name):
82
+ raise ConfigFileError(f'config object {spec!r} was not found')
83
+ value = getattr(module, object_name)
84
+ return _object_to_mapping(value)
85
+
86
+
87
+ def load_config_source(source: str | Path | Mapping[str, Any] | Any | None) -> dict[str, Any]:
88
+ if source is None:
89
+ return {}
90
+ if isinstance(source, Mapping):
91
+ return dict(source)
92
+ if dataclasses.is_dataclass(source):
93
+ return dataclasses.asdict(source)
94
+ if isinstance(source, Path):
95
+ return load_config_file(source)
96
+ if isinstance(source, str):
97
+ candidate = Path(source)
98
+ if candidate.exists():
99
+ return load_config_file(candidate)
100
+ if source.startswith('module:'):
101
+ return load_config_module(source.split(':', 1)[1])
102
+ if source.startswith('object:'):
103
+ return load_config_object(source.split(':', 1)[1])
104
+ raise ConfigFileError(
105
+ 'config source must be a file path or module:/object: reference '
106
+ f'(got {source!r})'
107
+ )
108
+ return _object_to_mapping(source)