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
27 changes: 24 additions & 3 deletions barrage/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 25 additions & 4 deletions barrage/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
-------
Expand All @@ -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("::")
Expand All @@ -70,21 +77,30 @@ 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(
f"Error: cannot use ::ClassName filter on a directory: {spec!r}",
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(
p,
suite,
class_name=class_name,
method_name=method_name,
top_level_dir=top,
)

else:
Expand Down Expand Up @@ -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*.

Expand All @@ -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))

Expand Down
221 changes: 221 additions & 0 deletions tests/test_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down