diff --git a/.gitignore b/.gitignore index 38f662d90..ae387afa6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .idea/ +.vscode/ + # Generated by Cargo # will have compiled files and executables debug/ diff --git a/crates/rattler_conda_types/src/lib.rs b/crates/rattler_conda_types/src/lib.rs index 5c2d8a928..671d34a35 100644 --- a/crates/rattler_conda_types/src/lib.rs +++ b/crates/rattler_conda_types/src/lib.rs @@ -37,7 +37,9 @@ pub use repo_data::patches::{PackageRecordPatch, PatchInstructions, RepoDataPatc pub use repo_data::{ChannelInfo, ConvertSubdirError, PackageRecord, RepoData}; pub use repo_data_record::RepoDataRecord; pub use run_export::RunExportKind; -pub use version::{ParseVersionError, ParseVersionErrorKind, Version, VersionWithSource}; +pub use version::{ + Component, ParseVersionError, ParseVersionErrorKind, Version, VersionWithSource, +}; pub use version_spec::VersionSpec; #[cfg(test)] diff --git a/crates/rattler_conda_types/src/version/mod.rs b/crates/rattler_conda_types/src/version/mod.rs index c00c5aece..239f52fce 100644 --- a/crates/rattler_conda_types/src/version/mod.rs +++ b/crates/rattler_conda_types/src/version/mod.rs @@ -207,7 +207,7 @@ impl Version { } /// Returns the individual segments of the version. - fn segments( + pub fn segments( &self, ) -> impl Iterator> + DoubleEndedIterator + ExactSizeIterator + '_ { let mut idx = if self.has_epoch() { 1 } else { 0 }; @@ -305,7 +305,7 @@ impl Version { /// 1.2+3.2.1-alpha0 /// ^^^^^^^^^^^^ This is the local part of the version /// ``` - fn local_segments( + pub fn local_segments( &self, ) -> impl Iterator> + DoubleEndedIterator + ExactSizeIterator + '_ { if let Some(start) = self.local_segment_index() { @@ -671,7 +671,8 @@ impl<'v, I: Iterator> + 'v> fmt::Display for SegmentForma /// Either a number, literal or the infinity. #[derive(Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] -enum Component { +pub enum Component { + /// Numeral Component. Numeral(u64), /// Post should always be ordered greater than anything else. @@ -686,11 +687,13 @@ enum Component { /// An underscore or dash. UnderscoreOrDash { + /// Dash flag. is_dash: bool, }, } impl Component { + /// Returns a component as numeric value. pub fn as_number(&self) -> Option { match self { Component::Numeral(value) => Some(*value), @@ -698,6 +701,7 @@ impl Component { } } + /// Returns a component as mutable numeric value. pub fn as_number_mut(&mut self) -> Option<&mut u64> { match self { Component::Numeral(value) => Some(value), @@ -705,6 +709,7 @@ impl Component { } } + /// Returns a component as string value. #[allow(dead_code)] pub fn as_string(&self) -> Option<&str> { match self { @@ -713,16 +718,19 @@ impl Component { } } + /// Checks whether a component is [`Component::Post`] #[allow(dead_code)] pub fn is_post(&self) -> bool { matches!(self, Component::Post) } + /// Checks whether a component is [`Component::Dev`] #[allow(dead_code)] pub fn is_dev(&self) -> bool { matches!(self, Component::Dev) } + /// Checks whether a component is [`Component::Numeral`] pub fn is_numeric(&self) -> bool { matches!(self, Component::Numeral(_)) } @@ -887,7 +895,7 @@ impl<'de> Deserialize<'de> for Version { } } -struct SegmentIter<'v> { +pub struct SegmentIter<'v> { /// Information about the segment we are iterating. segment: Segment, diff --git a/py-rattler/rattler/__init__.py b/py-rattler/rattler/__init__.py index 1ffed8702..4d084172b 100644 --- a/py-rattler/rattler/__init__.py +++ b/py-rattler/rattler/__init__.py @@ -1,3 +1,5 @@ from rattler.version import Version +from rattler.match_spec import MatchSpec, NamelessMatchSpec +from rattler.repo_data import PackageRecord -__all__ = ["Version"] +__all__ = ["Version", "MatchSpec", "NamelessMatchSpec", "PackageRecord"] diff --git a/py-rattler/rattler/match_spec/__init__.py b/py-rattler/rattler/match_spec/__init__.py new file mode 100644 index 000000000..a6be9d545 --- /dev/null +++ b/py-rattler/rattler/match_spec/__init__.py @@ -0,0 +1,4 @@ +from rattler.match_spec.match_spec import MatchSpec +from rattler.match_spec.nameless_match_spec import NamelessMatchSpec + +__all__ = ["MatchSpec", "NamelessMatchSpec"] diff --git a/py-rattler/rattler/match_spec/match_spec.py b/py-rattler/rattler/match_spec/match_spec.py new file mode 100644 index 000000000..d8b4a705c --- /dev/null +++ b/py-rattler/rattler/match_spec/match_spec.py @@ -0,0 +1,117 @@ +from __future__ import annotations +from typing import Self, TYPE_CHECKING + +from rattler.rattler import PyMatchSpec + +if TYPE_CHECKING: + from rattler.match_spec import NamelessMatchSpec + from rattler.repo_data import PackageRecord + + +class MatchSpec: + """ + A `MatchSpec` is a query language for conda packages. + It can be composed of any of the attributes of `PackageRecord`. + + `MatchSpec` can be composed of keyword arguments, where keys are + any of the attributes of `PackageRecord`. Values for keyword arguments + are exact values the attributes should match against. Many fields can + be matched against non-exact values by including wildcard `*` and `>`/`<` + ranges where supported. Any non-specified field is the equivalent of a + full wildcard match. + + MatchSpecs can also be composed using a single positional argument, with optional + keyword arguments. Keyword arguments also override any conflicting information + provided in the positional argument. Conda has historically had several string + representations for equivalent MatchSpecs. + + A series of rules are now followed for creating the canonical string + representation of a MatchSpec instance. The canonical string representation can + generically be represented by: + + `(channel(/subdir):(namespace):)name(version(build))[key1=value1,key2=value2]` + + where `()` indicate optional fields. + + The rules for constructing a canonical string representation are: + + 1. `name` (i.e. "package name") is required, but its value can be '*'. Its + position is always outside the key-value brackets. + 2. If `version` is an exact version, it goes outside the key-value brackets and + is prepended by `==`. If `version` is a "fuzzy" value (e.g. `1.11.*`), it goes + outside the key-value brackets with the `.*` left off and is prepended by `=`. + Otherwise `version` is included inside key-value brackets. + 3. If `version` is an exact version, and `build` is an exact value, `build` goes + outside key-value brackets prepended by a `=`. Otherwise, `build` goes inside + key-value brackets. `build_string` is an alias for `build`. + 4. The `namespace` position is being held for a future feature. It is currently + ignored. + 5. If `channel` is included and is an exact value, a `::` separator is used between + `channel` and `name`. `channel` can either be a canonical channel name or a + channel url. In the canonical string representation, the canonical channel name + will always be used. + 6. If `channel` is an exact value and `subdir` is an exact value, `subdir` is + appended to `channel` with a `/` separator. Otherwise, `subdir` is included in + the key-value brackets. + 7. Key-value brackets can be delimited by comma, space, or comma+space. Value can + optionally be wrapped in single or double quotes, but must be wrapped if `value` + contains a comma, space, or equal sign. The canonical format uses comma delimiters + and single quotes. + 8. When constructing a `MatchSpec` instance from a string, any key-value pair given + inside the key-value brackets overrides any matching parameter given outside the + brackets. + + When `MatchSpec` attribute values are simple strings, the are interpreted using the + following conventions: + - If the string begins with `^` and ends with `$`, it is converted to a regex. + - If the string contains an asterisk (`*`), it is transformed from a glob to a + regex. + - Otherwise, an exact match to the string is sought. + + To fully-specify a package with a full, exact spec, the following fields must be + given as exact values: + - channel + - subdir + - name + - version + - build + """ + + def __init__(self, spec: str): + if isinstance(spec, str): + self._match_spec = PyMatchSpec(spec) + else: + raise TypeError( + "MatchSpec constructor received unsupported type" + f" {type(spec).__name__!r} for the 'spec' parameter" + ) + + @classmethod + def _from_py_match_spec(cls, py_match_spec: PyMatchSpec) -> Self: + """ + Construct py-rattler MatchSpec from PyMatchSpec FFI object. + """ + match_spec = cls.__new__(cls) + match_spec._match_spec = py_match_spec + + return match_spec + + def matches(self, record: PackageRecord) -> bool: + """Match a MatchSpec against a PackageRecord.""" + return self._match_spec.matches(record._package_record) + + @classmethod + def from_nameless(cls, spec: NamelessMatchSpec, name: str) -> Self: + """ + Constructs a MatchSpec from a NamelessMatchSpec + and a name. + """ + return cls._from_py_match_spec( + PyMatchSpec.from_nameless(spec._nameless_match_spec, name) + ) + + def __str__(self) -> str: + return self._match_spec.as_str() + + def __repr__(self) -> str: + return self.__str__() diff --git a/py-rattler/rattler/match_spec/nameless_match_spec.py b/py-rattler/rattler/match_spec/nameless_match_spec.py new file mode 100644 index 000000000..032c33bdc --- /dev/null +++ b/py-rattler/rattler/match_spec/nameless_match_spec.py @@ -0,0 +1,58 @@ +from __future__ import annotations +from typing import Self, TYPE_CHECKING + +from rattler.rattler import PyNamelessMatchSpec + +if TYPE_CHECKING: + from rattler.match_spec import MatchSpec + from rattler.repo_data import PackageRecord + + +class NamelessMatchSpec: + """ + Similar to a `MatchSpec` but does not include the package name. + This is useful in places where the package name is already known + (e.g. `foo = "3.4.1 *cuda"`). + """ + + def __init__(self, spec: str): + if isinstance(spec, str): + self._nameless_match_spec = PyNamelessMatchSpec(spec) + else: + raise TypeError( + "NamelessMatchSpec constructor received unsupported type" + f" {type(spec).__name__!r} for the 'spec' parameter" + ) + + def matches(self, package_record: PackageRecord) -> bool: + """ + Match a MatchSpec against a PackageRecord + """ + return self._nameless_match_spec.matches(package_record._package_record) + + @classmethod + def _from_py_nameless_match_spec( + cls, py_nameless_match_spec: PyNamelessMatchSpec + ) -> Self: + """ + Construct py-rattler NamelessMatchSpec from PyNamelessMatchSpec FFI object. + """ + nameless_match_spec = cls.__new__(cls) + nameless_match_spec._nameless_match_spec = py_nameless_match_spec + + return nameless_match_spec + + @classmethod + def from_match_spec(cls, spec: MatchSpec) -> Self: + """ + Constructs a NamelessMatchSpec from a MatchSpec. + """ + return cls._from_py_nameless_match_spec( + PyNamelessMatchSpec.from_match_spec(spec._match_spec) + ) + + def __str__(self) -> str: + return self._nameless_match_spec.as_str() + + def __repr__(self) -> str: + return self.__str__() diff --git a/py-rattler/rattler/repo_data/__init__.py b/py-rattler/rattler/repo_data/__init__.py new file mode 100644 index 000000000..c2e09d680 --- /dev/null +++ b/py-rattler/rattler/repo_data/__init__.py @@ -0,0 +1,3 @@ +from rattler.repo_data.package_record import PackageRecord + +__all__ = ["PackageRecord"] diff --git a/py-rattler/rattler/repo_data/package_record.py b/py-rattler/rattler/repo_data/package_record.py new file mode 100644 index 000000000..ddc576802 --- /dev/null +++ b/py-rattler/rattler/repo_data/package_record.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from rattler.rattler import PyPackageRecord + + +class PackageRecord: + """ + A single record in the Conda repodata. A single + record refers to a single binary distribution + of a package on a Conda channel. + """ + + def __init__(self): + self._package_record = PyPackageRecord() + + def __str__(self) -> str: + return self._package_record.as_str() + + def __repr__(self) -> str: + return self.__str__() diff --git a/py-rattler/rattler/version/version.py b/py-rattler/rattler/version/version.py index 8787af572..73aaf64c3 100644 --- a/py-rattler/rattler/version/version.py +++ b/py-rattler/rattler/version/version.py @@ -1,11 +1,22 @@ from __future__ import annotations -from typing import Optional +from typing import List, Optional, Tuple, Union -from rattler.rattler import PyVersion +from rattler.rattler import PyVersion, InvalidVersionError class Version: + """ + This class implements an order relation between version strings. + Version strings can contain the usual alphanumeric characters + (A-Za-z0-9), separated into segments by dots and underscores. + Empty segments (i.e. two consecutive dots, a leading/trailing + underscore) are not permitted. An optional epoch number - an + integer followed by '!' - can precede the actual version string + (this is useful to indicate a change in the versioning scheme itself). + Version comparison is case-insensitive. + """ + def __init__(self, version: str): if isinstance(version, str): self._version = PyVersion(version) @@ -54,6 +65,184 @@ def bump(self) -> Version: """ return Version._from_py_version(self._version.bump()) + @property + def has_local(self) -> bool: + """ + Returns true if this version has a local segment defined. + The local part of a version is the part behind the (optional) `+`. + + Examples + -------- + >>> v = Version('1.0+3.2-alpha0') + >>> v.has_local + True + >>> v2 = Version('1.0') + >>> v2.has_local + False + """ + return self._version.has_local() + + def segments(self) -> List[List[Union[str, int]]]: + """ + Returns a list of segments of the version. It does not contain + the local segment of the version. + + Examples + -------- + >>> v = Version("1.2dev.3-alpha4.5+6.8") + >>> v.segments() + [[1], [2, 'dev'], [3], [0, 'alpha', 4], [5]] + """ + return self._version.segments() + + def local_segments(self) -> List[List[Union[str, int]]]: + """ + Returns a list of local segments of the version. It does not + contain the non-local segment of the version. + + Examples + -------- + >>> v = Version("1.2dev.3-alpha4.5+6.8") + >>> v.local_segments() + [[6], [8]] + """ + return self._version.local_segments() + + def as_major_minor(self) -> Optional[Tuple[int, int]]: + """ + Returns the major and minor segments from the version. + Requires a minimum of 2 segments in version to be split + into major and minor, returns `None` otherwise. + + Examples + -------- + >>> v = Version('1.0') + >>> v.as_major_minor() + (1, 0) + """ + return self._version.as_major_minor() + + @property + def is_dev(self) -> bool: + """ + Returns true if the version contains a component name "dev", + dev versions are sorted before non-dev version. + + Examples + -------- + >>> v = Version('1.0.1dev') + >>> v.is_dev + True + >>> v_non_dev = Version('1.0.1') + >>> v_non_dev >= v + True + """ + return self._version.is_dev() + + def starts_with(self, other: Version) -> bool: + """ + Checks if the version and local segment start + same as other version. + + Examples + -------- + >>> v1 = Version('1.0.1') + >>> v2 = Version('1.0') + >>> v1.starts_with(v2) + True + """ + return self._version.starts_with(other._version) + + def compatible_with(self, other: Version) -> bool: + """ + Checks if this version is compatible with other version. + Minor versions changes are compatible with older versions, + major version changes are breaking and will not be compatible. + + Examples + -------- + >>> v1 = Version('1.0') + >>> v2 = Version('1.2') + >>> v_major = Version('2.0') + >>> v1.compatible_with(v2) + False + >>> v2.compatible_with(v1) + True + >>> v_major.compatible_with(v2) + False + >>> v2.compatible_with(v_major) + False + """ + return self._version.compatible_with(other._version) + + def pop_segments(self, n: int = 1) -> Version: + """ + Pops `n` number of segments from the version and returns + the new version. Raises `InvalidVersionError` if version + becomes invalid due to the operation. + + Examples + -------- + >>> v = Version('2!1.0.1') + >>> v.pop_segments() # `n` defaults to 1 if left empty + 2!1.0 + >>> v.pop_segments(2) # old version is still usable + 2!1 + >>> v.pop_segments(3) # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + exceptions.InvalidVersionException: new Version must have atleast 1 valid + segment + """ + new_py_version = self._version.pop_segments(n) + if new_py_version: + return self._from_py_version(new_py_version) + else: + raise InvalidVersionError("new Version must have atleast 1 valid segment") + + def with_segments(self, start: int, stop: int) -> Version: + """ + Returns new version with with segments ranging from `start` to `stop`. + `stop` is exclusive. Raises `InvalidVersionError` if the provided range + is invalid. + + Examples + -------- + >>> v = Version('2!1.2.3') + >>> v.with_segments(0, 2) + 2!1.2 + """ + new_py_version = self._version.with_segments(start, stop) + if new_py_version: + return self._from_py_version(new_py_version) + else: + raise InvalidVersionError("Invalid segment range provided") + + @property + def segment_count(self) -> int: + """ + Returns the number of segments in the version. + This does not include epoch or local segment of the version + + Examples + -------- + >>> v = Version('2!1.2.3') + >>> v.segment_count + 3 + """ + return self._version.segment_count() + + def strip_local(self) -> Version: + """ + Returns a new version with local segment stripped. + + Examples + -------- + >>> v = Version('1.2.3+4.alpha-5') + >>> v.strip_local() + 1.2.3 + """ + return self._from_py_version(self._version.strip_local()) + def __eq__(self, other: Version) -> bool: return self._version.equals(other._version) diff --git a/py-rattler/src/error.rs b/py-rattler/src/error.rs index 8eb34322a..982ca38dc 100644 --- a/py-rattler/src/error.rs +++ b/py-rattler/src/error.rs @@ -1,12 +1,14 @@ use pyo3::exceptions::PyException; use pyo3::{create_exception, PyErr}; -use rattler_conda_types::ParseVersionError; +use rattler_conda_types::{ParseMatchSpecError, ParseVersionError}; use thiserror::Error; #[derive(Error, Debug)] pub enum PyRattlerError { #[error(transparent)] InvalidVersion(#[from] ParseVersionError), + #[error(transparent)] + InvalidMatchSpec(#[from] ParseMatchSpecError), } impl From for PyErr { @@ -15,8 +17,12 @@ impl From for PyErr { PyRattlerError::InvalidVersion(err) => { InvalidVersionException::new_err(err.to_string()) } + PyRattlerError::InvalidMatchSpec(err) => { + InvalidMatchSpecException::new_err(err.to_string()) + } } } } create_exception!(exceptions, InvalidVersionException, PyException); +create_exception!(exceptions, InvalidMatchSpecException, PyException); diff --git a/py-rattler/src/lib.rs b/py-rattler/src/lib.rs index fac921917..e6d32d4e8 100644 --- a/py-rattler/src/lib.rs +++ b/py-rattler/src/lib.rs @@ -1,20 +1,37 @@ mod error; +mod match_spec; +mod nameless_match_spec; +mod repo_data; mod version; -use error::{InvalidVersionException, PyRattlerError}; +use error::{InvalidMatchSpecException, InvalidVersionException, PyRattlerError}; +use match_spec::PyMatchSpec; +use nameless_match_spec::PyNamelessMatchSpec; +use repo_data::package_record::PyPackageRecord; +use version::PyVersion; use pyo3::prelude::*; -use version::PyVersion; #[pymodule] fn rattler(py: Python, m: &PyModule) -> PyResult<()> { m.add_class::().unwrap(); + m.add_class::().unwrap(); + m.add_class::().unwrap(); + + m.add_class::().unwrap(); + // Exceptions m.add( "InvalidVersionError", py.get_type::(), ) .unwrap(); + m.add( + "InvalidMatchSpecError", + py.get_type::(), + ) + .unwrap(); + Ok(()) } diff --git a/py-rattler/src/match_spec.rs b/py-rattler/src/match_spec.rs new file mode 100644 index 000000000..f29cd076a --- /dev/null +++ b/py-rattler/src/match_spec.rs @@ -0,0 +1,55 @@ +use pyo3::{pyclass, pymethods}; +use rattler_conda_types::MatchSpec; +use std::str::FromStr; + +use crate::{ + error::PyRattlerError, nameless_match_spec::PyNamelessMatchSpec, + repo_data::package_record::PyPackageRecord, +}; + +#[pyclass] +#[repr(transparent)] +#[derive(Clone)] +pub struct PyMatchSpec { + inner: MatchSpec, +} + +impl From for PyMatchSpec { + fn from(value: MatchSpec) -> Self { + Self { inner: value } + } +} + +impl From for MatchSpec { + fn from(value: PyMatchSpec) -> Self { + value.inner + } +} + +#[pymethods] +impl PyMatchSpec { + #[new] + pub fn __init__(spec: &str) -> pyo3::PyResult { + Ok(MatchSpec::from_str(spec) + .map(Into::into) + .map_err(PyRattlerError::from)?) + } + + /// Returns a string representation of MatchSpec + pub fn as_str(&self) -> String { + format!("{}", self.inner) + } + + /// Matches a MatchSpec against a PackageRecord + pub fn matches(&self, record: &PyPackageRecord) -> bool { + self.inner.matches(&record.inner) + } + + /// Constructs a PyMatchSpec from a PyNamelessMatchSpec and a name. + #[staticmethod] + pub fn from_nameless(spec: &PyNamelessMatchSpec, name: String) -> Self { + Self { + inner: MatchSpec::from_nameless(spec.clone().into(), Some(name)), + } + } +} diff --git a/py-rattler/src/nameless_match_spec.rs b/py-rattler/src/nameless_match_spec.rs new file mode 100644 index 000000000..e1a052561 --- /dev/null +++ b/py-rattler/src/nameless_match_spec.rs @@ -0,0 +1,59 @@ +use pyo3::{pyclass, pymethods}; +use rattler_conda_types::{MatchSpec, NamelessMatchSpec}; +use std::str::FromStr; + +use crate::{ + error::PyRattlerError, match_spec::PyMatchSpec, repo_data::package_record::PyPackageRecord, +}; + +#[pyclass] +#[repr(transparent)] +#[derive(Clone)] +pub struct PyNamelessMatchSpec { + inner: NamelessMatchSpec, +} + +impl From for PyNamelessMatchSpec { + fn from(value: NamelessMatchSpec) -> Self { + Self { inner: value } + } +} + +impl From for NamelessMatchSpec { + fn from(val: PyNamelessMatchSpec) -> Self { + val.inner + } +} + +impl From for PyNamelessMatchSpec { + fn from(value: PyMatchSpec) -> Self { + let inner: NamelessMatchSpec = Into::::into(value).into(); + Self { inner } + } +} + +#[pymethods] +impl PyNamelessMatchSpec { + #[new] + pub fn __init__(spec: &str) -> pyo3::PyResult { + Ok(NamelessMatchSpec::from_str(spec) + .map(Into::into) + .map_err(PyRattlerError::from)?) + } + + /// Returns a string representation of MatchSpec + pub fn as_str(&self) -> String { + format!("{}", self.inner) + } + + /// Match a PyNamelessMatchSpec against a PyPackageRecord + pub fn matches(&self, record: &PyPackageRecord) -> bool { + self.inner.matches(&record.clone().into()) + } + + /// Constructs a [`PyNamelessMatchSpec`] from a [`PyMatchSpec`]. + #[staticmethod] + pub fn from_match_spec(spec: &PyMatchSpec) -> Self { + Into::::into(spec.clone()) + } +} diff --git a/py-rattler/src/repo_data/mod.rs b/py-rattler/src/repo_data/mod.rs new file mode 100644 index 000000000..da0d5b5f7 --- /dev/null +++ b/py-rattler/src/repo_data/mod.rs @@ -0,0 +1 @@ +pub mod package_record; diff --git a/py-rattler/src/repo_data/package_record.rs b/py-rattler/src/repo_data/package_record.rs new file mode 100644 index 000000000..0f1330a7b --- /dev/null +++ b/py-rattler/src/repo_data/package_record.rs @@ -0,0 +1,30 @@ +use rattler_conda_types::PackageRecord; + +use pyo3::{pyclass, pymethods}; + +#[pyclass] +#[repr(transparent)] +#[derive(Clone)] +pub struct PyPackageRecord { + pub(crate) inner: PackageRecord, +} + +impl From for PyPackageRecord { + fn from(value: PackageRecord) -> Self { + Self { inner: value } + } +} + +impl From for PackageRecord { + fn from(val: PyPackageRecord) -> Self { + val.inner + } +} + +#[pymethods] +impl PyPackageRecord { + /// Returns a string representation of PyPackageRecord + fn as_str(&self) -> String { + format!("{}", self.inner) + } +} diff --git a/py-rattler/src/version.rs b/py-rattler/src/version.rs deleted file mode 100644 index 89de82601..000000000 --- a/py-rattler/src/version.rs +++ /dev/null @@ -1,72 +0,0 @@ -use crate::PyRattlerError; -use pyo3::{pyclass, pymethods}; -use rattler_conda_types::Version; -use std::str::FromStr; - -#[pyclass] -#[repr(transparent)] -#[derive(Clone)] -pub struct PyVersion { - inner: Version, -} - -impl From for PyVersion { - fn from(value: Version) -> Self { - PyVersion { inner: value } - } -} - -#[pymethods] -impl PyVersion { - #[new] - pub fn __init__(version: &str) -> pyo3::PyResult { - Ok(Version::from_str(version) - .map(Into::into) - .map_err(PyRattlerError::from)?) - } - - /// Returns a string representation of the version. - pub fn as_str(&self) -> String { - format!("{}", self.inner) - } - - /// Returns the epoch of the version - pub fn epoch(&self) -> Option { - self.inner.epoch_opt() - } - - /// Returns a new version where the last numerical segment of this version has been bumped. - pub fn bump(&self) -> Self { - Self { - inner: self.inner.bump(), - } - } - - pub fn equal(&self, other: &Self) -> bool { - self.inner == other.inner - } - - pub fn not_equal(&self, other: &Self) -> bool { - self.inner != other.inner - } - - pub fn less_than(&self, other: &Self) -> bool { - self.inner < other.inner - } - - pub fn less_than_equals(&self, other: &Self) -> bool { - self.inner <= other.inner - } - - pub fn equals(&self, other: &Self) -> bool { - self.inner == other.inner - } - - pub fn greater_than_equals(&self, other: &Self) -> bool { - self.inner >= other.inner - } - - pub fn greater_than(&self, other: &Self) -> bool { - self.inner > other.inner - } -} diff --git a/py-rattler/src/version/component.rs b/py-rattler/src/version/component.rs new file mode 100644 index 000000000..eb72c0898 --- /dev/null +++ b/py-rattler/src/version/component.rs @@ -0,0 +1,28 @@ +use pyo3::{IntoPy, PyObject, Python}; +use rattler_conda_types::Component; + +pub enum PyComponent { + String(String), + Number(u64), +} + +impl IntoPy for PyComponent { + fn into_py(self, py: Python) -> PyObject { + match self { + Self::Number(val) => val.into_py(py), + Self::String(val) => val.into_py(py), + } + } +} + +impl From for PyComponent { + fn from(value: Component) -> Self { + match value { + Component::Iden(v) => Self::String(v.to_string()), + Component::Numeral(n) => Self::Number(n), + Component::Dev => Self::String("dev".to_string()), + Component::Post => Self::String("post".to_string()), + Component::UnderscoreOrDash { .. } => Self::String("_".to_string()), + } + } +} diff --git a/py-rattler/src/version/mod.rs b/py-rattler/src/version/mod.rs new file mode 100644 index 000000000..db5ffa372 --- /dev/null +++ b/py-rattler/src/version/mod.rs @@ -0,0 +1,159 @@ +mod component; + +use crate::PyRattlerError; +use component::PyComponent; +use pyo3::{pyclass, pymethods}; +use rattler_conda_types::Version; +use std::str::FromStr; + +#[pyclass] +#[repr(transparent)] +#[derive(Clone)] +pub struct PyVersion { + inner: Version, +} + +impl From for PyVersion { + fn from(value: Version) -> Self { + PyVersion { inner: value } + } +} + +#[pymethods] +impl PyVersion { + #[new] + pub fn __init__(version: &str) -> pyo3::PyResult { + Ok(Version::from_str(version) + .map(Into::into) + .map_err(PyRattlerError::from)?) + } + + /// Returns a string representation of the version. + pub fn as_str(&self) -> String { + format!("{}", self.inner) + } + + /// Returns the epoch of the version. + pub fn epoch(&self) -> Option { + self.inner.epoch_opt() + } + + /// Returns true if this version has a local segment defined. + pub fn has_local(&self) -> bool { + self.inner.has_local() + } + + /// Returns the major and minor segments from the version. + pub fn as_major_minor(&self) -> Option<(u64, u64)> { + self.inner.as_major_minor() + } + + /// Returns true if the version contains a component name "dev". + pub fn is_dev(&self) -> bool { + self.inner.is_dev() + } + + /// Checks if the version and local segment start + /// same as other version. + pub fn starts_with(&self, other: &Self) -> bool { + self.inner.starts_with(&other.inner) + } + + /// Checks if this version is compatible with other version. + pub fn compatible_with(&self, other: &Self) -> bool { + self.inner.compatible_with(&other.inner) + } + + /// Pops `n` number of segments from the version and returns + /// the new version. Returns `None` if the version becomes + /// invalid due to the operation. + pub fn pop_segments(&self, n: usize) -> Option { + Some(Self { + inner: self.inner.pop_segments(n)?, + }) + } + + /// Returns a list of segments of the version. It does not contain + /// the local segment of the version. See `local_segments` for + /// local segments in version. + pub fn segments(&self) -> Vec> { + self.inner + .segments() + .map(|s| { + s.components() + .map(|c| c.to_owned().into()) + .collect::>() + }) + .collect::>() + } + + /// Returns a list of local segments of the version. It does not + /// contain the non-local segment of the version. + pub fn local_segments(&self) -> Vec> { + self.inner + .local_segments() + .map(|s| { + s.components() + .map(|c| c.to_owned().into()) + .collect::>() + }) + .collect::>() + } + + /// Returns new version with with segments ranging from `start` to `stop`. + /// `stop` is exclusive. + pub fn with_segments(&self, start: usize, stop: usize) -> Option { + let range = start..stop; + + Some(Self { + inner: self.inner.with_segments(range)?, + }) + } + + /// Returns the number of segments in the version. + pub fn segment_count(&self) -> usize { + self.inner.segment_count() + } + + /// Create a new version with local segment stripped. + pub fn strip_local(&self) -> Self { + Self { + inner: self.inner.strip_local().into_owned(), + } + } + + /// Returns a new version where the last numerical segment of this version has been bumped. + pub fn bump(&self) -> Self { + Self { + inner: self.inner.bump(), + } + } + + pub fn equal(&self, other: &Self) -> bool { + self.inner == other.inner + } + + pub fn not_equal(&self, other: &Self) -> bool { + self.inner != other.inner + } + + pub fn less_than(&self, other: &Self) -> bool { + self.inner < other.inner + } + + pub fn less_than_equals(&self, other: &Self) -> bool { + self.inner <= other.inner + } + + pub fn equals(&self, other: &Self) -> bool { + self.inner == other.inner + } + + pub fn greater_than_equals(&self, other: &Self) -> bool { + self.inner >= other.inner + } + + pub fn greater_than(&self, other: &Self) -> bool { + self.inner > other.inner + } +} diff --git a/py-rattler/tests/unit/test_version.py b/py-rattler/tests/unit/test_version.py index 902099e6e..7e7a72797 100644 --- a/py-rattler/tests/unit/test_version.py +++ b/py-rattler/tests/unit/test_version.py @@ -1,24 +1,19 @@ +import pytest from rattler import Version -def test_version_comparision(): - assert Version("1.0") < Version("2.0") - assert Version("1.0") <= Version("2.0") - assert Version("2.0") > Version("1.0") - assert Version("2.0") >= Version("1.0") - assert Version("1.0.0") == Version("1.0") - assert Version("1.0") != Version("2.0") +def test_version_dash_normalisation(): + assert Version("1.0-").segments() == [[1], [0, "_"]] + assert Version("1.0_").segments() == [[1], [0, "_"]] + assert Version("1.0dev-+2.3").segments() == [[1], [0, "dev", "_"]] + assert Version("1.0dev_").segments() == [[1], [0, "dev", "_"]] + assert Version("1.0dev-+2.3").local_segments() == [[2], [3]] + assert Version("1.0dev+3.4-dev").local_segments() == [[3], [4], [0, "dev"]] + assert Version("1.0dev+3.4-").local_segments() == [[3], [4, "_"]] -def test_bump(): - assert Version("1.0").bump() == Version("1.1") - assert Version("1.a").bump() == Version("1.1a") - assert Version("1dev").bump() == Version("2dev") - assert Version("1dev0").bump() == Version("1dev1") - assert Version("1!0").bump() == Version("1!1") - assert Version("1.2-alpha.3-beta-dev0").bump() == Version("1.2-alpha.3-beta-dev1") + with pytest.raises(Exception): + Version("1-.0dev-") - -def test_epoch(): - assert Version("1!1.0").epoch == 1 - assert Version("1.0").epoch is None + with pytest.raises(Exception): + Version("1-.0dev+3.4-")