devconfig-gen 1.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 +90 -0
- devconfig_gen/cli.py +213 -0
- devconfig_gen/engine.py +217 -0
- devconfig_gen/formats.py +708 -0
- devconfig_gen/interactive.py +377 -0
- devconfig_gen/models.py +135 -0
- devconfig_gen/providers/__init__.py +7 -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/py.typed +0 -0
- devconfig_gen/registry.py +38 -0
- devconfig_gen/validation.py +148 -0
- devconfig_gen/web_ui.py +2143 -0
- devconfig_gen-1.0.0.dist-info/METADATA +456 -0
- devconfig_gen-1.0.0.dist-info/RECORD +20 -0
- devconfig_gen-1.0.0.dist-info/WHEEL +5 -0
- devconfig_gen-1.0.0.dist-info/entry_points.txt +2 -0
- devconfig_gen-1.0.0.dist-info/licenses/LICENSE +201 -0
- devconfig_gen-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Interactive terminal wizard for generating configuration.
|
|
2
|
+
|
|
3
|
+
Designed for headless environments (e.g. Linux SSH servers or terminal-first
|
|
4
|
+
workflows on macOS). Driven entirely by the provider's declarative steps and
|
|
5
|
+
schema metadata, so there is no duplication with the core engine or the WebUI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Optional, Sequence, TextIO
|
|
13
|
+
|
|
14
|
+
from . import formats
|
|
15
|
+
from .engine import describe_provider, diagnose_request, generate
|
|
16
|
+
from .models import GenerationRequest, ProviderField, ProviderStep
|
|
17
|
+
from .registry import ProviderRegistry, default_registry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _EndOfInput(Exception):
|
|
21
|
+
"""Raised when the input stream ends before a required answer is given."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, field_name: str):
|
|
24
|
+
super().__init__(field_name)
|
|
25
|
+
self.field_name = field_name
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _set_dotted_path(target: Dict[str, Any], path: str, value: Any) -> None:
|
|
29
|
+
"""Set a value in a nested dict using a dotted path like 'app.port'."""
|
|
30
|
+
|
|
31
|
+
parts = path.split(".")
|
|
32
|
+
current = target
|
|
33
|
+
for part in parts[:-1]:
|
|
34
|
+
if part not in current or not isinstance(current[part], dict):
|
|
35
|
+
current[part] = {}
|
|
36
|
+
current = current[part]
|
|
37
|
+
current[parts[-1]] = value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_dotted_path(source: Any, path: str, default: Any = None) -> Any:
|
|
41
|
+
"""Retrieve a value from a nested dict using a dotted path."""
|
|
42
|
+
|
|
43
|
+
if not isinstance(source, dict):
|
|
44
|
+
return default
|
|
45
|
+
current: Any = source
|
|
46
|
+
for part in path.split("."):
|
|
47
|
+
if not isinstance(current, dict) or part not in current:
|
|
48
|
+
return default
|
|
49
|
+
current = current[part]
|
|
50
|
+
return current
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_line(reader: TextIO, writer: TextIO, prompt: str) -> Optional[str]:
|
|
54
|
+
"""Write ``prompt`` and read one line.
|
|
55
|
+
|
|
56
|
+
Returns the stripped line, or ``None`` when the stream is exhausted. A blank
|
|
57
|
+
line (``"\\n"``) is distinct from end-of-input (``""``).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
writer.write(prompt)
|
|
61
|
+
writer.flush()
|
|
62
|
+
line = reader.readline()
|
|
63
|
+
if line == "":
|
|
64
|
+
return None
|
|
65
|
+
return line.strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _prompt_string(
|
|
69
|
+
field: ProviderField,
|
|
70
|
+
current_value: Any,
|
|
71
|
+
reader: TextIO,
|
|
72
|
+
writer: TextIO,
|
|
73
|
+
) -> Any:
|
|
74
|
+
effective_default = current_value if current_value is not None else field.default
|
|
75
|
+
default_str = f" [{effective_default}]" if effective_default is not None else ""
|
|
76
|
+
req_str = " (required)" if field.required and effective_default is None else ""
|
|
77
|
+
|
|
78
|
+
if field.choices:
|
|
79
|
+
writer.write(f"? {field.name} - Select option:{req_str}\n")
|
|
80
|
+
for i, choice in enumerate(field.choices, 1):
|
|
81
|
+
writer.write(f" {i}) {choice}\n")
|
|
82
|
+
lower_choices = {str(c).lower(): c for c in field.choices}
|
|
83
|
+
while True:
|
|
84
|
+
answer = _read_line(reader, writer, f" Enter choice number or name{default_str}: ")
|
|
85
|
+
if answer is None:
|
|
86
|
+
if effective_default is not None:
|
|
87
|
+
return effective_default
|
|
88
|
+
raise _EndOfInput(field.name)
|
|
89
|
+
if answer == "":
|
|
90
|
+
if effective_default is not None:
|
|
91
|
+
return effective_default
|
|
92
|
+
if field.required:
|
|
93
|
+
writer.write(" [!] Selection is required. Please choose an option.\n")
|
|
94
|
+
continue
|
|
95
|
+
return None
|
|
96
|
+
if answer.isdigit():
|
|
97
|
+
idx = int(answer) - 1
|
|
98
|
+
if 0 <= idx < len(field.choices):
|
|
99
|
+
return field.choices[idx]
|
|
100
|
+
if answer.lower() in lower_choices:
|
|
101
|
+
return lower_choices[answer.lower()]
|
|
102
|
+
writer.write(
|
|
103
|
+
f" [!] Invalid choice: {answer!r}. Please select 1-{len(field.choices)}.\n"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
while True:
|
|
107
|
+
answer = _read_line(reader, writer, f"? {field.name}{req_str}{default_str}: ")
|
|
108
|
+
if answer is None:
|
|
109
|
+
if effective_default is not None:
|
|
110
|
+
return effective_default
|
|
111
|
+
raise _EndOfInput(field.name)
|
|
112
|
+
if answer == "":
|
|
113
|
+
if effective_default is not None:
|
|
114
|
+
return effective_default
|
|
115
|
+
if field.required:
|
|
116
|
+
writer.write(" [!] This field is required.\n")
|
|
117
|
+
continue
|
|
118
|
+
return None
|
|
119
|
+
return answer
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _prompt_integer(
|
|
123
|
+
field: ProviderField,
|
|
124
|
+
current_value: Any,
|
|
125
|
+
reader: TextIO,
|
|
126
|
+
writer: TextIO,
|
|
127
|
+
) -> Any:
|
|
128
|
+
effective_default = current_value if current_value is not None else field.default
|
|
129
|
+
default_str = f" [{effective_default}]" if effective_default is not None else ""
|
|
130
|
+
bounds = []
|
|
131
|
+
if field.minimum is not None:
|
|
132
|
+
bounds.append(f"min {int(field.minimum)}")
|
|
133
|
+
if field.maximum is not None:
|
|
134
|
+
bounds.append(f"max {int(field.maximum)}")
|
|
135
|
+
bound_str = f" ({', '.join(bounds)})" if bounds else ""
|
|
136
|
+
req_str = " (required)" if field.required and effective_default is None else ""
|
|
137
|
+
|
|
138
|
+
while True:
|
|
139
|
+
answer = _read_line(reader, writer, f"? {field.name}{bound_str}{req_str}{default_str}: ")
|
|
140
|
+
if answer is None:
|
|
141
|
+
if effective_default is not None:
|
|
142
|
+
return effective_default
|
|
143
|
+
raise _EndOfInput(field.name)
|
|
144
|
+
if answer == "":
|
|
145
|
+
if effective_default is not None:
|
|
146
|
+
return effective_default
|
|
147
|
+
if field.required:
|
|
148
|
+
writer.write(" [!] An integer value is required.\n")
|
|
149
|
+
continue
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
number = int(answer)
|
|
153
|
+
except ValueError:
|
|
154
|
+
writer.write(f" [!] Expected integer, got {answer!r}.\n")
|
|
155
|
+
continue
|
|
156
|
+
if field.minimum is not None and number < field.minimum:
|
|
157
|
+
writer.write(f" [!] Value must be >= {int(field.minimum)}.\n")
|
|
158
|
+
continue
|
|
159
|
+
if field.maximum is not None and number > field.maximum:
|
|
160
|
+
writer.write(f" [!] Value must be <= {int(field.maximum)}.\n")
|
|
161
|
+
continue
|
|
162
|
+
return number
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _prompt_boolean(
|
|
166
|
+
field: ProviderField,
|
|
167
|
+
current_value: Any,
|
|
168
|
+
reader: TextIO,
|
|
169
|
+
writer: TextIO,
|
|
170
|
+
) -> bool:
|
|
171
|
+
effective_default = bool(current_value if current_value is not None else field.default)
|
|
172
|
+
prompt_hint = "[Y/n]" if effective_default else "[y/N]"
|
|
173
|
+
while True:
|
|
174
|
+
answer = _read_line(reader, writer, f"? {field.name} {prompt_hint}: ")
|
|
175
|
+
if answer is None or answer == "":
|
|
176
|
+
return effective_default
|
|
177
|
+
lowered = answer.lower()
|
|
178
|
+
if lowered in ("y", "yes", "true", "1"):
|
|
179
|
+
return True
|
|
180
|
+
if lowered in ("n", "no", "false", "0"):
|
|
181
|
+
return False
|
|
182
|
+
writer.write(" [!] Please enter 'y' for yes or 'n' for no.\n")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _prompt_mapping(
|
|
186
|
+
field: ProviderField,
|
|
187
|
+
current_value: Any,
|
|
188
|
+
reader: TextIO,
|
|
189
|
+
writer: TextIO,
|
|
190
|
+
) -> Dict[str, str]:
|
|
191
|
+
mapping: Dict[str, str] = dict(current_value) if isinstance(current_value, dict) else {}
|
|
192
|
+
writer.write(
|
|
193
|
+
f"? {field.name} (enter key=value pairs, press enter on empty line to finish):\n"
|
|
194
|
+
)
|
|
195
|
+
if mapping:
|
|
196
|
+
writer.write(
|
|
197
|
+
" Current entries: " + ", ".join(f"{k}={v}" for k, v in mapping.items()) + "\n"
|
|
198
|
+
)
|
|
199
|
+
while True:
|
|
200
|
+
answer = _read_line(reader, writer, " key=val (or empty to finish): ")
|
|
201
|
+
if answer is None or answer == "":
|
|
202
|
+
break
|
|
203
|
+
if "=" not in answer:
|
|
204
|
+
writer.write(" [!] Invalid format. Must be key=value.\n")
|
|
205
|
+
continue
|
|
206
|
+
key, value = answer.split("=", 1)
|
|
207
|
+
mapping[key.strip()] = value.strip()
|
|
208
|
+
return mapping
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _prompt_document(
|
|
212
|
+
field: ProviderField,
|
|
213
|
+
current_value: Any,
|
|
214
|
+
reader: TextIO,
|
|
215
|
+
writer: TextIO,
|
|
216
|
+
) -> Any:
|
|
217
|
+
writer.write(f"? {field.name} (load document file or enter entries):\n")
|
|
218
|
+
path_str = _read_line(
|
|
219
|
+
reader, writer, " Path to JSON/YAML file (or press enter for key=value input): "
|
|
220
|
+
)
|
|
221
|
+
if path_str:
|
|
222
|
+
file_path = Path(path_str).expanduser()
|
|
223
|
+
if file_path.is_file():
|
|
224
|
+
try:
|
|
225
|
+
loaded = formats.load_file(file_path)
|
|
226
|
+
writer.write(f" [✓] Loaded document from {file_path}\n")
|
|
227
|
+
return loaded
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
writer.write(f" [!] Error reading {file_path}: {exc}\n")
|
|
230
|
+
else:
|
|
231
|
+
writer.write(f" [!] File not found: {path_str}\n")
|
|
232
|
+
return _prompt_mapping(field, current_value, reader, writer)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def prompt_field(
|
|
236
|
+
field: ProviderField,
|
|
237
|
+
current_value: Any,
|
|
238
|
+
reader: TextIO,
|
|
239
|
+
writer: TextIO,
|
|
240
|
+
) -> Any:
|
|
241
|
+
"""Prompt the user for a single field's value based on its type."""
|
|
242
|
+
|
|
243
|
+
if field.type == "integer":
|
|
244
|
+
return _prompt_integer(field, current_value, reader, writer)
|
|
245
|
+
if field.type == "boolean":
|
|
246
|
+
return _prompt_boolean(field, current_value, reader, writer)
|
|
247
|
+
if field.type in ("mapping", "dict"):
|
|
248
|
+
return _prompt_mapping(field, current_value, reader, writer)
|
|
249
|
+
if field.type in ("document", "tree"):
|
|
250
|
+
return _prompt_document(field, current_value, reader, writer)
|
|
251
|
+
return _prompt_string(field, current_value, reader, writer)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def run_interactive_wizard(
|
|
255
|
+
provider_name: str,
|
|
256
|
+
*,
|
|
257
|
+
input_path: Optional[str] = None,
|
|
258
|
+
context: Optional[Dict[str, Any]] = None,
|
|
259
|
+
output_dir: Optional[str] = None,
|
|
260
|
+
output_format: Optional[str] = None,
|
|
261
|
+
registry: Optional[ProviderRegistry] = None,
|
|
262
|
+
reader: Optional[TextIO] = None,
|
|
263
|
+
writer: Optional[TextIO] = None,
|
|
264
|
+
) -> int:
|
|
265
|
+
"""Execute the interactive wizard loop in the terminal.
|
|
266
|
+
|
|
267
|
+
``context`` seeds the wizard with existing answers. ``input_path`` loads a
|
|
268
|
+
document from disk and merges it on top of ``context``. On validation
|
|
269
|
+
failure the wizard offers to re-run while keeping every answer pre-filled.
|
|
270
|
+
|
|
271
|
+
Returns ``0`` on successful generation and ``1`` on cancellation or error.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
in_stream = reader or sys.stdin
|
|
275
|
+
out_stream = writer or sys.stdout
|
|
276
|
+
|
|
277
|
+
reg = registry or default_registry
|
|
278
|
+
try:
|
|
279
|
+
reg.get(provider_name)
|
|
280
|
+
except ValueError as exc:
|
|
281
|
+
out_stream.write(f"Error: {exc}\n")
|
|
282
|
+
return 1
|
|
283
|
+
|
|
284
|
+
steps: Sequence[ProviderStep] = describe_provider(provider_name, registry=reg)
|
|
285
|
+
if not steps:
|
|
286
|
+
out_stream.write(
|
|
287
|
+
f"Provider '{provider_name}' does not define declarative wizard steps.\n"
|
|
288
|
+
)
|
|
289
|
+
return 1
|
|
290
|
+
|
|
291
|
+
working: Dict[str, Any] = dict(context) if context else {}
|
|
292
|
+
if input_path:
|
|
293
|
+
try:
|
|
294
|
+
loaded = formats.load_file(input_path)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
out_stream.write(f"[warn] Could not load initial context: {exc}\n")
|
|
297
|
+
else:
|
|
298
|
+
if isinstance(loaded, dict):
|
|
299
|
+
working.update(loaded)
|
|
300
|
+
out_stream.write(f"[info] Pre-populated wizard with values from {input_path}\n")
|
|
301
|
+
else:
|
|
302
|
+
out_stream.write(
|
|
303
|
+
f"[warn] {input_path} does not contain a mapping; ignoring it.\n"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
out_stream.write("========================================================\n")
|
|
307
|
+
out_stream.write(f" DevConfig-Gen Interactive Wizard: '{provider_name}'\n")
|
|
308
|
+
out_stream.write(" Answer the prompts below. Press Enter to use defaults.\n")
|
|
309
|
+
out_stream.write("========================================================\n")
|
|
310
|
+
|
|
311
|
+
total_steps = len(steps)
|
|
312
|
+
while True:
|
|
313
|
+
try:
|
|
314
|
+
for step_idx, step in enumerate(steps, 1):
|
|
315
|
+
out_stream.write(f"\n--- [{step_idx}/{total_steps}] {step.title} ---\n")
|
|
316
|
+
if step.description:
|
|
317
|
+
out_stream.write(f" {step.description}\n")
|
|
318
|
+
for field in step.fields:
|
|
319
|
+
existing = _get_dotted_path(working, field.name)
|
|
320
|
+
value = prompt_field(field, existing, in_stream, out_stream)
|
|
321
|
+
if value is not None:
|
|
322
|
+
_set_dotted_path(working, field.name, value)
|
|
323
|
+
except _EndOfInput as exc:
|
|
324
|
+
out_stream.write(
|
|
325
|
+
f"\n[!] Input ended before '{exc.field_name}' was provided. Aborting.\n"
|
|
326
|
+
)
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
out_stream.write("\nValidating configuration...\n")
|
|
330
|
+
diagnostics = diagnose_request(provider_name, context=working, registry=reg)
|
|
331
|
+
errors = [item for item in diagnostics if item.severity == "error"]
|
|
332
|
+
if not errors:
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
out_stream.write("\n[!] Validation found errors:\n")
|
|
336
|
+
for err in errors:
|
|
337
|
+
out_stream.write(f" - {err.field}: {err.message}\n")
|
|
338
|
+
|
|
339
|
+
answer = _read_line(
|
|
340
|
+
in_stream,
|
|
341
|
+
out_stream,
|
|
342
|
+
"Re-run the wizard to correct these? [Y/n]: ",
|
|
343
|
+
)
|
|
344
|
+
if answer is None or answer.lower() in ("n", "no"):
|
|
345
|
+
out_stream.write("Aborted.\n")
|
|
346
|
+
return 1
|
|
347
|
+
out_stream.write("\nRe-running wizard with your previous answers pre-filled...\n")
|
|
348
|
+
|
|
349
|
+
out_stream.write("[✓] All validations passed!\n\n")
|
|
350
|
+
|
|
351
|
+
selected_format = output_format
|
|
352
|
+
if not selected_format:
|
|
353
|
+
answer = _read_line(
|
|
354
|
+
in_stream,
|
|
355
|
+
out_stream,
|
|
356
|
+
"Select output format (1: YAML [default], 2: JSON): ",
|
|
357
|
+
)
|
|
358
|
+
selected_format = "json" if answer == "2" else "yaml"
|
|
359
|
+
|
|
360
|
+
target_dir = output_dir or "."
|
|
361
|
+
out_stream.write(f"Writing configuration to '{target_dir}'...\n")
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
result = generate(
|
|
365
|
+
provider_name,
|
|
366
|
+
GenerationRequest(context=working, options={"format": selected_format}),
|
|
367
|
+
registry=reg,
|
|
368
|
+
output_dir=target_dir,
|
|
369
|
+
)
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
out_stream.write(f"[!] Generation failed: {exc}\n")
|
|
372
|
+
return 1
|
|
373
|
+
|
|
374
|
+
for artifact in result.artifacts:
|
|
375
|
+
out_path = Path(target_dir) / artifact.name
|
|
376
|
+
out_stream.write(f"[✓] Generated artifact: {out_path.resolve()}\n")
|
|
377
|
+
return 0
|
devconfig_gen/models.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Stable public data structures for configuration providers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Mapping, Optional, Protocol, Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class GenerationRequest:
|
|
9
|
+
"""Input to a provider; mappings are treated as read-only."""
|
|
10
|
+
|
|
11
|
+
context: Mapping[str, Any] = field(default_factory=dict)
|
|
12
|
+
options: Mapping[str, Any] = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Diagnostic:
|
|
17
|
+
"""A single, machine-readable validation message.
|
|
18
|
+
|
|
19
|
+
``field`` is the dotted path to the offending value (for example
|
|
20
|
+
``app.port``). ``message`` is the fully rendered, user-facing text
|
|
21
|
+
(for example ``port must be between 1 and 65535, got 99999``).
|
|
22
|
+
``severity`` is ``"error"`` or ``"warning"``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
field: str
|
|
26
|
+
message: str
|
|
27
|
+
severity: str = "error"
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return self.message
|
|
31
|
+
|
|
32
|
+
def as_dict(self) -> dict:
|
|
33
|
+
return {"field": self.field, "message": self.message, "severity": self.severity}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class GeneratedArtifact:
|
|
38
|
+
name: str
|
|
39
|
+
content: Any
|
|
40
|
+
media_type: str = "application/json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class GenerationResult:
|
|
45
|
+
provider: str
|
|
46
|
+
artifacts: Sequence[GeneratedArtifact]
|
|
47
|
+
diagnostics: Sequence[Diagnostic] = field(default_factory=tuple)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ProviderField:
|
|
52
|
+
"""Declarative description of one provider input field.
|
|
53
|
+
|
|
54
|
+
``name`` is the dotted field path, e.g. ``app.port``. The remaining
|
|
55
|
+
attributes describe the value for documentation and future UI generation.
|
|
56
|
+
|
|
57
|
+
``i18n`` optionally maps a locale (e.g. ``"zh"``) to translated
|
|
58
|
+
``title``/``description`` strings, letting clients render localized
|
|
59
|
+
metadata without changing the canonical English defaults.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
type: str = "string"
|
|
64
|
+
required: bool = False
|
|
65
|
+
default: Any = None
|
|
66
|
+
description: str = ""
|
|
67
|
+
choices: Sequence[str] = ()
|
|
68
|
+
minimum: Optional[float] = None
|
|
69
|
+
maximum: Optional[float] = None
|
|
70
|
+
title: str = ""
|
|
71
|
+
i18n: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
def as_dict(self) -> dict:
|
|
74
|
+
data = {
|
|
75
|
+
"name": self.name,
|
|
76
|
+
"title": self.title or self.name,
|
|
77
|
+
"type": self.type,
|
|
78
|
+
"required": self.required,
|
|
79
|
+
"default": self.default,
|
|
80
|
+
"description": self.description,
|
|
81
|
+
}
|
|
82
|
+
if self.choices:
|
|
83
|
+
data["choices"] = list(self.choices)
|
|
84
|
+
if self.minimum is not None:
|
|
85
|
+
data["minimum"] = self.minimum
|
|
86
|
+
if self.maximum is not None:
|
|
87
|
+
data["maximum"] = self.maximum
|
|
88
|
+
if self.i18n:
|
|
89
|
+
data["i18n"] = {locale: dict(values) for locale, values in self.i18n.items()}
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class ProviderStep:
|
|
95
|
+
"""A declarative step grouping fields for a guided input flow.
|
|
96
|
+
|
|
97
|
+
``i18n`` optionally maps a locale (e.g. ``"zh"``) to translated
|
|
98
|
+
``title``/``description`` strings for localized clients.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
id: str
|
|
102
|
+
title: str
|
|
103
|
+
description: str = ""
|
|
104
|
+
fields: Sequence[ProviderField] = ()
|
|
105
|
+
i18n: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
|
106
|
+
|
|
107
|
+
def as_dict(self) -> dict:
|
|
108
|
+
data = {
|
|
109
|
+
"id": self.id,
|
|
110
|
+
"title": self.title,
|
|
111
|
+
"description": self.description,
|
|
112
|
+
"fields": [item.as_dict() for item in self.fields],
|
|
113
|
+
}
|
|
114
|
+
if self.i18n:
|
|
115
|
+
data["i18n"] = {locale: dict(values) for locale, values in self.i18n.items()}
|
|
116
|
+
return data
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ConfigProvider(Protocol):
|
|
120
|
+
"""Structural contract every provider implements.
|
|
121
|
+
|
|
122
|
+
``validate`` returns rendered messages. Providers may additionally
|
|
123
|
+
implement ``diagnose`` (structured ``Diagnostic`` values) and
|
|
124
|
+
``describe_schema`` / ``steps`` (declarative field metadata). Metadata
|
|
125
|
+
methods are optional, so a minimal provider only needs ``name``,
|
|
126
|
+
``validate``, and ``generate``.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
name: str
|
|
130
|
+
|
|
131
|
+
def generate(self, request: GenerationRequest) -> GenerationResult:
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
def validate(self, request: GenerationRequest) -> Sequence[str]:
|
|
135
|
+
...
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""A generic, schema-free provider for arbitrary JSON/YAML documents.
|
|
2
|
+
|
|
3
|
+
Unlike schema-bound providers that validate a fixed set of domain fields,
|
|
4
|
+
``custom`` imposes no schema at all: any nesting of mappings, sequences and
|
|
5
|
+
scalars is accepted, and callers can add, remove, or clear nodes at any level.
|
|
6
|
+
It exists so users are not forced into a particular domain model.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from .. import formats
|
|
14
|
+
from ..models import (
|
|
15
|
+
GeneratedArtifact,
|
|
16
|
+
GenerationRequest,
|
|
17
|
+
GenerationResult,
|
|
18
|
+
ProviderField,
|
|
19
|
+
ProviderStep,
|
|
20
|
+
)
|
|
21
|
+
from ..validation import ValidationError
|
|
22
|
+
|
|
23
|
+
DEFAULT_ARTIFACT_STEM = "custom"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CustomProvider:
|
|
27
|
+
"""Pass an arbitrary, freely-edited document through the pipeline."""
|
|
28
|
+
|
|
29
|
+
name = "custom"
|
|
30
|
+
|
|
31
|
+
steps: Sequence[ProviderStep] = (
|
|
32
|
+
ProviderStep(
|
|
33
|
+
id="document",
|
|
34
|
+
title="Custom document",
|
|
35
|
+
description="Build any nested JSON/YAML structure; add, remove, or clear nodes at any level.",
|
|
36
|
+
i18n={
|
|
37
|
+
"zh": {
|
|
38
|
+
"title": "自定义文档",
|
|
39
|
+
"description": "自由构建任意嵌套的 JSON/YAML 结构;任意层级都可增删或清空。",
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
fields=(
|
|
43
|
+
ProviderField(
|
|
44
|
+
"document",
|
|
45
|
+
type="tree",
|
|
46
|
+
required=False,
|
|
47
|
+
title="Document tree",
|
|
48
|
+
description="Arbitrary nested document; keys and values are fully editable.",
|
|
49
|
+
i18n={
|
|
50
|
+
"zh": {
|
|
51
|
+
"title": "文档结构",
|
|
52
|
+
"description": "任意嵌套文档;键与值均可自由编辑。",
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def describe_schema(self) -> Sequence[ProviderStep]:
|
|
61
|
+
return self.steps
|
|
62
|
+
|
|
63
|
+
def _unwrap_context(self, context: Any) -> Any:
|
|
64
|
+
if isinstance(context, Mapping) and set(context.keys()) == {"document"}:
|
|
65
|
+
return context["document"]
|
|
66
|
+
return context
|
|
67
|
+
|
|
68
|
+
def validate(self, request: GenerationRequest):
|
|
69
|
+
return ()
|
|
70
|
+
|
|
71
|
+
def generate(self, request: GenerationRequest) -> GenerationResult:
|
|
72
|
+
errors = self.validate(request)
|
|
73
|
+
if errors:
|
|
74
|
+
raise ValidationError(errors)
|
|
75
|
+
fmt = formats.resolve_format(
|
|
76
|
+
request.options.get("format"),
|
|
77
|
+
name=request.options.get("name"),
|
|
78
|
+
default=formats.JSON,
|
|
79
|
+
)
|
|
80
|
+
suffix = "yaml" if fmt == formats.YAML else "json"
|
|
81
|
+
filename = request.options.get("name") or f"{DEFAULT_ARTIFACT_STEM}.{suffix}"
|
|
82
|
+
context = self._unwrap_context(request.context)
|
|
83
|
+
return GenerationResult(
|
|
84
|
+
provider=self.name,
|
|
85
|
+
artifacts=(
|
|
86
|
+
GeneratedArtifact(
|
|
87
|
+
name=str(filename),
|
|
88
|
+
content=context,
|
|
89
|
+
media_type=formats.media_type_for(fmt),
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
)
|