Skip to content

Commit 48e522c

Browse files
author
Arturo R Montesinos
committed
Implement range-based table view and fixtures
1 parent d43a426 commit 48e522c

File tree

8 files changed

+344
-10
lines changed

8 files changed

+344
-10
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,8 @@ This project is in an **early experimental** phase.
2525
`.ods` files and enumerate sheet names.
2626
- The CLI currently supports:
2727
- `--list-sheets FILE.ods` → list sheet names (ADR-0003).
28+
- A simple range-based table view for cell contents (ADR-0004).
2829
- Help/usage output when invoked without arguments.
29-
- A range-based table view mode (ADR-0004) is specified but **not yet
30-
implemented**; the README and `AI_CURATOR_RECIPE.md` describe this as
31-
upcoming work.
3230

3331
## Quickstart (local development)
3432

@@ -47,7 +45,9 @@ To see the current CLI behavior:
4745
```bash
4846
odsview --help
4947
odsview --list-sheets tests/fixtures/simple.ods
50-
odsview path/to/file.ods # viewing cell contents is not yet implemented
48+
odsview tests/fixtures/table_small.ods
49+
odsview --sheet Sheet2 tests/fixtures/table_small.ods
50+
odsview --range A1:C3 tests/fixtures/table_small.ods
5151
```
5252

5353
## Curation & Guardrails

src/odsview/cli.py

Lines changed: 142 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77

88
import argparse
99
import sys
10+
from dataclasses import dataclass
1011
from pathlib import Path
11-
from typing import Sequence
12+
from typing import Sequence, Tuple
1213

13-
from .workbook import load_workbook
14+
from .workbook import Workbook, load_workbook
15+
from .tableview import ParsedRange as TVParsedRange, iter_range as tv_iter_range
1416

1517

1618
def build_parser() -> argparse.ArgumentParser:
@@ -31,9 +33,116 @@ def build_parser() -> argparse.ArgumentParser:
3133
action="store_true",
3234
help="List sheet names for the given .ods file and exit",
3335
)
36+
parser.add_argument(
37+
"--sheet",
38+
metavar="NAME",
39+
help="Sheet name to view (defaults to first sheet)",
40+
)
41+
parser.add_argument(
42+
"--range",
43+
metavar="RANGE",
44+
help="Cell range to view (e.g. A1:E10). Defaults to A1:H10 if omitted",
45+
)
3446
return parser
3547

3648

