diff --git a/docs/reference/config.md b/docs/reference/config.md index 0dafb6a..a18b72f 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -11,7 +11,7 @@ All fields below describe `~/.nsc/config.yaml`. |---|---|---| | `default_profile` | `str | None` | `None` | | `profiles` | `dict[str, Profile]` | `{}` | -| `defaults` | `` | `Defaults(output=, page_size=50, timeout=30.0, schema_refresh=, color_mode=, audit_redaction=)` | +| `defaults` | `` | `Defaults(output=, page_size=50, timeout=30.0, schema_refresh=, color_mode=, object_colors=, audit_redaction=)` | | `columns` | `dict[str, dict[str, list[str]]]` | `{}` | | `saved_searches` | `dict[str, dict[str, dict[str, dict[str, str]]]]` | `{}` | @@ -35,4 +35,5 @@ All fields below describe `~/.nsc/config.yaml`. | `timeout` | `` | `30.0` | | `schema_refresh` | `` | `` | | `color_mode` | `` | `` | +| `object_colors` | `` | `` | | `audit_redaction` | `` | `` | diff --git a/nsc/cli/app.py b/nsc/cli/app.py index c83d23d..e17af5c 100644 --- a/nsc/cli/app.py +++ b/nsc/cli/app.py @@ -94,6 +94,14 @@ def _extract_global_overrides(args: list[str]) -> CLIOverrides: kwargs["refresh_schema"] = True i += 1 continue + if a == "--object-colors": + kwargs["object_colors"] = True + i += 1 + continue + if a == "--no-object-colors": + kwargs["object_colors"] = False + i += 1 + continue i += 1 return CLIOverrides(**kwargs) # type: ignore[arg-type] @@ -310,6 +318,13 @@ def _root( ), ] = False, output: Annotated[str | None, typer.Option("--output", "-o")] = None, + object_colors: Annotated[ + bool | None, + typer.Option( + "--object-colors/--no-object-colors", + help="Render NetBox-native object colors (role/tag/…) in table output.", + ), + ] = None, debug: Annotated[bool, typer.Option("--debug")] = False, ) -> None: """Root callback — global options live here.""" @@ -321,6 +336,7 @@ def _root( schema_override=schema, refresh_schema=refresh_schema, output=output, + object_colors=object_colors, ) try: config = load_config(default_paths().config_file) diff --git a/nsc/cli/globals.py b/nsc/cli/globals.py index bf5328c..2d8aded 100644 --- a/nsc/cli/globals.py +++ b/nsc/cli/globals.py @@ -10,10 +10,11 @@ CLIOverrides, RuntimeContext, resolve_color, + resolve_object_colors, resolve_profile, ) from nsc.config import default_paths -from nsc.config.models import Config +from nsc.config.models import Config, ObjectColorMode from nsc.http.client import NetBoxClient from nsc.output.render import select_format from nsc.schema.source import resolve_command_model @@ -48,6 +49,8 @@ def build_runtime_context(state: GlobalState) -> RuntimeContext: redaction=state.config.defaults.audit_redaction, profile_name=profile.name, ) + color = resolve_color(state.config.defaults.color_mode, is_tty=sys.stdout.isatty()) + object_color_mode = _resolve_object_color_mode(state) return RuntimeContext( resolved_profile=profile, config=state.config, @@ -56,11 +59,19 @@ def build_runtime_context(state: GlobalState) -> RuntimeContext: output_format=output, debug=state.debug, page_size=state.config.defaults.page_size, - color=resolve_color(state.config.defaults.color_mode, is_tty=sys.stdout.isatty()), + color=color, color_stderr=resolve_color(state.config.defaults.color_mode, is_tty=sys.stderr.isatty()), + object_colors=resolve_object_colors(object_color_mode, color=color), ) +def _resolve_object_color_mode(state: GlobalState) -> ObjectColorMode: + override = state.overrides.object_colors + if override is None: + return state.config.defaults.object_colors + return ObjectColorMode.ON if override else ObjectColorMode.OFF + + __all__ = [ "GlobalState", "build_runtime_context", diff --git a/nsc/cli/handlers.py b/nsc/cli/handlers.py index 39ae9f5..0c59f55 100644 --- a/nsc/cli/handlers.py +++ b/nsc/cli/handlers.py @@ -104,6 +104,7 @@ def handle_list( stream=_out(stream), compact=ctx.compact, color=ctx.color, + object_colors=ctx.object_colors, ) except (NetBoxAPIError, NetBoxClientError) as exc: env = map_error(exc, operation_id=operation.operation_id) @@ -130,6 +131,7 @@ def handle_get( stream=_out(stream), compact=ctx.compact, color=ctx.color, + object_colors=ctx.object_colors, ) except (NetBoxAPIError, NetBoxClientError) as exc: env = map_error(exc, operation_id=operation.operation_id) @@ -616,6 +618,7 @@ def _render_response( stream=stream, compact=ctx.compact, color=ctx.color, + object_colors=ctx.object_colors, ) diff --git a/nsc/cli/runtime.py b/nsc/cli/runtime.py index a4021bd..47e538c 100644 --- a/nsc/cli/runtime.py +++ b/nsc/cli/runtime.py @@ -15,7 +15,7 @@ from pydantic import BaseModel, ConfigDict, HttpUrl, SkipValidation from nsc.cache.store import ADHOC_PROFILE -from nsc.config.models import ColorMode, Config, OutputFormat, Profile +from nsc.config.models import ColorMode, Config, ObjectColorMode, OutputFormat, Profile from nsc.config.settings import default_paths from nsc.http.client import NetBoxClient from nsc.http.errors import NetBoxAPIError, NetBoxClientError @@ -52,6 +52,7 @@ class CLIOverrides(_Frozen): schema_override: str | None = None refresh_schema: bool = False output: str | None = None + object_colors: bool | None = None class ResolvedProfile(_Frozen): @@ -186,6 +187,22 @@ def resolve_color(mode: ColorMode, *, is_tty: bool) -> bool: return is_tty +def resolve_object_colors(mode: ObjectColorMode, *, color: bool) -> bool: + """Resolve whether NetBox-native object colors render. + + Always gated by the global `color` resolution (no ANSI when color is off), + but can be disabled independently: OFF turns object colors off while status + coloring stays on; ON/AUTO follow `color`. + + Contract: ON is deliberately *not* a force — it stays identical to AUTO so + that `--object-colors` (and `NSC_OBJECT_COLORS=on`) can never override the + no-color decision (`--no-color`, `NO_COLOR`, non-tty). Only OFF diverges. + """ + if mode is ObjectColorMode.OFF: + return False + return color + + class RuntimeContext(BaseModel): """Per-invocation runtime state. @@ -208,6 +225,7 @@ class RuntimeContext(BaseModel): compact: bool = False color: bool = False color_stderr: bool = False + object_colors: bool = False apply: bool = False explain: bool = False strict: bool = False diff --git a/nsc/cli/tui_commands.py b/nsc/cli/tui_commands.py index 2008656..cfb877a 100644 --- a/nsc/cli/tui_commands.py +++ b/nsc/cli/tui_commands.py @@ -107,6 +107,7 @@ def tui( initial_resource=resource, save_columns=_save_columns, column_prefs=column_prefs, + object_colors=runtime.object_colors, saved_searches=saved_searches, save_search=_save_search, delete_search=_delete_search, diff --git a/nsc/config/models.py b/nsc/config/models.py index ee09156..6594362 100644 --- a/nsc/config/models.py +++ b/nsc/config/models.py @@ -37,6 +37,15 @@ class ColorMode(StrEnum): OFF = "off" +class ObjectColorMode(StrEnum): + # Render NetBox-native hex colors (role/tag/…) independently of status + # coloring. Still gated by the global color resolution, so OFF never yields + # ANSI but ON/AUTO only color when color is otherwise enabled. + AUTO = "auto" + ON = "on" + OFF = "off" + + class AuditRedaction(StrEnum): # SAFE redacts only known secrets (passwords/tokens) in bodies; FULL omits # every body, leaving routing metadata only — stricter compliance, harder @@ -51,6 +60,7 @@ class Defaults(_Frozen): timeout: float = 30.0 schema_refresh: SchemaRefresh = SchemaRefresh.DAILY color_mode: ColorMode = ColorMode.AUTO + object_colors: ObjectColorMode = ObjectColorMode.AUTO audit_redaction: AuditRedaction = AuditRedaction.SAFE diff --git a/nsc/output/colors.py b/nsc/output/colors.py new file mode 100644 index 0000000..982b78d --- /dev/null +++ b/nsc/output/colors.py @@ -0,0 +1,36 @@ +"""Framework-free value object + helper for NetBox-native hex colors. + +NetBox FK/choice objects (roles, tags, …) carry a sibling ``color`` field — a +6-hex string (no leading ``#``) the web UI uses to tint the badge. The table and +TUI formatters preserve it by emitting a :class:`ColoredValue` instead of a bare +display string. This module imports nothing from cli/http/rich so it stays usable +by any formatter. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +_HEX = re.compile(r"\A[0-9a-fA-F]{6}\Z") + + +@dataclass(frozen=True) +class ColoredValue: + text: str + color: str | None + + +def normalize_hex(raw: object) -> str | None: + """Return a lowercased 6-hex color (no leading ``#``) or ``None``. + + Accepts an optional leading ``#``. Anything that is not a string of exactly + six hex digits (after stripping one leading ``#``) — including ``None``, + non-strings, empty strings, and wrong lengths — yields ``None``. + """ + if not isinstance(raw, str): + return None + candidate = raw[1:] if raw.startswith("#") else raw + if _HEX.match(candidate): + return candidate.lower() + return None diff --git a/nsc/output/flatten.py b/nsc/output/flatten.py index 0c648a8..b8bc05f 100644 --- a/nsc/output/flatten.py +++ b/nsc/output/flatten.py @@ -5,13 +5,20 @@ import json from typing import Any +from nsc.output.colors import ColoredValue, normalize_hex -def flatten(record: dict[str, Any], *, columns: list[str] | None = None) -> dict[str, Any]: + +def flatten( + record: dict[str, Any], + *, + columns: list[str] | None = None, + with_colors: bool = False, +) -> dict[str, Any]: if columns is None: flat: dict[str, Any] = {} _walk(record, "", flat) return flat - return {col: _select(record, col) for col in columns} + return {col: _select(record, col, with_colors=with_colors) for col in columns} def _walk(value: Any, prefix: str, out: dict[str, Any]) -> None: @@ -25,29 +32,41 @@ def _walk(value: Any, prefix: str, out: dict[str, Any]) -> None: out[prefix] = value -def _select(record: dict[str, Any], path: str) -> Any: +def _select(record: dict[str, Any], path: str, *, with_colors: bool = False) -> Any: cur: Any = record for part in path.split("."): if isinstance(cur, dict) and part in cur: cur = cur[part] else: return "" - return _displayify(cur) + return _displayify(cur, with_colors=with_colors) -def _displayify(value: Any) -> Any: +def _displayify(value: Any, *, with_colors: bool = False) -> Any: if isinstance(value, dict): # FK objects carry `display`; choice fields carry `label` (status, etc.). for key in ("display", "label"): candidate = value.get(key) if isinstance(candidate, str): + if with_colors: + color = normalize_hex(value.get("color")) + if color is not None: + return ColoredValue(candidate, color) return candidate return json.dumps(value, separators=(",", ":")) if isinstance(value, list): - return ", ".join(_as_cell(v) for v in value) + cells = [_as_cell(v, with_colors=with_colors) for v in value] + if with_colors and any(isinstance(c, ColoredValue) for c in cells): + # Keep the list uniform so downstream formatters (which require a + # homogeneous list of ColoredValue) don't reject a mix and emit a + # raw repr. Promote plain strings to an uncolored ColoredValue. + return [c if isinstance(c, ColoredValue) else ColoredValue(c, None) for c in cells] + return ", ".join(c.text if isinstance(c, ColoredValue) else c for c in cells) return value -def _as_cell(value: Any) -> str: - rendered = _displayify(value) +def _as_cell(value: Any, *, with_colors: bool = False) -> str | ColoredValue: + rendered = _displayify(value, with_colors=with_colors) + if isinstance(rendered, ColoredValue): + return rendered return "" if rendered is None else str(rendered) diff --git a/nsc/output/render.py b/nsc/output/render.py index 502743e..dc76b23 100644 --- a/nsc/output/render.py +++ b/nsc/output/render.py @@ -17,6 +17,7 @@ def render( stream: TextIO = sys.stdout, compact: bool = False, color: bool = False, + object_colors: bool = False, ) -> None: if format is OutputFormat.JSON: json_.render(data, stream=stream, compact=compact) @@ -27,7 +28,7 @@ def render( elif format is OutputFormat.CSV: csv_.render(data, stream=stream, columns=columns) elif format is OutputFormat.TABLE: - table.render(data, stream=stream, columns=columns, color=color) + table.render(data, stream=stream, columns=columns, color=color, object_colors=object_colors) else: # pragma: no cover (StrEnum exhaustively covered above) raise ValueError(f"unknown output format: {format!r}") diff --git a/nsc/output/table.py b/nsc/output/table.py index 0beb73a..af94909 100644 --- a/nsc/output/table.py +++ b/nsc/output/table.py @@ -9,6 +9,7 @@ from rich.table import Table from nsc.output._console import make_console +from nsc.output.colors import ColoredValue from nsc.output.flatten import flatten _STATUS_COLORS: dict[str, str] = { @@ -34,13 +35,15 @@ def render( stream: TextIO = sys.stdout, columns: list[str] | None = None, color: bool = False, + object_colors: bool = False, ) -> None: records = [data] if isinstance(data, dict) else list(data) if not records: make_console(stream, color=color).print("(no records)") return - flat_records = [flatten(r, columns=columns) for r in records] + with_colors = color and object_colors + flat_records = [flatten(r, columns=columns, with_colors=with_colors) for r in records] fieldnames = columns if columns is not None else _gather_fieldnames(flat_records) table = Table(show_header=True, header_style="bold") @@ -60,7 +63,33 @@ def _gather_fieldnames(records: list[dict[str, Any]]) -> list[str]: return list(seen.keys()) +def _colored_markup(value: ColoredValue) -> str: + inner = escape(value.text) + if value.color is None: + return inner + return f"[#{value.color}]{inner}[/]" + + +def _format_colored(value: ColoredValue | list[ColoredValue], *, color: bool) -> str: + # Object colors only ever reach here when color is on (gated in render), but + # escape unconditionally so a bare ColoredValue can't inject markup. + if isinstance(value, ColoredValue): + return _colored_markup(value) if color else escape(value.text) + if not color: + return escape(", ".join(v.text for v in value)) + return ", ".join(_colored_markup(v) for v in value) + + +def _is_colored_list(value: Any) -> bool: + if not isinstance(value, list) or not value: + return False + return all(isinstance(v, ColoredValue) for v in value) + + def _format_cell(value: Any, *, color: bool = False) -> str: + if isinstance(value, ColoredValue) or _is_colored_list(value): + return _format_colored(value, color=color) + if value is None: text = "" elif isinstance(value, bool): diff --git a/nsc/tui/__init__.py b/nsc/tui/__init__.py index 89588b6..abb5288 100644 --- a/nsc/tui/__init__.py +++ b/nsc/tui/__init__.py @@ -20,6 +20,7 @@ def run_tui( initial_resource: str | None = None, save_columns: Callable[[str, str, list[str]], None] | None = None, column_prefs: dict[str, dict[str, list[str]]] | None = None, + object_colors: bool = False, saved_searches: SavedSearchMap | None = None, save_search: Callable[[str, str, str, dict[str, str]], None] | None = None, delete_search: Callable[[str, str, str], None] | None = None, @@ -33,6 +34,7 @@ def run_tui( initial_resource=initial_resource, save_columns=save_columns, column_prefs=column_prefs, + object_colors=object_colors, saved_searches=saved_searches, save_search=save_search, delete_search=delete_search, diff --git a/nsc/tui/app.py b/nsc/tui/app.py index 223bfc3..ff11198 100644 --- a/nsc/tui/app.py +++ b/nsc/tui/app.py @@ -34,6 +34,7 @@ def __init__( initial_resource: str | None = None, save_columns: Callable[[str, str, list[str]], None] | None = None, column_prefs: dict[str, dict[str, list[str]]] | None = None, + object_colors: bool = False, saved_searches: SavedSearchMap | None = None, save_search: Callable[[str, str, str, dict[str, str]], None] | None = None, delete_search: Callable[[str, str, str], None] | None = None, @@ -44,6 +45,7 @@ def __init__( self._initial_resource = initial_resource self._save_columns = save_columns self._column_prefs = column_prefs or {} + self.object_colors = object_colors self._saved_searches: SavedSearchMap = saved_searches or {} self._save_search = save_search self._delete_search = delete_search @@ -123,6 +125,7 @@ def run_tui( initial_resource: str | None = None, save_columns: Callable[[str, str, list[str]], None] | None = None, column_prefs: dict[str, dict[str, list[str]]] | None = None, + object_colors: bool = False, saved_searches: SavedSearchMap | None = None, save_search: Callable[[str, str, str, dict[str, str]], None] | None = None, delete_search: Callable[[str, str, str], None] | None = None, @@ -133,6 +136,7 @@ def run_tui( initial_resource=initial_resource, save_columns=save_columns, column_prefs=column_prefs, + object_colors=object_colors, saved_searches=saved_searches, save_search=save_search, delete_search=delete_search, diff --git a/nsc/tui/screens/list.py b/nsc/tui/screens/list.py index c06ef2e..b057442 100644 --- a/nsc/tui/screens/list.py +++ b/nsc/tui/screens/list.py @@ -89,7 +89,7 @@ def selection(self) -> Selection: return self._selection @property - def _table(self) -> DataTable[str]: + def _table(self) -> DataTable[Any]: return self.query_one("#rows", DataTable) def on_mount(self) -> None: @@ -138,7 +138,9 @@ def _populate(self, records: list[dict[str, Any]]) -> None: sample = records[0] if records else None columns = choose_columns(self._op, self._columns_config, sample) table.add_columns(_MARKER_HEADER, *columns) - for record, row in zip(records, build_rows(records, columns), strict=True): + object_colors = bool(getattr(self.app, "object_colors", False)) + rows = build_rows(records, columns, object_colors=object_colors) + for record, row in zip(records, rows, strict=True): table.add_row(self._marker_for(record.get("id")), *row) self._records = records self._columns = columns diff --git a/nsc/tui/view.py b/nsc/tui/view.py index bf29357..db2987b 100644 --- a/nsc/tui/view.py +++ b/nsc/tui/view.py @@ -4,7 +4,10 @@ from typing import Any +from rich.text import Text + from nsc.model.command_model import Operation +from nsc.output.colors import ColoredValue from nsc.output.flatten import flatten _FALLBACK = ["id"] @@ -32,15 +35,35 @@ def detail_path(list_path: str, record_id: object) -> str: return f"{base}{record_id}/" -def build_rows(records: list[dict[str, Any]], columns: list[str]) -> list[list[str]]: - rows: list[list[str]] = [] +def build_rows( + records: list[dict[str, Any]], + columns: list[str], + *, + object_colors: bool = False, +) -> list[list[str | Text]]: + rows: list[list[str | Text]] = [] for record in records: - flat = flatten(record, columns=columns) + flat = flatten(record, columns=columns, with_colors=object_colors) rows.append([_cell(flat.get(col)) for col in columns]) return rows -def _cell(value: Any) -> str: +def _colored_text(value: ColoredValue) -> Text: + if value.color is None: + return Text(value.text) + return Text(value.text, style=f"#{value.color}") + + +def _cell(value: Any) -> str | Text: if value is None: return "" + if isinstance(value, ColoredValue): + return _colored_text(value) + if isinstance(value, list) and value and all(isinstance(v, ColoredValue) for v in value): + out = Text() + for i, item in enumerate(value): + if i: + out.append(", ") + out.append_text(_colored_text(item)) + return out return str(value) diff --git a/tests/cli/test_runtime_color.py b/tests/cli/test_runtime_color.py index e2c71d5..e047f10 100644 --- a/tests/cli/test_runtime_color.py +++ b/tests/cli/test_runtime_color.py @@ -3,8 +3,13 @@ import pytest from pydantic import HttpUrl -from nsc.cli.runtime import ResolvedProfile, RuntimeContext, resolve_color -from nsc.config.models import ColorMode, Config, OutputFormat +from nsc.cli.runtime import ( + ResolvedProfile, + RuntimeContext, + resolve_color, + resolve_object_colors, +) +from nsc.config.models import ColorMode, Config, ObjectColorMode, OutputFormat from nsc.http.client import NetBoxClient from nsc.model.command_model import CommandModel @@ -53,3 +58,22 @@ def test_resolve_color(mode: ColorMode, is_tty: bool, expected: bool) -> None: def test_runtime_context_accepts_color_field() -> None: ctx = _base_ctx() assert ctx.color is False + + +def test_runtime_context_object_colors_defaults_false() -> None: + assert _base_ctx().object_colors is False + + +@pytest.mark.parametrize( + "mode, color, expected", + [ + (ObjectColorMode.AUTO, True, True), + (ObjectColorMode.AUTO, False, False), + (ObjectColorMode.OFF, True, False), + (ObjectColorMode.OFF, False, False), + (ObjectColorMode.ON, True, True), + (ObjectColorMode.ON, False, False), + ], +) +def test_resolve_object_colors(mode: ObjectColorMode, color: bool, expected: bool) -> None: + assert resolve_object_colors(mode, color=color) == expected diff --git a/tests/cli/test_tui_command.py b/tests/cli/test_tui_command.py index 783eced..838aba6 100644 --- a/tests/cli/test_tui_command.py +++ b/tests/cli/test_tui_command.py @@ -38,6 +38,7 @@ def __init__(self) -> None: self.command_model = object() self.client = object() self.config = _FakeConfig() + self.object_colors = False def test_invoking_tui_calls_run_tui_with_resource(monkeypatch: pytest.MonkeyPatch) -> None: @@ -51,6 +52,7 @@ def _fake_run_tui( initial_resource: str | None = None, save_columns: Any = None, column_prefs: Any = None, + object_colors: bool = False, saved_searches: Any = None, save_search: Any = None, delete_search: Any = None, diff --git a/tests/config/test_models.py b/tests/config/test_models.py index 4b3af33..3978bc7 100644 --- a/tests/config/test_models.py +++ b/tests/config/test_models.py @@ -3,7 +3,15 @@ import pytest from pydantic import ValidationError -from nsc.config.models import ColorMode, Config, Defaults, OutputFormat, Profile, SchemaRefresh +from nsc.config.models import ( + ColorMode, + Config, + Defaults, + ObjectColorMode, + OutputFormat, + Profile, + SchemaRefresh, +) def test_defaults_have_sensible_values() -> None: @@ -70,3 +78,12 @@ def test_defaults_color_mode_is_auto() -> None: def test_color_mode_values() -> None: assert {m.value for m in ColorMode} == {"auto", "on", "off"} + + +def test_defaults_object_colors_default_auto() -> None: + assert Defaults().object_colors is ObjectColorMode.AUTO + + +def test_defaults_object_colors_parses_on_off() -> None: + assert Defaults(object_colors="on").object_colors is ObjectColorMode.ON + assert Defaults(object_colors="off").object_colors is ObjectColorMode.OFF diff --git a/tests/output/test_colors.py b/tests/output/test_colors.py new file mode 100644 index 0000000..329d416 --- /dev/null +++ b/tests/output/test_colors.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from nsc.output.colors import ColoredValue, normalize_hex + + +def test_normalize_hex_strips_leading_hash_and_lowercases() -> None: + assert normalize_hex("#4CAF50") == "4caf50" + assert normalize_hex("4CAF50") == "4caf50" + + +def test_normalize_hex_rejects_non_hex_and_wrong_length() -> None: + assert normalize_hex("ggg") is None + assert normalize_hex("abc") is None + assert normalize_hex("4caf5") is None + assert normalize_hex("4caf500") is None + assert normalize_hex(None) is None + assert normalize_hex(123) is None + + +def test_normalize_hex_treats_empty_string_as_none() -> None: + assert normalize_hex("") is None + + +def test_colored_value_is_frozen_dataclass() -> None: + cv = ColoredValue("Router", "4caf50") + assert cv.text == "Router" + assert cv.color == "4caf50" + assert ColoredValue("Router", "4caf50") == cv diff --git a/tests/output/test_flatten.py b/tests/output/test_flatten.py index 5c222a9..d3e6432 100644 --- a/tests/output/test_flatten.py +++ b/tests/output/test_flatten.py @@ -1,5 +1,6 @@ from __future__ import annotations +from nsc.output.colors import ColoredValue from nsc.output.flatten import flatten @@ -115,3 +116,99 @@ def test_flatten_pick_dotted_path_is_traversal_not_literal_key() -> None: # The dotted column resolves by traversing nested dicts, matching the # documented "drills in" semantics — it does not match a literal dotted key. assert flatten({"a": {"b": 5}}, columns=["a.b"]) == {"a.b": 5} + + +# --- with_colors --- + + +def test_flatten_with_colors_preserves_hex_as_colored_value() -> None: + record = {"role": {"display": "Router", "color": "4caf50"}} + out = flatten(record, columns=["role"], with_colors=True) + assert out["role"] == ColoredValue("Router", "4caf50") + + +def test_flatten_with_colors_normalizes_hash_and_case() -> None: + record = {"role": {"display": "Router", "color": "#4CAF50"}} + out = flatten(record, columns=["role"], with_colors=True) + assert out["role"] == ColoredValue("Router", "4caf50") + + +def test_flatten_with_colors_object_without_color_is_plain_string() -> None: + record = {"role": {"display": "Router"}} + assert flatten(record, columns=["role"], with_colors=True) == {"role": "Router"} + + +def test_flatten_with_colors_invalid_color_falls_back_to_plain() -> None: + record = {"role": {"display": "Router", "color": "notahex"}} + assert flatten(record, columns=["role"], with_colors=True) == {"role": "Router"} + + +def test_flatten_with_colors_empty_color_falls_back_to_plain() -> None: + record = {"role": {"display": "Router", "color": ""}} + assert flatten(record, columns=["role"], with_colors=True) == {"role": "Router"} + + +def test_flatten_with_colors_list_of_tags_keeps_per_item_color() -> None: + record = { + "tags": [ + {"display": "prod", "color": "ff0000"}, + {"display": "edge", "color": "00ff00"}, + ] + } + out = flatten(record, columns=["tags"], with_colors=True) + assert out["tags"] == [ + ColoredValue("prod", "ff0000"), + ColoredValue("edge", "00ff00"), + ] + + +def test_flatten_with_colors_list_without_any_color_joins_as_string() -> None: + record = {"tags": [{"display": "prod"}, {"display": "edge"}]} + out = flatten(record, columns=["tags"], with_colors=True) + assert out["tags"] == "prod, edge" + + +def test_flatten_with_colors_mixed_list_promotes_uncolored_to_none() -> None: + # When only some list items carry a color, the list must stay uniform: + # uncolored items become ColoredValue(text, None) so downstream formatters + # (which require homogeneous lists) don't fall through to a raw repr. + record = { + "tags": [ + {"display": "prod", "color": "ff0000"}, + {"display": "edge"}, + ] + } + out = flatten(record, columns=["tags"], with_colors=True) + assert out["tags"] == [ + ColoredValue("prod", "ff0000"), + ColoredValue("edge", None), + ] + + +def test_flatten_with_colors_mixed_list_with_invalid_color_promotes_to_none() -> None: + record = { + "tags": [ + {"display": "prod", "color": "ff0000"}, + {"display": "edge", "color": "notahex"}, + ] + } + out = flatten(record, columns=["tags"], with_colors=True) + assert out["tags"] == [ + ColoredValue("prod", "ff0000"), + ColoredValue("edge", None), + ] + + +def test_flatten_without_colors_unchanged_single_object() -> None: + record = {"role": {"display": "Router", "color": "4caf50"}} + assert flatten(record, columns=["role"], with_colors=False) == {"role": "Router"} + + +def test_flatten_without_colors_unchanged_list() -> None: + record = { + "tags": [ + {"display": "prod", "color": "ff0000"}, + {"display": "edge", "color": "00ff00"}, + ] + } + assert flatten(record, columns=["tags"], with_colors=False) == {"tags": "prod, edge"} diff --git a/tests/output/test_table_color.py b/tests/output/test_table_color.py index d311ffa..adad0ab 100644 --- a/tests/output/test_table_color.py +++ b/tests/output/test_table_color.py @@ -4,6 +4,7 @@ import re from nsc.config.models import OutputFormat +from nsc.output.colors import ColoredValue from nsc.output.render import render as render_dispatch from nsc.output.table import _format_cell, render @@ -136,3 +137,141 @@ def test_render_dispatch_non_table_ignores_color() -> None: out = buf.getvalue() assert "\x1b[" not in out assert "active" in out + + +# --- object colors --- + + +def test_format_cell_colored_value_emits_hex_markup() -> None: + result = _format_cell(ColoredValue("Router", "4caf50"), color=True) + assert "#4caf50" in result + assert "Router" in result + + +def test_format_cell_colored_value_escapes_markup_in_text() -> None: + result = _format_cell(ColoredValue("ro[le]", "4caf50"), color=True) + assert "#4caf50" in result + assert "\\[le]" in result + + +def test_format_cell_colored_value_no_color_strips_markup_but_escapes() -> None: + result = _format_cell(ColoredValue("ro[le]", "4caf50"), color=False) + assert "#4caf50" not in result + assert "\\[le]" in result + + +def test_format_cell_colored_value_list_comma_joined_colored() -> None: + result = _format_cell( + [ColoredValue("prod", "ff0000"), ColoredValue("edge", "00ff00")], color=True + ) + assert "#ff0000" in result + assert "#00ff00" in result + assert "prod" in result + assert "edge" in result + + +def test_format_cell_colored_value_list_mixed_color_and_none() -> None: + # A mixed list (one item colored, one with None) must render cleanly as a + # comma-joined string with the colored item styled and the other plain — + # never a raw repr like [ColoredValue(...), ...]. + result = _format_cell([ColoredValue("prod", "ff0000"), ColoredValue("edge", None)], color=True) + assert "#ff0000" in result + assert "ColoredValue" not in result + # Colored item carries hex markup; the None item stays plain; comma-joined. + assert result == "[#ff0000]prod[/], edge" + + +def test_render_object_colors_mixed_tag_list_no_raw_repr() -> None: + buf = io.StringIO() + render( + [ + { + "tags": [ + {"display": "prod", "color": "ff0000"}, + {"display": "edge"}, + ] + } + ], + stream=buf, + columns=["tags"], + color=True, + object_colors=True, + ) + out = buf.getvalue() + plain = strip_ansi(out) + assert "ColoredValue" not in out + assert "prod" in plain + assert "edge" in plain + + +def test_render_object_colors_emits_role_hex() -> None: + buf = io.StringIO() + render( + [{"role": {"display": "Router", "color": "4caf50"}}], + stream=buf, + columns=["role"], + color=True, + object_colors=True, + ) + out = buf.getvalue() + assert "\x1b[" in out + assert "Router" in strip_ansi(out) + + +def test_render_object_colors_off_keeps_status_colors_on() -> None: + buf = io.StringIO() + render( + [{"status": "active", "role": {"display": "Router", "color": "4caf50"}}], + stream=buf, + columns=["status", "role"], + color=True, + object_colors=False, + ) + out = buf.getvalue() + # status still colored (ANSI present), role rendered as plain text + assert "\x1b[" in out + plain = strip_ansi(out) + assert "active" in plain + assert "Router" in plain + + +def test_render_object_colors_requires_color() -> None: + buf = io.StringIO() + render( + [{"role": {"display": "Router [x]", "color": "4caf50"}}], + stream=buf, + columns=["role"], + color=False, + object_colors=True, + ) + out = buf.getvalue() + assert "\x1b[" not in out + assert "[x]" in out + + +def test_render_dispatch_forwards_object_colors() -> None: + buf = io.StringIO() + render_dispatch( + [{"role": {"display": "Router", "color": "4caf50"}}], + format=OutputFormat.TABLE, + columns=["role"], + stream=buf, + color=True, + object_colors=True, + ) + assert "\x1b[" in buf.getvalue() + + +def test_render_dispatch_non_table_ignores_object_colors() -> None: + buf = io.StringIO() + render_dispatch( + [{"role": {"display": "Router", "color": "4caf50"}}], + format=OutputFormat.CSV, + columns=["role"], + stream=buf, + color=True, + object_colors=True, + ) + out = buf.getvalue() + assert "ColoredValue" not in out + assert "Router" in out diff --git a/tests/tui/test_view.py b/tests/tui/test_view.py index 9c05bb3..861ab2d 100644 --- a/tests/tui/test_view.py +++ b/tests/tui/test_view.py @@ -1,5 +1,7 @@ from __future__ import annotations +from rich.text import Text + from nsc.model.command_model import Operation from nsc.tui.view import build_rows, choose_columns @@ -25,3 +27,61 @@ def test_build_rows_flattens_to_selected_columns_as_strings() -> None: records = [{"id": 1, "site": {"display": "HQ"}}, {"id": 2, "site": None}] rows = build_rows(records, ["id", "site.display"]) assert rows == [["1", "HQ"], ["2", ""]] + + +def test_build_rows_without_object_colors_returns_plain_str() -> None: + records = [{"role": {"display": "Router", "color": "4caf50"}}] + rows = build_rows(records, ["role"]) + assert rows == [["Router"]] + + +def test_build_rows_object_colors_produces_styled_text() -> None: + records = [{"role": {"display": "Router", "color": "4caf50"}}] + rows = build_rows(records, ["role"], object_colors=True) + cell = rows[0][0] + assert isinstance(cell, Text) + assert cell.plain == "Router" + assert str(cell.style) == "#4caf50" + + +def test_build_rows_object_colors_list_of_tags_joins_styled_text() -> None: + records = [ + { + "tags": [ + {"display": "prod", "color": "ff0000"}, + {"display": "edge", "color": "00ff00"}, + ] + } + ] + rows = build_rows(records, ["tags"], object_colors=True) + cell = rows[0][0] + assert isinstance(cell, Text) + assert cell.plain == "prod, edge" + styles = {str(span.style) for span in cell.spans} + assert "#ff0000" in styles + assert "#00ff00" in styles + + +def test_build_rows_object_colors_mixed_tag_list_joins_cleanly() -> None: + # One tag colored, one without — the row must be a single styled Text with + # both labels joined, not a fall-through to str(value) with a raw repr. + records = [ + { + "tags": [ + {"display": "prod", "color": "ff0000"}, + {"display": "edge"}, + ] + } + ] + rows = build_rows(records, ["tags"], object_colors=True) + cell = rows[0][0] + assert isinstance(cell, Text) + assert cell.plain == "prod, edge" + styles = {str(span.style) for span in cell.spans} + assert "#ff0000" in styles + + +def test_build_rows_object_colors_object_without_color_is_plain_str() -> None: + records = [{"role": {"display": "Router"}}] + rows = build_rows(records, ["role"], object_colors=True) + assert rows == [["Router"]]