Skip to content

Commit 5fc8efc

Browse files
committed
release fixes
1 parent 5c8eafd commit 5fc8efc

8 files changed

Lines changed: 227 additions & 18 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ pydanticforge generate [OPTIONS]
257257

258258
| Option | Description |
259259
|--------|-------------|
260-
| `--input` | JSON file or directory (repeat for multiple). Reads all `.json` files (recursive if directory). |
260+
| `--input` | JSON file or directory (repeat for multiple). Directory scans include `.json`, `.ndjson`, and `.jsonl` files (recursive). |
261261
| `--from-state` | Use this state file instead of inferring from input. |
262262
| `--from-json-schema` | Use this JSON Schema file as input. |
263263
| `--output` | Output path for generated `models.py`. If omitted, prints to stdout. |
@@ -279,6 +279,7 @@ From a single file or directory:
279279

280280
```bash
281281
pydanticforge generate --input ./samples/data.json --output models.py
282+
pydanticforge generate --input ./samples/events.ndjson --output models.py
282283
pydanticforge generate --input ./samples/ --output models.py
283284
```
284285

@@ -298,7 +299,7 @@ pydanticforge generate --from-json-schema schema.json --output models.py
298299

299300
### `monitor` — Scan directory for schema drift
300301

301-
Scans a directory for `.json` files, infers type from each file’s content, and compares to the expected schema from the state file. Reports drift (type mismatches, missing required fields, new fields). Optionally updates state and regenerates models (autopatch).
302+
Scans a directory for `.json`, `.ndjson`, and `.jsonl` files, infers type from each file’s content, and compares to the expected schema from the state file. Reports drift (type mismatches, missing required fields, new fields). Optionally updates state and regenerates models (autopatch).
302303

