Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 23 additions & 2 deletions cli/django_orm_lens/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]:
Expand All @@ -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]:
Expand Down
98 changes: 98 additions & 0 deletions cli/tests/test_cli_verbose.py
Original file line number Diff line number Diff line change
@@ -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()