Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ee350ea
chore: update pydantic and pydantic-core versions, enhance phone numb…
jirhiker Feb 15, 2026
1936f9a
Formatting changes
jirhiker Feb 15, 2026
b93b00c
chore: update pydantic and pydantic-core versions, enhance phone numb…
jirhiker Feb 15, 2026
40fbe54
chore: update phone validation output format in CLI tests
jirhiker Feb 15, 2026
f70ec28
Formatting changes
jirhiker Feb 15, 2026
4c1156b
Update schemas/well_inventory.py
jirhiker Feb 15, 2026
783a6ab
Formatting changes
jirhiker Feb 15, 2026
9c06f8c
delete file
jirhiker Feb 15, 2026
70cc08c
Apply suggestions from code review
jirhiker Feb 15, 2026
06c2120
Formatting changes
jirhiker Feb 15, 2026
23ce228
chore: update pydantic and pydantic-core versions, enhance phone numb…
jirhiker Feb 15, 2026
d03b553
chore: update CSV validation scenarios and improve auto-generation lo…
jirhiker Feb 15, 2026
87d1315
Formatting changes
jirhiker Feb 15, 2026
f8496cf
chore: limit displayed validation errors to 10 and update output form…
jirhiker Feb 15, 2026
0a76f6b
feat: add theme support and improve validation output formatting in C…
jirhiker Feb 15, 2026
b822c6f
Formatting changes
jirhiker Feb 15, 2026
3b7c561
feat: add validation for missing well_name_point_id column in CSV pro…
jirhiker Feb 15, 2026
c9d1305
test: update test for blank well_name_point_id to auto-generate IDs
jirhiker Feb 15, 2026
6e895ca
test: update CSV test to include a valid row with a blank well_name_p…
jirhiker Feb 15, 2026
21ad925
feat: enhance CSV processing to handle duplicate contact names and or…
jirhiker Feb 15, 2026
7c081d4
Update services/well_inventory_csv.py
jirhiker Feb 15, 2026
9765313
Update tests/features/environment.py
jirhiker Feb 15, 2026
f5d9013
Update tests/test_cli_commands.py
jirhiker Feb 15, 2026
619f59f
refactor: rename step implementations for clarity and consistency
jirhiker Feb 15, 2026
95f1426
test: add test for handling multiple contacts with null organizations…
jirhiker Feb 15, 2026
81a324e
test: remove redundant test for handling multiple null organization c…
jirhiker Feb 15, 2026
1d6d697
test: streamline CSV upload tests for blank well_name_point_id and du…
jirhiker Feb 15, 2026
729faba
fix: update type hint for well_id parameter in _extract_autogen_prefi…
jirhiker Feb 15, 2026
f8ceb2c
fix: enhance error handling and validation reporting in CSV upload pr…
jirhiker Feb 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 240 additions & 6 deletions cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
import os
from collections import Counter, defaultdict
from enum import Enum
from pathlib import Path
from textwrap import shorten, wrap

import typer
from dotenv import load_dotenv
Expand All @@ -32,8 +35,56 @@ class OutputFormat(str, Enum):
json = "json"


class ThemeMode(str, Enum):
auto = "auto"
light = "light"
dark = "dark"


def _resolve_theme(theme: ThemeMode) -> ThemeMode:
if theme != ThemeMode.auto:
return theme

env_theme = os.environ.get("OCO_THEME", "").strip().lower()
if env_theme in (ThemeMode.light.value, ThemeMode.dark.value):
return ThemeMode(env_theme)

colorfgbg = os.environ.get("COLORFGBG", "")
if colorfgbg:
try:
bg = int(colorfgbg.split(";")[-1])
return ThemeMode.light if bg >= 8 else ThemeMode.dark
except (TypeError, ValueError):
pass

return ThemeMode.dark


def _palette(theme: ThemeMode) -> dict[str, str]:
mode = _resolve_theme(theme)
if mode == ThemeMode.light:
return {
"ok": typer.colors.GREEN,
"issue": typer.colors.RED,
"accent": typer.colors.BLUE,
"muted": typer.colors.BLACK,
"field": typer.colors.RED,
}
return {
"ok": typer.colors.GREEN,
"issue": typer.colors.MAGENTA,
"accent": typer.colors.BRIGHT_BLUE,
"muted": typer.colors.BRIGHT_BLACK,
"field": typer.colors.BRIGHT_YELLOW,
}


