diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index c36cc65..e95c6e7 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -117,7 +117,7 @@ jobs: MATURIN_REPOSITORY_URL: "https://test.pypi.org/legacy/" with: command: upload - args: "--skip-existing **/*.whl" + args: "--skip-existing **/*.whl **/*.tar.gz" working-directory: bindings/python publish-pypi: @@ -137,5 +137,5 @@ jobs: MATURIN_REPOSITORY_URL: "https://upload.pypi.org/legacy/" with: command: upload - args: "--skip-existing **/*.whl" + args: "--skip-existing **/*.whl **/*.tar.gz" working-directory: bindings/python diff --git a/Cargo.lock b/Cargo.lock index 677cbf7..2c0d72d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,11 +720,10 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.23.4" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe09249128b3173d092de9523eaa75136bf7ba85e0d69eca241c7939c933cc" +checksum = "f239d656363bcee73afef85277f1b281e8ac6212a1d42aa90e55b90ed43c47a4" dependencies = [ - "cfg-if", "indoc", "libc", "memoffset", @@ -738,9 +737,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.23.4" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd3927b5a78757a0d71aa9dff669f903b1eb64b54142a9bd9f757f8fde65fd7" +checksum = "755ea671a1c34044fa165247aaf6f419ca39caa6003aee791a0df2713d8f1b6d" dependencies = [ "once_cell", "target-lexicon", @@ -748,9 +747,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.23.4" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab6bb2102bd8f991e7749f130a70d05dd557613e39ed2deeee8e9ca0c4d548d" +checksum = "fc95a2e67091e44791d4ea300ff744be5293f394f1bafd9f78c080814d35956e" dependencies = [ "libc", "pyo3-build-config", @@ -758,9 +757,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.23.4" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91871864b353fd5ffcb3f91f2f703a22a9797c91b9ab497b1acac7b07ae509c7" +checksum = "a179641d1b93920829a62f15e87c0ed791b6c8db2271ba0fd7c2686090510214" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -770,9 +769,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.23.4" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43abc3b80bc20f3facd86cd3c60beed58c3e2aa26213f3cda368de39c60a27e4" +checksum = "9dff85ebcaab8c441b0e3f7ae40a6963ecea8a9f5e74f647e33fcf5ec9a1e89e" dependencies = [ "heck", "proc-macro2", @@ -783,8 +782,9 @@ dependencies = [ [[package]] name = "pyroncalc" -version = "0.5.0" +version = "0.5.7" dependencies = [ + "bitcode", "ironcalc", "pyo3", "serde", @@ -984,9 +984,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.12.16" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" [[package]] name = "thiserror" diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index 7d9a5f6..e97dee2 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyroncalc" -version = "0.5.0" +version = "0.5.7" edition = "2021" @@ -13,7 +13,8 @@ crate-type = ["cdylib"] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" xlsx = { package= "ironcalc", path = "../../xlsx", version = "0.5.0" } -pyo3 = { version = "0.23", features = ["extension-module"] } +pyo3 = { version = "0.25", features = ["extension-module"] } +bitcode = "0.6.3" [features] diff --git a/bindings/python/README.md b/bindings/python/README.md index df2b78e..17822c6 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -10,9 +10,6 @@ You can add cell values, retrieve them and most importantly you can evaluate spr pip install ironcalc ``` - - - ## Compile and test To compile this and test it: @@ -28,4 +25,18 @@ examples $ python example.py From there if you use `python` you can `import ironcalc`. You can either create a new file, read it from a JSON string or import from Excel. -Hopefully the API is straightforward. \ No newline at end of file +Hopefully the API is straightforward. + +## Creating documentation + +We use sphinx + +``` +python -m venv venv +source venv/bin/activate +pip install maturin +pip install sphinx +maturin develop +sphinx-build -M html docs html +python -m http.server --directory html/html/ +``` diff --git a/bindings/python/build_docs.sh b/bindings/python/build_docs.sh new file mode 100755 index 0000000..9f68a3e --- /dev/null +++ b/bindings/python/build_docs.sh @@ -0,0 +1,9 @@ +#!/bin/bash +python -m venv venv +source venv/bin/activate +pip install patchelf +pip install maturin +pip install sphinx +maturin develop +sphinx-build -M html docs html +python -m http.server --directory html/html/ diff --git a/bindings/python/docs/index.rst b/bindings/python/docs/index.rst index 8f62473..13584da 100644 --- a/bindings/python/docs/index.rst +++ b/bindings/python/docs/index.rst @@ -8,7 +8,8 @@ IronCalc installation usage_examples top_level_methods - api_reference + raw_api_reference + user_api_reference objects IronCalc is a spreadsheet engine that allows you to create, modify and save spreadsheets. diff --git a/bindings/python/docs/api_reference.rst b/bindings/python/docs/raw_api_reference.rst similarity index 98% rename from bindings/python/docs/api_reference.rst rename to bindings/python/docs/raw_api_reference.rst index aefd32d..ae7b881 100644 --- a/bindings/python/docs/api_reference.rst +++ b/bindings/python/docs/raw_api_reference.rst @@ -1,6 +1,6 @@ -API Reference -------------- +Raw API Reference +----------------- In general methods in IronCalc use a 0-index base for the the sheet index and 1-index base for the row and column indexes. @@ -28,7 +28,7 @@ In general methods in IronCalc use a 0-index base for the the sheet index and 1- .. method:: get_cell_content(sheet: int, row: int, column: int) -> str - Returns the raw content of a cell. If the cell contains a formula, + Returns the raw content of a cell. If the cell contains a formula, the returned string starts with ``"="``. :param sheet: The sheet index (0-based). @@ -47,7 +47,7 @@ In general methods in IronCalc use a 0-index base for the the sheet index and 1- .. method:: get_formatted_cell_value(sheet: int, row: int, column: int) -> str - Returns the cell’s value as a formatted string, taking into + Returns the cell’s value as a formatted string, taking into account any number/currency/date formatting. :param sheet: The sheet index (0-based). @@ -167,7 +167,7 @@ In general methods in IronCalc use a 0-index base for the the sheet index and 1- .. method:: get_worksheets_properties() -> List[PySheetProperty] - Returns a list of :class:`PySheetProperty` describing each worksheet’s + Returns a list of :class:`PySheetProperty` describing each worksheet’s name, visibility state, ID, and tab color. :rtype: list of PySheetProperty @@ -204,7 +204,7 @@ In general methods in IronCalc use a 0-index base for the the sheet index and 1- .. method:: test_panic() - A test method that deliberately panics in Rust. + A test method that deliberately panics in Rust. Used for testing panic handling at the method level. :raises WorkbookError: (wrapped Rust panic) diff --git a/bindings/python/docs/top_level_methods.rst b/bindings/python/docs/top_level_methods.rst index d5130eb..fd0ba28 100644 --- a/bindings/python/docs/top_level_methods.rst +++ b/bindings/python/docs/top_level_methods.rst @@ -1,6 +1,13 @@ Top Level Methods ----------------- +This module provides a set of top-level methods for creating and loading IronCalc models. + .. autofunction:: ironcalc.create .. autofunction:: ironcalc.load_from_xlsx -.. autofunction:: ironcalc.load_from_icalc \ No newline at end of file +.. autofunction:: ironcalc.load_from_icalc +.. autofunction:: ironcalc.load_from_bytes +.. autofunction:: ironcalc.create_user_model +.. autofunction:: ironcalc.create_user_model_from_bytes +.. autofunction:: ironcalc.create_user_model_from_xlsx +.. autofunction:: ironcalc.create_user_model_from_icalc \ No newline at end of file diff --git a/bindings/python/docs/user_api_reference.rst b/bindings/python/docs/user_api_reference.rst new file mode 100644 index 0000000..e1039bc --- /dev/null +++ b/bindings/python/docs/user_api_reference.rst @@ -0,0 +1,41 @@ +User API Reference +------------------ + +This is the "user api". Models here have history, they evaluate automatically with each change and have a "diff" history. + + +.. method:: save_to_xlsx(file: str) + + Saves the user model to file in the XLSX format. + + ::param file: The file path to save the model to. + +.. method:: save_to_icalc(file: str) + + Saves the user model to file in the internal binary ic format. + + ::param file: The file path to save the model to. + +.. method:: apply_external_diffs(external_diffs: bytes) + + Applies external diffs to the model. This is used to apply changes from other instances of the model. + + ::param external_diffs: The external diffs to apply, as a byte array. + +.. method:: flush_send_queue() -> bytes + + Flushes the send queue and returns the bytes to be sent to the client. This is used to send changes to the client. + +.. method:: set_user_input(sheet: int, row: int, column: int, value: str) + + Sets an input in a cell, as would be done by a user typing into a spreadsheet cell. + +.. method:: get_formatted_cell_value(sheet: int, row: int, column: int) -> str + + Returns the cell’s value as a formatted string, taking into account any number/currency/date formatting. + +.. method:: to_bytes() -> bytes + + Returns the model as a byte array. This is useful for sending the model over a network or saving it to a file. + + diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index c3128a5..b86b237 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ironcalc" -version = "0.5.0" +version = "0.5.6" description = "Create, edit and evaluate Excel spreadsheets" requires-python = ">=3.10" keywords = [ diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 2c9b9fa..2adc201 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -2,8 +2,8 @@ use pyo3::exceptions::PyException; use pyo3::{create_exception, prelude::*, wrap_pyfunction}; use types::{PySheetProperty, PyStyle}; -use xlsx::base::types::Style; -use xlsx::base::Model; +use xlsx::base::types::{Style, Workbook}; +use xlsx::base::{Model, UserModel}; use xlsx::export::{save_to_icalc, save_to_xlsx}; use xlsx::import; @@ -14,6 +14,60 @@ use crate::types::PyCellType; create_exception!(_ironcalc, WorkbookError, PyException); +#[pyclass] +pub struct PyUserModel { + /// The user model, which is a wrapper around the Model + pub model: UserModel, +} + +#[pymethods] +impl PyUserModel { + /// Saves the user model to an xlsx file + pub fn save_to_xlsx(&self, file: &str) -> PyResult<()> { + let model = self.model.get_model(); + save_to_xlsx(model, file).map_err(|e| WorkbookError::new_err(e.to_string())) + } + + /// Saves the user model to file in the internal binary ic format + pub fn save_to_icalc(&self, file: &str) -> PyResult<()> { + let model = self.model.get_model(); + save_to_icalc(model, file).map_err(|e| WorkbookError::new_err(e.to_string())) + } + + pub fn apply_external_diffs(&mut self, external_diffs: &[u8]) -> PyResult<()> { + self.model + .apply_external_diffs(external_diffs) + .map_err(|e| WorkbookError::new_err(e.to_string())) + } + + pub fn flush_send_queue(&mut self) -> Vec { + self.model.flush_send_queue() + } + + pub fn set_user_input( + &mut self, + sheet: u32, + row: i32, + column: i32, + value: &str, + ) -> PyResult<()> { + self.model + .set_user_input(sheet, row, column, value) + .map_err(|e| WorkbookError::new_err(e.to_string())) + } + + pub fn get_formatted_cell_value(&self, sheet: u32, row: i32, column: i32) -> PyResult { + self.model + .get_formatted_cell_value(sheet, row, column) + .map_err(|e| WorkbookError::new_err(e.to_string())) + } + + pub fn to_bytes(&self) -> PyResult> { + let bytes = self.model.to_bytes(); + Ok(bytes) + } +} + /// This is a model implementing the 'raw' API #[pyclass] pub struct PyModel { @@ -32,6 +86,12 @@ impl PyModel { save_to_icalc(&self.model, file).map_err(|e| WorkbookError::new_err(e.to_string())) } + /// To bytes + pub fn to_bytes(&self) -> PyResult> { + let bytes = self.model.to_bytes(); + Ok(bytes) + } + /// Evaluates the workbook pub fn evaluate(&mut self) { self.model.evaluate() @@ -249,7 +309,19 @@ pub fn load_from_icalc(file_name: &str) -> PyResult { Ok(PyModel { model }) } -/// Creates an empty model +/// Loads a function from bytes +/// This function expects the bytes to be in the internal binary ic format +/// which is the same format used by the `save_to_icalc` function. +#[pyfunction] +pub fn load_from_bytes(bytes: &[u8]) -> PyResult { + let workbook: Workbook = + bitcode::decode(bytes).map_err(|e| WorkbookError::new_err(e.to_string()))?; + let model = + Model::from_workbook(workbook).map_err(|e| WorkbookError::new_err(e.to_string()))?; + Ok(PyModel { model }) +} + +/// Creates an empty model in the raw API #[pyfunction] pub fn create(name: &str, locale: &str, tz: &str) -> PyResult { let model = @@ -257,6 +329,49 @@ pub fn create(name: &str, locale: &str, tz: &str) -> PyResult { Ok(PyModel { model }) } +/// Creates a model with the user model API +#[pyfunction] +pub fn create_user_model(name: &str, locale: &str, tz: &str) -> PyResult { + let model = UserModel::new_empty(name, locale, tz) + .map_err(|e| WorkbookError::new_err(e.to_string()))?; + Ok(PyUserModel { model }) +} + +/// Creates a user model from an Excel file +#[pyfunction] +pub fn create_user_model_from_xlsx( + file_path: &str, + locale: &str, + tz: &str, +) -> PyResult { + let model = import::load_from_xlsx(file_path, locale, tz) + .map_err(|e| WorkbookError::new_err(e.to_string()))?; + let model = UserModel::from_model(model); + Ok(PyUserModel { model }) +} + +/// Creates a user model from an icalc file +#[pyfunction] +pub fn create_user_model_from_icalc(file_name: &str) -> PyResult { + let model = + import::load_from_icalc(file_name).map_err(|e| WorkbookError::new_err(e.to_string()))?; + let model = UserModel::from_model(model); + Ok(PyUserModel { model }) +} + +/// Creates a user model from bytes +/// This function expects the bytes to be in the internal binary ic format +/// which is the same format used by the `save_to_icalc` function. +#[pyfunction] +pub fn create_user_model_from_bytes(bytes: &[u8]) -> PyResult { + let workbook: Workbook = + bitcode::decode(bytes).map_err(|e| WorkbookError::new_err(e.to_string()))?; + let model = + Model::from_workbook(workbook).map_err(|e| WorkbookError::new_err(e.to_string()))?; + let user_model = UserModel::from_model(model); + Ok(PyUserModel { model: user_model }) +} + #[pyfunction] #[allow(clippy::panic)] pub fn test_panic() { @@ -272,7 +387,14 @@ fn ironcalc(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(create, m)?)?; m.add_function(wrap_pyfunction!(load_from_xlsx, m)?)?; m.add_function(wrap_pyfunction!(load_from_icalc, m)?)?; + m.add_function(wrap_pyfunction!(load_from_bytes, m)?)?; m.add_function(wrap_pyfunction!(test_panic, m)?)?; + // User model functions + m.add_function(wrap_pyfunction!(create_user_model, m)?)?; + m.add_function(wrap_pyfunction!(create_user_model_from_bytes, m)?)?; + m.add_function(wrap_pyfunction!(create_user_model_from_xlsx, m)?)?; + m.add_function(wrap_pyfunction!(create_user_model_from_icalc, m)?)?; + Ok(()) } diff --git a/bindings/python/tests/test_create.py b/bindings/python/tests/test_create.py index 7ad96c8..ca029e5 100644 --- a/bindings/python/tests/test_create.py +++ b/bindings/python/tests/test_create.py @@ -6,3 +6,24 @@ def test_simple(): model.evaluate() assert model.get_formatted_cell_value(0, 1, 1) == "3" + + bytes = model.to_bytes() + + model2 = ic.load_from_bytes(bytes) + assert model2.get_formatted_cell_value(0, 1, 1) == "3" + + +def test_simple_user(): + model = ic.create_user_model("model", "en", "UTC") + model.set_user_input(0, 1, 1, "=1+2") + model.set_user_input(0, 1, 2, "=A1+3") + + assert model.get_formatted_cell_value(0, 1, 1) == "3" + assert model.get_formatted_cell_value(0, 1, 2) == "6" + + diffs = model.flush_send_queue() + + model2 = ic.create_user_model("model", "en", "UTC") + model2.apply_external_diffs(diffs) + assert model2.get_formatted_cell_value(0, 1, 1) == "3" + assert model2.get_formatted_cell_value(0, 1, 2) == "6"