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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ uv run nsc circuits providers list --output csv
uv run nsc ipam prefixes list --filter created__gte=2026-01-01 --output yaml
```

### Saved searches

Filter sets you save from the TUI (Ctrl+W in the filter builder) are stored
locally under `saved_searches` in `~/.nsc/config.yaml`, keyed by tag/resource.
Re-apply one on the CLI with `--saved`:

```sh
uv run nsc dcim devices list --saved active-switches
# explicit --filter / typed options override the saved values:
uv run nsc dcim devices list --saved active-switches --status offline
```

This is a local, per-config convenience and is unrelated to NetBox's
server-side saved filters (`extras saved-filters`), which store web-UI query
strings.

## Writing

```sh
Expand Down
1 change: 1 addition & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All fields below describe `~/.nsc/config.yaml`.
| `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'>)` |
| `columns` | `dict[str, dict[str, list[str]]]` | `{}` |
| `saved_searches` | `dict[str, dict[str, dict[str, dict[str, str]]]]` | `{}` |

## `Profile`

Expand Down
57 changes: 51 additions & 6 deletions nsc/cli/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,39 @@ def _register_write(


_GLOBAL_FLAG_NAMES: frozenset[str] = frozenset(
{"output", "compact", "columns", "limit", "all_", "filter_"}
{"output", "compact", "columns", "limit", "all_", "filter_", "saved"}
)


def _resolve_saved_filters(
ctx: RuntimeContext,
tag_name: str,
resource_name: str,
saved_name: str | None,
explicit_filters: list[tuple[str, str]],
typed_kwargs: dict[str, Any],
) -> list[tuple[str, str]]:
"""Layer a `--saved` filter set under explicit `--filter` and typed options.

Saved params are the base; explicit `--filter` pairs and any non-None typed
query option (already in ``typed_kwargs``) win. Order matters: ``handle_list``
overlays ``ctx.filters`` onto the typed-option params, so saved keys that the
user also set via a typed flag must be dropped here to let the flag win.
"""
if saved_name is None:
return explicit_filters
from nsc.config.saved_searches import get_saved_search # noqa: PLC0415

saved = get_saved_search(ctx.config, tag_name, resource_name, saved_name)
if saved is None:
raise typer.BadParameter(
f"no saved search named {saved_name!r} for {tag_name}/{resource_name}"
)
typed_keys = {k for k, v in typed_kwargs.items() if v is not None}
base = [(k, v) for k, v in saved.items() if k not in typed_keys]
return base + explicit_filters


def _build_read_closure(
operation: Operation,
tag_name: str,
Expand Down Expand Up @@ -151,17 +180,19 @@ def impl(**kwargs: Any) -> None:
limit = kwargs.pop("limit", None)
fetch_all = kwargs.pop("all_", False)
filters_raw: list[str] = kwargs.pop("filter_", None) or []
saved_name: str | None = kwargs.pop("saved", None)
ctx = get_ctx()
explicit_filters = [
(item.split("=", 1)[0], item.split("=", 1)[1]) for item in filters_raw if "=" in item
]
update: dict[str, Any] = {
"compact": compact,
"columns_override": columns_csv.split(",") if columns_csv else None,
"limit": limit,
"fetch_all": fetch_all,
"filters": [
(item.split("=", 1)[0], item.split("=", 1)[1])
for item in filters_raw
if "=" in item
],
"filters": _resolve_saved_filters(
ctx, tag_name, resource_name, saved_name, explicit_filters, kwargs
),
}
if output:
update["output_format"] = OutputFormat(output)
Expand Down Expand Up @@ -311,6 +342,20 @@ def _build_global_flag_params() -> tuple[inspect.Parameter, ...]:
annotation=list[str] | None,
default=typer.Option(None, "--filter"),
),
inspect.Parameter(
name="saved",
kind=inspect.Parameter.KEYWORD_ONLY,
annotation=str | None,
default=typer.Option(
None,
"--saved",
help=(
"Apply a LOCAL named filter set saved from the TUI (config "
"`saved_searches`). Explicit --filter/typed options override it. "
"Unrelated to NetBox server-side saved filters."
),
),
),
)


Expand Down
50 changes: 50 additions & 0 deletions nsc/cli/tui_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,46 @@ def _save_columns(tag: str, resource: str, columns: list[str]) -> None:
atomic_write(path, dump_round_trip(doc))


