From fdaeb1123a7663064e1db87d74580e3a0e3a7239 Mon Sep 17 00:00:00 2001 From: Michael Luo Date: Mon, 8 Dec 2025 16:40:31 -0500 Subject: [PATCH 1/3] Add support for dependency groups --- public/tach-toml-schema.json | 12 ++ src/commands/check/check_external.rs | 7 +- src/commands/check/check_internal.rs | 2 +- src/commands/helpers/import.rs | 4 +- src/config/external.rs | 3 + src/external/parsing.rs | 216 ++++++++++++++++++++++++++- src/resolvers/package.rs | 17 ++- 7 files changed, 248 insertions(+), 13 deletions(-) diff --git a/public/tach-toml-schema.json b/public/tach-toml-schema.json index 49e356d1..bdb7a724 100644 --- a/public/tach-toml-schema.json +++ b/public/tach-toml-schema.json @@ -249,6 +249,18 @@ "type": "string" }, "description": "List of external dependency names to ignore during checks" + }, + "rename": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of module:name pairs to rename imports (e.g. 'PIL:pillow')" + }, + "include_dependency_groups": { + "type": "boolean", + "default": false, + "description": "Include dependencies from PEP 735 [dependency-groups] in pyproject.toml" } }, "additionalProperties": false diff --git a/src/commands/check/check_external.rs b/src/commands/check/check_external.rs index a990c6b8..dfe4576a 100644 --- a/src/commands/check/check_external.rs +++ b/src/commands/check/check_external.rs @@ -196,7 +196,12 @@ fn check_with_modules( )?; let source_root_resolver = SourceRootResolver::new(project_root, &file_walker); let source_roots: Vec = source_root_resolver.resolve(&project_config.source_roots)?; - let package_resolver = PackageResolver::try_new(project_root, &source_roots, &file_walker)?; + let package_resolver = PackageResolver::try_new( + project_root, + &source_roots, + &file_walker, + project_config.external.include_dependency_groups, + )?; let module_tree_builder = ModuleTreeBuilder::new( &source_roots, &file_walker, diff --git a/src/commands/check/check_internal.rs b/src/commands/check/check_internal.rs index 8f88f3a9..75f3277e 100644 --- a/src/commands/check/check_internal.rs +++ b/src/commands/check/check_internal.rs @@ -140,7 +140,7 @@ pub fn check( )?; let source_root_resolver = SourceRootResolver::new(project_root, &file_walker); let source_roots = source_root_resolver.resolve(&project_config.source_roots)?; - let package_resolver = PackageResolver::try_new(project_root, &source_roots, &file_walker)?; + let package_resolver = PackageResolver::try_new(project_root, &source_roots, &file_walker, false)?; let module_tree_builder = ModuleTreeBuilder::new( &source_roots, &file_walker, diff --git a/src/commands/helpers/import.rs b/src/commands/helpers/import.rs index 6c652aa6..2ad4c0d9 100644 --- a/src/commands/helpers/import.rs +++ b/src/commands/helpers/import.rs @@ -54,7 +54,7 @@ pub fn get_located_project_imports>( &project_config.exclude, project_config.respect_gitignore, )?; - let package_resolver = PackageResolver::try_new(project_root, source_roots, &file_walker)?; + let package_resolver = PackageResolver::try_new(project_root, source_roots, &file_walker, false)?; let package = match package_resolver.resolve_file_path(file_path.as_ref()) { PackageResolution::Found { package, .. } => package, PackageResolution::NotFound | PackageResolution::Excluded => { @@ -115,7 +115,7 @@ pub fn get_located_external_imports>( &project_config.exclude, project_config.respect_gitignore, )?; - let package_resolver = PackageResolver::try_new(project_root, source_roots, &file_walker)?; + let package_resolver = PackageResolver::try_new(project_root, source_roots, &file_walker, false)?; let package = match package_resolver.resolve_file_path(file_path.as_ref()) { PackageResolution::Found { package, .. } => package, PackageResolution::NotFound | PackageResolution::Excluded => { diff --git a/src/config/external.rs b/src/config/external.rs index 5eac0e98..9592f4c5 100644 --- a/src/config/external.rs +++ b/src/config/external.rs @@ -1,5 +1,6 @@ use pyo3::prelude::*; use serde::{Deserialize, Serialize}; +use std::ops::Not; #[derive(Debug, Serialize, Default, Deserialize, Clone, PartialEq)] #[pyclass(get_all, module = "tach.extension")] @@ -8,4 +9,6 @@ pub struct ExternalDependencyConfig { pub exclude: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rename: Vec, + #[serde(default, skip_serializing_if = "Not::not")] + pub include_dependency_groups: bool, } diff --git a/src/external/parsing.rs b/src/external/parsing.rs index a4485022..bb6e1249 100644 --- a/src/external/parsing.rs +++ b/src/external/parsing.rs @@ -13,11 +13,14 @@ pub struct ProjectInfo { pub source_paths: Vec, } -pub fn parse_pyproject_toml(pyproject_path: &Path) -> Result { +pub fn parse_pyproject_toml( + pyproject_path: &Path, + include_dependency_groups: bool, +) -> Result { let content = fs::read_to_string(pyproject_path)?; let toml_value: Value = toml::from_str(&content)?; let name = extract_project_name(&toml_value); - let dependencies = extract_dependencies(&toml_value); + let dependencies = extract_dependencies(&toml_value, include_dependency_groups); let source_paths = extract_source_paths(&toml_value, pyproject_path.parent().unwrap()); Ok(ProjectInfo { name, @@ -34,7 +37,7 @@ fn extract_project_name(toml_value: &Value) -> Option { .map(|s| s.to_string()) } -fn extract_dependencies(toml_value: &Value) -> HashSet { +fn extract_dependencies(toml_value: &Value, include_dependency_groups: bool) -> HashSet { let mut dependencies = HashSet::new(); // Extract dependencies from standard pyproject.toml format @@ -68,6 +71,16 @@ fn extract_dependencies(toml_value: &Value) -> HashSet { } } + // Extract PEP 735 dependency groups if enabled + if include_dependency_groups { + if let Some(groups) = toml_value + .get("dependency-groups") + .and_then(|g| g.as_table()) + { + extract_dependency_groups(&mut dependencies, groups); + } + } + dependencies } @@ -95,6 +108,59 @@ fn extract_deps_from_value(dependencies: &mut HashSet, deps: &Value) { } } +/// Extracts dependencies from PEP 735 [dependency-groups] table. +/// Handles both direct package names and {include-group = "..."} references. +fn extract_dependency_groups( + dependencies: &mut HashSet, + groups: &toml::map::Map, +) { + let mut visited = HashSet::new(); + for group_name in groups.keys() { + extract_group_deps(dependencies, groups, group_name, &mut visited); + } +} + +/// Recursively extracts dependencies from a single group, resolving include-group references. +fn extract_group_deps( + dependencies: &mut HashSet, + groups: &toml::map::Map, + group_name: &str, + visited: &mut HashSet, +) { + if !visited.insert(group_name.to_string()) { + return; + } + + let Some(group_value) = groups.get(group_name) else { + return; + }; + + let Some(group_array) = group_value.as_array() else { + return; + }; + + const EXCLUDED_DEPS: [&str; 3] = ["python", "poetry", "poetry-core"]; + + for item in group_array { + match item { + // Direct package name: "pytest>7" + Value::String(dep_str) => { + let pkg_name = normalize_package_name(&extract_package_name(dep_str)); + if !EXCLUDED_DEPS.contains(&pkg_name.as_str()) { + dependencies.insert(pkg_name); + } + } + // Include group reference: {include-group = "test"} + Value::Table(table) => { + if let Some(included_group) = table.get("include-group").and_then(|v| v.as_str()) { + extract_group_deps(dependencies, groups, included_group, visited); + } + } + _ => {} + } + } +} + fn extract_package_name(dep_str: &str) -> String { // Split on common separators and take the first part dep_str @@ -195,3 +261,147 @@ pub fn parse_requirements_txt(requirements_path: &Path) -> Result NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(content.as_bytes()).unwrap(); + file + } + + #[test] + fn test_dependency_groups_disabled_by_default() { + let content = r#" +[project] +name = "test" +dependencies = ["requests"] + +[dependency-groups] +test = ["pytest", "coverage"] +dev = ["ruff", "mypy"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path(), false).unwrap(); + + assert!(result.dependencies.contains("requests")); + assert!(!result.dependencies.contains("pytest")); + assert!(!result.dependencies.contains("coverage")); + assert!(!result.dependencies.contains("ruff")); + assert!(!result.dependencies.contains("mypy")); + } + + #[test] + fn test_dependency_groups_enabled() { + let content = r#" +[project] +name = "test" +dependencies = ["requests"] + +[dependency-groups] +test = ["pytest>=7", "coverage[toml]"] +dev = ["ruff~=0.1", "mypy"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path(), true).unwrap(); + + assert!(result.dependencies.contains("requests")); + assert!(result.dependencies.contains("pytest")); + assert!(result.dependencies.contains("coverage")); + assert!(result.dependencies.contains("ruff")); + assert!(result.dependencies.contains("mypy")); + } + + #[test] + fn test_dependency_groups_with_include_group() { + let content = r#" +[project] +name = "test" +dependencies = ["requests"] + +[dependency-groups] +coverage = ["coverage[toml]"] +test = ["pytest", {include-group = "coverage"}] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path(), true).unwrap(); + + assert!(result.dependencies.contains("requests")); + assert!(result.dependencies.contains("pytest")); + assert!(result.dependencies.contains("coverage")); + } + + #[test] + fn test_dependency_groups_with_transitive_include() { + let content = r#" +[project] +name = "test" + +[dependency-groups] +base = ["base-pkg"] +mid = ["mid-pkg", {include-group = "base"}] +top = ["top-pkg", {include-group = "mid"}] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path(), true).unwrap(); + + assert!(result.dependencies.contains("base_pkg")); + assert!(result.dependencies.contains("mid_pkg")); + assert!(result.dependencies.contains("top_pkg")); + } + + #[test] + fn test_dependency_groups_cycle_detection() { + let content = r#" +[project] +name = "test" + +[dependency-groups] +a = ["pkg-a", {include-group = "b"}] +b = ["pkg-b", {include-group = "a"}] +"#; + let file = create_temp_pyproject(content); + // Should not infinite loop + let result = parse_pyproject_toml(file.path(), true).unwrap(); + + assert!(result.dependencies.contains("pkg_a")); + assert!(result.dependencies.contains("pkg_b")); + } + + #[test] + fn test_dependency_groups_missing_include_group() { + let content = r#" +[project] +name = "test" + +[dependency-groups] +test = ["pytest", {include-group = "nonexistent"}] +"#; + let file = create_temp_pyproject(content); + // Should not fail, just skip the missing group + let result = parse_pyproject_toml(file.path(), true).unwrap(); + + assert!(result.dependencies.contains("pytest")); + } + + #[test] + fn test_dependency_groups_normalizes_names() { + let content = r#" +[project] +name = "test" + +[dependency-groups] +test = ["My-Package", "another_package", "UPPER-case"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path(), true).unwrap(); + + assert!(result.dependencies.contains("my_package")); + assert!(result.dependencies.contains("another_package")); + assert!(result.dependencies.contains("upper_case")); + } +} diff --git a/src/resolvers/package.rs b/src/resolvers/package.rs index f4e78f7d..be224a09 100644 --- a/src/resolvers/package.rs +++ b/src/resolvers/package.rs @@ -100,13 +100,17 @@ pub struct Package { pub dependencies: HashSet, } -impl TryFrom for Package { - type Error = PackageResolutionError; - - fn try_from(value: PackageRoot) -> std::result::Result { +impl Package { + fn from_package_root( + value: PackageRoot, + include_dependency_groups: bool, + ) -> Result { match value { PackageRoot::Pyproject(path) => { - let project_info = parsing::parse_pyproject_toml(&path.join("pyproject.toml"))?; + let project_info = parsing::parse_pyproject_toml( + &path.join("pyproject.toml"), + include_dependency_groups, + )?; Ok(Self { name: project_info.name, @@ -170,12 +174,13 @@ impl<'a> PackageResolver<'a> { project_root: &'a PathBuf, source_roots: &'a [PathBuf], file_walker: &'a filesystem::FSWalker, + include_dependency_groups: bool, ) -> Result { let package_for_source_root = source_roots .iter() .map(|source_root| { let package_root = find_package_root(project_root, source_root)?; - let mut package: Package = package_root.try_into()?; + let mut package = Package::from_package_root(package_root, include_dependency_groups)?; package.set_source_roots(source_roots.to_vec()); Ok((source_root.clone(), package)) }) From aa291536b6a973d4ba6ce093bab89cc32bc141c6 Mon Sep 17 00:00:00 2001 From: Michael Luo Date: Thu, 11 Dec 2025 16:16:39 -0500 Subject: [PATCH 2/3] Change to pyproject.toml, fix comments --- docs/usage/configuration.md | 24 +++ public/tach-toml-schema.json | 5 - .../example/many_features/pyproject.toml | 7 +- src/commands/check/check_external.rs | 7 +- src/commands/check/check_internal.rs | 2 +- src/commands/helpers/import.rs | 4 +- src/config/external.rs | 3 - src/external/error.rs | 7 + src/external/parsing.rs | 201 ++++++++++++++---- src/resolvers/package.rs | 13 +- uv.lock | 2 +- 11 files changed, 206 insertions(+), 69 deletions(-) diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index d161bf06..03ecf566 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -586,6 +586,30 @@ In most cases you should not need to specify `rename` manually (see the Note bel distribution metadata to map module names like 'git' to their distributions ('GitPython'). +### Dependency Groups (PEP 735) + +Tach supports [PEP 735 dependency groups](https://peps.python.org/pep-0735/). By default, Tach will include dependencies from the `dev` dependency group when checking external dependencies. + +To configure which dependency groups to include, add `[tool.tach.external]` to your `pyproject.toml`: + +```toml +# In pyproject.toml +[dependency-groups] +dev = ["ruff", "mypy"] +test = ["pytest", "coverage"] + +[tool.tach.external] +include_dependency_groups = ["dev", "test"] +``` + +This configuration is read from each package's `pyproject.toml`, making it suitable for monorepos where different packages may have different dependency groups. + +Special values: +- `["all"]` - include all dependency groups +- `[]` - disable dependency groups entirely (only use `[project.dependencies]`) + +If `[tool.tach.external]` is not specified, Tach defaults to `["dev"]`. + ## Rules Tach allows configuring the severity of certain issues. Each entry in the `rules` table can be set to `error`, `warn`, or `off`. diff --git a/public/tach-toml-schema.json b/public/tach-toml-schema.json index bdb7a724..998c6cfa 100644 --- a/public/tach-toml-schema.json +++ b/public/tach-toml-schema.json @@ -256,11 +256,6 @@ "type": "string" }, "description": "List of module:name pairs to rename imports (e.g. 'PIL:pillow')" - }, - "include_dependency_groups": { - "type": "boolean", - "default": false, - "description": "Include dependencies from PEP 735 [dependency-groups] in pyproject.toml" } }, "additionalProperties": false diff --git a/python/tests/example/many_features/pyproject.toml b/python/tests/example/many_features/pyproject.toml index 9303b6f7..a5668228 100644 --- a/python/tests/example/many_features/pyproject.toml +++ b/python/tests/example/many_features/pyproject.toml @@ -2,7 +2,6 @@ name = "many_features" version = "0.0.1" dependencies = [ - "pyyaml~=6.0", "tomli>=1.2.2", "tomli-w~=1.0", "rich~=13.0", @@ -14,6 +13,9 @@ dependencies = [ "importlib_metadata>=6.0; python_version == '3.7'", ] +[dependency-groups] +test_dep_groups = ["pyyaml~=6.0"] + [build-system] requires = ["maturin>=1.5,<2.0"] build-backend = "maturin" @@ -22,3 +24,6 @@ build-backend = "maturin" python-source = "real_src" module-name = "tach.extension" features = ["pyo3/extension-module"] + +[tool.tach.external] +include_dependency_groups = ["test_dep_groups"] diff --git a/src/commands/check/check_external.rs b/src/commands/check/check_external.rs index dfe4576a..a990c6b8 100644 --- a/src/commands/check/check_external.rs +++ b/src/commands/check/check_external.rs @@ -196,12 +196,7 @@ fn check_with_modules( )?; let source_root_resolver = SourceRootResolver::new(project_root, &file_walker); let source_roots: Vec = source_root_resolver.resolve(&project_config.source_roots)?; - let package_resolver = PackageResolver::try_new( - project_root, - &source_roots, - &file_walker, - project_config.external.include_dependency_groups, - )?; + let package_resolver = PackageResolver::try_new(project_root, &source_roots, &file_walker)?; let module_tree_builder = ModuleTreeBuilder::new( &source_roots, &file_walker, diff --git a/src/commands/check/check_internal.rs b/src/commands/check/check_internal.rs index 75f3277e..8f88f3a9 100644 --- a/src/commands/check/check_internal.rs +++ b/src/commands/check/check_internal.rs @@ -140,7 +140,7 @@ pub fn check( )?; let source_root_resolver = SourceRootResolver::new(project_root, &file_walker); let source_roots = source_root_resolver.resolve(&project_config.source_roots)?; - let package_resolver = PackageResolver::try_new(project_root, &source_roots, &file_walker, false)?; + let package_resolver = PackageResolver::try_new(project_root, &source_roots, &file_walker)?; let module_tree_builder = ModuleTreeBuilder::new( &source_roots, &file_walker, diff --git a/src/commands/helpers/import.rs b/src/commands/helpers/import.rs index 2ad4c0d9..6c652aa6 100644 --- a/src/commands/helpers/import.rs +++ b/src/commands/helpers/import.rs @@ -54,7 +54,7 @@ pub fn get_located_project_imports>( &project_config.exclude, project_config.respect_gitignore, )?; - let package_resolver = PackageResolver::try_new(project_root, source_roots, &file_walker, false)?; + let package_resolver = PackageResolver::try_new(project_root, source_roots, &file_walker)?; let package = match package_resolver.resolve_file_path(file_path.as_ref()) { PackageResolution::Found { package, .. } => package, PackageResolution::NotFound | PackageResolution::Excluded => { @@ -115,7 +115,7 @@ pub fn get_located_external_imports>( &project_config.exclude, project_config.respect_gitignore, )?; - let package_resolver = PackageResolver::try_new(project_root, source_roots, &file_walker, false)?; + let package_resolver = PackageResolver::try_new(project_root, source_roots, &file_walker)?; let package = match package_resolver.resolve_file_path(file_path.as_ref()) { PackageResolution::Found { package, .. } => package, PackageResolution::NotFound | PackageResolution::Excluded => { diff --git a/src/config/external.rs b/src/config/external.rs index 9592f4c5..5eac0e98 100644 --- a/src/config/external.rs +++ b/src/config/external.rs @@ -1,6 +1,5 @@ use pyo3::prelude::*; use serde::{Deserialize, Serialize}; -use std::ops::Not; #[derive(Debug, Serialize, Default, Deserialize, Clone, PartialEq)] #[pyclass(get_all, module = "tach.extension")] @@ -9,6 +8,4 @@ pub struct ExternalDependencyConfig { pub exclude: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rename: Vec, - #[serde(default, skip_serializing_if = "Not::not")] - pub include_dependency_groups: bool, } diff --git a/src/external/error.rs b/src/external/error.rs index 2aeb630f..0d5efa27 100644 --- a/src/external/error.rs +++ b/src/external/error.rs @@ -13,4 +13,11 @@ pub enum ParsingError { TomlParse(#[from] toml::de::Error), #[error("Missing field in TOML: {0}")] MissingField(String), + #[error("Dependency group '{included}' included from '{from_group}' does not exist")] + MissingDependencyGroup { + included: String, + from_group: String, + }, + #[error("Circular dependency group reference: '{group}' includes itself")] + CircularDependencyGroup { group: String }, } diff --git a/src/external/parsing.rs b/src/external/parsing.rs index bb6e1249..4c093746 100644 --- a/src/external/parsing.rs +++ b/src/external/parsing.rs @@ -13,14 +13,12 @@ pub struct ProjectInfo { pub source_paths: Vec, } -pub fn parse_pyproject_toml( - pyproject_path: &Path, - include_dependency_groups: bool, -) -> Result { +pub fn parse_pyproject_toml(pyproject_path: &Path) -> Result { let content = fs::read_to_string(pyproject_path)?; let toml_value: Value = toml::from_str(&content)?; let name = extract_project_name(&toml_value); - let dependencies = extract_dependencies(&toml_value, include_dependency_groups); + let include_dependency_groups = extract_tach_include_dependency_groups(&toml_value); + let dependencies = extract_dependencies(&toml_value, &include_dependency_groups)?; let source_paths = extract_source_paths(&toml_value, pyproject_path.parent().unwrap()); Ok(ProjectInfo { name, @@ -29,6 +27,22 @@ pub fn parse_pyproject_toml( }) } +fn extract_tach_include_dependency_groups(toml_value: &Value) -> Vec { + toml_value + .get("tool") + .and_then(|t| t.get("tach")) + .and_then(|t| t.get("external")) + .and_then(|e| e.get("include_dependency_groups")) + .and_then(|g| g.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_else(|| vec!["dev".to_string()]) +} + fn extract_project_name(toml_value: &Value) -> Option { toml_value .get("project") @@ -37,7 +51,10 @@ fn extract_project_name(toml_value: &Value) -> Option { .map(|s| s.to_string()) } -fn extract_dependencies(toml_value: &Value, include_dependency_groups: bool) -> HashSet { +fn extract_dependencies( + toml_value: &Value, + include_dependency_groups: &[String], +) -> Result> { let mut dependencies = HashSet::new(); // Extract dependencies from standard pyproject.toml format @@ -71,17 +88,17 @@ fn extract_dependencies(toml_value: &Value, include_dependency_groups: bool) -> } } - // Extract PEP 735 dependency groups if enabled - if include_dependency_groups { + // Extract PEP 735 dependency groups + if !include_dependency_groups.is_empty() { if let Some(groups) = toml_value .get("dependency-groups") .and_then(|g| g.as_table()) { - extract_dependency_groups(&mut dependencies, groups); + extract_dependency_groups(&mut dependencies, groups, include_dependency_groups)?; } } - dependencies + Ok(dependencies) } fn extract_deps_from_value(dependencies: &mut HashSet, deps: &Value) { @@ -110,14 +127,25 @@ fn extract_deps_from_value(dependencies: &mut HashSet, deps: &Value) { /// Extracts dependencies from PEP 735 [dependency-groups] table. /// Handles both direct package names and {include-group = "..."} references. +/// If `include_groups` contains "all", all groups are processed. +/// Otherwise, only the specified groups are processed. fn extract_dependency_groups( dependencies: &mut HashSet, groups: &toml::map::Map, -) { - let mut visited = HashSet::new(); - for group_name in groups.keys() { - extract_group_deps(dependencies, groups, group_name, &mut visited); + include_groups: &[String], +) -> Result<()> { + let include_all = include_groups.iter().any(|g| g == "all"); + let groups_to_process: Vec<&str> = if include_all { + groups.keys().map(|s| s.as_str()).collect() + } else { + include_groups.iter().map(|s| s.as_str()).collect() + }; + + for group_name in groups_to_process { + let mut visited = HashSet::new(); + extract_group_deps(dependencies, groups, group_name, &mut visited)?; } + Ok(()) } /// Recursively extracts dependencies from a single group, resolving include-group references. @@ -126,39 +154,42 @@ fn extract_group_deps( groups: &toml::map::Map, group_name: &str, visited: &mut HashSet, -) { +) -> Result<()> { if !visited.insert(group_name.to_string()) { - return; + return Err(error::ParsingError::CircularDependencyGroup { + group: group_name.to_string(), + }); } let Some(group_value) = groups.get(group_name) else { - return; + return Ok(()); }; let Some(group_array) = group_value.as_array() else { - return; + return Ok(()); }; - const EXCLUDED_DEPS: [&str; 3] = ["python", "poetry", "poetry-core"]; - for item in group_array { match item { - // Direct package name: "pytest>7" Value::String(dep_str) => { let pkg_name = normalize_package_name(&extract_package_name(dep_str)); - if !EXCLUDED_DEPS.contains(&pkg_name.as_str()) { - dependencies.insert(pkg_name); - } + dependencies.insert(pkg_name); } - // Include group reference: {include-group = "test"} Value::Table(table) => { if let Some(included_group) = table.get("include-group").and_then(|v| v.as_str()) { - extract_group_deps(dependencies, groups, included_group, visited); + if !groups.contains_key(included_group) { + return Err(error::ParsingError::MissingDependencyGroup { + included: included_group.to_string(), + from_group: group_name.to_string(), + }); + } + extract_group_deps(dependencies, groups, included_group, visited)?; } } _ => {} } } + Ok(()) } fn extract_package_name(dep_str: &str) -> String { @@ -275,7 +306,7 @@ mod tests { } #[test] - fn test_dependency_groups_disabled_by_default() { + fn test_dependency_groups_empty_list() { let content = r#" [project] name = "test" @@ -284,9 +315,12 @@ dependencies = ["requests"] [dependency-groups] test = ["pytest", "coverage"] dev = ["ruff", "mypy"] + +[tool.tach.external] +include_dependency_groups = [] "#; let file = create_temp_pyproject(content); - let result = parse_pyproject_toml(file.path(), false).unwrap(); + let result = parse_pyproject_toml(file.path()).unwrap(); assert!(result.dependencies.contains("requests")); assert!(!result.dependencies.contains("pytest")); @@ -296,7 +330,29 @@ dev = ["ruff", "mypy"] } #[test] - fn test_dependency_groups_enabled() { + fn test_dependency_groups_default_dev() { + let content = r#" +[project] +name = "test" +dependencies = ["requests"] + +[dependency-groups] +test = ["pytest", "coverage"] +dev = ["ruff", "mypy"] +"#; + let file = create_temp_pyproject(content); + // No [tool.tach.external] section, should default to ["dev"] + let result = parse_pyproject_toml(file.path()).unwrap(); + + assert!(result.dependencies.contains("requests")); + assert!(!result.dependencies.contains("pytest")); + assert!(!result.dependencies.contains("coverage")); + assert!(result.dependencies.contains("ruff")); + assert!(result.dependencies.contains("mypy")); + } + + #[test] + fn test_dependency_groups_specific_group() { let content = r#" [project] name = "test" @@ -305,9 +361,36 @@ dependencies = ["requests"] [dependency-groups] test = ["pytest>=7", "coverage[toml]"] dev = ["ruff~=0.1", "mypy"] + +[tool.tach.external] +include_dependency_groups = ["test"] "#; let file = create_temp_pyproject(content); - let result = parse_pyproject_toml(file.path(), true).unwrap(); + let result = parse_pyproject_toml(file.path()).unwrap(); + + assert!(result.dependencies.contains("requests")); + assert!(result.dependencies.contains("pytest")); + assert!(result.dependencies.contains("coverage")); + assert!(!result.dependencies.contains("ruff")); + assert!(!result.dependencies.contains("mypy")); + } + + #[test] + fn test_dependency_groups_all() { + let content = r#" +[project] +name = "test" +dependencies = ["requests"] + +[dependency-groups] +test = ["pytest>=7", "coverage[toml]"] +dev = ["ruff~=0.1", "mypy"] + +[tool.tach.external] +include_dependency_groups = ["all"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path()).unwrap(); assert!(result.dependencies.contains("requests")); assert!(result.dependencies.contains("pytest")); @@ -326,9 +409,12 @@ dependencies = ["requests"] [dependency-groups] coverage = ["coverage[toml]"] test = ["pytest", {include-group = "coverage"}] + +[tool.tach.external] +include_dependency_groups = ["test"] "#; let file = create_temp_pyproject(content); - let result = parse_pyproject_toml(file.path(), true).unwrap(); + let result = parse_pyproject_toml(file.path()).unwrap(); assert!(result.dependencies.contains("requests")); assert!(result.dependencies.contains("pytest")); @@ -345,9 +431,12 @@ name = "test" base = ["base-pkg"] mid = ["mid-pkg", {include-group = "base"}] top = ["top-pkg", {include-group = "mid"}] + +[tool.tach.external] +include_dependency_groups = ["top"] "#; let file = create_temp_pyproject(content); - let result = parse_pyproject_toml(file.path(), true).unwrap(); + let result = parse_pyproject_toml(file.path()).unwrap(); assert!(result.dependencies.contains("base_pkg")); assert!(result.dependencies.contains("mid_pkg")); @@ -363,13 +452,17 @@ name = "test" [dependency-groups] a = ["pkg-a", {include-group = "b"}] b = ["pkg-b", {include-group = "a"}] + +[tool.tach.external] +include_dependency_groups = ["a"] "#; let file = create_temp_pyproject(content); - // Should not infinite loop - let result = parse_pyproject_toml(file.path(), true).unwrap(); + let result = parse_pyproject_toml(file.path()); - assert!(result.dependencies.contains("pkg_a")); - assert!(result.dependencies.contains("pkg_b")); + assert!(matches!( + result, + Err(super::super::error::ParsingError::CircularDependencyGroup { .. }) + )); } #[test] @@ -380,12 +473,17 @@ name = "test" [dependency-groups] test = ["pytest", {include-group = "nonexistent"}] + +[tool.tach.external] +include_dependency_groups = ["test"] "#; let file = create_temp_pyproject(content); - // Should not fail, just skip the missing group - let result = parse_pyproject_toml(file.path(), true).unwrap(); + let result = parse_pyproject_toml(file.path()); - assert!(result.dependencies.contains("pytest")); + assert!(matches!( + result, + Err(super::super::error::ParsingError::MissingDependencyGroup { .. }) + )); } #[test] @@ -396,12 +494,35 @@ name = "test" [dependency-groups] test = ["My-Package", "another_package", "UPPER-case"] + +[tool.tach.external] +include_dependency_groups = ["test"] "#; let file = create_temp_pyproject(content); - let result = parse_pyproject_toml(file.path(), true).unwrap(); + let result = parse_pyproject_toml(file.path()).unwrap(); assert!(result.dependencies.contains("my_package")); assert!(result.dependencies.contains("another_package")); assert!(result.dependencies.contains("upper_case")); } + + #[test] + fn test_dependency_groups_nonexistent_group_ignored() { + let content = r#" +[project] +name = "test" +dependencies = ["requests"] + +[dependency-groups] +dev = ["ruff"] + +[tool.tach.external] +include_dependency_groups = ["nonexistent"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path()).unwrap(); + + assert!(result.dependencies.contains("requests")); + assert!(!result.dependencies.contains("ruff")); + } } diff --git a/src/resolvers/package.rs b/src/resolvers/package.rs index be224a09..32690e20 100644 --- a/src/resolvers/package.rs +++ b/src/resolvers/package.rs @@ -101,16 +101,10 @@ pub struct Package { } impl Package { - fn from_package_root( - value: PackageRoot, - include_dependency_groups: bool, - ) -> Result { + fn from_package_root(value: PackageRoot) -> Result { match value { PackageRoot::Pyproject(path) => { - let project_info = parsing::parse_pyproject_toml( - &path.join("pyproject.toml"), - include_dependency_groups, - )?; + let project_info = parsing::parse_pyproject_toml(&path.join("pyproject.toml"))?; Ok(Self { name: project_info.name, @@ -174,13 +168,12 @@ impl<'a> PackageResolver<'a> { project_root: &'a PathBuf, source_roots: &'a [PathBuf], file_walker: &'a filesystem::FSWalker, - include_dependency_groups: bool, ) -> Result { let package_for_source_root = source_roots .iter() .map(|source_root| { let package_root = find_package_root(project_root, source_root)?; - let mut package = Package::from_package_root(package_root, include_dependency_groups)?; + let mut package = Package::from_package_root(package_root)?; package.set_source_roots(source_roots.to_vec()); Ok((source_root.clone(), package)) }) diff --git a/uv.lock b/uv.lock index 3cb3b171..2000a538 100644 --- a/uv.lock +++ b/uv.lock @@ -2594,7 +2594,7 @@ wheels = [ [[package]] name = "tach" -version = "0.32.1" +version = "0.32.2" source = { editable = "." } dependencies = [ { name = "gitpython" }, From f4ab210d55d65873d3dc2022787cea034ba0d642 Mon Sep 17 00:00:00 2001 From: Michael Luo Date: Thu, 11 Dec 2025 16:19:41 -0500 Subject: [PATCH 3/3] make it a new version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b59d323c..762d55f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tach" -version = "0.32.2" +version = "0.33.0" authors = [ { name = "Caelean Barnes", email = "caeleanb@gmail.com" }, { name = "Evan Doyle", email = "evanmdoyle@gmail.com" },