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/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
@@ -0,0 +1,6 @@
1
+ """Types shared by generators, fillers and validators."""
2
+
3
+ from typing import Any
4
+
5
+ type Schema = dict[str, Any]
6
+ """A JSON Schema, or one of its sub-schemas."""
schemify/py.typed ADDED
File without changes