77
88import argparse
99import sys
10+ from dataclasses import dataclass
1011from 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
1618def 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+
37146def 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
0 commit comments