diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d5919..b41f5cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to Django ORM Lens will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **CLI `list` subcommand now supports `--format json`.** `django-orm-lens list --format json` emits a pipe-friendly JSON array `[{"app": "...", "model": "..."}, ...]`. The default `text` output remains unchanged for backward compatibility. + ## [0.5.1] + [py-1.0.17] - 2026-07-16 Bugfix release focused on parser accuracy and MCP correctness. E2E audit against a synthetic Django project with abstract mixins, custom user model, multi-file `models/` packages, real migrations, signals, and views uncovered five real bugs that would have hit the ~60 % of production Django codebases that use `TimeStampedModel`-style mixins or `settings.AUTH_USER_MODEL`. All fixed here without any behavioural regressions (44 pytests pass — 13 new + 31 pre-existing). diff --git a/cli/django_orm_lens/cli.py b/cli/django_orm_lens/cli.py index f1b6e58..51b3007 100644 --- a/cli/django_orm_lens/cli.py +++ b/cli/django_orm_lens/cli.py @@ -13,6 +13,7 @@ from __future__ import annotations import argparse +import json import sys from typing import List, Optional, Sequence @@ -99,10 +100,17 @@ def _cmd_hover(args: argparse.Namespace) -> int: def _cmd_list(args: argparse.Namespace) -> int: idx = _load_index(args) - for app in idx.apps: - for m in app.models: - print(f"{app.name}.{m.name}") - return 0 + fmt = args.format.lower() + if fmt == "json": + payload = [{"app": app.name, "model": m.name} for app in idx.apps for m in app.models] + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + if fmt == "text": + for app in idx.apps: + for m in app.models: + print(f"{app.name}.{m.name}") + return 0 + raise ValueError(f"Unknown format: {fmt!r}. Use text | json.") def _cmd_er(args: argparse.Namespace) -> int: @@ -209,6 +217,9 @@ def build_parser() -> argparse.ArgumentParser: lst = sub.add_parser("list", help="Flat list of app.Model — pipes well into shell") _add_scan_flags(lst) + lst.add_argument( + "--format", "-f", choices=("text", "json"), default="text" + ) lst.set_defaults(func=_cmd_list) er = sub.add_parser("er", help="Emit a Mermaid ER diagram (stdout or file)") diff --git a/cli/tests/test_list_command.py b/cli/tests/test_list_command.py new file mode 100644 index 0000000..38db01a --- /dev/null +++ b/cli/tests/test_list_command.py @@ -0,0 +1,78 @@ +"""Tests for the ``list`` subcommand.""" + +from __future__ import annotations + +import json +import unittest +from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from django_orm_lens.cli import main + +MODELS_SOURCE = """from django.db import models + + +class Author(models.Model): + name = models.CharField(max_length=100) + + +class Book(models.Model): + title = models.CharField(max_length=200) + author = models.ForeignKey(Author, on_delete=models.CASCADE) + + +class Tag(models.Model): + label = models.CharField(max_length=50) +""" + + +def _write_hello_app(app_dir: Path) -> None: + app_dir.mkdir(parents=True, exist_ok=True) + (app_dir / "models.py").write_text(MODELS_SOURCE, encoding="utf-8") + for name in ("__init__.py", "admin.py", "apps.py", "views.py", "urls.py", "tests.py"): + (app_dir / name).write_text("", encoding="utf-8") + (app_dir / "migrations").mkdir(exist_ok=True) + (app_dir / "migrations" / "__init__.py").write_text("", encoding="utf-8") + + +class ListCommandTest(unittest.TestCase): + def test_default_text_output_is_unchanged(self) -> None: + with TemporaryDirectory() as tmp: + _write_hello_app(Path(tmp) / "hello") + with patch("sys.stdout", new=StringIO()) as stdout: + code = main(["list", "--path", tmp]) + self.assertEqual(code, 0) + lines = stdout.getvalue().splitlines() + self.assertEqual(lines, ["hello.Author", "hello.Book", "hello.Tag"]) + + def test_json_output_has_app_model_shape(self) -> None: + with TemporaryDirectory() as tmp: + _write_hello_app(Path(tmp) / "hello") + with patch("sys.stdout", new=StringIO()) as stdout: + code = main(["list", "--format", "json", "--path", tmp]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual( + payload, + [ + {"app": "hello", "model": "Author"}, + {"app": "hello", "model": "Book"}, + {"app": "hello", "model": "Tag"}, + ], + ) + + def test_json_short_flag_works(self) -> None: + with TemporaryDirectory() as tmp: + _write_hello_app(Path(tmp) / "hello") + with patch("sys.stdout", new=StringIO()) as stdout: + code = main(["list", "-f", "json", "--path", tmp]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(len(payload), 3) + self.assertEqual(payload[0], {"app": "hello", "model": "Author"}) + + +if __name__ == "__main__": + unittest.main()