devconfig-gen-singbox 2.0.0__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.
- devconfig_gen/__init__.py +67 -0
- devconfig_gen/cli.py +163 -0
- devconfig_gen/engine.py +217 -0
- devconfig_gen/formats.py +708 -0
- devconfig_gen/models.py +135 -0
- devconfig_gen/providers/__init__.py +8 -0
- devconfig_gen/providers/custom.py +92 -0
- devconfig_gen/providers/env_provider.py +184 -0
- devconfig_gen/providers/json_provider.py +84 -0
- devconfig_gen/providers/singbox/__init__.py +9 -0
- devconfig_gen/providers/singbox/data/rules.json +97 -0
- devconfig_gen/providers/singbox/links.py +24 -0
- devconfig_gen/providers/singbox/models.py +76 -0
- devconfig_gen/providers/singbox/plugins/__init__.py +33 -0
- devconfig_gen/providers/singbox/plugins/anytls.py +129 -0
- devconfig_gen/providers/singbox/plugins/base.py +117 -0
- devconfig_gen/providers/singbox/plugins/hysteria2.py +110 -0
- devconfig_gen/providers/singbox/plugins/tuic.py +90 -0
- devconfig_gen/providers/singbox/provider.py +362 -0
- devconfig_gen/providers/singbox/route.py +369 -0
- devconfig_gen/providers/singbox/schema.py +362 -0
- devconfig_gen/py.typed +0 -0
- devconfig_gen/registry.py +39 -0
- devconfig_gen/validation.py +148 -0
- devconfig_gen_singbox-2.0.0.dist-info/METADATA +343 -0
- devconfig_gen_singbox-2.0.0.dist-info/RECORD +30 -0
- devconfig_gen_singbox-2.0.0.dist-info/WHEEL +5 -0
- devconfig_gen_singbox-2.0.0.dist-info/entry_points.txt +3 -0
- devconfig_gen_singbox-2.0.0.dist-info/licenses/LICENSE +201 -0
- devconfig_gen_singbox-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Context schema definition and structural validation for sing-box.
|
|
2
|
+
|
|
3
|
+
Validation is path-aware and collects *all* problems in one pass as
|
|
4
|
+
:class:`~devconfig_gen.models.Diagnostic` values with dotted paths such as
|
|
5
|
+
``network.protocols.0.auth.password``. Variant-specific field checks are
|
|
6
|
+
delegated to the plugins; this module only owns the neutral structure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, List, Mapping, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from ...models import Diagnostic
|
|
15
|
+
from ...validation import expect_enum, expect_integer, expect_mapping, expect_string
|
|
16
|
+
from .models import ClientConfig, NetworkConfig, ProtocolConfig
|
|
17
|
+
from .plugins import get_plugin, protocol_types
|
|
18
|
+
from .route import REQUIRED_BUCKETS
|
|
19
|
+
|
|
20
|
+
TUNNEL_MODES = ("none", "proxy", "tun")
|
|
21
|
+
TARGETS = ("server", "client", "both")
|
|
22
|
+
OUTPUT_FORMATS = ("json", "yaml")
|
|
23
|
+
RULES_SOURCES = ("embedded", "custom")
|
|
24
|
+
|
|
25
|
+
DEFAULT_TUNNEL_MODE = "proxy"
|
|
26
|
+
DEFAULT_TARGET = "both"
|
|
27
|
+
DEFAULT_FORMAT = "json"
|
|
28
|
+
DEFAULT_FINGERPRINT = "chrome"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ParsedContext:
|
|
33
|
+
"""A normalized request context plus the resolved protocol hosts."""
|
|
34
|
+
|
|
35
|
+
network: NetworkConfig
|
|
36
|
+
client: ClientConfig
|
|
37
|
+
options: Mapping[str, Any]
|
|
38
|
+
hosts: Mapping[str, str]
|
|
39
|
+
|
|
40
|
+
def enabled_protocols(self) -> Tuple[ProtocolConfig, ...]:
|
|
41
|
+
return tuple(cfg for cfg in self.network.protocols if cfg.enabled)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse(
|
|
45
|
+
context: Any, options: Optional[Mapping[str, Any]] = None
|
|
46
|
+
) -> Tuple[ParsedContext, List[Diagnostic]]:
|
|
47
|
+
"""Normalize and validate a request context.
|
|
48
|
+
|
|
49
|
+
Always returns a best-effort :class:`ParsedContext`; callers must check the
|
|
50
|
+
returned diagnostics before generating artifacts.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
diagnostics: List[Diagnostic] = []
|
|
54
|
+
if context is None:
|
|
55
|
+
context = {}
|
|
56
|
+
if not isinstance(context, Mapping):
|
|
57
|
+
diagnostics.append(
|
|
58
|
+
Diagnostic("", f"context must be a mapping at the document root, got {_describe(context)}")
|
|
59
|
+
)
|
|
60
|
+
context = {}
|
|
61
|
+
|
|
62
|
+
merged_options = _parse_options(context, options, diagnostics)
|
|
63
|
+
|
|
64
|
+
if "network" not in context:
|
|
65
|
+
diagnostics.append(Diagnostic("network", "network is required"))
|
|
66
|
+
network_raw: Mapping = {}
|
|
67
|
+
else:
|
|
68
|
+
network_raw = expect_mapping(context["network"], "network", diagnostics) or {}
|
|
69
|
+
|
|
70
|
+
network = _parse_network(network_raw, diagnostics)
|
|
71
|
+
client = _parse_client(context.get("client"), diagnostics)
|
|
72
|
+
hosts = _build_hosts(network, diagnostics)
|
|
73
|
+
|
|
74
|
+
parsed = ParsedContext(network=network, client=client, options=merged_options, hosts=hosts)
|
|
75
|
+
return parsed, diagnostics
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_options(
|
|
79
|
+
context: Mapping, options: Optional[Mapping[str, Any]], diagnostics: List[Diagnostic]
|
|
80
|
+
) -> dict:
|
|
81
|
+
merged: dict = {}
|
|
82
|
+
context_options = context.get("options")
|
|
83
|
+
if context_options is not None:
|
|
84
|
+
if isinstance(context_options, Mapping):
|
|
85
|
+
merged.update(context_options)
|
|
86
|
+
else:
|
|
87
|
+
diagnostics.append(
|
|
88
|
+
Diagnostic(
|
|
89
|
+
"options",
|
|
90
|
+
f"options must be a mapping, got {_describe(context_options)}",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
if options:
|
|
94
|
+
merged.update(options)
|
|
95
|
+
|
|
96
|
+
target = DEFAULT_TARGET
|
|
97
|
+
if "target" in merged:
|
|
98
|
+
resolved = expect_enum(merged["target"], "options.target", diagnostics, allowed=TARGETS)
|
|
99
|
+
if resolved:
|
|
100
|
+
target = resolved
|
|
101
|
+
fmt = DEFAULT_FORMAT
|
|
102
|
+
if "format" in merged:
|
|
103
|
+
resolved = expect_enum(merged["format"], "options.format", diagnostics, allowed=OUTPUT_FORMATS)
|
|
104
|
+
if resolved:
|
|
105
|
+
fmt = resolved
|
|
106
|
+
merged["target"] = target
|
|
107
|
+
merged["format"] = fmt
|
|
108
|
+
return merged
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_network(raw: Mapping, diagnostics: List[Diagnostic]) -> NetworkConfig:
|
|
112
|
+
domain_root = ""
|
|
113
|
+
if "domain_root" not in raw:
|
|
114
|
+
diagnostics.append(Diagnostic("network.domain_root", "domain_root is required"))
|
|
115
|
+
else:
|
|
116
|
+
domain_root = expect_string(raw.get("domain_root"), "network.domain_root", diagnostics) or ""
|
|
117
|
+
|
|
118
|
+
tunnel_mode = DEFAULT_TUNNEL_MODE
|
|
119
|
+
if "tunnel_mode" not in raw:
|
|
120
|
+
diagnostics.append(Diagnostic("network.tunnel_mode", "tunnel_mode is required"))
|
|
121
|
+
else:
|
|
122
|
+
resolved = expect_enum(
|
|
123
|
+
raw.get("tunnel_mode"), "network.tunnel_mode", diagnostics, allowed=TUNNEL_MODES
|
|
124
|
+
)
|
|
125
|
+
if resolved:
|
|
126
|
+
tunnel_mode = resolved
|
|
127
|
+
|
|
128
|
+
return NetworkConfig(
|
|
129
|
+
domain_root=domain_root,
|
|
130
|
+
subdomain_prefixes=_parse_prefixes(raw.get("subdomain_prefixes"), diagnostics),
|
|
131
|
+
tunnel_mode=tunnel_mode,
|
|
132
|
+
protocols=_parse_protocols(raw.get("protocols"), diagnostics),
|
|
133
|
+
routing=_parse_routing(raw.get("routing"), diagnostics),
|
|
134
|
+
dns=_parse_dns(raw.get("dns"), diagnostics),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_prefixes(value: Any, diagnostics: List[Diagnostic]) -> dict:
|
|
139
|
+
if value is None:
|
|
140
|
+
diagnostics.append(Diagnostic("network.subdomain_prefixes", "subdomain_prefixes is required"))
|
|
141
|
+
return {}
|
|
142
|
+
mapping = expect_mapping(value, "network.subdomain_prefixes", diagnostics)
|
|
143
|
+
if mapping is None:
|
|
144
|
+
return {}
|
|
145
|
+
prefixes: dict = {}
|
|
146
|
+
for key, item in mapping.items():
|
|
147
|
+
text = expect_string(item, f"network.subdomain_prefixes.{key}", diagnostics)
|
|
148
|
+
if text is not None:
|
|
149
|
+
prefixes[str(key)] = text
|
|
150
|
+
return prefixes
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_protocols(value: Any, diagnostics: List[Diagnostic]) -> List[ProtocolConfig]:
|
|
154
|
+
if value is None:
|
|
155
|
+
diagnostics.append(Diagnostic("network.protocols", "protocols is required"))
|
|
156
|
+
return []
|
|
157
|
+
if not isinstance(value, list):
|
|
158
|
+
diagnostics.append(
|
|
159
|
+
Diagnostic("network.protocols", f"protocols must be a list, got {_describe(value)}")
|
|
160
|
+
)
|
|
161
|
+
return []
|
|
162
|
+
if not value:
|
|
163
|
+
diagnostics.append(Diagnostic("network.protocols", "protocols must contain at least one entry"))
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
protocols: List[ProtocolConfig] = []
|
|
167
|
+
for index, item in enumerate(value):
|
|
168
|
+
path = f"network.protocols.{index}"
|
|
169
|
+
if not isinstance(item, Mapping):
|
|
170
|
+
diagnostics.append(Diagnostic(path, f"protocol must be a mapping, got {_describe(item)}"))
|
|
171
|
+
continue
|
|
172
|
+
resolved_type = expect_enum(
|
|
173
|
+
item.get("type"), f"{path}.type", diagnostics, allowed=protocol_types()
|
|
174
|
+
)
|
|
175
|
+
enabled = True
|
|
176
|
+
if "enabled" in item:
|
|
177
|
+
raw_enabled = item["enabled"]
|
|
178
|
+
if not isinstance(raw_enabled, bool):
|
|
179
|
+
diagnostics.append(
|
|
180
|
+
Diagnostic(
|
|
181
|
+
f"{path}.enabled",
|
|
182
|
+
f"enabled must be a boolean, got {_describe(raw_enabled)}",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
enabled = raw_enabled
|
|
187
|
+
port = None
|
|
188
|
+
if "port" in item:
|
|
189
|
+
port = expect_integer(item["port"], f"{path}.port", diagnostics, minimum=1, maximum=65535)
|
|
190
|
+
|
|
191
|
+
protocols.append(
|
|
192
|
+
ProtocolConfig(
|
|
193
|
+
type=resolved_type or str(item.get("type") or "").strip().lower(),
|
|
194
|
+
enabled=enabled,
|
|
195
|
+
port=port,
|
|
196
|
+
raw=dict(item),
|
|
197
|
+
path=path,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
enabled = [cfg for cfg in protocols if cfg.enabled]
|
|
202
|
+
if protocols and not enabled:
|
|
203
|
+
diagnostics.append(Diagnostic("network.protocols", "at least one protocol must be enabled"))
|
|
204
|
+
for cfg in enabled:
|
|
205
|
+
plugin = get_plugin(cfg.type)
|
|
206
|
+
if plugin is not None:
|
|
207
|
+
plugin.validate(cfg, diagnostics)
|
|
208
|
+
return protocols
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parse_routing(value: Any, diagnostics: List[Diagnostic]) -> dict:
|
|
212
|
+
if value is None:
|
|
213
|
+
return {}
|
|
214
|
+
mapping = expect_mapping(value, "network.routing", diagnostics)
|
|
215
|
+
if mapping is None:
|
|
216
|
+
return {}
|
|
217
|
+
result = dict(mapping)
|
|
218
|
+
|
|
219
|
+
if "rules_source" in mapping:
|
|
220
|
+
resolved = expect_enum(
|
|
221
|
+
mapping["rules_source"],
|
|
222
|
+
"network.routing.rules_source",
|
|
223
|
+
diagnostics,
|
|
224
|
+
allowed=RULES_SOURCES,
|
|
225
|
+
)
|
|
226
|
+
if resolved:
|
|
227
|
+
result["rules_source"] = resolved
|
|
228
|
+
|
|
229
|
+
if "geoip_cn" in mapping:
|
|
230
|
+
geoip = mapping["geoip_cn"]
|
|
231
|
+
if not isinstance(geoip, bool):
|
|
232
|
+
diagnostics.append(
|
|
233
|
+
Diagnostic(
|
|
234
|
+
"network.routing.geoip_cn",
|
|
235
|
+
f"geoip_cn must be a boolean, got {_describe(geoip)}",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
result["geoip_cn"] = geoip
|
|
240
|
+
|
|
241
|
+
if "custom_rules" in mapping:
|
|
242
|
+
custom = mapping["custom_rules"]
|
|
243
|
+
if not isinstance(custom, Mapping):
|
|
244
|
+
diagnostics.append(
|
|
245
|
+
Diagnostic(
|
|
246
|
+
"network.routing.custom_rules",
|
|
247
|
+
f"custom_rules must be a mapping, got {_describe(custom)}",
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
result["custom_rules"] = _parse_custom_rules(custom, diagnostics)
|
|
252
|
+
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _parse_custom_rules(custom: Mapping, diagnostics: List[Diagnostic]) -> dict:
|
|
257
|
+
cleaned: dict = {}
|
|
258
|
+
for key, items in custom.items():
|
|
259
|
+
path = f"network.routing.custom_rules.{key}"
|
|
260
|
+
if key not in REQUIRED_BUCKETS:
|
|
261
|
+
diagnostics.append(
|
|
262
|
+
Diagnostic(
|
|
263
|
+
path,
|
|
264
|
+
f"unknown rule bucket {key!r}; expected one of {', '.join(REQUIRED_BUCKETS)}",
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
if not isinstance(items, list):
|
|
269
|
+
diagnostics.append(Diagnostic(path, f"custom rule bucket must be a list, got {_describe(items)}"))
|
|
270
|
+
continue
|
|
271
|
+
cleaned[key] = list(items)
|
|
272
|
+
return cleaned
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _parse_dns(value: Any, diagnostics: List[Diagnostic]) -> dict:
|
|
276
|
+
if value is None:
|
|
277
|
+
return {}
|
|
278
|
+
mapping = expect_mapping(value, "network.dns", diagnostics)
|
|
279
|
+
if mapping is None:
|
|
280
|
+
return {}
|
|
281
|
+
result = dict(mapping)
|
|
282
|
+
|
|
283
|
+
if "direct_servers" in mapping:
|
|
284
|
+
servers = mapping["direct_servers"]
|
|
285
|
+
if not isinstance(servers, list) or not servers:
|
|
286
|
+
diagnostics.append(
|
|
287
|
+
Diagnostic(
|
|
288
|
+
"network.dns.direct_servers",
|
|
289
|
+
f"direct_servers must be a non-empty list, got {_describe(servers)}",
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
cleaned: list = []
|
|
294
|
+
for index, item in enumerate(servers):
|
|
295
|
+
text = expect_string(item, f"network.dns.direct_servers.{index}", diagnostics)
|
|
296
|
+
if text is not None:
|
|
297
|
+
cleaned.append(text)
|
|
298
|
+
result["direct_servers"] = cleaned
|
|
299
|
+
|
|
300
|
+
if "remote_server" in mapping:
|
|
301
|
+
text = expect_string(mapping["remote_server"], "network.dns.remote_server", diagnostics)
|
|
302
|
+
if text is not None:
|
|
303
|
+
result["remote_server"] = text
|
|
304
|
+
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _parse_client(value: Any, diagnostics: List[Diagnostic]) -> ClientConfig:
|
|
309
|
+
if value is None:
|
|
310
|
+
return ClientConfig()
|
|
311
|
+
mapping = expect_mapping(value, "client", diagnostics)
|
|
312
|
+
if mapping is None:
|
|
313
|
+
return ClientConfig()
|
|
314
|
+
|
|
315
|
+
server_ip = None
|
|
316
|
+
if "server_ip" in mapping:
|
|
317
|
+
server_ip = expect_string(mapping["server_ip"], "client.server_ip", diagnostics)
|
|
318
|
+
|
|
319
|
+
fingerprint = DEFAULT_FINGERPRINT
|
|
320
|
+
if "fingerprint" in mapping:
|
|
321
|
+
resolved = expect_string(mapping["fingerprint"], "client.fingerprint", diagnostics)
|
|
322
|
+
if resolved:
|
|
323
|
+
fingerprint = resolved
|
|
324
|
+
|
|
325
|
+
return ClientConfig(server_ip=server_ip, fingerprint=fingerprint)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _build_hosts(network: NetworkConfig, diagnostics: List[Diagnostic]) -> dict:
|
|
329
|
+
if not network.domain_root:
|
|
330
|
+
return {}
|
|
331
|
+
root = network.domain_root.strip().lower().rstrip(".")
|
|
332
|
+
hosts: dict = {}
|
|
333
|
+
for cfg in network.protocols:
|
|
334
|
+
if not cfg.enabled:
|
|
335
|
+
continue
|
|
336
|
+
plugin = get_plugin(cfg.type)
|
|
337
|
+
if plugin is None:
|
|
338
|
+
continue
|
|
339
|
+
key = plugin.host_key
|
|
340
|
+
if key in hosts:
|
|
341
|
+
continue
|
|
342
|
+
prefix = network.subdomain_prefixes.get(key)
|
|
343
|
+
if not prefix:
|
|
344
|
+
diagnostics.append(
|
|
345
|
+
Diagnostic(
|
|
346
|
+
f"network.subdomain_prefixes.{key}",
|
|
347
|
+
f"{key} subdomain prefix is required",
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
continue
|
|
351
|
+
hosts[key] = f"{prefix}.{root}"
|
|
352
|
+
return hosts
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _describe(value: Any) -> str:
|
|
356
|
+
if isinstance(value, str):
|
|
357
|
+
return f"string {value!r}"
|
|
358
|
+
if value is None:
|
|
359
|
+
return "null"
|
|
360
|
+
if isinstance(value, bool):
|
|
361
|
+
return f"boolean {value}"
|
|
362
|
+
return f"{type(value).__name__} {value!r}"
|
devconfig_gen/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Provider registration and lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Iterable
|
|
6
|
+
|
|
7
|
+
from .models import ConfigProvider
|
|
8
|
+
from .providers import CustomProvider, EnvProvider, JsonProvider
|
|
9
|
+
from .providers.singbox import SingBoxProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProviderRegistry:
|
|
13
|
+
def __init__(self, providers: Iterable[ConfigProvider] = ()):
|
|
14
|
+
self._providers: Dict[str, ConfigProvider] = {}
|
|
15
|
+
for provider in providers:
|
|
16
|
+
self.register(provider)
|
|
17
|
+
|
|
18
|
+
def register(self, provider: ConfigProvider) -> None:
|
|
19
|
+
name = str(provider.name).strip().lower()
|
|
20
|
+
if not name or name != provider.name:
|
|
21
|
+
raise ValueError("provider names must be non-empty lowercase strings")
|
|
22
|
+
if name in self._providers:
|
|
23
|
+
raise ValueError(f"provider already registered: {name}")
|
|
24
|
+
self._providers[name] = provider
|
|
25
|
+
|
|
26
|
+
def get(self, name: str) -> ConfigProvider:
|
|
27
|
+
try:
|
|
28
|
+
return self._providers[str(name).strip().lower()]
|
|
29
|
+
except KeyError as exc:
|
|
30
|
+
available = ", ".join(self.names()) or "none"
|
|
31
|
+
raise ValueError(f"unknown provider {name!r}; available: {available}") from exc
|
|
32
|
+
|
|
33
|
+
def names(self):
|
|
34
|
+
return tuple(sorted(self._providers))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
default_registry = ProviderRegistry(
|
|
38
|
+
(CustomProvider(), JsonProvider(), EnvProvider(), SingBoxProvider())
|
|
39
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Reusable, path-aware validation helpers.
|
|
2
|
+
|
|
3
|
+
Providers use these primitives to collect every problem in one pass instead of
|
|
4
|
+
failing on the first field. Each helper appends a :class:`Diagnostic` to the
|
|
5
|
+
supplied list. The rendered message uses the leaf field name so it reads
|
|
6
|
+
naturally, for example ``port must be between 1 and 65535, got 99999``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from .models import Diagnostic
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationError(ValueError):
|
|
17
|
+
"""Raised when one or more validation errors are present."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, diagnostics: Sequence[Any]):
|
|
20
|
+
self.diagnostics = tuple(_as_diagnostic(item) for item in diagnostics)
|
|
21
|
+
self.errors = tuple(item.message for item in self.diagnostics)
|
|
22
|
+
super().__init__("; ".join(self.errors))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _as_diagnostic(value: Any) -> Diagnostic:
|
|
26
|
+
if isinstance(value, Diagnostic):
|
|
27
|
+
return value
|
|
28
|
+
return Diagnostic(field="", message=str(value))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _leaf(path: str) -> str:
|
|
32
|
+
return path.rsplit(".", 1)[-1] if path else path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_mapping(value: Any) -> bool:
|
|
36
|
+
return isinstance(value, Mapping)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_integer(value: Any) -> bool:
|
|
40
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def expect_mapping(value: Any, path: str, errors: list) -> Optional[Mapping]:
|
|
44
|
+
if not is_mapping(value):
|
|
45
|
+
errors.append(
|
|
46
|
+
Diagnostic(path, f"{_leaf(path)} must be a mapping, got {_describe(value)}")
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def expect_string(value: Any, path: str, errors: list, *, allow_empty: bool = False) -> Optional[str]:
|
|
53
|
+
leaf = _leaf(path)
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
errors.append(Diagnostic(path, f"{leaf} must be a string, got {_describe(value)}"))
|
|
56
|
+
return None
|
|
57
|
+
text = value.strip()
|
|
58
|
+
if not text and not allow_empty:
|
|
59
|
+
errors.append(Diagnostic(path, f"{leaf} must not be empty"))
|
|
60
|
+
return None
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def expect_integer(
|
|
65
|
+
value: Any,
|
|
66
|
+
path: str,
|
|
67
|
+
errors: list,
|
|
68
|
+
*,
|
|
69
|
+
minimum: Optional[int] = None,
|
|
70
|
+
maximum: Optional[int] = None,
|
|
71
|
+
) -> Optional[int]:
|
|
72
|
+
leaf = _leaf(path)
|
|
73
|
+
if not is_integer(value):
|
|
74
|
+
errors.append(Diagnostic(path, f"{leaf} must be an integer, got {_describe(value)}"))
|
|
75
|
+
return None
|
|
76
|
+
if minimum is not None and maximum is not None:
|
|
77
|
+
if value < minimum or value > maximum:
|
|
78
|
+
errors.append(
|
|
79
|
+
Diagnostic(path, f"{leaf} must be between {minimum} and {maximum}, got {value}")
|
|
80
|
+
)
|
|
81
|
+
return None
|
|
82
|
+
elif minimum is not None and value < minimum:
|
|
83
|
+
errors.append(Diagnostic(path, f"{leaf} must be >= {minimum}, got {value}"))
|
|
84
|
+
return None
|
|
85
|
+
elif maximum is not None and value > maximum:
|
|
86
|
+
errors.append(Diagnostic(path, f"{leaf} must be <= {maximum}, got {value}"))
|
|
87
|
+
return None
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def expect_enum(
|
|
92
|
+
value: Any,
|
|
93
|
+
path: str,
|
|
94
|
+
errors: list,
|
|
95
|
+
*,
|
|
96
|
+
allowed: Sequence[str],
|
|
97
|
+
case_insensitive: bool = True,
|
|
98
|
+
) -> Optional[str]:
|
|
99
|
+
leaf = _leaf(path)
|
|
100
|
+
options = ", ".join(str(option) for option in allowed)
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
errors.append(
|
|
103
|
+
Diagnostic(path, f"{leaf} must be one of {options}, got {_describe(value)}")
|
|
104
|
+
)
|
|
105
|
+
return None
|
|
106
|
+
candidate = value.strip()
|
|
107
|
+
if case_insensitive:
|
|
108
|
+
for option in allowed:
|
|
109
|
+
if candidate.lower() == option.lower():
|
|
110
|
+
return option
|
|
111
|
+
elif candidate in allowed:
|
|
112
|
+
return candidate
|
|
113
|
+
errors.append(Diagnostic(path, f"{leaf} must be one of {options}, got {value!r}"))
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def expect_string_mapping(value: Any, path: str, errors: list) -> Optional[dict]:
|
|
118
|
+
if not is_mapping(value):
|
|
119
|
+
errors.append(
|
|
120
|
+
Diagnostic(path, f"{_leaf(path)} must be a mapping of labels, got {_describe(value)}")
|
|
121
|
+
)
|
|
122
|
+
return None
|
|
123
|
+
normalized = {}
|
|
124
|
+
for key, item in value.items():
|
|
125
|
+
key_text = str(key).strip()
|
|
126
|
+
if not key_text:
|
|
127
|
+
errors.append(Diagnostic(path, "label keys must not be empty"))
|
|
128
|
+
continue
|
|
129
|
+
if isinstance(item, (dict, list, tuple)):
|
|
130
|
+
errors.append(
|
|
131
|
+
Diagnostic(
|
|
132
|
+
f"{path}.{key_text}",
|
|
133
|
+
f"labels.{key_text} must be a scalar value, got {_describe(item)}",
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
normalized[key_text] = "" if item is None else str(item)
|
|
138
|
+
return normalized
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _describe(value: Any) -> str:
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
return f"string {value!r}"
|
|
144
|
+
if value is None:
|
|
145
|
+
return "null"
|
|
146
|
+
if isinstance(value, bool):
|
|
147
|
+
return f"boolean {value}"
|
|
148
|
+
return f"{type(value).__name__} {value!r}"
|