Skip to content

Commit 0a76f6b

Browse files
committed
feat: add theme support and improve validation output formatting in CLI commands
1 parent f8496cf commit 0a76f6b

2 files changed

Lines changed: 163 additions & 48 deletions

File tree

cli/cli.py

Lines changed: 157 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
# ===============================================================================
16-
from collections import defaultdict
16+
import os
17+
from collections import Counter, defaultdict
1718
from enum import Enum
1819
from pathlib import Path
19-
from textwrap import wrap
20+
from textwrap import shorten, wrap
2021

2122
import typer
2223
from dotenv import load_dotenv
@@ -34,8 +35,56 @@ class OutputFormat(str, Enum):
3435
json = "json"
3536

3637

38+
class ThemeMode(str, Enum):
39+
auto = "auto"
40+
light = "light"
41+
dark = "dark"
42+
43+
44+
def _resolve_theme(theme: ThemeMode) -> ThemeMode:
45+
if theme != ThemeMode.auto:
46+
return theme
47+
48+
env_theme = os.environ.get("OCO_THEME", "").strip().lower()
49+
if env_theme in (ThemeMode.light.value, ThemeMode.dark.value):
50+
return ThemeMode(env_theme)
51+
52+
colorfgbg = os.environ.get("COLORFGBG", "")
53+
if colorfgbg:
54+
try:
55+
bg = int(colorfgbg.split(";")[-1])
56+
return ThemeMode.light if bg >= 8 else ThemeMode.dark
57+
except (TypeError, ValueError):
58+
pass
59+
60+
return ThemeMode.dark
61+
62+
63+
def _palette(theme: ThemeMode) -> dict[str, str]:
64+
mode = _resolve_theme(theme)
65+
if mode == ThemeMode.light:
66+
return {
67+
"ok": typer.colors.GREEN,
68+
"issue": typer.colors.RED,
69+
"accent": typer.colors.BLUE,
70+
"muted": typer.colors.BLACK,
71+
"field": typer.colors.RED,
72+
}
73+
return {
74+
"ok": typer.colors.GREEN,
75+
"issue": typer.colors.MAGENTA,
76+
"accent": typer.colors.BRIGHT_BLUE,
77+
"muted": typer.colors.BRIGHT_BLACK,
78+
"field": typer.colors.BRIGHT_YELLOW,
79+
}
80+
81+
3782
@cli.command("initialize-lexicon")
38-
def initialize_lexicon():
83+
def initialize_lexicon(
84+
theme: ThemeMode = typer.Option(
85+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
86+
),
87+
):
3988
from core.initializers import init_lexicon
4089

4190
init_lexicon()
@@ -49,7 +98,10 @@ def associate_assets_command(
4998
file_okay=False,
5099
dir_okay=True,
51100
readable=True,
52-
)
101+
),
102+
theme: ThemeMode = typer.Option(
103+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
104+
),
53105
):
54106
from cli.service_adapter import associate_assets
55107

