diff --git a/CHANGELOG.md b/CHANGELOG.md index b41f5cf..3ae186c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- **`--verbose` / `-v` flag** on every scan-backed CLI subcommand (`scan`, `describe`, `hover`, `list`, `er`). Prints a one-line summary to stderr after the scan — `scanned 12 files in 34ms, found 8 apps / 47 models` — sourced from the actual file walk (`_iter_python_files`), a `time.perf_counter()` measurement around the scan, and the real app/model counts on the returned index. Stdout is untouched either way, so `django-orm-lens list -v | xargs ...` keeps piping cleanly; without `-v` nothing extra is printed. (#14) ## [0.5.1] + [py-1.0.17] - 2026-07-16 diff --git a/cli/django_orm_lens/cli.py b/cli/django_orm_lens/cli.py index 51b3007..1bef2f3 100644 --- a/cli/django_orm_lens/cli.py +++ b/cli/django_orm_lens/cli.py @@ -15,12 +15,14 @@ import argparse import json import sys +import time +from pathlib import Path from typing import List, Optional, Sequence from . import __version__ from .formatters import format_hover, format_index, format_model from .models import ParsedModel, WorkspaceIndex -from .parser import DEFAULT_EXCLUDES, scan_workspace +from .parser import DEFAULT_EXCLUDES, _iter_python_files, scan_workspace def _add_scan_flags(p: argparse.ArgumentParser) -> None: @@ -37,6 +39,12 @@ def _add_scan_flags(p: argparse.ArgumentParser) -> None: default=None, help="Glob to exclude, repeatable (default: migrations/, venv/, node_modules/)", ) + p.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print scan timing + file/app/model counts to stderr", + ) def _resolve_excludes(cli_excludes: Optional[Sequence[str]]) -> Sequence[str]: @@ -54,7 +62,20 @@ def _load_index(args: argparse.Namespace) -> WorkspaceIndex: file=sys.stderr, ) raise SystemExit(2) - return scan_workspace(args.path, _resolve_excludes(args.exclude)) + excludes = _resolve_excludes(args.exclude) + if not getattr(args, "verbose", False): + return scan_workspace(args.path, excludes) + + start = time.perf_counter() + index = scan_workspace(args.path, excludes) + elapsed_ms = (time.perf_counter() - start) * 1000 + file_count = sum(1 for _ in _iter_python_files(Path(args.path).resolve(), excludes)) + print( + f"scanned {file_count} files in {elapsed_ms:.0f}ms, " + f"found {len(index.apps)} apps / {index.total_models()} models", + file=sys.stderr, + ) + return index def _find_model(index: WorkspaceIndex, ref: str) -> Optional[ParsedModel]: diff --git a/cli/tests/test_cli_verbose.py b/cli/tests/test_cli_verbose.py new file mode 100644 index 0000000..98cc8ae --- /dev/null +++ b/cli/tests/test_cli_verbose.py @@ -0,0 +1,98 @@ +"""Tests for the ``--verbose``/``-v`` scan-timing summary (issue #14). + +``-v`` is opt-in per scan-backed subcommand via ``_add_scan_flags``. It must: + +* print a one-line ``scanned N files in Tms, found A apps / M models`` + summary to stderr after the scan; +* never touch stdout — piping stays byte-for-byte identical whether or not + ``-v`` is passed; +* print nothing at all when the flag is absent. + +The exact millisecond value is timing-dependent and not asserted — only the +shape/presence of the message, per the counts the scan actually produced. +""" + +from __future__ import annotations + +import io +import re +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory + +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) +""" + +VERBOSE_RE = re.compile(r"^scanned \d+ files in \d+ms, found \d+ apps / \d+ models$") + + +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") + + +def _run(argv): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + rc = main(argv) + return rc, out.getvalue(), err.getvalue() + + +class VerboseScanFlagTest(unittest.TestCase): + def test_verbose_prints_summary_to_stderr(self) -> None: + with TemporaryDirectory() as tmp: + app = Path(tmp) / "hello" + _write_hello_app(app) + rc, out, err = _run(["scan", "-v", "--path", str(app)]) + self.assertEqual(rc, 0) + stderr_lines = [ln for ln in err.splitlines() if ln.strip()] + self.assertEqual(len(stderr_lines), 1) + self.assertRegex(stderr_lines[0], VERBOSE_RE) + self.assertIn("scanned 1 files in", stderr_lines[0]) + self.assertIn("found 1 apps / 2 models", stderr_lines[0]) + + def test_verbose_long_flag_form_also_works(self) -> None: + with TemporaryDirectory() as tmp: + app = Path(tmp) / "hello" + _write_hello_app(app) + rc, out, err = _run(["scan", "--verbose", "--path", str(app)]) + self.assertEqual(rc, 0) + self.assertRegex(err.strip(), VERBOSE_RE) + + def test_verbose_does_not_alter_stdout(self) -> None: + """`list` output has no embedded timestamp, so it's safe to compare + byte-for-byte between a verbose and a non-verbose run — this is the + piping guarantee the issue asks for. + """ + with TemporaryDirectory() as tmp: + app = Path(tmp) / "hello" + _write_hello_app(app) + _, out_plain, err_plain = _run(["list", "--path", str(app)]) + _, out_verbose, err_verbose = _run(["list", "-v", "--path", str(app)]) + self.assertEqual(out_plain, out_verbose) + self.assertEqual(err_plain, "") + self.assertRegex(err_verbose.strip(), VERBOSE_RE) + + def test_no_verbose_flag_prints_nothing_to_stderr(self) -> None: + with TemporaryDirectory() as tmp: + app = Path(tmp) / "hello" + _write_hello_app(app) + rc, out, err = _run(["scan", "--path", str(app)]) + self.assertEqual(rc, 0) + self.assertEqual(err, "") + + +if __name__ == "__main__": + unittest.main()