diff --git a/Cargo.lock b/Cargo.lock index 51a9d5e..e147d96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,12 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "aho-corasick" @@ -47,6 +53,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "basic-toml" version = "0.1.9" @@ -56,6 +68,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bstr" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -109,18 +131,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.23" +version = "4.5.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" +checksum = "9560b07a799281c7e0958b9296854d6fafd4c5f31444a7e5bb1ad6dde5ccf1bd" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.23" +version = "4.5.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" +checksum = "874e0dd3eb68bf99058751ac9712f622e61e6f393a94f7128fa26e3f02f5c7cd" dependencies = [ "anstyle", "clap_lex", @@ -164,6 +186,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -237,12 +280,28 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.4" @@ -260,6 +319,19 @@ dependencies = [ "wasi", ] +[[package]] +name = "globset" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "half" version = "2.4.1" @@ -318,6 +390,21 @@ version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +[[package]] +name = "insta" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6513e4067e16e69ed1db5ab56048ed65db32d10ba5fc1217f5393f8f17d8b5a5" +dependencies = [ + "console", + "globset", + "linked-hash-map", + "once_cell", + "serde", + "similar", + "walkdir", +] + [[package]] name = "is-terminal" version = "0.4.13" @@ -378,6 +465,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "log" version = "0.4.22" @@ -421,6 +514,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" +dependencies = [ + "adler2", +] + [[package]] name = "nom" version = "7.1.3" @@ -765,6 +867,12 @@ dependencies = [ "serde", ] +[[package]] +name = "similar" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" + [[package]] name = "smallvec" version = "2.0.0-alpha.9" @@ -794,8 +902,11 @@ version = "0.5.1" dependencies = [ "anyhow", "base16ct", + "base64", "codspeed-criterion-compat", + "flate2", "indexmap", + "insta", "pyo3", "quick-xml", "rand", diff --git a/Cargo.toml b/Cargo.toml index b83f359..e79fcd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,11 @@ indexmap = "2.6.0" pyo3 = { version = "0.23.3", features = ["abi3-py312", "anyhow"] } quick-xml = "0.37.1" regex = "1.11.1" -rinja = "0.3.5" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" +rinja = "0.3.5" +base64 = "0.22.1" +flate2 = "1.0.35" smallvec = "2.0.0-alpha.7" thiserror = "2.0.3" watto = { git = "https://github.com/getsentry/watto", features = [ @@ -28,10 +30,12 @@ watto = { git = "https://github.com/getsentry/watto", features = [ [dev-dependencies] criterion = { version = "2.7.2", package = "codspeed-criterion-compat" } rand = { version = "0.8.5", features = ["small_rng"] } +insta = { version = "1.42.0", features = ["glob", "yaml"] } [profile.release] debug = 1 + [[bench]] name = "binary" harness = false diff --git a/README.md b/README.md index 9a8450c..0ea48ca 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,15 @@ The CI uses the maturin-action to build wheels and an sdist The version of the wheels built are determined by the value of the version in the cargo.toml -There are 2 parsing function currently implemented: + +There are 2 parsing functions currently implemented: - `parse_junit_xml`: this parses `junit.xml` files -- `parse_pytest_reportlog`: this parses files produced by the `pytest-reportlog` extension -Both these functions take the path to the file to parse as an arg and return a list of `Testrun` objects. +This function takes the path to the file to parse as an arg and returns a list of `Testrun` objects. The `Testrun` objects look like this: + ``` Outcome: Pass, @@ -33,4 +34,8 @@ Testrun: outcome: Outcome duration: float testsuite: str -``` \ No newline at end of file +``` + +- `parse_raw_upload`: this parses an entire raw test results upload + +this function takes in the raw upload bytes and returns a message packed list of Testrun objects diff --git a/pyproject.toml b/pyproject.toml index 56f7092..e9971bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,4 +21,5 @@ dev-dependencies = [ "pytest-cov>=6.0.0", "pytest-reportlog>=0.4.0", "maturin>=1.7.4", + "pytest-insta>=0.3.0", ] diff --git a/src/junit.rs b/src/junit.rs index 030aa24..8b421c5 100644 --- a/src/junit.rs +++ b/src/junit.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use pyo3::prelude::*; use std::collections::HashSet; @@ -7,7 +8,6 @@ use quick_xml::reader::Reader; use crate::compute_name::{compute_name, unescape_str}; use crate::testrun::{check_testsuites_name, Framework, Outcome, ParsingInfo, Testrun}; -use crate::ParserError; #[derive(Default)] struct RelevantAttrs { @@ -21,8 +21,7 @@ struct RelevantAttrs { fn get_relevant_attrs(attributes: Attributes) -> PyResult { let mut rel_attrs = RelevantAttrs::default(); for attribute in attributes { - let attribute = attribute - .map_err(|e| ParserError::new_err(format!("Error parsing attribute: {}", e)))?; + let attribute = attribute.context("Error parsing attribute")?; let bytes = attribute.value.into_owned(); let value = String::from_utf8(bytes)?; match attribute.key.into_inner() { @@ -39,7 +38,7 @@ fn get_relevant_attrs(attributes: Attributes) -> PyResult { fn get_attribute(e: &BytesStart, name: &str) -> PyResult> { let attr = if let Some(message) = e .try_get_attribute(name) - .map_err(|e| ParserError::new_err(format!("Error parsing attribute: {}", e)))? + .context("Error parsing attribute")? { Some(String::from_utf8(message.value.to_vec())?) } else { @@ -57,9 +56,7 @@ fn populate( ) -> PyResult<(Testrun, Option)> { let classname = rel_attrs.classname.unwrap_or_default(); - let name = rel_attrs - .name - .ok_or_else(|| ParserError::new_err("No name found"))?; + let name = rel_attrs.name.context("No name found")?; let duration = rel_attrs .time @@ -88,18 +85,6 @@ fn populate( Ok((t, framework)) } -#[pyfunction] -pub fn parse_junit_xml(file_bytes: &[u8]) -> PyResult { - let mut reader = Reader::from_reader(file_bytes); - reader.config_mut().trim_text(true); - let reader_result = use_reader(&mut reader, None).map_err(|e| { - let pos = reader.buffer_position(); - let (line, col) = get_position_info(file_bytes, pos.try_into().unwrap()); - ParserError::new_err(format!("Error at {}:{}: {}", line, col, e)) - })?; - Ok(reader_result) -} - pub fn get_position_info(input: &[u8], byte_offset: usize) -> (usize, usize) { let mut line = 1; let mut last_newline = 0; @@ -136,13 +121,9 @@ pub fn use_reader( let mut buf = Vec::new(); loop { - let event = reader.read_event_into(&mut buf).map_err(|e| { - ParserError::new_err(format!( - "Error parsing XML at position: {} {:?}", - reader.buffer_position(), - e - )) - })?; + let event = reader + .read_event_into(&mut buf) + .context("Error parsing XML")?; match event { Event::Eof => { break; @@ -167,13 +148,13 @@ pub fn use_reader( b"skipped" => { let testrun = saved_testrun .as_mut() - .ok_or_else(|| ParserError::new_err("Error accessing saved testrun"))?; + .context("Error accessing saved testrun")?; testrun.outcome = Outcome::Skip; } b"error" => { let testrun = saved_testrun .as_mut() - .ok_or_else(|| ParserError::new_err("Error accessing saved testrun"))?; + .context("Error accessing saved testrun")?; testrun.outcome = Outcome::Error; testrun.failure_message = get_attribute(&e, "message")? @@ -184,7 +165,7 @@ pub fn use_reader( b"failure" => { let testrun = saved_testrun .as_mut() - .ok_or_else(|| ParserError::new_err("Error accessing saved testrun"))?; + .context("Error accessing saved testrun")?; testrun.outcome = Outcome::Failure; testrun.failure_message = get_attribute(&e, "message")? @@ -204,11 +185,9 @@ pub fn use_reader( }, Event::End(e) => match e.name().as_ref() { b"testcase" => { - let testrun = saved_testrun.take().ok_or_else(|| { - ParserError::new_err( - "Met testcase closing tag without first meeting testcase opening tag", - ) - })?; + let testrun = saved_testrun.take().context( + "Met testcase closing tag without first meeting testcase opening tag", + )?; testruns.push(testrun); } b"failure" => in_failure = false, @@ -239,7 +218,7 @@ pub fn use_reader( b"failure" => { let testrun = saved_testrun .as_mut() - .ok_or_else(|| ParserError::new_err("Error accessing saved testrun"))?; + .context("Error accessing saved testrun")?; testrun.outcome = Outcome::Failure; testrun.failure_message = get_attribute(&e, "message")? @@ -248,13 +227,13 @@ pub fn use_reader( b"skipped" => { let testrun = saved_testrun .as_mut() - .ok_or_else(|| ParserError::new_err("Error accessing saved testrun"))?; + .context("Error accessing saved testrun")?; testrun.outcome = Outcome::Skip; } b"error" => { let testrun = saved_testrun .as_mut() - .ok_or_else(|| ParserError::new_err("Error accessing saved testrun"))?; + .context("Error accessing saved testrun")?; testrun.outcome = Outcome::Error; testrun.failure_message = get_attribute(&e, "message")? @@ -266,7 +245,7 @@ pub fn use_reader( if in_failure || in_error { let testrun = saved_testrun .as_mut() - .ok_or_else(|| ParserError::new_err("Error accessing saved testrun"))?; + .context("Error accessing saved testrun")?; xml_failure_message.inplace_trim_end(); xml_failure_message.inplace_trim_start(); diff --git a/src/lib.rs b/src/lib.rs index b1ee782..147a9ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,23 +5,17 @@ pub mod binary; mod compute_name; mod failure_message; mod junit; +mod raw_upload; mod testrun; -pub use testrun::{Framework, Outcome, Testrun}; +pub use testrun::{Outcome, Testrun}; -pyo3::create_exception!(test_results_parser, ParserError, PyException); pyo3::create_exception!(test_results_parser, ComputeNameError, PyException); /// A Python module implemented in Rust. #[pymodule] -fn test_results_parser(py: Python, m: &Bound) -> PyResult<()> { - m.add("ParserError", py.get_type::())?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - m.add_class::()?; - - m.add_function(wrap_pyfunction!(junit::parse_junit_xml, m)?)?; +fn test_results_parser(_: Python, m: &Bound) -> PyResult<()> { + m.add_function(wrap_pyfunction!(raw_upload::parse_raw_upload, m)?)?; m.add_function(wrap_pyfunction!(failure_message::build_message, m)?)?; m.add_function(wrap_pyfunction!(failure_message::escape_message, m)?)?; m.add_function(wrap_pyfunction!(failure_message::shorten_file_paths, m)?)?; diff --git a/src/raw_upload.rs b/src/raw_upload.rs new file mode 100644 index 0000000..3cd4bae --- /dev/null +++ b/src/raw_upload.rs @@ -0,0 +1,135 @@ +use anyhow::Context; + +use base64::prelude::*; +use pyo3::prelude::*; +use std::collections::HashSet; +use std::io::prelude::*; + +use flate2::bufread::ZlibDecoder; + +use quick_xml::reader::Reader; +use serde::Deserialize; + +use crate::junit::{get_position_info, use_reader}; +use crate::testrun::ParsingInfo; + +#[derive(Deserialize, Debug, Clone)] +struct TestResultFile { + filename: String, + data: String, +} +#[derive(Deserialize, Debug, Clone)] +struct RawTestResultUpload { + #[serde(default)] + network: Option>, + test_results_files: Vec, +} + +#[derive(Debug, Clone)] +struct ReadableFile { + filename: String, + data: Vec, +} + +const LEGACY_FORMAT_PREFIX: &[u8] = b"# path="; +const LEGACY_FORMAT_SUFFIX: &[u8] = b"<<<<<< EOF"; + +fn serialize_to_legacy_format(readable_files: Vec) -> Vec { + let mut res = Vec::new(); + for file in readable_files { + res.extend_from_slice(LEGACY_FORMAT_PREFIX); + res.extend_from_slice(file.filename.as_bytes()); + res.extend_from_slice(b"\n"); + res.extend_from_slice(&file.data); + res.extend_from_slice(b"\n"); + res.extend_from_slice(LEGACY_FORMAT_SUFFIX); + res.extend_from_slice(b"\n"); + } + res +} + +#[pyfunction] +#[pyo3(signature = (raw_upload_bytes))] +pub fn parse_raw_upload(raw_upload_bytes: &[u8]) -> anyhow::Result<(Vec, Vec)> { + let upload: RawTestResultUpload = + serde_json::from_slice(raw_upload_bytes).context("Error deserializing json")?; + let network: Option> = upload.network; + + let mut results: Vec = Vec::with_capacity(upload.test_results_files.len()); + let mut readable_files: Vec = Vec::with_capacity(upload.test_results_files.len()); + + for file in upload.test_results_files { + let decoded_file_bytes = BASE64_STANDARD + .decode(file.data) + .context("Error decoding base64")?; + + let mut decoder = ZlibDecoder::new(decoded_file_bytes.as_slice()); + + let mut decompressed_file_bytes = Vec::new(); + decoder + .read_to_end(&mut decompressed_file_bytes) + .context("Error decompressing file")?; + + let mut reader = Reader::from_reader(decompressed_file_bytes.as_slice()); + reader.config_mut().trim_text(true); + let reader_result = use_reader(&mut reader, network.as_ref()).with_context(|| { + let pos = reader.buffer_position(); + let (line, col) = get_position_info(&decompressed_file_bytes, pos.try_into().unwrap()); + format!( + "Error parsing JUnit XML in {} at {}:{}", + file.filename, line, col + ) + })?; + + results.push(reader_result); + + let readable_file = ReadableFile { + data: decompressed_file_bytes, + filename: file.filename, + }; + readable_files.push(readable_file); + } + + let readable_file = serialize_to_legacy_format(readable_files); + + Ok((results, readable_file)) +} + +#[cfg(test)] +mod tests { + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use base64::Engine; + use flate2::Compression; + use std::io::Write; + + use super::*; + use insta::{assert_yaml_snapshot, glob}; + + fn file_into_bytes(filename: &str) -> Vec { + let upload = std::fs::read(filename).unwrap(); + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&upload).unwrap(); + let compressed = encoder.finish().unwrap(); + let base64_data = BASE64_STANDARD.encode(compressed); + let upload_json = format!( + r#"{{"network": [], "test_results_files": [{{"filename": "{}", "format": "base64+compressed", "data": "{}"}}]}}"#, + filename.split('/').last().unwrap(), + base64_data, + ); + upload_json.into() + } + + #[test] + fn test_parse_raw_upload_success() { + glob!("../tests", "*.xml", |path| { + let upload_json = file_into_bytes(path.to_str().unwrap()); + let result = parse_raw_upload(&upload_json); + match result { + Ok((results, _)) => assert_yaml_snapshot!(results), + Err(e) => { + assert_yaml_snapshot!(e.to_string()); + } + } + }); + } +} diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@ctest.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@ctest.xml.snap new file mode 100644 index 0000000..d3e99a9 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@ctest.xml.snap @@ -0,0 +1,16 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/ctest.xml +--- +- framework: ~ + testruns: + - name: a_unit_test + classname: a_unit_test + duration: 33.4734 + outcome: Failure + testsuite: Linux-c++ + failure_message: Failed + filename: ~ + build_url: ~ + computed_name: ~ diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@empty_failure.junit.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@empty_failure.junit.xml.snap new file mode 100644 index 0000000..d8e2465 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@empty_failure.junit.xml.snap @@ -0,0 +1,25 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/empty_failure.junit.xml +--- +- framework: ~ + testruns: + - name: test.test works + classname: test.test + duration: 0.234 + outcome: Pass + testsuite: test + failure_message: ~ + filename: "./test.rb" + build_url: ~ + computed_name: ~ + - name: test.test fails + classname: test.test + duration: 1 + outcome: Failure + testsuite: test + failure_message: TestError + filename: "./test.rb" + build_url: ~ + computed_name: ~ diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@error.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@error.xml.snap new file mode 100644 index 0000000..6f54c4d --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@error.xml.snap @@ -0,0 +1,6 @@ +--- +source: src/raw_upload.rs +expression: e.to_string() +input_file: tests/error.xml +--- +"Error parsing JUnit XML in error.xml at 8:24" diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@jest-junit.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@jest-junit.xml.snap new file mode 100644 index 0000000..a1bd571 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@jest-junit.xml.snap @@ -0,0 +1,43 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/jest-junit.xml +--- +- framework: Jest + testruns: + - name: Title when rendered renders pull title + classname: Title when rendered renders pull title + duration: 0.036 + outcome: Pass + testsuite: Title + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: Title when rendered renders pull title + - name: Title when rendered renders pull author + classname: Title when rendered renders pull author + duration: 0.005 + outcome: Pass + testsuite: Title + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: Title when rendered renders pull author + - name: Title when rendered renders pull updatestamp + classname: Title when rendered renders pull updatestamp + duration: 0.002 + outcome: Pass + testsuite: Title + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: Title when rendered renders pull updatestamp + - name: Title when rendered for first pull request renders pull title + classname: Title when rendered for first pull request renders pull title + duration: 0.006 + outcome: Pass + testsuite: Title + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: Title when rendered for first pull request renders pull title diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit-nested-testsuite.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit-nested-testsuite.xml.snap new file mode 100644 index 0000000..9190c95 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit-nested-testsuite.xml.snap @@ -0,0 +1,25 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/junit-nested-testsuite.xml +--- +- framework: Pytest + testruns: + - name: "test_junit[junit.xml--True]" + classname: tests.test_parsers.TestParsers + duration: 0.186 + outcome: Failure + testsuite: nested_testsuite + failure_message: aaaaaaa + filename: ~ + build_url: ~ + computed_name: ~ + - name: "test_junit[jest-junit.xml--False]" + classname: tests.test_parsers.TestParsers + duration: 0.186 + outcome: Pass + testsuite: pytest + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: "tests.test_parsers.TestParsers::test_junit[jest-junit.xml--False]" diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit-no-testcase-timestamp.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit-no-testcase-timestamp.xml.snap new file mode 100644 index 0000000..f9cc97b --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit-no-testcase-timestamp.xml.snap @@ -0,0 +1,25 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/junit-no-testcase-timestamp.xml +--- +- framework: Pytest + testruns: + - name: "test_junit[junit.xml--True]" + classname: tests.test_parsers.TestParsers + duration: 0.186 + outcome: Failure + testsuite: pytest + failure_message: aaaaaaa + filename: ~ + build_url: ~ + computed_name: "tests.test_parsers.TestParsers::test_junit[junit.xml--True]" + - name: "test_junit[jest-junit.xml--False]" + classname: tests.test_parsers.TestParsers + duration: 0.186 + outcome: Pass + testsuite: pytest + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: "tests.test_parsers.TestParsers::test_junit[jest-junit.xml--False]" diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit.xml.snap new file mode 100644 index 0000000..e8a7831 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@junit.xml.snap @@ -0,0 +1,25 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/junit.xml +--- +- framework: Pytest + testruns: + - name: "test_junit[junit.xml--True]" + classname: tests.test_parsers.TestParsers + duration: 0.001 + outcome: Failure + testsuite: pytest + failure_message: "self = , filename = 'junit.xml', expected = '', check = True\n\n @pytest.mark.parametrize(\n \"filename,expected,check\",\n [(\"junit.xml\", \"\", True), (\"jest-junit.xml\", \"\", False)],\n )\n def test_junit(self, filename, expected, check):\n with open(filename) as f:\n junit_string = f.read()\n res = parse_junit_xml(junit_string)\n print(res)\n if check:\n> assert res == expected\nE AssertionError: assert [{'duration': '0.010', 'name': 'tests.test_parsers.TestParsers.test_junit[junit.xml-]', 'outcome': 'failure'}, {'duration': '0.063', 'name': 'tests.test_parsers.TestParsers.test_junit[jest-junit.xml-]', 'outcome': 'pass'}] == ''\n\ntests/test_parsers.py:16: AssertionError" + filename: ~ + build_url: ~ + computed_name: "tests.test_parsers.TestParsers::test_junit[junit.xml--True]" + - name: "test_junit[jest-junit.xml--False]" + classname: tests.test_parsers.TestParsers + duration: 0.064 + outcome: Pass + testsuite: pytest + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: "tests.test_parsers.TestParsers::test_junit[jest-junit.xml--False]" diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@no-testsuite-name.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@no-testsuite-name.xml.snap new file mode 100644 index 0000000..a078fb0 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@no-testsuite-name.xml.snap @@ -0,0 +1,16 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/no-testsuite-name.xml +--- +- framework: ~ + testruns: + - name: a_unit_test + classname: a_unit_test + duration: 33.4734 + outcome: Failure + testsuite: "" + failure_message: Failed + filename: ~ + build_url: ~ + computed_name: ~ diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@no-time.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@no-time.xml.snap new file mode 100644 index 0000000..a334194 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@no-time.xml.snap @@ -0,0 +1,25 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/no-time.xml +--- +- framework: PHPUnit + testruns: + - name: test1 + classname: class.className + duration: ~ + outcome: Pass + testsuite: Thing + failure_message: ~ + filename: /file1.php + build_url: ~ + computed_name: "class.className::test1" + - name: test2 + classname: "" + duration: ~ + outcome: Pass + testsuite: Thing + failure_message: ~ + filename: /file1.php + build_url: ~ + computed_name: "::test2" diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@phpunit.junit.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@phpunit.junit.xml.snap new file mode 100644 index 0000000..827c4c9 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@phpunit.junit.xml.snap @@ -0,0 +1,25 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/phpunit.junit.xml +--- +- framework: PHPUnit + testruns: + - name: test1 + classname: class.className + duration: 0.1 + outcome: Pass + testsuite: Thing + failure_message: ~ + filename: /file1.php + build_url: ~ + computed_name: "class.className::test1" + - name: test2 + classname: "" + duration: 0.1 + outcome: Pass + testsuite: Thing + failure_message: ~ + filename: /file1.php + build_url: ~ + computed_name: "::test2" diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@skip-error.junit.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@skip-error.junit.xml.snap new file mode 100644 index 0000000..861cc27 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@skip-error.junit.xml.snap @@ -0,0 +1,34 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/skip-error.junit.xml +--- +- framework: Pytest + testruns: + - name: test_subtract + classname: tests.test_math.TestMath + duration: 0.1 + outcome: Error + testsuite: pytest + failure_message: hello world + filename: ~ + build_url: ~ + computed_name: "tests.test_math.TestMath::test_subtract" + - name: test_multiply + classname: tests.test_math.TestMath + duration: 0.1 + outcome: Error + testsuite: pytest + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: "tests.test_math.TestMath::test_multiply" + - name: test_add + classname: tests.test_math.TestMath + duration: 0.1 + outcome: Skip + testsuite: pytest + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: "tests.test_math.TestMath::test_add" diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@testsuites.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@testsuites.xml.snap new file mode 100644 index 0000000..e9c42ab --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@testsuites.xml.snap @@ -0,0 +1,7 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/testsuites.xml +--- +- framework: ~ + testruns: [] diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@vitest-junit.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@vitest-junit.xml.snap new file mode 100644 index 0000000..cdc935c --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@vitest-junit.xml.snap @@ -0,0 +1,25 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/vitest-junit.xml +--- +- framework: Vitest + testruns: + - name: first test file > 2 + 2 should equal 4 + classname: __tests__/test-file-1.test.ts + duration: 0.01 + outcome: Failure + testsuite: __tests__/test-file-1.test.ts + failure_message: "AssertionError: expected 5 to be 4 // Object.is equality\n ❯ __tests__/test-file-1.test.ts:20:28" + filename: ~ + build_url: ~ + computed_name: __tests__/test-file-1.test.ts > first test file > 2 + 2 should equal 4 + - name: first test file > 4 - 2 should equal 2 + classname: __tests__/test-file-1.test.ts + duration: 0 + outcome: Pass + testsuite: __tests__/test-file-1.test.ts + failure_message: ~ + filename: ~ + build_url: ~ + computed_name: __tests__/test-file-1.test.ts > first test file > 4 - 2 should equal 2 diff --git a/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@windows.junit.xml.snap b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@windows.junit.xml.snap new file mode 100644 index 0000000..7a8c510 --- /dev/null +++ b/src/snapshots/test_results_parser__raw_upload__tests__parse_raw_upload_success@windows.junit.xml.snap @@ -0,0 +1,7 @@ +--- +source: src/raw_upload.rs +expression: results +input_file: tests/windows.junit.xml +--- +- framework: ~ + testruns: [] diff --git a/src/testrun.rs b/src/testrun.rs index a9a8f36..2f93eec 100644 --- a/src/testrun.rs +++ b/src/testrun.rs @@ -1,60 +1,17 @@ -use std::fmt::Display; - -use pyo3::class::basic::CompareOp; use pyo3::prelude::*; +use pyo3::types::PyString; +use pyo3::{PyAny, PyResult}; +use serde::Serialize; -#[derive(Clone, Copy, Debug, PartialEq)] -#[pyclass(eq, eq_int)] -pub enum Outcome { - Pass, - Error, - Failure, - Skip, -} - -#[pymethods] -impl Outcome { - #[new] - fn new(value: &str) -> Self { - match value { - "pass" => Outcome::Pass, - "failure" => Outcome::Failure, - "error" => Outcome::Error, - "skip" => Outcome::Skip, - _ => Outcome::Failure, - } - } - - fn __str__(&self) -> &str { - match &self { - Outcome::Pass => "pass", - Outcome::Failure => "failure", - Outcome::Error => "error", - Outcome::Skip => "skip", - } - } -} - -impl Display for Outcome { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self { - Outcome::Pass => write!(f, "Pass"), - Outcome::Failure => write!(f, "Failure"), - Outcome::Error => write!(f, "Error"), - Outcome::Skip => write!(f, "Skip"), - } - } -} - -static FRAMEWORKS: &[(&str, Framework)] = &[ +static FRAMEWORKS: [(&str, Framework); 4] = [ ("pytest", Framework::Pytest), ("vitest", Framework::Vitest), ("jest", Framework::Jest), ("phpunit", Framework::PHPUnit), ]; -static EXTENSIONS: &[(&str, Framework)] = - &[(".py", Framework::Pytest), (".php", Framework::PHPUnit)]; +static EXTENSIONS: [(&str, Framework); 2] = + [(".py", Framework::Pytest), (".php", Framework::PHPUnit)]; fn check_substring_before_word_boundary(string: &str, substring: &str) -> bool { if let Some((_, suffix)) = string.to_lowercase().split_once(substring) { @@ -75,26 +32,104 @@ pub fn check_testsuites_name(testsuites_name: &str) -> Option { .next() } -#[derive(Clone, Debug, PartialEq)] -#[pyclass] +#[derive(Clone, Copy, Debug, Serialize, PartialEq)] +pub enum Outcome { + Pass, + Failure, + Skip, + Error, +} + +impl<'py> IntoPyObject<'py> for Outcome { + type Target = PyString; + type Output = Bound<'py, Self::Target>; + type Error = std::convert::Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + match self { + Outcome::Pass => Ok("pass".into_pyobject(py)?), + Outcome::Failure => Ok("failure".into_pyobject(py)?), + Outcome::Skip => Ok("skip".into_pyobject(py)?), + Outcome::Error => Ok("error".into_pyobject(py)?), + } + } +} + +impl<'py> FromPyObject<'py> for Outcome { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + let s = ob.extract::<&str>()?; + match s { + "pass" => Ok(Outcome::Pass), + "failure" => Ok(Outcome::Failure), + "skip" => Ok(Outcome::Skip), + "error" => Ok(Outcome::Error), + _ => Err(PyErr::new::(format!( + "Invalid outcome: {}", + s + ))), + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq)] +pub enum Framework { + Pytest, + Vitest, + Jest, + PHPUnit, +} + +impl<'py> IntoPyObject<'py> for Framework { + type Target = PyString; + type Output = Bound<'py, Self::Target>; + type Error = std::convert::Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + match self { + Framework::Pytest => Ok("Pytest".into_pyobject(py)?), + Framework::Vitest => Ok("Vitest".into_pyobject(py)?), + Framework::Jest => Ok("Jest".into_pyobject(py)?), + Framework::PHPUnit => Ok("PHPUnit".into_pyobject(py)?), + } + } +} + +impl<'py> FromPyObject<'py> for Framework { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + let s = ob.extract::<&str>()?; + match s { + "Pytest" => Ok(Framework::Pytest), + "Vitest" => Ok(Framework::Vitest), + "Jest" => Ok(Framework::Jest), + "PHPUnit" => Ok(Framework::PHPUnit), + _ => Err(PyErr::new::(format!( + "Invalid outcome: {}", + s + ))), + } + } +} + +// i can't seem to get pyo3(from_item_all) to work when IntoPyObject is also being derived +#[derive(IntoPyObject, FromPyObject, Clone, Debug, Serialize, PartialEq)] pub struct Testrun { - #[pyo3(get, set)] + #[pyo3(item)] pub name: String, - #[pyo3(get, set)] + #[pyo3(item)] pub classname: String, - #[pyo3(get, set)] + #[pyo3(item)] pub duration: Option, - #[pyo3(get, set)] + #[pyo3(item)] pub outcome: Outcome, - #[pyo3(get, set)] + #[pyo3(item)] pub testsuite: String, - #[pyo3(get, set)] + #[pyo3(item)] pub failure_message: Option, - #[pyo3(get, set)] + #[pyo3(item)] pub filename: Option, - #[pyo3(get, set)] + #[pyo3(item)] pub build_url: Option, - #[pyo3(get, set)] + #[pyo3(item)] pub computed_name: Option, } @@ -102,7 +137,7 @@ impl Testrun { pub fn framework(&self) -> Option { for (name, framework) in FRAMEWORKS { if check_substring_before_word_boundary(&self.testsuite, name) { - return Some(framework.to_owned()); + return Some(framework); } } @@ -110,18 +145,18 @@ impl Testrun { if check_substring_before_word_boundary(&self.classname, extension) || check_substring_before_word_boundary(&self.name, extension) { - return Some(framework.to_owned()); + return Some(framework); } if let Some(message) = &self.failure_message { if check_substring_before_word_boundary(message, extension) { - return Some(framework.to_owned()); + return Some(framework); } } if let Some(filename) = &self.filename { if check_substring_before_word_boundary(filename, extension) { - return Some(framework.to_owned()); + return Some(framework); } } } @@ -129,130 +164,12 @@ impl Testrun { } } -#[pymethods] -impl Testrun { - #[allow(clippy::too_many_arguments)] - #[new] - #[pyo3(signature = (name, classname, duration, outcome, testsuite, failure_message=None, filename=None, build_url=None, computed_name=None))] - fn new( - name: String, - classname: String, - duration: Option, - outcome: Outcome, - testsuite: String, - failure_message: Option, - filename: Option, - build_url: Option, - computed_name: Option, - ) -> Self { - Self { - name, - classname, - duration, - outcome, - testsuite, - failure_message, - filename, - build_url, - computed_name, - } - } - - fn __repr__(&self) -> String { - format!( - "({}, {}, {}, {:?}, {}, {:?}, {:?}, {:?})", - self.name, - self.classname, - self.outcome, - self.duration, - self.testsuite, - self.failure_message, - self.filename, - self.computed_name, - ) - } - - fn __richcmp__(&self, other: &Self, op: CompareOp) -> PyResult { - match op { - CompareOp::Eq => Ok(self.name == other.name - && self.classname == other.classname - && self.outcome == other.outcome - && self.duration == other.duration - && self.testsuite == other.testsuite - && self.failure_message == other.failure_message - && self.filename == other.filename - && self.computed_name == other.computed_name), - _ => todo!(), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -#[pyclass(eq, eq_int)] -pub enum Framework { - Pytest, - Vitest, - Jest, - PHPUnit, -} - -#[pymethods] -impl Framework { - fn __str__(&self) -> &str { - match &self { - Framework::Pytest => "Pytest", - Framework::Vitest => "Vitest", - Framework::Jest => "Jest", - Framework::PHPUnit => "PHPUnit", - } - } -} - -impl Display for Framework { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self { - Framework::Pytest => write!(f, "Pytest"), - Framework::Vitest => write!(f, "Vitest"), - Framework::Jest => write!(f, "Jest"), - Framework::PHPUnit => write!(f, "PHPUnit"), - } - } -} - -#[derive(Clone, Debug)] -#[pyclass] +#[derive(Clone, Debug, Serialize, IntoPyObject)] pub struct ParsingInfo { - #[pyo3(get, set)] pub framework: Option, - #[pyo3(get, set)] pub testruns: Vec, } -#[pymethods] -impl ParsingInfo { - #[new] - #[pyo3(signature = (framework, testruns))] - fn new(framework: Option, testruns: Vec) -> Self { - Self { - framework, - testruns, - } - } - - fn __repr__(&self) -> String { - format!("({:?}, {:?})", self.framework, self.testruns) - } - - fn __richcmp__(&self, other: &Self, op: CompareOp) -> PyResult { - match op { - CompareOp::Eq => { - Ok(self.framework == other.framework && self.testruns == other.testruns) - } - _ => todo!(), - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -282,7 +199,7 @@ mod tests { build_url: None, computed_name: None, }; - assert_eq!(t.framework(), Some(Framework::Pytest)); + assert_eq!(t.framework(), Some(Framework::Pytest)) } #[test] @@ -298,7 +215,7 @@ mod tests { build_url: None, computed_name: None, }; - assert_eq!(t.framework(), Some(Framework::Pytest)); + assert_eq!(t.framework(), Some(Framework::Pytest)) } #[test] @@ -314,7 +231,7 @@ mod tests { build_url: None, computed_name: None, }; - assert_eq!(t.framework(), Some(Framework::Pytest)); + assert_eq!(t.framework(), Some(Framework::Pytest)) } #[test] @@ -330,7 +247,7 @@ mod tests { build_url: None, computed_name: None, }; - assert_eq!(t.framework(), Some(Framework::Pytest)); + assert_eq!(t.framework(), Some(Framework::Pytest)) } #[test] @@ -346,7 +263,7 @@ mod tests { build_url: None, computed_name: None, }; - assert_eq!(t.framework(), Some(Framework::Pytest)); + assert_eq!(t.framework(), Some(Framework::Pytest)) } #[test] @@ -362,6 +279,6 @@ mod tests { build_url: Some("https://example.com/build_url".to_string()), computed_name: None, }; - assert_eq!(t.framework(), Some(Framework::Pytest)); + assert_eq!(t.framework(), Some(Framework::Pytest)) } } diff --git a/tests/error.xml b/tests/error.xml new file mode 100644 index 0000000..0e1c3b6 --- /dev/null +++ b/tests/error.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/tests/snapshots/parse_raw_upload__TestParsers__junit__0.bin b/tests/snapshots/parse_raw_upload__TestParsers__junit__0.bin new file mode 100644 index 0000000..43bcfca --- /dev/null +++ b/tests/snapshots/parse_raw_upload__TestParsers__junit__0.bin @@ -0,0 +1,18 @@ +# path=junit.xml +self = <test_parsers.TestParsers object at 0x102182d10>, filename = 'junit.xml', expected = '', check = True + + @pytest.mark.parametrize( + "filename,expected,check", + [("junit.xml", "", True), ("jest-junit.xml", "", False)], + ) + def test_junit(self, filename, expected, check): + with open(filename) as f: + junit_string = f.read() + res = parse_junit_xml(junit_string) + print(res) + if check: +> assert res == expected +E AssertionError: assert [{'duration': '0.010', 'name': 'tests.test_parsers.TestParsers.test_junit[junit.xml-]', 'outcome': 'failure'}, {'duration': '0.063', 'name': 'tests.test_parsers.TestParsers.test_junit[jest-junit.xml-]', 'outcome': 'pass'}] == '' + +tests/test_parsers.py:16: AssertionError +<<<<<< EOF diff --git a/tests/snapshots/parse_raw_upload__TestParsers__junit__1.json b/tests/snapshots/parse_raw_upload__TestParsers__junit__1.json new file mode 100644 index 0000000..ce99cfe --- /dev/null +++ b/tests/snapshots/parse_raw_upload__TestParsers__junit__1.json @@ -0,0 +1,29 @@ +[ + { + "framework": "Pytest", + "testruns": [ + { + "name": "test_junit[junit.xml--True]", + "classname": "tests.test_parsers.TestParsers", + "duration": 0.001, + "outcome": "failure", + "testsuite": "pytest", + "failure_message": "self = , filename = 'junit.xml', expected = '', check = True\n\n @pytest.mark.parametrize(\n \"filename,expected,check\",\n [(\"junit.xml\", \"\", True), (\"jest-junit.xml\", \"\", False)],\n )\n def test_junit(self, filename, expected, check):\n with open(filename) as f:\n junit_string = f.read()\n res = parse_junit_xml(junit_string)\n print(res)\n if check:\n> assert res == expected\nE AssertionError: assert [{'duration': '0.010', 'name': 'tests.test_parsers.TestParsers.test_junit[junit.xml-]', 'outcome': 'failure'}, {'duration': '0.063', 'name': 'tests.test_parsers.TestParsers.test_junit[jest-junit.xml-]', 'outcome': 'pass'}] == ''\n\ntests/test_parsers.py:16: AssertionError", + "filename": null, + "build_url": null, + "computed_name": "tests.test_parsers.TestParsers::test_junit[junit.xml--True]" + }, + { + "name": "test_junit[jest-junit.xml--False]", + "classname": "tests.test_parsers.TestParsers", + "duration": 0.064, + "outcome": "pass", + "testsuite": "pytest", + "failure_message": null, + "filename": null, + "build_url": null, + "computed_name": "tests.test_parsers.TestParsers::test_junit[jest-junit.xml--False]" + } + ] + } +] diff --git a/tests/test_aggregation.py b/tests/test_aggregation.py index 9c0e47b..f8d4627 100644 --- a/tests/test_aggregation.py +++ b/tests/test_aggregation.py @@ -1,7 +1,10 @@ from datetime import datetime, timezone +import json +import base64 +import zlib from test_results_parser import ( - parse_junit_xml, + parse_raw_upload, AggregationReader, BinaryFormatWriter, ) @@ -9,7 +12,17 @@ def test_aggregation(): with open("./tests/junit.xml", "br") as f: junit_file = f.read() - parsed = parse_junit_xml(junit_file) + + raw_upload = { + "test_results_files": [ + { + "filename": "test_results.json", + "data": base64.b64encode(zlib.compress(junit_file)).decode("utf-8"), + } + ] + } + + parsed, _ = parse_raw_upload(json.dumps(raw_upload).encode("utf-8")) now = int(datetime.now(timezone.utc).timestamp()) @@ -18,7 +31,7 @@ def test_aggregation(): timestamp=now, commit_hash="e9fcd08652d091fa0c8d28e323c24fb0f4acf249", flags=["upload", "flags"], - testruns=parsed.testruns, + testruns=parsed[0]["testruns"], ) serialized = writer.serialize() diff --git a/tests/test_junit.py b/tests/test_junit.py deleted file mode 100644 index 09cc2d0..0000000 --- a/tests/test_junit.py +++ /dev/null @@ -1,348 +0,0 @@ -import pytest -from test_results_parser import ( - Framework, - Outcome, - ParsingInfo, - Testrun, - parse_junit_xml, -) - - -class TestParsers: - @pytest.mark.parametrize( - "filename,expected", - [ - ( - "./tests/junit.xml", - ParsingInfo( - Framework.Pytest, - [ - Testrun( - name="test_junit[junit.xml--True]", - classname="tests.test_parsers.TestParsers", - duration=0.001, - outcome=Outcome.Failure, - testsuite="pytest", - failure_message="""self = , filename = 'junit.xml', expected = '', check = True - - @pytest.mark.parametrize( - "filename,expected,check", - [("junit.xml", "", True), ("jest-junit.xml", "", False)], - ) - def test_junit(self, filename, expected, check): - with open(filename) as f: - junit_string = f.read() - res = parse_junit_xml(junit_string) - print(res) - if check: -> assert res == expected -E AssertionError: assert [{'duration': '0.010', 'name': 'tests.test_parsers.TestParsers.test_junit[junit.xml-]', 'outcome': 'failure'}, {'duration': '0.063', 'name': 'tests.test_parsers.TestParsers.test_junit[jest-junit.xml-]', 'outcome': 'pass'}] == '' - -tests/test_parsers.py:16: AssertionError""", - filename=None, - computed_name="tests.test_parsers.TestParsers::test_junit[junit.xml--True]", - ), - Testrun( - name="test_junit[jest-junit.xml--False]", - classname="tests.test_parsers.TestParsers", - duration=0.064, - outcome=Outcome.Pass, - testsuite="pytest", - failure_message=None, - filename=None, - computed_name="tests.test_parsers.TestParsers::test_junit[jest-junit.xml--False]", - ), - ], - ), - ), - ( - "./tests/junit-no-testcase-timestamp.xml", - ParsingInfo( - Framework.Pytest, - [ - Testrun( - name="test_junit[junit.xml--True]", - classname="tests.test_parsers.TestParsers", - duration=0.186, - outcome=Outcome.Failure, - testsuite="pytest", - failure_message="""aaaaaaa""", - filename=None, - computed_name="tests.test_parsers.TestParsers::test_junit[junit.xml--True]", - ), - Testrun( - name="test_junit[jest-junit.xml--False]", - classname="tests.test_parsers.TestParsers", - duration=0.186, - outcome=Outcome.Pass, - testsuite="pytest", - failure_message=None, - filename=None, - computed_name="tests.test_parsers.TestParsers::test_junit[jest-junit.xml--False]", - ), - ], - ), - ), - ( - "./tests/junit-nested-testsuite.xml", - ParsingInfo( - Framework.Pytest, - [ - Testrun( - name="test_junit[junit.xml--True]", - classname="tests.test_parsers.TestParsers", - duration=0.186, - outcome=Outcome.Failure, - testsuite="nested_testsuite", - failure_message="""aaaaaaa""", - filename=None, - computed_name=None, - ), - Testrun( - name="test_junit[jest-junit.xml--False]", - classname="tests.test_parsers.TestParsers", - duration=0.186, - outcome=Outcome.Pass, - testsuite="pytest", - failure_message=None, - filename=None, - computed_name="tests.test_parsers.TestParsers::test_junit[jest-junit.xml--False]", - ), - ], - ), - ), - ( - "./tests/jest-junit.xml", - ParsingInfo( - Framework.Jest, - [ - Testrun( - name="Title when rendered renders pull title", - classname="Title when rendered renders pull title", - duration=0.036, - outcome=Outcome.Pass, - testsuite="Title", - failure_message=None, - filename=None, - computed_name="Title when rendered renders pull title", - ), - Testrun( - name="Title when rendered renders pull author", - classname="Title when rendered renders pull author", - duration=0.005, - outcome=Outcome.Pass, - testsuite="Title", - failure_message=None, - filename=None, - computed_name="Title when rendered renders pull author", - ), - Testrun( - name="Title when rendered renders pull updatestamp", - classname="Title when rendered renders pull updatestamp", - duration=0.002, - outcome=Outcome.Pass, - testsuite="Title", - failure_message=None, - filename=None, - computed_name="Title when rendered renders pull updatestamp", - ), - Testrun( - name="Title when rendered for first pull request renders pull title", - classname="Title when rendered for first pull request renders pull title", - duration=0.006, - outcome=Outcome.Pass, - testsuite="Title", - failure_message=None, - filename=None, - computed_name="Title when rendered for first pull request renders pull title", - ), - ], - ), - ), - ( - "./tests/vitest-junit.xml", - ParsingInfo( - Framework.Vitest, - [ - Testrun( - name="first test file > 2 + 2 should equal 4", - classname="__tests__/test-file-1.test.ts", - duration=0.01, - outcome=Outcome.Failure, - testsuite="__tests__/test-file-1.test.ts", - failure_message="""AssertionError: expected 5 to be 4 // Object.is equality - ❯ __tests__/test-file-1.test.ts:20:28""", - filename=None, - computed_name="__tests__/test-file-1.test.ts > first test file > 2 + 2 should equal 4", - ), - Testrun( - name="first test file > 4 - 2 should equal 2", - classname="__tests__/test-file-1.test.ts", - duration=0, - outcome=Outcome.Pass, - testsuite="__tests__/test-file-1.test.ts", - failure_message=None, - filename=None, - computed_name="__tests__/test-file-1.test.ts > first test file > 4 - 2 should equal 2", - ), - ], - ), - ), - ( - "./tests/empty_failure.junit.xml", - ParsingInfo( - None, - [ - Testrun( - name="test.test works", - classname="test.test", - duration=0.234, - outcome=Outcome.Pass, - testsuite="test", - failure_message=None, - filename="./test.rb", - ), - Testrun( - name="test.test fails", - classname="test.test", - duration=1, - outcome=Outcome.Failure, - testsuite="test", - failure_message="TestError", - filename="./test.rb", - ), - ], - ), - ), - ( - "./tests/phpunit.junit.xml", - ParsingInfo( - Framework.PHPUnit, - [ - Testrun( - name="test1", - classname="class.className", - duration=0.1, - outcome=Outcome.Pass, - testsuite="Thing", - failure_message=None, - filename="/file1.php", - computed_name="class.className::test1", - ), - Testrun( - name="test2", - classname="", - duration=0.1, - outcome=Outcome.Pass, - testsuite="Thing", - failure_message=None, - filename="/file1.php", - computed_name="::test2", - ), - ], - ), - ), - ( - "./tests/ctest.xml", - ParsingInfo( - None, - [ - Testrun( - name="a_unit_test", - classname="a_unit_test", - duration=33.4734, - outcome=Outcome.Failure, - testsuite="Linux-c++", - failure_message="Failed", - filename=None, - ) - ], - ), - ), - ( - "./tests/no-testsuite-name.xml", - ParsingInfo( - None, - [ - Testrun( - name="a_unit_test", - classname="a_unit_test", - duration=33.4734, - outcome=Outcome.Failure, - testsuite="", - failure_message="Failed", - filename=None, - ) - ], - ), - ), - ( - "./tests/testsuites.xml", - ParsingInfo( - None, - [], - ), - ), - ( - "./tests/skip-error.junit.xml", - ParsingInfo( - Framework.Pytest, - [ - Testrun( - name="test_subtract", - classname="tests.test_math.TestMath", - duration=0.1, - outcome=Outcome.Error, - testsuite="pytest", - failure_message="hello world", - filename=None, - computed_name="tests.test_math.TestMath::test_subtract", - ), - Testrun( - name="test_multiply", - classname="tests.test_math.TestMath", - duration=0.1, - outcome=Outcome.Error, - testsuite="pytest", - failure_message=None, - filename=None, - computed_name="tests.test_math.TestMath::test_multiply", - ), - Testrun( - name="test_add", - classname="tests.test_math.TestMath", - duration=0.1, - outcome=Outcome.Skip, - testsuite="pytest", - failure_message=None, - filename=None, - computed_name="tests.test_math.TestMath::test_add", - ) - ], - ), - ), - ], - ) - def test_junit(self, filename, expected): - with open(filename, "b+r") as f: - res = parse_junit_xml(f.read()) - assert res.framework == expected.framework - assert len(res.testruns) == len(expected.testruns) - for restest, extest in zip(res.testruns, expected.testruns): - print( - restest.classname, - restest.duration, - restest.filename, - restest.name, - restest.outcome, - restest.testsuite, - ) - print( - extest.classname, - extest.duration, - extest.filename, - extest.name, - extest.outcome, - extest.testsuite, - ) - assert restest == extest diff --git a/tests/test_parse_raw_upload.py b/tests/test_parse_raw_upload.py new file mode 100644 index 0000000..91483e0 --- /dev/null +++ b/tests/test_parse_raw_upload.py @@ -0,0 +1,96 @@ +import pytest +import base64 +import zlib +import json +from test_results_parser import parse_raw_upload +class TestParsers: + def test_junit(self, snapshot): + with open("tests/junit.xml", "b+r") as f: + file_bytes = f.read() + raw_upload = { + "network": [ + "a/b/c.py", + ], + "test_results_files": [ + { + "filename": "junit.xml", + "format": "base64+compressed", + "data": base64.b64encode(zlib.compress(file_bytes)).decode( + "utf-8" + ), + } + ] + } + json_bytes = json.dumps(raw_upload).encode("utf-8") + parsing_infos, readable_files_bytes = parse_raw_upload(json_bytes) + + + readable_files = bytes(readable_files_bytes) + + + assert snapshot("bin") == readable_files + assert snapshot("json") == parsing_infos + + + + def test_json_error(self): + with pytest.raises(RuntimeError): + parse_raw_upload(b"whatever") + + def test_base64_error(self): + raw_upload = { + "network": [ + "a/b/c.py", + ], + "test_results_files": [ + { + "filename": "junit.xml", + "format": "base64+compressed", + "data": "whatever", + } + ] + } + json_bytes = json.dumps(raw_upload).encode("utf-8") + with pytest.raises(RuntimeError): + parse_raw_upload(json_bytes) + + def test_decompression_error(self): + raw_upload = { + "network": [ + "a/b/c.py", + ], + "test_results_files": [ + { + "filename": "junit.xml", + "format": "base64+compressed", + "data": base64.b64encode(b"whatever").decode("utf-8"), + } + ] + } + json_bytes = json.dumps(raw_upload).encode("utf-8") + with pytest.raises(RuntimeError): + parse_raw_upload(json_bytes) + + def test_parser_error(self): + with open("tests/error.xml", "b+r") as f: + file_bytes = f.read() + raw_upload = { + "network": [ + "a/b/c.py", + ], + "test_results_files": [ + { + "filename": "jest-junit.xml", + "format": "base64+compressed", + "data": base64.b64encode(zlib.compress(file_bytes)).decode( + "utf-8" + ), + } + ] + } + json_bytes = json.dumps(raw_upload).encode("utf-8") + with pytest.raises(RuntimeError): + parse_raw_upload(json_bytes) + + + diff --git a/uv.lock b/uv.lock index 4d070e2..19e3987 100644 --- a/uv.lock +++ b/uv.lock @@ -123,6 +123,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, ] +[[package]] +name = "pytest-insta" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/5b/6ca4baca60c3f8361415501668cde3abd94dbad44293325833fd89d1a7c1/pytest_insta-0.3.0.tar.gz", hash = "sha256:9e6e1c70a021f68ccc4643360b2c2f8326cf3befba85f942c1da17b9caf713f7", size = 14960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/d5/1459b2861cf703cf49d96b6f29731ee74f4ac7e34b0c60b0ff75bdd318bc/pytest_insta-0.3.0-py3-none-any.whl", hash = "sha256:93a105e3850f2887b120a581923b10bb313d722e00d369377a1d91aa535df704", size = 13660 }, +] + [[package]] name = "pytest-reportlog" version = "0.4.0" @@ -145,6 +158,7 @@ dev = [ { name = "maturin" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-insta" }, { name = "pytest-reportlog" }, ] @@ -155,5 +169,42 @@ dev = [ { name = "maturin", specifier = ">=1.7.4" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-insta", specifier = ">=0.3.0" }, { name = "pytest-reportlog", specifier = ">=0.4.0" }, ] + +[[package]] +name = "wrapt" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/a1/fc03dca9b0432725c2e8cdbf91a349d2194cf03d8523c124faebe581de09/wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801", size = 55542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/82/518605474beafff11f1a34759f6410ab429abff9f7881858a447e0d20712/wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569", size = 38904 }, + { url = "https://files.pythonhosted.org/packages/80/6c/17c3b2fed28edfd96d8417c865ef0b4c955dc52c4e375d86f459f14340f1/wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504", size = 88622 }, + { url = "https://files.pythonhosted.org/packages/4a/11/60ecdf3b0fd3dca18978d89acb5d095a05f23299216e925fcd2717c81d93/wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451", size = 80920 }, + { url = "https://files.pythonhosted.org/packages/d2/50/dbef1a651578a3520d4534c1e434989e3620380c1ad97e309576b47f0ada/wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1", size = 89170 }, + { url = "https://files.pythonhosted.org/packages/44/a2/78c5956bf39955288c9e0dd62e807b308c3aa15a0f611fbff52aa8d6b5ea/wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106", size = 86748 }, + { url = "https://files.pythonhosted.org/packages/99/49/2ee413c78fc0bdfebe5bee590bf3becdc1fab0096a7a9c3b5c9666b2415f/wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada", size = 79734 }, + { url = "https://files.pythonhosted.org/packages/c0/8c/4221b7b270e36be90f0930fe15a4755a6ea24093f90b510166e9ed7861ea/wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4", size = 87552 }, + { url = "https://files.pythonhosted.org/packages/4c/6b/1aaccf3efe58eb95e10ce8e77c8909b7a6b0da93449a92c4e6d6d10b3a3d/wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635", size = 36647 }, + { url = "https://files.pythonhosted.org/packages/b3/4f/243f88ac49df005b9129194c6511b3642818b3e6271ddea47a15e2ee4934/wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7", size = 38830 }, + { url = "https://files.pythonhosted.org/packages/67/9c/38294e1bb92b055222d1b8b6591604ca4468b77b1250f59c15256437644f/wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181", size = 38904 }, + { url = "https://files.pythonhosted.org/packages/78/b6/76597fb362cbf8913a481d41b14b049a8813cd402a5d2f84e57957c813ae/wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393", size = 88608 }, + { url = "https://files.pythonhosted.org/packages/bc/69/b500884e45b3881926b5f69188dc542fb5880019d15c8a0df1ab1dfda1f7/wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4", size = 80879 }, + { url = "https://files.pythonhosted.org/packages/52/31/f4cc58afe29eab8a50ac5969963010c8b60987e719c478a5024bce39bc42/wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b", size = 89119 }, + { url = "https://files.pythonhosted.org/packages/aa/9c/05ab6bf75dbae7a9d34975fb6ee577e086c1c26cde3b6cf6051726d33c7c/wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721", size = 86778 }, + { url = "https://files.pythonhosted.org/packages/0e/6c/4b8d42e3db355603d35fe5c9db79c28f2472a6fd1ccf4dc25ae46739672a/wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90", size = 79793 }, + { url = "https://files.pythonhosted.org/packages/69/23/90e3a2ee210c0843b2c2a49b3b97ffcf9cad1387cb18cbeef9218631ed5a/wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a", size = 87606 }, + { url = "https://files.pythonhosted.org/packages/5f/06/3683126491ca787d8d71d8d340e775d40767c5efedb35039d987203393b7/wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045", size = 36651 }, + { url = "https://files.pythonhosted.org/packages/f1/bc/3bf6d2ca0d2c030d324ef9272bea0a8fdaff68f3d1fa7be7a61da88e51f7/wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838", size = 38835 }, + { url = "https://files.pythonhosted.org/packages/ce/b5/251165c232d87197a81cd362eeb5104d661a2dd3aa1f0b33e4bf61dda8b8/wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b", size = 40146 }, + { url = "https://files.pythonhosted.org/packages/89/33/1e1bdd3e866eeb73d8c4755db1ceb8a80d5bd51ee4648b3f2247adec4e67/wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379", size = 113444 }, + { url = "https://files.pythonhosted.org/packages/9f/7c/94f53b065a43f5dc1fbdd8b80fd8f41284315b543805c956619c0b8d92f0/wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d", size = 101246 }, + { url = "https://files.pythonhosted.org/packages/62/5d/640360baac6ea6018ed5e34e6e80e33cfbae2aefde24f117587cd5efd4b7/wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f", size = 109320 }, + { url = "https://files.pythonhosted.org/packages/e3/cf/6c7a00ae86a2e9482c91170aefe93f4ccda06c1ac86c4de637c69133da59/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c", size = 110193 }, + { url = "https://files.pythonhosted.org/packages/cd/cc/aa718df0d20287e8f953ce0e2f70c0af0fba1d3c367db7ee8bdc46ea7003/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b", size = 100460 }, + { url = "https://files.pythonhosted.org/packages/f7/16/9f3ac99fe1f6caaa789d67b4e3c562898b532c250769f5255fa8b8b93983/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab", size = 106347 }, + { url = "https://files.pythonhosted.org/packages/64/85/c77a331b2c06af49a687f8b926fc2d111047a51e6f0b0a4baa01ff3a673a/wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf", size = 37971 }, + { url = "https://files.pythonhosted.org/packages/05/9b/b2469f8be9efed24283fd7b9eeb8e913e9bc0715cf919ea8645e428ab7af/wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a", size = 40755 }, + { url = "https://files.pythonhosted.org/packages/4b/d9/a8ba5e9507a9af1917285d118388c5eb7a81834873f45df213a6fe923774/wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371", size = 23592 }, +]