@@ -64,7 +116,10 @@ def well_inventory_csv(
64116
file_okay=True,
65117
dir_okay=False,
66118
readable=True,
67-
)
119+
),
120+
theme: ThemeMode = typer.Option(
121+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
122+
),
68123
):
69124
"""
70125
parse and upload a csv to database
@@ -77,39 +132,88 @@ def well_inventory_csv(
77132
summary = payload.get("summary", {})
78133
validation_errors = payload.get("validation_errors", [])
79134
detail = payload.get("detail")
135+
colors = _palette(theme)
80136

81137
if result.exit_code == 0:
82-
typer.secho("[WELL INVENTORY IMPORT] SUCCESS", fg=typer.colors.GREEN, bold=True)
138+
typer.secho("[WELL INVENTORY IMPORT] SUCCESS", fg=colors["ok"], bold=True)
83139
else:
84140
typer.secho(
85141
"[WELL INVENTORY IMPORT] COMPLETED WITH ISSUES",
86-
fg=typer.colors.BRIGHT_YELLOW,
142+
fg=colors["issue"],
87143
bold=True,
88144
)
89-
typer.secho("=" * 72, fg=typer.colors.BRIGHT_BLUE)
145+
typer.secho("=" * 72, fg=colors["accent"])
90146

91147
if summary:
92148
processed = summary.get("total_rows_processed", 0)
93149
imported = summary.get("total_rows_imported", 0)
94150
rows_with_issues = summary.get("validation_errors_or_warnings", 0)
95-
typer.secho("SUMMARY", fg=typer.colors.BRIGHT_BLUE, bold=True)
96-
typer.echo(
97-
f"Summary: processed={processed} imported={imported} rows_with_issues={rows_with_issues}"
151+
typer.secho("SUMMARY", fg=colors["accent"], bold=True)
152+
label_width = 16
153+
value_width = 8
154+
typer.secho(" " + "-" * (label_width + 3 + value_width), fg=colors["muted"])
155+
typer.secho(
156+
f" {'processed':<{label_width}} | {processed:>{value_width}}",
157+
fg=colors["accent"],
98158
)
99-
typer.secho(f" processed : {processed}", fg=typer.colors.CYAN)
100-
typer.secho(f" imported : {imported}", fg=typer.colors.GREEN)
101-
issue_color = (
102-
typer.colors.BRIGHT_YELLOW if rows_with_issues else typer.colors.GREEN
159+
typer.secho(
160+
f" {'imported':<{label_width}} | {imported:>{value_width}}",
161+
fg=colors["ok"],
162+
)
163+
issue_color = colors["issue"] if rows_with_issues else colors["ok"]
164+
typer.secho(
165+
f" {'rows_with_issues':<{label_width}} | {rows_with_issues:>{value_width}}",
166+
fg=issue_color,
103167
)
104-
typer.secho(f" rows_with_issues : {rows_with_issues}", fg=issue_color)
168+
typer.echo()
105169

106170
if validation_errors:
107-
typer.secho("VALIDATION", fg=typer.colors.BRIGHT_BLUE, bold=True)
171+
typer.secho("VALIDATION", fg=colors["accent"], bold=True)
108172
typer.secho(
109173
f"Validation errors: {len(validation_errors)}",
110-
fg=typer.colors.BRIGHT_YELLOW,
174+
fg=colors["issue"],
111175
bold=True,
112176
)
177+
common_errors = Counter()
178+
for err in validation_errors:
179+
field = err.get("field", "unknown")
180+
message = err.get("error") or err.get("msg") or "validation error"
181+
common_errors[(field, message)] += 1
182+
183+
if common_errors:
184+
typer.secho(
185+
"Most common validation errors:", fg=colors["accent"], bold=True
186+
)
187+
field_width = 28
188+
count_width = 5
189+
error_width = 100
190+
typer.secho(
191+
f" {'#':>2} | {'field':<{field_width}} | {'count':>{count_width}} | error",
192+
fg=colors["muted"],
193+
bold=True,
194+
)
195+
typer.secho(
196+
" " + "-" * (2 + 3 + field_width + 3 + count_width + 3 + error_width),
197+
fg=colors["muted"],
198+
)
199+
for idx, ((field, message), count) in enumerate(
200+
common_errors.most_common(5), start=1
201+
):
202+
error_one_line = shorten(
203+
str(message).replace("\n", " "),
204+
width=error_width,
205+
placeholder="...",
206+
)
207+
field_text = shorten(str(field), width=field_width, placeholder="...")
208+
field_part = typer.style(
209+
f"{field_text:<{field_width}}", fg=colors["field"], bold=True
210+
)
211+
count_part = f"{int(count):>{count_width}}"
212+
idx_part = typer.style(f"{idx:>2}", fg=colors["issue"])
213+
error_part = typer.style(error_one_line, fg=colors["issue"])
214+
typer.echo(f" {idx_part} | {field_part} | {count_part} | {error_part}")
215+
typer.echo()
216+
113217
grouped_errors = defaultdict(list)
114218
for err in validation_errors:
115219
row = err.get("row", "?")
@@ -130,14 +234,11 @@ def _row_sort_key(row_value):
130234

131235
row_errors = grouped_errors[row]
132236
if not first_group:
133-
typer.secho(
134-
" " + "-" * 56,
135-
fg=typer.colors.BRIGHT_BLACK,
136-
)
237+
typer.secho(" " + "-" * 56, fg=colors["muted"])
137238
first_group = False
138239
typer.secho(
139240
f" Row {row} ({len(row_errors)} issue{'s' if len(row_errors) != 1 else ''})",
140-
fg=typer.colors.CYAN,
241+
fg=colors["accent"],
141242
bold=True,
142243
)
143244

@@ -153,42 +254,35 @@ def _row_sort_key(row_value):
153254
str(message),
154255
width=max(20, 200 - len(prefix_raw) - len(field_raw) - 1),
155256
) or [""]
156-
prefix = typer.style(prefix_raw, fg=typer.colors.BRIGHT_YELLOW)
157-
field_part = f"\033[1;38;5;208m{field_raw}\033[0m"
158-
first_msg_part = typer.style(
159-
msg_chunks[0], fg=typer.colors.BRIGHT_YELLOW
160-
)
257+
prefix = typer.style(prefix_raw, fg=colors["issue"])
258+
field_part = typer.style(field_raw, fg=colors["field"], bold=True)
259+
first_msg_part = typer.style(msg_chunks[0], fg=colors["issue"])
161260
typer.echo(f"{prefix}{field_part} {first_msg_part}")
162261
msg_indent = " " * (len(prefix_raw) + len(field_raw) + 1)
163262
for chunk in msg_chunks[1:]:
164-
typer.secho(f"{msg_indent}{chunk}", fg=typer.colors.BRIGHT_YELLOW)
263+
typer.secho(f"{msg_indent}{chunk}", fg=colors["issue"])
165264
if input_value is not None:
166-
input_prefix = " input="
265+
input_prefix = " input: "
167266
input_chunks = wrap(
168267
str(input_value), width=max(20, 200 - len(input_prefix))
169268
) or [""]
170-
typer.secho(
171-
f"{input_prefix}{input_chunks[0]}", fg=typer.colors.BRIGHT_WHITE
172-
)
269+
typer.echo(f"{input_prefix}{input_chunks[0]}")
173270
input_indent = " " * len(input_prefix)
174271
for chunk in input_chunks[1:]:
175-
typer.secho(
176-
f"{input_indent}{chunk}", fg=typer.colors.BRIGHT_WHITE
177-
)
272+
typer.echo(f"{input_indent}{chunk}")
178273
shown += 1
179274
typer.echo()
180275

181276
if len(validation_errors) > shown:
182277
typer.secho(
183278
f"... and {len(validation_errors) - shown} more validation errors",
184-
fg=typer.colors.YELLOW,
279+
fg=colors["issue"],
185280
)
186-
187281
if detail:
188-
typer.secho("ERRORS", fg=typer.colors.BRIGHT_BLUE, bold=True)
189-
typer.secho(f"Error: {detail}", fg=typer.colors.BRIGHT_YELLOW, bold=True)
282+
typer.secho("ERRORS", fg=colors["accent"], bold=True)
283+
typer.secho(f"Error: {detail}", fg=colors["issue"], bold=True)
190284

191-
typer.secho("=" * 72, fg=typer.colors.BRIGHT_BLUE)
285+
typer.secho("=" * 72, fg=colors["accent"])
192286

193287
raise typer.Exit(result.exit_code)
194288

@@ -209,6 +303,9 @@ def water_levels_bulk_upload(
209303
"--output",
210304
help="Optional output format",
211305
),
306+
theme: ThemeMode = typer.Option(
307+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
308+
),
212309
):
213310
"""
214311
parse and upload a csv
@@ -221,7 +318,11 @@ def water_levels_bulk_upload(
221318

222319

223320
@data_migrations.command("list")
224-
def data_migrations_list():
321+
def data_migrations_list(
322+
theme: ThemeMode = typer.Option(
323+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
324+
),
325+
):
225326
from data_migrations.registry import list_migrations
226327

227328
migrations = list_migrations()
@@ -234,7 +335,11 @@ def data_migrations_list():
234335

235336

236337
@data_migrations.command("status")
237-
def data_migrations_status():
338+
def data_migrations_status(
339+
theme: ThemeMode = typer.Option(
340+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
341+
),
342+
):
238343
from db.engine import session_ctx
239344
from data_migrations.runner import get_status
240345

@@ -258,6 +363,9 @@ def data_migrations_run(
258363
force: bool = typer.Option(
259364
False, "--force", help="Re-run even if already applied."
260365
),
366+
theme: ThemeMode = typer.Option(
367+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
368+
),
261369
):
262370
from db.engine import session_ctx
263371
from data_migrations.runner import run_migration_by_id
@@ -277,6 +385,9 @@ def data_migrations_run_all(
277385
force: bool = typer.Option(
278386
False, "--force", help="Re-run non-repeatable migrations."
279387
),
388+
theme: ThemeMode = typer.Option(
389+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
390+
),
280391
):
281392
from db.engine import session_ctx
282393
from data_migrations.runner import run_all
@@ -297,6 +408,9 @@ def alembic_upgrade_and_data(
297408
force: bool = typer.Option(
298409
False, "--force", help="Re-run non-repeatable migrations."
299410
),
411+
theme: ThemeMode = typer.Option(
412+
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
413+
),
300414
):
301415
from alembic import command
302416
from alembic.config import Config

tests/test_cli_commands.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def fake_well_inventory(file_path):
9292

9393
assert result.exit_code == 0, result.output
9494
assert Path(captured["path"]) == inventory_file
95-
assert "Summary: processed=1 imported=1 rows_with_issues=0" in result.output
95+
assert "[WELL INVENTORY IMPORT] SUCCESS" in result.output
9696

9797

9898
def test_well_inventory_csv_command_reports_validation_errors(monkeypatch, tmp_path):
@@ -134,13 +134,12 @@ def fake_well_inventory(_file_path):
134134
result = runner.invoke(cli, ["well-inventory-csv", str(inventory_file)])
135135

136136
assert result.exit_code == 1
137-
assert "Summary: processed=2 imported=0 rows_with_issues=2" in result.output
138137
assert "Validation errors: 2" in result.output
139138
assert (
140139
"Row 1 (1 issue)" in result.output
141140
and "1. contact_1_phone_1: Invalid phone" in result.output
142141
) or "- row=1 field=contact_1_phone_1: Invalid phone" in result.output
143-
assert "input=555-INVALID" in result.output
142+
assert "input: 555-INVALID" in result.output
144143

145144

146145
def test_water_levels_bulk_upload_default_output(monkeypatch, tmp_path):
@@ -201,10 +200,12 @@ def test_water_levels_cli_persists_observations(tmp_path, water_well_thing):
201200
"""
202201

203202
def _write_csv(path: Path, *, well_name: str, notes: str):
204-
csv_text = textwrap.dedent(f"""\
203+
csv_text = textwrap.dedent(
204+
f"""\
205205
field_staff,well_name_point_id,field_event_date_time,measurement_date_time,sampler,sample_method,mp_height,level_status,depth_to_water_ft,data_quality,water_level_notes
206206
CLI Tester,{well_name},2025-02-15T08:00:00-07:00,2025-02-15T10:30:00-07:00,Groundwater Team,electric tape,1.5,stable,42.5,approved,{notes}
207-
""")
207+
"""
208+
)
208209
path.write_text(csv_text)
209210

210211
unique_notes = f"pytest-{uuid.uuid4()}"

0 commit comments

Comments
 (0)