diff --git a/Cargo.lock b/Cargo.lock index 54d870e..dd3dcd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,9 +47,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "calamine" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9ca341b94bc4b29e08970b1a03914fad2dfe177e2ecb67fcd6de9046004aafb" +checksum = "f431b67cba6122a1f7d26b7adafc71bbdf07026095fe811f5efc025d8748e1d8" dependencies = [ "atoi_simd", "byteorder", diff --git a/Cargo.toml b/Cargo.toml index a402740..3a69202 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ name = "python_calamine" crate-type = ["cdylib"] [dependencies] -calamine = { version = "0.37.0", features = ["chrono"] } +calamine = { version = "0.38.0", features = ["chrono"] } pyo3 = { version = "0.30.1", features = ["chrono"] } chrono = { version = "0.4.42", features = ["serde"] } pyo3-file = "0.18.0" diff --git a/README.md b/README.md index 5854e41..c8c0e0b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,21 @@ workbook = CalamineWorkbook.from_path("file.xlsx").get_sheet_by_name("Sheet1").t # ] ``` +Workbook document properties, such as the author, are available for xlsx, xlsb, xls and ods files. +```python +from python_calamine import CalamineWorkbook + +properties = CalamineWorkbook.from_path("file.xlsx").workbook_properties +properties.creator +# "John" +properties.last_modified_by +# "Jane" +properties.modified +# datetime.datetime(2024, 6, 20, 14, 22, tzinfo=datetime.timezone.utc) +properties.custom_properties +# {"Department": "Finance"} +``` + Pandas 2.2 and above have built-in support of python-calamine. Also, you can find additional examples in [tests](https://github.com/dimastbk/python-calamine/blob/master/tests/test_base.py). diff --git a/python/python_calamine/__init__.py b/python/python_calamine/__init__.py index c14d5db..6a4fd47 100644 --- a/python/python_calamine/__init__.py +++ b/python/python_calamine/__init__.py @@ -11,6 +11,7 @@ TablesNotLoaded, TablesNotSupported, WorkbookClosed, + WorkbookProperties, WorksheetNotFound, XmlError, ZipError, @@ -30,6 +31,7 @@ "TablesNotLoaded", "TablesNotSupported", "WorkbookClosed", + "WorkbookProperties", "WorksheetNotFound", "XmlError", "ZipError", diff --git a/python/python_calamine/_python_calamine.pyi b/python/python_calamine/_python_calamine.pyi index 536c989..4273b54 100644 --- a/python/python_calamine/_python_calamine.pyi +++ b/python/python_calamine/_python_calamine.pyi @@ -174,6 +174,57 @@ class CalamineTable: ]: """Returning data from table as list of lists.""" +@typing.final +class WorkbookProperties: + """Document properties of a workbook. + + Fields that the file doesn't store are `None`. `created` and `modified` are + timezone-aware when the file stores a UTC offset (xlsx, xlsb and xls always do) + and naive otherwise, for example in ods files saved by LibreOffice. A date the + file stores in another format is `None`. + """ + + creator: str | None + """Creator (author) of the workbook.""" + last_modified_by: str | None + """User who last saved the workbook.""" + created: datetime.datetime | None + """Creation date.""" + modified: datetime.datetime | None + """Last modification date.""" + title: str | None + """Title.""" + subject: str | None + """Subject.""" + description: str | None + """Description or comments.""" + keywords: str | None + """Keywords.""" + category: str | None + """Category.""" + content_status: str | None + """Content status.""" + revision: str | None + """Revision number.""" + version: str | None + """Version.""" + application: str | None + """Name of the application that created the workbook.""" + app_version: str | None + """Version of the application that created the workbook.""" + company: str | None + """Company.""" + template: str | None + """Template the workbook is based on.""" + manager: str | None + """Manager.""" + custom_properties: dict[str, str] + """Custom document properties, keyed by name. + + Values are returned as the text stored in the file, for example `"4"`, + `"true"` or `"2020-08-24T20:19:22Z"`. + """ + @typing.final class CalamineWorkbook: path: str | None @@ -184,6 +235,17 @@ class CalamineWorkbook: """All sheets metadata of this workbook, in workbook order.""" table_names: list[str] | None """All table names of this workbook.""" + @property + def workbook_properties(self) -> WorkbookProperties: + """Document properties of this workbook, such as the creator and title. + + Supported for xlsx, xlsb, xls and ods. The properties are read when this + attribute is first accessed, not when the workbook is opened. + + Raises: + WorkbookClosed: If workbook already closed. + """ + @classmethod def from_object( cls, path_or_filelike: str | os.PathLike | ReadBuffer, load_tables: bool = False @@ -309,6 +371,7 @@ __all__ = [ "TablesNotLoaded", "TablesNotSupported", "WorkbookClosed", + "WorkbookProperties", "WorksheetNotFound", "XmlError", "ZipError", diff --git a/src/lib.rs b/src/lib.rs index 5ae3e19..67a3daa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ mod types; use crate::types::{ CalamineError, CalamineSheet, CalamineTable, CalamineWorkbook, CellValue, Error, PasswordError, SheetMetadata, SheetTypeEnum, SheetVisibleEnum, TableNotFound, TablesNotLoaded, - TablesNotSupported, WorkbookClosed, WorksheetNotFound, XmlError, ZipError, + TablesNotSupported, WorkbookClosed, WorkbookProperties, WorksheetNotFound, XmlError, ZipError, }; #[pyfunction] @@ -26,6 +26,7 @@ fn _python_calamine(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add("CalamineError", py.get_type::())?; m.add("PasswordError", py.get_type::())?; m.add("WorksheetNotFound", py.get_type::())?; diff --git a/src/types/mod.rs b/src/types/mod.rs index c5cff3d..b13c8e6 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -3,6 +3,7 @@ mod errors; mod sheet; mod table; mod workbook; +mod workbook_properties; pub use cell::CellValue; pub use errors::{ CalamineError, Error, PasswordError, TableNotFound, TablesNotLoaded, TablesNotSupported, @@ -11,3 +12,4 @@ pub use errors::{ pub use sheet::{CalamineSheet, SheetMetadata, SheetTypeEnum, SheetVisibleEnum}; pub use table::CalamineTable; pub use workbook::CalamineWorkbook; +pub use workbook_properties::WorkbookProperties; diff --git a/src/types/workbook.rs b/src/types/workbook.rs index 123146a..a1df727 100644 --- a/src/types/workbook.rs +++ b/src/types/workbook.rs @@ -10,7 +10,9 @@ use pyo3::prelude::*; use pyo3::types::PyType; use pyo3_file::PyFileLikeObject; -use crate::{CalamineSheet, CalamineTable, Error, SheetMetadata, WorksheetNotFound}; +use crate::{ + CalamineSheet, CalamineTable, Error, SheetMetadata, WorkbookProperties, WorksheetNotFound, +}; enum SheetsEnum { File(Sheets>), @@ -65,6 +67,15 @@ impl SheetsEnum { } } + fn workbook_properties(&mut self) -> Result { + match self { + SheetsEnum::File(f) => f.workbook_properties().map_err(Error::Calamine), + SheetsEnum::FileLike(f) => f.workbook_properties().map_err(Error::Calamine), + SheetsEnum::None => Err(Error::WorkbookClosed), + } + .map(WorkbookProperties::from) + } + fn worksheet_range(&mut self, name: &str) -> Result, Error> { match self { SheetsEnum::File(f) => f.worksheet_range(name).map_err(Error::Calamine), @@ -265,6 +276,11 @@ impl CalamineWorkbook { } } + #[getter] + fn workbook_properties(&mut self, py: Python<'_>) -> PyResult { + py.detach(|| Ok(self.sheets.workbook_properties()?)) + } + #[pyo3(name = "get_table_by_name")] fn py_get_table_by_name(&mut self, py: Python<'_>, name: &str) -> PyResult { py.detach(|| self.get_table_by_name(name)) diff --git a/src/types/workbook_properties.rs b/src/types/workbook_properties.rs new file mode 100644 index 0000000..b530fa4 --- /dev/null +++ b/src/types/workbook_properties.rs @@ -0,0 +1,134 @@ +use std::collections::BTreeMap; + +use chrono::{DateTime, FixedOffset, NaiveDateTime}; +use pyo3::prelude::*; + +/// A document property date, with a UTC offset if the file stores one. +#[derive(Clone, Debug, PartialEq, IntoPyObject)] +enum PropertyDate { + Aware(DateTime), + Naive(NaiveDateTime), +} + +impl PropertyDate { + /// Parse a date such as "2024-01-15T08:30:00Z" (xlsx, xlsb, xls) or + /// "2024-06-20T14:22:00.123456789" (ods). Other formats return `None`. + fn parse(value: &str) -> Option { + let value = value.trim(); + if let Ok(date) = DateTime::parse_from_rfc3339(value) { + return Some(PropertyDate::Aware(date)); + } + NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") + .ok() + .map(PropertyDate::Naive) + } +} + +/// Document properties of a workbook, such as the creator, title and company. +#[pyclass(eq, from_py_object)] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WorkbookProperties { + #[pyo3(get)] + creator: Option, + #[pyo3(get)] + last_modified_by: Option, + #[pyo3(get)] + created: Option, + #[pyo3(get)] + modified: Option, + #[pyo3(get)] + title: Option, + #[pyo3(get)] + subject: Option, + #[pyo3(get)] + description: Option, + #[pyo3(get)] + keywords: Option, + #[pyo3(get)] + category: Option, + #[pyo3(get)] + content_status: Option, + #[pyo3(get)] + revision: Option, + #[pyo3(get)] + version: Option, + #[pyo3(get)] + application: Option, + #[pyo3(get)] + app_version: Option, + #[pyo3(get)] + company: Option, + #[pyo3(get)] + template: Option, + #[pyo3(get)] + manager: Option, + #[pyo3(get)] + custom_properties: BTreeMap, +} + +#[pymethods] +impl WorkbookProperties { + fn __repr__(&self, py: Python<'_>) -> PyResult { + let fields = [ + ("creator", self.creator.clone().into_pyobject(py)?), + ( + "last_modified_by", + self.last_modified_by.clone().into_pyobject(py)?, + ), + ("created", self.created.clone().into_pyobject(py)?), + ("modified", self.modified.clone().into_pyobject(py)?), + ("title", self.title.clone().into_pyobject(py)?), + ("subject", self.subject.clone().into_pyobject(py)?), + ("description", self.description.clone().into_pyobject(py)?), + ("keywords", self.keywords.clone().into_pyobject(py)?), + ("category", self.category.clone().into_pyobject(py)?), + ( + "content_status", + self.content_status.clone().into_pyobject(py)?, + ), + ("revision", self.revision.clone().into_pyobject(py)?), + ("version", self.version.clone().into_pyobject(py)?), + ("application", self.application.clone().into_pyobject(py)?), + ("app_version", self.app_version.clone().into_pyobject(py)?), + ("company", self.company.clone().into_pyobject(py)?), + ("template", self.template.clone().into_pyobject(py)?), + ("manager", self.manager.clone().into_pyobject(py)?), + ]; + let mut parts = Vec::new(); + for (name, value) in fields { + if !value.is_none() { + parts.push(format!("{name}={}", value.repr()?)); + } + } + if !self.custom_properties.is_empty() { + let custom = (&self.custom_properties).into_pyobject(py)?; + parts.push(format!("custom_properties={}", custom.repr()?)); + } + Ok(format!("WorkbookProperties({})", parts.join(", "))) + } +} + +impl From for WorkbookProperties { + fn from(props: calamine::WorkbookProperties) -> Self { + WorkbookProperties { + creator: props.creator, + last_modified_by: props.last_modified_by, + created: props.created.as_deref().and_then(PropertyDate::parse), + modified: props.modified.as_deref().and_then(PropertyDate::parse), + title: props.title, + subject: props.subject, + description: props.description, + keywords: props.keywords, + category: props.category, + content_status: props.content_status, + revision: props.revision, + version: props.version, + application: props.application, + app_version: props.app_version, + company: props.company, + template: props.template, + manager: props.manager, + custom_properties: props.custom_properties.into_iter().collect(), + } + } +} diff --git a/tests/data/workbook_properties.ods b/tests/data/workbook_properties.ods new file mode 100644 index 0000000..6d072c3 Binary files /dev/null and b/tests/data/workbook_properties.ods differ diff --git a/tests/data/workbook_properties.xlsx b/tests/data/workbook_properties.xlsx new file mode 100644 index 0000000..5586399 Binary files /dev/null and b/tests/data/workbook_properties.xlsx differ diff --git a/tests/test_workbook_properties.py b/tests/test_workbook_properties.py new file mode 100644 index 0000000..96704df --- /dev/null +++ b/tests/test_workbook_properties.py @@ -0,0 +1,136 @@ +from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path + +import pytest +from python_calamine import CalamineWorkbook, WorkbookClosed, WorkbookProperties + +PATH = Path(__file__).parent / "data" + + +def test_xlsx_workbook_properties(): + props = CalamineWorkbook.from_path( + PATH / "workbook_properties.xlsx" + ).workbook_properties + + assert isinstance(props, WorkbookProperties) + assert props.creator == "Test Creator" + assert props.last_modified_by == "Last Modifier" + assert props.created == datetime(2024, 1, 15, 8, 30, tzinfo=timezone.utc) + assert props.modified == datetime(2024, 6, 20, 14, 22, tzinfo=timezone.utc) + assert props.title == "Workbook Title" + assert props.subject == "Test Subject" + assert props.description == "A test workbook" + assert props.keywords == "test, calamine" + assert props.category == "Testing" + assert props.content_status == "Draft" + assert props.revision == "3" + assert props.version == "1.2" + assert props.application == "Microsoft Excel" + assert props.app_version == "16.0300" + assert props.company == "Contoso & Co" + assert props.template == "Book.xltx" + assert props.manager == "Jane Doe" + assert props.custom_properties == {"Department": "Finance", "Reviewed": "true"} + + +def test_ods_workbook_properties(): + props = CalamineWorkbook.from_path( + PATH / "workbook_properties.ods" + ).workbook_properties + + assert props.creator == "Test Creator" + assert props.last_modified_by == "Last Modifier" + assert props.created == datetime(2024, 1, 15, 8, 30) + assert props.modified == datetime(2024, 6, 20, 14, 22, 0, 123456) + assert props.title == "Workbook Title" + assert props.subject == "Test Subject" + assert props.description == "A test workbook" + assert props.keywords == "test, calamine" + assert props.revision == "3" + assert props.application is not None + assert props.application.startswith("LibreOffice/24.2") + assert props.company is None + assert props.manager is None + assert props.custom_properties == { + "Department": "Finance & Ops", + "Reviewed": "true", + } + + +@pytest.mark.parametrize( + ("file", "modified", "application"), + [ + ( + "any_sheets.xlsx", + datetime(2023, 7, 28, 16, 31, 17, tzinfo=timezone.utc), + "Microsoft Excel", + ), + ( + "any_sheets.xlsb", + datetime(2023, 7, 28, 16, 31, 46, tzinfo=timezone.utc), + "Microsoft Excel", + ), + ( + "any_sheets.xls", + datetime(2023, 7, 28, 16, 31, 33, tzinfo=timezone.utc), + "Microsoft Excel", + ), + ( + "any_sheets.ods", + datetime(2023, 7, 28, 16, 32, 8, tzinfo=timezone.utc), + "MicrosoftOffice/16.0 MicrosoftExcel/CalculationVersion-14420", + ), + ], +) +def test_workbook_properties_all_formats(file, modified, application): + props = CalamineWorkbook.from_path(PATH / file).workbook_properties + + assert props.creator == "Алена" + assert props.last_modified_by == "Алена" + assert props.created == datetime(2023, 7, 28, 16, 28, 6, tzinfo=timezone.utc) + assert props.modified == modified + assert props.application == application + assert props.title is None + assert props.custom_properties == {} + + +@pytest.mark.parametrize( + "file", + ["any_sheets.xlsx", "any_sheets.xlsb", "any_sheets.xls", "any_sheets.ods"], +) +def test_workbook_properties_from_filelike(file): + with open(PATH / file, "rb") as f: + props = CalamineWorkbook.from_filelike(BytesIO(f.read())).workbook_properties + + assert props == CalamineWorkbook.from_path(PATH / file).workbook_properties + assert props.creator == "Алена" + + +def test_workbook_properties_missing_fields(): + props = CalamineWorkbook.from_path(PATH / "base.xlsx").workbook_properties + + assert props.creator is None + assert props.last_modified_by is None + assert props.created == datetime(2021, 9, 17, 15, 27, 18, tzinfo=timezone.utc) + assert props.app_version == "16.0300" + + +def test_workbook_properties_repr(): + props = CalamineWorkbook.from_path(PATH / "base.xls").workbook_properties + + assert repr(props) == ( + "WorkbookProperties(" + "created=datetime.datetime(2021, 9, 17, 5, 27, 18, tzinfo=datetime.timezone.utc), " + "modified=datetime.datetime(2025, 4, 13, 18, 43, 16, tzinfo=datetime.timezone.utc), " + "revision='18', app_version='16.0000', " + "custom_properties={'AppVersion': '15.0000'})" + ) + + +def test_workbook_properties_closed(): + reader = CalamineWorkbook.from_path(PATH / "base.xlsx") + reader.close() + + with pytest.raises(WorkbookClosed): + reader.workbook_properties