49+
def _col_label_to_index(label: str) -> int:
50+
"""Convert a column label like 'A' or 'C' to a 1-based index.
51+
52+
This initial implementation supports A-Z only as required for the
53+
small fixtures; it is structured so that multi-letter columns can be
54+
added later without changing the public CLI contract.
55+
"""
56+
57+
if not label.isalpha() or len(label) != 1:
58+
raise ValueError(f"unsupported column label: {label!r}")
59+
return ord(label.upper()) - ord("A") + 1
60+
61+
62+
def _parse_cell_ref(ref: str) -> Tuple[int, int]:
63+
"""Parse a minimal cell reference like 'A1' into (row, col)."""
64+
65+
if not ref:
66+
raise ValueError("empty cell reference")
67+
68+
col_part = ""
69+
row_part = ""
70+
for ch in ref:
71+
if ch.isalpha():
72+
if row_part:
73+
raise ValueError(f"invalid cell reference: {ref!r}")
74+
col_part += ch
75+
elif ch.isdigit():
76+
row_part += ch
77+
else:
78+
raise ValueError(f"invalid character in cell reference: {ref!r}")
79+
80+
if not col_part or not row_part:
81+
raise ValueError(f"invalid cell reference: {ref!r}")
82+
83+
col = _col_label_to_index(col_part)
84+
row = int(row_part)
85+
if row < 1:
86+
raise ValueError(f"row index must be >= 1 in {ref!r}")
87+
return row, col
88+
89+
90+
def _parse_range_spec(spec: str | None) -> TVParsedRange:
91+
"""Parse a RANGE argument like 'A1:E10' into coordinates.
92+
93+
If ``spec`` is ``None``, the default window A1:H10 is used.
94+
A single-cell reference like 'A1' is interpreted as the top-left
95+
corner of the default window.
96+
"""
97+
98+
default = TVParsedRange(start_row=1, end_row=10, start_col=1, end_col=8)
99+
if spec is None:
100+
return default
101+
102+
parts = spec.split(":")
103+
if len(parts) == 1:
104+
start = parts[0]
105+
start_row, start_col = _parse_cell_ref(start)
106+
return TVParsedRange(
107+
start_row=start_row,
108+
end_row=start_row + (default.end_row - default.start_row),
109+
start_col=start_col,
110+
end_col=start_col + (default.end_col - default.start_col),
111+
)
112+
if len(parts) == 2:
113+
start, end = parts
114+
start_row, start_col = _parse_cell_ref(start)
115+
end_row, end_col = _parse_cell_ref(end)
116+
return TVParsedRange(
117+
start_row=min(start_row, end_row),
118+
end_row=max(start_row, end_row),
119+
start_col=min(start_col, end_col),
120+
end_col=max(start_col, end_col),
121+
)
122+
123+
raise ValueError(f"invalid range specification: {spec!r}")
124+
125+
126+
def _render_table(workbook: Workbook, sheet_name: str | None, rng: TVParsedRange) -> None:
127+
"""Render a rectangular table to stdout using a simple text layout."""
128+
129+
rows = list(tv_iter_range(workbook._odf_doc, sheet_name, rng))
130+
131+
if not rows:
132+
return
133+
134+
# Compute column widths.
135+
num_cols = len(rows[0])
136+
widths = [0] * num_cols
137+
for row in rows:
138+
for idx, cell in enumerate(row):
139+
widths[idx] = max(widths[idx], len(cell))
140+
141+
for row in rows:
142+
padded = [cell.ljust(widths[idx]) for idx, cell in enumerate(row)]
143+
print(" ".join(padded))
144+
145+
37146
def main(argv: Sequence[str] | None = None) -> int:
38147
"""Entry point for the odsview CLI.
39148
@@ -53,7 +162,7 @@ def main(argv: Sequence[str] | None = None) -> int:
53162
parser.print_help(sys.stdout)
54163
return 0
55164

56-
# --list-sheets requires a file.
165+
# --list-sheets requires a file and ignores table-view options.
57166
if args.list_sheets:
58167
if args.file is None:
59168
parser.error("--list-sheets requires a FILE argument")
@@ -73,11 +182,38 @@ def main(argv: Sequence[str] | None = None) -> int:
73182

74183
return 0
75184

76-
# Any other use of FILE remains unimplemented for now.
77-
if args.file is not None:
78-
parser.error("viewing .ods files is not implemented yet")
185+
# Table view mode: requires a file.
186+
if args.file is None and (args.sheet or args.range):
187+
parser.error("table view requires a FILE argument")
79188
return 2
80189

190+
if args.file is not None:
191+
try:
192+
workbook = load_workbook(Path(args.file))
193+
except FileNotFoundError as exc: # pragma: no cover - exercised via integration tests
194+
print(str(exc), file=sys.stderr)
195+
return 1
196+
except Exception as exc: # pragma: no cover - generic fallback
197+
print(str(exc), file=sys.stderr)
198+
return 1
199+
200+
try:
201+
rng = _parse_range_spec(args.range)
202+
except ValueError as exc:
203+
print(str(exc), file=sys.stderr)
204+
return 2
205+
206+
try:
207+
_render_table(workbook, args.sheet, rng)
208+
except KeyError as exc:
209+
print(str(exc), file=sys.stderr)
210+
return 1
211+
except ValueError as exc:
212+
print(str(exc), file=sys.stderr)
213+
return 2
214+
215+
return 0
216+
81217
# Fallback: show help.
82218
parser.print_help(sys.stdout)
83219
return 0

src/odsview/tableview.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Helpers for rendering a rectangular table view from an ODS file.
2+
3+
This module deliberately works directly with odfpy's DOM rather than
4+
extending :mod:`odsview.workbook`. The workbook abstraction currently
5+
exposes only sheet metadata; higher‑level viewing behaviour is kept
6+
separate for now to avoid destabilising existing callers.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass
12+
from typing import Iterable, List, Sequence
13+
14+
from odf.table import Table, TableCell, TableRow
15+
from odf.text import P
16+
17+
18+
@dataclass
19+
class ParsedRange:
20+
start_row: int
21+
end_row: int
22+
start_col: int
23+
end_col: int
24+
25+
26+
def _iter_sheets(odf_doc: object) -> Iterable[Table]:
27+
"""Yield table elements in document order."""
28+
29+
# getElementsByType is provided by odfpy's document classes.
30+
return odf_doc.getElementsByType(Table) # type: ignore[no-any-return]
31+
32+
33+
def _find_sheet(odf_doc: object, name: str | None) -> Table:
34+
"""Return the requested sheet or the first sheet if *name* is None.
35+
36+
Raises ``KeyError`` if a specific *name* is requested but not found,
37+
or ``ValueError`` if the document has no sheets at all.
38+
"""
39+
40+
sheets = list(_iter_sheets(odf_doc))
41+
if not sheets:
42+
raise ValueError("workbook contains no sheets")
43+
44+
if name is None:
45+
return sheets[0]
46+
47+
for table in sheets:
48+
if table.getAttribute("name") == name:
49+
return table
50+
51+
raise KeyError(f"sheet not found: {name}")
52+
53+
54+
def _cell_text(cell: TableCell) -> str:
55+
"""Extract visible text from a table cell.
56+
57+
odfpy represents cell text as ``text:p`` children rather than via a
58+
plain attribute in many cases. For our fixtures, concatenating all
59+
paragraph texts with a single space is sufficient.
60+
"""
61+
62+
pieces: List[str] = []
63+
for p in cell.getElementsByType(P):
64+
# Each P instance behaves like an element whose string
65+
# representation yields its textual content.
66+
text = str(p)
67+
if text:
68+
pieces.append(text)
69+
return " ".join(pieces)
70+
71+
72+
def iter_range(
73+
odf_doc: object,
74+
sheet_name: str | None,
75+
rng: ParsedRange,
76+
) -> Iterable[Sequence[str]]:
77+
"""Iterate over rows of text cells for the given rectangular range.
78+
79+
Row and column indices in *rng* are 1‑based and inclusive. Cells
80+
missing from the underlying sheet are yielded as empty strings.
81+
"""
82+
83+
table = _find_sheet(odf_doc, sheet_name)
84+
85+
# Collect rows within the desired index window.
86+
rows: List[Sequence[str]] = []
87+
for row_idx, row in enumerate(table.getElementsByType(TableRow), start=1):
88+
if row_idx < rng.start_row or row_idx > rng.end_row:
89+
continue
90+
91+
cells = list(row.getElementsByType(TableCell))
92+
row_values: List[str] = []
93+
for col_idx in range(rng.start_col, rng.end_col + 1):
94+
cell: TableCell | None = cells[col_idx - 1] if col_idx - 1 < len(cells) else None
95+
row_values.append(_cell_text(cell) if cell is not None else "")
96+
97+
rows.append(row_values)
98+
99+
return rows

tests/fixtures/multiline_small.ods

9.47 KB
Binary file not shown.
11.7 KB
Binary file not shown.

tests/fixtures/sparse_small.ods

10.3 KB
Binary file not shown.

tests/fixtures/table_small.ods

9.45 KB
Binary file not shown.

0 commit comments

Comments
 (0)