This commit is contained in:
liam 2025-12-16 17:38:22 +01:00 committed by GitHub
commit b71e9d8ef0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 367 additions and 65 deletions

View File

@ -7,6 +7,8 @@ use std::{
use jiff::{Span, Timestamp, ToSpan, Unit, tz::TimeZone};
use rustc_hash::FxHashMap;
use serde::Deserialize;
use serde::de::value::MapAccessDeserializer;
use uv_normalize::PackageName;
#[derive(Debug, Clone, PartialEq, Eq)]
@ -95,9 +97,9 @@ impl std::fmt::Display for ExcludeNewerChange {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExcludeNewerPackageChange {
PackageAdded(PackageName, ExcludeNewerValue),
PackageAdded(PackageName, PackageExcludeNewer),
PackageRemoved(PackageName),
PackageChanged(PackageName, ExcludeNewerValueChange),
PackageChanged(PackageName, Box<PackageExcludeNewerChange>),
}
impl ExcludeNewerPackageChange {
@ -112,18 +114,23 @@ impl ExcludeNewerPackageChange {
impl std::fmt::Display for ExcludeNewerPackageChange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PackageAdded(name, value) => {
Self::PackageAdded(name, PackageExcludeNewer::Enabled(value)) => {
write!(
f,
"addition of exclude newer `{value}` for package `{name}`"
"addition of exclude newer `{}` for package `{name}`",
value.as_ref()
)
}
Self::PackageAdded(name, PackageExcludeNewer::Disabled) => {
write!(
f,
"addition of exclude newer exclusion for package `{name}`"
)
}
Self::PackageRemoved(name) => {
write!(f, "removal of exclude newer for package `{name}`")
}
Self::PackageChanged(name, change) => {
write!(f, "{change} for package `{name}`")
}
Self::PackageChanged(name, change) => write!(f, "{change} for package `{name}`"),
}
}
}
@ -255,7 +262,7 @@ impl ExcludeNewerValue {
self.span.as_ref()
}
/// Create a new [`ExcludeNewerTimestamp`].
/// Create a new [`ExcludeNewerValue`].
pub fn new(timestamp: Timestamp, span: Option<ExcludeNewerSpan>) -> Self {
Self { timestamp, span }
}
@ -326,7 +333,7 @@ fn format_exclude_newer_error(
impl FromStr for ExcludeNewerValue {
type Err = String;
/// Parse an [`ExcludeNewerTimestamp`] from a string.
/// Parse an [`ExcludeNewerValue`] from a string.
///
/// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format
/// (e.g., `2006-12-02`), "friendly" durations (e.g., `1 week`, `30 days`), and ISO 8601
@ -425,47 +432,170 @@ impl std::fmt::Display for ExcludeNewerValue {
}
}
/// Per-package exclude-newer setting.
///
/// This enum represents whether exclude-newer should be disabled for a package,
/// or if a specific cutoff (absolute or relative) should be used.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum PackageExcludeNewer {
/// Disable exclude-newer for this package (allow all versions regardless of upload date).
Disabled,
/// Enable exclude-newer with this cutoff for this package.
Enabled(Box<ExcludeNewerValue>),
}
/// A package-specific exclude-newer entry.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ExcludeNewerPackageEntry {
pub package: PackageName,
pub timestamp: ExcludeNewerValue,
pub setting: PackageExcludeNewer,
}
impl FromStr for ExcludeNewerPackageEntry {
type Err = String;
/// Parses a [`ExcludeNewerPackageEntry`] from a string in the format `PACKAGE=DATE`.
/// Parses a [`ExcludeNewerPackageEntry`] from a string in the format `PACKAGE=DATE` or `PACKAGE=false`.
fn from_str(s: &str) -> Result<Self, Self::Err> {
let Some((package, date)) = s.split_once('=') else {
let Some((package, value)) = s.split_once('=') else {
return Err(format!(
"Invalid `exclude-newer-package` value `{s}`: expected format `PACKAGE=DATE`"
"Invalid `exclude-newer-package` value `{s}`: expected format `PACKAGE=DATE` or `PACKAGE=false`"
));
};
let package = PackageName::from_str(package).map_err(|err| {
format!("Invalid `exclude-newer-package` package name `{package}`: {err}")
})?;
let timestamp = ExcludeNewerValue::from_str(date)
.map_err(|err| format!("Invalid `exclude-newer-package` timestamp `{date}`: {err}"))?;
Ok(Self { package, timestamp })
let setting = if value == "false" {
PackageExcludeNewer::Disabled
} else {
PackageExcludeNewer::Enabled(Box::new(ExcludeNewerValue::from_str(value).map_err(
|err| format!("Invalid `exclude-newer-package` value `{value}`: {err}"),
)?))
};
Ok(Self { package, setting })
}
}
impl From<(PackageName, PackageExcludeNewer)> for ExcludeNewerPackageEntry {
fn from((package, setting): (PackageName, PackageExcludeNewer)) -> Self {
Self { package, setting }
}
}
impl From<(PackageName, ExcludeNewerValue)> for ExcludeNewerPackageEntry {
fn from((package, timestamp): (PackageName, ExcludeNewerValue)) -> Self {
Self { package, timestamp }
Self {
package,
setting: PackageExcludeNewer::Enabled(Box::new(timestamp)),
}
}
}
impl<'de> serde::Deserialize<'de> for PackageExcludeNewer {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = PackageExcludeNewer;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(
"a date/timestamp/duration string, false to disable exclude-newer, or a table \
with timestamp/span",
)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
ExcludeNewerValue::from_str(v)
.map(|ts| PackageExcludeNewer::Enabled(Box::new(ts)))
.map_err(|e| E::custom(format!("failed to parse exclude-newer value: {e}")))
}
fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if v {
Err(E::custom(
"expected false to disable exclude-newer, got true",
))
} else {
Ok(PackageExcludeNewer::Disabled)
}
}
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
Ok(PackageExcludeNewer::Enabled(Box::new(
ExcludeNewerValue::deserialize(MapAccessDeserializer::new(map))?,
)))
}
}
deserializer.deserialize_any(Visitor)
}
}
impl serde::Serialize for PackageExcludeNewer {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Enabled(timestamp) => timestamp.to_string().serialize(serializer),
Self::Disabled => serializer.serialize_bool(false),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PackageExcludeNewerChange {
Disabled { was: ExcludeNewerValue },
Enabled { now: ExcludeNewerValue },
TimestampChanged(ExcludeNewerValueChange),
}
impl PackageExcludeNewerChange {
pub fn is_relative_timestamp_change(&self) -> bool {
match self {
Self::Disabled { .. } | Self::Enabled { .. } => false,
Self::TimestampChanged(change) => change.is_relative_timestamp_change(),
}
}
}
impl std::fmt::Display for PackageExcludeNewerChange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled { was } => {
write!(f, "add exclude newer exclusion (was `{was}`)")
}
Self::Enabled { now } => {
write!(f, "remove exclude newer exclusion (now `{now}`)")
}
Self::TimestampChanged(change) => write!(f, "{change}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ExcludeNewerPackage(FxHashMap<PackageName, ExcludeNewerValue>);
pub struct ExcludeNewerPackage(FxHashMap<PackageName, PackageExcludeNewer>);
impl Deref for ExcludeNewerPackage {
type Target = FxHashMap<PackageName, ExcludeNewerValue>;
type Target = FxHashMap<PackageName, PackageExcludeNewer>;
fn deref(&self) -> &Self::Target {
&self.0
@ -482,15 +612,15 @@ impl FromIterator<ExcludeNewerPackageEntry> for ExcludeNewerPackage {
fn from_iter<T: IntoIterator<Item = ExcludeNewerPackageEntry>>(iter: T) -> Self {
Self(
iter.into_iter()
.map(|entry| (entry.package, entry.timestamp))
.map(|entry| (entry.package, entry.setting))
.collect(),
)
}
}
impl IntoIterator for ExcludeNewerPackage {
type Item = (PackageName, ExcludeNewerValue);
type IntoIter = std::collections::hash_map::IntoIter<PackageName, ExcludeNewerValue>;
type Item = (PackageName, PackageExcludeNewer);
type IntoIter = std::collections::hash_map::IntoIter<PackageName, PackageExcludeNewer>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
@ -498,8 +628,8 @@ impl IntoIterator for ExcludeNewerPackage {
}
impl<'a> IntoIterator for &'a ExcludeNewerPackage {
type Item = (&'a PackageName, &'a ExcludeNewerValue);
type IntoIter = std::collections::hash_map::Iter<'a, PackageName, ExcludeNewerValue>;
type Item = (&'a PackageName, &'a PackageExcludeNewer);
type IntoIter = std::collections::hash_map::Iter<'a, PackageName, PackageExcludeNewer>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter()
@ -508,20 +638,55 @@ impl<'a> IntoIterator for &'a ExcludeNewerPackage {
impl ExcludeNewerPackage {
/// Convert to the inner `HashMap`.
pub fn into_inner(self) -> FxHashMap<PackageName, ExcludeNewerValue> {
pub fn into_inner(self) -> FxHashMap<PackageName, PackageExcludeNewer> {
self.0
}
/// Returns true if this map is empty (no package-specific settings).
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn compare(&self, other: &Self) -> Option<ExcludeNewerPackageChange> {
for (package, timestamp) in self {
let Some(other_timestamp) = other.get(package) else {
return Some(ExcludeNewerPackageChange::PackageRemoved(package.clone()));
};
if let Some(change) = timestamp.compare(other_timestamp) {
return Some(ExcludeNewerPackageChange::PackageChanged(
package.clone(),
change,
));
for (package, setting) in self {
match (setting, other.get(package)) {
(
PackageExcludeNewer::Enabled(self_timestamp),
Some(PackageExcludeNewer::Enabled(other_timestamp)),
) => {
if let Some(change) = self_timestamp.compare(other_timestamp) {
return Some(ExcludeNewerPackageChange::PackageChanged(
package.clone(),
Box::new(PackageExcludeNewerChange::TimestampChanged(change)),
));
}
}
(
PackageExcludeNewer::Enabled(self_timestamp),
Some(PackageExcludeNewer::Disabled),
) => {
return Some(ExcludeNewerPackageChange::PackageChanged(
package.clone(),
Box::new(PackageExcludeNewerChange::Disabled {
was: self_timestamp.as_ref().clone(),
}),
));
}
(
PackageExcludeNewer::Disabled,
Some(PackageExcludeNewer::Enabled(other_timestamp)),
) => {
return Some(ExcludeNewerPackageChange::PackageChanged(
package.clone(),
Box::new(PackageExcludeNewerChange::Enabled {
now: other_timestamp.as_ref().clone(),
}),
));
}
(PackageExcludeNewer::Disabled, Some(PackageExcludeNewer::Disabled)) => {}
(_, None) => {
return Some(ExcludeNewerPackageChange::PackageRemoved(package.clone()));
}
}
}
@ -574,12 +739,16 @@ impl ExcludeNewer {
Self { global, package }
}
/// Returns the timestamp for a specific package, falling back to the global timestamp if set.
/// Returns the exclude-newer value for a specific package, returning `Some(value)` if the
/// package has a package-specific setting or falls back to the global value if set, or `None`
/// if exclude-newer is explicitly disabled for the package (set to `false`) or if no
/// exclude-newer is configured.
pub fn exclude_newer_package(&self, package_name: &PackageName) -> Option<ExcludeNewerValue> {
self.package
.get(package_name)
.cloned()
.or(self.global.clone())
match self.package.get(package_name) {
Some(PackageExcludeNewer::Enabled(timestamp)) => Some(timestamp.as_ref().clone()),
Some(PackageExcludeNewer::Disabled) => None,
None => self.global.clone(),
}
}
/// Returns true if this has any configuration (global or per-package).
@ -615,11 +784,18 @@ impl std::fmt::Display for ExcludeNewer {
}
}
let mut first = true;
for (name, timestamp) in &self.package {
for (name, setting) in &self.package {
if !first {
write!(f, ", ")?;
}
write!(f, "{name}: {timestamp}")?;
match setting {
PackageExcludeNewer::Enabled(timestamp) => {
write!(f, "{name}: {}", timestamp.as_ref())?;
}
PackageExcludeNewer::Disabled => {
write!(f, "{name}: disabled")?;
}
}
first = false;
}
Ok(())

View File

@ -2,7 +2,8 @@ pub use dependency_mode::DependencyMode;
pub use error::{ErrorTree, NoSolutionError, NoSolutionHeader, ResolveError, SentinelRange};
pub use exclude_newer::{
ExcludeNewer, ExcludeNewerChange, ExcludeNewerPackage, ExcludeNewerPackageChange,
ExcludeNewerPackageEntry, ExcludeNewerValue, ExcludeNewerValueChange,
ExcludeNewerPackageEntry, ExcludeNewerValue, ExcludeNewerValueChange, PackageExcludeNewer,
PackageExcludeNewerChange,
};
pub use exclusions::Exclusions;
pub use flat_index::{FlatDistributions, FlatIndex};

View File

@ -61,7 +61,7 @@ use crate::resolution::{AnnotatedDist, ResolutionGraphNode};
use crate::universal_marker::{ConflictMarker, UniversalMarker};
use crate::{
ExcludeNewer, ExcludeNewerPackage, ExcludeNewerValue, InMemoryIndex, MetadataResponse,
PrereleaseMode, ResolutionMode, ResolverOutput,
PackageExcludeNewer, PrereleaseMode, ResolutionMode, ResolverOutput,
};
mod export;
@ -1071,20 +1071,29 @@ impl Lock {
// Serialize package-specific exclusions as a separate field
if !exclude_newer.package.is_empty() {
let mut package_table = toml_edit::Table::new();
for (name, exclude_newer_value) in &exclude_newer.package {
if let Some(span) = exclude_newer_value.span() {
// Serialize as inline table with timestamp and span
let mut inline = toml_edit::InlineTable::new();
inline.insert(
"timestamp",
exclude_newer_value.timestamp().to_string().into(),
);
inline.insert("span", span.to_string().into());
package_table.insert(name.as_ref(), Item::Value(inline.into()));
} else {
// Serialize as simple string
package_table
.insert(name.as_ref(), value(exclude_newer_value.to_string()));
for (name, setting) in &exclude_newer.package {
match setting {
PackageExcludeNewer::Enabled(exclude_newer_value) => {
if let Some(span) = exclude_newer_value.span() {
// Serialize as inline table with timestamp and span
let mut inline = toml_edit::InlineTable::new();
inline.insert(
"timestamp",
exclude_newer_value.timestamp().to_string().into(),
);
inline.insert("span", span.to_string().into());
package_table.insert(name.as_ref(), Item::Value(inline.into()));
} else {
// Serialize as simple string
package_table.insert(
name.as_ref(),
value(exclude_newer_value.to_string()),
);
}
}
PackageExcludeNewer::Disabled => {
package_table.insert(name.as_ref(), value(false));
}
}
}
options_table.insert("exclude-newer-package", Item::Table(package_table));

View File

@ -228,9 +228,11 @@ impl Combine for ExcludeNewer {
if self.package.is_empty() {
self.package = other.package;
} else {
// Merge package-specific timestamps, with self taking precedence
for (pkg, timestamp) in &other.package {
self.package.entry(pkg.clone()).or_insert(timestamp.clone());
// Merge package-specific settings, with self taking precedence
for (pkg, setting) in &other.package {
self.package
.entry(pkg.clone())
.or_insert_with(|| setting.clone());
}
}
}

View File

@ -31179,6 +31179,94 @@ fn test_tilde_equals_python_version() -> Result<()> {
Ok(())
}
/// Test that exclude-newer-package can be disabled for specific packages using `false`.
#[test]
fn lock_exclude_newer_package_disable() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["idna", "iniconfig"]
"#,
)?;
// Lock with global exclude-newer and disable it for idna
uv_snapshot!(context.filters(), context
.lock()
.env_remove(EnvVars::UV_EXCLUDE_NEWER)
.arg("--exclude-newer")
.arg("2022-04-04T12:00:00Z")
.arg("--exclude-newer-package")
.arg("idna=false"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 3 packages in [TIME]
"###);
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.12"
[options]
exclude-newer = "2022-04-04T12:00:00Z"
[options.exclude-newer-package]
idna = false
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/a2/97899f6bd0e873fed3a7e67ae8d3a08b21799430fb4da15cfedf10d6e2c2/iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32", size = 8104, upload-time = "2020-10-14T10:20:18.572Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", size = 4990, upload-time = "2020-10-16T17:37:23.05Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "idna" },
{ name = "iniconfig" },
]
[package.metadata]
requires-dist = [
{ name = "idna" },
{ name = "iniconfig" },
]
"#
);
});
Ok(())
}
/// Test that exclude-newer-package is properly serialized in the lockfile.
#[test]
fn lock_exclude_newer_package() -> Result<()> {

View File

@ -3565,7 +3565,7 @@ fn compile_exclude_newer_package_errors() -> Result<()> {
----- stdout -----
----- stderr -----
error: invalid value 'tqdm' for '--exclude-newer-package <EXCLUDE_NEWER_PACKAGE>': Invalid `exclude-newer-package` value `tqdm`: expected format `PACKAGE=DATE`
error: invalid value 'tqdm' for '--exclude-newer-package <EXCLUDE_NEWER_PACKAGE>': Invalid `exclude-newer-package` value `tqdm`: expected format `PACKAGE=DATE` or `PACKAGE=false`
For more information, try '--help'.
"
@ -3583,7 +3583,7 @@ fn compile_exclude_newer_package_errors() -> Result<()> {
----- stdout -----
----- stderr -----
error: invalid value 'tqdm=invalid-date' for '--exclude-newer-package <EXCLUDE_NEWER_PACKAGE>': Invalid `exclude-newer-package` timestamp `invalid-date`: `invalid-date` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`)
error: invalid value 'tqdm=invalid-date' for '--exclude-newer-package <EXCLUDE_NEWER_PACKAGE>': Invalid `exclude-newer-package` value `invalid-date`: `invalid-date` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`)
For more information, try '--help'.
"

View File

@ -658,8 +658,8 @@ configured time zone.
The package index must support the `upload-time` field as specified in
[`PEP 700`](https://peps.python.org/pep-0700/). If the field is not present for a given
distribution, the distribution will be treated as unavailable. PyPI provides `upload-time` for
all packages.
distribution, the distribution will be treated as unavailable unless the package is opted out
via `--exclude-newer-package <package>=false`. PyPI provides `upload-time` for all packages.
To ensure reproducibility, messages for unsatisfiable resolutions will not mention that
distributions were excluded due to the `--exclude-newer` flag — newer distributions will be treated
@ -689,6 +689,9 @@ Values may also be specified for specific packages, e.g.,
exclude-newer-package = { setuptools = "2006-12-02T02:07:43Z" }
```
The same flag also accepts `<package>=false` to opt a package out of the `--exclude-newer`
restriction, e.g., to allow resolving packages from an index that does not publish upload times.
Package-specific values will take precedence over global values.
## Dependency cooldowns

25
uv.schema.json generated
View File

@ -899,7 +899,7 @@
"ExcludeNewerPackage": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/ExcludeNewerTimestamp"
"$ref": "#/definitions/PackageExcludeNewer"
}
},
"ExcludeNewerTimestamp": {
@ -1213,6 +1213,29 @@
"$ref": "#/definitions/ConfigSettings"
}
},
"PackageExcludeNewer": {
"description": "Per-package exclude-newer setting.\n\nThis enum represents whether exclude-newer should be disabled for a package,\nor if a specific cutoff (absolute or relative) should be used.",
"oneOf": [
{
"description": "Disable exclude-newer for this package (allow all versions regardless of upload date).",
"type": "string",
"const": "Disabled"
},
{
"description": "Enable exclude-newer with this cutoff for this package.",
"type": "object",
"properties": {
"Enabled": {
"$ref": "#/definitions/ExcludeNewerTimestamp"
}
},
"additionalProperties": false,
"required": [
"Enabled"
]
}
]
},
"PackageName": {
"description": "The normalized name of a package.\n\nConverts the name to lowercase and collapses runs of `-`, `_`, and `.` down to a single `-`.\nFor example, `---`, `.`, and `__` are all converted to a single `-`.\n\nSee: <https://packaging.python.org/en/latest/specifications/name-normalization/>",
"type": "string"