From 5587829827b1528d9de97fdec530372879a68cac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:42:32 +0000 Subject: [PATCH 1/4] build(deps): bump calamine from 0.37.0 to 0.38.0 Bumps [calamine](https://github.com/tafia/calamine) from 0.37.0 to 0.38.0. - [Release notes](https://github.com/tafia/calamine/releases) - [Changelog](https://github.com/tafia/calamine/blob/master/Changelog.md) - [Commits](https://github.com/tafia/calamine/compare/v0.37.0...v0.38.0) --- updated-dependencies: - dependency-name: calamine dependency-version: 0.38.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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" From e13973e89c70177862e9583b9479708995b3568e Mon Sep 17 00:00:00 2001 From: Alex Ley Date: Sat, 14 Nov 2026 14:29:03 +0000 Subject: [PATCH 2/4] feat: expose workbook document properties Add CalamineWorkbook.workbook_properties, which returns a new WorkbookProperties class built from calamine's Sheets::workbook_properties() (calamine 0.38). It covers xlsx, xlsb, xls and ods, for workbooks opened from a path or a file-like object. The properties are read on access rather than when the workbook is opened, so opening a file costs nothing extra and a malformed property part can't break loading. Accessing them after close() raises WorkbookClosed. The workbook_properties.xlsx/.ods fixtures come from calamine's test suite. --- README.md | 13 ++ python/python_calamine/__init__.py | 2 + python/python_calamine/_python_calamine.pyi | 61 +++++++++ src/lib.rs | 3 +- src/types/mod.rs | 2 + src/types/workbook.rs | 18 ++- src/types/workbook_properties.rs | 106 ++++++++++++++++ tests/data/workbook_properties.ods | Bin 0 -> 11908 bytes tests/data/workbook_properties.xlsx | Bin 0 -> 2918 bytes tests/test_workbook_properties.py | 133 ++++++++++++++++++++ 10 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 src/types/workbook_properties.rs create mode 100644 tests/data/workbook_properties.ods create mode 100644 tests/data/workbook_properties.xlsx create mode 100644 tests/test_workbook_properties.py diff --git a/README.md b/README.md index 5854e41..8b25e40 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,19 @@ 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.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..1490be2 100644 --- a/python/python_calamine/_python_calamine.pyi +++ b/python/python_calamine/_python_calamine.pyi @@ -174,6 +174,55 @@ 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`. Dates are returned as the text + stored in the file (usually ISO 8601), not parsed into `datetime`. + """ + + creator: str | None + """Creator (author) of the workbook.""" + last_modified_by: str | None + """User who last saved the workbook.""" + created: str | None + """Creation date.""" + modified: str | 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 +233,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 from the file + each time this attribute is accessed. + + Raises: + WorkbookClosed: If workbook already closed. + """ + @classmethod def from_object( cls, path_or_filelike: str | os.PathLike | ReadBuffer, load_tables: bool = False @@ -309,6 +369,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..eb97515 --- /dev/null +++ b/src/types/workbook_properties.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeMap; + +use pyo3::prelude::*; + +/// 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), + ("last_modified_by", &self.last_modified_by), + ("created", &self.created), + ("modified", &self.modified), + ("title", &self.title), + ("subject", &self.subject), + ("description", &self.description), + ("keywords", &self.keywords), + ("category", &self.category), + ("content_status", &self.content_status), + ("revision", &self.revision), + ("version", &self.version), + ("application", &self.application), + ("app_version", &self.app_version), + ("company", &self.company), + ("template", &self.template), + ("manager", &self.manager), + ]; + let mut parts = Vec::new(); + for (name, value) in fields { + if let Some(value) = value { + parts.push(format!("{name}={}", value.into_pyobject(py)?.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, + modified: props.modified, + 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 0000000000000000000000000000000000000000..6d072c3ff83d00a6577efe26b0535c0a76eca971 GIT binary patch literal 11908 zcmb7q1z23kwlxq4F2RFC2pZfic+lYP);QfjHxS&N;4Z=4-2w!H6A13^?vkL7+;Yi#f8Rzh#1P`}|F9$dO&cw2 z49x&wCJ_sWjiD|0-*gfF4prBA5jcp8p7N!6&gwfu_bTDQR=*x`Z zcf{`x7@8Jhf`hZC>2I`5|HKiX0mpQjjJenv9;2q1Q?#2tzE<{zZ7IAa<-m}rH?*pt zU4`A4_GQ1MdF>88Z!=?K?i}yzbR9iuGVzI}Z*DqWlM|4RM?c=5feXd@YjqaQyW}%| z`HvLs6Wn%^DBU&T2#6%_MJz7X-3WMwRuN6r6(M^WkyhIL9Ufpuh2 z-a_vYW&8agzC=xOGF@!(XNo-SnFp&q1}$U0S^Nga^x6%}KWyDIetmepT3+KQW-T<7ZFU{GRdY(JDpCH^=l|#~ z!&Yi?au_HmC)ode`T!6^#&?_=Laj4@*b9NmV{h|FVdS&fa?=#dY9z%7Lpx7Fsk ztVwKgG~t`OTw3dtst?6&A7nfG@{tXTuXt`u_>H_l$*?8ymIbavM&hQ(!Q@H(pzdgA z{raFZsNM&9Gx-8ufsw~6J4#*^7rn<}p3tA8w!=sq-JsG~!zp7E_V&xFBIR^y>00`b z1Ab;17wd#+Ah#%)p=1?}Jea+jns)r&hcu$+Tq7LuyM3dWG31SC{lnZi``q*-q*Y7u zd5rUQrfU^}8HO~sFrWiPAs=1F*hPj*5%jxVN+CiP;tf*Yx@6fB z!RgC`NzP1G2l9jl8+Mns<45r@@A?4VKt$QrMc1F6bK{`;sGl&ekqmuUHZjPzGo6z0 zQj|g$X)ngGZ3EQGOeaZIq8I4U6~jlv`zpchxyB)RoLK&b}NV(5MqSD-1MiBwNyEMhq+^^_R#t zYlz$Xh6NdKSP|)7m!m1d7dzU{bR`vlI2Jji-9*^yFH3!T_Kql3Uga{f0lX!8ZzY*Q zM>TYQeASA;K2QN^DF4na)*Ac$rq&-~o zi84=8m%^sumAjl;ip=_e3()eQq}_$%P-fkwJW44*!|W$zWwV z`*pDIUOVYk+jN5O71u*URw&%b@qGRwT?b3O@;%Y&xKE!QuROk-A%>llan~x3IIYwc zONcG+)?z9Z_fY;2*VdPpbJp`RV=QeXuXyA)!>jemzfP{P^}}r4SHCde?2(`EtJG9l zMWRYd#PnQmQVv}4WBp)!qvxGkqCqp__O+cH*C%)A*jhv3GCs&o89-^T8hXycoQ7Ob zCy@>N>I7qz?pb@|2Q)qWW|`M{YY80~vjK#54z zCbRLWi;E};yhKmIVD#x9pW10%<1#r4u9`X9D zmfvXG!w@i#Hfo&TcLZ=W;ej?Kb1W+y?u};>qN6f*$j+LE0an_O9h^` zVIipPuuc+(jN!gn%JRz-WIwJFesZZA=4Xo@|NWWwHib@IkfRJw+?NvcwtLeB_u>9Y zG4^MNJR97+VGzL;3G8gB&F7LD6zO99u0fy2o4Ty_*xoCY;8b`!6>K;_NXombM_JU{ zdys38RE3k2)r|UKtz5ZL;CWnp@R9JP;~ilN;n?Ja{83=1h5V`N+ZcsCj_kwlS(eGT$e7oNE$_|xCNKXe2Ch!*>(tT;IoM+Z z%*4+a{rEl1*4UY zeA>@WsL%D@+qHra;&<>eb8H8vsb`$epZU|@E?g}=y$#$mno*1vGKyI`NY=In)6>-2 zHYYJjZv~U(#iItNIVxXo&!C(<&+b^=?Zg@7EqQfE#fL` zET8BXZI%Gzqs9fI&j*dol*dE@!^13Jw6o>1--joU@A0N28yUow>R0lFM|THau$Qse zsP{I`xtBO_fS0HZ%|kzsosu!rX`p`En~_6Tvua2W$gi27qf@%9*xg=Nt93d>)YPmS z08(XBLahuLR!^^{no4EyNi=ANGn3%}sX5qR)V{O{7B5pn^cSz6`Ve#W=D+n7!-37<-I|X4&+&Th}1sJEGkw%_yD%WvD&otP@{4%I9o42d*YzuQ=mIVfz zI6_G$OJ=dlp6RD_O2kYhb^?M(se)w^1cfjYtme}?`7XbobhWw{Ch&LcYfXeP$;~IY z6vGqj;Lf29UVHSdzrPTvndmZiIo@3*+lZVzUmFP6xzRp*sRhZte_I?S9C!zz-mZ_u zX}1Th!`@-;K+ua@KOtU^s~&W7Iw!ukHNT`QZkt&tYMb$X%#*wF&FZPm`HduFX`_7T z%xi7hN4d0Udd;my1H`!n9DAcqXqz?mZKjRVX<-7gm*YjcbXf6!?2lycp!J7QpYbviwxo+B6Fy+@A6Lp0#J3}PE2iMWhWAn;x z*-4%8-;TdPpV6oj=!q3qWh-YvjMsUkzHOCm*-NA9tt1CGD5bXie1a%5&CyU9^#Z0W zUq3?FK;}rzpzz8ZujNQMyhUVqWUMup=qiiBe^;)_pd5bvjZM5RNah>Va z9qC9al2Yv=&1gXM2~Jia_X}RcZIc-uhO3&i=&JS+qwa}(dL=!g*(v!c7}?<2tbPGYdTJiRRpmdCtac+ z&ASSpX1DigJn$7@s_j%#9O}njx0uscUU#N$^(C?)qD~12ew`IF?QZI#AEKL^3uaf+ z>=etMvF3VzA1v4US_DtjhMx(vmSl0J-?`fQQb#%av(yEfactUaBzyAu-RekEUskY8 zZi9a2RrZ$mbx%>NzD{L045#R}W*c4gh21j;%$as^m+cZ-y||$}%P9fKVkU66{H#HF z>!i@(a-&Bl>MQpFP;j3#xO8Lj*#JeKiU$|ZTN(dzoUOVi)v1fGB%Que%dH8Z?dB_R ziln}CMC(<5@rje0Q1@ik(nB`ulqv*C$yk$8Z079L8sfo2IYW;}X;5g*LG&T*>l-#` zC4RLi20a+x2J2Ni#xSq=*r-zgVD-WPC90(7898p`^d@^$Px3%$@MMFkGRFk74+jAvaJ3SGl4H`nDNpX8l9i8;m*NdRTI$eoIYe-xOMzDKR-GE;f z<8zG}nRIg>{tj=Q9hPWZ$rq=J_v3CjlxNubB135LfGws&p;cQ`n|3MlBCWAQF0~0W z`uB71ohEB8oX>4~OWa$;THir`3V!3!==$h6?uL;8oZ% z`#W3VIAlmSwGh`dIWhxolQ>*)llSG?qIQlvol z=zrY=ZvhA$XrDqsJt2pJLVnx?8-sw5A7P5eUGRjKHfW9$+heI5gWUkc$}ksfGiaw+ zgC9^z8$$KuQf(L$fSfDlo#XcXiZAD|YMt4FsKjeUgTMYr=iNIRlZ!)qWh?oHAPsx7 z=IlzFs7^ECSnH#GPm%{)+{@+*CVJd}DEW_G-!)}1cfP%fI-Y{r5qn2V4fCO5T$=U6 zJCsn#cZlJ^$2mupv6Q(bPK;|7&EK1tI#m{D_-K{UGe55|^U6wufkxKUbIijjC4!|7 z8PH*7djy_G zj$pyC)xh9==I38MUW~);&KF`1RUCTX@^HRSeWu4?zbR?tWQYm;q!DZ_h?{?Wah#7^EdnTc~zf~USj1(9-zK_`x3ebUYQ{T zZ?}rJETmmU=Y$jR-1y%A(@bZn zt>{zrS(6twmg_VHSXJKGK43iefpmJ{orz_`G`Q3!>FFjM8>-fX57(yD_?Ndkl%ZUH zgmVEyU_JQE#x*joLQXmrBi$W0ZI0DQfK=qw@Q2o%&X4f{)#f^KieEP*F?ItKU`l7g5Px77eK4kUS$ubg%U>N?Vi`9QP6r}G z(8cZ?9txdj6V)WH44%D?P+EJF2(BkmM(XkcVa3MS6bI=W>m<(Z*>m>RBB?O=F~8s@ zzYNC0NDsN7TSA+tiW8*Iak_LRG30)?l0ox)HR!|cMP5M`w|2D9b%uASD5c+f#^ML; z_Q{EtW&+D%=lIvC`Lef*D+73_U$}TiC9fmD3q0MnJmZHxV2hC@tbToaQGao4h@qV@ z0y{4yiP?tM6k!s9^%0Kei66&VN`{-C`>T?#>jv%s7P+U;j^Fg?Tg(eqLmg73n5kbZ zjkpw*X0&OCfBRAzhSTc+ACR43YG;%XJt$7?A1~ez_Ng|~nNaT7$xPJXtB(kvX<*+@0MunkEQOL>fY57Kp~GVbReqWoajobs$H#cc>%2 z$+{|w5tz#w!s3uT8g14|RNr~mFwljchtQFR4GNBtaq~_`54k*`y1lG;A=X3;PdK!p zEQ=8wyQ=J6I$UBZho7I82$vCOF994f+!BpQHVp!!-JhLL)*;0=^*%=*PtCDWB`}&M zY>uu^%%hd5>o=(^dV0>GmAPq*x_oZGg4OOOM3BbwE$3@>u1!`WV&_f|fFK6?WF=AO z7-_e5s#v@J(z1t@5yjT|2~b>$st=t8qti@^IR`4u`P8I;Gba6`Y|6e`+j+`Mn@_L9 z)7VK0TH3MvW#Q$pMT!fVx^!CXN72QRZzV-i6*cX*>-pa{?aHK$Az)u}+}*;(`dZZZ zdvhB0v1!%In@$O%Z(QSUZ%qk%&IyE<`Fc1LpC3X_sEz|SKa%>Dqv!8slM=uV4!4nr zuyY&Y`Jp1qUEpx|8!}>8nrM}u_DQzj^_c^uFbbb$*_)rTpuF>;gz3%^$UtYoVdmfH zoHf9I=`DH^pfIwc_=JL5Tj49Qy$aLEiQ2}9J&ci0J^UCK=ZRPsPVI?375LvmYE2;% zlrs%(pWiQx9w8DaB&APmf{l_p9H_})d~{@ecp6bN6E|H;b_r!z0Gm65ShK6%mRwU@ zv=g^)Ml(nQjX@LJl`KO`4tw}TaycL$C@DFBcH1KqL% zpTMTRJ)e@0(G=Sqyf~V6`_aC`lmUB{dP1wc#y^ZoatN4z&bQb@!MeYJ* zA@SjQZwg#}GO%aUnyb_8Fex^JW>1df*%^g#YGR|N(WaoWQ9RSzFTH89W?c1O`_X%K zXvLAMu^H~wX)X8QTkB3|R!u#~BN7Y-%anMSDyDU4FX&{1!5=I?dfA}9E8u{MmtXKS z)OdjZ>xxn`xGYo)2L;tn@kcNd41ho^fM$OLRhktvOHe9ij~bjGso`UK1)fRhO;v$F(#S4+b7{-a+KgAoB8S3tMr4O!3SHLD4rp zw^DRELaPdRQQzGS*~b|{$mzvx3nMcqJQb@-)aPU?2`KiA0TsyPavEKkCINmz_>o9( zD3wUiBqlhADh23$Y9*&0ke8};t<@bt9~bd(Q*YvoSVFSoM<%EVzJyj~(R7{fIMU>v z?|p)GwbA5lpp=^Gy11Ac*_p@;urW;{sN?b6N-h?0*b6*3FLp|4EJ05Q5@k9W?Amn9 z+HWP3EqnDWtktEoLjKV8ITnkFalumzltTUK*Xxx`BorOUsyk)hUi1pWc=)h=KtIKA zbgM*fDv4c>8PUcV#B*gx0Oh8Lt}qEK>rP8P-UQ9by)DBViisG$mE+qN;cEYhY8rQnL+6+t+}bxh`HRoJ8Khe>g=QZ5 z<)ApA(VP^g3PpG);SJDYrtdNCHYIJ1g|kh)9LECQ(gt{87x5#kM70YOt;lYpt%O{4 z7IA)Nckux)cWO47LRF&xT!F3f?=>UFh#bv{@@E)cxR;Gh+l8BtK(#1#Ky#*g8Cm<1 zfg7Vi0}__s#kWCza@gaYO?R{C235zz!VRM18q67mcl);^G0RT0Uy*s!BjpwLu*VFT zbfvk}bCtd?+wb^JCHpFCc_9vnMr^ZN3NMGy@#DR;pm>%<1%J-D=dZJ`lkv=zv+Ijj z^WKeSY)%*`co&))$FDaWHJWlrmNyN<#Ksh38NCn1E>OQ1YO_wprL7M0tB;v;{` z&-R=MU0>e|irXjFVfQ<<3F6jQy($iV!X`RVuOLgT?>bmri>Q*|1B!2P+=JXj z9Oc^6VW&KOs$D2^fk1Fy<+p2nYzg#~$zt*>=6uVIXHO|nTRbF*Nb-2t@x%plhi^2I z%@0)Sh>HRs6M3b1_}3^kUM@aHD?t6DFsylGbo`HvW#lkE4GQm{mM(>!!ti~t7l?{; z*%FVVj(#=AuuvcLT&^6l#29CmJ&CtO#p}4a+jh+rIoQ5En64GWBvU|J-7dqon=Cy= zTu#YcXEL`Jl8q@x;ZRaUd~u!9f~u;E-#3EufFdvKq8yVK&{Pzoh0+b^ORkBt8w)!q z?y{z+T^sV{A(WHd8w6EICA3OeXlR2=r+}h(*Mt!+ z17X&73$6@IHlQ4WHT!kB^UTONm-6I&PSAv+@{9PHi%hPj=P2++T2{qRq;|pyX3Z&f zHVZ08yOT$WrJF7>CI}95sP?rW2hriKOdd<8c~@OTfsUC&gV+S$I+)?KITD$$1^UdX~F#7 z$BU_gtcv*K6&V>B3k!>okdTs+^3|(XY;0`&{QRP#qSDgR%F4>x+SFF1z z%leSw3fb7L z`3;e;`(oRq$a8G58-HS=CLz4Jm$OWV6IaW`{loseMc;bPaO?EXXH^Lp#3$4)TJD|$M!xaZ^+a5SmI@9YUg|f8yVQO>4;|y%r=i=U*&!Nb zLfc`@LpivNDa%ftW<)Q)=smkzCE)F6s^#A2j4h0I{g}94?@>P|nb&*d!O|PCE2$Ai7kICycIqeq@Ee9b7! z&1S*fj@O+tT$tr`ZJ=FvwtD#9GMbf5eSX{cYDq_L8S!anUayHQ_W<(;zs@|_{a)sQ z>>N&463Wn|b4~E}g0g_|nA6@{QurC8)(Ia2Z@6|CWFI)(wu4W1crv~ERf$z9RC0p~ zJKaqnQoxQX;b_`)*P*mD|x=YID-kvx(lg|i{ z{dE`qDzd)W<6G&%=H~Oo9PyuGk}}yzJP(kl?^VZa0dFk9ALzNxy~m^Xd*;yjUe8bf zO+1cL3DO)mEXptZ$(h~rW89QdWcJG-SkJGTLh%^H*^Te23aFEa=DRX`2d$5aG^yaZ zwi@W4k6s^A#_Z%5D=Vs=r;67N%r8@Qk4a2jZ~>>WUXz^aa-Uqdq-|kX=50#ROOp8h$cHb6>;l zxgG=sYD%$3{BpR@3fH{&mZy8lvNtWxFu|xW$wBq}5N|4{v31_#O5O$|slaB#DX@5C zgUw|07NQPfQRWt~G>j@{9{Rj}`%qz9-fpOjyhAk0Bu#8g4Pri+)_1huvuZtQM2{T9H8ysab&57u+ALppyxF`_i*vASD~T$?7g%RRrv0YnyaS@b@3AI@bp^cW@pN{Gi5rm|;Z?g58=sEM)!doX zTO_nXu00};)Q-@z7xL1YJAm_!rwdori`~F6xfZSuK*Mwt)BUlEHp>*_bg{IB>G%0V zy0S0hbz{%7rD6&j>nsMo?fUy_)CjKm;+jp(J&YPK;)jVAWmLFrNz*0zpOSK16+ zY8g4L%DKl5Xr!Vz%8MyYA5gS3PbzTcE>wpqH#5fEwd#0IrK-9mI7t&5%1$NUGE3Mc zUsTril|7K(kkT7lO2Hz(LcrC)cvV66z=SJ7TP^IEPLZZ36C#|@vt#wy^J^|aEuP(_ z!Hn4;UZI!Nn^%2r(JS{Y zR1L{wajI;H+Q{q3&wxFS;`Q~?y@pL;OW8TsdwLJ+-2(30WN^+N{h=PUUHqCY#R<#Im>% zaSJ|iPv(<3C1*!~8)*Iyn& z-*2kAvr0*28r5VT6{8is~2 z2mE?pB~jw0t6T=n@&@<8G*dj=N&JOkDBJsV{xy{wf3CFbdbe_aR+MsO+8ljZ&&lY1 z&*GO!FI?&f=qE2k>@sTaQs!x`ZMTr?Z^9TQGE&y-;y}^J8cIjtKLX)gvn{DUnm=2 zDXIh2S3(wek;ifT_woVzV-5NBJpD1sKT7}nm!D7DpB&*&0FR}=1NgBz{jo&- zwUq2v9sgRuewP046a9Zmf5-2KEB=+iAJl$K3I7M+ANc(i;L*u{3-Ad4TYkT5;3KvF zir?=V_)i1yPyBv%;@{c-H~fCvz{jEeZ}|Pw0IUyrLdjzlwP@8w`#x@Op`^s+#L7er ze17iK|By)q1>ZfeJgUlGJ<6nT9{=@LTt%2cO70EQzvX3*LH^R1x4_S27;mn4{JZK{ za}M%E?5YaJv2x%uOl>2Ny&fYib9mZzt3@H$GBYP^ACFjq1`o%ZcM(`LBT!v8tkj_E z^tAU0E?XXo1gC+`v*$>q-eUFyDjP(3do_kUS?6>!l!_OTl(nPqLI-p6Poi(NnR;^e ztf`KN7|pj<*zJs|sUuIG*JdfXBl;+NnFlg#H2B;7{WUcC^D+}0yh`-x&gbKe6EuW zk^Wv8_;+<5)3DXePkE6(y-?#xio9@W#KC>3?&3b26u|AOj794fW4b&>!RRBY8dc{4ZhX z->rU>{Y*dJqJAbX=tnWqe~^CuweFu*Kdyg83Hc4mFDdB%FUqIip!|}C{xixCspik* z1%3H=`*$(xpQWPzjPpY*`ZIY!Gd}j{-*Nsb9sOsdzcMoa4bq>br2mZbS4J7X!TGDS z^q-Ob%4qgCNPm`^{xi-GvFXp`1&#SAi~M^Z{U7P+Pq5#MPk({QKZ5;xu6UH9{&M2q z>o9*=dOW`5KPsaCrON!f)!$`zzpT0*qsG5Pcz?J1`$^?5yMBznoLm0g@b3ZXUxq|@ zj}`tYJpIe^_py#&mN$=%@K2GCUnswyhkv2u()`1$tRMsTcqR)4_3ZHjc$~fw>3;nB EA8)IjBme*a literal 0 HcmV?d00001 diff --git a/tests/data/workbook_properties.xlsx b/tests/data/workbook_properties.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5586399023daeb4804fe3de6068bf51bc1b91330 GIT binary patch literal 2918 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 Bse}Lk literal 0 HcmV?d00001 diff --git a/tests/test_workbook_properties.py b/tests/test_workbook_properties.py new file mode 100644 index 0000000..73b7b97 --- /dev/null +++ b/tests/test_workbook_properties.py @@ -0,0 +1,133 @@ +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 == "2024-01-15T08:30:00Z" + assert props.modified == "2024-06-20T14:22:00Z" + 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 == "2024-01-15T08:30:00" + assert props.modified == "2024-06-20T14:22:00.123456789" + 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", + "2023-07-28T16:31:17Z", + "Microsoft Excel", + ), + ( + "any_sheets.xlsb", + "2023-07-28T16:31:46Z", + "Microsoft Excel", + ), + ( + "any_sheets.xls", + "2023-07-28T16:31:33Z", + "Microsoft Excel", + ), + ( + "any_sheets.ods", + "2023-07-28T16:32:08Z", + "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 == "2023-07-28T16:28:06Z" + 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 == "2021-09-17T15:27:18Z" + 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='2021-09-17T05:27:18Z', " + "modified='2025-04-13T18:43:16Z', 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 From fac7c62c228d1834830288096719e8e4b7211b30 Mon Sep 17 00:00:00 2001 From: Alex Ley Date: Sat, 14 Nov 2026 20:59:55 +0000 Subject: [PATCH 3/4] docs: don't promise that workbook_properties rereads the file xlsx and xlsb cache the properties after the first read. --- python/python_calamine/_python_calamine.pyi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/python_calamine/_python_calamine.pyi b/python/python_calamine/_python_calamine.pyi index 1490be2..97a6066 100644 --- a/python/python_calamine/_python_calamine.pyi +++ b/python/python_calamine/_python_calamine.pyi @@ -237,8 +237,8 @@ class CalamineWorkbook: 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 from the file - each time this attribute is accessed. + 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. From 9203b4896a405df3ad9439a09068e9e72b256ad7 Mon Sep 17 00:00:00 2001 From: Alex Ley Date: Sat, 14 Nov 2026 20:59:55 +0000 Subject: [PATCH 4/4] feat: return created and modified properties as datetime Dates with a UTC offset (xlsx, xlsb and xls, and ods files saved by Excel) are timezone-aware. ods files saved by LibreOffice store no offset, so their dates are naive. A date in another format is None. --- README.md | 2 + python/python_calamine/_python_calamine.pyi | 10 +-- src/types/workbook_properties.rs | 74 ++++++++++++++------- tests/test_workbook_properties.py | 27 ++++---- 4 files changed, 74 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8b25e40..c8c0e0b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ 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"} ``` diff --git a/python/python_calamine/_python_calamine.pyi b/python/python_calamine/_python_calamine.pyi index 97a6066..4273b54 100644 --- a/python/python_calamine/_python_calamine.pyi +++ b/python/python_calamine/_python_calamine.pyi @@ -178,17 +178,19 @@ class CalamineTable: class WorkbookProperties: """Document properties of a workbook. - Fields that the file doesn't store are `None`. Dates are returned as the text - stored in the file (usually ISO 8601), not parsed into `datetime`. + 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: str | None + created: datetime.datetime | None """Creation date.""" - modified: str | None + modified: datetime.datetime | None """Last modification date.""" title: str | None """Title.""" diff --git a/src/types/workbook_properties.rs b/src/types/workbook_properties.rs index eb97515..b530fa4 100644 --- a/src/types/workbook_properties.rs +++ b/src/types/workbook_properties.rs @@ -1,7 +1,29 @@ 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)] @@ -11,9 +33,9 @@ pub struct WorkbookProperties { #[pyo3(get)] last_modified_by: Option, #[pyo3(get)] - created: Option, + created: Option, #[pyo3(get)] - modified: Option, + modified: Option, #[pyo3(get)] title: Option, #[pyo3(get)] @@ -48,28 +70,34 @@ pub struct WorkbookProperties { impl WorkbookProperties { fn __repr__(&self, py: Python<'_>) -> PyResult { let fields = [ - ("creator", &self.creator), - ("last_modified_by", &self.last_modified_by), - ("created", &self.created), - ("modified", &self.modified), - ("title", &self.title), - ("subject", &self.subject), - ("description", &self.description), - ("keywords", &self.keywords), - ("category", &self.category), - ("content_status", &self.content_status), - ("revision", &self.revision), - ("version", &self.version), - ("application", &self.application), - ("app_version", &self.app_version), - ("company", &self.company), - ("template", &self.template), - ("manager", &self.manager), + ("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 let Some(value) = value { - parts.push(format!("{name}={}", value.into_pyobject(py)?.repr()?)); + if !value.is_none() { + parts.push(format!("{name}={}", value.repr()?)); } } if !self.custom_properties.is_empty() { @@ -85,8 +113,8 @@ impl From for WorkbookProperties { WorkbookProperties { creator: props.creator, last_modified_by: props.last_modified_by, - created: props.created, - modified: props.modified, + 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, diff --git a/tests/test_workbook_properties.py b/tests/test_workbook_properties.py index 73b7b97..96704df 100644 --- a/tests/test_workbook_properties.py +++ b/tests/test_workbook_properties.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from io import BytesIO from pathlib import Path @@ -15,8 +16,8 @@ def test_xlsx_workbook_properties(): assert isinstance(props, WorkbookProperties) assert props.creator == "Test Creator" assert props.last_modified_by == "Last Modifier" - assert props.created == "2024-01-15T08:30:00Z" - assert props.modified == "2024-06-20T14:22:00Z" + 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" @@ -40,8 +41,8 @@ def test_ods_workbook_properties(): assert props.creator == "Test Creator" assert props.last_modified_by == "Last Modifier" - assert props.created == "2024-01-15T08:30:00" - assert props.modified == "2024-06-20T14:22:00.123456789" + 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" @@ -62,22 +63,22 @@ def test_ods_workbook_properties(): [ ( "any_sheets.xlsx", - "2023-07-28T16:31:17Z", + datetime(2023, 7, 28, 16, 31, 17, tzinfo=timezone.utc), "Microsoft Excel", ), ( "any_sheets.xlsb", - "2023-07-28T16:31:46Z", + datetime(2023, 7, 28, 16, 31, 46, tzinfo=timezone.utc), "Microsoft Excel", ), ( "any_sheets.xls", - "2023-07-28T16:31:33Z", + datetime(2023, 7, 28, 16, 31, 33, tzinfo=timezone.utc), "Microsoft Excel", ), ( "any_sheets.ods", - "2023-07-28T16:32:08Z", + datetime(2023, 7, 28, 16, 32, 8, tzinfo=timezone.utc), "MicrosoftOffice/16.0 MicrosoftExcel/CalculationVersion-14420", ), ], @@ -87,7 +88,7 @@ def test_workbook_properties_all_formats(file, modified, application): assert props.creator == "Алена" assert props.last_modified_by == "Алена" - assert props.created == "2023-07-28T16:28:06Z" + 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 @@ -111,7 +112,7 @@ def test_workbook_properties_missing_fields(): assert props.creator is None assert props.last_modified_by is None - assert props.created == "2021-09-17T15:27:18Z" + assert props.created == datetime(2021, 9, 17, 15, 27, 18, tzinfo=timezone.utc) assert props.app_version == "16.0300" @@ -119,8 +120,10 @@ def test_workbook_properties_repr(): props = CalamineWorkbook.from_path(PATH / "base.xls").workbook_properties assert repr(props) == ( - "WorkbookProperties(created='2021-09-17T05:27:18Z', " - "modified='2025-04-13T18:43:16Z', revision='18', app_version='16.0000', " + "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'})" )