diff --git a/barrage/__main__.py b/barrage/__main__.py index 0fe34bb..66a177a 100644 --- a/barrage/__main__.py +++ b/barrage/__main__.py @@ -27,6 +27,10 @@ python3 -m barrage --max-concurrency 4 test/ python3 -m barrage -i test/test_example.py::MyTestClass::test_method python3 -m barrage -x test/ + + # Set a test directory (used as default search dir and import root) + python3 -m barrage -t tests/ + python3 -m barrage --top-level-directory tests/ test_foo.py::MyClass::test_method """ import argparse @@ -100,23 +104,40 @@ def main(argv: list[str] | None = None) -> int: default="test_*.py", help="Pattern to match test files when discovering in directories (default: test_*.py)", ) + parser.add_argument( + "-t", + "--top-level-directory", + default=None, + dest="top_level_directory", + metavar="DIR", + help=( + "Top-level directory for test discovery and imports. " + "When no positional paths are given, tests are discovered " + "in this directory instead of the current directory. " + "When paths are given, relative paths are resolved against " + "this directory and it is used as the import root." + ), + ) parser.add_argument( "paths", nargs="*", - default=["."], + default=None, metavar="path", help=( "Paths to test files or directories. A path may be suffixed " "with ::ClassName to select a single test class, or " "::ClassName::test_method to select a single test. " "When no paths are given, discovers tests in the current " - "directory." + "directory (or -t if set)." ), ) args = parser.parse_args(argv) - suite = resolve_tests(args.paths, pattern=args.pattern) + paths = args.paths if args.paths else [args.top_level_directory or "."] + top_level_dir = args.top_level_directory + + suite = resolve_tests(paths, pattern=args.pattern, top_level_dir=top_level_dir) if not suite.entries: print("No tests discovered.", file=sys.stderr) diff --git a/barrage/discovery.py b/barrage/discovery.py index 55f48c6..8ba36da 100644 --- a/barrage/discovery.py +++ b/barrage/discovery.py @@ -14,6 +14,7 @@ def resolve_tests( path_specs: list[str], pattern: str = "test_*.py", + top_level_dir: str | None = None, ) -> AsyncTestSuite: """ Build an :class:`AsyncTestSuite` from a list of path specifications. @@ -40,6 +41,11 @@ def resolve_tests( pattern: Glob pattern for test file names used during directory discovery (default ``test_*.py``). + top_level_dir: + Top-level directory of the project, used as the import root + for discovered modules. When ``None``, directory discovery + uses *start_dir* and file discovery uses the current working + directory. Returns ------- @@ -54,6 +60,7 @@ def resolve_tests( ``SystemExit(2)`` is raised. """ suite = AsyncTestSuite() + top = Path(top_level_dir) if top_level_dir else None for spec in path_specs: parts = spec.split("::") @@ -70,6 +77,14 @@ def resolve_tests( p = Path(path_str) + # When a top-level directory is set and the path is not + # absolute, resolve it relative to that directory so that + # ``-t test test_file.py::Class`` finds ``test/test_file.py``. + if top and not p.is_absolute() and not p.exists(): + candidate = top / p + if candidate.exists(): + p = candidate + if p.is_dir(): if class_name is not None: print( @@ -77,7 +92,7 @@ def resolve_tests( file=sys.stderr, ) raise SystemExit(2) - _discover_directory(p, suite, pattern=pattern) + _discover_directory(p, suite, pattern=pattern, top_level_dir=top) elif p.is_file(): _discover_file( @@ -85,6 +100,7 @@ def resolve_tests( suite, class_name=class_name, method_name=method_name, + top_level_dir=top, ) else: @@ -176,6 +192,7 @@ def _discover_file( suite: AsyncTestSuite, class_name: str | None = None, method_name: str | None = None, + top_level_dir: Path | None = None, ) -> None: """Import *filepath* and add matching test classes/methods to *suite*. @@ -189,12 +206,16 @@ def _discover_file( If given, only the class with this name is collected. method_name: If given (requires *class_name*), only this method is collected. + top_level_dir: + Top-level directory used as the import root. When ``None``, + the current working directory is used. """ filepath = filepath.resolve() - # Use the current working directory as the top-level import root so - # that ``test/test_foo.py`` imports as ``test.test_foo``. - top = Path.cwd().resolve() + # Use the provided top-level directory or fall back to the current + # working directory as the import root so that ``test/test_foo.py`` + # imports as ``test.test_foo``. + top = top_level_dir.resolve() if top_level_dir else Path.cwd().resolve() if str(top) not in sys.path: sys.path.insert(0, str(top)) diff --git a/tests/test_framework.py b/tests/test_framework.py index e450690..86dd4e2 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -1287,6 +1287,65 @@ async def test_resolve_non_test_class(self) -> None: assert isinstance(ctx.exception, SystemExit) self.assertEqual(ctx.exception.code, 2) + # ── top_level_dir ───────────────────────────────────────────────── + + async def test_resolve_top_level_dir_discovers_directory(self) -> None: + """``top_level_dir`` used as discovery root when passed as path.""" + suite = resolve_tests([self._sample_dir], top_level_dir=self._sample_dir) + class_names = sorted(cls.__name__ for cls, _ in suite.entries) + self.assertIn("SamplePassingTests", class_names) + self.assertIn("SampleSequentialTests", class_names) + total_methods = sum(len(m) for _, m in suite.entries) + self.assertEqual(total_methods, 4) + + async def test_resolve_top_level_dir_relative_file(self) -> None: + """A relative file path is resolved against ``top_level_dir``.""" + suite = resolve_tests(["test_sample.py"], top_level_dir=self._sample_dir) + class_names = sorted(cls.__name__ for cls, _ in suite.entries) + self.assertIn("SamplePassingTests", class_names) + self.assertIn("SampleSequentialTests", class_names) + + async def test_resolve_top_level_dir_relative_file_with_class(self) -> None: + """A relative ``file::Class`` spec is resolved against ``top_level_dir``.""" + suite = resolve_tests( + ["test_sample.py::SamplePassingTests"], + top_level_dir=self._sample_dir, + ) + self.assertEqual(len(suite.entries), 1) + cls, methods = suite.entries[0] + self.assertEqual(cls.__name__, "SamplePassingTests") + self.assertEqual(len(methods), 2) + + async def test_resolve_top_level_dir_relative_file_with_method(self) -> None: + """A relative ``file::Class::method`` spec is resolved against ``top_level_dir``.""" + suite = resolve_tests( + ["test_sample.py::SamplePassingTests::test_add"], + top_level_dir=self._sample_dir, + ) + self.assertEqual(len(suite.entries), 1) + cls, methods = suite.entries[0] + self.assertEqual(cls.__name__, "SamplePassingTests") + self.assertEqual(methods, ["test_add"]) + + async def test_resolve_top_level_dir_absolute_path_ignores_top(self) -> None: + """An absolute path is not resolved against ``top_level_dir``.""" + suite = resolve_tests( + [self._sample_file], + top_level_dir="/tmp/_barrage_no_such_dir_99999", + ) + class_names = sorted(cls.__name__ for cls, _ in suite.entries) + self.assertIn("SamplePassingTests", class_names) + + async def test_resolve_top_level_dir_relative_not_found(self) -> None: + """A relative path that doesn't exist in either cwd or top dir gives SystemExit(2).""" + with self.assertRaises(SystemExit) as ctx: + resolve_tests( + ["no_such_file_99999.py"], + top_level_dir=self._sample_dir, + ) + assert isinstance(ctx.exception, SystemExit) + self.assertEqual(ctx.exception.code, 2) + # ===================================================================== # # 17. CLI (__main__) via subprocess @@ -1421,6 +1480,168 @@ async def test_main_nonexistent_method_exits_2(self) -> None: self.assertEqual(result.returncode, 2) self.assertIn("no_such_method", result.stderr.decode()) + async def test_top_level_directory_discovers_tests(self) -> None: + """``-t dir`` with no positional paths discovers tests in that directory.""" + sample_dir = str(Path(__file__).parent / "_sample_discover") + top_dir = str(Path(__file__).resolve().parents[1]) + + if not Path(sample_dir).is_dir(): + self.skipTest("sample directory missing") + + async with asyncio.timeout(30): + result = await run( + [sys.executable, "-m", "barrage", "-t", sample_dir, "-v"], + stdout=PIPE, + stderr=PIPE, + cwd=top_dir, + check=False, + ) + stdout_text = result.stdout.decode() + self.assertEqual(result.returncode, 0, f"stdout:\n{stdout_text}\nstderr:\n{result.stderr.decode()}") + self.assertIn("Ran 4 test(s)", stdout_text) + self.assertIn("OK", stdout_text) + + async def test_top_level_directory_resolves_relative_path(self) -> None: + """``-t dir file.py`` resolves the relative path against -t.""" + sample_dir = str(Path(__file__).parent / "_sample_discover") + top_dir = str(Path(__file__).resolve().parents[1]) + + if not Path(sample_dir).is_dir(): + self.skipTest("sample directory missing") + + async with asyncio.timeout(30): + result = await run( + [sys.executable, "-m", "barrage", "-t", sample_dir, "test_sample.py", "-v"], + stdout=PIPE, + stderr=PIPE, + cwd=top_dir, + check=False, + ) + stdout_text = result.stdout.decode() + self.assertEqual(result.returncode, 0, f"stdout:\n{stdout_text}\nstderr:\n{result.stderr.decode()}") + self.assertIn("Ran 4 test(s)", stdout_text) + self.assertIn("OK", stdout_text) + + async def test_top_level_directory_with_class_selector(self) -> None: + """``-t dir file.py::Class`` resolves the file and selects the class.""" + sample_dir = str(Path(__file__).parent / "_sample_discover") + top_dir = str(Path(__file__).resolve().parents[1]) + + if not Path(sample_dir).is_dir(): + self.skipTest("sample directory missing") + + async with asyncio.timeout(30): + result = await run( + [ + sys.executable, + "-m", + "barrage", + "-t", + sample_dir, + "test_sample.py::SamplePassingTests", + "-v", + ], + stdout=PIPE, + stderr=PIPE, + cwd=top_dir, + check=False, + ) + stdout_text = result.stdout.decode() + self.assertEqual(result.returncode, 0, f"stdout:\n{stdout_text}\nstderr:\n{result.stderr.decode()}") + self.assertIn("Ran 2 test(s)", stdout_text) + self.assertIn("OK", stdout_text) + + async def test_top_level_directory_with_method_selector(self) -> None: + """``-t dir file.py::Class::method`` resolves and selects a single test.""" + sample_dir = str(Path(__file__).parent / "_sample_discover") + top_dir = str(Path(__file__).resolve().parents[1]) + + if not Path(sample_dir).is_dir(): + self.skipTest("sample directory missing") + + async with asyncio.timeout(30): + result = await run( + [ + sys.executable, + "-m", + "barrage", + "-t", + sample_dir, + "test_sample.py::SamplePassingTests::test_add", + "-v", + ], + stdout=PIPE, + stderr=PIPE, + cwd=top_dir, + check=False, + ) + stdout_text = result.stdout.decode() + self.assertEqual(result.returncode, 0, f"stdout:\n{stdout_text}\nstderr:\n{result.stderr.decode()}") + self.assertIn("Ran 1 test(s)", stdout_text) + self.assertIn("test_add", stdout_text) + self.assertIn("OK", stdout_text) + + async def test_top_level_directory_nonexistent_exits_2(self) -> None: + """``-t /nonexistent`` with no positional paths exits with code 2.""" + top_dir = str(Path(__file__).resolve().parents[1]) + + async with asyncio.timeout(30): + result = await run( + [sys.executable, "-m", "barrage", "-t", "/tmp/_barrage_no_such_dir_99999"], + stdout=PIPE, + stderr=PIPE, + cwd=top_dir, + check=False, + ) + self.assertEqual(result.returncode, 2) + + async def test_top_level_directory_long_form(self) -> None: + """The long form ``--top-level-directory`` works the same as ``-t``.""" + sample_dir = str(Path(__file__).parent / "_sample_discover") + top_dir = str(Path(__file__).resolve().parents[1]) + + if not Path(sample_dir).is_dir(): + self.skipTest("sample directory missing") + + async with asyncio.timeout(30): + result = await run( + [sys.executable, "-m", "barrage", "--top-level-directory", sample_dir, "-v"], + stdout=PIPE, + stderr=PIPE, + cwd=top_dir, + check=False, + ) + stdout_text = result.stdout.decode() + self.assertEqual(result.returncode, 0, f"stdout:\n{stdout_text}\nstderr:\n{result.stderr.decode()}") + self.assertIn("Ran 4 test(s)", stdout_text) + self.assertIn("OK", stdout_text) + + async def test_top_level_directory_with_absolute_path(self) -> None: + """``-t`` does not interfere when positional paths are absolute.""" + sample_file = str(Path(__file__).parent / "_sample_discover" / "test_sample.py") + top_dir = str(Path(__file__).resolve().parents[1]) + + async with asyncio.timeout(30): + result = await run( + [ + sys.executable, + "-m", + "barrage", + "-t", + "/tmp/_barrage_no_such_dir_99999", + sample_file, + "-v", + ], + stdout=PIPE, + stderr=PIPE, + cwd=top_dir, + check=False, + ) + stdout_text = result.stdout.decode() + self.assertEqual(result.returncode, 0, f"stdout:\n{stdout_text}\nstderr:\n{result.stderr.decode()}") + self.assertIn("Ran 4 test(s)", stdout_text) + self.assertIn("OK", stdout_text) + # ===================================================================== # # 18. Stress test: many tests with limited concurrency