From 6c98ae9d77b5ed2176df66938a75f774bd9e015f Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Fri, 5 Jan 2024 11:57:32 -0500 Subject: [PATCH] pep440: rewrite the parser and make version comparisons cheaper (#789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR builds on #780 by making both version parsing faster, and perhaps more importantly, making version comparisons much faster. Overall, these changes result in a considerable improvement for the `boto3.in` workload. Here's the status quo: ``` $ time puffin pip-compile --no-build --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/requirements/boto3.in Resolved 31 packages in 34.56s real 34.579 user 34.004 sys 0.413 maxmem 2867 MB faults 0 ``` And now with this PR: ``` $ time puffin pip-compile --no-build --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/requirements/boto3.in Resolved 31 packages in 9.20s real 9.218 user 8.919 sys 0.165 maxmem 463 MB faults 0 ``` This particular workload gets stuck in pubgrub doing resolution, and thus benefits mightily from a faster `Version::cmp` routine. With that said, this change does also help a fair bit with "normal" runs: ``` $ hyperfine -w10 \ "puffin-base pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in" \ "puffin-cmparc pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in" Benchmark 1: puffin-base pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in Time (mean ± σ): 337.5 ms ± 3.9 ms [User: 310.5 ms, System: 73.2 ms] Range (min … max): 333.6 ms … 343.4 ms 10 runs Benchmark 2: puffin-cmparc pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in Time (mean ± σ): 189.8 ms ± 3.0 ms [User: 168.1 ms, System: 78.4 ms] Range (min … max): 185.0 ms … 196.2 ms 15 runs Summary puffin-cmparc pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in ran 1.78 ± 0.03 times faster than puffin-base pip-compile --cache-dir ~/astral/tmp/cache/ -o /dev/null ./scripts/benchmarks/requirements.in ``` There is perhaps some future work here (detailed in the commit messages), but I suspect it would be more fruitful to explore ways of making resolution itself and/or deserialization faster. Fixes #373, Closes #396 --- .../distribution-filename/src/source_dist.rs | 4 +- crates/distribution-filename/src/wheel.rs | 6 +- crates/pep440-rs/src/lib.rs | 9 +- crates/pep440-rs/src/version.rs | 2378 ++++++++++++++--- crates/pep440-rs/src/version_specifier.rs | 113 +- crates/pep508-rs/src/lib.rs | 15 +- crates/pep508-rs/src/marker.rs | 53 +- crates/puffin-cli/tests/pip_compile.rs | 2 +- crates/puffin-cli/tests/pip_uninstall.rs | 6 +- crates/pypi-types/src/metadata.rs | 4 +- 10 files changed, 2103 insertions(+), 487 deletions(-) diff --git a/crates/distribution-filename/src/source_dist.rs b/crates/distribution-filename/src/source_dist.rs index 4db1d317a..0ab79f549 100644 --- a/crates/distribution-filename/src/source_dist.rs +++ b/crates/distribution-filename/src/source_dist.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; use thiserror::Error; -use pep440_rs::Version; +use pep440_rs::{Version, VersionParseError}; use puffin_normalize::{InvalidNameError, PackageName}; #[derive(Clone, Debug, PartialEq, Eq)] @@ -116,7 +116,7 @@ pub enum SourceDistFilenameError { #[error("Source distributions filenames must end with .zip or .tar.gz, not {0}")] InvalidExtension(String), #[error("Source distribution filename version section is invalid: {0}")] - InvalidVersion(String), + InvalidVersion(VersionParseError), #[error("Source distribution filename has an invalid package name: {0}")] InvalidPackageName(String, #[source] InvalidNameError), } diff --git a/crates/distribution-filename/src/wheel.rs b/crates/distribution-filename/src/wheel.rs index 6cfae56a4..f83d0436b 100644 --- a/crates/distribution-filename/src/wheel.rs +++ b/crates/distribution-filename/src/wheel.rs @@ -6,7 +6,7 @@ use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; use url::Url; -use pep440_rs::Version; +use pep440_rs::{Version, VersionParseError}; use platform_tags::{TagPriority, Tags}; use puffin_normalize::{InvalidNameError, PackageName}; @@ -217,7 +217,7 @@ pub enum WheelFilenameError { #[error("The wheel filename \"{0}\" is invalid: {1}")] InvalidWheelFileName(String, String), #[error("The wheel filename \"{0}\" has an invalid version part: {1}")] - InvalidVersion(String, String), + InvalidVersion(String, VersionParseError), #[error("The wheel filename \"{0}\" has an invalid package name")] InvalidPackageName(String, InvalidNameError), } @@ -278,7 +278,7 @@ mod tests { #[test] fn err_invalid_version() { let err = WheelFilename::from_str("foo-x.y.z-python-abi-platform.whl").unwrap_err(); - insta::assert_display_snapshot!(err, @r###"The wheel filename "foo-x.y.z-python-abi-platform.whl" has an invalid version part: Version `x.y.z` doesn't match PEP 440 rules"###); + insta::assert_display_snapshot!(err, @r###"The wheel filename "foo-x.y.z-python-abi-platform.whl" has an invalid version part: expected version to start with a number, but no leading ASCII digits were found"###); } #[test] diff --git a/crates/pep440-rs/src/lib.rs b/crates/pep440-rs/src/lib.rs index 3a9540d48..94b3eccb8 100644 --- a/crates/pep440-rs/src/lib.rs +++ b/crates/pep440-rs/src/lib.rs @@ -12,10 +12,6 @@ //! assert!(version_specifiers.iter().all(|specifier| specifier.contains(&version))); //! ``` //! -//! One thing that's a bit awkward about the API is that there's two kinds of -//! [Version]: One that doesn't allow stars (i.e. a package version), and one that does -//! (i.e. a version in a specifier), but they both use the same struct. -//! //! The error handling and diagnostics is a bit overdone because this my parser-and-diagnostics //! learning project (which kinda failed because the byte based regex crate and char-based //! diagnostics don't mix well) @@ -43,7 +39,10 @@ #![deny(missing_docs)] pub use { - version::{LocalSegment, Operator, PreRelease, Version}, + version::{ + LocalSegment, Operator, OperatorParseError, PreRelease, Version, VersionParseError, + VersionPattern, VersionPatternParseError, + }, version_specifier::{ parse_version_specifiers, VersionSpecifier, VersionSpecifiers, VersionSpecifiersParseError, }, diff --git a/crates/pep440-rs/src/version.rs b/crates/pep440-rs/src/version.rs index 4a94fec0c..a90bce803 100644 --- a/crates/pep440-rs/src/version.rs +++ b/crates/pep440-rs/src/version.rs @@ -3,6 +3,7 @@ use std::{ cmp::Ordering, hash::{Hash, Hasher}, str::FromStr, + sync::Arc, }; #[cfg(feature = "pyo3")] @@ -12,48 +13,6 @@ use pyo3::{ }; #[cfg(feature = "serde")] use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; -use { - once_cell::sync::Lazy, - regex::{Captures, Regex}, -}; - -/// A regex copied from , -/// updated to support stars for version ranges -pub(crate) const VERSION_RE_INNER: &str = r" -(?: - (?:v?) # - (?:(?P[0-9]+)!)? # epoch - (?P[0-9*]+(?:\.[0-9]+)*) # release segment, this now allows for * versions which are more lenient than necessary so we can put better error messages in the code - (?P # pre-release - [-_\.]? - (?P(a|b|c|rc|alpha|beta|pre|preview)) - [-_\.]? - (?P
[0-9]+)?
-    )?
-    (?P                                   # post release
-        (?:-(?P[0-9]+))
-        |
-        (?:
-            [-_\.]?
-            (?Ppost|rev|r)
-            [-_\.]?
-            (?P[0-9]+)?
-        )
-    )?
-    (?P                                    # dev release
-        [-_\.]?
-        (?Pdev)
-        [-_\.]?
-        (?P[0-9]+)?
-    )?
-)
-(?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
-(?P\.\*)?                          # allow for version matching `.*`
-";
-
-/// Matches a python version, such as `1.19.a1`. Based on the PEP 440 regex
-static VERSION_RE: Lazy =
-    Lazy::new(|| Regex::new(&format!(r#"(?xi)^(?:\s*){VERSION_RE_INNER}(?:\s*)$"#)).unwrap());
 
 /// One of `~=` `==` `!=` `<=` `>=` `<` `>` `===`
 #[derive(Eq, PartialEq, Debug, Hash, Clone, Copy)]
@@ -122,7 +81,7 @@ impl Operator {
 }
 
 impl FromStr for Operator {
-    type Err = String;
+    type Err = OperatorParseError;
 
     /// Notably, this does not know about star versions, it just assumes the base operator
     fn from_str(s: &str) -> Result {
@@ -144,9 +103,9 @@ impl FromStr for Operator {
             ">=" => Self::GreaterThanEqual,
             // Should be forbidden by the regex if called from normal parsing
             other => {
-                return Err(format!(
-                    "No such comparison operator '{other}', must be one of ~= == != <= >= < > ===",
-                ));
+                return Err(OperatorParseError {
+                    got: other.to_string(),
+                })
             }
         };
         Ok(operator)
@@ -187,6 +146,24 @@ impl Operator {
     }
 }
 
+/// An error that occurs when parsing an invalid version specifier operator.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct OperatorParseError {
+    pub(crate) got: String,
+}
+
+impl std::error::Error for OperatorParseError {}
+
+impl std::fmt::Display for OperatorParseError {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        write!(
+            f,
+            "no such comparison operator {:?}, must be one of ~= == != <= >= < > ===",
+            self.got
+        )
+    }
+}
+
 // NOTE: I did a little bit of experimentation to determine what most version
 // numbers actually look like. The idea here is that if we know what most look
 // like, then we can optimize our representation for the common case, while
@@ -265,40 +242,22 @@ impl Operator {
 /// ```
 #[derive(Clone)]
 pub struct Version {
-    /// The [versioning epoch](https://peps.python.org/pep-0440/#version-epochs). Normally just 0,
-    /// but you can increment it if you switched the versioning scheme.
-    epoch: u64,
-    /// The normal number part of the version
-    /// (["final release"](https://peps.python.org/pep-0440/#final-releases)),
-    /// such a `1.2.3` in `4!1.2.3-a8.post9.dev1`
-    ///
-    /// Note that we drop the * placeholder by moving it to `Operator`
-    release: Vec,
-    /// The [prerelease](https://peps.python.org/pep-0440/#pre-releases), i.e. alpha, beta or rc
-    /// plus a number
-    ///
-    /// Note that whether this is Some influences the version
-    /// range matching since normally we exclude all prerelease versions
-    pre: Option<(PreRelease, u64)>,
-    /// The [Post release version](https://peps.python.org/pep-0440/#post-releases),
-    /// higher post version are preferred over lower post or none-post versions
-    post: Option,
-    /// The [developmental release](https://peps.python.org/pep-0440/#developmental-releases),
-    /// if any
-    dev: Option,
-    /// A [local version identifier](https://peps.python.org/pep-0440/#local-version-identifiers)
-    /// such as `+deadbeef` in `1.2.3+deadbeef`
-    ///
-    /// > They consist of a normal public version identifier (as defined in the previous section),
-    /// > along with an arbitrary “local version label”, separated from the public version
-    /// > identifier by a plus. Local version labels have no specific semantics assigned, but some
-    /// > syntactic restrictions are imposed.
-    local: Option>,
+    inner: Arc,
+}
+
+#[derive(Clone, Debug)]
+enum VersionInner {
+    Small { small: VersionSmall },
+    Full { full: VersionFull },
 }
 
 impl Version {
     /// Create a new version from an iterator of segments in the release part
     /// of a version.
+    ///
+    /// # Panics
+    ///
+    /// When the iterator yields no elements.
     #[inline]
     pub fn new(release_numbers: I) -> Version
     where
@@ -306,35 +265,13 @@ impl Version {
         R: Borrow,
     {
         Version {
-            epoch: 0,
-            release: vec![],
-            pre: None,
-            post: None,
-            dev: None,
-            local: None,
+            inner: Arc::new(VersionInner::Small {
+                small: VersionSmall::new(),
+            }),
         }
         .with_release(release_numbers)
     }
 
-    /// Like [`Self::from_str`], but also allows the version to end with a star
-    /// and returns whether it did. This variant is for use in specifiers.
-    ///
-    ///  * `1.2.3` -> false
-    ///  * `1.2.3.*` -> true
-    ///  * `1.2.*.4` -> err
-    ///  * `1.0-dev1.*` -> err
-    pub fn from_str_star(version: &str) -> Result<(Self, bool), String> {
-        if let Some(v) = version_fast_parse(version) {
-            return Ok((v, false));
-        }
-
-        let captures = VERSION_RE
-            .captures(version)
-            .ok_or_else(|| format!("Version `{version}` doesn't match PEP 440 rules"))?;
-        let (version, star) = Version::parse_impl(&captures)?;
-        Ok((version, star))
-    }
-
     /// Whether this is an alpha/beta/rc or dev version
     #[inline]
     pub fn any_prerelease(&self) -> bool {
@@ -344,61 +281,82 @@ impl Version {
     /// Whether this is an alpha/beta/rc version
     #[inline]
     pub fn is_pre(&self) -> bool {
-        self.pre.is_some()
+        self.pre().is_some()
     }
 
     /// Whether this is a dev version
     #[inline]
     pub fn is_dev(&self) -> bool {
-        self.dev.is_some()
+        self.dev().is_some()
     }
 
     /// Whether this is a post version
     #[inline]
     pub fn is_post(&self) -> bool {
-        self.post.is_some()
+        self.post().is_some()
     }
 
     /// Whether this is a local version (e.g. `1.2.3+localsuffixesareweird`)
+    ///
+    /// When true, it is guaranteed that the slice returned by
+    /// [`Version::local`] is non-empty.
     #[inline]
     pub fn is_local(&self) -> bool {
-        self.local.is_some()
+        !self.local().is_empty()
     }
 
     /// Returns the epoch of this version.
     #[inline]
     pub fn epoch(&self) -> u64 {
-        self.epoch
+        match *self.inner {
+            VersionInner::Small { ref small } => small.epoch(),
+            VersionInner::Full { ref full } => full.epoch,
+        }
     }
 
     /// Returns the release number part of the version.
     #[inline]
     pub fn release(&self) -> &[u64] {
-        &self.release
+        match *self.inner {
+            VersionInner::Small { ref small } => small.release(),
+            VersionInner::Full { ref full, .. } => &full.release,
+        }
     }
 
     /// Returns the pre-relase part of this version, if it exists.
     #[inline]
     pub fn pre(&self) -> Option<(PreRelease, u64)> {
-        self.pre
+        match *self.inner {
+            VersionInner::Small { ref small } => small.pre(),
+            VersionInner::Full { ref full } => full.pre,
+        }
     }
 
     /// Returns the post-release part of this version, if it exists.
     #[inline]
     pub fn post(&self) -> Option {
-        self.post
+        match *self.inner {
+            VersionInner::Small { ref small } => small.post(),
+            VersionInner::Full { ref full } => full.post,
+        }
     }
 
     /// Returns the dev-release part of this version, if it exists.
     #[inline]
     pub fn dev(&self) -> Option {
-        self.dev
+        match *self.inner {
+            VersionInner::Small { ref small } => small.dev(),
+            VersionInner::Full { ref full } => full.dev,
+        }
     }
 
     /// Returns the local segments in this version, if any exist.
     #[inline]
-    pub fn local(&self) -> Option<&[LocalSegment]> {
-        self.local.as_deref()
+    pub fn local(&self) -> &[LocalSegment] {
+        match *self.inner {
+            VersionInner::Small { ref small } => small.local(),
+            VersionInner::Full { ref full } => &full.local,
+        }
     }
 
     /// Set the release numbers and return the updated version.
@@ -407,48 +365,109 @@ impl Version {
     /// the updated release numbers, but this is useful when one wants to
     /// preserve the other components of a version number while only changing
     /// the release numbers.
+    ///
+    /// # Panics
+    ///
+    /// When the iterator yields no elements.
     #[inline]
-    pub fn with_release(self, release_numbers: I) -> Version
+    pub fn with_release(mut self, release_numbers: I) -> Version
     where
         I: IntoIterator,
         R: Borrow,
     {
-        Version {
-            release: release_numbers.into_iter().map(|r| *r.borrow()).collect(),
-            ..self
+        self.clear_release();
+        for n in release_numbers {
+            self.push_release(*n.borrow());
+        }
+        assert!(
+            !self.release().is_empty(),
+            "release must have non-zero size"
+        );
+        self
+    }
+
+    /// Push the given release number into this version. It will become the
+    /// last number in the release component.
+    #[inline]
+    fn push_release(&mut self, n: u64) {
+        if let VersionInner::Small { ref mut small } = Arc::make_mut(&mut self.inner) {
+            if small.push_release(n) {
+                return;
+            }
+        }
+        self.make_full().release.push(n);
+    }
+
+    /// Clears the release component of this version so that it has no numbers.
+    ///
+    /// Generally speaking, this empty state should not be exposed to callers
+    /// since all versions should have at least one release number.
+    #[inline]
+    fn clear_release(&mut self) {
+        match Arc::make_mut(&mut self.inner) {
+            VersionInner::Small { ref mut small } => small.clear_release(),
+            VersionInner::Full { ref mut full } => {
+                full.release.clear();
+            }
         }
     }
 
     /// Set the epoch and return the updated version.
     #[inline]
-    pub fn with_epoch(self, epoch: u64) -> Version {
-        Version { epoch, ..self }
+    pub fn with_epoch(mut self, value: u64) -> Version {
+        if let VersionInner::Small { ref mut small } = Arc::make_mut(&mut self.inner) {
+            if small.set_epoch(value) {
+                return self;
+            }
+        }
+        self.make_full().epoch = value;
+        self
     }
 
     /// Set the pre-release component and return the updated version.
     #[inline]
-    pub fn with_pre(self, pre: Option<(PreRelease, u64)>) -> Version {
-        Version { pre, ..self }
+    pub fn with_pre(mut self, value: Option<(PreRelease, u64)>) -> Version {
+        if let VersionInner::Small { ref mut small } = Arc::make_mut(&mut self.inner) {
+            if small.set_pre(value) {
+                return self;
+            }
+        }
+        self.make_full().pre = value;
+        self
     }
 
     /// Set the post-release component and return the updated version.
     #[inline]
-    pub fn with_post(self, post: Option) -> Version {
-        Version { post, ..self }
+    pub fn with_post(mut self, value: Option) -> Version {
+        if let VersionInner::Small { ref mut small } = Arc::make_mut(&mut self.inner) {
+            if small.set_post(value) {
+                return self;
+            }
+        }
+        self.make_full().post = value;
+        self
     }
 
     /// Set the dev-release component and return the updated version.
     #[inline]
-    pub fn with_dev(self, dev: Option) -> Version {
-        Version { dev, ..self }
+    pub fn with_dev(mut self, value: Option) -> Version {
+        if let VersionInner::Small { ref mut small } = Arc::make_mut(&mut self.inner) {
+            if small.set_dev(value) {
+                return self;
+            }
+        }
+        self.make_full().dev = value;
+        self
     }
 
     /// Set the local segments and return the updated version.
     #[inline]
-    pub fn with_local(self, local: Vec) -> Version {
-        Version {
-            local: Some(local),
-            ..self
+    pub fn with_local(mut self, value: Vec) -> Version {
+        if value.is_empty() {
+            self.without_local()
+        } else {
+            self.make_full().local = value;
+            self
         }
     }
 
@@ -457,113 +476,69 @@ impl Version {
     /// and local version labels MUST be ignored entirely when checking if
     /// candidate versions match a given version specifier."
     #[inline]
-    pub fn without_local(self) -> Version {
-        Version {
-            local: None,
-            ..self
+    pub fn without_local(mut self) -> Version {
+        // A "small" version is already guaranteed not to have a local
+        // component, so we only need to do anything if we have a "full"
+        // version.
+        if let VersionInner::Full { ref mut full } = Arc::make_mut(&mut self.inner) {
+            full.local.clear();
+        }
+        self
+    }
+
+    /// Convert this version to a "full" representation in-place and return a
+    /// mutable borrow to the full type.
+    fn make_full(&mut self) -> &mut VersionFull {
+        if let VersionInner::Small { ref small } = *self.inner {
+            let full = VersionFull {
+                epoch: small.epoch(),
+                release: small.release().to_vec(),
+                pre: small.pre(),
+                post: small.post(),
+                dev: small.dev(),
+                local: vec![],
+            };
+            *self = Version {
+                inner: Arc::new(VersionInner::Full { full }),
+            };
+        }
+        match Arc::make_mut(&mut self.inner) {
+            VersionInner::Full { ref mut full } => full,
+            VersionInner::Small { .. } => unreachable!(),
         }
     }
 
-    /// Extracted for reusability around star/non-star
-    pub(crate) fn parse_impl(captures: &Captures) -> Result<(Version, bool), String> {
-        let number_field = |field_name| {
-            if let Some(field_str) = captures.name(field_name) {
-                match field_str.as_str().parse::() {
-                    Ok(number) => Ok(Some(number)),
-                    // Should be already forbidden by the regex
-                    Err(err) => Err(format!(
-                        "Couldn't parse '{}' as number from {}: {}",
-                        field_str.as_str(),
-                        field_name,
-                        err
-                    )),
-                }
-            } else {
-                Ok(None)
+    /// Performs a "slow" but complete comparison between two versions.
+    ///
+    /// This comparison is done using only the public API of a `Version`, and
+    /// is thus independent of its specific representation. This is useful
+    /// to use when comparing two versions that aren't *both* the small
+    /// representation.
+    #[cold]
+    #[inline(never)]
+    fn cmp_slow(&self, other: &Version) -> Ordering {
+        match self.epoch().cmp(&other.epoch()) {
+            Ordering::Less => {
+                return Ordering::Less;
             }
-        };
-        let epoch = number_field("epoch")?
-            // "If no explicit epoch is given, the implicit epoch is 0"
-            .unwrap_or_default();
-        let pre = {
-            let pre_type = captures
-                .name("pre_name")
-                .map(|pre| PreRelease::from_str(pre.as_str()))
-                // Shouldn't fail due to the regex
-                .transpose()?;
-            let pre_number = number_field("pre")?
-                // 
-                .unwrap_or_default();
-            pre_type.map(|pre_type| (pre_type, pre_number))
-        };
-        let post = if captures.name("post_field").is_some() {
-            // While PEP 440 says .post is "followed by a non-negative integer value",
-            // packaging has tests that ensure that it defaults to 0
-            // https://github.com/pypa/packaging/blob/237ff3aa348486cf835a980592af3a59fccd6101/tests/test_version.py#L187-L202
-            Some(
-                number_field("post_new")?
-                    .or(number_field("post_old")?)
-                    .unwrap_or_default(),
-            )
-        } else {
-            None
-        };
-        let dev = if captures.name("dev_field").is_some() {
-            // 
-            Some(number_field("dev")?.unwrap_or_default())
-        } else {
-            None
-        };
-        let local = captures.name("local").map(|local| {
-            local
-                .as_str()
-                .split(&['-', '_', '.'][..])
-                .map(|segment| {
-                    if let Ok(number) = segment.parse::() {
-                        LocalSegment::Number(number)
-                    } else {
-                        // "and if a segment contains any ASCII letters then that segment is compared lexicographically with case insensitivity"
-                        LocalSegment::String(segment.to_lowercase())
-                    }
-                })
-                .collect()
-        });
-        let release = captures
-            .name("release")
-            // Should be forbidden by the regex
-            .ok_or_else(|| "No release in version".to_string())?
-            .as_str()
-            .split('.')
-            .map(|segment| segment.parse::().map_err(|err| err.to_string()))
-            .collect::, String>>()?;
-
-        let star = captures.name("trailing_dot_star").is_some();
-        if star {
-            if pre.is_some() {
-                return Err(
-                    "You can't have both a trailing `.*` and a prerelease version".to_string(),
-                );
-            }
-            if post.is_some() {
-                return Err("You can't have both a trailing `.*` and a post version".to_string());
-            }
-            if dev.is_some() {
-                return Err("You can't have both a trailing `.*` and a dev version".to_string());
-            }
-            if local.is_some() {
-                return Err("You can't have both a trailing `.*` and a local version".to_string());
+            Ordering::Equal => {}
+            Ordering::Greater => {
+                return Ordering::Greater;
             }
         }
 
-        let version = Version {
-            epoch,
-            release,
-            pre,
-            post,
-            dev,
-            local,
-        };
-        Ok((version, star))
+        match compare_release(self.release(), other.release()) {
+            Ordering::Less => {
+                return Ordering::Less;
+            }
+            Ordering::Equal => {}
+            Ordering::Greater => {
+                return Ordering::Greater;
+            }
+        }
+
+        // release is equal, so compare the other parts
+        sortable_tuple(self).cmp(&sortable_tuple(other))
     }
 }
 
@@ -593,41 +568,42 @@ impl Serialize for Version {
 /// Shows normalized version
 impl std::fmt::Display for Version {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let epoch = if self.epoch == 0 {
+        let epoch = if self.epoch() == 0 {
             String::new()
         } else {
-            format!("{}!", self.epoch)
+            format!("{}!", self.epoch())
         };
         let release = self
-            .release
+            .release()
             .iter()
             .map(ToString::to_string)
             .collect::>()
             .join(".");
         let pre = self
-            .pre
+            .pre()
             .as_ref()
             .map(|(pre_kind, pre_version)| format!("{pre_kind}{pre_version}"))
             .unwrap_or_default();
         let post = self
-            .post
+            .post()
             .map(|post| format!(".post{post}"))
             .unwrap_or_default();
-        let dev = self.dev.map(|dev| format!(".dev{dev}")).unwrap_or_default();
-        let local = self
-            .local
-            .as_ref()
-            .map(|segments| {
-                format!(
-                    "+{}",
-                    segments
-                        .iter()
-                        .map(std::string::ToString::to_string)
-                        .collect::>()
-                        .join(".")
-                )
-            })
+        let dev = self
+            .dev()
+            .map(|dev| format!(".dev{dev}"))
             .unwrap_or_default();
+        let local = if self.local().is_empty() {
+            String::new()
+        } else {
+            format!(
+                "+{}",
+                self.local()
+                    .iter()
+                    .map(std::string::ToString::to_string)
+                    .collect::>()
+                    .join(".")
+            )
+        };
         write!(f, "{epoch}{release}{pre}{post}{dev}{local}")
     }
 }
@@ -639,6 +615,7 @@ impl std::fmt::Debug for Version {
 }
 
 impl PartialEq for Version {
+    #[inline]
     fn eq(&self, other: &Self) -> bool {
         self.cmp(other) == Ordering::Equal
     }
@@ -648,20 +625,22 @@ impl Eq for Version {}
 
 impl Hash for Version {
     /// Custom implementation to ignoring trailing zero because `PartialEq` zero pads
+    #[inline]
     fn hash(&self, state: &mut H) {
-        self.epoch.hash(state);
+        self.epoch().hash(state);
         // Skip trailing zeros
-        for i in self.release.iter().rev().skip_while(|x| **x == 0) {
+        for i in self.release().iter().rev().skip_while(|x| **x == 0) {
             i.hash(state);
         }
-        self.pre.hash(state);
-        self.dev.hash(state);
-        self.post.hash(state);
-        self.local.hash(state);
+        self.pre().hash(state);
+        self.dev().hash(state);
+        self.post().hash(state);
+        self.local().hash(state);
     }
 }
 
 impl PartialOrd for Version {
+    #[inline]
     fn partial_cmp(&self, other: &Self) -> Option {
         Some(self.cmp(other))
     }
@@ -671,52 +650,421 @@ impl Ord for Version {
     /// 1.0.dev456 < 1.0a1 < 1.0a2.dev456 < 1.0a12.dev456 < 1.0a12 < 1.0b1.dev456 < 1.0b2
     /// < 1.0b2.post345.dev456 < 1.0b2.post345 < 1.0b2-346 < 1.0c1.dev456 < 1.0c1 < 1.0rc2 < 1.0c3
     /// < 1.0 < 1.0.post456.dev34 < 1.0.post456
+    #[inline]
     fn cmp(&self, other: &Self) -> Ordering {
-        match self.epoch.cmp(&other.epoch) {
-            Ordering::Less => {
-                return Ordering::Less;
-            }
-            Ordering::Equal => {}
-            Ordering::Greater => {
-                return Ordering::Greater;
+        match (&*self.inner, &*other.inner) {
+            (VersionInner::Small { small: small1 }, VersionInner::Small { small: small2 }) => {
+                small1.repr.cmp(&small2.repr)
             }
+            _ => self.cmp_slow(other),
         }
-
-        match compare_release(&self.release, &other.release) {
-            Ordering::Less => {
-                return Ordering::Less;
-            }
-            Ordering::Equal => {}
-            Ordering::Greater => {
-                return Ordering::Greater;
-            }
-        }
-
-        // release is equal, so compare the other parts
-        sortable_tuple(self).cmp(&sortable_tuple(other))
     }
 }
 
 impl FromStr for Version {
-    type Err = String;
+    type Err = VersionParseError;
 
     /// Parses a version such as `1.19`, `1.0a1`,`1.0+abc.5` or `1!2012.2`
     ///
     /// Note that this variant doesn't allow the version to end with a star, see
     /// [`Self::from_str_star`] if you want to parse versions for specifiers
     fn from_str(version: &str) -> Result {
-        if let Some(v) = version_fast_parse(version) {
-            return Ok(v);
-        }
+        Parser::new(version.as_bytes()).parse()
+    }
+}
 
-        let captures = VERSION_RE
-            .captures(version)
-            .ok_or_else(|| format!("Version `{version}` doesn't match PEP 440 rules"))?;
-        let (version, star) = Version::parse_impl(&captures)?;
-        if star {
-            return Err("A star (`*`) must not be used in a fixed version (use `Version::from_string_star` otherwise)".to_string());
+/// A "small" representation of a version.
+///
+/// This representation is used for a (very common) subset of versions: the
+/// set of all versions with ~small numbers and no local component. The
+/// representation is designed to be (somewhat) compact, but also laid out in
+/// a way that makes comparisons between two small versions equivalent to a
+/// simple `memcmp`.
+///
+/// The methods on this type encapsulate the representation. Since this type
+/// cannot represent the full range of all versions, setters on this type will
+/// return `false` if the value could not be stored. In this case, callers
+/// should generally convert a version into its "full" representation and then
+/// set the value on the full type.
+///
+/// # Representation
+///
+/// At time of writing, this representation supports versions that meet all of
+/// the following criteria:
+///
+/// * The epoch must be `0`.
+/// * The release portion must have 4 or fewer segments.
+/// * All release segments, except for the first, must be representable in a
+/// `u8`. The first segment must be representable in a `u16`. (This permits
+/// calendar versions, like `2023.03`, to be represented.)
+/// * There is *at most* one of the following components: pre, dev or post.
+/// * If there is a pre segment, then its numeric value is less than 64.
+/// * If there is a dev or post segment, then its value is less than u8::MAX.
+/// * There are zero "local" segments.
+///
+/// The above constraints were chosen as a balancing point between being able
+/// to represent all parts of a version in a very small amount of space,
+/// and for supporting as many versions in the wild as possible. There is,
+/// however, another constraint in play here: comparisons between two `Version`
+/// values. It turns out that we do a lot of them as part of resolution, and
+/// the cheaper we can make that, the better. This constraint pushes us
+/// toward using as little space as possible. Indeed, here, comparisons are
+/// implemented via `u64::cmp`.
+///
+/// We pack versions fitting the above constraints into a `u64` in such a way
+/// that it preserves the ordering between versions as prescribed in PEP 440.
+/// Namely:
+///
+/// * Bytes 6 and 7 correspond to the first release segment as a `u16`.
+/// * Bytes 5, 4 and 3 correspond to the second, third and fourth release
+/// segments, respectively.
+/// * Byte 2 corresponds to the post-release segment. If there is no
+/// post-release segment, then byte 2 is set to 0x00. This makes "no
+/// post-release" sort before "has post-release." The numeric value
+/// (constrained to be  1.2.3rc9999`.
+///
+/// Notice also that nothing about the representation inherently prohibits
+/// storing any combination of pre, dev or post release components. We
+/// could absolutely store all three (assuming they fit into their various
+/// constraints outlined above). But, if we did that, a simple `u64::cmp` would
+/// no longer be correct. For example, `1.0.post456.dev34 < 1.0.post456`, but
+/// in the representation above, it would treat `1.0.post456.dev34` as greater
+/// than `1.0.post456`. To make comparisons cheap for multi-component versions
+/// like that, we'd need to use more space. Thankfully, such versions are
+/// incredibly rare. Virtually all versions have zero or one pre, dev or post
+/// release components.
+#[derive(Clone, Debug)]
+struct VersionSmall {
+    /// The representation discussed above.
+    repr: u64,
+    /// The `u64` numbers in the release component.
+    ///
+    /// These are *only* used to implement the public API `Version::release`
+    /// method. This is necessary in order to provide a `&[u64]` to the caller.
+    /// If we didn't need the public API, or could re-work it, then we could
+    /// get rid of this extra storage. (Which is indeed duplicative of what is
+    /// stored in `repr`.) Note that this uses `u64` not because it can store
+    /// bigger numbers than what's in `repr` (it can't), but so that it permits
+    /// us to return a `&[u64]`.
+    ///
+    /// I believe there is only one way to get rid of this extra storage:
+    /// change the public API so that it doesn't return a `&[u64]`. Instead,
+    /// we'd return a new type that conceptually represents a `&[u64]`, but may
+    /// use a different representation based on what kind of `Version` it came
+    /// from. The downside of this approach is that one loses the flexibility
+    /// of a simple `&[u64]`. (Which, at time of writing, is taken advantage of
+    /// in several places via slice patterns.) But, if we needed to change it,
+    /// we could do it without losing expressivity, but losing convenience.
+    release: [u64; 4],
+    /// The number of segments in the release component.
+    ///
+    /// Strictly speaking, this isn't necessary since `1.2` is considered
+    /// equivalent to `1.2.0.0`. But in practice it's nice to be able
+    /// to truncate the zero components. And always filling out to 4
+    /// places somewhat exposes internal details, since the "full" version
+    /// representation would not do that.
+    len: u8,
+}
+
+impl VersionSmall {
+    #[inline]
+    fn new() -> VersionSmall {
+        VersionSmall {
+            repr: 0x00000000_0000FFFF,
+            release: [0, 0, 0, 0],
+            len: 0,
         }
-        Ok(version)
+    }
+
+    #[inline]
+    fn epoch(&self) -> u64 {
+        0
+    }
+
+    #[inline]
+    fn set_epoch(&mut self, value: u64) -> bool {
+        if value != 0 {
+            return false;
+        }
+        true
+    }
+
+    #[inline]
+    fn release(&self) -> &[u64] {
+        &self.release[..usize::from(self.len)]
+    }
+
+    #[inline]
+    fn clear_release(&mut self) {
+        self.repr &= !0xFFFFFFFF_FF000000;
+        self.release = [0, 0, 0, 0];
+        self.len = 0;
+    }
+
+    #[inline]
+    fn push_release(&mut self, n: u64) -> bool {
+        if self.len == 0 {
+            if n > u64::from(u16::MAX) {
+                return false;
+            }
+            self.repr |= n << 48;
+            self.release[0] = n;
+            self.len = 1;
+            true
+        } else {
+            if n > u64::from(u8::MAX) {
+                return false;
+            }
+            if self.len >= 4 {
+                return false;
+            }
+            let shift = 48 - (usize::from(self.len) * 8);
+            self.repr |= n << shift;
+            self.release[usize::from(self.len)] = n;
+            self.len += 1;
+            true
+        }
+    }
+
+    #[inline]
+    fn post(&self) -> Option {
+        let v = (self.repr >> 16) & 0xFF;
+        if v == 0 {
+            None
+        } else {
+            Some(v - 1)
+        }
+    }
+
+    #[inline]
+    fn set_post(&mut self, value: Option) -> bool {
+        if value.is_some() && (self.pre().is_some() || self.dev().is_some()) {
+            return false;
+        }
+        match value {
+            None => {
+                self.repr &= !(0xFF << 16);
+            }
+            Some(number) => {
+                if number > 0b1111_1110 {
+                    return false;
+                }
+                self.repr &= !(0xFF << 16);
+                self.repr |= (number + 1) << 16;
+            }
+        }
+        true
+    }
+
+    #[inline]
+    fn pre(&self) -> Option<(PreRelease, u64)> {
+        let v = (self.repr >> 8) & 0xFF;
+        if v == 0xFF {
+            return None;
+        }
+        let number = v & 0b0011_1111;
+        let kind = match v >> 6 {
+            0 => PreRelease::Alpha,
+            1 => PreRelease::Beta,
+            2 => PreRelease::Rc,
+            _ => unreachable!(),
+        };
+        Some((kind, number))
+    }
+
+    #[inline]
+    fn set_pre(&mut self, value: Option<(PreRelease, u64)>) -> bool {
+        if value.is_some() && (self.post().is_some() || self.dev().is_some()) {
+            return false;
+        }
+        match value {
+            None => {
+                self.repr |= 0xFF << 8;
+            }
+            Some((kind, number)) => {
+                if number > 0b0011_1111 {
+                    return false;
+                }
+                let kind = match kind {
+                    PreRelease::Alpha => 0,
+                    PreRelease::Beta => 1,
+                    PreRelease::Rc => 2,
+                };
+                self.repr &= !(0xFF << 8);
+                self.repr |= ((kind << 6) | number) << 8;
+            }
+        }
+        true
+    }
+
+    #[inline]
+    fn dev(&self) -> Option {
+        let v = self.repr & 0xFF;
+        if v == 0xFF {
+            None
+        } else {
+            Some(v)
+        }
+    }
+
+    #[inline]
+    fn set_dev(&mut self, value: Option) -> bool {
+        if value.is_some() && (self.pre().is_some() || self.post().is_some()) {
+            return false;
+        }
+        match value {
+            None => {
+                self.repr |= 0xFF;
+            }
+            Some(number) => {
+                if number > 0b1111_1110 {
+                    return false;
+                }
+                self.repr &= !0xFF;
+                self.repr |= number;
+            }
+        }
+        true
+    }
+
+    #[inline]
+    fn local(&self) -> &[LocalSegment] {
+        // A "small" version is never used if the version has a non-zero number
+        // of local segments.
+        &[]
+    }
+}
+
+/// The "full" representation of a version.
+///
+/// This can represent all possible versions, but is a bit beefier because of
+/// it. It also uses some indirection for variable length data such as the
+/// release numbers and the local segments.
+///
+/// In general, the "full" representation is rarely used in practice since most
+/// versions will fit into the "small" representation.
+#[derive(Clone, Debug)]
+struct VersionFull {
+    /// The [versioning
+    /// epoch](https://peps.python.org/pep-0440/#version-epochs). Normally
+    /// just 0, but you can increment it if you switched the versioning
+    /// scheme.
+    epoch: u64,
+    /// The normal number part of the version (["final
+    /// release"](https://peps.python.org/pep-0440/#final-releases)), such
+    /// a `1.2.3` in `4!1.2.3-a8.post9.dev1`
+    ///
+    /// Note that we drop the * placeholder by moving it to `Operator`
+    release: Vec,
+    /// The [prerelease](https://peps.python.org/pep-0440/#pre-releases),
+    /// i.e. alpha, beta or rc plus a number
+    ///
+    /// Note that whether this is Some influences the version range
+    /// matching since normally we exclude all prerelease versions
+    pre: Option<(PreRelease, u64)>,
+    /// The [Post release
+    /// version](https://peps.python.org/pep-0440/#post-releases), higher
+    /// post version are preferred over lower post or none-post versions
+    post: Option,
+    /// The [developmental
+    /// release](https://peps.python.org/pep-0440/#developmental-releases),
+    /// if any
+    dev: Option,
+    /// A [local version
+    /// identifier](https://peps.python.org/pep-0440/#local-version-identif
+    /// iers) such as `+deadbeef` in `1.2.3+deadbeef`
+    ///
+    /// > They consist of a normal public version identifier (as defined
+    /// > in the previous section), along with an arbitrary “local version
+    /// > label”, separated from the public version identifier by a plus.
+    /// > Local version labels have no specific semantics assigned, but
+    /// > some syntactic restrictions are imposed.
+    local: Vec,
+}
+
+/// A version number pattern.
+///
+/// A version pattern appears in a
+/// [`VersionSpecifier`](crate::VersionSpecifier). It is just like a version,
+/// except that it permits a trailing `*` (wildcard) at the end of the version
+/// number. The wildcard indicates that any version with the same prefix should
+/// match.
+///
+/// A `VersionPattern` cannot do any matching itself. Instead,
+/// it needs to be paired with an [`Operator`] to create a
+/// [`VersionSpecifier`](crate::VersionSpecifier).
+///
+/// Here are some valid and invalid examples:
+///
+/// * `1.2.3` -> verbatim pattern
+/// * `1.2.3.*` -> wildcard pattern
+/// * `1.2.*.4` -> invalid
+/// * `1.0-dev1.*` -> invalid
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub struct VersionPattern {
+    version: Version,
+    wildcard: bool,
+}
+
+impl VersionPattern {
+    /// Creates a new verbatim version pattern that matches the given
+    /// version exactly.
+    #[inline]
+    pub fn verbatim(version: Version) -> VersionPattern {
+        VersionPattern {
+            version,
+            wildcard: false,
+        }
+    }
+
+    /// Creates a new wildcard version pattern that matches any version with
+    /// the given version as a prefix.
+    #[inline]
+    pub fn wildcard(version: Version) -> VersionPattern {
+        VersionPattern {
+            version,
+            wildcard: true,
+        }
+    }
+
+    /// Returns the underlying version.
+    #[inline]
+    pub fn version(&self) -> &Version {
+        &self.version
+    }
+
+    /// Consumes this pattern and returns ownership of the underlying version.
+    #[inline]
+    pub fn into_version(self) -> Version {
+        self.version
+    }
+
+    /// Returns true if and only if this pattern contains a wildcard.
+    #[inline]
+    pub fn is_wildcard(&self) -> bool {
+        self.wildcard
+    }
+}
+
+impl FromStr for VersionPattern {
+    type Err = VersionPatternParseError;
+
+    fn from_str(version: &str) -> Result {
+        Parser::new(version.as_bytes()).parse_pattern()
     }
 }
 
@@ -734,21 +1082,6 @@ pub enum PreRelease {
     Rc,
 }
 
-impl FromStr for PreRelease {
-    type Err = String;
-
-    fn from_str(prerelease: &str) -> Result {
-        match prerelease.to_lowercase().as_str() {
-            "a" | "alpha" => Ok(Self::Alpha),
-            "b" | "beta" => Ok(Self::Beta),
-            "c" | "rc" | "pre" | "preview" => Ok(Self::Rc),
-            _ => Err(format!(
-                "'{prerelease}' isn't recognized as alpha, beta or release candidate",
-            )),
-        }
-    }
-}
-
 impl std::fmt::Display for PreRelease {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
@@ -797,20 +1130,6 @@ impl PartialOrd for LocalSegment {
     }
 }
 
-impl FromStr for LocalSegment {
-    /// This can be a never type when stabilized
-    type Err = ();
-
-    fn from_str(segment: &str) -> Result {
-        Ok(if let Ok(number) = segment.parse::() {
-            Self::Number(number)
-        } else {
-            // "and if a segment contains any ASCII letters then that segment is compared lexicographically with case insensitivity"
-            Self::String(segment.to_lowercase())
-        })
-    }
-}
-
 impl Ord for LocalSegment {
     fn cmp(&self, other: &Self) -> Ordering {
         // 
@@ -823,6 +1142,827 @@ impl Ord for LocalSegment {
     }
 }
 
+/// The state used for [parsing a version][pep440].
+///
+/// This parses the most "flexible" format of a version as described in the
+/// "normalization" section of PEP 440.
+///
+/// This can also parse a version "pattern," which essentially is just like
+/// parsing a version, but permits a trailing wildcard. e.g., `1.2.*`.
+///
+/// [pep440]: https://packaging.python.org/en/latest/specifications/version-specifiers/
+#[derive(Debug)]
+struct Parser<'a> {
+    /// The version string we are parsing.
+    v: &'a [u8],
+    /// The current position of the parser.
+    i: usize,
+    /// The epoch extracted from the version.
+    epoch: u64,
+    /// The release numbers extracted from the version.
+    release: ReleaseNumbers,
+    /// The pre-release version, if any.
+    pre: Option<(PreRelease, u64)>,
+    /// The post-release version, if any.
+    post: Option,
+    /// The dev release, if any.
+    dev: Option,
+    /// The local segments, if any.
+    local: Vec,
+    /// Whether a wildcard at the end of the version was found or not.
+    ///
+    /// This is only valid when a version pattern is being parsed.
+    wildcard: bool,
+}
+
+impl<'a> Parser<'a> {
+    /// The "separators" that are allowed in several different parts of a
+    /// version.
+    const SEPARATOR: ByteSet = ByteSet::new(&[b'.', b'_', b'-']);
+
+    /// Create a new `Parser` for parsing the version in the given byte string.
+    fn new(version: &'a [u8]) -> Parser<'a> {
+        Parser {
+            v: version,
+            i: 0,
+            epoch: 0,
+            release: ReleaseNumbers::new(),
+            pre: None,
+            post: None,
+            dev: None,
+            local: vec![],
+            wildcard: false,
+        }
+    }
+
+    /// Parse a verbatim version.
+    ///
+    /// If a version pattern is found, then an error is returned.
+    fn parse(self) -> Result {
+        match self.parse_pattern() {
+            Ok(vpat) => {
+                if !vpat.is_wildcard() {
+                    Ok(vpat.into_version())
+                } else {
+                    Err(ErrorKind::Wildcard.into())
+                }
+            }
+            // If we get an error when parsing a version pattern, then
+            // usually it will actually just be a VersionParseError.
+            // But if it's specific to version patterns, and since
+            // we are expecting a verbatim version here, we can just
+            // return a generic "wildcards not allowed" error in that
+            // case.
+            Err(err) => match *err.kind {
+                PatternErrorKind::Version(err) => Err(err),
+                PatternErrorKind::WildcardNotTrailing => Err(ErrorKind::Wildcard.into()),
+            },
+        }
+    }
+
+    /// Parse a version pattern, which may be a verbatim version.
+    fn parse_pattern(mut self) -> Result {
+        if let Some(vpat) = self.parse_fast() {
+            return Ok(vpat);
+        }
+        self.bump_while(|byte| byte.is_ascii_whitespace());
+        self.bump_if("v");
+        self.parse_epoch_and_initial_release()?;
+        self.parse_rest_of_release()?;
+        if self.parse_wildcard()? {
+            return Ok(self.into_pattern());
+        }
+        self.parse_pre()?;
+        self.parse_post()?;
+        self.parse_dev()?;
+        self.parse_local()?;
+        self.bump_while(|byte| byte.is_ascii_whitespace());
+        if !self.is_done() {
+            let remaining = String::from_utf8_lossy(&self.v[self.i..]).into_owned();
+            let version = self.into_pattern().version;
+            return Err(ErrorKind::UnexpectedEnd { version, remaining }.into());
+        }
+        Ok(self.into_pattern())
+    }
+
+    /// Attempts to do a "fast parse" of a version.
+    ///
+    /// This looks for versions of the form `w[.x[.y[.z]]]` while
+    /// simultaneously parsing numbers. This format corresponds to the
+    /// overwhelming majority of all version strings and can avoid most of the
+    /// work done in the more general parser.
+    ///
+    /// If the version string is not in the format of `w[.x[.y[.z]]]`, then
+    /// this returns `None`.
+    fn parse_fast(&self) -> Option {
+        let (mut prev_digit, mut cur, mut release, mut len) = (false, 0u8, [0u8; 4], 0u8);
+        for &byte in self.v {
+            if byte == b'.' {
+                if !prev_digit {
+                    return None;
+                }
+                prev_digit = false;
+                *release.get_mut(usize::from(len))? = cur;
+                len += 1;
+                cur = 0;
+            } else {
+                let digit = byte.checked_sub(b'0')?;
+                if digit > 9 {
+                    return None;
+                }
+                prev_digit = true;
+                cur = cur.checked_mul(10)?.checked_add(digit)?;
+            }
+        }
+        if !prev_digit {
+            return None;
+        }
+        *release.get_mut(usize::from(len))? = cur;
+        len += 1;
+        let small = VersionSmall {
+            // Clippy warns about no-ops like `(0x00 << 16)`, but I
+            // think it makes the bit logic much clearer, and makes it
+            // explicit that nothing was forgotten.
+            #[allow(clippy::identity_op)]
+            repr: (u64::from(release[0]) << 48)
+                | (u64::from(release[1]) << 40)
+                | (u64::from(release[2]) << 32)
+                | (u64::from(release[3]) << 24)
+                | (0x00 << 16)
+                | (0xFF << 8)
+                | (0xFF << 0),
+            release: [
+                u64::from(release[0]),
+                u64::from(release[1]),
+                u64::from(release[2]),
+                u64::from(release[3]),
+            ],
+            len,
+        };
+        let inner = Arc::new(VersionInner::Small { small });
+        let version = Version { inner };
+        Some(VersionPattern {
+            version,
+            wildcard: false,
+        })
+    }
+
+    /// Parses an optional initial epoch number and the first component of the
+    /// release part of a version number. In all cases, the first part of a
+    /// version must be a single number, and if one isn't found, an error is
+    /// returned.
+    ///
+    /// Upon success, the epoch is possibly set and the release has exactly one
+    /// number in it. The parser will be positioned at the beginning of the
+    /// next component, which is usually a `.`, indicating the start of the
+    /// second number in the release component. It could however point to the
+    /// end of input, in which case, a valid version should be returned.
+    fn parse_epoch_and_initial_release(&mut self) -> Result<(), VersionPatternParseError> {
+        let first_number = self.parse_number()?.ok_or(ErrorKind::NoLeadingNumber)?;
+        let first_release_number = if self.bump_if("!") {
+            self.epoch = first_number;
+            self.parse_number()?
+                .ok_or(ErrorKind::NoLeadingReleaseNumber)?
+        } else {
+            first_number
+        };
+        self.release.push(first_release_number);
+        Ok(())
+    }
+
+    /// This parses the rest of the numbers in the release component of
+    /// the version. Upon success, the release part of this parser will be
+    /// completely finished, and the parser will be positioned at the first
+    /// character after the last number in the release component. This position
+    /// may point to a `.`, for example, the second dot in `1.2.*` or `1.2.a5`
+    /// or `1.2.dev5`. It may also point to the end of the input, in which
+    /// case, the caller should return the current version.
+    ///
+    /// Callers should use this after the initial optional epoch and the first
+    /// release number have been parsed.
+    fn parse_rest_of_release(&mut self) -> Result<(), VersionPatternParseError> {
+        while self.bump_if(".") {
+            let Some(n) = self.parse_number()? else {
+                self.unbump();
+                break;
+            };
+            self.release.push(n);
+        }
+        Ok(())
+    }
+
+    /// Attempts to parse a trailing wildcard after the numbers in the release
+    /// component. Upon success, this returns `true` and positions the parser
+    /// immediately after the `.*` (which must necessarily be the end of
+    /// input), or leaves it unchanged if no wildcard was found. It is an error
+    /// if a `.*` is found and there is still more input after the `.*`.
+    ///
+    /// Callers should use this immediately after parsing all of the numbers in
+    /// the release component of the version.
+    fn parse_wildcard(&mut self) -> Result {
+        if !self.bump_if(".*") {
+            return Ok(false);
+        }
+        if !self.is_done() {
+            return Err(PatternErrorKind::WildcardNotTrailing.into());
+        }
+        self.wildcard = true;
+        Ok(true)
+    }
+
+    /// Parses the pre-release component of a version.
+    ///
+    /// If this version has no pre-release component, then this is a no-op.
+    /// Otherwise, it sets `self.pre` and positions the parser to the first
+    /// byte immediately following the pre-release.
+    fn parse_pre(&mut self) -> Result<(), VersionPatternParseError> {
+        // SPELLINGS and MAP are in correspondence. SPELLINGS is used to look
+        // for what spelling is used in the version string (if any), and
+        // the index of the element found is used to lookup which type of
+        // PreRelease it is.
+        //
+        // Note also that the order of the strings themselves matters. If 'pre'
+        // were before 'preview' for example, then 'preview' would never match
+        // since the strings are matched in order.
+        const SPELLINGS: StringSet =
+            StringSet::new(&["alpha", "beta", "preview", "pre", "rc", "a", "b", "c"]);
+        const MAP: &[PreRelease] = &[
+            PreRelease::Alpha,
+            PreRelease::Beta,
+            PreRelease::Rc,
+            PreRelease::Rc,
+            PreRelease::Rc,
+            PreRelease::Alpha,
+            PreRelease::Beta,
+            PreRelease::Rc,
+        ];
+
+        let oldpos = self.i;
+        self.bump_if_byte_set(&Parser::SEPARATOR);
+        let Some(spelling) = self.bump_if_string_set(&SPELLINGS) else {
+            // We might see a separator (or not) and then something
+            // that isn't a pre-release. At this stage, we can't tell
+            // whether it's invalid or not. So we back-up and let the
+            // caller try something else.
+            self.reset(oldpos);
+            return Ok(());
+        };
+        let kind = MAP[spelling];
+        self.bump_if_byte_set(&Parser::SEPARATOR);
+        // Under the normalization rules, a pre-release without an
+        // explicit number defaults to `0`.
+        let number = self.parse_number()?.unwrap_or(0);
+        self.pre = Some((kind, number));
+        Ok(())
+    }
+
+    /// Parses the post-release component of a version.
+    ///
+    /// If this version has no post-release component, then this is a no-op.
+    /// Otherwise, it sets `self.post` and positions the parser to the first
+    /// byte immediately following the post-release.
+    fn parse_post(&mut self) -> Result<(), VersionPatternParseError> {
+        const SPELLINGS: StringSet = StringSet::new(&["post", "rev", "r"]);
+
+        let oldpos = self.i;
+        if self.bump_if("-") {
+            if let Some(n) = self.parse_number()? {
+                self.post = Some(n);
+                return Ok(());
+            }
+            self.reset(oldpos);
+        }
+        self.bump_if_byte_set(&Parser::SEPARATOR);
+        if self.bump_if_string_set(&SPELLINGS).is_none() {
+            // As with pre-releases, if we don't see post|rev|r here, we can't
+            // yet determine whether the version as a whole is invalid since
+            // post-releases are optional.
+            self.reset(oldpos);
+            return Ok(());
+        }
+        self.bump_if_byte_set(&Parser::SEPARATOR);
+        // Under the normalization rules, a post-release without an
+        // explicit number defaults to `0`.
+        self.post = Some(self.parse_number()?.unwrap_or(0));
+        Ok(())
+    }
+
+    /// Parses the dev-release component of a version.
+    ///
+    /// If this version has no dev-release component, then this is a no-op.
+    /// Otherwise, it sets `self.dev` and positions the parser to the first
+    /// byte immediately following the post-release.
+    fn parse_dev(&mut self) -> Result<(), VersionPatternParseError> {
+        let oldpos = self.i;
+        self.bump_if_byte_set(&Parser::SEPARATOR);
+        if !self.bump_if("dev") {
+            // As with pre-releases, if we don't see dev here, we can't
+            // yet determine whether the version as a whole is invalid
+            // since dev-releases are optional.
+            self.reset(oldpos);
+            return Ok(());
+        }
+        self.bump_if_byte_set(&Parser::SEPARATOR);
+        // Under the normalization rules, a post-release without an
+        // explicit number defaults to `0`.
+        self.dev = Some(self.parse_number()?.unwrap_or(0));
+        Ok(())
+    }
+
+    /// Parses the local component of a version.
+    ///
+    /// If this version has no local component, then this is a no-op.
+    /// Otherwise, it adds to `self.local` and positions the parser to the
+    /// first byte immediately following the local component. (Which ought to
+    /// be the end of the version since the local component is the last thing
+    /// that can appear in a version.)
+    fn parse_local(&mut self) -> Result<(), VersionPatternParseError> {
+        if !self.bump_if("+") {
+            return Ok(());
+        }
+        let mut precursor = '+';
+        loop {
+            let first = self.bump_while(|byte| byte.is_ascii_alphanumeric());
+            if first.is_empty() {
+                return Err(ErrorKind::LocalEmpty { precursor }.into());
+            }
+            self.local.push(if let Ok(number) = parse_u64(first) {
+                LocalSegment::Number(number)
+            } else {
+                let string = String::from_utf8(first.to_ascii_lowercase())
+                    .expect("ASCII alphanumerics are always valid UTF-8");
+                LocalSegment::String(string)
+            });
+            let Some(byte) = self.bump_if_byte_set(&Parser::SEPARATOR) else {
+                break;
+            };
+            precursor = char::from(byte);
+        }
+        Ok(())
+    }
+
+    /// Consumes input from the current position while the characters are ASCII
+    /// digits, and then attempts to parse what was consumed as a decimal
+    /// number.
+    ///
+    /// If nothing was consumed, then `Ok(None)` is returned. Otherwise, if the
+    /// digits consumed do not form a valid decimal number that fits into a
+    /// `u64`, then an error is returned.
+    fn parse_number(&mut self) -> Result, VersionPatternParseError> {
+        let digits = self.bump_while(|ch| ch.is_ascii_digit());
+        if digits.is_empty() {
+            return Ok(None);
+        }
+        Ok(Some(parse_u64(digits)?))
+    }
+
+    /// Turns whatever state has been gathered into a `VersionPattern`.
+    ///
+    /// # Panics
+    ///
+    /// When `self.release` is empty. Callers must ensure at least one part
+    /// of the release component has been successfully parsed. Otherwise, the
+    /// version itself is invalid.
+    fn into_pattern(self) -> VersionPattern {
+        assert!(
+            self.release.len() > 0,
+            "version with no release numbers is invalid"
+        );
+        let version = Version::new(self.release.as_slice())
+            .with_epoch(self.epoch)
+            .with_pre(self.pre)
+            .with_post(self.post)
+            .with_dev(self.dev)
+            .with_local(self.local);
+        VersionPattern {
+            version,
+            wildcard: self.wildcard,
+        }
+    }
+
+    /// Consumes input from this parser while the given predicate returns true.
+    /// The resulting input (which may be empty) is returned.
+    ///
+    /// Once returned, the parser is positioned at the first position where the
+    /// predicate returns `false`. (This may be the position at the end of the
+    /// input such that [`Parser::is_done`] returns `true`.)
+    fn bump_while(&mut self, mut predicate: impl FnMut(u8) -> bool) -> &'a [u8] {
+        let start = self.i;
+        while !self.is_done() && predicate(self.byte()) {
+            self.i = self.i.saturating_add(1);
+        }
+        &self.v[start..self.i]
+    }
+
+    /// Consumes `bytes.len()` bytes from the current position of the parser if
+    /// and only if `bytes` is a prefix of the input starting at the current
+    /// position. Otherwise, this is a no-op. Returns true when consumption was
+    /// successful.
+    fn bump_if(&mut self, string: &str) -> bool {
+        if self.is_done() {
+            return false;
+        }
+        if starts_with_ignore_ascii_case(string.as_bytes(), &self.v[self.i..]) {
+            self.i = self
+                .i
+                .checked_add(string.len())
+                .expect("valid offset because of prefix");
+            true
+        } else {
+            false
+        }
+    }
+
+    /// Like [`Parser::bump_if`], but attempts each string in the ordered set
+    /// given. If one is successfully consumed from the start of the current
+    /// position in the input, then it is returned.
+    fn bump_if_string_set(&mut self, set: &StringSet) -> Option {
+        let index = set.starts_with(&self.v[self.i..])?;
+        let found = &set.strings[index];
+        self.i = self
+            .i
+            .checked_add(found.len())
+            .expect("valid offset because of prefix");
+        Some(index)
+    }
+
+    /// Like [`Parser::bump_if`], but attempts each byte in the set
+    /// given. If one is successfully consumed from the start of the
+    /// current position in the input.
+    fn bump_if_byte_set(&mut self, set: &ByteSet) -> Option {
+        let found = set.starts_with(&self.v[self.i..])?;
+        self.i = self
+            .i
+            .checked_add(1)
+            .expect("valid offset because of prefix");
+        Some(found)
+    }
+
+    /// Moves the parser back one byte. i.e., ungetch.
+    ///
+    /// This is useful when one has bumped the parser "too far" and wants to
+    /// back-up. This tends to help with composition among parser routines.
+    ///
+    /// # Panics
+    ///
+    /// When the parser is already positioned at the beginning.
+    fn unbump(&mut self) {
+        self.i = self.i.checked_sub(1).expect("not at beginning of input");
+    }
+
+    /// Resets the parser to the given position.
+    ///
+    /// # Panics
+    ///
+    /// When `offset` is greater than `self.v.len()`.
+    fn reset(&mut self, offset: usize) {
+        assert!(offset <= self.v.len());
+        self.i = offset;
+    }
+
+    /// Returns the byte at the current position of the parser.
+    ///
+    /// # Panics
+    ///
+    /// When `Parser::is_done` returns `true`.
+    fn byte(&self) -> u8 {
+        self.v[self.i]
+    }
+
+    /// Returns true if and only if there is no more input to consume.
+    fn is_done(&self) -> bool {
+        self.i >= self.v.len()
+    }
+}
+
+/// Stores the numbers found in the release portion of a version.
+///
+/// We use this in the version parser to avoid allocating in the 90+% case.
+#[derive(Debug)]
+enum ReleaseNumbers {
+    Inline { numbers: [u64; 4], len: usize },
+    Vec(Vec),
+}
+
+impl ReleaseNumbers {
+    /// Create a new empty set of release numbers.
+    fn new() -> ReleaseNumbers {
+        ReleaseNumbers::Inline {
+            numbers: [0; 4],
+            len: 0,
+        }
+    }
+
+    /// Push a new release number. This automatically switches over to the heap
+    /// when the lengths grow too big.
+    fn push(&mut self, n: u64) {
+        match *self {
+            ReleaseNumbers::Inline {
+                ref mut numbers,
+                ref mut len,
+            } => {
+                assert!(*len <= 4);
+                if *len == 4 {
+                    let mut numbers = numbers.to_vec();
+                    numbers.push(n);
+                    *self = ReleaseNumbers::Vec(numbers.to_vec());
+                } else {
+                    numbers[*len] = n;
+                    *len += 1;
+                }
+            }
+            ReleaseNumbers::Vec(ref mut numbers) => {
+                numbers.push(n);
+            }
+        }
+    }
+
+    /// Returns the number of components in this release component.
+    fn len(&self) -> usize {
+        self.as_slice().len()
+    }
+
+    /// Returns the release components as a slice.
+    fn as_slice(&self) -> &[u64] {
+        match *self {
+            ReleaseNumbers::Inline { ref numbers, len } => &numbers[..len],
+            ReleaseNumbers::Vec(ref vec) => vec,
+        }
+    }
+}
+
+/// Represents a set of strings for prefix searching.
+///
+/// This can be built as a constant and is useful for quickly looking for one
+/// of a number of matching literal strings while ignoring ASCII case.
+struct StringSet {
+    /// A set of the first bytes of each string in this set. We use this to
+    /// quickly bail out of searching if the first byte of our haystack doesn't
+    /// match any element in this set.
+    first_byte: ByteSet,
+    /// The strings in this set. They are matched in order.
+    strings: &'static [&'static str],
+}
+
+impl StringSet {
+    /// Create a new string set for prefix searching from the given strings.
+    ///
+    /// # Panics
+    ///
+    /// When the number of strings is too big.
+    const fn new(strings: &'static [&'static str]) -> StringSet {
+        assert!(
+            strings.len() <= 20,
+            "only a small number of strings are supported"
+        );
+        let (mut firsts, mut firsts_len) = ([0u8; 20], 0);
+        let mut i = 0;
+        while i < strings.len() {
+            assert!(
+                !strings[i].is_empty(),
+                "every string in set should be non-empty",
+            );
+            firsts[firsts_len] = strings[i].as_bytes()[0];
+            firsts_len += 1;
+            i += 1;
+        }
+        let first_byte = ByteSet::new(&firsts);
+        StringSet {
+            first_byte,
+            strings,
+        }
+    }
+
+    /// Returns the index of the first string in this set that is a prefix of
+    /// the given haystack, or `None` if no elements are a prefix.
+    fn starts_with(&self, haystack: &[u8]) -> Option {
+        let first_byte = self.first_byte.starts_with(haystack)?;
+        for (i, &string) in self.strings.iter().enumerate() {
+            let bytes = string.as_bytes();
+            if bytes[0].eq_ignore_ascii_case(&first_byte)
+                && starts_with_ignore_ascii_case(bytes, haystack)
+            {
+                return Some(i);
+            }
+        }
+        None
+    }
+}
+
+/// A set of bytes for searching case insensitively (ASCII only).
+struct ByteSet {
+    set: [bool; 256],
+}
+
+impl ByteSet {
+    /// Create a new byte set for searching from the given bytes.
+    const fn new(bytes: &[u8]) -> ByteSet {
+        let mut set = [false; 256];
+        let mut i = 0;
+        while i < bytes.len() {
+            set[bytes[i].to_ascii_uppercase() as usize] = true;
+            set[bytes[i].to_ascii_lowercase() as usize] = true;
+            i += 1;
+        }
+        ByteSet { set }
+    }
+
+    /// Returns the first byte in the haystack if and only if that byte is in
+    /// this set (ignoring ASCII case).
+    fn starts_with(&self, haystack: &[u8]) -> Option {
+        let byte = *haystack.first()?;
+        if self.contains(byte) {
+            Some(byte)
+        } else {
+            None
+        }
+    }
+
+    /// Returns true if and only if the given byte is in this set.
+    fn contains(&self, byte: u8) -> bool {
+        self.set[usize::from(byte)]
+    }
+}
+
+impl std::fmt::Debug for ByteSet {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        let mut set = f.debug_set();
+        for byte in 0..=255 {
+            if self.contains(byte) {
+                set.entry(&char::from(byte));
+            }
+        }
+        set.finish()
+    }
+}
+
+/// An error that occurs when parsing a [`Version`] string fails.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VersionParseError {
+    kind: Box,
+}
+
+impl std::error::Error for VersionParseError {}
+
+impl std::fmt::Display for VersionParseError {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        match *self.kind {
+            ErrorKind::Wildcard => write!(f, "wildcards are not allowed in a version"),
+            ErrorKind::InvalidDigit { got } if got.is_ascii() => {
+                write!(f, "expected ASCII digit, but found {:?}", char::from(got))
+            }
+            ErrorKind::InvalidDigit { got } => {
+                write!(
+                    f,
+                    "expected ASCII digit, but found non-ASCII byte \\x{:02X}",
+                    got
+                )
+            }
+            ErrorKind::NumberTooBig { ref bytes } => {
+                let string = match std::str::from_utf8(bytes) {
+                    Ok(v) => v,
+                    Err(err) => {
+                        std::str::from_utf8(&bytes[..err.valid_up_to()]).expect("valid UTF-8")
+                    }
+                };
+                write!(
+                    f,
+                    "expected number less than or equal to {}, \
+                     but number found in {string:?} exceeds it",
+                    u64::MAX,
+                )
+            }
+            ErrorKind::NoLeadingNumber => {
+                write!(
+                    f,
+                    "expected version to start with a number, \
+                     but no leading ASCII digits were found"
+                )
+            }
+            ErrorKind::NoLeadingReleaseNumber => {
+                write!(
+                    f,
+                    "expected version to have a non-empty release component after an epoch, \
+                     but no ASCII digits after the epoch were found"
+                )
+            }
+            ErrorKind::LocalEmpty { precursor } => {
+                write!(
+                    f,
+                    "found a `{precursor}` indicating the start of a local \
+                     component in a version, but did not find any alpha-numeric \
+                     ASCII segment following the `{precursor}`",
+                )
+            }
+            ErrorKind::UnexpectedEnd {
+                ref version,
+                ref remaining,
+            } => {
+                write!(
+                    f,
+                    "after parsing {version}, found {remaining:?} after it, \
+                     which is not part of a valid version",
+                )
+            }
+        }
+    }
+}
+
+/// The kind of error that occurs when parsing a `Version`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) enum ErrorKind {
+    /// Occurs when a version pattern is found but a normal verbatim version is
+    /// expected.
+    Wildcard,
+    /// Occurs when an ASCII digit was expected, but something else was found.
+    InvalidDigit {
+        /// The (possibly non-ASCII) byte that was seen instead of [0-9].
+        got: u8,
+    },
+    /// Occurs when a number was found that exceeds what can fit into a u64.
+    NumberTooBig {
+        /// The bytes that were being parsed as a number. These may contain
+        /// invalid digits or even invalid UTF-8.
+        bytes: Vec,
+    },
+    /// Occurs when a version does not start with a leading number.
+    NoLeadingNumber,
+    /// Occurs when an epoch version does not have a number after the `!`.
+    NoLeadingReleaseNumber,
+    /// Occurs when a `+` (or a `.` after the first local segment) is seen
+    /// (indicating a local component of a version), but no alpha-numeric ASCII
+    /// string is found following it.
+    LocalEmpty {
+        /// Either a `+` or a `[-_.]` indicating what was found that demands a
+        /// non-empty local segment following it.
+        precursor: char,
+    },
+    /// Occurs when a version has been parsed but there is some unexpected
+    /// trailing data in the string.
+    UnexpectedEnd {
+        /// The version that has been parsed so far.
+        version: Version,
+        /// The bytes that were remaining and not parsed.
+        remaining: String,
+    },
+}
+
+impl From for VersionParseError {
+    fn from(kind: ErrorKind) -> VersionParseError {
+        VersionParseError {
+            kind: Box::new(kind),
+        }
+    }
+}
+
+/// An error that occurs when parsing a [`VersionPattern`] string fails.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VersionPatternParseError {
+    kind: Box,
+}
+
+impl std::error::Error for VersionPatternParseError {}
+
+impl std::fmt::Display for VersionPatternParseError {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        match *self.kind {
+            PatternErrorKind::Version(ref err) => err.fmt(f),
+            PatternErrorKind::WildcardNotTrailing => {
+                write!(f, "wildcards in versions must be at the end")
+            }
+        }
+    }
+}
+
+/// The kind of error that occurs when parsing a `VersionPattern`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) enum PatternErrorKind {
+    Version(VersionParseError),
+    WildcardNotTrailing,
+}
+
+impl From for VersionPatternParseError {
+    fn from(kind: PatternErrorKind) -> VersionPatternParseError {
+        VersionPatternParseError {
+            kind: Box::new(kind),
+        }
+    }
+}
+
+impl From for VersionPatternParseError {
+    fn from(kind: ErrorKind) -> VersionPatternParseError {
+        VersionPatternParseError::from(VersionParseError::from(kind))
+    }
+}
+
+impl From for VersionPatternParseError {
+    fn from(err: VersionParseError) -> VersionPatternParseError {
+        VersionPatternParseError {
+            kind: Box::new(PatternErrorKind::Version(err)),
+        }
+    }
+}
+
 /// Workaround for 
 #[cfg(feature = "pyo3")]
 #[derive(Clone, Debug)]
@@ -890,7 +2030,7 @@ impl PyVersion {
     #[new]
     pub fn parse(version: &str) -> PyResult {
         Ok(Self(
-            Version::from_str(version).map_err(PyValueError::new_err)?,
+            Version::from_str(version).map_err(|e| PyValueError::new_err(e.to_string()))?,
         ))
     }
 
@@ -899,9 +2039,10 @@ impl PyVersion {
     #[cfg(feature = "pyo3")]
     #[staticmethod]
     pub fn parse_star(version_specifier: &str) -> PyResult<(Self, bool)> {
-        Version::from_str_star(version_specifier)
-            .map_err(PyValueError::new_err)
-            .map(|(version, star)| (Self(version), star))
+        version_specifier
+            .parse::()
+            .map_err(|e| PyValueError::new_err(e.to_string()))
+            .map(|VersionPattern { version, wildcard }| (Self(version), wildcard))
     }
 
     /// Returns the normalized representation
@@ -987,68 +2128,69 @@ pub(crate) fn compare_release(this: &[u64], other: &[u64]) -> Ordering {
 /// implementation
 ///
 /// [pep440-suffix-ordering]: https://peps.python.org/pep-0440/#summary-of-permitted-suffixes-and-relative-ordering
-fn sortable_tuple(version: &Version) -> (u64, u64, Option, u64, Option<&[LocalSegment]>) {
-    match (&version.pre, &version.post, &version.dev) {
+fn sortable_tuple(version: &Version) -> (u64, u64, Option, u64, &[LocalSegment]) {
+    match (version.pre(), version.post(), version.dev()) {
         // dev release
-        (None, None, Some(n)) => (0, 0, None, *n, version.local.as_deref()),
+        (None, None, Some(n)) => (0, 0, None, n, version.local()),
         // alpha release
-        (Some((PreRelease::Alpha, n)), post, dev) => (
-            1,
-            *n,
-            *post,
-            dev.unwrap_or(u64::MAX),
-            version.local.as_deref(),
-        ),
+        (Some((PreRelease::Alpha, n)), post, dev) => {
+            (1, n, post, dev.unwrap_or(u64::MAX), version.local())
+        }
         // beta release
-        (Some((PreRelease::Beta, n)), post, dev) => (
-            2,
-            *n,
-            *post,
-            dev.unwrap_or(u64::MAX),
-            version.local.as_deref(),
-        ),
+        (Some((PreRelease::Beta, n)), post, dev) => {
+            (2, n, post, dev.unwrap_or(u64::MAX), version.local())
+        }
         // alpha release
-        (Some((PreRelease::Rc, n)), post, dev) => (
-            3,
-            *n,
-            *post,
-            dev.unwrap_or(u64::MAX),
-            version.local.as_deref(),
-        ),
+        (Some((PreRelease::Rc, n)), post, dev) => {
+            (3, n, post, dev.unwrap_or(u64::MAX), version.local())
+        }
         // final release
-        (None, None, None) => (4, 0, None, 0, version.local.as_deref()),
+        (None, None, None) => (4, 0, None, 0, version.local()),
         // post release
-        (None, Some(post), dev) => (
-            5,
-            0,
-            Some(*post),
-            dev.unwrap_or(u64::MAX),
-            version.local.as_deref(),
-        ),
+        (None, Some(post), dev) => (5, 0, Some(post), dev.unwrap_or(u64::MAX), version.local()),
     }
 }
 
-/// Attempt to parse the given version string very quickly.
+/// Returns true only when, ignoring ASCII case, `needle` is a prefix of
+/// `haystack`.
+fn starts_with_ignore_ascii_case(needle: &[u8], haystack: &[u8]) -> bool {
+    needle.len() <= haystack.len()
+        && std::iter::zip(needle, haystack).all(|(b1, b2)| b1.eq_ignore_ascii_case(b2))
+}
+
+/// Parses a u64 number from the given slice of ASCII digit characters.
 ///
-/// This looks for a version string that is of the form `n(.n)*` (i.e., release
-/// only) and returns the corresponding `Version` of it. If the version string
-/// has any other form, then this returns `None`.
-fn version_fast_parse(version: &str) -> Option {
-    let mut parts = vec![];
-    for part in version.split('.') {
-        if !part.as_bytes().iter().all(|b| b.is_ascii_digit()) {
-            return None;
-        }
-        parts.push(part.parse().ok()?);
+/// If any byte in the given slice is not [0-9], then this returns an error.
+/// Similarly, if the number parsed does not fit into a `u64`, then this
+/// returns an error.
+///
+/// # Motivation
+///
+/// We hand-write this for a couple reasons. Firstly, the standard library's
+/// FromStr impl for parsing integers requires UTF-8 validation first. We
+/// don't need that for version parsing since we stay in the realm of ASCII.
+/// Secondly, std's version is a little more flexible because it supports
+/// signed integers. So for example, it permits a leading `+` before the actual
+/// integer. We don't need that for version parsing.
+fn parse_u64(bytes: &[u8]) -> Result {
+    let mut n: u64 = 0;
+    for &byte in bytes {
+        let digit = match byte.checked_sub(b'0') {
+            None => return Err(ErrorKind::InvalidDigit { got: byte }.into()),
+            Some(digit) if digit > 9 => return Err(ErrorKind::InvalidDigit { got: byte }.into()),
+            Some(digit) => {
+                debug_assert!((0..=9).contains(&digit));
+                u64::from(digit)
+            }
+        };
+        n = n
+            .checked_mul(10)
+            .and_then(|n| n.checked_add(digit))
+            .ok_or_else(|| ErrorKind::NumberTooBig {
+                bytes: bytes.to_vec(),
+            })?;
     }
-    Some(Version {
-        epoch: 0,
-        release: parts,
-        pre: None,
-        post: None,
-        dev: None,
-        local: None,
-    })
+    Ok(n)
 }
 
 #[cfg(test)]
@@ -1058,7 +2200,9 @@ mod tests {
     #[cfg(feature = "pyo3")]
     use pyo3::pyfunction;
 
-    use crate::{LocalSegment, PreRelease, Version, VersionSpecifier};
+    use crate::VersionSpecifier;
+
+    use super::*;
 
     /// 
     #[test]
@@ -1400,22 +2544,14 @@ mod tests {
             "1.0+_foobar",
             "1.0+foo&asd",
             "1.0+1+1",
+            // Nonsensical versions should also be invalid
+            "french toast",
+            "==french toast",
         ];
         for version in versions {
-            assert_eq!(
-                Version::from_str(version).unwrap_err(),
-                format!("Version `{version}` doesn't match PEP 440 rules")
-            );
-            assert_eq!(
-                VersionSpecifier::from_str(&format!("=={version}"))
-                    .unwrap_err()
-                    .to_string(),
-                format!("Version `{version}` doesn't match PEP 440 rules")
-            );
+            assert!(Version::from_str(version).is_err());
+            assert!(VersionSpecifier::from_str(&format!("=={version}")).is_err());
         }
-        // Nonsensical versions should be invalid (different error message)
-        Version::from_str("french toast").unwrap_err();
-        VersionSpecifier::from_str("==french toast").unwrap_err();
     }
 
     #[test]
@@ -1623,44 +2759,482 @@ mod tests {
     #[test]
     fn test_star_fixed_version() {
         let result = Version::from_str("0.9.1.*");
-        assert_eq!(
-            result.unwrap_err(),
-            "A star (`*`) must not be used in a fixed version (use `Version::from_string_star` otherwise)"
-        );
+        assert_eq!(result.unwrap_err(), ErrorKind::Wildcard.into());
     }
 
     #[test]
     fn test_regex_mismatch() {
         let result = Version::from_str("blergh");
-        assert_eq!(
-            result.unwrap_err(),
-            "Version `blergh` doesn't match PEP 440 rules"
-        );
+        assert_eq!(result.unwrap_err(), ErrorKind::NoLeadingNumber.into());
     }
 
     #[test]
     fn test_from_version_star() {
-        assert!(!Version::from_str_star("1.2.3").unwrap().1);
-        assert!(Version::from_str_star("1.2.3.*").unwrap().1);
+        let p = |s: &str| -> Result { s.parse() };
+        assert!(!p("1.2.3").unwrap().is_wildcard());
+        assert!(p("1.2.3.*").unwrap().is_wildcard());
         assert_eq!(
-            Version::from_str_star("1.2.*.4.*").unwrap_err(),
-            "Version `1.2.*.4.*` doesn't match PEP 440 rules"
+            p("1.2.*.4.*").unwrap_err(),
+            PatternErrorKind::WildcardNotTrailing.into(),
         );
         assert_eq!(
-            Version::from_str_star("1.0-dev1.*").unwrap_err(),
-            "You can't have both a trailing `.*` and a dev version"
+            p("1.0-dev1.*").unwrap_err(),
+            ErrorKind::UnexpectedEnd {
+                version: Version::new([1, 0]).with_dev(Some(1)),
+                remaining: ".*".to_string()
+            }
+            .into(),
         );
         assert_eq!(
-            Version::from_str_star("1.0a1.*").unwrap_err(),
-            "You can't have both a trailing `.*` and a prerelease version"
+            p("1.0a1.*").unwrap_err(),
+            ErrorKind::UnexpectedEnd {
+                version: Version::new([1, 0]).with_pre(Some((PreRelease::Alpha, 1))),
+                remaining: ".*".to_string()
+            }
+            .into(),
         );
         assert_eq!(
-            Version::from_str_star("1.0.post1.*").unwrap_err(),
-            "You can't have both a trailing `.*` and a post version"
+            p("1.0.post1.*").unwrap_err(),
+            ErrorKind::UnexpectedEnd {
+                version: Version::new([1, 0]).with_post(Some(1)),
+                remaining: ".*".to_string()
+            }
+            .into(),
         );
         assert_eq!(
-            Version::from_str_star("1.0+lolwat.*").unwrap_err(),
-            "You can't have both a trailing `.*` and a local version"
+            p("1.0+lolwat.*").unwrap_err(),
+            ErrorKind::LocalEmpty { precursor: '.' }.into(),
+        );
+    }
+
+    // Tests the valid cases of our version parser. These were written
+    // in tandem with the parser.
+    //
+    // They are meant to be additional (but in some cases likely redundant)
+    // with some of the above tests.
+    #[test]
+    fn parse_version_valid() {
+        let p = |s: &str| match Parser::new(s.as_bytes()).parse() {
+            Ok(v) => v,
+            Err(err) => unreachable!("expected valid version, but got error: {err:?}"),
+        };
+
+        // release-only tests
+        assert_eq!(p("5"), Version::new([5]));
+        assert_eq!(p("5.6"), Version::new([5, 6]));
+        assert_eq!(p("5.6.7"), Version::new([5, 6, 7]));
+        assert_eq!(p("512.623.734"), Version::new([512, 623, 734]));
+        assert_eq!(p("1.2.3.4"), Version::new([1, 2, 3, 4]));
+        assert_eq!(p("1.2.3.4.5"), Version::new([1, 2, 3, 4, 5]));
+
+        // epoch tests
+        assert_eq!(p("4!5"), Version::new([5]).with_epoch(4));
+        assert_eq!(p("4!5.6"), Version::new([5, 6]).with_epoch(4));
+
+        // pre-release tests
+        assert_eq!(
+            p("5a1"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 1)))
+        );
+        assert_eq!(
+            p("5alpha1"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 1)))
+        );
+        assert_eq!(
+            p("5b1"),
+            Version::new([5]).with_pre(Some((PreRelease::Beta, 1)))
+        );
+        assert_eq!(
+            p("5beta1"),
+            Version::new([5]).with_pre(Some((PreRelease::Beta, 1)))
+        );
+        assert_eq!(
+            p("5rc1"),
+            Version::new([5]).with_pre(Some((PreRelease::Rc, 1)))
+        );
+        assert_eq!(
+            p("5c1"),
+            Version::new([5]).with_pre(Some((PreRelease::Rc, 1)))
+        );
+        assert_eq!(
+            p("5preview1"),
+            Version::new([5]).with_pre(Some((PreRelease::Rc, 1)))
+        );
+        assert_eq!(
+            p("5pre1"),
+            Version::new([5]).with_pre(Some((PreRelease::Rc, 1)))
+        );
+        assert_eq!(
+            p("5.6.7pre1"),
+            Version::new([5, 6, 7]).with_pre(Some((PreRelease::Rc, 1)))
+        );
+        assert_eq!(
+            p("5alpha789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5.alpha789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5-alpha789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5_alpha789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5alpha.789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5alpha-789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5alpha_789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5ALPHA789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5aLpHa789"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 789)))
+        );
+        assert_eq!(
+            p("5alpha"),
+            Version::new([5]).with_pre(Some((PreRelease::Alpha, 0)))
+        );
+
+        // post-release tests
+        assert_eq!(p("5post2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5rev2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5r2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5.post2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5-post2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5_post2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5.post.2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5.post-2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5.post_2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(
+            p("5.6.7.post_2"),
+            Version::new([5, 6, 7]).with_post(Some(2))
+        );
+        assert_eq!(p("5-2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5.6.7-2"), Version::new([5, 6, 7]).with_post(Some(2)));
+        assert_eq!(p("5POST2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5PoSt2"), Version::new([5]).with_post(Some(2)));
+        assert_eq!(p("5post"), Version::new([5]).with_post(Some(0)));
+
+        // dev-release tests
+        assert_eq!(p("5dev2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5.dev2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5-dev2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5_dev2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5.dev.2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5.dev-2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5.dev_2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5.6.7.dev_2"), Version::new([5, 6, 7]).with_dev(Some(2)));
+        assert_eq!(p("5DEV2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5dEv2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5DeV2"), Version::new([5]).with_dev(Some(2)));
+        assert_eq!(p("5dev"), Version::new([5]).with_dev(Some(0)));
+
+        // local tests
+        assert_eq!(
+            p("5+2"),
+            Version::new([5]).with_local(vec![LocalSegment::Number(2)])
+        );
+        assert_eq!(
+            p("5+a"),
+            Version::new([5]).with_local(vec![LocalSegment::String("a".to_string())])
+        );
+        assert_eq!(
+            p("5+abc.123"),
+            Version::new([5]).with_local(vec![
+                LocalSegment::String("abc".to_string()),
+                LocalSegment::Number(123),
+            ])
+        );
+        assert_eq!(
+            p("5+123.abc"),
+            Version::new([5]).with_local(vec![
+                LocalSegment::Number(123),
+                LocalSegment::String("abc".to_string()),
+            ])
+        );
+        assert_eq!(
+            p("5+18446744073709551615.abc"),
+            Version::new([5]).with_local(vec![
+                LocalSegment::Number(18446744073709551615),
+                LocalSegment::String("abc".to_string()),
+            ])
+        );
+        assert_eq!(
+            p("5+18446744073709551616.abc"),
+            Version::new([5]).with_local(vec![
+                LocalSegment::String("18446744073709551616".to_string()),
+                LocalSegment::String("abc".to_string()),
+            ])
+        );
+        assert_eq!(
+            p("5+ABC.123"),
+            Version::new([5]).with_local(vec![
+                LocalSegment::String("abc".to_string()),
+                LocalSegment::Number(123),
+            ])
+        );
+        assert_eq!(
+            p("5+ABC-123.4_5_xyz-MNO"),
+            Version::new([5]).with_local(vec![
+                LocalSegment::String("abc".to_string()),
+                LocalSegment::Number(123),
+                LocalSegment::Number(4),
+                LocalSegment::Number(5),
+                LocalSegment::String("xyz".to_string()),
+                LocalSegment::String("mno".to_string()),
+            ])
+        );
+        assert_eq!(
+            p("5.6.7+abc-00123"),
+            Version::new([5, 6, 7]).with_local(vec![
+                LocalSegment::String("abc".to_string()),
+                LocalSegment::Number(123),
+            ])
+        );
+        assert_eq!(
+            p("5.6.7+abc-foo00123"),
+            Version::new([5, 6, 7]).with_local(vec![
+                LocalSegment::String("abc".to_string()),
+                LocalSegment::String("foo00123".to_string()),
+            ])
+        );
+        assert_eq!(
+            p("5.6.7+abc-00123a"),
+            Version::new([5, 6, 7]).with_local(vec![
+                LocalSegment::String("abc".to_string()),
+                LocalSegment::String("00123a".to_string()),
+            ])
+        );
+
+        // {pre-release, post-release} tests
+        assert_eq!(
+            p("5a2post3"),
+            Version::new([5])
+                .with_pre(Some((PreRelease::Alpha, 2)))
+                .with_post(Some(3))
+        );
+        assert_eq!(
+            p("5.a-2_post-3"),
+            Version::new([5])
+                .with_pre(Some((PreRelease::Alpha, 2)))
+                .with_post(Some(3))
+        );
+        assert_eq!(
+            p("5a2-3"),
+            Version::new([5])
+                .with_pre(Some((PreRelease::Alpha, 2)))
+                .with_post(Some(3))
+        );
+
+        // Ignoring a no-op 'v' prefix.
+        assert_eq!(p("v5"), Version::new([5]));
+        assert_eq!(p("V5"), Version::new([5]));
+        assert_eq!(p("v5.6.7"), Version::new([5, 6, 7]));
+
+        // Ignoring leading and trailing whitespace.
+        assert_eq!(p("  v5  "), Version::new([5]));
+        assert_eq!(p("  5  "), Version::new([5]));
+        assert_eq!(
+            p("  5.6.7+abc.123.xyz  "),
+            Version::new([5, 6, 7]).with_local(vec![
+                LocalSegment::String("abc".to_string()),
+                LocalSegment::Number(123),
+                LocalSegment::String("xyz".to_string())
+            ])
+        );
+        assert_eq!(p("  \n5\n \t"), Version::new([5]));
+    }
+
+    // Tests the error cases of our version parser.
+    //
+    // I wrote these with the intent to cover every possible error
+    // case.
+    //
+    // They are meant to be additional (but in some cases likely redundant)
+    // with some of the above tests.
+    #[test]
+    fn parse_version_invalid() {
+        let p = |s: &str| match Parser::new(s.as_bytes()).parse() {
+            Err(err) => err,
+            Ok(v) => unreachable!(
+                "expected version parser error, but got: {v:?}",
+                v = v.as_bloated_debug()
+            ),
+        };
+
+        assert_eq!(p(""), ErrorKind::NoLeadingNumber.into());
+        assert_eq!(p("a"), ErrorKind::NoLeadingNumber.into());
+        assert_eq!(p("v 5"), ErrorKind::NoLeadingNumber.into());
+        assert_eq!(p("V 5"), ErrorKind::NoLeadingNumber.into());
+        assert_eq!(p("x 5"), ErrorKind::NoLeadingNumber.into());
+        assert_eq!(
+            p("18446744073709551616"),
+            ErrorKind::NumberTooBig {
+                bytes: b"18446744073709551616".to_vec()
+            }
+            .into()
+        );
+        assert_eq!(p("5!"), ErrorKind::NoLeadingReleaseNumber.into());
+        assert_eq!(
+            p("5.6./"),
+            ErrorKind::UnexpectedEnd {
+                version: Version::new([5, 6]),
+                remaining: "./".to_string()
+            }
+            .into()
+        );
+        assert_eq!(
+            p("5.6.-alpha2"),
+            ErrorKind::UnexpectedEnd {
+                version: Version::new([5, 6]),
+                remaining: ".-alpha2".to_string()
+            }
+            .into()
+        );
+        assert_eq!(
+            p("1.2.3a18446744073709551616"),
+            ErrorKind::NumberTooBig {
+                bytes: b"18446744073709551616".to_vec()
+            }
+            .into()
+        );
+        assert_eq!(p("5+"), ErrorKind::LocalEmpty { precursor: '+' }.into());
+        assert_eq!(p("5+ "), ErrorKind::LocalEmpty { precursor: '+' }.into());
+        assert_eq!(p("5+abc."), ErrorKind::LocalEmpty { precursor: '.' }.into());
+        assert_eq!(p("5+abc-"), ErrorKind::LocalEmpty { precursor: '-' }.into());
+        assert_eq!(p("5+abc_"), ErrorKind::LocalEmpty { precursor: '_' }.into());
+        assert_eq!(
+            p("5+abc. "),
+            ErrorKind::LocalEmpty { precursor: '.' }.into()
+        );
+        assert_eq!(
+            p("5.6-"),
+            ErrorKind::UnexpectedEnd {
+                version: Version::new([5, 6]),
+                remaining: "-".to_string()
+            }
+            .into()
+        );
+    }
+
+    #[test]
+    fn parse_version_pattern_valid() {
+        let p = |s: &str| match Parser::new(s.as_bytes()).parse_pattern() {
+            Ok(v) => v,
+            Err(err) => unreachable!("expected valid version, but got error: {err:?}"),
+        };
+
+        assert_eq!(p("5.*"), VersionPattern::wildcard(Version::new([5])));
+        assert_eq!(p("5.6.*"), VersionPattern::wildcard(Version::new([5, 6])));
+        assert_eq!(
+            p("2!5.6.*"),
+            VersionPattern::wildcard(Version::new([5, 6]).with_epoch(2))
+        );
+    }
+
+    #[test]
+    fn parse_version_pattern_invalid() {
+        let p = |s: &str| match Parser::new(s.as_bytes()).parse_pattern() {
+            Err(err) => err,
+            Ok(vpat) => unreachable!("expected version pattern parser error, but got: {vpat:?}"),
+        };
+
+        assert_eq!(p("*"), ErrorKind::NoLeadingNumber.into());
+        assert_eq!(p("2!*"), ErrorKind::NoLeadingReleaseNumber.into());
+    }
+
+    // Tests that the ordering between versions is correct.
+    //
+    // The ordering example used here was taken from PEP 440:
+    // https://packaging.python.org/en/latest/specifications/version-specifiers/#summary-of-permitted-suffixes-and-relative-ordering
+    #[test]
+    fn ordering() {
+        let versions = &[
+            "1.dev0",
+            "1.0.dev456",
+            "1.0a1",
+            "1.0a2.dev456",
+            "1.0a12.dev456",
+            "1.0a12",
+            "1.0b1.dev456",
+            "1.0b2",
+            "1.0b2.post345.dev456",
+            "1.0b2.post345",
+            "1.0rc1.dev456",
+            "1.0rc1",
+            "1.0",
+            "1.0+abc.5",
+            "1.0+abc.7",
+            "1.0+5",
+            "1.0.post456.dev34",
+            "1.0.post456",
+            "1.0.15",
+            "1.1.dev1",
+        ];
+        for pair in versions.windows(2) {
+            let less = pair[0].parse::().unwrap();
+            let greater = pair[1].parse::().unwrap();
+            assert_eq!(
+                less.cmp(&greater),
+                Ordering::Less,
+                "less: {:?}\ngreater: {:?}",
+                less.as_bloated_debug(),
+                greater.as_bloated_debug()
+            );
+        }
+    }
+
+    // Tests our bespoke u64 decimal integer parser.
+    #[test]
+    fn parse_number_u64() {
+        let p = |s: &str| parse_u64(s.as_bytes());
+        assert_eq!(p("0"), Ok(0));
+        assert_eq!(p("00"), Ok(0));
+        assert_eq!(p("1"), Ok(1));
+        assert_eq!(p("01"), Ok(1));
+        assert_eq!(p("9"), Ok(9));
+        assert_eq!(p("10"), Ok(10));
+        assert_eq!(p("18446744073709551615"), Ok(18446744073709551615));
+        assert_eq!(p("018446744073709551615"), Ok(18446744073709551615));
+        assert_eq!(p("000000018446744073709551615"), Ok(18446744073709551615));
+
+        assert_eq!(p("10a"), Err(ErrorKind::InvalidDigit { got: b'a' }.into()));
+        assert_eq!(p("10["), Err(ErrorKind::InvalidDigit { got: b'[' }.into()));
+        assert_eq!(p("10/"), Err(ErrorKind::InvalidDigit { got: b'/' }.into()));
+        assert_eq!(
+            p("18446744073709551616"),
+            Err(ErrorKind::NumberTooBig {
+                bytes: b"18446744073709551616".to_vec()
+            }
+            .into())
+        );
+        assert_eq!(
+            p("18446744073799551615abc"),
+            Err(ErrorKind::NumberTooBig {
+                bytes: b"18446744073799551615abc".to_vec()
+            }
+            .into())
+        );
+        assert_eq!(
+            parse_u64(b"18446744073799551615\xFF"),
+            Err(ErrorKind::NumberTooBig {
+                bytes: b"18446744073799551615\xFF".to_vec()
+            }
+            .into())
         );
     }
 
@@ -1696,7 +3270,7 @@ mod tests {
     }
 
     impl Version {
-        fn as_bloated_debug(&self) -> VersionBloatedDebug<'_> {
+        pub(crate) fn as_bloated_debug(&self) -> impl std::fmt::Debug + '_ {
             VersionBloatedDebug(self)
         }
     }
diff --git a/crates/pep440-rs/src/version_specifier.rs b/crates/pep440-rs/src/version_specifier.rs
index 97fe72097..69040735f 100644
--- a/crates/pep440-rs/src/version_specifier.rs
+++ b/crates/pep440-rs/src/version_specifier.rs
@@ -14,7 +14,9 @@ use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
 
 #[cfg(feature = "pyo3")]
 use crate::version::PyVersion;
-use crate::{version, Operator, Version};
+use crate::{
+    version, Operator, OperatorParseError, Version, VersionPattern, VersionPatternParseError,
+};
 
 /// A thin wrapper around `Vec` with a serde implementation
 ///
@@ -323,11 +325,12 @@ impl VersionSpecifier {
     /// parameter indicates a trailing `.*`, to differentiate between `1.1.*` and `1.1`
     pub fn new(
         operator: Operator,
-        version: Version,
-        star: bool,
+        version_pattern: VersionPattern,
     ) -> Result {
+        let star = version_pattern.is_wildcard();
+        let version = version_pattern.into_version();
         // "Local version identifiers are NOT permitted in this version specifier."
-        if version.local().is_some() && !operator.is_local_compatible() {
+        if version.is_local() && !operator.is_local_compatible() {
             return Err(BuildErrorKind::OperatorLocalCombo { operator, version }.into());
         }
 
@@ -391,7 +394,7 @@ impl VersionSpecifier {
         // "Except where specifically noted below, local version identifiers MUST NOT be permitted
         // in version specifiers, and local version labels MUST be ignored entirely when checking
         // if candidate versions match a given version specifier."
-        let (this, other) = if self.version.local().is_some() {
+        let (this, other) = if !self.version.local().is_empty() {
             (self.version.clone(), version.clone())
         } else {
             // self is already without local
@@ -524,10 +527,9 @@ impl FromStr for VersionSpecifier {
         if version.is_empty() {
             return Err(ParseErrorKind::MissingVersion.into());
         }
-        let (version, star) =
-            Version::from_str_star(version).map_err(ParseErrorKind::InvalidVersion)?;
-        let version_specifier = VersionSpecifier::new(operator, version, star)
-            .map_err(ParseErrorKind::InvalidSpecifier)?;
+        let vpat = version.parse().map_err(ParseErrorKind::InvalidVersion)?;
+        let version_specifier =
+            VersionSpecifier::new(operator, vpat).map_err(ParseErrorKind::InvalidSpecifier)?;
         s.eat_while(|c: char| c.is_whitespace());
         if !s.done() {
             return Err(ParseErrorKind::InvalidTrailing(s.after().to_string()).into());
@@ -565,7 +567,6 @@ impl std::fmt::Display for VersionSpecifierBuildError {
                 let local = version
                     .local()
                     .iter()
-                    .flat_map(|segments| segments.iter())
                     .map(|segment| segment.to_string())
                     .collect::>()
                     .join(".");
@@ -661,8 +662,8 @@ impl std::fmt::Display for VersionSpecifierParseError {
 /// specifier from a string.
 #[derive(Clone, Debug, Eq, PartialEq)]
 enum ParseErrorKind {
-    InvalidOperator(String),
-    InvalidVersion(String),
+    InvalidOperator(OperatorParseError),
+    InvalidVersion(VersionPatternParseError),
     InvalidSpecifier(VersionSpecifierBuildError),
     MissingOperator,
     MissingVersion,
@@ -726,7 +727,7 @@ mod tests {
 
     use indoc::indoc;
 
-    use crate::LocalSegment;
+    use crate::{LocalSegment, PreRelease};
 
     use super::*;
 
@@ -1100,12 +1101,14 @@ mod tests {
             ("2.0.5", ">2.0dev"),
         ];
 
-        for (version, specifier) in pairs {
+        for (s_version, s_spec) in pairs {
+            let version = s_version.parse::().unwrap();
+            let spec = s_spec.parse::().unwrap();
             assert!(
-                VersionSpecifier::from_str(specifier)
-                    .unwrap()
-                    .contains(&Version::from_str(version).unwrap()),
-                "{version} {specifier}"
+                spec.contains(&version),
+                "{s_version} {s_spec}\nversion repr: {:?}\nspec version repr: {:?}",
+                version.as_bloated_debug(),
+                spec.version.as_bloated_debug(),
             );
         }
     }
@@ -1255,10 +1258,8 @@ mod tests {
         let result = VersionSpecifiers::from_str("== 0.9.*.1");
         assert_eq!(
             result.unwrap_err().inner.err,
-            ParseErrorKind::InvalidVersion(
-                "Version `0.9.*.1` doesn't match PEP 440 rules".to_string()
-            )
-            .into()
+            ParseErrorKind::InvalidVersion(version::PatternErrorKind::WildcardNotTrailing.into())
+                .into(),
         );
     }
 
@@ -1295,10 +1296,9 @@ mod tests {
             // Invalid operator
             (
                 "=>2.0",
-                ParseErrorKind::InvalidOperator(
-                    "No such comparison operator '=>', must be one of ~= == != <= >= < > ==="
-                        .to_string(),
-                )
+                ParseErrorKind::InvalidOperator(OperatorParseError {
+                    got: "=>".to_string(),
+                })
                 .into(),
             ),
             // Version-less specifier
@@ -1419,14 +1419,14 @@ mod tests {
             (
                 "==1.0.*+5",
                 ParseErrorKind::InvalidVersion(
-                    "Version `1.0.*+5` doesn't match PEP 440 rules".to_string(),
+                    version::PatternErrorKind::WildcardNotTrailing.into(),
                 )
                 .into(),
             ),
             (
                 "!=1.0.*+deadbeef",
                 ParseErrorKind::InvalidVersion(
-                    "Version `1.0.*+deadbeef` doesn't match PEP 440 rules".to_string(),
+                    version::PatternErrorKind::WildcardNotTrailing.into(),
                 )
                 .into(),
             ),
@@ -1435,56 +1435,80 @@ mod tests {
             (
                 "==2.0a1.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a prerelease version".to_string(),
+                    version::ErrorKind::UnexpectedEnd {
+                        version: Version::new([2, 0]).with_pre(Some((PreRelease::Alpha, 1))),
+                        remaining: ".*".to_string(),
+                    }
+                    .into(),
                 )
                 .into(),
             ),
             (
                 "!=2.0a1.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a prerelease version".to_string(),
+                    version::ErrorKind::UnexpectedEnd {
+                        version: Version::new([2, 0]).with_pre(Some((PreRelease::Alpha, 1))),
+                        remaining: ".*".to_string(),
+                    }
+                    .into(),
                 )
                 .into(),
             ),
             (
                 "==2.0.post1.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a post version".to_string(),
+                    version::ErrorKind::UnexpectedEnd {
+                        version: Version::new([2, 0]).with_post(Some(1)),
+                        remaining: ".*".to_string(),
+                    }
+                    .into(),
                 )
                 .into(),
             ),
             (
                 "!=2.0.post1.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a post version".to_string(),
+                    version::ErrorKind::UnexpectedEnd {
+                        version: Version::new([2, 0]).with_post(Some(1)),
+                        remaining: ".*".to_string(),
+                    }
+                    .into(),
                 )
                 .into(),
             ),
             (
                 "==2.0.dev1.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a dev version".to_string(),
+                    version::ErrorKind::UnexpectedEnd {
+                        version: Version::new([2, 0]).with_dev(Some(1)),
+                        remaining: ".*".to_string(),
+                    }
+                    .into(),
                 )
                 .into(),
             ),
             (
                 "!=2.0.dev1.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a dev version".to_string(),
+                    version::ErrorKind::UnexpectedEnd {
+                        version: Version::new([2, 0]).with_dev(Some(1)),
+                        remaining: ".*".to_string(),
+                    }
+                    .into(),
                 )
                 .into(),
             ),
             (
                 "==1.0+5.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a local version".to_string(),
+                    version::ErrorKind::LocalEmpty { precursor: '.' }.into(),
                 )
                 .into(),
             ),
             (
                 "!=1.0+deadbeef.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a local version".to_string(),
+                    version::ErrorKind::LocalEmpty { precursor: '.' }.into(),
                 )
                 .into(),
             ),
@@ -1492,7 +1516,7 @@ mod tests {
             (
                 "==1.0.*.5",
                 ParseErrorKind::InvalidVersion(
-                    "Version `1.0.*.5` doesn't match PEP 440 rules".to_string(),
+                    version::PatternErrorKind::WildcardNotTrailing.into(),
                 )
                 .into(),
             ),
@@ -1505,14 +1529,22 @@ mod tests {
             (
                 "==1.0.dev1.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a dev version".to_string(),
+                    version::ErrorKind::UnexpectedEnd {
+                        version: Version::new([1, 0]).with_dev(Some(1)),
+                        remaining: ".*".to_string(),
+                    }
+                    .into(),
                 )
                 .into(),
             ),
             (
                 "!=1.0.dev1.*",
                 ParseErrorKind::InvalidVersion(
-                    "You can't have both a trailing `.*` and a dev version".to_string(),
+                    version::ErrorKind::UnexpectedEnd {
+                        version: Version::new([1, 0]).with_dev(Some(1)),
+                        remaining: ".*".to_string(),
+                    }
+                    .into(),
                 )
                 .into(),
             ),
@@ -1625,7 +1657,8 @@ Failed to parse version: Unexpected end of version specifier, expected operator:
         };
         let op = Operator::TildeEqual;
         let v = Version::new([5]);
-        assert_eq!(err, VersionSpecifier::new(op, v, false).unwrap_err());
+        let vpat = VersionPattern::verbatim(v);
+        assert_eq!(err, VersionSpecifier::new(op, vpat).unwrap_err());
         assert_eq!(
             err.to_string(),
             "The ~= operator requires at least two segments in the release version"
diff --git a/crates/pep508-rs/src/lib.rs b/crates/pep508-rs/src/lib.rs
index 65396dab9..feb562099 100644
--- a/crates/pep508-rs/src/lib.rs
+++ b/crates/pep508-rs/src/lib.rs
@@ -913,7 +913,7 @@ mod tests {
 
     use indoc::indoc;
 
-    use pep440_rs::{Operator, Version, VersionSpecifier};
+    use pep440_rs::{Operator, Version, VersionPattern, VersionSpecifier};
     use puffin_normalize::{ExtraName, PackageName};
 
     use crate::marker::{
@@ -977,11 +977,14 @@ mod tests {
                 [
                     VersionSpecifier::new(
                         Operator::GreaterThanEqual,
-                        Version::new([2, 8, 1]),
-                        false,
+                        VersionPattern::verbatim(Version::new([2, 8, 1])),
+                    )
+                    .unwrap(),
+                    VersionSpecifier::new(
+                        Operator::Equal,
+                        VersionPattern::wildcard(Version::new([2, 8])),
                     )
                     .unwrap(),
-                    VersionSpecifier::new(Operator::Equal, Version::new([2, 8]), true).unwrap(),
                 ]
                 .into_iter()
                 .collect(),
@@ -1114,7 +1117,7 @@ mod tests {
         assert_err(
             "numpy ( ><1.19 )",
             indoc! {"
-                No such comparison operator '><', must be one of ~= == != <= >= < > ===
+                no such comparison operator \"><\", must be one of ~= == != <= >= < > ===
                 numpy ( ><1.19 )
                         ^^^^^^^"
             },
@@ -1430,7 +1433,7 @@ mod tests {
         assert_err(
             "name==1.0.org1",
             indoc! {"
-                Version `1.0.org1` doesn't match PEP 440 rules
+                after parsing 1.0, found \".org1\" after it, which is not part of a valid version
                 name==1.0.org1
                     ^^^^^^^^^^"
             },
diff --git a/crates/pep508-rs/src/marker.rs b/crates/pep508-rs/src/marker.rs
index 0ac963b77..ec59cceef 100644
--- a/crates/pep508-rs/src/marker.rs
+++ b/crates/pep508-rs/src/marker.rs
@@ -10,7 +10,7 @@
 //! bogus comparisons with unintended semantics are made.
 
 use crate::{Cursor, Pep508Error, Pep508ErrorSource};
-use pep440_rs::{Version, VersionSpecifier};
+use pep440_rs::{Version, VersionPattern, VersionSpecifier};
 #[cfg(feature = "pyo3")]
 use pyo3::{
     basic::CompareOp, exceptions::PyValueError, pyclass, pymethods, PyAny, PyResult, Python,
@@ -307,7 +307,7 @@ impl FromStr for StringVersion {
     fn from_str(s: &str) -> Result {
         Ok(Self {
             string: s.to_string(),
-            version: Version::from_str(s)?,
+            version: Version::from_str(s).map_err(|e| e.to_string())?,
         })
     }
 }
@@ -559,9 +559,9 @@ impl MarkerExpression {
             // The only sound choice for this is `  `
             MarkerValue::MarkerEnvVersion(l_key) => {
                 let value = &self.r_value;
-                let (r_version, r_star) = if let MarkerValue::QuotedString(r_string) = &value {
-                    match Version::from_str_star(r_string) {
-                        Ok((version, star)) => (version, star),
+                let r_vpat = if let MarkerValue::QuotedString(r_string) = &value {
+                    match r_string.parse::() {
+                        Ok(vpat) => vpat,
                         Err(err) => {
                             reporter(MarkerWarningKind::Pep440Error, format!(
                                 "Expected PEP 440 version to compare with {}, found {}, evaluating to false: {}",
@@ -582,14 +582,14 @@ impl MarkerExpression {
                     None => {
                         reporter(MarkerWarningKind::Pep440Error, format!(
                             "Expected PEP 440 version operator to compare {} with '{}', found '{}', evaluating to false",
-                            l_key, r_version, self.operator
+                            l_key, r_vpat.version(), self.operator
                         ), self);
                         return false;
                     }
                     Some(operator) => operator,
                 };
 
-                let specifier = match VersionSpecifier::new(operator, r_version, r_star) {
+                let specifier = match VersionSpecifier::new(operator, r_vpat) {
                     Ok(specifier) => specifier,
                     Err(err) => {
                         reporter(
@@ -660,18 +660,20 @@ impl MarkerExpression {
                             Some(operator) => operator,
                         };
 
-                        let specifier =
-                            match VersionSpecifier::new(operator, r_version.clone(), false) {
-                                Ok(specifier) => specifier,
-                                Err(err) => {
-                                    reporter(
-                                        MarkerWarningKind::Pep440Error,
-                                        format!("Invalid operator/version combination: {err}"),
-                                        self,
-                                    );
-                                    return false;
-                                }
-                            };
+                        let specifier = match VersionSpecifier::new(
+                            operator,
+                            VersionPattern::verbatim(r_version.clone()),
+                        ) {
+                            Ok(specifier) => specifier,
+                            Err(err) => {
+                                reporter(
+                                    MarkerWarningKind::Pep440Error,
+                                    format!("Invalid operator/version combination: {err}"),
+                                    self,
+                                );
+                                return false;
+                            }
+                        };
 
                         specifier.contains(&l_version)
                     }
@@ -756,10 +758,10 @@ impl MarkerExpression {
                 // ignore all errors block
                 (|| {
                     // The right hand side is allowed to contain a star, e.g. `python_version == '3.*'`
-                    let (r_version, r_star) = Version::from_str_star(r_string).ok()?;
+                    let r_vpat = r_string.parse::().ok()?;
                     let operator = operator.to_pep440_operator()?;
                     // operator and right hand side make the specifier
-                    let specifier = VersionSpecifier::new(operator, r_version, r_star).ok()?;
+                    let specifier = VersionSpecifier::new(operator, r_vpat).ok()?;
 
                     let compatible = python_versions
                         .iter()
@@ -783,7 +785,10 @@ impl MarkerExpression {
                     let compatible = python_versions.iter().any(|r_version| {
                         // operator and right hand side make the specifier and in this case the
                         // right hand is `python_version` so changes every iteration
-                        match VersionSpecifier::new(operator, r_version.clone(), false) {
+                        match VersionSpecifier::new(
+                            operator,
+                            VersionPattern::verbatim(r_version.clone()),
+                        ) {
                             Ok(specifier) => specifier.contains(&l_version),
                             Err(_) => true,
                         }
@@ -1439,7 +1444,9 @@ mod test {
         testing_logger::validate(|captured_logs| {
             assert_eq!(
                 captured_logs[0].body,
-                "Expected PEP 440 version to compare with python_version, found '3.9.', evaluating to false: Version `3.9.` doesn't match PEP 440 rules"
+                "Expected PEP 440 version to compare with python_version, found '3.9.', \
+                 evaluating to false: after parsing 3.9, found \".\" after it, \
+                 which is not part of a valid version"
             );
             assert_eq!(captured_logs[0].level, Level::Warn);
             assert_eq!(captured_logs.len(), 1);
diff --git a/crates/puffin-cli/tests/pip_compile.rs b/crates/puffin-cli/tests/pip_compile.rs
index 8b5485697..2a5e39c53 100644
--- a/crates/puffin-cli/tests/pip_compile.rs
+++ b/crates/puffin-cli/tests/pip_compile.rs
@@ -701,7 +701,7 @@ fn compile_python_invalid_version() -> Result<()> {
         ----- stdout -----
 
         ----- stderr -----
-        error: invalid value '3.7.x' for '--python-version ': Version `3.7.x` doesn't match PEP 440 rules
+        error: invalid value '3.7.x' for '--python-version ': after parsing 3.7, found ".x" after it, which is not part of a valid version
 
         For more information, try '--help'.
         "###);
diff --git a/crates/puffin-cli/tests/pip_uninstall.rs b/crates/puffin-cli/tests/pip_uninstall.rs
index 958c3a6c0..96e13663a 100644
--- a/crates/puffin-cli/tests/pip_uninstall.rs
+++ b/crates/puffin-cli/tests/pip_uninstall.rs
@@ -49,7 +49,7 @@ fn invalid_requirement() -> Result<()> {
 
     ----- stderr -----
     error: Failed to parse `flask==1.0.x`
-      Caused by: Version `1.0.x` doesn't match PEP 440 rules
+      Caused by: after parsing 1.0, found ".x" after it, which is not part of a valid version
     flask==1.0.x
          ^^^^^^^
     "###);
@@ -96,7 +96,7 @@ fn invalid_requirements_txt_requirement() -> Result<()> {
 
     ----- stderr -----
     error: Couldn't parse requirement in requirements.txt position 0 to 12
-      Caused by: Version `1.0.x` doesn't match PEP 440 rules
+      Caused by: after parsing 1.0, found ".x" after it, which is not part of a valid version
     flask==1.0.x
          ^^^^^^^
     "###);
@@ -210,7 +210,7 @@ dependencies = ["flask==1.0.x"]
       |
     3 | dependencies = ["flask==1.0.x"]
       |                ^^^^^^^^^^^^^^^^
-    Version `1.0.x` doesn't match PEP 440 rules
+    after parsing 1.0, found ".x" after it, which is not part of a valid version
     flask==1.0.x
          ^^^^^^^
 
diff --git a/crates/pypi-types/src/metadata.rs b/crates/pypi-types/src/metadata.rs
index 7f4ce64df..775deaa90 100644
--- a/crates/pypi-types/src/metadata.rs
+++ b/crates/pypi-types/src/metadata.rs
@@ -7,7 +7,7 @@ use mailparse::{MailHeaderMap, MailParseError};
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
-use pep440_rs::{Version, VersionSpecifiers, VersionSpecifiersParseError};
+use pep440_rs::{Version, VersionParseError, VersionSpecifiers, VersionSpecifiersParseError};
 use pep508_rs::{Pep508Error, Requirement};
 use puffin_normalize::{ExtraName, InvalidNameError, PackageName};
 
@@ -60,7 +60,7 @@ pub enum Error {
     MultipleMetadataFiles(Vec),
     /// Invalid Version
     #[error("invalid version: {0}")]
-    Pep440VersionError(String),
+    Pep440VersionError(VersionParseError),
     /// Invalid VersionSpecifier
     #[error(transparent)]
     Pep440Error(#[from] VersionSpecifiersParseError),