pyo3_stub_gen/
pyproject.rs

1//! `pyproject.toml` parser for reading `[tool.maturin]` configuration.
2//!
3//! ```
4//! use pyo3_stub_gen::pyproject::PyProject;
5//! use std::path::Path;
6//!
7//! let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
8//! let pyproject = PyProject::parse_toml(
9//!     root.join("examples/mixed/pyproject.toml")
10//! ).unwrap();
11//! ```
12
13use anyhow::{bail, Result};
14use serde::{Deserialize, Serialize};
15use std::{fs, path::*};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct PyProject {
19    pub project: Project,
20    pub tool: Option<Tool>,
21
22    #[serde(skip)]
23    toml_path: PathBuf,
24}
25
26impl PyProject {
27    pub fn parse_toml(path: impl AsRef<Path>) -> Result<Self> {
28        let path = path.as_ref();
29        if path.file_name() != Some("pyproject.toml".as_ref()) {
30            bail!("{} is not a pyproject.toml", path.display())
31        }
32        let mut out: PyProject = toml::de::from_str(&fs::read_to_string(path)?)?;
33        out.toml_path = path.to_path_buf();
34        Ok(out)
35    }
36
37    pub fn module_name(&self) -> &str {
38        if let Some(tool) = &self.tool {
39            if let Some(maturin) = &tool.maturin {
40                if let Some(module_name) = &maturin.module_name {
41                    return module_name;
42                }
43            }
44        }
45        &self.project.name
46    }
47
48    /// Return `tool.maturin.python_source` if it exists, which means the project is a mixed Rust/Python project.
49    pub fn python_source(&self) -> Option<PathBuf> {
50        if let Some(tool) = &self.tool {
51            if let Some(maturin) = &tool.maturin {
52                if let Some(python_source) = &maturin.python_source {
53                    if let Some(base) = self.toml_path.parent() {
54                        return Some(base.join(python_source));
55                    } else {
56                        return Some(PathBuf::from(python_source));
57                    }
58                }
59            }
60        }
61        None
62    }
63
64    /// Return stub generation configuration from `[tool.pyo3-stub-gen]`.
65    /// Returns default configuration if the section is not present.
66    pub fn stub_gen_config(&self) -> StubGenConfig {
67        self.tool
68            .as_ref()
69            .and_then(|t| t.pyo3_stub_gen.clone())
70            .unwrap_or_default()
71    }
72
73    /// Return doc-gen configuration with output_dir resolved relative to pyproject.toml directory
74    pub fn doc_gen_config_resolved(&self) -> Option<crate::docgen::DocGenConfig> {
75        if let Some(mut config) = self.stub_gen_config().doc_gen {
76            // Resolve output_dir relative to pyproject.toml directory
77            // Only resolve if the path is relative (absolute paths stay unchanged)
78            if config.output_dir.is_relative() {
79                if let Some(base) = self.toml_path.parent() {
80                    config.output_dir = base.join(&config.output_dir);
81                }
82            }
83            Some(config)
84        } else {
85            None
86        }
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct Project {
92    pub name: String,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Tool {
97    pub maturin: Option<Maturin>,
98    #[serde(rename = "pyo3-stub-gen")]
99    pub pyo3_stub_gen: Option<StubGenConfig>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Maturin {
104    #[serde(rename = "python-source")]
105    pub python_source: Option<String>,
106    #[serde(rename = "module-name")]
107    pub module_name: Option<String>,
108}
109
110/// Configuration for `__init__.py` generation.
111///
112/// This can be:
113/// - `false` or unset: Disable generation (default)
114/// - `true`: Generate for all modules with re-exports
115/// - `["pkg", "pkg.submod"]`: Generate for specific modules only
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(untagged)]
118pub enum GenerateInitPy {
119    /// Enable/disable for all modules with re-exports
120    All(bool),
121    /// Generate for specific modules only
122    Modules(Vec<String>),
123}
124
125impl Default for GenerateInitPy {
126    fn default() -> Self {
127        GenerateInitPy::All(false)
128    }
129}
130
131impl GenerateInitPy {
132    /// Check if generation is enabled for a given module name.
133    ///
134    /// - `All(true)`: Returns `true` for any module
135    /// - `All(false)`: Returns `false` for any module
136    /// - `Modules(list)`: Returns `true` if the module is in the list
137    ///
138    /// Module names are normalized (dashes to underscores) before comparison
139    /// to match Python's module name requirements.
140    pub fn is_enabled_for(&self, module_name: &str) -> bool {
141        match self {
142            GenerateInitPy::All(enabled) => *enabled,
143            GenerateInitPy::Modules(modules) => {
144                let normalized_name = module_name.replace('-', "_");
145                modules
146                    .iter()
147                    .any(|m| m.replace('-', "_") == normalized_name)
148            }
149        }
150    }
151
152    /// Check if any generation is enabled.
153    pub fn is_enabled(&self) -> bool {
154        match self {
155            GenerateInitPy::All(enabled) => *enabled,
156            GenerateInitPy::Modules(modules) => !modules.is_empty(),
157        }
158    }
159}
160
161/// Configuration options for stub generation from `[tool.pyo3-stub-gen]` in pyproject.toml.
162///
163/// This struct is marked as `#[non_exhaustive]` to allow adding new configuration
164/// options in future versions without breaking backward compatibility.
165#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
166#[non_exhaustive]
167pub struct StubGenConfig {
168    /// Whether to use Python 3.12+ `type` statement syntax for type aliases.
169    /// Default is `false` (use pre-3.12 `TypeAlias` syntax).
170    #[serde(rename = "use-type-statement", default)]
171    pub use_type_statement: bool,
172    /// Documentation generation configuration
173    #[serde(rename = "doc-gen")]
174    pub doc_gen: Option<crate::docgen::DocGenConfig>,
175    /// Configuration for `__init__.py` generation
176    #[serde(rename = "generate-init-py", default)]
177    pub generate_init_py: GenerateInitPy,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_stub_gen_config_true() {
186        let toml_str = r#"
187            [project]
188            name = "test"
189
190            [tool.pyo3-stub-gen]
191            use-type-statement = true
192        "#;
193        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
194        assert!(pyproject.stub_gen_config().use_type_statement);
195    }
196
197    #[test]
198    fn test_stub_gen_config_false() {
199        let toml_str = r#"
200            [project]
201            name = "test"
202
203            [tool.pyo3-stub-gen]
204            use-type-statement = false
205        "#;
206        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
207        assert!(!pyproject.stub_gen_config().use_type_statement);
208    }
209
210    #[test]
211    fn test_stub_gen_config_default() {
212        let toml_str = r#"
213            [project]
214            name = "test"
215        "#;
216        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
217        assert!(!pyproject.stub_gen_config().use_type_statement);
218    }
219
220    #[test]
221    fn test_stub_gen_config_empty_section() {
222        let toml_str = r#"
223            [project]
224            name = "test"
225
226            [tool.pyo3-stub-gen]
227        "#;
228        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
229        assert!(!pyproject.stub_gen_config().use_type_statement);
230    }
231
232    #[test]
233    fn test_generate_init_py_default() {
234        let toml_str = r#"
235            [project]
236            name = "test"
237        "#;
238        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
239        let config = pyproject.stub_gen_config();
240        assert!(!config.generate_init_py.is_enabled());
241        assert!(!config.generate_init_py.is_enabled_for("pkg"));
242    }
243
244    #[test]
245    fn test_generate_init_py_true() {
246        let toml_str = r#"
247            [project]
248            name = "test"
249
250            [tool.pyo3-stub-gen]
251            generate-init-py = true
252        "#;
253        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
254        let config = pyproject.stub_gen_config();
255        assert!(config.generate_init_py.is_enabled());
256        assert!(config.generate_init_py.is_enabled_for("pkg"));
257        assert!(config.generate_init_py.is_enabled_for("any_module"));
258    }
259
260    #[test]
261    fn test_generate_init_py_false() {
262        let toml_str = r#"
263            [project]
264            name = "test"
265
266            [tool.pyo3-stub-gen]
267            generate-init-py = false
268        "#;
269        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
270        let config = pyproject.stub_gen_config();
271        assert!(!config.generate_init_py.is_enabled());
272        assert!(!config.generate_init_py.is_enabled_for("pkg"));
273    }
274
275    #[test]
276    fn test_generate_init_py_modules() {
277        let toml_str = r#"
278            [project]
279            name = "test"
280
281            [tool.pyo3-stub-gen]
282            generate-init-py = ["pkg", "pkg.submod"]
283        "#;
284        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
285        let config = pyproject.stub_gen_config();
286        assert!(config.generate_init_py.is_enabled());
287        assert!(config.generate_init_py.is_enabled_for("pkg"));
288        assert!(config.generate_init_py.is_enabled_for("pkg.submod"));
289        assert!(!config.generate_init_py.is_enabled_for("other"));
290    }
291
292    #[test]
293    fn test_generate_init_py_dash_underscore_normalization() {
294        let toml_str = r#"
295            [project]
296            name = "test"
297
298            [tool.pyo3-stub-gen]
299            generate-init-py = ["my-package", "my_package.sub-mod"]
300        "#;
301        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
302        let config = pyproject.stub_gen_config();
303        // Dashes in config should match underscores in module names
304        assert!(config.generate_init_py.is_enabled_for("my_package"));
305        assert!(config.generate_init_py.is_enabled_for("my-package"));
306        assert!(config.generate_init_py.is_enabled_for("my_package.sub_mod"));
307        assert!(config.generate_init_py.is_enabled_for("my_package.sub-mod"));
308        assert!(!config.generate_init_py.is_enabled_for("other"));
309    }
310
311    #[test]
312    fn test_generate_init_py_empty_modules() {
313        let toml_str = r#"
314            [project]
315            name = "test"
316
317            [tool.pyo3-stub-gen]
318            generate-init-py = []
319        "#;
320        let pyproject: PyProject = toml::from_str(toml_str).unwrap();
321        let config = pyproject.stub_gen_config();
322        assert!(!config.generate_init_py.is_enabled());
323    }
324}