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.
- tigrcorn_config/__init__.py +46 -0
- tigrcorn_config/audit.py +144 -0
- tigrcorn_config/defaults.py +8 -0
- tigrcorn_config/env.py +211 -0
- tigrcorn_config/files.py +108 -0
- tigrcorn_config/governance_surface.py +329 -0
- tigrcorn_config/load.py +666 -0
- tigrcorn_config/merge.py +22 -0
- tigrcorn_config/model.py +471 -0
- tigrcorn_config/negative_surface.py +165 -0
- tigrcorn_config/normalize.py +244 -0
- tigrcorn_config/observability_surface.py +118 -0
- tigrcorn_config/origin_surface.py +182 -0
- tigrcorn_config/policy_surface.py +306 -0
- tigrcorn_config/profiles.py +344 -0
- tigrcorn_config/py.typed +1 -0
- tigrcorn_config/quic_surface.py +102 -0
- tigrcorn_config/validate.py +266 -0
- tigrcorn_config-0.3.16.dev5.dist-info/METADATA +237 -0
- tigrcorn_config-0.3.16.dev5.dist-info/RECORD +23 -0
- tigrcorn_config-0.3.16.dev5.dist-info/WHEEL +5 -0
- tigrcorn_config-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
- tigrcorn_config-0.3.16.dev5.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
tigrcorn_config/audit.py
ADDED
|
@@ -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
|
+
}
|
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)
|
tigrcorn_config/files.py
ADDED
|
@@ -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)
|