@cli.command("initialize-lexicon")
def initialize_lexicon():
def initialize_lexicon(
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
from core.initializers import init_lexicon

init_lexicon()
Comment thread
jirhiker marked this conversation as resolved.
Expand All @@ -47,7 +98,10 @@ def associate_assets_command(
file_okay=False,
dir_okay=True,
readable=True,
)
),
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
from cli.service_adapter import associate_assets

Expand All @@ -62,15 +116,175 @@ def well_inventory_csv(
file_okay=True,
dir_okay=False,
readable=True,
)
),
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
"""
parse and upload a csv to database
"""
# TODO: use the same helper function used by api to parse and upload a WI csv
from cli.service_adapter import well_inventory_csv

well_inventory_csv(file_path)
result = well_inventory_csv(file_path)
payload = result.payload if isinstance(result.payload, dict) else {}
summary = payload.get("summary", {})
validation_errors = payload.get("validation_errors", [])
detail = payload.get("detail")
colors = _palette(theme)

if result.exit_code == 0:
typer.secho("[WELL INVENTORY IMPORT] SUCCESS", fg=colors["ok"], bold=True)
else:
typer.secho(
"[WELL INVENTORY IMPORT] COMPLETED WITH ISSUES",
fg=colors["issue"],
bold=True,
)
typer.secho("=" * 72, fg=colors["accent"])

if summary:
processed = summary.get("total_rows_processed", 0)
imported = summary.get("total_rows_imported", 0)
rows_with_issues = summary.get("validation_errors_or_warnings", 0)
typer.secho("SUMMARY", fg=colors["accent"], bold=True)
Comment thread
jirhiker marked this conversation as resolved.
label_width = 16
value_width = 8
typer.secho(" " + "-" * (label_width + 3 + value_width), fg=colors["muted"])
typer.secho(
f" {'processed':<{label_width}} | {processed:>{value_width}}",
fg=colors["accent"],
)
typer.secho(
f" {'imported':<{label_width}} | {imported:>{value_width}}",
fg=colors["ok"],
)
issue_color = colors["issue"] if rows_with_issues else colors["ok"]
typer.secho(
f" {'rows_with_issues':<{label_width}} | {rows_with_issues:>{value_width}}",
fg=issue_color,
)
typer.echo()
Comment thread
jirhiker marked this conversation as resolved.

if validation_errors:
typer.secho("VALIDATION", fg=colors["accent"], bold=True)
typer.secho(
f"Validation errors: {len(validation_errors)}",
fg=colors["issue"],
bold=True,
)
common_errors = Counter()
for err in validation_errors:
field = err.get("field", "unknown")
message = err.get("error") or err.get("msg") or "validation error"
common_errors[(field, message)] += 1

if common_errors:
typer.secho(
"Most common validation errors:", fg=colors["accent"], bold=True
)
field_width = 28
count_width = 5
error_width = 100
typer.secho(
f" {'#':>2} | {'field':<{field_width}} | {'count':>{count_width}} | error",
fg=colors["muted"],
bold=True,
)
typer.secho(
" " + "-" * (2 + 3 + field_width + 3 + count_width + 3 + error_width),
fg=colors["muted"],
)
for idx, ((field, message), count) in enumerate(
common_errors.most_common(5), start=1
):
error_one_line = shorten(
str(message).replace("\n", " "),
width=error_width,
placeholder="...",
)
field_text = shorten(str(field), width=field_width, placeholder="...")
field_part = typer.style(
f"{field_text:<{field_width}}", fg=colors["field"], bold=True
)
count_part = f"{int(count):>{count_width}}"
idx_part = typer.style(f"{idx:>2}", fg=colors["issue"])
error_part = typer.style(error_one_line, fg=colors["issue"])
typer.echo(f" {idx_part} | {field_part} | {count_part} | {error_part}")
typer.echo()

grouped_errors = defaultdict(list)
for err in validation_errors:
row = err.get("row", "?")
grouped_errors[row].append(err)

def _row_sort_key(row_value):
try:
return (0, int(row_value))
except (TypeError, ValueError):
return (1, str(row_value))

