schemify-cli 0.1.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.
- schemify/__init__.py +21 -0
- schemify/cli/__init__.py +1 -0
- schemify/cli/dependencies/__init__.py +4 -0
- schemify/cli/dependencies/console.py +9 -0
- schemify/cli/dependencies/schema.py +128 -0
- schemify/cli/main.py +138 -0
- schemify/computers.py +682 -0
- schemify/fillers.py +272 -0
- schemify/formatters.py +66 -0
- schemify/generators.py +323 -0
- schemify/models.py +6 -0
- schemify/py.typed +0 -0
- schemify/registry.py +69 -0
- schemify/validators.py +300 -0
- schemify_cli-0.1.0.dist-info/METADATA +93 -0
- schemify_cli-0.1.0.dist-info/RECORD +18 -0
- schemify_cli-0.1.0.dist-info/WHEEL +4 -0
- schemify_cli-0.1.0.dist-info/entry_points.txt +3 -0
schemify/fillers.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Data fillers: ask the user for a value matching a JSON Schema, picked by the schema kind.
|
|
2
|
+
|
|
3
|
+
The kind is `enum` when the schema has choices, else its `format` when a filler is
|
|
4
|
+
registered for it (`date`, `date-time`), else its `type`.
|
|
5
|
+
|
|
6
|
+
A schema `default` is offered as the answer of a text, boolean or enum prompt.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import questionary
|
|
13
|
+
|
|
14
|
+
from .computers import KEYWORD, ComputeError, compute
|
|
15
|
+
from .models import Schema
|
|
16
|
+
from .registry import NotSupportedError, Registry
|
|
17
|
+
from .validators import Parser, check_value, to_date, to_datetime, to_int, to_number
|
|
18
|
+
|
|
19
|
+
type FillFn = Callable[[Schema, str, bool], Any]
|
|
20
|
+
"""Asks for a value of a sub-schema, given its label and whether it is required.
|
|
21
|
+
|
|
22
|
+
An optional value the user skips is returned as `MISSING`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
MISSING: Any = object()
|
|
26
|
+
"""Returned by a filler when the user skips an optional value."""
|
|
27
|
+
|
|
28
|
+
fillers = Registry[FillFn]("Schema kind")
|
|
29
|
+
"""The default registry, used by `ask_data`."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidDataError(Exception):
|
|
33
|
+
"""Raised when the entered data does not match the whole schema.
|
|
34
|
+
|
|
35
|
+
Each value is checked as it is entered, this catches the keywords
|
|
36
|
+
no filler asks about (like `allOf` or `dependentRequired`).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ask_data(schema: Schema) -> Any:
|
|
41
|
+
"""Ask the user for data matching `schema`, usually an object, and return it.
|
|
42
|
+
|
|
43
|
+
The `x-compute` properties are not asked, they are computed once the data is entered.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
NotSupportedError: A part of the schema has no filler (like `$ref` or `oneOf`),
|
|
47
|
+
or a computed property names an unknown computer.
|
|
48
|
+
InvalidDataError: A property can not be computed,
|
|
49
|
+
or the data does not match the whole schema.
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
data = fill(schema, "")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
compute(schema, data)
|
|
56
|
+
except ComputeError as e:
|
|
57
|
+
raise InvalidDataError(e) from e
|
|
58
|
+
error = check_value(schema, data)
|
|
59
|
+
|
|
60
|
+
if error is not None:
|
|
61
|
+
raise InvalidDataError(error)
|
|
62
|
+
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ask_list(schema: Schema) -> list[Any]:
|
|
67
|
+
"""Ask for items matching `schema` until the user stops, at least one, and return them.
|
|
68
|
+
|
|
69
|
+
Each item is asked, computed and checked like `ask_data`, so `$.` paths point to the item.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
NotSupportedError: A part of the schema has no filler, or names an unknown computer.
|
|
73
|
+
InvalidDataError: An item can not be computed, or does not match the whole schema.
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
items: list[Any] = []
|
|
77
|
+
|
|
78
|
+
while True:
|
|
79
|
+
questionary.print(f"Item {len(items) + 1}", style="bold")
|
|
80
|
+
items.append(ask_data(schema))
|
|
81
|
+
questionary.print("") # separate each item's questions from the next
|
|
82
|
+
|
|
83
|
+
if not questionary.confirm(
|
|
84
|
+
f"Add another item? ({len(items)} so far)", default=True
|
|
85
|
+
).unsafe_ask():
|
|
86
|
+
return items
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def kind(schema: Schema) -> str:
|
|
90
|
+
"""Return the filler name of `schema`: `enum`, a registered `format`, or its `type`."""
|
|
91
|
+
if "enum" in schema:
|
|
92
|
+
return "enum"
|
|
93
|
+
|
|
94
|
+
if schema.get("format") in fillers:
|
|
95
|
+
return schema["format"]
|
|
96
|
+
|
|
97
|
+
return str(schema.get("type"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fill(schema: Schema, label: str, *, required: bool = True) -> Any:
|
|
101
|
+
"""Ask for a value of `schema` with the filler matching its kind.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
NotSupportedError: No filler matches the schema kind.
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
fill_fn = fillers[kind(schema)]
|
|
109
|
+
except NotSupportedError as e:
|
|
110
|
+
message = f"Can not fill '{label or 'data'}': {e}"
|
|
111
|
+
raise NotSupportedError(message) from e
|
|
112
|
+
|
|
113
|
+
return fill_fn(schema, label, required)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# -----------------------
|
|
117
|
+
# utils
|
|
118
|
+
# -----------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _name(schema: Schema, label: str) -> str:
|
|
122
|
+
"""Return the schema `title` when it has one, else the property path."""
|
|
123
|
+
return schema.get("title") or label
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _message(schema: Schema, label: str) -> str:
|
|
127
|
+
name = _name(schema, label)
|
|
128
|
+
description = schema.get("description")
|
|
129
|
+
|
|
130
|
+
return f"{name} ({description}):" if description else f"{name}:"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _skipped(schema: Schema, label: str, *, required: bool) -> bool:
|
|
134
|
+
"""Ask whether to add an optional value, `False` for required values."""
|
|
135
|
+
if required:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
return not questionary.confirm(f"Add '{_name(schema, label)}'?", default=True).unsafe_ask()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# -----------------------
|
|
142
|
+
# typed text
|
|
143
|
+
# -----------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _text_filler(parse: Parser) -> FillFn:
|
|
147
|
+
"""Build a filler asking a text answer, parsed then checked against the schema."""
|
|
148
|
+
|
|
149
|
+
def fill_text(schema: Schema, label: str, required: bool) -> Any: # noqa: FBT001
|
|
150
|
+
def validate(text: str) -> bool | str:
|
|
151
|
+
if not required and not text.strip():
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
value = parse(text)
|
|
156
|
+
except ValueError as e:
|
|
157
|
+
return str(e)
|
|
158
|
+
|
|
159
|
+
error = check_value(schema, value)
|
|
160
|
+
|
|
161
|
+
return error is None or error
|
|
162
|
+
|
|
163
|
+
default = str(schema["default"]) if "default" in schema else ""
|
|
164
|
+
answer = questionary.text(
|
|
165
|
+
_message(schema, label), default=default, validate=validate
|
|
166
|
+
).unsafe_ask()
|
|
167
|
+
|
|
168
|
+
if not required and not answer.strip():
|
|
169
|
+
return MISSING
|
|
170
|
+
|
|
171
|
+
return parse(answer)
|
|
172
|
+
|
|
173
|
+
return fill_text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
fillers.add(_text_filler(str), "string")
|
|
177
|
+
fillers.add(_text_filler(to_int), "integer")
|
|
178
|
+
fillers.add(_text_filler(to_number), "number")
|
|
179
|
+
fillers.add(_text_filler(to_date), "date")
|
|
180
|
+
fillers.add(_text_filler(to_datetime), "date-time")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# -----------------------
|
|
184
|
+
# choices
|
|
185
|
+
# -----------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@fillers.register("boolean")
|
|
189
|
+
def fill_boolean(schema: Schema, label: str, required: bool) -> Any: # noqa: FBT001
|
|
190
|
+
"""Ask yes / no."""
|
|
191
|
+
if _skipped(schema, label, required=required):
|
|
192
|
+
return MISSING
|
|
193
|
+
|
|
194
|
+
default = schema.get("default") is True
|
|
195
|
+
|
|
196
|
+
return questionary.confirm(_message(schema, label), default=default).unsafe_ask()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@fillers.register("enum")
|
|
200
|
+
def fill_enum(schema: Schema, label: str, required: bool) -> Any: # noqa: FBT001
|
|
201
|
+
"""Ask to pick one of the `enum` choices, they can be any JSON value."""
|
|
202
|
+
if _skipped(schema, label, required=required):
|
|
203
|
+
return MISSING
|
|
204
|
+
|
|
205
|
+
choices = {str(choice): choice for choice in schema["enum"]}
|
|
206
|
+
default = str(schema["default"]) if schema.get("default") in schema["enum"] else None
|
|
207
|
+
answer = questionary.select(
|
|
208
|
+
_message(schema, label), choices=list(choices), default=default
|
|
209
|
+
).unsafe_ask()
|
|
210
|
+
|
|
211
|
+
return choices[answer]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# -----------------------
|
|
215
|
+
# containers
|
|
216
|
+
# -----------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@fillers.register("array")
|
|
220
|
+
def fill_array(schema: Schema, label: str, required: bool) -> Any: # noqa: FBT001
|
|
221
|
+
"""Ask items until the user stops, respecting `minItems`, `maxItems` and `uniqueItems`.
|
|
222
|
+
|
|
223
|
+
shortcut: a `default` of the whole array is not offered, its items are asked one by one.
|
|
224
|
+
"""
|
|
225
|
+
if _skipped(schema, label, required=required):
|
|
226
|
+
return MISSING
|
|
227
|
+
|
|
228
|
+
items_schema = schema.get("items", {})
|
|
229
|
+
min_items = schema.get("minItems", 0)
|
|
230
|
+
max_items = schema.get("maxItems")
|
|
231
|
+
items: list[Any] = []
|
|
232
|
+
|
|
233
|
+
while max_items is None or len(items) < max_items:
|
|
234
|
+
if (
|
|
235
|
+
len(items) >= min_items
|
|
236
|
+
and not questionary.confirm(
|
|
237
|
+
f"Add an item to '{_name(schema, label)}'?", default=True
|
|
238
|
+
).unsafe_ask()
|
|
239
|
+
):
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
item = fill(items_schema, f"{label}[{len(items)}]")
|
|
243
|
+
|
|
244
|
+
if schema.get("uniqueItems") and item in items:
|
|
245
|
+
questionary.print(f"{item!r} is already in '{_name(schema, label)}'", style="fg:red")
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
items.append(item)
|
|
249
|
+
|
|
250
|
+
return items
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@fillers.register("object")
|
|
254
|
+
def fill_object(schema: Schema, label: str, required: bool) -> Any: # noqa: FBT001
|
|
255
|
+
"""Ask each property in order, optional ones can be skipped, computed ones are not asked."""
|
|
256
|
+
if _skipped(schema, label, required=required):
|
|
257
|
+
return MISSING
|
|
258
|
+
|
|
259
|
+
required_names = set(schema.get("required", []))
|
|
260
|
+
data: dict[str, Any] = {}
|
|
261
|
+
|
|
262
|
+
for name, property_schema in schema.get("properties", {}).items():
|
|
263
|
+
if KEYWORD in property_schema:
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
path = f"{label}.{name}" if label else name
|
|
267
|
+
value = fill(property_schema, path, required=name in required_names)
|
|
268
|
+
|
|
269
|
+
if value is not MISSING:
|
|
270
|
+
data[name] = value
|
|
271
|
+
|
|
272
|
+
return data
|
schemify/formatters.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Data formatters: turn filled data into source text, picked by format name.
|
|
2
|
+
|
|
3
|
+
Each format name is also its pygments lexer name, used to highlight the printed output.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import pprint
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .registry import Registry
|
|
13
|
+
|
|
14
|
+
type FormatFn = Callable[[Any], str]
|
|
15
|
+
|
|
16
|
+
formatters = Registry[FormatFn]("Format")
|
|
17
|
+
"""The default registry, used by the `fill --format` option."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@formatters.register("json")
|
|
21
|
+
def to_json(data: Any) -> str:
|
|
22
|
+
"""Format as indented JSON, keeping non-ASCII characters."""
|
|
23
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@formatters.register("python")
|
|
27
|
+
def to_python(data: Any) -> str:
|
|
28
|
+
"""Format as a Python literal: `{'done': True, 'note': None}`."""
|
|
29
|
+
return pprint.pformat(data, sort_dicts=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@formatters.register("javascript")
|
|
33
|
+
def to_javascript(data: Any) -> str:
|
|
34
|
+
"""Format as a JavaScript object literal, quoting only the keys that need it."""
|
|
35
|
+
return _javascript(data, 0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# -----------------------
|
|
39
|
+
# utils
|
|
40
|
+
# -----------------------
|
|
41
|
+
|
|
42
|
+
_JS_IDENTIFIER = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _javascript_key(key: str) -> str:
|
|
46
|
+
return key if _JS_IDENTIFIER.fullmatch(key) else json.dumps(key, ensure_ascii=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _javascript(value: Any, level: int) -> str:
|
|
50
|
+
indent = " " * (level + 1)
|
|
51
|
+
closing_indent = " " * level
|
|
52
|
+
|
|
53
|
+
if isinstance(value, dict) and value:
|
|
54
|
+
lines = [
|
|
55
|
+
f"{indent}{_javascript_key(k)}: {_javascript(v, level + 1)}" for k, v in value.items()
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
return "{\n" + ",\n".join(lines) + f"\n{closing_indent}}}"
|
|
59
|
+
|
|
60
|
+
if isinstance(value, list) and value:
|
|
61
|
+
lines = [f"{indent}{_javascript(item, level + 1)}" for item in value]
|
|
62
|
+
|
|
63
|
+
return "[\n" + ",\n".join(lines) + f"\n{closing_indent}]"
|
|
64
|
+
|
|
65
|
+
# scalars, `{}` and `[]`: JSON is valid JavaScript
|
|
66
|
+
return json.dumps(value, ensure_ascii=False)
|
schemify/generators.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Schema generators: ask the user about a type and return its JSON Schema, picked by type name."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import Any, get_origin
|
|
7
|
+
|
|
8
|
+
import questionary
|
|
9
|
+
|
|
10
|
+
from .computers import KEYWORD, computers, groups
|
|
11
|
+
from .models import Schema
|
|
12
|
+
from .registry import Registry
|
|
13
|
+
from .validators import (
|
|
14
|
+
ARRAY_CONSTRAINTS,
|
|
15
|
+
NUMBER_CONSTRAINTS,
|
|
16
|
+
STRING_CONSTRAINTS,
|
|
17
|
+
Constraint,
|
|
18
|
+
default_parser,
|
|
19
|
+
is_choice,
|
|
20
|
+
is_property_name,
|
|
21
|
+
optional,
|
|
22
|
+
validator,
|
|
23
|
+
within_bounds,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
type GenerateFn = Callable[[], Schema]
|
|
27
|
+
|
|
28
|
+
SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
|
29
|
+
|
|
30
|
+
generators = Registry[GenerateFn]("Type")
|
|
31
|
+
"""The default registry, every registered type is offered in the type prompts."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ask_schema(title: str | None = None) -> Schema:
|
|
35
|
+
"""Ask the user for the properties of an object and return its JSON Schema."""
|
|
36
|
+
header = {"$schema": SCHEMA_DIALECT, "title": title} if title else {"$schema": SCHEMA_DIALECT}
|
|
37
|
+
|
|
38
|
+
return {**header, **generators["object"]()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# -----------------------
|
|
42
|
+
# utils
|
|
43
|
+
# -----------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _ask_type(message: str, extra: Sequence[str] = ()) -> str:
|
|
47
|
+
choices = [*generators.names, *extra]
|
|
48
|
+
|
|
49
|
+
return questionary.select(message, choices=choices, default="string").unsafe_ask()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _wants_validators() -> bool:
|
|
53
|
+
return questionary.confirm("Add validators?", default=False).unsafe_ask()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ask_constraints(constraints: list[Constraint]) -> Schema:
|
|
57
|
+
"""Ask each constraint, an empty answer skips it, a conflicting bound is asked again."""
|
|
58
|
+
schema: Schema = {}
|
|
59
|
+
|
|
60
|
+
for constraint in constraints:
|
|
61
|
+
keyword, question, parse, _ = constraint
|
|
62
|
+
answer = questionary.text(
|
|
63
|
+
f"{question} (optional):", validate=optional(within_bounds(schema, constraint))
|
|
64
|
+
).unsafe_ask()
|
|
65
|
+
|
|
66
|
+
if answer.strip():
|
|
67
|
+
schema[keyword] = parse(answer)
|
|
68
|
+
|
|
69
|
+
return schema
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# -----------------------
|
|
73
|
+
# primitive types
|
|
74
|
+
# -----------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@generators.register("string")
|
|
78
|
+
def generate_string() -> Schema:
|
|
79
|
+
"""Ask for a string: format (email, uuid), digits only, length, pattern, blocked values."""
|
|
80
|
+
schema: Schema = {"type": "string"}
|
|
81
|
+
|
|
82
|
+
if not _wants_validators():
|
|
83
|
+
return schema
|
|
84
|
+
|
|
85
|
+
string_format = questionary.select("Format:", choices=["none", "email", "uuid"]).unsafe_ask()
|
|
86
|
+
|
|
87
|
+
if string_format != "none":
|
|
88
|
+
return {**schema, "format": string_format}
|
|
89
|
+
|
|
90
|
+
constraints = STRING_CONSTRAINTS
|
|
91
|
+
|
|
92
|
+
if questionary.confirm("Digits only?", default=False).unsafe_ask():
|
|
93
|
+
schema["pattern"] = r"^\d+$"
|
|
94
|
+
constraints = [c for c in STRING_CONSTRAINTS if c[0] != "pattern"]
|
|
95
|
+
|
|
96
|
+
return {**schema, **_ask_constraints(constraints)}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _number_generator(number_type: str) -> GenerateFn:
|
|
100
|
+
def generate() -> Schema:
|
|
101
|
+
"""Ask for an integer or a number: bounds and a multiple."""
|
|
102
|
+
schema: Schema = {"type": number_type}
|
|
103
|
+
|
|
104
|
+
if not _wants_validators():
|
|
105
|
+
return schema
|
|
106
|
+
|
|
107
|
+
return {**schema, **_ask_constraints(NUMBER_CONSTRAINTS)}
|
|
108
|
+
|
|
109
|
+
return generate
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
generators.add(_number_generator("integer"), "integer")
|
|
113
|
+
generators.add(_number_generator("number"), "number")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@generators.register("boolean")
|
|
117
|
+
def generate_boolean() -> Schema:
|
|
118
|
+
"""Return a boolean."""
|
|
119
|
+
return {"type": "boolean"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@generators.register("date")
|
|
123
|
+
def generate_date() -> Schema:
|
|
124
|
+
"""Return an ISO date string: `2026-11-04`."""
|
|
125
|
+
return {"type": "string", "format": "date"}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@generators.register("datetime")
|
|
129
|
+
def generate_datetime() -> Schema:
|
|
130
|
+
"""Return an RFC 3339 datetime string: `2026-11-04T10:30:00+03:00`."""
|
|
131
|
+
return {"type": "string", "format": "date-time"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@generators.register("enum")
|
|
135
|
+
def generate_enum() -> Schema:
|
|
136
|
+
"""Ask for a string limited to choices, one per prompt."""
|
|
137
|
+
choices: list[str] = []
|
|
138
|
+
|
|
139
|
+
while choice := (
|
|
140
|
+
questionary.text("Choice (empty to finish):", validate=is_choice(choices))
|
|
141
|
+
.unsafe_ask()
|
|
142
|
+
.strip()
|
|
143
|
+
):
|
|
144
|
+
choices.append(choice)
|
|
145
|
+
|
|
146
|
+
return {"type": "string", "enum": choices}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# -----------------------
|
|
150
|
+
# container types
|
|
151
|
+
# -----------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@generators.register("array")
|
|
155
|
+
def generate_array() -> Schema:
|
|
156
|
+
"""Ask for a list of items of one type: size and unique items."""
|
|
157
|
+
items = generators[_ask_type("Items type:")]()
|
|
158
|
+
schema: Schema = {"type": "array", "items": items}
|
|
159
|
+
|
|
160
|
+
if not _wants_validators():
|
|
161
|
+
return schema
|
|
162
|
+
|
|
163
|
+
schema = {**schema, **_ask_constraints(ARRAY_CONSTRAINTS)}
|
|
164
|
+
|
|
165
|
+
if questionary.confirm("Unique items?", default=False).unsafe_ask():
|
|
166
|
+
schema["uniqueItems"] = True
|
|
167
|
+
|
|
168
|
+
return schema
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@generators.register("object")
|
|
172
|
+
def generate_object() -> Schema:
|
|
173
|
+
"""Ask for an object: each property name, type, label, description, default, and if required.
|
|
174
|
+
|
|
175
|
+
The label is saved as the JSON Schema `title`, fillers show it instead of the property name.
|
|
176
|
+
"""
|
|
177
|
+
properties: Schema = {}
|
|
178
|
+
required: list[str] = []
|
|
179
|
+
|
|
180
|
+
while (
|
|
181
|
+
name := questionary.text(
|
|
182
|
+
"Property name (empty to finish):", validate=is_property_name(properties)
|
|
183
|
+
)
|
|
184
|
+
.unsafe_ask()
|
|
185
|
+
.strip()
|
|
186
|
+
):
|
|
187
|
+
# a computed property needs earlier properties to compute from
|
|
188
|
+
type_name = _ask_type(f"Type of '{name}':", ["computed"] if properties else [])
|
|
189
|
+
property_schema = (
|
|
190
|
+
_generate_computed(list(properties))
|
|
191
|
+
if type_name == "computed"
|
|
192
|
+
else generators[type_name]()
|
|
193
|
+
)
|
|
194
|
+
annotations = {
|
|
195
|
+
"title": questionary.text(f"Label shown instead of '{name}' when filling (optional):")
|
|
196
|
+
.unsafe_ask()
|
|
197
|
+
.strip(),
|
|
198
|
+
"description": questionary.text(f"Description of '{name}' (optional):")
|
|
199
|
+
.unsafe_ask()
|
|
200
|
+
.strip(),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
properties[name] = {
|
|
204
|
+
**{keyword: value for keyword, value in annotations.items() if value},
|
|
205
|
+
**property_schema,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# a computed property sets its own `default` in `x-compute`
|
|
209
|
+
if type_name != "computed":
|
|
210
|
+
parse = default_parser(property_schema)
|
|
211
|
+
default = questionary.text(
|
|
212
|
+
f"Default value of '{name}' (optional):", validate=optional(validator(parse))
|
|
213
|
+
).unsafe_ask()
|
|
214
|
+
|
|
215
|
+
if default.strip():
|
|
216
|
+
properties[name]["default"] = parse(default)
|
|
217
|
+
|
|
218
|
+
if questionary.confirm(f"Is '{name}' required?", default=True).unsafe_ask():
|
|
219
|
+
required.append(name)
|
|
220
|
+
|
|
221
|
+
questionary.print("") # separate each property's questions from the next
|
|
222
|
+
|
|
223
|
+
schema: Schema = {"type": "object", "properties": properties}
|
|
224
|
+
|
|
225
|
+
if required:
|
|
226
|
+
schema["required"] = required
|
|
227
|
+
|
|
228
|
+
if not questionary.confirm("Allow extra properties?", default=False).unsafe_ask():
|
|
229
|
+
schema["additionalProperties"] = False
|
|
230
|
+
|
|
231
|
+
return schema
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _to_options(text: str) -> dict[str, Any]:
|
|
235
|
+
"""Parse computer options, a JSON object: `{"sep": ", "}`."""
|
|
236
|
+
try:
|
|
237
|
+
options = json.loads(text)
|
|
238
|
+
except json.JSONDecodeError as e:
|
|
239
|
+
message = f"Invalid JSON: {e.msg}"
|
|
240
|
+
raise ValueError(message) from None
|
|
241
|
+
|
|
242
|
+
if not isinstance(options, dict):
|
|
243
|
+
message = "Options must be a JSON object"
|
|
244
|
+
raise ValueError(message) # noqa: TRY004 - prompt validators catch ValueError
|
|
245
|
+
|
|
246
|
+
return options
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _computer_choices() -> list[str | questionary.Separator]:
|
|
250
|
+
"""List the computers under a header per group, the ungrouped ones last under 'Other'."""
|
|
251
|
+
grouped = {name for names in groups.values() for name in names}
|
|
252
|
+
other = [name for name in computers.names if name not in grouped]
|
|
253
|
+
choices: list[str | questionary.Separator] = []
|
|
254
|
+
|
|
255
|
+
for group, names in {**groups, "Other": other}.items():
|
|
256
|
+
if names:
|
|
257
|
+
choices += [questionary.Separator(f"── {group} ──"), *names]
|
|
258
|
+
|
|
259
|
+
return choices
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
_PLACEHOLDERS: dict[Any, Any] = {str: "text", int: 1, float: 1.5, bool: True, dict: {}, list: []}
|
|
263
|
+
_JSON_TYPES = (str, int, float, bool, dict, list, type(None))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _options_example(fn: Callable[..., Any], sources: int) -> dict[str, Any] | None:
|
|
267
|
+
"""Return example options of `fn` once `sources` values fill its first parameters.
|
|
268
|
+
|
|
269
|
+
An option with a default shows it, a required one a placeholder of its type.
|
|
270
|
+
`None` when `fn` takes no options.
|
|
271
|
+
"""
|
|
272
|
+
example: dict[str, Any] = {}
|
|
273
|
+
takes_any = False
|
|
274
|
+
|
|
275
|
+
for param in inspect.signature(fn).parameters.values():
|
|
276
|
+
if param.kind is param.VAR_POSITIONAL:
|
|
277
|
+
sources = 0 # it takes the remaining sources
|
|
278
|
+
elif param.kind is param.VAR_KEYWORD:
|
|
279
|
+
takes_any = True
|
|
280
|
+
elif sources and param.kind is not param.KEYWORD_ONLY:
|
|
281
|
+
sources -= 1
|
|
282
|
+
elif param.default is not param.empty and isinstance(param.default, _JSON_TYPES):
|
|
283
|
+
example[param.name] = param.default
|
|
284
|
+
else:
|
|
285
|
+
kind = get_origin(param.annotation) or param.annotation # `dict[str, Any]` -> `dict`
|
|
286
|
+
example[param.name] = _PLACEHOLDERS.get(kind, "value")
|
|
287
|
+
|
|
288
|
+
return example if example or takes_any else None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _describe(name: str, fn: Callable[..., Any]) -> None:
|
|
292
|
+
"""Print the computer signature and summary, so the user knows its sources and options."""
|
|
293
|
+
summary = (inspect.getdoc(fn) or "").split("\n\n")[0]
|
|
294
|
+
questionary.print(f"{name}{inspect.signature(fn)}", style="bold")
|
|
295
|
+
|
|
296
|
+
if summary:
|
|
297
|
+
questionary.print(f" {summary}", style="italic")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _generate_computed(names: list[str]) -> Schema:
|
|
301
|
+
"""Ask how a property is computed from the earlier `names`, and the type of its result.
|
|
302
|
+
|
|
303
|
+
Keyword sources (`{"alias": "path"}`), `$.` / `[*]` paths and `default` are written by hand.
|
|
304
|
+
"""
|
|
305
|
+
fn = questionary.select("Compute with:", choices=_computer_choices()).unsafe_ask()
|
|
306
|
+
_describe(fn, computers[fn])
|
|
307
|
+
sources = questionary.checkbox("Computed from (in order):", choices=names).unsafe_ask()
|
|
308
|
+
example = _options_example(computers[fn], len(sources))
|
|
309
|
+
spec: Schema = {"fn": fn}
|
|
310
|
+
|
|
311
|
+
if sources:
|
|
312
|
+
spec["from"] = sources
|
|
313
|
+
|
|
314
|
+
if example is not None:
|
|
315
|
+
hint = json.dumps(example, ensure_ascii=False)
|
|
316
|
+
options = questionary.text(
|
|
317
|
+
f"Options as JSON (optional), like {hint}:", validate=optional(validator(_to_options))
|
|
318
|
+
).unsafe_ask()
|
|
319
|
+
|
|
320
|
+
if options.strip():
|
|
321
|
+
spec["options"] = _to_options(options)
|
|
322
|
+
|
|
323
|
+
return {**generators[_ask_type("Result type:")](), KEYWORD: spec}
|
schemify/models.py
ADDED
schemify/py.typed
ADDED
|
File without changes
|