refactor(hermes_cli): plugins_cmd — partial-based enabled/disabled accessors, shared _table builder, compact composite UI (nav table + color-pair loop), fold small helpers
This commit is contained in:
+93
-151
@@ -65,6 +65,15 @@ def _console():
|
||||
return Console()
|
||||
|
||||
|
||||
def _table(columns, **kwargs):
|
||||
"""A Rich ``Table(**kwargs)`` with ``(header, style)`` *columns* added in order."""
|
||||
from rich.table import Table
|
||||
table = Table(**kwargs)
|
||||
for header, style in columns:
|
||||
table.add_column(header, style=style)
|
||||
return table
|
||||
|
||||
|
||||
def _is_tty() -> bool:
|
||||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||
|
||||
@@ -316,12 +325,11 @@ def _copy_example_files(plugin_dir: Path, console) -> None:
|
||||
def _missing_env_specs(manifest: dict) -> list[dict]:
|
||||
"""``requires_env`` entries (plain names or ``{name, description, url, secret}`` dicts,
|
||||
normalised to dicts; nameless entries dropped) whose variable is unset in ``~/.hermes/.env``."""
|
||||
env_specs: list[dict] = []
|
||||
for entry in manifest.get("requires_env") or []:
|
||||
if isinstance(entry, str):
|
||||
env_specs.append({"name": entry})
|
||||
elif isinstance(entry, dict) and entry.get("name"):
|
||||
env_specs.append(entry)
|
||||
env_specs = [
|
||||
{"name": entry} if isinstance(entry, str) else entry
|
||||
for entry in manifest.get("requires_env") or []
|
||||
if isinstance(entry, str) or (isinstance(entry, dict) and entry.get("name"))
|
||||
]
|
||||
if not env_specs:
|
||||
return []
|
||||
from hermes_cli.config import get_env_value
|
||||
@@ -470,20 +478,14 @@ def _git_head_revision(repo: Path, git_exe: str) -> str:
|
||||
|
||||
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str) -> None:
|
||||
"""Fetch and detach at one immutable commit, then verify the resulting HEAD."""
|
||||
steps = (
|
||||
(
|
||||
("fetch", "--depth", "1", "origin", revision),
|
||||
f"Git fetch of commit '{revision}' timed out after 60 seconds.",
|
||||
f"Git commit '{revision}' could not be fetched:\n"),
|
||||
(
|
||||
("checkout", "--detach", revision),
|
||||
f"Git checkout of commit '{revision}' timed out after 60 seconds.",
|
||||
f"Git checkout of commit '{revision}' failed:\n"))
|
||||
for args, timeout_msg, failure_prefix in steps:
|
||||
for verb, args, failure_prefix in (
|
||||
("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"),
|
||||
("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"),
|
||||
):
|
||||
try:
|
||||
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise PluginOperationError(timeout_msg) from exc
|
||||
raise PluginOperationError(f"Git {verb} of commit '{revision}' timed out after 60 seconds.") from exc
|
||||
actual = _git_head_revision(repo, git_exe)
|
||||
if actual != revision:
|
||||
raise PluginOperationError(
|
||||
@@ -613,12 +615,9 @@ def _install_plugin_core(
|
||||
# Reinstalling the same pinned source retains its pin, even if its plugin
|
||||
# directory was manually removed. Moving a pin requires an explicit --ref.
|
||||
if requested_revision is None:
|
||||
matching_pins = [
|
||||
entry for entry in old_metadata.values()
|
||||
if entry.get("source") == source and entry.get("pinned") is True
|
||||
]
|
||||
if len(matching_pins) == 1 and isinstance(matching_pins[0].get("revision"), str):
|
||||
requested_revision = _normalize_exact_revision(matching_pins[0]["revision"])
|
||||
pins = [e for e in old_metadata.values() if e.get("source") == source and e.get("pinned") is True]
|
||||
if len(pins) == 1 and isinstance(pins[0].get("revision"), str):
|
||||
requested_revision = _normalize_exact_revision(pins[0]["revision"])
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".install-", dir=plugins_dir) as tmp:
|
||||
tmp_clone = Path(tmp) / "plugin"
|
||||
@@ -640,23 +639,14 @@ def _install_plugin_core(
|
||||
f"Plugin '{plugin_name}' already exists. Use force reinstall "
|
||||
f"or run `hermes plugins update {plugin_name}`.")
|
||||
prior = old_metadata.get(plugin_name)
|
||||
if (
|
||||
target.exists()
|
||||
and requested_revision is None
|
||||
and isinstance(prior, dict)
|
||||
and prior.get("pinned") is True
|
||||
):
|
||||
if target.exists() and requested_revision is None and isinstance(prior, dict) and prior.get("pinned") is True:
|
||||
raise PluginOperationError(
|
||||
f"Plugin '{plugin_name}' is pinned. Reinstall it with an explicit "
|
||||
"--ref <40-character commit SHA> to change its source or revision.")
|
||||
|
||||
new_metadata = {
|
||||
**old_metadata,
|
||||
plugin_name: {
|
||||
"pinned": requested_revision is not None,
|
||||
"revision": installed_revision,
|
||||
"source": source,
|
||||
},
|
||||
plugin_name: {"pinned": requested_revision is not None, "revision": installed_revision, "source": source},
|
||||
}
|
||||
_swap_in_plugin(tmp_target, target, Path(tmp) / "previous-plugin", old_metadata, new_metadata)
|
||||
|
||||
@@ -902,15 +892,24 @@ def cmd_remove(name: str) -> None:
|
||||
console.print()
|
||||
|
||||
|
||||
def _get_disabled_set() -> set:
|
||||
"""``plugins.disabled`` — an explicit deny-list that wins over ``plugins.enabled``."""
|
||||
return _config_name_set("plugins", "disabled")
|
||||
# ``plugins.disabled`` is an explicit deny-list that wins over the ``plugins.enabled`` allow-list.
|
||||
_get_disabled_set = functools.partial(_config_name_set, "plugins", "disabled")
|
||||
_get_enabled_set = functools.partial(_config_name_set, "plugins", "enabled")
|
||||
|
||||
|
||||
def _save_disabled_set(disabled: set) -> None:
|
||||
_write_config_value("plugins", "disabled", sorted(disabled))
|
||||
|
||||
|
||||
def _save_enabled_set(enabled: set) -> None:
|
||||
_write_config_value("plugins", "enabled", sorted(enabled))
|
||||
|
||||
|
||||
def _save_plugin_sets(enabled: set, disabled: set) -> None:
|
||||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
|
||||
|
||||
_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"})
|
||||
|
||||
|
||||
@@ -929,15 +928,6 @@ def ensure_basic_auth_plugin_enabled_in_config(cfg: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _get_enabled_set() -> set:
|
||||
"""``plugins.enabled`` — the opt-in allow-list (empty when missing)."""
|
||||
return _config_name_set("plugins", "enabled")
|
||||
|
||||
|
||||
def _save_enabled_set(enabled: set) -> None:
|
||||
_write_config_value("plugins", "enabled", sorted(enabled))
|
||||
|
||||
|
||||
def _discard_key_and_leaf(names: set, key: str) -> None:
|
||||
"""Drop *key* and its bare leaf (``observability/langfuse`` -> ``langfuse``) from *names*, so a
|
||||
stale legacy bare-name entry can't keep vetoing the canonical key."""
|
||||
@@ -954,11 +944,6 @@ def _set_plugin_enabled(name: str, *, enable: bool) -> None:
|
||||
_save_plugin_sets(enabled, disabled)
|
||||
|
||||
|
||||
def _save_plugin_sets(enabled: set, disabled: set) -> None:
|
||||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
|
||||
|
||||
def _resolve_plugin_key(name: str) -> Optional[str]:
|
||||
"""Canonical registry key for a manifest name / directory name / path key, or ``None``.
|
||||
The single normalization point so enable/disable write the key ``PluginManager`` gates on."""
|
||||
@@ -1150,12 +1135,12 @@ def cmd_capabilities(name: Optional[str] = None) -> None:
|
||||
if not declared:
|
||||
console.print(" declared: [dim](none)[/dim]")
|
||||
for cap in declared:
|
||||
if cap in effective:
|
||||
mark = "[green]granted[/green]"
|
||||
if cap not in granted:
|
||||
mark += " [dim](via legacy allow_* key — deprecated)[/dim]"
|
||||
else:
|
||||
if cap not in effective:
|
||||
mark = "[yellow]not granted[/yellow]"
|
||||
elif cap in granted:
|
||||
mark = "[green]granted[/green]"
|
||||
else:
|
||||
mark = "[green]granted[/green] [dim](via legacy allow_* key — deprecated)[/dim]"
|
||||
console.print(f" {cap}: {mark}")
|
||||
for cap in sorted(effective - set(declared)):
|
||||
console.print(f" {cap}: [green]granted[/green] [dim](not declared in manifest)[/dim]")
|
||||
@@ -1296,11 +1281,8 @@ def _discover_all_plugins() -> list:
|
||||
|
||||
def _plugin_status(name: str, enabled: set, disabled: set, key: str = "") -> str:
|
||||
"""User-facing activation state for a plugin name or key."""
|
||||
if name in disabled or key in disabled:
|
||||
return "disabled"
|
||||
if name in enabled or key in enabled:
|
||||
return "enabled"
|
||||
return "not enabled"
|
||||
names = {name, key}
|
||||
return "disabled" if names & disabled else "enabled" if names & enabled else "not enabled"
|
||||
|
||||
|
||||
def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set) -> list:
|
||||
@@ -1321,7 +1303,6 @@ _STATUS_MARKUP = {"disabled": "[red]disabled[/red]", "enabled": "[green]enabled[
|
||||
|
||||
def cmd_list(args: Any | None = None) -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state."""
|
||||
from rich.table import Table
|
||||
console = _console()
|
||||
entries = _discover_all_plugins()
|
||||
if not entries:
|
||||
@@ -1351,12 +1332,9 @@ def cmd_list(args: Any | None = None) -> None:
|
||||
console.print("[dim]No plugins matched the selected filters.[/dim]")
|
||||
return
|
||||
|
||||
table = Table(title="Plugins", show_lines=False)
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Status")
|
||||
table.add_column("Version", style="dim")
|
||||
table.add_column("Description")
|
||||
table.add_column("Source", style="dim")
|
||||
table = _table(
|
||||
(("Name", "bold"), ("Status", None), ("Version", "dim"), ("Description", None), ("Source", "dim")),
|
||||
title="Plugins", show_lines=False)
|
||||
for name, status_name, version, description, source in rows:
|
||||
status = _STATUS_MARKUP.get(status_name, "[yellow]not enabled[/yellow]")
|
||||
table.add_row(name, status, version, description, source)
|
||||
@@ -1485,16 +1463,13 @@ def cmd_toggle() -> None:
|
||||
# name let plugins.disabled drift so "explicit disable wins" kept a plugin off forever.
|
||||
plugin_keys = [entry[5] for entry in entries]
|
||||
plugin_labels = [
|
||||
(f"{name} \u2014 {description}" if description else name)
|
||||
+ (" [bundled]" if source == "bundled" else "")
|
||||
(f"{name} \u2014 {description}" if description else name) + (" [bundled]" if source == "bundled" else "")
|
||||
for name, _version, description, source, _d, _key in entries
|
||||
]
|
||||
# Selected when enabled AND not disabled; the legacy bare name counts on either side.
|
||||
plugin_selected = {
|
||||
i for i, (name, _v, _desc, _src, _d, key) in enumerate(entries)
|
||||
if (key in enabled_set or name in enabled_set)
|
||||
and key not in disabled_set
|
||||
and name not in disabled_set
|
||||
if {key, name} & enabled_set and not ({key, name} & disabled_set)
|
||||
}
|
||||
categories = _provider_categories()
|
||||
|
||||
@@ -1503,11 +1478,9 @@ def cmd_toggle() -> None:
|
||||
return
|
||||
try:
|
||||
import curses
|
||||
_run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected,
|
||||
disabled_set, categories, console)
|
||||
_run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console)
|
||||
except ImportError:
|
||||
_run_composite_fallback(plugin_keys, plugin_labels, plugin_selected,
|
||||
disabled_set, categories, console)
|
||||
_run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disabled_set, categories, console)
|
||||
|
||||
|
||||
def _persist_plugin_selection(plugin_keys, chosen, disabled) -> tuple[bool, set]:
|
||||
@@ -1536,26 +1509,33 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disab
|
||||
"""Custom curses screen with checkboxes + category action rows."""
|
||||
from hermes_cli.curses_ui import _addnstr, flush_stdin
|
||||
chosen = set(plugin_selected)
|
||||
n_plugins = len(plugin_keys)
|
||||
n_categories = len(categories)
|
||||
n_plugins, n_categories = len(plugin_keys), len(categories)
|
||||
total_items = n_plugins + n_categories # navigable rows (headers/separator are skipped)
|
||||
providers_changed = False
|
||||
nav = { # key -> new cursor, given (cursor, page_size)
|
||||
key: move
|
||||
for keys, move in (
|
||||
((curses.KEY_UP, ord("k")), lambda c, p: (c - 1) % total_items),
|
||||
((curses.KEY_DOWN, ord("j")), lambda c, p: (c + 1) % total_items),
|
||||
((curses.KEY_NPAGE, ord("f")), lambda c, p: min(total_items - 1, c + p)),
|
||||
((curses.KEY_PPAGE, ord("b")), lambda c, p: max(0, c - p)),
|
||||
((curses.KEY_HOME,), lambda c, p: 0),
|
||||
((curses.KEY_END,), lambda c, p: total_items - 1),
|
||||
)
|
||||
for key in keys
|
||||
}
|
||||
|
||||
def _init_colors():
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(3, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim gray
|
||||
gray = 8 if curses.COLORS > 8 else curses.COLOR_WHITE
|
||||
for pair, fg in ((1, curses.COLOR_GREEN), (2, curses.COLOR_YELLOW), (3, curses.COLOR_CYAN), (4, gray)):
|
||||
curses.init_pair(pair, fg, -1)
|
||||
|
||||
def _attr(base, pair):
|
||||
return base | curses.color_pair(pair) if curses.has_colors() else base
|
||||
|
||||
def _put(stdscr, y, x, text, max_x, attr):
|
||||
_addnstr(stdscr, y, x, text, max_x - 1, attr)
|
||||
|
||||
def _row(text, idx, cursor, pair):
|
||||
"""One navigable body row: arrow marker + bold color when *idx* is the cursor."""
|
||||
arrow = "\u2192" if idx == cursor else " "
|
||||
@@ -1563,8 +1543,8 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disab
|
||||
|
||||
def _configure_category(ci):
|
||||
"""Leave curses, run the category's picker, refresh its row, re-enter curses."""
|
||||
curses.endwin()
|
||||
nonlocal providers_changed
|
||||
curses.endwin()
|
||||
cat_name, _cat_cur, cat_fn = categories[ci]
|
||||
if cat_fn():
|
||||
providers_changed = True
|
||||
@@ -1588,33 +1568,23 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disab
|
||||
lines.append(("", curses.A_NORMAL))
|
||||
if n_categories > 0:
|
||||
lines.append((" Provider Plugins", _attr(curses.A_BOLD, 2)))
|
||||
for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories):
|
||||
lines.append(_row(f" {cat_name:<24} \u25b8 {cat_current}", n_plugins + ci, cursor, 3))
|
||||
lines += [
|
||||
_row(f" {cat_name:<24} \u25b8 {cat_current}", n_plugins + ci, cursor, 3)
|
||||
for ci, (cat_name, cat_current, _cat_fn) in enumerate(categories)
|
||||
]
|
||||
return lines
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
_init_colors()
|
||||
nav = {} # key -> new cursor, given (cursor, page_size)
|
||||
for keys, move in (
|
||||
((curses.KEY_UP, ord("k")), lambda c, p: (c - 1) % total_items),
|
||||
((curses.KEY_DOWN, ord("j")), lambda c, p: (c + 1) % total_items),
|
||||
((curses.KEY_NPAGE, ord("f")), lambda c, p: min(total_items - 1, c + p)),
|
||||
((curses.KEY_PPAGE, ord("b")), lambda c, p: max(0, c - p)),
|
||||
((curses.KEY_HOME,), lambda c, p: 0),
|
||||
((curses.KEY_END,), lambda c, p: total_items - 1),
|
||||
):
|
||||
nav.update(dict.fromkeys(keys, move))
|
||||
cursor = scroll_offset = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
_put(stdscr, 0, 0, "Plugins", max_x, _attr(curses.A_BOLD, 2))
|
||||
_put(
|
||||
stdscr, 1, 0,
|
||||
" ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done",
|
||||
max_x, curses.A_DIM)
|
||||
_addnstr(stdscr, 0, 0, "Plugins", max_x - 1, _attr(curses.A_BOLD, 2))
|
||||
_addnstr(
|
||||
stdscr, 1, 0, " ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done",
|
||||
max_x - 1, curses.A_DIM)
|
||||
visible_rows = max_y - 4
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
@@ -1623,7 +1593,7 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disab
|
||||
lines = _body_lines(cursor, scroll_offset, visible_rows)
|
||||
for y, (text, attr) in enumerate(lines[: max(0, max_y - 4)], start=3):
|
||||
if text:
|
||||
_put(stdscr, y, 0, text, max_x, attr)
|
||||
_addnstr(stdscr, y, 0, text, max_x - 1, attr)
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
@@ -1744,11 +1714,7 @@ def _get_plugin_toolset_key(name: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
def _first_toolset(tool_names) -> Optional[str]:
|
||||
for tool_name in tool_names:
|
||||
entry = registry.get_entry(tool_name)
|
||||
if entry and entry.toolset:
|
||||
return entry.toolset
|
||||
return None
|
||||
return next((e.toolset for t in tool_names if (e := registry.get_entry(t)) and e.toolset), None)
|
||||
|
||||
def _from_loaded_plugin() -> Optional[str]:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
@@ -1760,12 +1726,11 @@ def _get_plugin_toolset_key(name: str) -> Optional[str]:
|
||||
|
||||
def _from_manifest_on_disk() -> Optional[str]:
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
for base in (get_bundled_plugins_dir(), _plugins_dir()):
|
||||
if base.is_dir() and (base / name).is_dir() and (
|
||||
toolset := _first_toolset(_read_manifest(base / name).get("provides_tools") or [])
|
||||
):
|
||||
return toolset
|
||||
return None
|
||||
return next((
|
||||
toolset for base in (get_bundled_plugins_dir(), _plugins_dir())
|
||||
if base.is_dir() and (base / name).is_dir()
|
||||
and (toolset := _first_toolset(_read_manifest(base / name).get("provides_tools") or []))
|
||||
), None)
|
||||
|
||||
for lookup in (_from_loaded_plugin, _from_manifest_on_disk):
|
||||
try:
|
||||
@@ -1787,14 +1752,8 @@ def _toggle_plugin_toolset(name: str, *, enable: bool) -> None:
|
||||
platform_toolsets = _child_dict(config, "platform_toolsets")
|
||||
changed = False
|
||||
for ts_list in platform_toolsets.values():
|
||||
if not isinstance(ts_list, list):
|
||||
continue
|
||||
if enable:
|
||||
if toolset_key not in ts_list:
|
||||
ts_list.append(toolset_key)
|
||||
changed = True
|
||||
elif toolset_key in ts_list:
|
||||
ts_list.remove(toolset_key)
|
||||
if isinstance(ts_list, list) and enable != (toolset_key in ts_list):
|
||||
(ts_list.append if enable else ts_list.remove)(toolset_key)
|
||||
changed = True
|
||||
# Enabling with no platform lists yet: seed "cli" at minimum.
|
||||
if enable and not changed and not platform_toolsets:
|
||||
@@ -1852,13 +1811,9 @@ def _clear_plugin_bytecode(target: Path) -> int:
|
||||
removed = 0
|
||||
try:
|
||||
for cache_dir in target.rglob("__pycache__"):
|
||||
if not cache_dir.is_dir():
|
||||
continue
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
if cache_dir.is_dir():
|
||||
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||
removed += 0 if cache_dir.exists() else 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
@@ -1991,26 +1946,17 @@ def cmd_search(
|
||||
results = search_index(entries, term, capability=capability)
|
||||
if json_output:
|
||||
print(json.dumps(
|
||||
{
|
||||
"source": source,
|
||||
"query": term,
|
||||
"results": [e.to_dict() for e in results],
|
||||
"note": SECURITY_FOOTER,
|
||||
},
|
||||
indent=2,
|
||||
))
|
||||
{"source": source, "query": term, "results": [e.to_dict() for e in results], "note": SECURITY_FOOTER},
|
||||
indent=2))
|
||||
return
|
||||
|
||||
if not results:
|
||||
console.print(f"[yellow]No plugins matched '{term}'[/yellow] [dim](index source: {source})[/dim]")
|
||||
return
|
||||
|
||||
from rich.table import Table
|
||||
table = Table(title=f"Community plugins ({len(results)} match{'es' if len(results) != 1 else ''})")
|
||||
table.add_column("Name", style="bold")
|
||||
table.add_column("Description")
|
||||
table.add_column("Author")
|
||||
table.add_column("Tags", style="dim")
|
||||
table = _table(
|
||||
(("Name", "bold"), ("Description", None), ("Author", None), ("Tags", "dim")),
|
||||
title=f"Community plugins ({len(results)} match{'es' if len(results) != 1 else ''})")
|
||||
for e in results:
|
||||
desc = e.description if len(e.description) <= 70 else e.description[:67] + "..."
|
||||
table.add_row(e.name, desc, e.author, ", ".join(e.tags))
|
||||
@@ -2021,11 +1967,7 @@ def cmd_search(
|
||||
|
||||
def _tri_state_flag(args, yes_attr: str, no_attr: str) -> Optional[bool]:
|
||||
"""Map an argparse ``--x`` / ``--no-x`` pair to True / False / None (neither given)."""
|
||||
if getattr(args, yes_attr, False):
|
||||
return True
|
||||
if getattr(args, no_attr, False):
|
||||
return False
|
||||
return None
|
||||
return True if getattr(args, yes_attr, False) else (False if getattr(args, no_attr, False) else None)
|
||||
|
||||
|
||||
def _action_pack(args):
|
||||
|
||||
Reference in New Issue
Block a user