def _save_search(tag: str, resource: str, name: str, params: dict[str, str]) -> None:
"""Persist a filter set to ``saved_searches.<tag>.<resource>.<name>``."""
from nsc.config.saved_searches import validate_saved_search_name # noqa: PLC0415
from nsc.config.settings import default_paths # noqa: PLC0415
from nsc.config.writer import ( # noqa: PLC0415
acquire_lock,
atomic_write,
dump_round_trip,
load_round_trip,
set_path,
)

# Defensive: a dotted name would split into a nested map under set_path and
# corrupt the file. Validate before touching disk so the file is untouched.
validate_saved_search_name(name)
path = default_paths().config_file
with acquire_lock(path):
doc = load_round_trip(path)
set_path(doc, f"saved_searches.{tag}.{resource}.{name}", dict(params))
atomic_write(path, dump_round_trip(doc))


def _delete_search(tag: str, resource: str, name: str) -> None:
"""Remove ``saved_searches.<tag>.<resource>.<name>`` and prune empty parents."""
from nsc.config.settings import default_paths # noqa: PLC0415
from nsc.config.writer import ( # noqa: PLC0415
acquire_lock,
atomic_write,
dump_round_trip,
load_round_trip,
unset_path,
)

path = default_paths().config_file
with acquire_lock(path):
doc = load_round_trip(path)
unset_path(doc, f"saved_searches.{tag}.{resource}.{name}")
atomic_write(path, dump_round_trip(doc))


