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 49e356d1..998c6cfa 100644 --- a/public/tach-toml-schema.json +++ b/public/tach-toml-schema.json @@ -249,6 +249,13 @@ "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')" } }, "additionalProperties": false 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" }, 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/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 a4485022..4c093746 100644 --- a/src/external/parsing.rs +++ b/src/external/parsing.rs @@ -17,7 +17,8 @@ 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); + 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, @@ -26,6 +27,22 @@ pub fn parse_pyproject_toml(pyproject_path: &Path) -> Result { }) } +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") @@ -34,7 +51,10 @@ 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: &[String], +) -> Result> { let mut dependencies = HashSet::new(); // Extract dependencies from standard pyproject.toml format @@ -68,7 +88,17 @@ fn extract_dependencies(toml_value: &Value) -> HashSet { } } - dependencies + // 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, include_dependency_groups)?; + } + } + + Ok(dependencies) } fn extract_deps_from_value(dependencies: &mut HashSet, deps: &Value) { @@ -95,6 +125,73 @@ 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, + 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. +fn extract_group_deps( + dependencies: &mut HashSet, + groups: &toml::map::Map, + group_name: &str, + visited: &mut HashSet, +) -> Result<()> { + if !visited.insert(group_name.to_string()) { + return Err(error::ParsingError::CircularDependencyGroup { + group: group_name.to_string(), + }); + } + + let Some(group_value) = groups.get(group_name) else { + return Ok(()); + }; + + let Some(group_array) = group_value.as_array() else { + return Ok(()); + }; + + for item in group_array { + match item { + Value::String(dep_str) => { + let pkg_name = normalize_package_name(&extract_package_name(dep_str)); + dependencies.insert(pkg_name); + } + Value::Table(table) => { + if let Some(included_group) = table.get("include-group").and_then(|v| v.as_str()) { + 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 { // Split on common separators and take the first part dep_str @@ -195,3 +292,237 @@ 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_empty_list() { + let content = r#" +[project] +name = "test" +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()).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_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" +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()).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")); + 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"}] + +[tool.tach.external] +include_dependency_groups = ["test"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path()).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"}] + +[tool.tach.external] +include_dependency_groups = ["top"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path()).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"}] + +[tool.tach.external] +include_dependency_groups = ["a"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path()); + + assert!(matches!( + result, + Err(super::super::error::ParsingError::CircularDependencyGroup { .. }) + )); + } + + #[test] + fn test_dependency_groups_missing_include_group() { + let content = r#" +[project] +name = "test" + +[dependency-groups] +test = ["pytest", {include-group = "nonexistent"}] + +[tool.tach.external] +include_dependency_groups = ["test"] +"#; + let file = create_temp_pyproject(content); + let result = parse_pyproject_toml(file.path()); + + assert!(matches!( + result, + Err(super::super::error::ParsingError::MissingDependencyGroup { .. }) + )); + } + + #[test] + fn test_dependency_groups_normalizes_names() { + let content = r#" +[project] +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()).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 f4e78f7d..32690e20 100644 --- a/src/resolvers/package.rs +++ b/src/resolvers/package.rs @@ -100,10 +100,8 @@ 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) -> Result { match value { PackageRoot::Pyproject(path) => { let project_info = parsing::parse_pyproject_toml(&path.join("pyproject.toml"))?; @@ -175,7 +173,7 @@ impl<'a> PackageResolver<'a> { .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)?; 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" },