diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae186c..403e2da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ...` 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 diff --git a/cli/django_orm_lens/cli.py b/cli/django_orm_lens/cli.py index 1bef2f3..1a2f777 100644 --- a/cli/django_orm_lens/cli.py +++ b/cli/django_orm_lens/cli.py @@ -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]: @@ -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): @@ -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" + " /models.py or /models/*.py. Check the path or pass\n" + " --exclude to whitelist non-standard layouts.", + file=sys.stderr, + ) return index diff --git a/cli/tests/test_cli_hint.py b/cli/tests/test_cli_hint.py new file mode 100644 index 0000000..cf563da --- /dev/null +++ b/cli/tests/test_cli_hint.py @@ -0,0 +1,65 @@ +"""Regression test for #12: friendlier error when no models.py is found. + +`scan --path ` 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 +" 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()