Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ All fields below describe `~/.nsc/config.yaml`.
|---|---|---|
| `default_profile` | `str | None` | `None` |
| `profiles` | `dict[str, Profile]` | `{}` |
| `defaults` | `<class 'Defaults'>` | `Defaults(output=<OutputFormat.TABLE: 'table'>, page_size=50, timeout=30.0, schema_refresh=<SchemaRefresh.DAILY: 'daily'>, color_mode=<ColorMode.AUTO: 'auto'>, audit_redaction=<AuditRedaction.SAFE: 'safe'>)` |
| `defaults` | `<class 'Defaults'>` | `Defaults(output=<OutputFormat.TABLE: 'table'>, page_size=50, timeout=30.0, schema_refresh=<SchemaRefresh.DAILY: 'daily'>, color_mode=<ColorMode.AUTO: 'auto'>, object_colors=<ObjectColorMode.AUTO: 'auto'>, audit_redaction=<AuditRedaction.SAFE: 'safe'>)` |
| `columns` | `dict[str, dict[str, list[str]]]` | `{}` |
| `saved_searches` | `dict[str, dict[str, dict[str, dict[str, str]]]]` | `{}` |

Expand All @@ -35,4 +35,5 @@ All fields below describe `~/.nsc/config.yaml`.
| `timeout` | `<class 'float'>` | `30.0` |
| `schema_refresh` | `<enum 'SchemaRefresh'>` | `<SchemaRefresh.DAILY: 'daily'>` |
| `color_mode` | `<enum 'ColorMode'>` | `<ColorMode.AUTO: 'auto'>` |
| `object_colors` | `<enum 'ObjectColorMode'>` | `<ObjectColorMode.AUTO: 'auto'>` |
| `audit_redaction` | `<enum 'AuditRedaction'>` | `<AuditRedaction.SAFE: 'safe'>` |
16 changes: 16 additions & 0 deletions nsc/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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."""
Expand All @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions nsc/cli/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions nsc/cli/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -616,6 +618,7 @@ def _render_response(
stream=stream,
compact=ctx.compact,
color=ctx.color,
object_colors=ctx.object_colors,
)


Expand Down
20 changes: 19 additions & 1 deletion nsc/cli/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions nsc/cli/tui_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions nsc/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down
36 changes: 36 additions & 0 deletions nsc/output/colors.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 27 additions & 8 deletions nsc/output/flatten.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
3 changes: 2 additions & 1 deletion nsc/output/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}")

Expand Down
31 changes: 30 additions & 1 deletion nsc/output/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand All @@ -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")
Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions nsc/tui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading