diff --git a/docs/dev-build.md b/docs/dev-build.md index d34379a..33ab1bf 100644 --- a/docs/dev-build.md +++ b/docs/dev-build.md @@ -349,8 +349,72 @@ Python also often provides single functions which can receive multiple significa --8<-- "rust/fizzbuzzo3/src/lib.rs" ``` -!!! warning "`Union` type returns" - If you want to create something like: `#!python def fizzbuzz(n: int | list[int]) -> str | list[str]:` [Issue pyo3/#1637](https://github.com/PyO3/pyo3/issues/1637) suggests you may be able to do something with the `IntoPy` trait but I haven't tried (yet) +!!! pyo3 "`Union` type returns: `#!python def fizzbuzz(n: int | list[int]) -> str | list[str]`" + If you would like to provide different return types for different cases: + + 1. Implement an `enum`, or a wrapper `struct` around an existing `enum`, that holds the different types. + 1. Provide one or more conversion `From` traits to convert from the return of your core rust functions. + 1. Provide a conversion [`IntoPy` trait](https://docs.rs/pyo3/latest/pyo3/conversion/trait.IntoPy.html) to convert to the relevant PyO3 types. + 1. Use this new type as the return of your wrapped function. + + In **`/rust/fizzbuzzo3/src/lib.rs`**: + ```rust + ... + struct FizzBuzzReturn(FizzBuzzAnswer); + + impl From for FizzBuzzReturn { + fn from(value: FizzBuzzAnswer) -> Self { + FizzBuzzReturn(value) + } + } + + impl IntoPy> for FizzBuzzReturn { + fn into_py(self, py: Python<'_>) -> Py { + match self.0 { + FizzBuzzAnswer::One(string) => string.into_py(py), + FizzBuzzAnswer::Many(list) => list.into_py(py), + } + } + } + ... + #[pyfunction] + #[pyo3(name = "fizzbuzz", text_signature = "(n)")] + fn py_fizzbuzz(num: FizzBuzzable) -> PyResult { + ... + ``` + + Thanks to the comments in [Issue pyo3/#1637](https://github.com/PyO3/pyo3/issues/1637) for pointers on how to get this working. + + 1. Add `@overload` hints for your IDE (see [IDE type & doc hinting](#ide-type-doc-hinting)), so that it understands the relationships between input and output types: + + In **`/python/fizzbuzz/fizzbuzzo3.pyi`**: + ```python + ... + from typing import overload + + @overload + def fizzbuzz(n: int) -> str: + ... + + @overload + def fizzbuzz(n: list[int] | slice) -> list[str]: + ... + + def fizzbuzz(n): + """ + Returns the correct fizzbuzz answer for any number or list/range of numbers. + ... + ``` + + ??? pyo3 "**`rust/fizzbuzzo3/src/lib.rs`** - full source" + ```rust + --8<-- "rust/fizzbuzzo3/src/lib.rs" + ``` + + ??? python "**`python/fizzbuzz/fizzbuzzo3.pyi`** - full source" + ```python + --8<-- "python/fizzbuzz/fizzbuzzo3.pyi" + ``` ## IDE type & doc hinting diff --git a/python/fizzbuzz/fizzbuzzo3.pyi b/python/fizzbuzz/fizzbuzzo3.pyi index 25627d9..0733095 100644 --- a/python/fizzbuzz/fizzbuzzo3.pyi +++ b/python/fizzbuzz/fizzbuzzo3.pyi @@ -10,7 +10,17 @@ Usage: ``` """ -def fizzbuzz(n: int | list[int] | slice) -> str: +from typing import overload + +@overload +def fizzbuzz(n: int) -> str: + ... + +@overload +def fizzbuzz(n: list[int] | slice) -> list[str]: + ... + +def fizzbuzz(n): """ Returns the correct fizzbuzz answer for any number or list/range of numbers. @@ -21,8 +31,8 @@ def fizzbuzz(n: int | list[int] | slice) -> str: n: the number(s) to fizzbuzz Returns: - A `str` with the correct fizzbuzz answer(s). - In the case of a list or range of inputs the answers will be separated by `, ` + In the case of a single number: a `str` with the correct fizzbuzz answer. + In the case of a list or range of inputs: a `list` of `str` with the correct fizzbuzz answers. Examples: a single `int`: @@ -37,18 +47,21 @@ def fizzbuzz(n: int | list[int] | slice) -> str: ``` from fizzbuzz.fizzbuzzo3 import fizzbuzz >>> fizzbuzz([1,3]) - '1, fizz' + ['1', 'fizz'] ``` a `slice` representing a range: ``` from fizzbuzz.fizzbuzzo3 import fizzbuzz >>> fizzbuzz(slice(1,4,2)) - '1, fizz' + ['1', 'fizz'] >>> fizzbuzz(slice(1,4)) - '1, 2, fizz' + ['1', '2', 'fizz'] >>> fizzbuzz(slice(4,1,-1)) - '4, fizz, 2' + ['4', 'fizz', '2'] + >>> fizzbuzz(slice(1,5,-1)) + [] ``` Note: Slices are inclusive on the left, exclusive on the right and can contain an optional step. - Negative steps require start > stop. + Negative steps require start > stop, positive steps require stop > start; other combinations return `[]`. + A step of zero is invalid and will raise a `ValueError`. """ diff --git a/rust/fizzbuzzo3/src/lib.rs b/rust/fizzbuzzo3/src/lib.rs index 707f26b..8751459 100644 --- a/rust/fizzbuzzo3/src/lib.rs +++ b/rust/fizzbuzzo3/src/lib.rs @@ -1,6 +1,6 @@ use std::ops::Neg; -use fizzbuzz::{FizzBuzz, MultiFizzBuzz}; +use fizzbuzz::{FizzBuzz, FizzBuzzAnswer, MultiFizzBuzz}; use pyo3::{exceptions::PyValueError, prelude::*, types::PySlice}; use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator}; @@ -25,6 +25,24 @@ impl IntoPy> for MySlice { } } +/// A wrapper struct for FizzBuzzAnswer to provide a custom implementation of `IntoPy`. +struct FizzBuzzReturn(FizzBuzzAnswer); + +impl From for FizzBuzzReturn { + fn from(value: FizzBuzzAnswer) -> Self { + FizzBuzzReturn(value) + } +} + +impl IntoPy> for FizzBuzzReturn { + fn into_py(self, py: Python<'_>) -> Py { + match self.0 { + FizzBuzzAnswer::One(string) => string.into_py(py), + FizzBuzzAnswer::Many(list) => list.into_py(py), + } + } +} + /// Returns the correct fizzbuzz answer for any number or list/range of numbers. /// /// This is an optimised algorithm compiled in rust. Large lists will utilise multiple CPU cores for processing. @@ -34,8 +52,8 @@ impl IntoPy> for MySlice { /// n: the number(s) to fizzbuzz /// /// Returns: -/// A `str` with the correct fizzbuzz answer(s). -/// In the case of a list or range of inputs the answers will be separated by `, ` +/// In the case of a single number: a `str` with the correct fizzbuzz answer. +/// In the case of a list or range of inputs: a `list` of `str` with the correct fizzbuzz answers. /// /// Examples: /// a single `int`: @@ -50,23 +68,27 @@ impl IntoPy> for MySlice { /// ``` /// from fizzbuzz.fizzbuzzo3 import fizzbuzz /// >>> fizzbuzz([1,3]) -/// '1, fizz' +/// ['1', 'fizz'] /// ``` /// a `slice` representing a range: /// ``` /// from fizzbuzz.fizzbuzzo3 import fizzbuzz /// >>> fizzbuzz(slice(1,4,2)) -/// '1, fizz' +/// ['1', 'fizz'] /// >>> fizzbuzz(slice(1,4)) -/// '1, 2, fizz' +/// ['1', '2', 'fizz'] /// >>> fizzbuzz(slice(4,1,-1)) -/// '4, fizz, 2' +/// ['4', 'fizz', '2'] +/// >>> fizzbuzz(slice(1,5,-1)) +/// [] /// ``` /// Note: Slices are inclusive on the left, exclusive on the right and can contain an optional step. -/// Negative steps require start > stop. +/// Note: Slices are inclusive on the left, exclusive on the right and can contain an optional step. +/// Negative steps require start > stop, positive steps require stop > start; other combinations return `[]`. +/// A step of zero is invalid and will raise a `ValueError`. #[pyfunction] #[pyo3(name = "fizzbuzz", text_signature = "(n)")] -fn py_fizzbuzz(num: FizzBuzzable) -> PyResult { +fn py_fizzbuzz(num: FizzBuzzable) -> PyResult { match num { FizzBuzzable::Int(n) => Ok(n.fizzbuzz().into()), FizzBuzzable::Float(n) => Ok(n.fizzbuzz().into()), @@ -141,8 +163,15 @@ mod tests { #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)] fn test_fizzbuzz_vec() { let input = vec![1, 2, 3, 4, 5]; - let result: String = fizzbuzz!(input); - assert_eq!(result, "1, 2, fizz, 4, buzz"); + let expected = vec![ + "1".to_string(), + "2".to_string(), + "fizz".to_string(), + "4".to_string(), + "buzz".to_string(), + ]; + let result: Vec = fizzbuzz!(input); + assert_eq!(result, expected); } #[pyo3test] @@ -160,8 +189,15 @@ mod tests { stop: 6, step: Some(1), }; - let result: String = fizzbuzz!(input); - assert_eq!(result, "1, 2, fizz, 4, buzz"); + let expected = vec![ + "1".to_string(), + "2".to_string(), + "fizz".to_string(), + "4".to_string(), + "buzz".to_string(), + ]; + let result: Vec = fizzbuzz!(input); + assert_eq!(result, expected); } #[pyo3test] @@ -172,8 +208,15 @@ mod tests { stop: 6, step: None, }; - let result: String = fizzbuzz!(input); - assert_eq!(result, "1, 2, fizz, 4, buzz"); + let expected = vec![ + "1".to_string(), + "2".to_string(), + "fizz".to_string(), + "4".to_string(), + "buzz".to_string(), + ]; + let result: Vec = fizzbuzz!(input); + assert_eq!(result, expected); } #[pyo3test] @@ -184,8 +227,9 @@ mod tests { stop: 6, step: Some(2), }; - let result: String = fizzbuzz!(input); - assert_eq!(result, "1, fizz, buzz"); + let expected = vec!["1".to_string(), "fizz".to_string(), "buzz".to_string()]; + let result: Vec = fizzbuzz!(input); + assert_eq!(result, expected); } #[pyo3test] @@ -196,8 +240,9 @@ mod tests { stop: 0, step: Some(1), }; - let result: String = fizzbuzz!(input); - assert_eq!(result, ""); + let result: Vec = fizzbuzz!(input); + let expected: Vec = vec![]; + assert_eq!(result, expected); } #[pyo3test] @@ -208,8 +253,9 @@ mod tests { stop: 0, step: Some(-2), }; - let result: String = fizzbuzz!(input); - assert_eq!(result, "buzz, fizz, 1"); + let expected = vec!["buzz".to_string(), "fizz".to_string(), "1".to_string()]; + let result: Vec = fizzbuzz!(input); + assert_eq!(result, expected); } #[pyo3test] @@ -220,8 +266,14 @@ mod tests { stop: 1, step: Some(-1), }; - let result: String = fizzbuzz!(input); - assert_eq!(result, "buzz, 4, fizz, 2"); + let expected = vec![ + "buzz".to_string(), + "4".to_string(), + "fizz".to_string(), + "2".to_string(), + ]; + let result: Vec = fizzbuzz!(input); + assert_eq!(result, expected); } #[pyo3test] @@ -232,8 +284,10 @@ mod tests { stop: 0, step: Some(-2), }; - let result: String = fizzbuzz!(input); - assert_eq!(result, "fizz, 4, 2"); + + let expected = vec!["fizz".to_string(), "4".to_string(), "2".to_string()]; + let result: Vec = fizzbuzz!(input); + assert_eq!(result, expected); } #[pyo3test] #[allow(unused_macros)] diff --git a/tests/perf_results.md b/tests/perf_results.md index 1065fa8..4769fa0 100644 --- a/tests/perf_results.md +++ b/tests/perf_results.md @@ -92,3 +92,95 @@ Rust vector, with python list overhead: [1 calls of 10 runs creating and fizzbuz Rust range: [1 calls of 10 runs fizzbuzzing a range of numbers up to 1000000] [0.5420241989995702] ``` + +## Comparing return types (`-> str | list[str]` vs. `-> str`) + +```text +(.venv) pyo3@6195c4a7706f:/workspaces/FizzBuzz$ echo "No LTO, Union" +No LTO, Union +(.venv) pyo3@6195c4a7706f:/workspaces/FizzBuzz$ /workspaces/FizzBuzz/.venv/bin/python /workspaces/FizzBuzz/tests/perftest.py +Rust: [1 calls of 10 runs fizzbuzzing up to 1000000] +[2.7247621990100015] +Rust vector: [1 calls of 10 runs fizzbuzzing a list of numbers up to 1000000] +[1.4409539260086603] +Rust vector, with python list overhead: [1 calls of 10 runs creating and fizzbuzzing a list of numbers up to 1000000] +[1.748141026997473] +Rust range: [1 calls of 10 runs fizzbuzzing a range of numbers up to 1000000] +[1.140464444004465] +(.venv) pyo3@6195c4a7706f:/workspaces/FizzBuzz$ echo "thin LTO, Union" +thin LTO, Union +(.venv) pyo3@6195c4a7706f:/workspaces/FizzBuzz$ /workspaces/FizzBuzz/.venv/bin/python /workspaces/FizzBuzz/tests/perftest.py +Rust: [1 calls of 10 runs fizzbuzzing up to 1000000] +[2.573878561001038] +Rust vector: [1 calls of 10 runs fizzbuzzing a list of numbers up to 1000000] +[1.5258527039986802] +Rust vector, with python list overhead: [1 calls of 10 runs creating and fizzbuzzing a list of numbers up to 1000000] +[1.7503311760083307] +Rust range: [1 calls of 10 runs fizzbuzzing a range of numbers up to 1000000] +[1.1543225019995589] +(.venv) pyo3@6195c4a7706f:/workspaces/FizzBuzz$ echo "fat LTO, Union" +fat LTO, Union +(.venv) pyo3@6195c4a7706f:/workspaces/FizzBuzz$ /workspaces/FizzBuzz/.venv/bin/python /workspaces/FizzBuzz/tests/perftest.py +Rust: [1 calls of 10 runs fizzbuzzing up to 1000000] +[2.659256437997101] +Rust vector: [1 calls of 10 runs fizzbuzzing a list of numbers up to 1000000] +[1.4467686470015906] +Rust vector, with python list overhead: [1 calls of 10 runs creating and fizzbuzzing a list of numbers up to 1000000] +[1.6921475639974233] +Rust range: [1 calls of 10 runs fizzbuzzing a range of numbers up to 1000000] +[1.1023815070075216] +(.venv) pyo3@6195c4a7706f:/workspaces/FizzBuzz$ echo "no LTO, String" +no LTO, String +(.venv) pyo3@6195c4a7706f:/workspaces/FizzBuzz$ /workspaces/FizzBuzz/.venv/bin/python /workspaces/FizzBuzz/tests/perftest.py +Rust: [1 calls of 10 runs fizzbuzzing up to 1000000] +[2.6100861899903975] +Rust vector: [1 calls of 10 runs fizzbuzzing a list of numbers up to 1000000] +[0.8597368839982664] +Rust vector, with python list overhead: [1 calls of 10 runs creating and fizzbuzzing a list of numbers up to 1000000] +[1.1903306849999353] +Rust range: [1 calls of 10 runs fizzbuzzing a range of numbers up to 1000000] +[0.6246530729986262] +``` + +## Comparing return types in general (1..10_000_000) + +`Str`ings are 2x faster than `list`s created from `Vec` + +### `-> str` + +```text +Rust: [1 calls of 10 runs fizzbuzzing up to 10000000] +[27.814233318000333] +Rust vector: [1 calls of 10 runs fizzbuzzing a list of numbers up to 10000000] +[7.261143727999297] +Rust vector, with python list overhead: [1 calls of 10 runs creating and fizzbuzzing a list of numbers up to 10000000] +[10.321640708003542] +Rust range: [1 calls of 10 runs fizzbuzzing a range of numbers up to 10000000] +[4.721871253001154] +``` + +### `-> str | list[str]` + +```text +Rust: [1 calls of 10 runs fizzbuzzing up to 10000000] +[25.37807360100851] +Rust vector: [1 calls of 10 runs fizzbuzzing a list of numbers up to 10000000] +[12.790041114989435] +Rust vector, with python list overhead: [1 calls of 10 runs creating and fizzbuzzing a list of numbers up to 10000000] +[16.75132880899764] +Rust range: [1 calls of 10 runs fizzbuzzing a range of numbers up to 10000000] +[9.89638055099931] +``` + +### `-> list[str]` + +```text +Rust: [1 calls of 10 runs fizzbuzzing up to 10000000] +[47.682113279995974] +Rust vector: [1 calls of 10 runs fizzbuzzing a list of numbers up to 10000000] +[12.776051424996695] +Rust vector, with python list overhead: [1 calls of 10 runs creating and fizzbuzzing a list of numbers up to 10000000] +[16.503915808003512] +Rust range: [1 calls of 10 runs fizzbuzzing a range of numbers up to 10000000] +[9.859676376989228] +``` diff --git a/tests/perftest.py b/tests/perftest.py index c04ee1c..0564ae0 100644 --- a/tests/perftest.py +++ b/tests/perftest.py @@ -18,10 +18,10 @@ def main(): fbo3times = fbo3timer.repeat(repeat=REPEAT, number=NUMBER) print(fbo3times) - print(f"Python: [{REPEAT} calls of {NUMBER} runs fizzbuzzing up to {FIZZBUZZES}]") - fbpytimer = timeit.Timer(stmt="[fbpy(i) for i in range(1,FIZZBUZZES)]", globals=globals()) - fbpytimes = fbpytimer.repeat(repeat=REPEAT, number=NUMBER) - print(fbpytimes) + # print(f"Python: [{REPEAT} calls of {NUMBER} runs fizzbuzzing up to {FIZZBUZZES}]") + # fbpytimer = timeit.Timer(stmt="[fbpy(i) for i in range(1,FIZZBUZZES)]", globals=globals()) + # fbpytimes = fbpytimer.repeat(repeat=REPEAT, number=NUMBER) + # print(fbpytimes) print(f"Rust vector: [{REPEAT} calls of {NUMBER} runs fizzbuzzing a list of numbers up to {FIZZBUZZES}]") fbo3vectimer = timeit.Timer(stmt="[fbo3(LISTOFNUMBERS)]", globals=globals()) diff --git a/tests/test_fizzbuzzo3.py b/tests/test_fizzbuzzo3.py index 167ae26..1f904ce 100644 --- a/tests/test_fizzbuzzo3.py +++ b/tests/test_fizzbuzzo3.py @@ -11,17 +11,27 @@ def test_lazy(): assert fizzbuzz(6) == "fizz" assert fizzbuzz(15) == "fizzbuzz" + +def test_fizzbuzz_empty_list(): + assert fizzbuzz([]) == [] + + def test_float(): assert fizzbuzz(1.0) == "1" assert fizzbuzz(3.0) == "fizz" + def test_list(): - assert fizzbuzz([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15]) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, fizzbuzz" + assert fizzbuzz( + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15], + ) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, fizzbuzz".split(", ") + def test_string(): with pytest.raises(TypeError): fizzbuzz("1") + def test_1_to_100(): results = [fizzbuzz(i) for i in range(1, 101)] every_3rd_has_fizz = all("fizz" in r for r in results[2::3]) @@ -39,16 +49,24 @@ def test_1_to_100(): all_numbers_correct = all(r == str(i + 1) for i, r in enumerate(results) if r not in ("fizz", "buzz", "fizzbuzz")) assert all_numbers_correct + def test_slice(): - assert fizzbuzz(slice(1,16,1)) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, 11, fizz, 13, 14, fizzbuzz" + assert fizzbuzz( + slice(1, 16, 1), + ) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, 11, fizz, 13, 14, fizzbuzz".split(", ") + # This case is REALLY IMPORTANT as it cannot be tested via rust unit tests... def test_slice_no_step(): - assert fizzbuzz(slice(1,16)) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, 11, fizz, 13, 14, fizzbuzz" + assert fizzbuzz( + slice(1, 16), + ) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, 11, fizz, 13, 14, fizzbuzz".split(", ") + def test_slice_negative_step(): - assert fizzbuzz(slice(15,0,-3)) == "fizzbuzz, fizz, fizz, fizz, fizz" + assert fizzbuzz(slice(15, 0, -3)) == "fizzbuzz, fizz, fizz, fizz, fizz".split(", ") + def test_slice_zero_step(): with pytest.raises(ValueError, match="step cannot be zero"): - fizzbuzz(slice(1,16,0)) \ No newline at end of file + fizzbuzz(slice(1, 16, 0))