303304
```bash
304305
pydanticforge monitor <directory> [OPTIONS]
@@ -348,7 +349,7 @@ Monitor exit codes:
348349

349350
### `diff` — Semantic diff between two model files
350351

351-
Parses two Python files containing Pydantic `BaseModel` classes and prints a semantic diff (added/removed classes and fields, required/optional and type changes), classified as breaking or non-breaking.
352+
Parses two Python files containing Pydantic `BaseModel` or `RootModel` classes and prints a semantic diff (added/removed classes and fields, required/optional and type changes), classified as breaking or non-breaking.
352353

353354
```bash
354355
pydanticforge diff <old_model.py> <new_model.py> [OPTIONS]
@@ -612,7 +613,7 @@ Use the same `strict_numbers` when calling `join_types` (e.g. in monitor autopat
612613
## Input and output formats
613614

614615
- **Stdin (watch / generate):** Newline-delimited JSON (NDJSON). Each line is one JSON value (object or array). If a line is an array, each element is treated as a separate sample.
615-
- **Files:** `.json` files. Content can be a single JSON value (object or array) or NDJSON; arrays are expanded into one sample per element.
616+
- **Files:** `.json`, `.ndjson`, and `.jsonl` files. Content can be a single JSON value (object or array) or NDJSON; arrays are expanded into one sample per element.
616617
- **State file:** JSON file with `schema_version` and `root` (internal type graph). Do not edit by hand; use CLI or `save_schema_state` / `load_schema_state`.
617618
- **JSON Schema:** Draft 2020-12 document import/export for state interop (`schema`, `generate`, `watch`, `monitor`).
618619
- **Monitor JSON report:** `monitor --format json` emits machine-readable summary + per-file/per-event severities.
@@ -645,7 +646,7 @@ Quality checks:
645646
ruff check .
646647
ruff format --check .
647648
mypy src
648-
pytest -q
649+
python -m pytest -q
649650
pip-audit
650651
```
651652

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Author: Goutam Adwant
1+
# Author: gadwant
22
[build-system]
33
requires = ["hatchling>=1.27.0"]
44
build-backend = "hatchling.build"

src/pydanticforge/diff/semantic.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,43 @@ def _classify_type_change(old: str, new: str) -> str:
6868
return "changed"
6969

7070

71-
def _is_basemodel_subclass(class_node: ast.ClassDef) -> bool:
71+
def _base_name(base: ast.expr) -> str | None:
72+
if isinstance(base, ast.Name):
73+
return base.id
74+
if isinstance(base, ast.Attribute):
75+
return base.attr
76+
if isinstance(base, ast.Subscript):
77+
return _base_name(base.value)
78+
return None
79+
80+
81+
def _has_model_base(class_node: ast.ClassDef, base_name: str) -> bool:
82+
return any(_base_name(base) == base_name for base in class_node.bases)
83+
84+
85+
def _is_pydantic_model_subclass(class_node: ast.ClassDef) -> bool:
86+
return _has_model_base(class_node, "BaseModel") or _has_model_base(class_node, "RootModel")
87+
88+
89+
def _extract_root_model_annotation(class_node: ast.ClassDef) -> str | None:
7290
for base in class_node.bases:
73-
if isinstance(base, ast.Name) and base.id == "BaseModel":
74-
return True
75-
if isinstance(base, ast.Attribute) and base.attr == "BaseModel":
76-
return True
77-
return False
91+
if _base_name(base) != "RootModel":
92+
continue
93+
94+
if isinstance(base, ast.Subscript):
95+
return _normalize_annotation(ast.unparse(base.slice))
96+
return "Any"
97+
98+
return None
7899

79100

80101
def _is_field_required(statement: ast.AnnAssign) -> bool:
81102
if statement.value is None:
82103
return True
83104

105+
if isinstance(statement.value, ast.Constant) and statement.value.value is ...:
106+
return True
107+
84108
if isinstance(statement.value, ast.Call):
85109
fn = statement.value.func
86110
if isinstance(fn, ast.Name) and fn.id == "Field":
@@ -98,10 +122,15 @@ def parse_pydantic_models(path: Path) -> ModelSchema:
98122
for node in tree.body:
99123
if not isinstance(node, ast.ClassDef):
100124
continue
101-
if not _is_basemodel_subclass(node):
125+
if not _is_pydantic_model_subclass(node):
102126
continue
103127

104128
fields: dict[str, ModelField] = {}
129+
130+
root_annotation = _extract_root_model_annotation(node)
131+
if root_annotation is not None:
132+
fields["__root__"] = ModelField(annotation=root_annotation, required=True)
133+
105134
for statement in node.body:
106135
if not isinstance(statement, ast.AnnAssign):
107136
continue

src/pydanticforge/io/files.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
orjson: Any | None = importlib.import_module("orjson") if find_spec("orjson") is not None else None
1212

13+
_JSON_FILE_SUFFIXES = (".json", ".ndjson", ".jsonl")
14+
1315

1416
def _loads(raw: bytes | str) -> Any:
1517
if orjson is not None:
@@ -21,10 +23,39 @@ def _loads(raw: bytes | str) -> Any:
2123
return json.loads(raw)
2224

2325

26+
def _parse_ndjson(raw: bytes) -> list[Any]:
27+
text = raw.decode("utf-8")
28+
samples: list[Any] = []
29+
30+
for line in text.splitlines():
31+
stripped = line.strip()
32+
if not stripped:
33+
continue
34+
samples.append(_loads(stripped))
35+
36+
return samples
37+
38+
2439
def read_json_file(path: Path) -> Any:
25-
return _loads(path.read_bytes())
40+
raw = path.read_bytes()
41+
try:
42+
return _loads(raw)
43+
except ValueError as primary_error:
44+
try:
45+
ndjson_samples = _parse_ndjson(raw)
46+
except (UnicodeDecodeError, ValueError):
47+
raise primary_error from None
48+
49+
if not ndjson_samples:
50+
raise primary_error from None
51+
52+
return ndjson_samples
2653

2754

2855
def iter_json_files(directory: Path, *, recursive: bool = True) -> Iterable[Path]:
29-
pattern = "**/*.json" if recursive else "*.json"
30-
yield from sorted(p for p in directory.glob(pattern) if p.is_file())
56+
files: set[Path] = set()
57+
for suffix in _JSON_FILE_SUFFIXES:
58+
pattern = f"**/*{suffix}" if recursive else f"*{suffix}"
59+
files.update(path for path in directory.glob(pattern) if path.is_file())
60+
61+
yield from sorted(files)

src/pydanticforge/monitor/watcher.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ def monitor_directory_once(
5555
expected_root = observed
5656
continue
5757

58-
file_events.extend(detect_drift(expected_root, observed, path="$"))
59-
if autopatch and file_events:
58+
sample_events = detect_drift(expected_root, observed, path="$")
59+
file_events.extend(sample_events)
60+
if autopatch and sample_events:
6061
expected_root = join_types(
6162
expected_root,
6263
observed,

tests/integration/test_cli.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,59 @@ def test_monitor_breaking_exit_code(tmp_path: Path, capsys: object) -> None:
255255
assert exit_code == 21
256256
payload = json.loads(captured.out)
257257
assert payload["summary"]["breaking_events"] >= 1
258+
259+
260+
def test_generate_command_from_ndjson_file(tmp_path: Path) -> None:
261+
input_file = tmp_path / "samples.ndjson"
262+
input_file.write_text('{"id": 1}\n{"id": 2, "name": "alice"}\n', encoding="utf-8")
263+
264+
output = tmp_path / "models.py"
265+
exit_code = main(["generate", "--input", str(input_file), "--output", str(output)])
266+
267+
assert exit_code == 0
268+
text = output.read_text(encoding="utf-8")
269+
assert "class PydanticforgeModel(BaseModel):" in text
270+
assert "name: str | None = None" in text
271+
272+
273+
def test_monitor_and_status_support_ndjson_files(tmp_path: Path, capsys: object) -> None:
274+
state_file = tmp_path / "state.json"
275+
_run_with_stdin(
276+
["generate", "--save-state", str(state_file)],
277+
'{"id": 1, "name": "baseline"}\n',
278+
)
279+
capsys.readouterr()
280+
281+
logs_dir = tmp_path / "logs"
282+
logs_dir.mkdir()
283+
(logs_dir / "events.ndjson").write_text(
284+
'{"id": 2, "name": "ok"}\n{"id": "bad", "name": "oops"}\n',
285+
encoding="utf-8",
286+
)
287+
288+
monitor_exit = main(
289+
[
290+
"monitor",
291+
str(logs_dir),
292+
"--state",
293+
str(state_file),
294+
"--format",
295+
"json",
296+
"--fail-on",
297+
"breaking",
298+
]
299+
)
300+
monitor_output = capsys.readouterr()
301+
302+
assert monitor_exit == 21
303+
monitor_payload = json.loads(monitor_output.out)
304+
assert monitor_payload["summary"]["files_scanned"] == 1
305+
assert monitor_payload["summary"]["breaking_events"] >= 1
306+
307+
status_exit = main(["status", str(logs_dir), "--state", str(state_file), "--format", "json"])
308+
status_output = capsys.readouterr()
309+
310+
assert status_exit == 0
311+
status_payload = json.loads(status_output.out)
312+
assert status_payload["drift"]["summary"]["files_scanned"] == 1
313+
assert status_payload["drift"]["summary"]["breaking_events"] >= 1

tests/unit/test_diff.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,41 @@ def test_semantic_diff_classifies_breaking_and_non_breaking(tmp_path: Path) -> N
5555
and "optional" in entry.message
5656
for entry in entries
5757
)
58+
59+
60+
def test_semantic_diff_detects_root_model_type_change(tmp_path: Path) -> None:
61+
old_file = tmp_path / "old_root.py"
62+
new_file = tmp_path / "new_root.py"
63+
64+
old_file.write_text(
65+
"\n".join(
66+
[
67+
"from pydantic import RootModel",
68+
"",
69+
"class Payload(RootModel[int]):",
70+
" pass",
71+
]
72+
),
73+
encoding="utf-8",
74+
)
75+
76+
new_file.write_text(
77+
"\n".join(
78+
[
79+
"from pydantic import RootModel",
80+
"",
81+
"class Payload(RootModel[str]):",
82+
" pass",
83+
]
84+
),
85+
encoding="utf-8",
86+
)
87+
88+
entries = diff_models(old_file, new_file)
89+
90+
assert any(
91+
entry.field_name == "__root__"
92+
and entry.severity == "breaking"
93+
and "Type changed" in entry.message
94+
for entry in entries
95+
)

tests/unit/test_io_files.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Author: gadwant
2+
from __future__ import annotations
3+
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from pydanticforge.io.files import iter_json_files, read_json_file
9+
10+
11+
def test_read_json_file_supports_standard_json(tmp_path: Path) -> None:
12+
path = tmp_path / "sample.json"
13+
path.write_text('{"id": 1, "name": "alice"}', encoding="utf-8")
14+
15+
payload = read_json_file(path)
16+
17+
assert payload == {"id": 1, "name": "alice"}
18+
19+
20+
def test_read_json_file_supports_ndjson(tmp_path: Path) -> None:
21+
path = tmp_path / "sample.ndjson"
22+
path.write_text('{"id": 1}\n{"id": 2, "name": "alice"}\n', encoding="utf-8")
23+
24+
payload = read_json_file(path)
25+
26+
assert isinstance(payload, list)
27+
assert payload[0] == {"id": 1}
28+
assert payload[1] == {"id": 2, "name": "alice"}
29+
30+
31+
def test_read_json_file_preserves_parse_error_for_invalid_json(tmp_path: Path) -> None:
32+
path = tmp_path / "broken.json"
33+
path.write_text('{"id":\n 1,\n', encoding="utf-8")
34+
35+
with pytest.raises(ValueError):
36+
read_json_file(path)
37+
38+
39+
def test_iter_json_files_includes_json_ndjson_and_jsonl(tmp_path: Path) -> None:
40+
(tmp_path / "a.json").write_text("{}", encoding="utf-8")
41+
(tmp_path / "b.ndjson").write_text("{}\n", encoding="utf-8")
42+
(tmp_path / "c.jsonl").write_text("{}\n", encoding="utf-8")
43+
(tmp_path / "ignored.txt").write_text("{}", encoding="utf-8")
44+
45+
nested = tmp_path / "nested"
46+
nested.mkdir()
47+
(nested / "d.json").write_text("{}", encoding="utf-8")
48+
49+
recursive_files = list(iter_json_files(tmp_path, recursive=True))
50+
non_recursive_files = list(iter_json_files(tmp_path, recursive=False))
51+
52+
assert [path.name for path in recursive_files] == ["a.json", "b.ndjson", "c.jsonl", "d.json"]
53+
assert [path.name for path in non_recursive_files] == ["a.json", "b.ndjson", "c.jsonl"]

0 commit comments

Comments
 (0)