From 106cec2f1a66c767ccb263d4618174a42cb08f0b Mon Sep 17 00:00:00 2001 From: Ben Brown <9870007+brownben@users.noreply.github.com> Date: Sat, 9 Nov 2024 11:32:39 +0000 Subject: [PATCH] read config from pyproject.toml --- Cargo.lock | 75 +++++++++++++++++++++ Cargo.toml | 1 + src/config.rs | 164 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 84 ++++-------------------- src/output/mod.rs | 2 +- 5 files changed, 255 insertions(+), 71 deletions(-) create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 2e6016b..9140e76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,12 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "getopts" version = "0.2.21" @@ -269,6 +275,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" + [[package]] name = "heck" version = "0.5.0" @@ -291,6 +303,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indicatif" version = "0.17.8" @@ -693,6 +715,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -749,6 +780,40 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.13" @@ -920,6 +985,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + [[package]] name = "xc" version = "0.1.0" @@ -935,6 +1009,7 @@ dependencies = [ "ruff_python_parser", "serde", "serde_json", + "toml", "widestring", ] diff --git a/Cargo.toml b/Cargo.toml index f7fdc6b..0f6bf5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ rayon = "1.10.0" ruff_python_parser = { git = "https://github.com/astral-sh/ruff", "rev" = "d3f1c8e" } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" +toml = "0.8.19" widestring = "1.1.0" [dev-dependencies] diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..15b03a1 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,164 @@ +use clap::Parser; + +#[derive(Parser, Debug, Default)] +#[command(version, about, long_about = None)] +pub(crate) struct Settings { + /// List of files or directories to test + #[clap(default_value = ".")] + pub paths: Vec, + + /// List of files or directories to exclude from testing + #[clap(long, value_name = "FILE_PATTERN")] + pub exclude: Vec, + + #[clap(flatten)] + pub coverage: CoverageSettings, + + /// Don't stop executing tests after one has failed + #[clap(long, default_value_t = false)] + pub no_fail_fast: bool, + + /// How test results should be reported + #[clap(long, value_enum, default_value_t = OutputFormat::Standard)] + pub output: OutputFormat, +} + +#[derive(clap::Args, Debug, Default)] +pub(crate) struct CoverageSettings { + /// Enable line coverage gathering and reporting + #[clap(long = "coverage", default_value_t = false)] + pub enabled: bool, + + /// List of paths, used to determine files to report coverage for + #[clap( + name = "coverage-include", + long = "coverage-include", + value_name = "FILE_PATTERN", + help_heading = "Coverage" + )] + pub include: Vec, + + /// List of paths, used to omit files and/or directories from coverage reporting + #[clap( + name = "coverage-exclude", + long = "coverage-exclude", + value_name = "FILE_PATTERN", + help_heading = "Coverage" + )] + pub exclude: Vec, +} + +#[derive(Copy, Clone, Default, Debug, clap::ValueEnum)] +pub(crate) enum OutputFormat { + /// The standard output format to the terminal + #[default] + Standard, + /// Output each test as a JSON object on a new line + Json, +} + +/// Reads settings from command line arguments and `pyproject.toml` +pub fn read_settings() -> Settings { + let mut settings = Settings::parse(); + + if let Some(pyproject_toml) = pyproject_toml::find() { + if let Some(xc_config) = pyproject_toml::load(&pyproject_toml) { + pyproject_toml::update_settings(&mut settings, xc_config); + } + } + + settings +} + +mod pyproject_toml { + use serde::Deserialize; + use std::{ + env, fs, mem, + path::{Path, PathBuf}, + }; + + #[derive(Deserialize, Default)] + struct PyprojectToml { + tool: Option, + } + + #[derive(Deserialize, Default)] + struct PyprojectTomlTool { + xc: Option, + } + + #[derive(Deserialize, Default)] + pub struct XCSettings { + include: Option>, + exclude: Option>, + no_fail_fast: Option, + coverage: Option, + coverage_include: Option>, + coverage_exclude: Option>, + } + + /// Get the path to a `pyproject.toml` file, if one exists in the current tree + pub fn find() -> Option { + let mut path = env::current_dir().unwrap(); + + while path.parent().is_some() { + path.push("./pyproject.toml"); + if path.exists() { + return Some(path); + } + + // Remove the `pyproject.toml` file + path.pop(); + + // Go up to the next folder + path.pop(); + } + + None + } + + pub fn load(path: &Path) -> Option { + let pyproject_file = fs::read_to_string(path).ok()?; + let pyproject = toml::from_str::(&pyproject_file).ok()?; + + pyproject.tool?.xc + } + + pub fn update_settings(settings: &mut super::Settings, mut toml_config: XCSettings) { + if let Some(include) = &mut toml_config.include { + if settings.paths.is_empty() { + settings.paths = mem::take(include); + } + } + + if let Some(exclude) = &mut toml_config.exclude { + if settings.exclude.is_empty() { + settings.exclude = mem::take(exclude); + } + } + + if let Some(no_fail_fast) = toml_config.no_fail_fast { + if !settings.no_fail_fast { + settings.no_fail_fast = no_fail_fast; + } + } + + if let Some(coverage) = toml_config.coverage { + if !settings.coverage.enabled { + settings.coverage.enabled = coverage; + } + } + + if let Some(coverage_include) = &mut toml_config.coverage_include { + if settings.coverage.include.is_empty() { + settings.coverage.include = mem::take(coverage_include); + } + } + + if let Some(coverage_exclude) = &mut toml_config.coverage_exclude { + if settings.coverage.exclude.is_empty() { + settings.coverage.exclude = mem::take(coverage_exclude); + } + } + } +} diff --git a/src/main.rs b/src/main.rs index ad7ff30..576f995 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ #![allow(clippy::unsafe_derive_deserialize)] #![deny(unsafe_code)] +mod config; mod coverage; mod discovery; mod output; @@ -11,7 +12,6 @@ mod run; #[cfg(test)] mod tests; -use clap::Parser; use rayon::prelude::*; use run::TestOutcome; use std::{ @@ -19,70 +19,14 @@ use std::{ time::{Duration, Instant}, }; -#[derive(Parser, Debug)] -#[command(version, about, long_about = None)] -struct Args { - /// List of files or directories to test - #[clap(default_value = ".")] - pub paths: Vec, - - /// List of files or directories to exclude from testing - #[clap(long, value_name = "FILE_PATTERN")] - pub exclude: Vec, - - #[clap(flatten)] - pub coverage: CoverageArgs, - - /// Don't stop executing tests after one has failed - #[clap(long, default_value_t = false)] - pub no_fail_fast: bool, - - /// How test results should be reported - #[clap(long, value_enum, default_value_t = OutputFormat::Standard)] - pub output: OutputFormat, -} - -#[derive(clap::Args, Debug)] -struct CoverageArgs { - /// Enable line coverage gathering and reporting - #[clap(long = "coverage", default_value_t = false)] - pub enabled: bool, - - /// List of paths, used to determine files to report coverage for - #[clap( - name = "coverage-include", - long = "coverage-include", - value_name = "FILE_PATTERN", - help_heading = "Coverage" - )] - pub include: Vec, - - /// List of paths, used to omit files and/or directories from coverage reporting - #[clap( - name = "coverage-exclude", - long = "coverage-exclude", - value_name = "FILE_PATTERN", - help_heading = "Coverage" - )] - pub exclude: Vec, -} - -#[derive(Copy, Clone, Default, Debug, clap::ValueEnum)] -enum OutputFormat { - /// The standard output format to the terminal - #[default] - Standard, - /// Output each test as a JSON object on a new line - Json, -} - fn main() -> ExitCode { - let args = Args::parse(); - let mut reporter = output::new_reporter(args.output); + let settings = config::read_settings(); + + let mut reporter = output::new_reporter(settings.output); reporter.initialize(python::version()); // Discover tests - let discovered = discovery::find_tests(&args.paths, &args.exclude); + let discovered = discovery::find_tests(&settings.paths, &settings.exclude); reporter.discovered(&discovered); // Main Python interpreter must be initialized in the main thread @@ -95,7 +39,7 @@ fn main() -> ExitCode { .map(|test| { let mut subinterpreter = python::SubInterpreter::new(); - if args.coverage.enabled { + if settings.coverage.enabled { subinterpreter.enable_coverage(); } @@ -107,7 +51,7 @@ fn main() -> ExitCode { .inspect(|(outcome, _coverage)| { reporter.result(outcome); - if !args.no_fail_fast && outcome.is_fail() { + if !settings.no_fail_fast && outcome.is_fail() { reporter.fail_fast_error(outcome); process::exit(1); } @@ -119,16 +63,16 @@ fn main() -> ExitCode { let successful = results.failed == 0 && results.passed > 0; - if args.coverage.enabled && successful { - let coverage_include = if args.coverage.include.is_empty() { - &args.paths + if settings.coverage.enabled && successful { + let coverage_include = if settings.coverage.include.is_empty() { + &settings.paths } else { - &args.coverage.include + &settings.coverage.include }; - let coverage_exclude = if args.coverage.exclude.is_empty() { - &args.exclude + let coverage_exclude = if settings.coverage.exclude.is_empty() { + &settings.exclude } else { - &args.coverage.exclude + &settings.coverage.exclude }; let possible_lines = coverage::get_executable_lines(coverage_include, coverage_exclude); diff --git a/src/output/mod.rs b/src/output/mod.rs index e004cd3..0d7e6d4 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,4 +1,4 @@ -use crate::{discovery::DiscoveredTests, run::TestOutcome, OutputFormat, TestSummary}; +use crate::{config::OutputFormat, discovery::DiscoveredTests, run::TestOutcome, TestSummary}; pub trait Reporter: Sync { fn initialize(&mut self, _python_version: String) {}