max_errors_to_show = 10
Comment thread
jirhiker marked this conversation as resolved.
shown = 0
first_group = True
for row in sorted(grouped_errors.keys(), key=_row_sort_key):
if shown >= max_errors_to_show:
break

row_errors = grouped_errors[row]
if not first_group:
typer.secho(" " + "-" * 56, fg=colors["muted"])
first_group = False
typer.secho(
f" Row {row} ({len(row_errors)} issue{'s' if len(row_errors) != 1 else ''})",
fg=colors["accent"],
bold=True,
)

for idx, err in enumerate(row_errors, start=1):
if shown >= max_errors_to_show:
break
field = err.get("field", "unknown")
message = err.get("error") or err.get("msg") or "validation error"
input_value = err.get("value")
prefix_raw = f" {idx}. "
field_raw = f"{field}:"
msg_chunks = wrap(
str(message),
width=max(20, 200 - len(prefix_raw) - len(field_raw) - 1),
) or [""]
prefix = typer.style(prefix_raw, fg=colors["issue"])
field_part = typer.style(field_raw, fg=colors["field"], bold=True)
first_msg_part = typer.style(msg_chunks[0], fg=colors["issue"])
typer.echo(f"{prefix}{field_part} {first_msg_part}")
msg_indent = " " * (len(prefix_raw) + len(field_raw) + 1)
for chunk in msg_chunks[1:]:
typer.secho(f"{msg_indent}{chunk}", fg=colors["issue"])
if input_value is not None:
input_prefix = " input: "
input_chunks = wrap(
str(input_value), width=max(20, 200 - len(input_prefix))
) or [""]
typer.echo(f"{input_prefix}{input_chunks[0]}")
input_indent = " " * len(input_prefix)
for chunk in input_chunks[1:]:
typer.echo(f"{input_indent}{chunk}")
shown += 1
typer.echo()

if len(validation_errors) > shown:
typer.secho(
f"... and {len(validation_errors) - shown} more validation errors",
fg=colors["issue"],
)
if detail:
typer.secho("ERRORS", fg=colors["accent"], bold=True)
typer.secho(f"Error: {detail}", fg=colors["issue"], bold=True)

typer.secho("=" * 72, fg=colors["accent"])

raise typer.Exit(result.exit_code)


@water_levels.command("bulk-upload")
Expand All @@ -89,6 +303,9 @@ def water_levels_bulk_upload(
"--output",
help="Optional output format",
),
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
"""
parse and upload a csv
Expand All @@ -101,7 +318,11 @@ def water_levels_bulk_upload(


@data_migrations.command("list")
def data_migrations_list():
def data_migrations_list(
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
from data_migrations.registry import list_migrations

migrations = list_migrations()
Expand All @@ -114,7 +335,11 @@ def data_migrations_list():


@data_migrations.command("status")
def data_migrations_status():
def data_migrations_status(
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
from db.engine import session_ctx
from data_migrations.runner import get_status

Expand All @@ -138,6 +363,9 @@ def data_migrations_run(
force: bool = typer.Option(
False, "--force", help="Re-run even if already applied."
),
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
from db.engine import session_ctx
from data_migrations.runner import run_migration_by_id
Expand All @@ -157,6 +385,9 @@ def data_migrations_run_all(
force: bool = typer.Option(
False, "--force", help="Re-run non-repeatable migrations."
),
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
from db.engine import session_ctx
from data_migrations.runner import run_all
Expand All @@ -177,6 +408,9 @@ def alembic_upgrade_and_data(
force: bool = typer.Option(
False, "--force", help="Re-run non-repeatable migrations."
),
theme: ThemeMode = typer.Option(
ThemeMode.auto, "--theme", help="Color theme: auto, light, dark."
),
):
from alembic import command
from alembic.config import Config
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ dependencies = [
"pyasn1==0.6.2",
"pyasn1-modules==0.4.2",
"pycparser==2.23",
"pydantic==2.11.7",
"pydantic-core==2.33.2",
"pydantic==2.12.5",
"pydantic-core==2.41.5",
"pygments==2.19.2",
"pyjwt==2.11.0",
"pyproj==3.7.2",
Expand Down
Loading
Loading