From 0d5b689dd5d2fac1f052a4623abd397dd8d3db75 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 7 Mar 2024 02:33:58 +0100 Subject: [PATCH] Implement rye test command built on pytest (#847) --- CHANGELOG.md | 2 + docs/guide/commands/test.md | 58 ++++++++++++ rye-devtools/pyproject.toml | 4 +- rye-devtools/tests/test_batch.py | 5 + rye/src/cli/mod.rs | 3 + rye/src/cli/test.rs | 151 ++++++++++++++++++++++++++++++ rye/src/pyproject.rs | 3 + rye/tests/test_test.rs | 152 +++++++++++++++++++++++++++++++ 8 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 docs/guide/commands/test.md create mode 100644 rye-devtools/tests/test_batch.py create mode 100644 rye/src/cli/test.rs create mode 100644 rye/tests/test_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a20c09ab..c9a4b37505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ _Unreleased_ - `rye add` now retains version and URL for the requirements when `uv` is used. #846 +- Added a `rye test` command which invokes `pytest`. #847 + ## 0.27.0 diff --git a/docs/guide/commands/test.md b/docs/guide/commands/test.md new file mode 100644 index 0000000000..85b6e56952 --- /dev/null +++ b/docs/guide/commands/test.md @@ -0,0 +1,58 @@ +# `test` + ++++ X.X.X + +Run the test suites of the project. At the moment this always runs `pytest`. +Note that `pytest` must be installed into the virtual env unlike `ruff` +which is used behind the scenes automatically for linting and formatting. +Thus in order to use this, you need to declare `pytest` as dev dependency. + +``` +$ rye add --dev pytest +``` + +It's recommended to place tests in a folder called `tests` adjacent to the +`src` folder of your project. + +For more information about how to use pytest, have a look at the +[Pytest Documentation](https://docs.pytest.org/en/8.0.x/). + +## Example + +Run the test suite: + +``` +$ rye test +platform win32 -- Python 3.11.1, pytest-8.0.2, pluggy-1.4.0 +rootdir: /Users/john/Development/stuff +plugins: anyio-4.3.0 +collected 1 item + +stuff/tests/test_batch.py . [100%] +``` + +## Arguments + +* `[EXTRA_ARGS]...` Extra arguments to the test runner. + + These arguments are forwarded directly to the underlying test runner (currently + always `pytest`). Note that extra arguments must be separated from other arguments + with the `--` marker. + +## Options + +* `-a, --all`: Lint all packages in the workspace + +* `-p, --package `: Run the test suite of a specific package + +* `--pyproject `: Use this `pyproject.toml` file + +* `-v, --verbose`: Enables verbose diagnostics + +* `-q, --quiet`: Turns off all output + +* `-i, --ignore`: Ignore the specified directory + +* `-s`, `--no-capture`: Disable stdout/stderr capture for the test runner + +* `-h, --help`: Print help (see a summary with '-h') \ No newline at end of file diff --git a/rye-devtools/pyproject.toml b/rye-devtools/pyproject.toml index 36d0fe14d7..81e65bff86 100644 --- a/rye-devtools/pyproject.toml +++ b/rye-devtools/pyproject.toml @@ -18,7 +18,9 @@ build-backend = "hatchling.build" [tool.rye] managed = true -dev-dependencies = [] +dev-dependencies = [ + "pytest==8.0.2", +] [tool.hatch.metadata] allow-direct-references = true diff --git a/rye-devtools/tests/test_batch.py b/rye-devtools/tests/test_batch.py new file mode 100644 index 0000000000..c448cd2aa9 --- /dev/null +++ b/rye-devtools/tests/test_batch.py @@ -0,0 +1,5 @@ +from rye_devtools.find_downloads import batched + + +def test_batched(): + assert list(batched("ABCDEFG", 3)) == [tuple("ABC"), tuple("DEF"), tuple("G")] diff --git a/rye/src/cli/mod.rs b/rye/src/cli/mod.rs index 32054b82ee..7d8232b882 100644 --- a/rye/src/cli/mod.rs +++ b/rye/src/cli/mod.rs @@ -22,6 +22,7 @@ mod rye; mod shim; mod show; mod sync; +mod test; mod toolchain; mod tools; mod uninstall; @@ -67,6 +68,7 @@ enum Command { Run(run::Args), Show(show::Args), Sync(sync::Args), + Test(test::Args), Toolchain(toolchain::Args), Tools(tools::Args), #[command(name = "self")] @@ -125,6 +127,7 @@ pub fn execute() -> Result<(), Error> { Command::Run(cmd) => run::execute(cmd), Command::Show(cmd) => show::execute(cmd), Command::Sync(cmd) => sync::execute(cmd), + Command::Test(cmd) => test::execute(cmd), Command::Toolchain(cmd) => toolchain::execute(cmd), Command::Tools(cmd) => tools::execute(cmd), Command::Rye(cmd) => rye::execute(cmd), diff --git a/rye/src/cli/test.rs b/rye/src/cli/test.rs new file mode 100644 index 0000000000..05fe898a3c --- /dev/null +++ b/rye/src/cli/test.rs @@ -0,0 +1,151 @@ +use std::env::consts::EXE_EXTENSION; +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::Command; + +use anyhow::{bail, Error}; +use clap::Parser; +use console::style; +use same_file::is_same_file; + +use crate::config::Config; +use crate::consts::VENV_BIN; +use crate::pyproject::{locate_projects, normalize_package_name, DependencyKind, PyProject}; +use crate::sync::autosync; +use crate::utils::{CommandOutput, QuietExit}; + +/// Run the tests on the project. +/// +/// Today this will always run `pytest` for all projects. +#[derive(Parser, Debug)] +pub struct Args { + /// Perform the operation on all packages + #[arg(short, long)] + all: bool, + /// Perform the operation on a specific package + #[arg(short, long)] + package: Vec, + /// Use this pyproject.toml file + #[arg(long, value_name = "PYPROJECT_TOML")] + pyproject: Option, + // Disable test output capture to stdout + #[arg(long = "no-capture", short = 's')] + no_capture: bool, + /// Enables verbose diagnostics. + #[arg(short, long)] + verbose: bool, + /// Turns off all output. + #[arg(short, long, conflicts_with = "verbose")] + quiet: bool, + /// Extra arguments to pytest + #[arg(last = true)] + extra_args: Vec, +} + +pub fn execute(cmd: Args) -> Result<(), Error> { + let output = CommandOutput::from_quiet_and_verbose(cmd.quiet, cmd.verbose); + let project = PyProject::load_or_discover(cmd.pyproject.as_deref())?; + + let mut failed_with = None; + + // when working with workspaces we always want to know what other projects exist. + // for that we locate all those projects and their paths. This is later used to + // prevent accidentally recursing into the wrong projects. + let project_roots = if let Some(workspace) = project.workspace() { + workspace + .iter_projects() + .filter_map(|x| x.ok()) + .map(|x| x.root_path().to_path_buf()) + .collect() + } else { + vec![project.root_path().to_path_buf()] + }; + + let pytest = project + .venv_path() + .join(VENV_BIN) + .join("pytest") + .with_extension(EXE_EXTENSION); + + let projects = locate_projects(project, cmd.all, &cmd.package[..])?; + + if !pytest.is_file() { + let has_pytest = has_pytest_dependency(&projects)?; + if has_pytest { + if Config::current().autosync() { + autosync(&projects[0], output)?; + } else { + bail!("pytest not installed but in dependencies. Run `rye sync`.") + } + } else { + bail!("pytest not installed. Run `rye add --dev pytest`"); + } + } + + for (idx, project) in projects.iter().enumerate() { + if output != CommandOutput::Quiet { + if idx > 0 { + println!(); + } + println!( + "Running tests for {} ({})", + style(project.name().unwrap_or("")).cyan(), + style(project.root_path().display()).dim() + ); + } + + let mut pytest_cmd = Command::new(&pytest); + if cmd.no_capture || output == CommandOutput::Quiet { + pytest_cmd.arg("--capture=no"); + } + match output { + CommandOutput::Normal => {} + CommandOutput::Verbose => { + pytest_cmd.arg("-q"); + } + CommandOutput::Quiet => { + pytest_cmd.arg("-v"); + } + } + pytest_cmd.args(&cmd.extra_args); + pytest_cmd + .arg("--rootdir") + .arg(project.root_path().as_os_str()) + .current_dir(project.root_path()); + + // always ignore projects that are nested but not selected. + for path in &project_roots { + if !is_same_file(path, project.root_path()).unwrap_or(false) { + pytest_cmd.arg("--ignore").arg(path.as_os_str()); + } + } + + let status = pytest_cmd.status()?; + if !status.success() { + failed_with = Some(status.code().unwrap_or(1)); + } + } + + if let Some(code) = failed_with { + Err(Error::new(QuietExit(code))) + } else { + Ok(()) + } +} + +/// Does any of those projects have a pytest dependency? +fn has_pytest_dependency(projects: &[PyProject]) -> Result { + for project in projects { + for dep in project + .iter_dependencies(DependencyKind::Dev) + .chain(project.iter_dependencies(DependencyKind::Normal)) + { + if let Ok(req) = dep.expand(|name| std::env::var(name).ok()) { + if normalize_package_name(&req.name) == "pytest" { + return Ok(true); + } + } + } + } + Ok(false) +} diff --git a/rye/src/pyproject.rs b/rye/src/pyproject.rs index 3e5f4f6baa..73dc748fa6 100644 --- a/rye/src/pyproject.rs +++ b/rye/src/pyproject.rs @@ -1412,5 +1412,8 @@ pub fn locate_projects( } } } + + projects.sort_by(|a, b| a.name().cmp(&b.name())); + Ok(projects) } diff --git a/rye/tests/test_test.rs b/rye/tests/test_test.rs new file mode 100644 index 0000000000..89111b60a5 --- /dev/null +++ b/rye/tests/test_test.rs @@ -0,0 +1,152 @@ +use std::fs; + +use insta::Settings; +use toml_edit::{value, Array}; + +use crate::common::{rye_cmd_snapshot, Space}; + +mod common; + +const BASIC_TEST: &str = r#" +def test_okay(): + pass + +def test_fail(): + 1 / 0 +"#; + +// pytest for weird reasons has different formatting behavior on +// different platforms -.- +#[cfg(windows)] +const PYTEST_COLS: &str = "80"; +#[cfg(unix)] +const PYTEST_COLS: &str = "79"; + +#[test] +fn test_basic_tool_behavior() { + // fixes issues for rendering between platforms + let mut settings = Settings::clone_current(); + settings.add_filter(r"(?m)^(platform )(.*?)( --)", "$1[PLATFORM]$3"); + settings.add_filter(r"(?m)\s+(\[\d+%\])\s*?$", " $1"); + let _guard = settings.bind_to_scope(); + + let space = Space::new(); + space.init("foo"); + space.edit_toml("pyproject.toml", |doc| { + let mut deps = Array::new(); + deps.push("pytest>=7.0.0"); + deps.push("colorama==0.4.6"); + let mut workspace_members = Array::new(); + workspace_members.push("."); + workspace_members.push("child-dep"); + doc["tool"]["rye"]["dev-dependencies"] = value(deps); + doc["tool"]["rye"]["workspace"]["members"] = value(workspace_members); + }); + let status = space + .rye_cmd() + .arg("init") + .arg("-q") + .arg(space.project_path().join("child-dep")) + .status() + .unwrap(); + assert!(status.success()); + + let root_tests = space.project_path().join("tests"); + fs::create_dir_all(&root_tests).unwrap(); + fs::write(root_tests.join("test_foo.py"), BASIC_TEST).unwrap(); + + let child_tests = space.project_path().join("child-dep").join("tests"); + fs::create_dir_all(&child_tests).unwrap(); + fs::write(child_tests.join("test_child.py"), BASIC_TEST).unwrap(); + + rye_cmd_snapshot!(space.rye_cmd().arg("test").env("COLUMNS", PYTEST_COLS), @r###" + success: false + exit_code: 1 + ----- stdout ----- + Initializing new virtualenv in [TEMP_PATH]/project/.venv + Python version: cpython@3.12.2 + Generating production lockfile: [TEMP_PATH]/project/requirements.lock + Generating dev lockfile: [TEMP_PATH]/project/requirements-dev.lock + Installing dependencies + Done! + Running tests for foo ([TEMP_PATH]/project) + ============================= test session starts ============================= + platform [PLATFORM] -- Python 3.12.2, pytest-7.4.3, pluggy-1.3.0 + rootdir: [TEMP_PATH]/project + collected 2 items + + tests/test_foo.py .F [100%] + + ================================== FAILURES =================================== + __________________________________ test_fail __________________________________ + + def test_fail(): + > 1 / 0 + E ZeroDivisionError: division by zero + + tests/test_foo.py:6: ZeroDivisionError + =========================== short test summary info =========================== + FAILED tests/test_foo.py::test_fail - ZeroDivisionError: division by zero + ========================= 1 failed, 1 passed in [EXECUTION_TIME] ========================= + + ----- stderr ----- + Built 2 editables in [EXECUTION_TIME] + Resolved 3 packages in [EXECUTION_TIME] + Downloaded 3 packages in [EXECUTION_TIME] + Installed 7 packages in [EXECUTION_TIME] + + child-dep==0.1.0 (from file:[TEMP_PATH]/project/child-dep) + + colorama==0.4.6 + + foo==0.1.0 (from file:[TEMP_PATH]/project) + + iniconfig==2.0.0 + + packaging==23.2 + + pluggy==1.3.0 + + pytest==7.4.3 + "###); + + rye_cmd_snapshot!(space.rye_cmd().arg("test").arg("--all").env("COLUMNS", PYTEST_COLS), @r###" + success: false + exit_code: 1 + ----- stdout ----- + Running tests for child-dep ([TEMP_PATH]/project/child-dep) + ============================= test session starts ============================= + platform [PLATFORM] -- Python 3.12.2, pytest-7.4.3, pluggy-1.3.0 + rootdir: [TEMP_PATH]/project/child-dep + collected 2 items + + tests/test_child.py .F [100%] + + ================================== FAILURES =================================== + __________________________________ test_fail __________________________________ + + def test_fail(): + > 1 / 0 + E ZeroDivisionError: division by zero + + tests/test_child.py:6: ZeroDivisionError + =========================== short test summary info =========================== + FAILED tests/test_child.py::test_fail - ZeroDivisionError: division by zero + ========================= 1 failed, 1 passed in [EXECUTION_TIME] ========================= + + Running tests for foo ([TEMP_PATH]/project) + ============================= test session starts ============================= + platform [PLATFORM] -- Python 3.12.2, pytest-7.4.3, pluggy-1.3.0 + rootdir: [TEMP_PATH]/project + collected 2 items + + tests/test_foo.py .F [100%] + + ================================== FAILURES =================================== + __________________________________ test_fail __________________________________ + + def test_fail(): + > 1 / 0 + E ZeroDivisionError: division by zero + + tests/test_foo.py:6: ZeroDivisionError + =========================== short test summary info =========================== + FAILED tests/test_foo.py::test_fail - ZeroDivisionError: division by zero + ========================= 1 failed, 1 passed in [EXECUTION_TIME] ========================= + + ----- stderr ----- + "###); +}