Skip to content

Commit

Permalink
Implement rye test command built on pytest (#847)
Browse files Browse the repository at this point in the history
  • Loading branch information
mitsuhiko authored Mar 7, 2024
1 parent e55be15 commit 0d5b689
Show file tree
Hide file tree
Showing 8 changed files with 377 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!-- released start -->

## 0.27.0
Expand Down
58 changes: 58 additions & 0 deletions docs/guide/commands/test.md
Original file line number Diff line number Diff line change
@@ -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 <PACKAGE>`: Run the test suite of a specific package

* `--pyproject <PYPROJECT_TOML>`: 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')
4 changes: 3 additions & 1 deletion rye-devtools/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions rye-devtools/tests/test_batch.py
Original file line number Diff line number Diff line change
@@ -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")]
3 changes: 3 additions & 0 deletions rye/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod rye;
mod shim;
mod show;
mod sync;
mod test;
mod toolchain;
mod tools;
mod uninstall;
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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),
Expand Down
151 changes: 151 additions & 0 deletions rye/src/cli/test.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// Use this pyproject.toml file
#[arg(long, value_name = "PYPROJECT_TOML")]
pyproject: Option<PathBuf>,
// 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<OsString>,
}

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("<unknown>")).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<bool, Error> {
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)
}
3 changes: 3 additions & 0 deletions rye/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1412,5 +1412,8 @@ pub fn locate_projects(
}
}
}

projects.sort_by(|a, b| a.name().cmp(&b.name()));

Ok(projects)
}
Loading

0 comments on commit 0d5b689

Please sign in to comment.