def register(app: typer.Typer) -> None:
def tui(
ctx: typer.Context,
Expand All @@ -54,12 +94,22 @@ def tui(
from nsc.tui import run_tui # noqa: PLC0415 # deferred: keeps Textual lazy.

column_prefs = {tag: dict(resources) for tag, resources in runtime.config.columns.items()}
saved_searches = {
tag: {
res: {name: dict(params) for name, params in sets.items()}
for res, sets in resources.items()
}
for tag, resources in runtime.config.saved_searches.items()
}
run_tui(
runtime.command_model,
runtime.client,
initial_resource=resource,
save_columns=_save_columns,
column_prefs=column_prefs,
saved_searches=saved_searches,
save_search=_save_search,
delete_search=_delete_search,
)

# `tui` is canonical; `interactive` and `i` are hidden aliases for the same
Expand Down
4 changes: 4 additions & 0 deletions nsc/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,7 @@ class Config(_Frozen):
profiles: dict[str, Profile] = Field(default_factory=dict)
defaults: Defaults = Field(default_factory=Defaults)
columns: dict[str, dict[str, list[str]]] = Field(default_factory=dict)
# Local, named filter sets keyed `<tag>.<resource>.<name> = {filter_key: value}`.
# The leaf dict is exactly a `FilterState.as_params()` payload so a saved search
# round-trips through the TUI filter screen and the CLI `--saved` flag alike.
saved_searches: dict[str, dict[str, dict[str, dict[str, str]]]] = Field(default_factory=dict)
54 changes: 54 additions & 0 deletions nsc/config/saved_searches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Framework-free lookups over the `saved_searches` config mapping.

Keeps saved-search resolution out of `cli/` and `tui/` so both surfaces share
one implementation and the logic stays unit-testable without any I/O.
"""

from __future__ import annotations

from nsc.config.models import Config

_MIN_PRINTABLE = 0x20


class InvalidSavedSearchName(ValueError):
"""Raised when a saved-search name is unsafe to persist."""


def validate_saved_search_name(name: str) -> None:
"""Reject names that would corrupt config.yaml or surprise the user.

Saved searches are persisted at the dotted config path
``saved_searches.<tag>.<resource>.<name>`` and the writer splits on ``.``,
so a name containing ``.`` would be written as a *nested map* rather than a
leaf — making the whole file fail ``Config.model_validate`` on the next run.
Control characters and surrounding whitespace are rejected for the same
"don't persist a name that won't round-trip cleanly" reason.
"""
if not name or name != name.strip():
raise InvalidSavedSearchName(
"saved-search name must be non-empty and not start or end with whitespace"
)
if "." in name:
raise InvalidSavedSearchName(
f"saved-search name {name!r} may not contain '.' "
"(it is used as a config-path separator)"
)
if any((ch.isspace() and ch != " ") or ord(ch) < _MIN_PRINTABLE for ch in name):
raise InvalidSavedSearchName(
f"saved-search name {name!r} may not contain tabs, newlines, "
"or other control characters"
)


def get_saved_search(config: Config, tag: str, resource: str, name: str) -> dict[str, str] | None:
"""The stored filter params for `<tag>.<resource>.<name>`, or None if absent."""
params = config.saved_searches.get(tag, {}).get(resource, {}).get(name)
if params is None:
return None
return dict(params)


def list_saved_searches(config: Config, tag: str, resource: str) -> list[str]:
"""Sorted names of saved searches for `<tag>.<resource>` (empty if none)."""
return sorted(config.saved_searches.get(tag, {}).get(resource, {}))
9 changes: 9 additions & 0 deletions nsc/tui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@
__all__ = ["run_tui"]


SavedSearchMap = dict[str, dict[str, dict[str, dict[str, str]]]]


def run_tui(
model: CommandModel,
client: Any,
*,
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,
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,
) -> None:
"""Lazy entrypoint so importing `nsc.tui` never imports Textual eagerly."""
from nsc.tui.app import run_tui as _run # noqa: PLC0415 # deferred: keeps Textual lazy.
Expand All @@ -27,4 +33,7 @@ def run_tui(
initial_resource=initial_resource,
save_columns=save_columns,
column_prefs=column_prefs,
saved_searches=saved_searches,
save_search=save_search,
delete_search=delete_search,
)
30 changes: 30 additions & 0 deletions nsc/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from nsc.tui.screens.picker import ResourcePicker
from nsc.tui.widgets.help import HelpOverlay

SavedSearchMap = dict[str, dict[str, dict[str, dict[str, str]]]]


class NscTuiApp(App[None]):
BINDINGS: ClassVar[list[BindingType]] = textual_bindings("global")
Expand All @@ -32,13 +34,19 @@ 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,
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,
) -> None:
super().__init__()
self._model = model
self._client = client
self._initial_resource = initial_resource
self._save_columns = save_columns
self._column_prefs = column_prefs or {}
self._saved_searches: SavedSearchMap = saved_searches or {}
self._save_search = save_search
self._delete_search = delete_search

def columns_for(self, tag: str, resource: str) -> list[str] | None:
"""Saved visible columns for a resource, if any (read by ListScreen)."""
Expand All @@ -51,6 +59,22 @@ def save_columns(self, tag: str, resource: str, columns: list[str]) -> None:
if self._save_columns is not None:
self._save_columns(tag, resource, columns)

def saved_searches_for(self, tag: str, resource: str) -> dict[str, dict[str, str]]:
"""Named saved filter sets for a resource (read by FilterScreen)."""
return self._saved_searches.get(tag, {}).get(resource, {})

def save_search(self, tag: str, resource: str, name: str, params: dict[str, str]) -> None:
# Update the in-memory map too so the picker reflects the new entry
# immediately, without a relaunch.
self._saved_searches.setdefault(tag, {}).setdefault(resource, {})[name] = dict(params)
if self._save_search is not None:
self._save_search(tag, resource, name, params)

def delete_search(self, tag: str, resource: str, name: str) -> None:
self._saved_searches.get(tag, {}).get(resource, {}).pop(name, None)
if self._delete_search is not None:
self._delete_search(tag, resource, name)

def on_mount(self) -> None:
ref = self._resolve_initial()
if ref is None:
Expand Down Expand Up @@ -99,11 +123,17 @@ 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,
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,
) -> None:
NscTuiApp(
model,
client,
initial_resource=initial_resource,
save_columns=save_columns,
column_prefs=column_prefs,
saved_searches=saved_searches,
save_search=save_search,
delete_search=delete_search,
).run()
6 changes: 5 additions & 1 deletion nsc/tui/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from dataclasses import dataclass

Context = str # "global" | "list" | "detail" | "edit" | "bulk"
Context = str # "global" | "list" | "detail" | "edit" | "bulk" | "filter"

# Textual key identifiers have no printable form; map them to the glyph the
# user actually presses so footer and help do not show raw tokens.
Expand Down Expand Up @@ -65,6 +65,9 @@ def _b(keys: str, action: str, description: str, context: Context, show: bool =
_b("b", "go_back", "Back", "edit"),
_b("p", "preview", "Preview changes", "bulk"),
_b("b", "go_back", "Back", "bulk"),
_b("ctrl+s", "apply", "Apply", "filter"),
_b("ctrl+w", "save_search", "Save search", "filter"),
_b("ctrl+o", "load_search", "Load saved", "filter"),
)


Expand All @@ -81,6 +84,7 @@ def help_groups() -> dict[Context, list[KeyBinding]]:
"detail": [],
"edit": [],
"bulk": [],
"filter": [],
}
for b in KEYMAP:
groups[b.context].append(b)
Expand Down
Loading
Loading