diff --git a/Cargo.toml b/Cargo.toml index 6524c79..1e45dba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,12 +10,25 @@ repository = "https://github.com/John15321/rust-pip" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true + [dependencies] anyhow = { version = "1.0.58", features = ["backtrace"] } -reqwest = { version = "0.11", features = ["blocking", "json"] } +reqwest = { version = "0.11", features = ["blocking", "json", "stream"] } structopt = { version = "0.3.26", features = ["color"] } -strum = "0.24.1" -strum_macros = "0.24.2" +serde = {version = "1", features = ["derive"]} +strum = {version = "0.24.1", features = ["derive"]} +clap = { version = "3.2", features = ["derive"] } +strum_macros = "0.24.2" +lazy_static = "1.4.0" +pomsky-macro = "0.6.0" +derivative = "2.2.0" +regex = "1" [dev-dependencies] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/src/main.rs b/src/main.rs index fa54e9f..ce081fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +mod requirements; + use std::path::PathBuf; use structopt::StructOpt; diff --git a/src/requirements/mod.rs b/src/requirements/mod.rs new file mode 100644 index 0000000..a48643d --- /dev/null +++ b/src/requirements/mod.rs @@ -0,0 +1,237 @@ +mod package_version; + +use std::fmt::Display; +use std::fs::{read_to_string, File}; +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use package_version::PackageVersion; +use pomsky_macro::pomsky; +use regex::Regex; + +static REQUIREMENTS_LINE_PARSER: &str = pomsky!( + "v"? + ( + :op("==" | ">=" | "<=") + ) +); + +#[derive(Debug, PartialEq, Eq)] +/// Represents the possible "operators" of a package-version pair. +/// +/// For now, this is `==`, `>=`, and `<=` +pub enum PyRequirementsOperator { + EqualTo, + GreaterThan, + LesserThan, +} + +impl PyRequirementsOperator { + /// Creates a new `PyRequirementsOperator` + /// + /// # Examples + /// ``` + /// let a = PyRequirementsOperator::new("==").unwrap(); // Returns PyRequirementsOperator::EqualTo + /// let b = PyRequirementsOperator::new("BigChungus"); // Returns an Err + /// let c = PyRequirementsOperator::new("!!").unwrap(); // Also returns an Err + /// ``` + fn new(op: &str) -> Result { + if op.len() > 2 { + return Err(format!("Operator is {} long", op.len())); + } + + match op { + "==" => Ok(Self::EqualTo), + ">=" => Ok(Self::GreaterThan), + "<=" => Ok(Self::LesserThan), + _ => Err(format!("Unknown Operator: {}", op)), + } + } +} + +impl Default for PyRequirementsOperator { + fn default() -> Self { + Self::EqualTo + } +} + +impl Display for PyRequirementsOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::EqualTo => "==", + Self::GreaterThan => ">=", + Self::LesserThan => "<=", + } + ) + } +} + +/// Represents a module in a `requirements.txt` file +#[derive(Debug)] +pub struct PyRequirementsModule { + pub package: String, + pub version: PackageVersion, + pub operator: PyRequirementsOperator, +} + +impl PyRequirementsModule { + /// Represents a dependency stated in a project's `requirements.txt` file + /// + /// # Example + /// ``` + /// let bs4 = PyRequirementsModule::new("bs4==10.3.2"); + /// ``` + fn new(raw: &str) -> Result { + let regex = Regex::new(REQUIREMENTS_LINE_PARSER).unwrap(); + let res = match regex.captures(raw) { + Some(caps) => caps, + None => bail!("unable to parse line"), + }; + + let op = res.name("op").unwrap(); + let (op_start, op_end) = (op.start(), op.end()); + + Ok(Self { + operator: match PyRequirementsOperator::new( + res.name("op").unwrap().as_str(), + ) { + Ok(op) => op, + Err(err) => bail!("Op Parsing returned an error: {}", err), + }, + package: raw[..op_start].to_string(), + version: match PackageVersion::new(&raw[op_end..]) { + Ok(ver) => ver, + Err(err) => bail!("Package Versioner returned an error: {}", err), + }, + }) + } +} + +impl Display for PyRequirementsModule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {} {}", self.package, self.operator, self.version) + } +} + +/// Represents a `requirements.txt` file +#[derive(Debug)] +pub struct PyRequirements { + file: PathBuf, + /// ALl the dependencies of a project + pub requirements: Vec, +} + +impl PyRequirements { + /// Represents a `requirements.txt` file + /// + /// # Example + /// ``` + /// let req = PyRequirements::new(PathBuf::from("project/requirements.txt")); + /// ``` + pub fn new(path: &PathBuf) -> Result { + if !path.exists() { + return Err(format!("{:?} does not exist!", path.to_str().unwrap())); + } + + // Check if the path specified is a file + if !path.is_file() { + return Err(format!("{:?} is not a file!", path.to_str().unwrap())); + } + + // Then check if that file is a "requirements.txt" file + // TODO: Use some magic to see if the file can be parsed + // and then use that to check instead of this + if !path.ends_with("requirements.txt") { + return Err(format!( + "File specified is not a 'requirements.txt' file: {:?}", + path.to_str().unwrap() + )); + } + + let binding = read_to_string(&path).expect( + format!("Unable to read file: {:?}", path.to_str().unwrap()).as_str(), + ); + + let raw: Vec<&str> = binding.split("\n").collect(); + let mut requirements = Vec::::new(); + + for (lineno, line) in raw.iter().enumerate() { + match PyRequirementsModule::new(line) { + Ok(py_mod) => requirements.push(py_mod), + Err(err) => { + println!("Unable to parse line {}: {}", lineno, err) + } + } + } + + // I FORGOR TO HAVE IT RETURN ITSELF + // :(((((((((((((((((((((((((((((( + Ok(Self { + file: path, + requirements: requirements, + }) + } +} + +#[cfg(test)] +mod tests { + use anyhow::{bail, Result}; + use std::path::PathBuf; + + use super::PyRequirements; + use super::PyRequirementsModule; + use super::PyRequirementsOperator; + + #[test] + fn check_py_requirements_operator() -> Result<()> { + let eq = PyRequirementsOperator::new("==").unwrap(); + let gt = PyRequirementsOperator::new(">=").unwrap(); + let lt = PyRequirementsOperator::new("<=").unwrap(); + + let e1 = PyRequirementsOperator::new("AMOGUSSSSSSSSSSSSS"); + + assert_eq!(eq, PyRequirementsOperator::EqualTo); + assert_eq!(gt, PyRequirementsOperator::GreaterThan); + assert_eq!(lt, PyRequirementsOperator::LesserThan); + + assert!(e1.is_err(), "e1 is supposed to be an Error!"); + + Ok(()) + } + + #[test] + fn check_py_requirements_line_parser() -> Result<()> { + let sample = "Pygments==2.11.2"; + let line = PyRequirementsModule::new(&sample); + + assert!( + line.is_ok(), + "Failed to parse line: {:?}", + line.unwrap_err() + ); + let res = line.unwrap(); + + assert_eq!(res.package, "Pygments"); + assert_eq!(res.version.to_string(), "2.11.2"); + assert_eq!(res.operator, PyRequirementsOperator::EqualTo); + + Ok(()) + } + + #[test] + fn check_py_requirements_file_parser() -> Result<()> { + let path = PathBuf::from("test/requirements.txt"); + let raw = PyRequirements::new(&path); + + assert!( + raw.is_ok(), + "Unable to parse file {:?}: {:?}", + path, + raw.unwrap_err() + ); + Ok(()) + } +} diff --git a/src/requirements/package_version.rs b/src/requirements/package_version.rs new file mode 100644 index 0000000..a367361 --- /dev/null +++ b/src/requirements/package_version.rs @@ -0,0 +1,474 @@ +//! Handling of pep-440 +// I went through heaven and hell just to pull this file ;-; +// - Kiwi +use anyhow::Result; +use derivative::Derivative; +use lazy_static::lazy_static; +use pomsky_macro::pomsky; +use serde::{Deserialize, Serialize}; +use std::{cmp::Ordering, fmt}; + +static VALIDATION_REGEX: &str = pomsky!( +"v"? + +(:epoch(['0'-'9']+)'!')? + +:release(['0'-'9']+("."['0'-'9']+)*) + +:pre( + ["-" "_" "."]? + + :pre_l( + ("preview"|"alpha"|"beta"|"pre"|"rc"|"a"|"b"|"c") + ) + + ["-" "_" "."]? + + :pre_n(['0'-'9']+)? +)? + +:post( + "-" + :post_n1(['0'-'9']+) + + | + + ["-" "_" "."]? + :post_l("post" | "rev" | "r") + ["-" "_" "."]? + :post_n2(['0'-'9']+)? +)? + +:dev( + ["-" "_" "."]? + :dev_l("dev") + ["-" "_" "."]? + :dev_n(['0'-'9']+)? +)? + +( +"+" +:local( + ['a'-'z' '0'-'9']+ + ((["-" "_" "."] ['a'-'z' '0'-'9']+)+)? +) +)? +); + +/// # Pep-440 Developmental release identifier +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, PartialOrd)] +pub struct DevHead { + dev_num: Option, +} + +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +pub enum PostHead { + Post, + Rev, +} + +impl PartialOrd for PostHead { + fn partial_cmp(&self, _other: &Self) -> Option { + Some(Ordering::Equal) + } +} + +/// # Pep-440 Post-Release identifier +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct PostHeader { + pub post_head: Option, + pub post_num: Option, +} + +impl PartialOrd for PostHeader { + fn partial_cmp(&self, other: &Self) -> Option { + if self.post_num == other.post_num { + return Some(Ordering::Equal); + } + + if self.post_num.is_none() && other.post_num.is_some() { + return Some(Ordering::Less); + } else if self.post_num.is_some() && other.post_num.is_none() { + return Some(Ordering::Greater); + } + + if self.post_num < other.post_num { + Some(Ordering::Less) + } else { + Some(Ordering::Greater) + } + } +} + +/// # Pep-440 Pre-Release identifier +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, PartialOrd)] +pub enum PreHeader { + Beta(Option), + /// Present in 1.1alpha1 or 1.1a1 both are represented the same way + /// ``` + /// PreHeader::Alpha(Some(1)) + /// ``` + Alpha(Option), + Preview(Option), + ReleaseCandidate(Option), +} + +/// Tracks Major and Minor Version Numbers +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, PartialOrd)] +pub struct ReleaseHeader { + /// Major release such as 1.0 or breaking changes + pub major: u32, + /// Minor release Such as new functionality + pub minor: u32, +} + +/// This struct is sorted so that PartialOrd +/// correctly interprets priority +/// +/// Lower == More important +/// +/// # Example Usage +/// ``` +/// let _ = PackageVersion::new("v1.0"); +/// ``` +#[derive(Derivative, Debug, Serialize, Deserialize)] +#[derivative(PartialOrd, PartialEq)] +pub struct PackageVersion { + #[derivative(PartialOrd = "ignore", PartialEq = "ignore")] + pub original: String, + + /// # Pep-440 Local version identifier + /// Local version sorting will have to be it's own issue + /// since there are no limits to what a local version can be + /// + /// For those who can read regex here it is for the local version: + /// `[a-z0-9]+(?:(?:[\-_.][a-z0-9]+)+)?` + /// + /// Here in Rulex: + /// ``` + /// ['a'-'z' '0'-'9']+ + /// ((["-" "_" "."] ['a'-'z' '0'-'9']+)+)? + /// ``` + #[derivative(PartialOrd = "ignore", PartialEq = "ignore")] + pub local: Option, + + /// # Pep-440 Developmental release identifier + pub dev: Option, + + /// # Pep-440 Post-Release identifier + pub post: Option, + + /// # Pep-440 Pre-Release identifier + pub pre: Option, + + /// # Pep-440 Release number + pub release: ReleaseHeader, + + /// # Pep-440 Version-Epoch + pub epoch: Option, +} + +impl PackageVersion { + pub fn new(version: &str) -> Result { + lazy_static! { + // Safe to unwrap since Regex is predefined + // Regex as defined in PEP-0440 + static ref VERSION_VALIDATOR: regex::Regex = + regex::Regex::new(VALIDATION_REGEX).unwrap(); + } + + let version_match = match VERSION_VALIDATOR.captures(version) { + Some(v) => v, + None => anyhow::bail!("Failed to decode version {}", version), + }; + + let epoch: Option = match version_match.name("epoch") { + Some(v) => Some(v.as_str().parse::()?), + None => None, + }; + + let release: ReleaseHeader = match version_match.name("release") { + Some(v) => { + if v.as_str().contains('.') { + let split: Vec<&str> = v.as_str().split('.').into_iter().collect(); + ReleaseHeader { + major: split[0].parse::()?, + minor: split[1].parse::()?, + } + } else { + ReleaseHeader { + major: v.as_str().parse::()?, + minor: 0, + } + } + } + // There always has to be at least a major version + None => anyhow::bail!("Failed to decode version {}", version), + }; + + let pre: Option = match version_match.name("pre") { + Some(_) => { + let pre_n = match version_match.name("pre_n") { + Some(v) => Some(v.as_str().parse::()?), + None => None, + }; + + // Should be safe to unwrap since we already checked if pre has a value + match version_match.name("pre_l").unwrap().as_str() { + "alpha" => Some(PreHeader::Alpha(pre_n)), + "a" => Some(PreHeader::Alpha(pre_n)), + "beta" => Some(PreHeader::Beta(pre_n)), + "b" => Some(PreHeader::Beta(pre_n)), + "rc" => Some(PreHeader::ReleaseCandidate(pre_n)), + "c" => Some(PreHeader::ReleaseCandidate(pre_n)), + "preview" => Some(PreHeader::Preview(pre_n)), + "pre" => Some(PreHeader::Preview(pre_n)), + _ => None, + } + } + None => None, + }; + + let post: Option = match version_match.name("post") { + Some(_) => { + let post_num: Option = match version_match.name("post_n1") { + Some(v) => Some(v.as_str().parse::()?), + None => match version_match.name("post_n2") { + Some(v) => Some(v.as_str().parse::()?), + _ => None, + }, + }; + + let post_head: Option = match version_match.name("post_l") { + Some(v) => { + match v.as_str() { + "post" => Some(PostHead::Post), + "rev" => Some(PostHead::Rev), + "r" => Some(PostHead::Rev), + // This branch Should be impossible (see regex-group post_l) + _ => None, + } + } + None => None, + }; + + Some(PostHeader { + post_head, + post_num, + }) + } + None => None, + }; + + let dev: Option = match version_match.name("dev") { + Some(_) => { + let dev_num = match version_match.name("dev_n") { + Some(v) => Some(v.as_str().parse::()?), + None => None, + }; + Some(DevHead { dev_num }) + } + None => None, + }; + + let local: Option = + version_match.name("local").map(|v| v.as_str().to_string()); + + Ok(Self { + original: version.to_string(), + epoch, + release, + pre, + post, + dev, + local, + }) + } +} + +impl fmt::Display for PackageVersion { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.original) + } +} + +#[cfg(test)] +mod tests { + use std::fmt::Debug; + + use crate::requirements::package_version::PackageVersion; + use anyhow::bail; + use anyhow::Result; + + use super::DevHead; + use super::PostHead; + use super::PostHeader; + use super::PreHeader; + use super::ReleaseHeader; + + fn check_a_greater(a: T, b: T) -> Result<()> + where + T: PartialEq + PartialOrd + Debug, + { + if a <= b { + bail!( + "Failed Less Than or Equal Check for A: {:?} \n<=\n B: {:?}", + a, + b + ) + } + Ok(()) + } + + #[test] + fn check_pep440_ordering() -> Result<()> { + check_a_greater( + PackageVersion::new("v1!1.0-preview-921.post-516.dev-241+yeah.this.is.the.problem.with.local.versions")?, + PackageVersion::new("1.0")?, + )?; + Ok(()) + } + + #[test] + fn check_release_ordering() -> Result<()> { + check_a_greater( + ReleaseHeader { major: 1, minor: 0 }, + ReleaseHeader { major: 0, minor: 0 }, + )?; + check_a_greater( + ReleaseHeader { major: 1, minor: 1 }, + ReleaseHeader { major: 1, minor: 0 }, + )?; + check_a_greater( + ReleaseHeader { major: 2, minor: 1 }, + ReleaseHeader { + major: 1, + minor: 52, + }, + )?; + Ok(()) + } + + #[test] + fn check_pre_ordering() -> Result<()> { + check_a_greater(PreHeader::ReleaseCandidate(None), PreHeader::Preview(None))?; + check_a_greater(PreHeader::Preview(None), PreHeader::Alpha(None))?; + check_a_greater(PreHeader::Alpha(None), PreHeader::Beta(None))?; + + check_a_greater( + PreHeader::ReleaseCandidate(Some(2)), + PreHeader::ReleaseCandidate(Some(1)), + )?; + check_a_greater(PreHeader::Preview(Some(50)), PreHeader::Preview(Some(3)))?; + check_a_greater(PreHeader::Alpha(Some(504)), PreHeader::Alpha(Some(0)))?; + check_a_greater(PreHeader::Beta(Some(1234)), PreHeader::Beta(Some(1)))?; + + check_a_greater( + PreHeader::ReleaseCandidate(Some(1)), + PreHeader::Beta(Some(45067885)), + )?; + Ok(()) + } + + #[test] + fn check_post_ordering() -> Result<()> { + check_a_greater( + PostHeader { + post_head: Some(PostHead::Post), + post_num: Some(0), + }, + PostHeader { + post_head: Some(PostHead::Post), + post_num: None, + }, + )?; + check_a_greater( + PostHeader { + post_head: Some(PostHead::Post), + post_num: Some(1), + }, + PostHeader { + post_head: Some(PostHead::Post), + post_num: Some(0), + }, + )?; + Ok(()) + } + + #[test] + fn check_dev_ordering() -> Result<()> { + check_a_greater(DevHead { dev_num: Some(0) }, DevHead { dev_num: None })?; + check_a_greater(DevHead { dev_num: Some(1) }, DevHead { dev_num: Some(0) })?; + Ok(()) + } + + #[test] + fn check_pep440_equality() -> Result<()> { + assert_eq!( + PackageVersion::new("1.0a1")?, + PackageVersion::new("1.0alpha1")? + ); + assert_eq!( + PackageVersion::new("1.0b")?, + PackageVersion::new("1.0beta")? + ); + assert_eq!(PackageVersion::new("1.0r")?, PackageVersion::new("1.0rev")?); + assert_eq!(PackageVersion::new("1.0c")?, PackageVersion::new("1.0rc")?); + assert_eq!(PackageVersion::new("v1.0")?, PackageVersion::new("1.0")?); + Ok(()) + } + + #[test] + fn check_pep440() { + // list of every example mentioned in pep-440 + let versions = vec![ + "1.0", + "v1.1", + "2.0", + "2013.10", + "2014.04", + "1!1.0", + "1!1.1", + "1!2.0", + "2!1.0.pre0", + "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 version in versions { + match PackageVersion::new(version) { + Ok(_v) => continue, + Err(e) => panic!("Oh no {}", e), + } + } + } + + #[test] + fn check_pep440_negative() { + let versions = vec!["not a version"]; + + for version in versions { + match PackageVersion::new(version) { + Ok(v) => panic!("Oh no {}", v), + Err(_e) => continue, + } + } + } +} diff --git a/test/README.txt b/test/README.txt new file mode 100644 index 0000000..40bade4 --- /dev/null +++ b/test/README.txt @@ -0,0 +1,4 @@ +This folder is for testing purposes. All files in this directory (except this README) +has been put here to test this codebase's functions + +- Kiwifruit \ No newline at end of file diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..d0b51d3 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,29 @@ +aiohttp==3.8.1 +aiosignal==1.2.0 +async-timeout==4.0.2 +attrs==21.4.0 +beautifulsoup4==4.10.0 +black==22.3.0 +certifi==2021.10.8 +charset-normalizer==2.0.12 +click==8.1.2 +commonmark==0.9.1 +expiringdict==1.2.1 +frozenlist==1.3.0 +idna==3.3 +multidict==6.0.2 +mypy-extensions==0.4.3 +NHentai-API==0.0.19 +pathspec==0.9.0 +platformdirs==2.5.1 +Pygments==2.11.2 +PyYAML==6.0 +requests==2.27.1 +retrying==1.3.3 +rich==12.1.0 +six==1.16.0 +soupsieve==2.3.2 +textual==0.1.17 +tomli==2.0.1 +urllib3==1.26.9 +yarl==1.7.2