schematico 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.
schematico/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from importlib.metadata import PackageNotFoundError, version as _version
2
+
3
+ from schematico.generator import run_generation
4
+ from schematico.discovery import run_discovery
5
+ from schematico.models import build_batch_model, model_from_dict, model_from_json
6
+ from schematico.providers import (
7
+ DEFAULT_MODEL,
8
+ SchematicoModel,
9
+ get_llm_model,
10
+ )
11
+
12
+ try:
13
+ __version__ = _version("schematico")
14
+ except PackageNotFoundError: # running from a source tree without an install
15
+ __version__ = "0.0.0+unknown"
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "DEFAULT_MODEL",
20
+ "SchematicoModel",
21
+ "build_batch_model",
22
+ "get_llm_model",
23
+ "model_from_dict",
24
+ "model_from_json",
25
+ "run_generation",
26
+ "run_discovery",
27
+ ]
File without changes
schematico/cli/main.py ADDED
@@ -0,0 +1,375 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from dotenv import load_dotenv
7
+
8
+ from schematico import __version__
9
+ from schematico.cli import runner
10
+ from schematico.cli.projects import (
11
+ Mode,
12
+ ProjectNotFoundError,
13
+ config_path,
14
+ delete_project,
15
+ find_projects_by_name,
16
+ get_default,
17
+ list_projects,
18
+ parse_config_filename,
19
+ resolve_active_project,
20
+ save_project,
21
+ set_default,
22
+ )
23
+ from schematico.cli.wizard import run_wizard
24
+ from schematico.logging import get_logger
25
+
26
+ load_dotenv()
27
+ logger = get_logger("cli.main")
28
+
29
+ app = typer.Typer(
30
+ name="schematico",
31
+ help=(
32
+ "Generate or discover synthetic data from a JSON schema.\n\n"
33
+ "Run `schematico help` to see every command and flag in one place."
34
+ ),
35
+ no_args_is_help=True,
36
+ add_completion=False,
37
+ )
38
+
39
+
40
+ def _version_callback(value: bool) -> None:
41
+ if value:
42
+ typer.echo(f"schematico {__version__}")
43
+ raise typer.Exit()
44
+
45
+
46
+ @app.callback()
47
+ def _root(
48
+ version: bool = typer.Option(
49
+ False,
50
+ "--version",
51
+ "-V",
52
+ help="Show the version and exit.",
53
+ callback=_version_callback,
54
+ is_eager=True,
55
+ ),
56
+ ) -> None:
57
+ """schematico — generate or discover data from a JSON schema."""
58
+
59
+
60
+ def _make_mode_app(mode: Mode) -> typer.Typer:
61
+ sub = typer.Typer(
62
+ name=mode,
63
+ help=f"{mode.capitalize()} records using a project config.",
64
+ invoke_without_command=True,
65
+ no_args_is_help=False,
66
+ )
67
+
68
+ @sub.callback()
69
+ def _default(
70
+ ctx: typer.Context,
71
+ config: str | None = typer.Option(
72
+ None,
73
+ "--config",
74
+ "-c",
75
+ help="Project config name to use (overrides default).",
76
+ ),
77
+ output_path: str | None = typer.Option(
78
+ None, "--output", "-o", help="Override output path."
79
+ ),
80
+ count: int | None = typer.Option(
81
+ None, "--count", "-n", help="Override record count."
82
+ ),
83
+ model: str | None = typer.Option(
84
+ None, "--model", "-m", help="Override AI model."
85
+ ),
86
+ ) -> None:
87
+ if ctx.invoked_subcommand is not None:
88
+ ctx.obj = {"config": config}
89
+ return
90
+ try:
91
+ cfg = resolve_active_project(mode, name_override=config)
92
+ except ProjectNotFoundError as e:
93
+ typer.echo(f"schematico: error: {e}", err=True)
94
+ raise typer.Exit(1)
95
+ runner.run(
96
+ cfg,
97
+ output_override=output_path,
98
+ count_override=count,
99
+ model_override=model,
100
+ )
101
+
102
+ @sub.command("list", help=f"List {mode} configs.")
103
+ def _list() -> None:
104
+ default_name = get_default(mode)
105
+ paths = list_projects(mode)
106
+ if not paths:
107
+ typer.echo(f"No {mode} configs found in ./.schematico/.")
108
+ typer.echo("Run `schematico new` to create one.")
109
+ return
110
+ for p in paths:
111
+ parsed = parse_config_filename(p.name)
112
+ name = parsed[0] if parsed else p.name
113
+ marker = "*" if name == default_name else " "
114
+ typer.echo(f"{marker} {name} ({p})")
115
+
116
+ @sub.command(
117
+ "use",
118
+ help=f"Select default {mode} config (`use <name>`) or set model (`use model <id>`).",
119
+ )
120
+ def _use(args: list[str] = typer.Argument(None)) -> None:
121
+ if not args:
122
+ typer.echo(
123
+ f"Usage: schematico {mode} use <name> | use model <id>", err=True
124
+ )
125
+ raise typer.Exit(2)
126
+ if args[0] == "model":
127
+ if len(args) != 2:
128
+ typer.echo(f"Usage: schematico {mode} use model <id>", err=True)
129
+ raise typer.Exit(2)
130
+ try:
131
+ cfg = resolve_active_project(mode)
132
+ except ProjectNotFoundError as e:
133
+ typer.echo(f"schematico: error: {e}", err=True)
134
+ raise typer.Exit(1)
135
+ cfg.model = args[1]
136
+ save_project(cfg)
137
+ typer.echo(f"Set model={args[1]} in '{cfg.name}.{mode}.toml'.")
138
+ return
139
+ if len(args) != 1:
140
+ typer.echo(f"Usage: schematico {mode} use <name>", err=True)
141
+ raise typer.Exit(2)
142
+ name = args[0]
143
+ if not config_path(name, mode).exists():
144
+ typer.echo(
145
+ f"schematico: error: config '{name}.{mode}.toml' not found.", err=True
146
+ )
147
+ raise typer.Exit(1)
148
+ set_default(mode, name)
149
+ typer.echo(f"Default {mode} config set to '{name}'.")
150
+
151
+ @sub.command("delete", help=f"Delete a {mode} config.")
152
+ def _delete(
153
+ name: str = typer.Argument(...),
154
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
155
+ ) -> None:
156
+ p = config_path(name, mode)
157
+ if not p.exists():
158
+ typer.echo(
159
+ f"schematico: error: config '{name}.{mode}.toml' not found.",
160
+ err=True,
161
+ )
162
+ raise typer.Exit(1)
163
+ if not yes and not typer.confirm(f"Delete {p}?", default=False):
164
+ typer.echo("Aborted.")
165
+ raise typer.Exit(0)
166
+ was_default = delete_project(name, mode)
167
+ msg = f"Deleted '{name}.{mode}.toml'."
168
+ if was_default:
169
+ msg += f" Cleared default for `schematico {mode}`."
170
+ typer.echo(msg)
171
+
172
+ @sub.command("output")
173
+ def _set_output(path: str = typer.Argument(...)) -> None:
174
+ try:
175
+ cfg = resolve_active_project(mode)
176
+ except ProjectNotFoundError as e:
177
+ typer.echo(f"schematico: error: {e}", err=True)
178
+ raise typer.Exit(1)
179
+ cfg.output_path = path
180
+ save_project(cfg)
181
+ typer.echo(f"Set output_path={path} in '{cfg.name}.{mode}.toml'.")
182
+
183
+ schema_app = typer.Typer(
184
+ help="Manage the schema for the active config (`import` or `path`).",
185
+ no_args_is_help=True,
186
+ )
187
+
188
+ @schema_app.command("import")
189
+ def _schema_import(
190
+ file: str = typer.Argument(..., help="Path to a JSON schema file.")
191
+ ) -> None:
192
+ """Embed a JSON schema file into the active config's [schema] table."""
193
+ import json as _json
194
+
195
+ from schematico.models import model_from_dict
196
+
197
+ try:
198
+ cfg = resolve_active_project(mode)
199
+ except ProjectNotFoundError as e:
200
+ typer.echo(f"schematico: error: {e}", err=True)
201
+ raise typer.Exit(1)
202
+ p = Path(file)
203
+ if not p.exists():
204
+ typer.echo(f"schematico: error: file '{file}' not found.", err=True)
205
+ raise typer.Exit(1)
206
+ try:
207
+ raw = _json.loads(p.read_text(encoding="utf-8"))
208
+ except _json.JSONDecodeError as e:
209
+ typer.echo(f"schematico: error: invalid JSON in '{file}': {e}", err=True)
210
+ raise typer.Exit(1)
211
+ try:
212
+ model_from_dict(raw)
213
+ except ValueError as e:
214
+ typer.echo(f"schematico: error: {e}", err=True)
215
+ raise typer.Exit(1)
216
+ cfg.record_schema = raw
217
+ cfg.schema_path = ""
218
+ save_project(cfg)
219
+ typer.echo(
220
+ f"Imported schema from '{file}' into '{cfg.name}.{mode}.toml' "
221
+ f"({len(raw.get('fields', []))} fields)."
222
+ )
223
+
224
+ @schema_app.command("path")
225
+ def _schema_path(
226
+ file: str = typer.Argument(..., help="Path to a JSON schema file to reference.")
227
+ ) -> None:
228
+ """Reference an external JSON schema file (not embedded)."""
229
+ from schematico.models import model_from_json
230
+
231
+ try:
232
+ cfg = resolve_active_project(mode)
233
+ except ProjectNotFoundError as e:
234
+ typer.echo(f"schematico: error: {e}", err=True)
235
+ raise typer.Exit(1)
236
+ try:
237
+ model_from_json(file)
238
+ except (FileNotFoundError, ValueError) as e:
239
+ typer.echo(f"schematico: error: {e}", err=True)
240
+ raise typer.Exit(1)
241
+ cfg.schema_path = file
242
+ cfg.record_schema = {}
243
+ save_project(cfg)
244
+ typer.echo(f"Set schema_path={file} in '{cfg.name}.{mode}.toml'.")
245
+
246
+ sub.add_typer(schema_app, name="schema")
247
+
248
+ return sub
249
+
250
+
251
+ app.add_typer(_make_mode_app("generate"), name="generate")
252
+ app.add_typer(_make_mode_app("discover"), name="discover")
253
+
254
+
255
+ @app.command("new")
256
+ def new_cmd() -> None:
257
+ """Interactively create a new project config."""
258
+ run_wizard()
259
+
260
+
261
+ @app.command("delete", help="Delete a project config by name (auto-detects mode).")
262
+ def delete_cmd(
263
+ name: str = typer.Argument(..., help="Project name (without .toml suffix)."),
264
+ mode: str | None = typer.Option(
265
+ None,
266
+ "--mode",
267
+ "-m",
268
+ help="Required when the name exists for both `generate` and `discover`.",
269
+ ),
270
+ all_modes: bool = typer.Option(
271
+ False, "--all", "-a", help="Delete the project for every mode it exists in."
272
+ ),
273
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
274
+ ) -> None:
275
+ matches = find_projects_by_name(name)
276
+ if not matches:
277
+ typer.echo(f"schematico: error: no project named '{name}'.", err=True)
278
+ raise typer.Exit(1)
279
+
280
+ if mode is not None:
281
+ if mode not in ("generate", "discover"):
282
+ typer.echo(
283
+ f"schematico: error: --mode must be 'generate' or 'discover'.",
284
+ err=True,
285
+ )
286
+ raise typer.Exit(2)
287
+ if mode not in matches:
288
+ typer.echo(
289
+ f"schematico: error: no {mode} project named '{name}'.", err=True
290
+ )
291
+ raise typer.Exit(1)
292
+ targets: list[Mode] = [mode] # type: ignore[list-item]
293
+ elif all_modes:
294
+ targets = matches
295
+ elif len(matches) == 1:
296
+ targets = matches
297
+ else:
298
+ typer.echo(
299
+ f"schematico: error: '{name}' exists for both modes "
300
+ f"({', '.join(matches)}). Pass --mode <generate|discover> or --all.",
301
+ err=True,
302
+ )
303
+ raise typer.Exit(1)
304
+
305
+ paths = [config_path(name, m) for m in targets]
306
+ if not yes:
307
+ typer.echo("About to delete:")
308
+ for p in paths:
309
+ typer.echo(f" - {p}")
310
+ if not typer.confirm("Proceed?", default=False):
311
+ typer.echo("Aborted.")
312
+ raise typer.Exit(0)
313
+
314
+ for m in targets:
315
+ was_default = delete_project(name, m)
316
+ msg = f"Deleted '{name}.{m}.toml'."
317
+ if was_default:
318
+ msg += f" Cleared default for `schematico {m}`."
319
+ typer.echo(msg)
320
+
321
+
322
+ @app.command("list")
323
+ def list_cmd() -> None:
324
+ """List all schematico project configs (both discover and generate)."""
325
+ rows: list[tuple[str, str, str, str]] = [] # (marker, mode, name, path)
326
+ for mode in ("discover", "generate"):
327
+ default_name = get_default(mode) # type: ignore[arg-type]
328
+ for p in list_projects(mode): # type: ignore[arg-type]
329
+ parsed = parse_config_filename(p.name)
330
+ name = parsed[0] if parsed else p.name
331
+ marker = "*" if name == default_name else " "
332
+ rows.append((marker, mode, name, str(p)))
333
+
334
+ if not rows:
335
+ typer.echo("No project configs found in ./.schematico/.")
336
+ typer.echo("Run `schematico new` to create one.")
337
+ return
338
+
339
+ mode_w = max(len(r[1]) for r in rows)
340
+ name_w = max(len(r[2]) for r in rows)
341
+ for marker, mode, name, path in rows:
342
+ typer.echo(f"{marker} [{mode:<{mode_w}}] {name:<{name_w}} ({path})")
343
+
344
+
345
+ def _walk_help(cmd, ctx, path: str) -> None:
346
+ import click
347
+
348
+ typer.echo("")
349
+ typer.echo("=" * 72)
350
+ typer.echo(path)
351
+ typer.echo("=" * 72)
352
+ typer.echo(cmd.get_help(ctx))
353
+ if isinstance(cmd, click.Group):
354
+ for name, sub in cmd.commands.items():
355
+ sub_ctx = click.Context(sub, info_name=name, parent=ctx)
356
+ _walk_help(sub, sub_ctx, f"{path} {name}")
357
+
358
+
359
+ @app.command("help")
360
+ def help_cmd() -> None:
361
+ """Show help for every command and subcommand (full tree)."""
362
+ import click
363
+ from typer.main import get_command
364
+
365
+ cli = get_command(app)
366
+ ctx = click.Context(cli, info_name="schematico")
367
+ _walk_help(cli, ctx, "schematico")
368
+
369
+
370
+ def main() -> None:
371
+ app()
372
+
373
+
374
+ if __name__ == "__main__":
375
+ main()
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+
6
+ class ProgressReporter:
7
+ def __init__(self, table: str) -> None:
8
+ self._table = table
9
+ self._last_len = 0
10
+ print(
11
+ f"Generating data for table: '{table}'...",
12
+ file=sys.stderr,
13
+ flush=True,
14
+ )
15
+
16
+ def update(self, found: int, total: int, event: str) -> None:
17
+ if event == "duplicate":
18
+ msg = f"\r Found duplicate — retrying... "
19
+ else:
20
+ msg = f"\r {found} of {total} entries found "
21
+
22
+ print(msg, end="", file=sys.stderr, flush=True)
23
+ self._last_len = len(msg)
24
+
25
+ def done(self, count: int) -> None:
26
+ print(
27
+ f"\r Found data — {count} records generated. ",
28
+ file=sys.stderr,
29
+ flush=True,
30
+ )
31
+ print("", file=sys.stderr)
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import tomllib
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ import tomli_w
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ Mode = Literal["generate", "discover"]
12
+ MODES: tuple[Mode, ...] = ("generate", "discover")
13
+
14
+ CONFIG_DIRNAME = ".schematico"
15
+ STATE_FILENAME = "state.toml"
16
+ DEFAULT_ENV_KEY = "PYDANTIC_AI_GATEWAY_API_KEY"
17
+ DEFAULT_COUNT = 25
18
+
19
+
20
+ class ProjectConfig(BaseModel):
21
+ model_config = ConfigDict(populate_by_name=True, protected_namespaces=())
22
+
23
+ name: str
24
+ mode: Mode
25
+ model: str = ""
26
+ env_key: str = DEFAULT_ENV_KEY
27
+ base_url: str = ""
28
+ output_path: str = f"./{CONFIG_DIRNAME}/output"
29
+ count: int = DEFAULT_COUNT
30
+ logfire_token: str = ""
31
+ schema_path: str = ""
32
+ record_schema: dict[str, Any] = Field(default_factory=dict, alias="schema")
33
+
34
+
35
+ def config_dir(cwd: Path | None = None) -> Path:
36
+ base = cwd if cwd is not None else Path.cwd()
37
+ return base / CONFIG_DIRNAME
38
+
39
+
40
+ def ensure_config_dir(cwd: Path | None = None) -> Path:
41
+ d = config_dir(cwd)
42
+ d.mkdir(parents=True, exist_ok=True)
43
+ return d
44
+
45
+
46
+ def config_filename(name: str, mode: Mode) -> str:
47
+ return f"{name}.{mode}.toml"
48
+
49
+
50
+ def config_path(name: str, mode: Mode, cwd: Path | None = None) -> Path:
51
+ return config_dir(cwd) / config_filename(name, mode)
52
+
53
+
54
+ def state_path(cwd: Path | None = None) -> Path:
55
+ return config_dir(cwd) / STATE_FILENAME
56
+
57
+
58
+ def load_state(cwd: Path | None = None) -> dict[str, str]:
59
+ p = state_path(cwd)
60
+ if not p.exists():
61
+ return {}
62
+ with p.open("rb") as f:
63
+ return tomllib.load(f)
64
+
65
+
66
+ def save_state(state: dict[str, str], cwd: Path | None = None) -> None:
67
+ ensure_config_dir(cwd)
68
+ p = state_path(cwd)
69
+ with p.open("wb") as f:
70
+ tomli_w.dump(state, f)
71
+
72
+
73
+ def get_default(mode: Mode, cwd: Path | None = None) -> str | None:
74
+ return load_state(cwd).get(f"{mode}_default")
75
+
76
+
77
+ def set_default(mode: Mode, name: str, cwd: Path | None = None) -> None:
78
+ state = load_state(cwd)
79
+ state[f"{mode}_default"] = name
80
+ save_state(state, cwd)
81
+
82
+
83
+ def clear_default(mode: Mode, cwd: Path | None = None) -> bool:
84
+ state = load_state(cwd)
85
+ key = f"{mode}_default"
86
+ if key not in state:
87
+ return False
88
+ del state[key]
89
+ save_state(state, cwd)
90
+ return True
91
+
92
+
93
+ def delete_project(name: str, mode: Mode, cwd: Path | None = None) -> bool:
94
+ """Delete the config file and clear the mode default if it pointed here.
95
+
96
+ Returns True if the deleted project was the current default for its mode.
97
+ Raises FileNotFoundError if no such config exists.
98
+ """
99
+ p = config_path(name, mode, cwd)
100
+ if not p.exists():
101
+ raise FileNotFoundError(p)
102
+ p.unlink()
103
+ if get_default(mode, cwd) == name:
104
+ clear_default(mode, cwd)
105
+ return True
106
+ return False
107
+
108
+
109
+ def find_projects_by_name(name: str, cwd: Path | None = None) -> list[Mode]:
110
+ """Return modes that have a project with the given name."""
111
+ return [m for m in MODES if config_path(name, m, cwd).exists()]
112
+
113
+
114
+ def load_project(path: Path) -> ProjectConfig:
115
+ with path.open("rb") as f:
116
+ raw = tomllib.load(f)
117
+ return ProjectConfig(**raw)
118
+
119
+
120
+ def save_project(config: ProjectConfig, cwd: Path | None = None) -> Path:
121
+ ensure_config_dir(cwd)
122
+ p = config_path(config.name, config.mode, cwd)
123
+ data = config.model_dump(by_alias=True)
124
+ with p.open("wb") as f:
125
+ tomli_w.dump(data, f)
126
+ return p
127
+
128
+
129
+ def list_projects(mode: Mode | None = None, cwd: Path | None = None) -> list[Path]:
130
+ d = config_dir(cwd)
131
+ if not d.exists():
132
+ return []
133
+ suffix = f".{mode}.toml" if mode else ".toml"
134
+ return sorted(
135
+ p for p in d.iterdir() if p.name.endswith(suffix) and p.name != STATE_FILENAME
136
+ )
137
+
138
+
139
+ def parse_config_filename(filename: str) -> tuple[str, Mode] | None:
140
+ m = re.fullmatch(r"(.+)\.(generate|discover)\.toml", filename)
141
+ if not m:
142
+ return None
143
+ return m.group(1), m.group(2) # type: ignore[return-value]
144
+
145
+
146
+ def next_available_name(base: str, mode: Mode, cwd: Path | None = None) -> str:
147
+ if not config_path(base, mode, cwd).exists():
148
+ return base
149
+ n = 2
150
+ while config_path(f"{base}_{n}", mode, cwd).exists():
151
+ n += 1
152
+ return f"{base}_{n}"
153
+
154
+
155
+ class ProjectNotFoundError(Exception):
156
+ pass
157
+
158
+
159
+ def resolve_active_project(
160
+ mode: Mode,
161
+ name_override: str | None = None,
162
+ cwd: Path | None = None,
163
+ ) -> ProjectConfig:
164
+ name = name_override or get_default(mode, cwd)
165
+ if not name:
166
+ raise ProjectNotFoundError(
167
+ f"No active {mode} project. Run `schematico new` to create one, "
168
+ f"or `schematico {mode} use <name>` to select an existing config."
169
+ )
170
+ p = config_path(name, mode, cwd)
171
+ if not p.exists():
172
+ raise ProjectNotFoundError(f"Config '{p.name}' not found in {config_dir(cwd)}.")
173
+ return load_project(p)