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 @@ -10,6 +10,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)
- **CLI: friendlier hint when no `models.py` is found.** `scan`/`describe`/`hover`/`list`/`er` previously printed a silently-empty result (e.g. `{"apps": []}`) with no signal to a new user whether the tool worked or the `--path` was wrong. When zero models are found *and* no `models.py`/`models/*.py` file was walked at all, a `hint: no models.py found under <path>...` line is now printed to stderr (stdout stays clean for piping, exit code stays `0`). New `--quiet`/`-q` flag suppresses the hint. (#12)

## [0.5.1] + [py-1.0.17] - 2026-07-16

Expand Down
43 changes: 33 additions & 10 deletions cli/django_orm_lens/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ def _add_scan_flags(p: argparse.ArgumentParser) -> None:
action="store_true",
help="Print scan timing + file/app/model counts to stderr",
)
p.add_argument(
"--quiet",
"-q",
action="store_true",
default=False,
help="Suppress the 'no models.py found' hint on a zero-model scan",
)


def _resolve_excludes(cli_excludes: Optional[Sequence[str]]) -> Sequence[str]:
Expand All @@ -54,6 +61,12 @@ def _resolve_excludes(cli_excludes: Optional[Sequence[str]]) -> Sequence[str]:
def _load_index(args: argparse.Namespace) -> WorkspaceIndex:
"""Validate ``--path`` exists before scanning. Silent-empty result on a
typo'd path is a common confusion — surface it as an error.

A zero-model result where no ``models.py`` was even walked is a second,
quieter version of the same confusion (new users can't tell "it works,
your app just has no models" from "the path is wrong"). Print a hint to
stderr in that case, unless ``--quiet`` was passed. Exit code is
untouched — an empty result is valid, not an error.
"""
import os
if not os.path.isdir(args.path):
Expand All @@ -63,18 +76,28 @@ def _load_index(args: argparse.Namespace) -> WorkspaceIndex:
)
raise SystemExit(2)
excludes = _resolve_excludes(args.exclude)
if not getattr(args, "verbose", False):
return scan_workspace(args.path, excludes)
root = Path(args.path).resolve()
verbose = getattr(args, "verbose", False)

start = time.perf_counter()
start = time.perf_counter() if verbose else 0.0
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,
)
if verbose:
elapsed_ms = (time.perf_counter() - start) * 1000
file_count = sum(1 for _ in _iter_python_files(root, 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,
)
if index.total_models() == 0 and not getattr(args, "quiet", False):
saw_models_file = any(_iter_python_files(root, excludes))
if not saw_models_file:
print(
f"hint: no models.py found under {args.path}. Django apps typically have\n"
" <app>/models.py or <app>/models/*.py. Check the path or pass\n"
" --exclude to whitelist non-standard layouts.",
file=sys.stderr,
)
return index


Expand Down
65 changes: 65 additions & 0 deletions cli/tests/test_cli_hint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Regression test for #12: friendlier error when no models.py is found.

`scan --path <empty dir>` used to print an empty apps list with no signal
that the tool actually ran. It now emits a "hint: no models.py found under
<path>" line to stderr (stdout stays clean for piping, exit code stays 0),
suppressible with ``--quiet``. The hint must NOT fire when a models.py *was*
walked but simply defined zero Django model classes — that's a different
situation (models.py seen, just nothing in it) from "no models.py at all".
"""

from __future__ import annotations

import contextlib
import io
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory

from django_orm_lens.cli import main


def _run(argv):
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
exit_code = main(argv)
return exit_code, out.getvalue(), err.getvalue()


class NoModelsHintTest(unittest.TestCase):
def test_hint_printed_to_stderr_on_empty_workspace(self) -> None:
with TemporaryDirectory() as tmp:
exit_code, out, err = _run(["scan", "--path", tmp])

self.assertEqual(exit_code, 0)
self.assertIn('"apps": []', out)
self.assertIn(f"hint: no models.py found under {tmp}", err)
self.assertIn("--exclude to whitelist non-standard layouts", err)

def test_quiet_suppresses_hint(self) -> None:
with TemporaryDirectory() as tmp:
exit_code, out, err = _run(["scan", "--path", tmp, "--quiet"])

self.assertEqual(exit_code, 0)
self.assertIn('"apps": []', out)
self.assertEqual(err, "")

def test_hint_not_printed_when_models_py_seen_but_empty_of_models(self) -> None:
with TemporaryDirectory() as tmp:
app = Path(tmp) / "hello"
app.mkdir()
# A real models.py that was walked but defines no Django model
# classes — this is NOT the "no models.py found" situation.
(app / "models.py").write_text(
"class NotAModel:\n pass\n", encoding="utf-8"
)

exit_code, out, err = _run(["scan", "--path", tmp])

self.assertEqual(exit_code, 0)
self.assertIn('"apps": []', out)
self.assertEqual(err, "")


if __name__ == "__main__":
unittest.main()