diff --git a/Cargo.lock b/Cargo.lock index 836fb3f95..e99781972 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,6 +923,7 @@ dependencies = [ "once_cell", "platform-info", "pretty_env_logger", + "pyproject-toml", "regex", "reqwest", "rpassword", @@ -1279,6 +1280,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "pyproject-toml" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e623b250f749d036a4a60f41fdc7bc0c214491a0241c889abdc18fd9ee9c155d" +dependencies = [ + "serde", + "toml", +] + [[package]] name = "quick-error" version = "1.2.3" diff --git a/Cargo.toml b/Cargo.toml index 9130157be..98be3ae80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ toml_edit = "0.2.0" once_cell = "1.7.2" scroll = "0.10.2" target-lexicon = "0.12.0" +pyproject-toml = "0.1.0" [dev-dependencies] indoc = "1.0.3" diff --git a/Changelog.md b/Changelog.md index e8364cbd3..4bdd92f38 100644 --- a/Changelog.md +++ b/Changelog.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unrelease +* Add support for reading metadata from [PEP 621](https://www.python.org/dev/peps/pep-0621/) project table in `pyproject.toml` in [#555](https://github.com/PyO3/maturin/pull/555) +* Users should migrate away from the old `[package.metadata.maturin]` table of `Cargo.toml` to this new `[project]` table of `pyproject.toml` * Add PEP 656 musllinux support in [#543](https://github.com/PyO3/maturin/pull/543) * `--manylinux` is now called `--compatibility` and supports musllinux * The pure rust install layout changed from just the shared library to a python module that reexports the shared library. This should have now observable consequences for users of the created wheel expect that `my_project.my_project` is now also importable (and equal to just `my_project`) diff --git a/src/build_context.rs b/src/build_context.rs index 5cc12e353..c9d238a69 100644 --- a/src/build_context.rs +++ b/src/build_context.rs @@ -14,7 +14,6 @@ use crate::Target; use anyhow::{anyhow, bail, Context, Result}; use cargo_metadata::Metadata; use fs_err as fs; -use std::collections::HashMap; use std::path::{Path, PathBuf}; /// The way the rust code is used in the wheel @@ -127,8 +126,6 @@ pub struct BuildContext { pub project_layout: ProjectLayout, /// Python Package Metadata 2.1 pub metadata21: Metadata21, - /// The `[console_scripts]` for the entry_points.txt - pub scripts: HashMap, /// The name of the crate pub crate_name: String, /// The name of the module can be distinct from the package name, mostly @@ -240,13 +237,7 @@ impl BuildContext { let platform = self.target.get_platform_tag(platform_tag, self.universal2); let tag = format!("cp{}{}-abi3-{}", major, min_minor, platform); - let mut writer = WheelWriter::new( - &tag, - &self.out, - &self.metadata21, - &self.scripts, - &[tag.clone()], - )?; + let mut writer = WheelWriter::new(&tag, &self.out, &self.metadata21, &[tag.clone()])?; write_bindings_module( &mut writer, @@ -301,13 +292,7 @@ impl BuildContext { ) -> Result { let tag = python_interpreter.get_tag(platform_tag, self.universal2); - let mut writer = WheelWriter::new( - &tag, - &self.out, - &self.metadata21, - &self.scripts, - &[tag.clone()], - )?; + let mut writer = WheelWriter::new(&tag, &self.out, &self.metadata21, &[tag.clone()])?; write_bindings_module( &mut writer, @@ -397,8 +382,7 @@ impl BuildContext { .target .get_universal_tags(platform_tag, self.universal2); - let mut builder = - WheelWriter::new(&tag, &self.out, &self.metadata21, &self.scripts, &tags)?; + let mut builder = WheelWriter::new(&tag, &self.out, &self.metadata21, &tags)?; write_cffi_module( &mut builder, @@ -436,12 +420,11 @@ impl BuildContext { .target .get_universal_tags(platform_tag, self.universal2); - if !self.scripts.is_empty() { + if !self.metadata21.scripts.is_empty() { bail!("Defining entrypoints and working with a binary doesn't mix well"); } - let mut builder = - WheelWriter::new(&tag, &self.out, &self.metadata21, &self.scripts, &tags)?; + let mut builder = WheelWriter::new(&tag, &self.out, &self.metadata21, &tags)?; match self.project_layout { ProjectLayout::Mixed { diff --git a/src/build_options.rs b/src/build_options.rs index 10695b110..2ad27da7e 100644 --- a/src/build_options.rs +++ b/src/build_options.rs @@ -133,7 +133,6 @@ impl BuildOptions { let metadata21 = Metadata21::from_cargo_toml(&cargo_toml, &manifest_dir) .context("Failed to parse Cargo.toml into python metadata")?; let extra_metadata = cargo_toml.remaining_core_metadata(); - let scripts = cargo_toml.scripts(); let crate_name = &cargo_toml.package.name; @@ -231,7 +230,6 @@ impl BuildOptions { bridge, project_layout, metadata21, - scripts, crate_name: crate_name.to_string(), module_name, manifest_path: self.manifest_path, diff --git a/src/develop.rs b/src/develop.rs index 25afea5a8..d8125ef77 100644 --- a/src/develop.rs +++ b/src/develop.rs @@ -178,12 +178,7 @@ pub fn develop( } }; - write_dist_info( - &mut writer, - &build_context.metadata21, - &build_context.scripts, - &tags, - )?; + write_dist_info(&mut writer, &build_context.metadata21, &tags)?; // https://packaging.python.org/specifications/recording-installed-packages/#the-installer-file writer.add_bytes( diff --git a/src/main.rs b/src/main.rs index 1fa492e5b..dffbc8d91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -410,7 +410,7 @@ fn pep517(subcommand: Pep517Command) -> Result<()> { }; let mut writer = PathWriter::from_path(metadata_directory); - write_dist_info(&mut writer, &context.metadata21, &context.scripts, &tags)?; + write_dist_info(&mut writer, &context.metadata21, &tags)?; println!("{}", context.metadata21.get_dist_info_dir().display()); } Pep517Command::BuildWheel { diff --git a/src/metadata.rs b/src/metadata.rs index 7b643cfdc..329fc25aa 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,5 +1,5 @@ -use crate::CargoToml; -use anyhow::{Context, Result}; +use crate::{CargoToml, PyProjectToml}; +use anyhow::{bail, Context, Result}; use fs_err as fs; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -52,6 +52,9 @@ pub struct Metadata21 { pub requires_external: Vec, pub project_url: HashMap, pub provides_extra: Vec, + pub scripts: HashMap, + pub gui_scripts: HashMap, + pub entry_points: HashMap>, } const PLAINTEXT_CONTENT_TYPE: &str = "text/plain; charset=UTF-8"; @@ -76,6 +79,176 @@ fn path_to_content_type(path: &Path) -> String { } impl Metadata21 { + /// Merge metadata with pyproject.toml, where pyproject.toml takes precedence + /// + /// manifest_path must be the directory, not the file + fn merge_pyproject_toml(&mut self, manifest_path: impl AsRef) -> Result<()> { + let manifest_path = manifest_path.as_ref(); + if !manifest_path.join("pyproject.toml").is_file() { + return Ok(()); + } + let pyproject_toml = + PyProjectToml::new(manifest_path).context("pyproject.toml is invalid")?; + if let Some(project) = &pyproject_toml.project { + self.name = project.name.clone(); + + if let Some(version) = &project.version { + self.version = version.clone(); + } + + if let Some(description) = &project.description { + self.summary = Some(description.clone()); + } + + match &project.readme { + Some(pyproject_toml::ReadMe::RelativePath(readme_path)) => { + let readme_path = manifest_path.join(readme_path); + let description = Some(fs::read_to_string(&readme_path).context(format!( + "Failed to read readme specified in pyproject.toml, which should be at {}", + readme_path.display() + ))?); + self.description = description; + self.description_content_type = Some(path_to_content_type(&readme_path)); + } + Some(pyproject_toml::ReadMe::Table { + file, + text, + content_type, + }) => { + if file.is_some() && text.is_some() { + bail!("file and text fields of 'project.readme' are mutually-exclusive, only one of them should be specified"); + } + if let Some(readme_path) = file { + let readme_path = manifest_path.join(readme_path); + let description = Some(fs::read_to_string(&readme_path).context(format!( + "Failed to read readme specified in pyproject.toml, which should be at {}", + readme_path.display() + ))?); + self.description = description; + } + if let Some(description) = text { + self.description = Some(description.clone()); + } + self.description_content_type = content_type.clone(); + } + None => {} + } + + if let Some(requires_python) = &project.requires_python { + self.requires_python = Some(requires_python.clone()); + } + + if let Some(pyproject_toml::License { file, text }) = &project.license { + if file.is_some() && text.is_some() { + bail!("file and text fields of 'project.license' are mutually-exclusive, only one of them should be specified"); + } + if let Some(license_path) = file { + let license_path = manifest_path.join(license_path); + self.license = Some(fs::read_to_string(&license_path).context(format!( + "Failed to read license file specified in pyproject.toml, which should be at {}", + license_path.display() + ))?); + } + if let Some(license_text) = text { + self.license = Some(license_text.clone()); + } + } + + if let Some(authors) = &project.authors { + let mut names = Vec::with_capacity(authors.len()); + let mut emails = Vec::with_capacity(authors.len()); + for author in authors { + match (&author.name, &author.email) { + (Some(name), Some(email)) => { + emails.push(format!("{} <{}>", name, email)); + } + (Some(name), None) => { + names.push(name.as_str()); + } + (None, Some(email)) => { + emails.push(email.clone()); + } + (None, None) => {} + } + } + self.author = Some(names.join(", ")); + self.author_email = Some(emails.join(", ")); + } + + if let Some(maintainers) = &project.maintainers { + let mut names = Vec::with_capacity(maintainers.len()); + let mut emails = Vec::with_capacity(maintainers.len()); + for maintainer in maintainers { + match (&maintainer.name, &maintainer.email) { + (Some(name), Some(email)) => { + emails.push(format!("{} <{}>", name, email)); + } + (Some(name), None) => { + names.push(name.as_str()); + } + (None, Some(email)) => { + emails.push(email.clone()); + } + (None, None) => {} + } + } + self.maintainer = Some(names.join(", ")); + self.maintainer_email = Some(emails.join(", ")); + } + + if let Some(keywords) = &project.keywords { + self.keywords = Some(keywords.join(",")); + } + + if let Some(classifiers) = &project.classifiers { + self.classifiers = classifiers.clone(); + } + + if let Some(urls) = &project.urls { + self.project_url = urls.clone(); + } + + if let Some(dependencies) = &project.dependencies { + self.requires_dist = dependencies.clone(); + } + + if let Some(dependencies) = &project.optional_dependencies { + for (extra, deps) in dependencies { + self.provides_extra.push(extra.clone()); + for dep in deps { + let dist = if let Some((dep, marker)) = dep.split_once(';') { + // optional dependency already has environment markers + let new_marker = + format!("({}) and extra == '{}'", marker.trim(), extra); + format!("{}; {}", dep, new_marker) + } else { + format!("{}; extra == '{}'", dep, extra) + }; + self.requires_dist.push(dist); + } + } + } + + if let Some(scripts) = &project.scripts { + self.scripts = scripts.clone(); + } + if let Some(gui_scripts) = &project.gui_scripts { + self.gui_scripts = gui_scripts.clone(); + } + if let Some(entry_points) = &project.entry_points { + // Raise error on ambiguous entry points: https://www.python.org/dev/peps/pep-0621/#entry-points + if entry_points.contains_key("console_scripts") { + bail!("console_scripts is not allowed in project.entry-points table"); + } + if entry_points.contains_key("gui_scripts") { + bail!("gui_scripts is not allowed in project.entry-points table"); + } + self.entry_points = entry_points.clone(); + } + } + Ok(()) + } + /// Uses a Cargo.toml to create the metadata for python packages /// /// manifest_path must be the directory, not the file @@ -123,7 +296,7 @@ impl Metadata21 { }) .unwrap_or_else(|| cargo_toml.package.name.clone()); - Ok(Metadata21 { + let mut metadata = Metadata21 { metadata_version: "2.1".to_owned(), // Mapped from cargo metadata @@ -136,7 +309,7 @@ impl Metadata21 { .package .keywords .clone() - .map(|keywords| keywords.join(" ")), + .map(|keywords| keywords.join(",")), home_page: cargo_toml.package.homepage.clone(), download_url: None, // Cargo.toml has no distinction between author and author email @@ -161,7 +334,12 @@ impl Metadata21 { // Open question: Should those also be supported? And if so, how? platform: Vec::new(), supported_platform: Vec::new(), - }) + scripts: cargo_toml.scripts(), + gui_scripts: HashMap::new(), + entry_points: HashMap::new(), + }; + metadata.merge_pyproject_toml(manifest_path)?; + Ok(metadata) } /// Formats the metadata into a list where keys with multiple values @@ -180,10 +358,9 @@ impl Metadata21 { } }; - add_vec("Supported-Platform", &self.supported_platform); add_vec("Platform", &self.platform); add_vec("Supported-Platform", &self.supported_platform); - add_vec("Classifiers", &self.classifiers); + add_vec("Classifier", &self.classifiers); add_vec("Requires-Dist", &self.requires_dist); add_vec("Provides-Dist", &self.provides_dist); add_vec("Obsoletes-Dist", &self.obsoletes_dist); @@ -199,11 +376,11 @@ impl Metadata21 { add_option("Summary", &self.summary); add_option("Keywords", &self.keywords); add_option("Home-Page", &self.home_page); - add_option("Download-Url", &self.download_url); + add_option("Download-URL", &self.download_url); add_option("Author", &self.author); - add_option("Author-Email", &self.author_email); + add_option("Author-email", &self.author_email); add_option("Maintainer", &self.maintainer); - add_option("Maintainer-Email", &self.maintainer_email); + add_option("Maintainer-email", &self.maintainer_email); add_option("License", &self.license); add_option("Requires-Python", &self.requires_python); add_option("Description-Content-Type", &self.description_content_type); @@ -364,14 +541,14 @@ mod test { Metadata-Version: 2.1 Name: info-project Version: 0.1.0 - Classifiers: Programming Language :: Python + Classifier: Programming Language :: Python Requires-Dist: flask~=1.1.0 Requires-Dist: toml==0.10.0 Summary: A test project - Keywords: ffi test + Keywords: ffi,test Home-Page: https://example.org Author: konstin - Author-Email: konstin + Author-email: konstin Description-Content-Type: text/plain; charset=UTF-8 Project-URL: Bug Tracker, http://bitbucket.org/tarek/distribute/issues/ @@ -423,14 +600,14 @@ mod test { Metadata-Version: 2.1 Name: info-project Version: 0.1.0 - Classifiers: Programming Language :: Python + Classifier: Programming Language :: Python Requires-Dist: flask~=1.1.0 Requires-Dist: toml==0.10.0 Summary: A test project - Keywords: ffi test + Keywords: ffi,test Home-Page: https://example.org Author: konstin - Author-Email: konstin + Author-email: konstin Description-Content-Type: text/x-rst Some test package @@ -471,11 +648,11 @@ mod test { Metadata-Version: 2.1 Name: info Version: 0.1.0 - Classifiers: Programming Language :: Python + Classifier: Programming Language :: Python Summary: A test project Home-Page: https://example.org Author: konstin - Author-Email: konstin + Author-email: konstin "# ); @@ -519,4 +696,37 @@ mod test { ); } } + + #[test] + fn test_merge_metadata_from_pyproject_toml() { + let cargo_toml_str = fs_err::read_to_string("test-crates/pyo3-pure/Cargo.toml").unwrap(); + let cargo_toml: CargoToml = toml::from_str(&cargo_toml_str).unwrap(); + let metadata = Metadata21::from_cargo_toml(&cargo_toml, "test-crates/pyo3-pure").unwrap(); + assert_eq!( + metadata.summary, + Some("Implements a dummy function in Rust".to_string()) + ); + assert_eq!( + metadata.description, + Some(fs_err::read_to_string("test-crates/pyo3-pure/Readme.md").unwrap()) + ); + assert_eq!(metadata.classifiers, &["Programming Language :: Rust"]); + assert_eq!( + metadata.maintainer_email, + Some("messense ".to_string()) + ); + assert_eq!(metadata.scripts["get_42"], "pyo3_pure:DummyClass.get_42"); + assert_eq!( + metadata.gui_scripts["get_42_gui"], + "pyo3_pure:DummyClass.get_42" + ); + assert_eq!(metadata.provides_extra, &["test"]); + assert_eq!( + metadata.requires_dist, + &[ + "attrs; extra == 'test'", + "boltons; (sys_platform == 'win32') and extra == 'test'" + ] + ) + } } diff --git a/src/module_writer.rs b/src/module_writer.rs index d2c3a569f..881afa8f7 100644 --- a/src/module_writer.rs +++ b/src/module_writer.rs @@ -248,7 +248,6 @@ impl WheelWriter { tag: &str, wheel_dir: &Path, metadata21: &Metadata21, - scripts: &HashMap, tags: &[String], ) -> Result { let wheel_path = wheel_dir.join(format!( @@ -267,7 +266,7 @@ impl WheelWriter { wheel_path, }; - write_dist_info(&mut builder, &metadata21, &scripts, &tags)?; + write_dist_info(&mut builder, &metadata21, &tags)?; Ok(builder) } @@ -379,10 +378,13 @@ Root-Is-Purelib: false } /// https://packaging.python.org/specifications/entry-points/ -fn entry_points_txt(entrypoints: &HashMap) -> String { +fn entry_points_txt( + entry_type: &str, + entrypoints: &HashMap, +) -> String { entrypoints .iter() - .fold("[console_scripts]\n".to_owned(), |text, (k, v)| { + .fold(format!("[{}]\n", entry_type), |text, (k, v)| { text + k + "=" + v + "\n" }) } @@ -733,7 +735,6 @@ pub fn write_python_part( pub fn write_dist_info( writer: &mut impl ModuleWriter, metadata21: &Metadata21, - scripts: &HashMap, tags: &[String], ) -> Result<()> { let dist_info_dir = metadata21.get_dist_info_dir(); @@ -747,10 +748,20 @@ pub fn write_dist_info( writer.add_bytes(&dist_info_dir.join("WHEEL"), wheel_file(tags).as_bytes())?; - if !scripts.is_empty() { + let mut entry_points = String::new(); + if !metadata21.scripts.is_empty() { + entry_points.push_str(&entry_points_txt("console_scripts", &metadata21.scripts)); + } + if !metadata21.gui_scripts.is_empty() { + entry_points.push_str(&entry_points_txt("gui_scripts", &metadata21.gui_scripts)); + } + for (entry_type, scripts) in &metadata21.entry_points { + entry_points.push_str(&entry_points_txt(entry_type, scripts)); + } + if !entry_points.is_empty() { writer.add_bytes( &dist_info_dir.join("entry_points.txt"), - entry_points_txt(scripts).as_bytes(), + entry_points.as_bytes(), )?; } diff --git a/src/pyproject_toml.rs b/src/pyproject_toml.rs index 70b58f4ca..983387b85 100644 --- a/src/pyproject_toml.rs +++ b/src/pyproject_toml.rs @@ -1,21 +1,9 @@ use anyhow::{format_err, Context, Result}; +use pyproject_toml::PyProjectToml as ProjectToml; use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; -/// The `[build-system]` section of a pyproject.toml as specified in PEP 517 -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "kebab-case")] -pub struct BuildSystem { - /// PEP 518: This key must have a value of a list of strings representing PEP 508 dependencies - /// required to execute the build system (currently that means what dependencies are required - /// to execute a setup.py file). - pub requires: Vec, - /// PEP 517: `build-backend` is a string naming a Python object that will be used to perform - /// the build - pub build_backend: String, -} - /// The `[tool]` section of a pyproject.toml #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "kebab-case")] @@ -34,14 +22,8 @@ pub struct ToolMaturin { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "kebab-case")] pub struct PyProjectToml { - /// PEP 518: The [build-system] table is used to store build-related data. Initially only one - /// key of the table will be valid and is mandatory for the table: requires. This key must have - /// a value of a list of strings representing PEP 508 dependencies required to execute the - /// build system (currently that means what dependencies are required to execute a setup.py - /// file). - /// - /// We also mandate `build_backend` - pub build_system: BuildSystem, + #[serde(flatten)] + inner: ProjectToml, /// PEP 518: The `[tool]` table is where any tool related to your Python project, not just build /// tools, can have users specify configuration data as long as they use a sub-table within /// `[tool]`, e.g. the flit tool would store its configuration in `[tool.flit]`. @@ -50,6 +32,14 @@ pub struct PyProjectToml { pub tool: Option, } +impl std::ops::Deref for PyProjectToml { + type Target = ProjectToml; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + impl PyProjectToml { /// Returns the contents of a pyproject.toml with a `[build-system]` entry or an error /// @@ -61,10 +51,10 @@ impl PyProjectToml { "Couldn't find pyproject.toml at {}", path.display() ))?; - let cargo_toml: PyProjectToml = toml::from_str(&contents) + let pyproject: PyProjectToml = toml::from_str(&contents) .map_err(|err| format_err!("pyproject.toml is not PEP 517 compliant: {}", err))?; - cargo_toml.warn_missing_maturin_version(); - Ok(cargo_toml) + pyproject.warn_missing_maturin_version(); + Ok(pyproject) } /// Returns the value of `[maturin.sdist-include]` in pyproject.toml diff --git a/src/upload.rs b/src/upload.rs index 388c9b8a7..1cd5b99f2 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -77,7 +77,15 @@ pub fn upload( // Type system shenanigans .chain(metadata21.to_vec().into_iter()) // All fields must be lower case and with underscores or they will be ignored by warehouse - .map(|(key, value)| (key.to_lowercase().replace("-", "_"), value)) + .map(|(key, value)| { + let mut key = key.to_lowercase().replace("-", "_"); + if key == "classifier" { + // PyPI upload api expects `classifiers` instead of `classifier` + // See https://github.com/pypa/warehouse/issues/3151#issuecomment-796965735 + key = "classifiers".to_string(); + } + (key, value) + }) .collect(); let mut form = Form::new(); diff --git a/test-crates/pyo3-pure/Cargo.toml b/test-crates/pyo3-pure/Cargo.toml index 3f19c3e79..f73940293 100644 --- a/test-crates/pyo3-pure/Cargo.toml +++ b/test-crates/pyo3-pure/Cargo.toml @@ -2,17 +2,8 @@ authors = ["konstin "] name = "pyo3-pure" version = "2.1.2" -description = "Implements a dummy function (get_fortytwo.DummyClass.get_42()) in rust" -readme = "Readme.md" edition = "2018" - -[package.metadata.maturin.scripts] -get_42 = "pyo3_pure:DummyClass.get_42" - -[package.metadata.maturin] -classifier = [ - "Programming Language :: Rust" -] +description = "Implements a dummy function (get_fortytwo.DummyClass.get_42()) in rust" [dependencies] pyo3 = { version = "0.13.2", features = ["abi3-py36", "extension-module"] } diff --git a/test-crates/pyo3-pure/pyproject.toml b/test-crates/pyo3-pure/pyproject.toml index 9a2f56d97..5b15900d6 100644 --- a/test-crates/pyo3-pure/pyproject.toml +++ b/test-crates/pyo3-pure/pyproject.toml @@ -1,3 +1,26 @@ [build-system] requires = ["maturin>=0.10,<0.11"] build-backend = "maturin" + +[project] +name = "pyo3-pure" +classifiers = [ + "Programming Language :: Rust" +] +description = "Implements a dummy function in Rust" +readme = "Readme.md" +maintainers = [ + {name = "messense", email = "messense@icloud.com"} +] + +[project.optional-dependencies] +test = [ + "attrs", + "boltons; sys_platform == 'win32'" +] + +[project.scripts] +get_42 = "pyo3_pure:DummyClass.get_42" + +[project.gui-scripts] +get_42_gui = "pyo3_pure:DummyClass.get_42"