diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index b66f9ddd7..a379aa801 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -2,10 +2,10 @@ use std::path::Path; use std::str::FromStr; use std::{fmt, mem}; +use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; +use pep508_rs::{ExtraName, PackageName, Requirement, VersionOrUrl}; use thiserror::Error; use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; - -use pep508_rs::{ExtraName, PackageName, Requirement, VersionOrUrl}; use uv_fs::PortablePath; use crate::pyproject::{DependencyType, PyProjectToml, Source}; @@ -32,10 +32,21 @@ pub enum Error { MalformedSources, #[error("Workspace in `pyproject.toml` is malformed")] MalformedWorkspace, + #[error("Expected a dependency at index {0}")] + MissingDependency(usize), #[error("Cannot perform ambiguous update; found multiple entries with matching package names")] Ambiguous, } +/// The result of editing an array in a TOML document. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ArrayEdit { + /// An existing entry (at the given index) was updated. + Update(usize), + /// A new entry was added at the given index (typically, the end of the array). + Add(usize), +} + impl PyProjectTomlMut { /// Initialize a [`PyProjectTomlMut`] from a [`PyProjectToml`]. pub fn from_toml(pyproject: &PyProjectToml) -> Result { @@ -78,11 +89,13 @@ impl PyProjectTomlMut { } /// Adds a dependency to `project.dependencies`. + /// + /// Returns `true` if the dependency was added, `false` if it was updated. pub fn add_dependency( &mut self, - req: Requirement, - source: Option, - ) -> Result<(), Error> { + req: &Requirement, + source: Option<&Source>, + ) -> Result { // Get or create `project.dependencies`. let dependencies = self .doc @@ -96,21 +109,23 @@ impl PyProjectTomlMut { .ok_or(Error::MalformedDependencies)?; let name = req.name.clone(); - add_dependency(req, dependencies, source.is_some())?; + let edit = add_dependency(req, dependencies, source.is_some())?; if let Some(source) = source { - self.add_source(&name, &source)?; + self.add_source(&name, source)?; } - Ok(()) + Ok(edit) } /// Adds a development dependency to `tool.uv.dev-dependencies`. + /// + /// Returns `true` if the dependency was added, `false` if it was updated. pub fn add_dev_dependency( &mut self, - req: Requirement, - source: Option, - ) -> Result<(), Error> { + req: &Requirement, + source: Option<&Source>, + ) -> Result { // Get or create `tool.uv.dev-dependencies`. let dev_dependencies = self .doc @@ -128,22 +143,24 @@ impl PyProjectTomlMut { .ok_or(Error::MalformedDependencies)?; let name = req.name.clone(); - add_dependency(req, dev_dependencies, source.is_some())?; + let edit = add_dependency(req, dev_dependencies, source.is_some())?; if let Some(source) = source { - self.add_source(&name, &source)?; + self.add_source(&name, source)?; } - Ok(()) + Ok(edit) } /// Adds a dependency to `project.optional-dependencies`. + /// + /// Returns `true` if the dependency was added, `false` if it was updated. pub fn add_optional_dependency( &mut self, - req: Requirement, group: &ExtraName, - source: Option, - ) -> Result<(), Error> { + req: &Requirement, + source: Option<&Source>, + ) -> Result { // Get or create `project.optional-dependencies`. let optional_dependencies = self .doc @@ -163,12 +180,125 @@ impl PyProjectTomlMut { .ok_or(Error::MalformedDependencies)?; let name = req.name.clone(); - add_dependency(req, group, source.is_some())?; + let added = add_dependency(req, group, source.is_some())?; if let Some(source) = source { - self.add_source(&name, &source)?; + self.add_source(&name, source)?; } + Ok(added) + } + + /// Set the minimum version for an existing dependency in `project.dependencies`. + pub fn set_dependency_minimum_version( + &mut self, + index: usize, + version: &Version, + ) -> Result<(), Error> { + // Get or create `project.dependencies`. + let dependencies = self + .doc + .entry("project") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .ok_or(Error::MalformedDependencies)? + .entry("dependencies") + .or_insert(Item::Value(Value::Array(Array::new()))) + .as_array_mut() + .ok_or(Error::MalformedDependencies)?; + + let Some(req) = dependencies.get(index) else { + return Err(Error::MissingDependency(index)); + }; + + let mut req = req + .as_str() + .and_then(try_parse_requirement) + .ok_or(Error::MalformedDependencies)?; + req.version_or_url = Some(VersionOrUrl::VersionSpecifier(VersionSpecifiers::from( + VersionSpecifier::greater_than_equal_version(version.clone()), + ))); + dependencies.replace(index, req.to_string()); + + Ok(()) + } + + /// Set the minimum version for an existing dependency in `tool.uv.dev-dependencies`. + pub fn set_dev_dependency_minimum_version( + &mut self, + index: usize, + version: &Version, + ) -> Result<(), Error> { + // Get or create `tool.uv.dev-dependencies`. + let dev_dependencies = self + .doc + .entry("tool") + .or_insert(implicit()) + .as_table_mut() + .ok_or(Error::MalformedSources)? + .entry("uv") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .ok_or(Error::MalformedSources)? + .entry("dev-dependencies") + .or_insert(Item::Value(Value::Array(Array::new()))) + .as_array_mut() + .ok_or(Error::MalformedDependencies)?; + + let Some(req) = dev_dependencies.get(index) else { + return Err(Error::MissingDependency(index)); + }; + + let mut req = req + .as_str() + .and_then(try_parse_requirement) + .ok_or(Error::MalformedDependencies)?; + req.version_or_url = Some(VersionOrUrl::VersionSpecifier(VersionSpecifiers::from( + VersionSpecifier::greater_than_equal_version(version.clone()), + ))); + dev_dependencies.replace(index, req.to_string()); + + Ok(()) + } + + /// Set the minimum version for an existing dependency in `project.optional-dependencies`. + pub fn set_optional_dependency_minimum_version( + &mut self, + group: &ExtraName, + index: usize, + version: &Version, + ) -> Result<(), Error> { + // Get or create `project.optional-dependencies`. + let optional_dependencies = self + .doc + .entry("project") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .ok_or(Error::MalformedDependencies)? + .entry("optional-dependencies") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .ok_or(Error::MalformedDependencies)?; + + let group = optional_dependencies + .entry(group.as_ref()) + .or_insert(Item::Value(Value::Array(Array::new()))) + .as_array_mut() + .ok_or(Error::MalformedDependencies)?; + + let Some(req) = group.get(index) else { + return Err(Error::MissingDependency(index)); + }; + + let mut req = req + .as_str() + .and_then(try_parse_requirement) + .ok_or(Error::MalformedDependencies)?; + req.version_or_url = Some(VersionOrUrl::VersionSpecifier(VersionSpecifiers::from( + VersionSpecifier::greater_than_equal_version(version.clone()), + ))); + group.replace(index, req.to_string()); + Ok(()) } @@ -267,7 +397,7 @@ impl PyProjectTomlMut { Ok(requirements) } - // Remove a matching source from `tool.uv.sources`, if it exists. + /// Remove a matching source from `tool.uv.sources`, if it exists. fn remove_source(&mut self, name: &PackageName) -> Result<(), Error> { if let Some(sources) = self .doc @@ -350,27 +480,37 @@ fn implicit() -> Item { } /// Adds a dependency to the given `deps` array. -pub fn add_dependency(req: Requirement, deps: &mut Array, has_source: bool) -> Result<(), Error> { +/// +/// Returns `true` if the dependency was added, `false` if it was updated. +pub fn add_dependency( + req: &Requirement, + deps: &mut Array, + has_source: bool, +) -> Result { // Find matching dependencies. let mut to_replace = find_dependencies(&req.name, deps); match to_replace.as_slice() { - [] => deps.push(req.to_string()), + [] => { + deps.push(req.to_string()); + reformat_array_multiline(deps); + Ok(ArrayEdit::Add(deps.len() - 1)) + } [_] => { let (i, mut old_req) = to_replace.remove(0); update_requirement(&mut old_req, req, has_source); deps.replace(i, old_req.to_string()); + reformat_array_multiline(deps); + Ok(ArrayEdit::Update(i)) } // Cannot perform ambiguous updates. - _ => return Err(Error::Ambiguous), + _ => Err(Error::Ambiguous), } - reformat_array_multiline(deps); - Ok(()) } /// Update an existing requirement. -fn update_requirement(old: &mut Requirement, new: Requirement, has_source: bool) { +fn update_requirement(old: &mut Requirement, new: &Requirement, has_source: bool) { // Add any new extras. - old.extras.extend(new.extras); + old.extras.extend(new.extras.iter().cloned()); old.extras.sort_unstable(); old.extras.dedup(); @@ -380,14 +520,14 @@ fn update_requirement(old: &mut Requirement, new: Requirement, has_source: bool) } // Update the source if a new one was specified. - match new.version_or_url { + match &new.version_or_url { None => {} Some(VersionOrUrl::VersionSpecifier(specifier)) if specifier.is_empty() => {} - Some(version_or_url) => old.version_or_url = Some(version_or_url), + Some(version_or_url) => old.version_or_url = Some(version_or_url.clone()), } // Update the marker expression. - if let Some(marker) = new.marker { + if let Some(marker) = new.marker.clone() { old.marker = Some(marker); } } @@ -412,8 +552,8 @@ fn remove_dependency(req: &PackageName, deps: &mut Array) -> Vec { removed } -// Returns a `Vec` containing the all dependencies with the given name, along with their positions -// in the array. +/// Returns a `Vec` containing the all dependencies with the given name, along with their positions +/// in the array. fn find_dependencies(name: &PackageName, deps: &Array) -> Vec<(usize, Requirement)> { let mut to_replace = Vec::new(); for (i, dep) in deps.iter().enumerate() { diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index ae29b79d8..8d38991ee 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; - -use pep508_rs::ExtraName; +use pep508_rs::{ExtraName, Requirement, VersionOrUrl}; +use rustc_hash::{FxBuildHasher, FxHashMap}; +use std::collections::hash_map::Entry; use uv_auth::store_credentials_from_url; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; @@ -15,7 +16,7 @@ use uv_resolver::FlatIndex; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; use uv_workspace::pyproject::{DependencyType, Source, SourceError}; -use uv_workspace::pyproject_mut::PyProjectTomlMut; +use uv_workspace::pyproject_mut::{ArrayEdit, PyProjectTomlMut}; use uv_workspace::{DiscoveryOptions, ProjectWorkspace, VirtualProject, Workspace}; use crate::commands::pip::operations::Modifications; @@ -157,21 +158,25 @@ pub(crate) async fn add( // Add the requirements to the `pyproject.toml`. let existing = project.current_project().pyproject_toml(); let mut pyproject = PyProjectTomlMut::from_toml(existing)?; - for mut req in requirements { + let mut edits = Vec::with_capacity(requirements.len()); + for mut requirement in requirements { // Add the specified extras. - req.extras.extend(extras.iter().cloned()); - req.extras.sort_unstable(); - req.extras.dedup(); + requirement.extras.extend(extras.iter().cloned()); + requirement.extras.sort_unstable(); + requirement.extras.dedup(); - let (req, source) = if raw_sources { + let (requirement, source) = if raw_sources { // Use the PEP 508 requirement directly. - (pep508_rs::Requirement::from(req), None) + (pep508_rs::Requirement::from(requirement), None) } else { // Otherwise, try to construct the source. - let workspace = project.workspace().packages().contains_key(&req.name); + let workspace = project + .workspace() + .packages() + .contains_key(&requirement.name); let result = Source::from_requirement( - &req.name, - req.source.clone(), + &requirement.name, + requirement.source.clone(), workspace, editable, rev.clone(), @@ -182,29 +187,36 @@ pub(crate) async fn add( let source = match result { Ok(source) => source, Err(SourceError::UnresolvedReference(rev)) => { - anyhow::bail!("Cannot resolve Git reference `{rev}` for requirement `{}`. Specify the reference with one of `--tag`, `--branch`, or `--rev`, or use the `--raw-sources` flag.", req.name) + anyhow::bail!("Cannot resolve Git reference `{rev}` for requirement `{name}`. Specify the reference with one of `--tag`, `--branch`, or `--rev`, or use the `--raw-sources` flag.", name = requirement.name) } Err(err) => return Err(err.into()), }; // Ignore the PEP 508 source. - let mut req = pep508_rs::Requirement::from(req); - req.clear_url(); + let mut requirement = pep508_rs::Requirement::from(requirement); + requirement.clear_url(); - (req, source) + (requirement, source) }; - match dependency_type { + // Update the `pyproject.toml`. + let edit = match dependency_type { DependencyType::Production => { - pyproject.add_dependency(req, source)?; - } - DependencyType::Dev => { - pyproject.add_dev_dependency(req, source)?; + pyproject.add_dependency(&requirement, source.as_ref())? } + DependencyType::Dev => pyproject.add_dev_dependency(&requirement, source.as_ref())?, DependencyType::Optional(ref group) => { - pyproject.add_optional_dependency(req, group, source)?; + pyproject.add_optional_dependency(group, &requirement, source.as_ref())? } - } + }; + + // Keep track of the exact location of the edit. + edits.push(DependencyEdit { + dependency_type: &dependency_type, + requirement, + source, + edit, + }); } // Save the modified `pyproject.toml`. @@ -264,6 +276,78 @@ pub(crate) async fn add( Err(err) => return Err(err.into()), }; + // Avoid modifying the user request further if `--raw-sources` is set. + if !raw_sources { + // Extract the minimum-supported version for each dependency. + let mut minimum_version = + FxHashMap::with_capacity_and_hasher(lock.lock.distributions().len(), FxBuildHasher); + for dist in lock.lock.distributions() { + let name = dist.name(); + let version = dist.version(); + match minimum_version.entry(name) { + Entry::Vacant(entry) => { + entry.insert(version); + } + Entry::Occupied(mut entry) => { + if version < *entry.get() { + entry.insert(version); + } + } + } + } + + // If any of the requirements were added without version specifiers, add a lower bound. + let mut modified = false; + for edit in &edits { + // Only set a minimum version for newly-added dependencies (as opposed to updates). + let ArrayEdit::Add(index) = &edit.edit else { + continue; + }; + + // Only set a minimum version for registry requirements. + if edit.source.is_some() { + continue; + } + + // Only set a minimum version for registry requirements. + let is_empty = match edit.requirement.version_or_url.as_ref() { + Some(VersionOrUrl::VersionSpecifier(version)) => version.is_empty(), + Some(VersionOrUrl::Url(_)) => false, + None => true, + }; + if !is_empty { + continue; + } + + // Set the minimum version. + let Some(minimum) = minimum_version.get(&edit.requirement.name) else { + continue; + }; + + match edit.dependency_type { + DependencyType::Production => { + pyproject.set_dependency_minimum_version(*index, minimum)?; + } + DependencyType::Dev => { + pyproject.set_dev_dependency_minimum_version(*index, minimum)?; + } + DependencyType::Optional(ref group) => { + pyproject.set_optional_dependency_minimum_version(group, *index, minimum)?; + } + } + + modified = true; + } + + // Save the modified `pyproject.toml`. + if modified { + fs_err::write( + project.current_project().root().join("pyproject.toml"), + pyproject.to_string(), + )?; + } + } + // Perform a full sync, because we don't know what exactly is affected by the removal. // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? let extras = ExtrasSpecification::All; @@ -290,6 +374,14 @@ pub(crate) async fn add( Ok(ExitStatus::Success) } +#[derive(Debug, Clone)] +struct DependencyEdit<'a> { + dependency_type: &'a DependencyType, + requirement: Requirement, + source: Option, + edit: ArrayEdit, +} + /// Render a [`uv_resolver::NoSolutionError`] with a help message. #[derive(Debug, miette::Diagnostic, thiserror::Error)] #[error("{header}")] diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 477e6bc77..da10dc062 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2187,3 +2187,325 @@ fn add_error() -> Result<()> { Ok(()) } + +/// Set a lower bound when adding unconstrained dependencies. +#[test] +fn add_lower_bound() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Adding `anyio` should include a lower-bound. + uv_snapshot!(context.filters(), context.add(&["anyio"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio>=4.3.0", + ] + "### + ); + }); + + Ok(()) +} + +/// Avoid setting a lower bound when updating existing dependencies. +#[test] +fn add_lower_bound_existing() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio"] + "#})?; + + // Adding `anyio` should _not_ set a lower-bound, since it's already present (even if + // unconstrained). + uv_snapshot!(context.filters(), context.add(&["anyio"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio", + ] + "### + ); + }); + + Ok(()) +} + +/// Avoid setting a lower bound with `--raw-sources`. +#[test] +fn add_lower_bound_raw() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio"] + "#})?; + + // Adding `anyio` should _not_ set a lower-bound when using `--raw-sources`. + uv_snapshot!(context.filters(), context.add(&["anyio"]).arg("--raw-sources"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "anyio", + ] + "### + ); + }); + + Ok(()) +} + +/// Set a lower bound when adding unconstrained dev dependencies. +#[test] +fn add_lower_bound_dev() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Adding `anyio` should include a lower-bound. + uv_snapshot!(context.filters(), context.add(&["anyio"]).arg("--dev"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [tool.uv] + dev-dependencies = [ + "anyio>=4.3.0", + ] + "### + ); + }); + + Ok(()) +} + +/// Set a lower bound when adding unconstrained dev dependencies. +#[test] +fn add_lower_bound_optional() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Adding `anyio` should include a lower-bound. + uv_snapshot!(context.filters(), context.add(&["anyio"]).arg("--optional=io"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + io = [ + "anyio>=4.3.0", + ] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + exclude-newer = "2024-03-25 00:00:00 UTC" + + [[distribution]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 }, + ] + + [[distribution]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }, + ] + + [[distribution]] + name = "project" + version = "0.1.0" + source = { editable = "." } + + [distribution.optional-dependencies] + io = [ + { name = "anyio" }, + ] + + [[distribution]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + ] + "### + ); + }); + + Ok(()) +}