Skip to content

Commit e706e13

Browse files
author
Arturo R Montesinos
committed
feat: add odfpy-based workbook loader
1 parent 0b0ccf1 commit e706e13

File tree

6 files changed

+192
-2
lines changed

6 files changed

+192
-2
lines changed

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ name = "odsview-cli-python"
1717
version = "0.0.1"
1818
description = "Read-only terminal viewer for .ods (LibreOffice Calc) files"
1919
requires-python = ">=3.9"
20-
dependencies = []
20+
dependencies = [
21+
"odfpy>=1.4.1",
22+
]
2123

2224
[project.scripts]
2325
odsview = "odsview.cli:main"

src/odsview/container.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Container-level helpers for opening ODS workbooks.
2+
3+
This module is responsible for working at the ZIP/container level and
4+
provides a narrow interface for the rest of the application. At this
5+
stage we only support **plain (unencrypted)** `.ods` files.
6+
7+
Encryption support will be added in future milestones, as described in
8+
ADR-0002.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import zipfile
14+
from pathlib import Path
15+
from typing import BinaryIO
16+
17+
18+
class ODSContainerError(Exception):
19+
"""Base exception for container-related errors."""
20+
21+
22+
class ODSFileNotFoundError(ODSContainerError):
23+
"""Raised when the requested ODS file does not exist."""
24+
25+
26+
class ODSEncryptedError(ODSContainerError):
27+
"""Raised when the ODS file appears to be encrypted.
28+
29+
Encryption is not yet supported; this exception allows the caller to
30+
surface a clear, testable error message.
31+
"""
32+
33+
34+
def open_ods_container(path: str | Path) -> zipfile.ZipFile:
35+
"""Open an ODS file as a ZIP container.
36+
37+
Parameters
38+
----------
39+
path:
40+
Path to the ODS file on disk.
41+
42+
Returns
43+
-------
44+
zipfile.ZipFile
45+
An open ZipFile object. The caller is responsible for closing it.
46+
47+
Raises
48+
------
49+
ODSFileNotFoundError
50+
If the path does not exist or is not a file.
51+
ODSContainerError
52+
If the file is not a valid ZIP/ODS container.
53+
ODSEncryptedError
54+
Reserved for future use once encryption detection is implemented.
55+
"""
56+
57+
p = Path(path)
58+
if not p.is_file():
59+
raise ODSFileNotFoundError(f"ODS file not found: {p}")
60+
61+
try:
62+
zf = zipfile.ZipFile(p)
63+
except Exception as exc: # pragma: no cover - zipfile error paths
64+
raise ODSContainerError(f"Failed to open ODS container: {p}") from exc
65+
66+
# TODO: In a future milestone, inspect META-INF/manifest.xml here to
67+
# decide whether the content is encrypted and raise ODSEncryptedError
68+
# if necessary.
69+
70+
return zf
71+
72+
73+
def open_content_xml(zf: zipfile.ZipFile) -> BinaryIO:
74+
"""Return a file-like object for `content.xml` inside the ODS ZIP.
75+
76+
The caller is responsible for closing both the returned file object
77+
(if applicable) and the underlying ZipFile.
78+
"""
79+
80+
try:
81+
return zf.open("content.xml")
82+
except KeyError as exc: # pragma: no cover - malformed ODS
83+
raise ODSContainerError("ODS container is missing content.xml") from exc

src/odsview/workbook.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Workbook abstraction built on top of odfpy.
2+
3+
This module exposes a small, testable interface over an ODS workbook
4+
for the rest of the application. It delegates container work to
5+
:mod:`odsview.container`.
6+
7+
At this milestone we support only plain (unencrypted) `.ods` files and a
8+
minimal feature set:
9+
10+
- Open an ODS file.
11+
- List sheet names.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from dataclasses import dataclass
17+
from pathlib import Path
18+
from typing import Iterable, List
19+
20+
from odf.opendocument import load
21+
from odf.table import Table
22+
23+
from .container import open_ods_container
24+
25+
26+
@dataclass
27+
class SheetInfo:
28+
"""Lightweight description of a sheet in a workbook."""
29+
30+
name: str
31+
32+
33+
@dataclass
34+
class Workbook:
35+
"""Simple wrapper around an ODS document.
36+
37+
For now this only exposes sheet metadata. More operations (row
38+
iteration, cell access, etc.) can be added as milestones progress.
39+
"""
40+
41+
path: Path
42+
_odf_doc: object
43+
44+
@property
45+
def sheets(self) -> List[SheetInfo]:
46+
"""Return a list of sheets in the workbook.
47+
48+
The order matches the order in the underlying ODS document.
49+
"""
50+
51+
result: List[SheetInfo] = []
52+
for table in self._odf_doc.getElementsByType(Table): # type: ignore[no-any-return]
53+
name = table.getAttribute("name") or ""
54+
result.append(SheetInfo(name=name))
55+
return result
56+
57+
58+
def load_workbook(path: str | Path) -> Workbook:
59+
"""Load a workbook from a `.ods` file.
60+
61+
This function uses :mod:`odsview.container` to open the file as a
62+
ZIP container, then passes the path to :func:`odf.opendocument.load`.
63+
64+
Encryption detection/handling will be added to the container layer in
65+
future milestones.
66+
"""
67+
68+
p = Path(path)
69+
# Ensure the file exists and is a valid container first; this will
70+
# raise well-typed container errors instead of generic exceptions.
71+
with open_ods_container(p):
72+
odf_doc = load(str(p))
73+
return Workbook(path=p, _odf_doc=odf_doc)

tests/fixtures/simple.ods

7.81 KB
Binary file not shown.

tests/test_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Smoke tests for the odsview CLI skeleton."""
1+
"""Smoke tests for the odsview CLI skeleton and basic loading."""
22

33
from __future__ import annotations
44

tests/test_workbook.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Tests for the odsview workbook and container layers.
2+
3+
These tests currently exercise plain (unencrypted) `.ods` handling at a
4+
very shallow level, using a tiny fixture ODS file.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from pathlib import Path
10+
11+
from odsview.workbook import Workbook, SheetInfo, load_workbook
12+
13+
14+
def test_load_workbook_and_list_sheets(tmp_path: Path) -> None:
15+
"""Smoke test that we can call load_workbook on a valid file.
16+
17+
This test expects a small fixture file committed under
18+
`tests/fixtures/simple.ods`. It asserts that at least one sheet is
19+
present and that sheet names are non-empty strings.
20+
"""
21+
22+
project_root = Path(__file__).resolve().parents[1]
23+
fixture = project_root / "tests" / "fixtures" / "simple.ods"
24+
assert fixture.is_file(), "Missing test fixture: tests/fixtures/simple.ods"
25+
26+
wb = load_workbook(fixture)
27+
assert isinstance(wb, Workbook)
28+
29+
sheets = wb.sheets
30+
assert sheets, "Expected at least one sheet in the workbook"
31+
assert all(isinstance(s, SheetInfo) for s in sheets)
32+
assert all(s.name for s in sheets)

0 commit comments

Comments
 (0)