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/registry.py ADDED
@@ -0,0 +1,69 @@
1
+ """A registry of functions picked by name, used by the schema generators and data fillers."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+
7
+ class NotSupportedError(Exception):
8
+ """Raised when no function is registered under a name."""
9
+
10
+
11
+ class Registry[F: Callable[..., Any]]:
12
+ """Maps names (case-insensitive) to functions.
13
+
14
+ Example:
15
+ ```python
16
+ generators = Registry[Callable[[], Schema]]("Type")
17
+
18
+
19
+ @generators.register("string")
20
+ def generate_string() -> Schema: ...
21
+
22
+
23
+ schema = generators["string"]()
24
+ ```
25
+
26
+ """
27
+
28
+ def __init__(self, kind: str) -> None:
29
+ """Create an empty registry, `kind` names its entries in error messages."""
30
+ self._kind = kind
31
+ self._fns: dict[str, F] = {}
32
+
33
+ def __contains__(self, name: object) -> bool:
34
+ """Whether a function is registered under `name`."""
35
+ return isinstance(name, str) and name.lower() in self._fns
36
+
37
+ def __getitem__(self, name: str) -> F:
38
+ """Return the function registered under `name`.
39
+
40
+ Raises:
41
+ NotSupportedError: Nothing is registered under `name`.
42
+
43
+ """
44
+ if name not in self:
45
+ supported = ", ".join(self.names)
46
+ message = f"{self._kind} '{name}' is not supported, supported: {supported}"
47
+ raise NotSupportedError(message)
48
+
49
+ return self._fns[name.lower()]
50
+
51
+ @property
52
+ def names(self) -> list[str]:
53
+ """The registered names, sorted."""
54
+ return sorted(self._fns)
55
+
56
+ def register(self, *names: str) -> Callable[[F], F]:
57
+ """Register the decorated function under `names`."""
58
+
59
+ def decorator(fn: F) -> F:
60
+ for name in names:
61
+ self.add(fn, name)
62
+
63
+ return fn
64
+
65
+ return decorator
66
+
67
+ def add(self, fn: F, name: str) -> None:
68
+ """Register `fn` under `name`, replacing any existing one."""
69
+ self._fns[name.lower()] = fn
schemify/validators.py ADDED
@@ -0,0 +1,300 @@
1
+ """Validators and parsers for prompt answers and values, they never prompt.
2
+
3
+ Prompt validators follow the questionary `validate=` contract:
4
+ they return `True` when the answer is valid, else the error message.
5
+ """
6
+
7
+ import contextlib
8
+ import json
9
+ import math
10
+ import re
11
+ from collections.abc import Callable, Collection
12
+ from datetime import date, datetime
13
+ from typing import Any
14
+
15
+ from jsonschema import Draft202012Validator, FormatChecker, SchemaError
16
+
17
+ from .models import Schema
18
+
19
+ type Parser = Callable[[str], Any]
20
+ """Turns a prompt answer into a value, raises `ValueError` with a readable message."""
21
+
22
+ type PromptValidator = Callable[[str], bool | str]
23
+ """Returns `True` for a valid answer, else the error message."""
24
+
25
+ type Constraint = tuple[str, str, Parser, PromptValidator]
26
+ """A schema keyword, its question, the answer parser and the answer validator."""
27
+
28
+ # -----------------------
29
+ # parsers
30
+ # -----------------------
31
+
32
+
33
+ def to_int(text: str) -> int:
34
+ """Parse an integer: `"42"`."""
35
+ try:
36
+ return int(text.strip())
37
+ except ValueError:
38
+ message = f"'{text}' is not an integer"
39
+ raise ValueError(message) from None
40
+
41
+
42
+ def to_number(text: str) -> int | float:
43
+ """Parse a finite number, keeping integers as `int`: `"42"`, `"4.2"`."""
44
+ try:
45
+ return to_int(text)
46
+ except ValueError:
47
+ pass
48
+
49
+ try:
50
+ number = float(text.strip())
51
+ except ValueError:
52
+ message = f"'{text}' is not a number"
53
+ raise ValueError(message) from None
54
+
55
+ if not math.isfinite(number):
56
+ message = f"'{text}' is not a finite number"
57
+ raise ValueError(message)
58
+
59
+ return number
60
+
61
+
62
+ def to_non_negative_int(text: str) -> int:
63
+ """Parse an integer that is 0 or more, like a length."""
64
+ number = to_int(text)
65
+
66
+ if number < 0:
67
+ message = f"'{text}' must be 0 or more"
68
+ raise ValueError(message)
69
+
70
+ return number
71
+
72
+
73
+ def to_positive_number(text: str) -> int | float:
74
+ """Parse a number greater than 0, like `multipleOf`."""
75
+ number = to_number(text)
76
+
77
+ if number <= 0:
78
+ message = f"'{text}' must be greater than 0"
79
+ raise ValueError(message)
80
+
81
+ return number
82
+
83
+
84
+ def to_regex(text: str) -> str:
85
+ """Check `text` compiles as a regular expression and return it unchanged."""
86
+ try:
87
+ re.compile(text)
88
+ except re.error as e:
89
+ message = f"Invalid regular expression: {e}"
90
+ raise ValueError(message) from None
91
+
92
+ return text
93
+
94
+
95
+ def to_choices(text: str) -> list[str]:
96
+ """Parse comma separated choices: `"red, green"` -> `["red", "green"]`."""
97
+ choices = [choice.strip() for choice in text.split(",") if choice.strip()]
98
+
99
+ if not choices:
100
+ message = "Enter at least one choice"
101
+ raise ValueError(message)
102
+
103
+ if len(set(choices)) != len(choices):
104
+ message = "Choices must be unique"
105
+ raise ValueError(message)
106
+
107
+ return choices
108
+
109
+
110
+ def to_date(text: str) -> str:
111
+ """Parse an ISO date, returned as an ISO string: `"2026-11-04"`."""
112
+ return date.fromisoformat(text.strip()).isoformat()
113
+
114
+
115
+ def to_datetime(text: str) -> str:
116
+ """Parse an ISO datetime, returned as an RFC 3339 string.
117
+
118
+ A datetime without offset is taken as local time, RFC 3339 requires the offset.
119
+ """
120
+ value = datetime.fromisoformat(text.strip())
121
+
122
+ return (value if value.tzinfo else value.astimezone()).isoformat()
123
+
124
+
125
+ def default_parser(schema: Schema) -> Parser:
126
+ """Build a parser for a default value of `schema`.
127
+
128
+ The answer is read as JSON (`42`, `true`, `["a"]`), else as text, the first matching `schema`
129
+ is kept: `42` is a number for an integer and the text `"42"` for a string.
130
+ """
131
+
132
+ def parse(text: str) -> Any:
133
+ candidates: list[Any] = [text.strip()]
134
+
135
+ with contextlib.suppress(json.JSONDecodeError):
136
+ candidates.insert(0, json.loads(text))
137
+
138
+ errors = [check_value(schema, candidate) for candidate in candidates]
139
+
140
+ for candidate, error in zip(candidates, errors, strict=True):
141
+ if error is None:
142
+ return candidate
143
+
144
+ raise ValueError(errors[0])
145
+
146
+ return parse
147
+
148
+
149
+ # -----------------------
150
+ # prompt validators
151
+ # -----------------------
152
+
153
+
154
+ def validator(parse: Parser) -> PromptValidator:
155
+ """Build a prompt validator accepting the answers `parse` accepts."""
156
+
157
+ def validate(text: str) -> bool | str:
158
+ try:
159
+ parse(text)
160
+ except ValueError as e:
161
+ return str(e)
162
+
163
+ return True
164
+
165
+ return validate
166
+
167
+
168
+ def optional(validate: PromptValidator) -> PromptValidator:
169
+ """Accept an empty answer (to skip), else run `validate`."""
170
+ return lambda text: not text.strip() or validate(text)
171
+
172
+
173
+ def is_choice(existing: Collection[str]) -> PromptValidator:
174
+ """Accept a new enum choice, or an empty answer to finish once one choice exists."""
175
+
176
+ def validate(text: str) -> bool | str:
177
+ choice = text.strip()
178
+
179
+ if not choice:
180
+ return bool(existing) or "Add at least one choice"
181
+
182
+ return choice not in existing or f"Choice '{choice}' already exists"
183
+
184
+ return validate
185
+
186
+
187
+ def is_property_name(existing: Collection[str]) -> PromptValidator:
188
+ """Accept a new property name, or an empty answer to finish once one property exists."""
189
+
190
+ def validate(text: str) -> bool | str:
191
+ name = text.strip()
192
+
193
+ if not name:
194
+ return bool(existing) or "Add at least one property"
195
+
196
+ return name not in existing or f"Property '{name}' already exists"
197
+
198
+ return validate
199
+
200
+
201
+ # -----------------------
202
+ # constraints
203
+ # -----------------------
204
+
205
+ STRING_CONSTRAINTS: list[Constraint] = [
206
+ ("minLength", "Minimum length", to_non_negative_int, validator(to_non_negative_int)),
207
+ ("maxLength", "Maximum length", to_non_negative_int, validator(to_non_negative_int)),
208
+ ("pattern", "Regular expression pattern", to_regex, validator(to_regex)),
209
+ (
210
+ "not",
211
+ "Blocked values, comma separated",
212
+ lambda text: {"enum": to_choices(text)},
213
+ validator(to_choices),
214
+ ),
215
+ ]
216
+ """The validators asked for a string, mapped to JSON Schema keywords."""
217
+
218
+ NUMBER_CONSTRAINTS: list[Constraint] = [
219
+ ("exclusiveMinimum", "Greater than", to_number, validator(to_number)),
220
+ ("minimum", "Greater than or equal to", to_number, validator(to_number)),
221
+ ("exclusiveMaximum", "Less than", to_number, validator(to_number)),
222
+ ("maximum", "Less than or equal to", to_number, validator(to_number)),
223
+ ("multipleOf", "Multiple of", to_positive_number, validator(to_positive_number)),
224
+ ]
225
+ """The validators asked for an integer or a number, mapped to JSON Schema keywords."""
226
+
227
+ ARRAY_CONSTRAINTS: list[Constraint] = [
228
+ ("minItems", "Minimum items", to_non_negative_int, validator(to_non_negative_int)),
229
+ ("maxItems", "Maximum items", to_non_negative_int, validator(to_non_negative_int)),
230
+ ]
231
+ """The validators asked for an array, mapped to JSON Schema keywords."""
232
+
233
+ _BOUNDS = [
234
+ # (lower keyword, upper keyword, can they be equal)
235
+ ("minLength", "maxLength", True),
236
+ ("minItems", "maxItems", True),
237
+ ("minimum", "maximum", True),
238
+ ("minimum", "exclusiveMaximum", False),
239
+ ("exclusiveMinimum", "maximum", False),
240
+ ("exclusiveMinimum", "exclusiveMaximum", False),
241
+ ]
242
+
243
+
244
+ def check_bounds(schema: Schema) -> str | None:
245
+ """Return why no value can satisfy the bounds of `schema`, or `None` when they can."""
246
+ for low, high, equal_ok in _BOUNDS:
247
+ if low not in schema or high not in schema:
248
+ continue
249
+
250
+ if schema[low] > schema[high] or (not equal_ok and schema[low] == schema[high]):
251
+ return f"{low} ({schema[low]}) must be less than {high} ({schema[high]})"
252
+
253
+ return None
254
+
255
+
256
+ def within_bounds(schema: Schema, constraint: Constraint) -> PromptValidator:
257
+ """Run the `constraint` validator, then reject an answer conflicting with `schema` bounds.
258
+
259
+ So the user stays on the conflicting answer instead of starting the constraints over.
260
+ """
261
+ keyword, _, parse, validate = constraint
262
+
263
+ def validate_bounds(text: str) -> bool | str:
264
+ result = validate(text)
265
+
266
+ if result is not True:
267
+ return result
268
+
269
+ return check_bounds({**schema, keyword: parse(text)}) or True
270
+
271
+ return validate_bounds
272
+
273
+
274
+ # -----------------------
275
+ # json schema
276
+ # -----------------------
277
+
278
+
279
+ def check_schema(schema: Any) -> str | None:
280
+ """Return why `schema` is not a valid JSON Schema object, or `None` when it is."""
281
+ if not isinstance(schema, dict):
282
+ return "A schema must be a JSON object"
283
+
284
+ try:
285
+ Draft202012Validator.check_schema(schema)
286
+ except SchemaError as e:
287
+ return e.message
288
+
289
+ return None
290
+
291
+
292
+ def check_value(schema: Schema, value: Any) -> str | None:
293
+ """Return why `value` does not match `schema`, or `None` when it does.
294
+
295
+ Formats are checked when jsonschema supports them without extras (date, email, uuid, ...).
296
+ """
297
+ validator_ = Draft202012Validator(schema, format_checker=FormatChecker())
298
+ error = next(validator_.iter_errors(value), None)
299
+
300
+ return None if error is None else error.message
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.3
2
+ Name: schemify-cli
3
+ Version: 0.1.0
4
+ Summary: Build JSON Schemas interactively, and fill JSON data from them, from the command line.
5
+ Author: aaltatan
6
+ Author-email: aaltatan <a.altatan@gmail.com>
7
+ Requires-Dist: jsonschema>=4.26.0
8
+ Requires-Dist: num2words>=0.5.14
9
+ Requires-Dist: questionary>=2.1.1
10
+ Requires-Dist: rich>=15.0.0
11
+ Requires-Dist: typer>=0.27.3
12
+ Requires-Dist: typer-di>=0.1.5
13
+ Requires-Dist: typer-swissknife>=0.1.1
14
+ Requires-Python: >=3.12
15
+ Description-Content-Type: text/markdown
16
+
17
+ # schemify-cli
18
+
19
+ Build JSON Schemas interactively, and fill JSON data from them, from the command line.
20
+
21
+ Source: [github.com/aaltatan/schemify-cli](https://github.com/aaltatan/schemify-cli)
22
+
23
+ A web version runs at [schemify.surge.sh](https://schemify.surge.sh)
24
+ ([source](https://github.com/aaltatan/schemify)): the same questions as fields, with the
25
+ schema updating as you type.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ uv tool install schemify
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ schemify generate -t Person -o person.schema.json # ask for the properties, write the schema
37
+ schemify fill person.schema.json -o person.json # ask for one object matching it
38
+ schemify list person.schema.json -f python # ask for a list of them
39
+ ```
40
+
41
+ | Command | Alias | Does |
42
+ |---|---|---|
43
+ | `generate` | `g` | Asks for the properties of an object and builds its JSON Schema (Draft 2020-12) |
44
+ | `fill` | `f` | Asks for data matching a schema file, checked against it |
45
+ | `list` | `l` | Asks for a list of items, each matching the schema |
46
+
47
+ - `-o, --output` saves to a file (asks before overwriting); without it the result is printed.
48
+ - `-f, --format` picks the output of `fill` and `list`: `json`, `python` or `javascript`.
49
+
50
+ Types: `string`, `integer`, `number`, `boolean`, `date`, `datetime`, `enum`, `array`, `object`,
51
+ each with its constraints (length, pattern, format, range, items, ...) and an optional default.
52
+
53
+ ## Computed properties
54
+
55
+ A property with `x-compute` is not asked in `fill`, it is computed from the other values:
56
+
57
+ ```json
58
+ "full_name": {"type": "string", "x-compute": {"fn": "join", "from": ["first", "middle?", "last"]}}
59
+ ```
60
+
61
+ - `fn`: the computer, `from`: source paths (a list, or an `{alias: path}` object for `template`
62
+ and `expr`), `options`: extra arguments, `default`: the value when a source is missing.
63
+ - Paths are relative to the owning object (`address.city`, `items[0]`), or to the root
64
+ (`$.company`). `items[*].price` gives every price; a trailing `?` makes a source optional.
65
+
66
+ Built-in computers:
67
+
68
+ - **Text**: join, concat, template, upper, lower, title, capitalize, trim, replace, slice,
69
+ initials, slug, length, pad, mask
70
+ - **Numbers**: sum, subtract, multiply, divide, average, min, max, abs, round, percent,
71
+ percent_of, expr
72
+ - **Dates**: today, now, add_days, add_months, add_years, days_between, age, year, month, day,
73
+ weekday, format_date, start_of_month, end_of_month
74
+ - **Number to words**: words
75
+ - **Logic, lookup and misc**: coalesce, if_else, equals, lookup, constant, count, count_if,
76
+ unique, uuid
77
+
78
+ ## Reusing it in a typer app
79
+
80
+ `schemify.cli.dependencies.schema` exposes typer-di dependencies that load a schema file and ask
81
+ for its data: `FilledDataArgumentDI` and `FilledListArgumentDI` take the path as an argument,
82
+ `FilledDataOptionDI` and `FilledListOptionDI` as `--js`.
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ uv sync
88
+ uv run pytest
89
+ uv run ruff check . && uv run mypy src
90
+ ```
91
+
92
+ After changing `generators.py`, `validators.py` or `computers.py`, run `pnpm sync && pnpm check`
93
+ in [schemify-web](https://github.com/aaltatan/schemify-web) so it stays in step.
@@ -0,0 +1,18 @@
1
+ schemify/__init__.py,sha256=bVb4Qylc0TYeBR3jhRz5rp0EZe5gCwbRfYToXAMpB_M,564
2
+ schemify/cli/__init__.py,sha256=aayFp21WiVMIDHa1Z84eqw15ZLtteL24vuQR6a1W9Mg,43
3
+ schemify/cli/dependencies/__init__.py,sha256=Nq8g_PGFq5RBHU1ELk7p1PKVVe9Xjj5AVL3dKAJqZAs,134
4
+ schemify/cli/dependencies/console.py,sha256=Ai0VzaU7cL6kK8YjqaPW2YWFeO2FbWazBeFj4vpuIGE,265
5
+ schemify/cli/dependencies/schema.py,sha256=RoNJJLYTR3VoM99FsaENYp09OgRERQGtAq5XgovKQAs,3700
6
+ schemify/cli/main.py,sha256=zN-LH-ci0zqRITOrJtBVtpQoF3rt_m9e0yXaZ_PL-Ns,3766
7
+ schemify/computers.py,sha256=cRbk4DVeXpMB3zWQXbqpSivm-7HieXGLBLoj_Ija_hA,20732
8
+ schemify/fillers.py,sha256=avd50hSzFA86M2a30m3HZm31fpxxV43_kxPA3T1zDnU,8306
9
+ schemify/formatters.py,sha256=RF7NlpOrxTr5TS4cx0aq9c4kB1GNmNLwV6rhXD4Gdiw,1903
10
+ schemify/generators.py,sha256=dnP8f0mpP-6Bb0FBghFI_81q8In6uywPbd8nmXrfGFk,10421
11
+ schemify/models.py,sha256=V9Ob9vTF4U5AsaFHOtl3HOUDpBowBUQ2h-WZo2irO-g,160
12
+ schemify/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ schemify/registry.py,sha256=gLneezkhx6OjnybD35R0DL_KWGfXMFnqK-tuo1SlmCo,1928
14
+ schemify/validators.py,sha256=2CVLgwwl_Ofm1wSaONA7UcWiJJ8LU-5yJy5jEHCw-O0,9079
15
+ schemify_cli-0.1.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
16
+ schemify_cli-0.1.0.dist-info/entry_points.txt,sha256=4uAUa_6hC_cz6oMOXTN-djmSHdPh9Uc9-rq1qd9j_ic,53
17
+ schemify_cli-0.1.0.dist-info/METADATA,sha256=fUOJhRqrfKlJ-s7shE6V_LUdok3Ks8cBVpywJq45DGQ,3565
18
+ schemify_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ schemify = schemify.cli.main:main
3
+