From e3f57b5e35a5daa6530f354bf68c58b041371e1c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 21 Jun 2022 21:07:44 -0400 Subject: [PATCH 01/10] [wip] adding npe list --- npe2/cli.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/npe2/cli.py b/npe2/cli.py index f767538f..2d957e12 100644 --- a/npe2/cli.py +++ b/npe2/cli.py @@ -126,6 +126,42 @@ def parse( _pprint_formatted(manifest_string, fmt) +@app.command() +def list(): + from rich.console import Console + from rich.table import Table + + from npe2 import PluginManager + + pm = PluginManager.instance() + pm.discover(include_npe1=True) + + table = Table(title="Installed napari plugins") + + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Version", style="magenta") + table.add_column("npe2") + table.add_column("Contributions", style="green") + + rows = ( + ( + mf.name, + mf.package_version, + "" if mf.npe1_shim else ":white_check_mark:", + ", ".join( + [f"{k}({len(v)})" for k, v in mf.contributions.dict().items() if v] + ), + ) + for mf in pm.iter_manifests() + ) + + for row in sorted(rows, key=lambda r: r[0]): + table.add_row(*row) + + console = Console() + console.print(table) + + @app.command() def fetch( name: str, From 6fb4c0b98b61ac3682ba9147ae8f4196fd0a020b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 22 Jun 2022 07:54:14 -0400 Subject: [PATCH 02/10] add field selection and sorting --- npe2/cli.py | 73 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/npe2/cli.py b/npe2/cli.py index 2d957e12..e105265b 100644 --- a/npe2/cli.py +++ b/npe2/cli.py @@ -1,9 +1,10 @@ import builtins import warnings from enum import Enum +from itertools import cycle from pathlib import Path from textwrap import indent -from typing import List, Optional +from typing import Any, List, Optional import typer @@ -127,35 +128,69 @@ def parse( @app.command() -def list(): +def list(fields: str = "name,version,npe2,contributions", sort: str = "0"): from rich.console import Console from rich.table import Table from npe2 import PluginManager + _fields = [f.lower() for f in fields.split(",")] + + try: + _sort_by = int(sort) + sort_index = _sort_by + except ValueError: + sort_index = _fields.index(sort.lower()) + if sort_index >= len(_fields): + raise typer.BadParameter( + f"Invalid sort value {sort!r}. " + f"Must be integer <{len(_fields)} or one of: " + ", ".join(_fields) + ) + pm = PluginManager.instance() pm.discover(include_npe1=True) - table = Table(title="Installed napari plugins") + table = Table() - table.add_column("Name", style="cyan", no_wrap=True) - table.add_column("Version", style="magenta") - table.add_column("npe2") - table.add_column("Contributions", style="green") + for f, color in zip(_fields, cycle(["cyan", "magenta", "green", "yellow"])): + name = f.split(".")[-1].replace("_", " ").title() + table.add_column(name, style=color) - rows = ( - ( - mf.name, - mf.package_version, - "" if mf.npe1_shim else ":white_check_mark:", - ", ".join( - [f"{k}({len(v)})" for k, v in mf.contributions.dict().items() if v] - ), - ) - for mf in pm.iter_manifests() - ) + _aliases = {"version": "package_version", "npe2": "!npe1_shim", "npe1": "npe1_shim"} - for row in sorted(rows, key=lambda r: r[0]): + def _get_field(mf: PluginManifest, field: str): + field = _aliases.get(field, field) + if field == "contributions": + return ", ".join( + [f"{k}({len(v)})" for k, v in mf.contributions.dict().items() if v] + ) + negate = False + if field.startswith("!"): + field = field[1:] + negate = True + + parts = field.split(".") + try: + val: Any = mf + while parts: + val = getattr(val, parts.pop(0)) + except AttributeError as e: + raise typer.BadParameter(f"Unknown field name: {field!r}") from e + + if negate: + val = not val + if (f := mf.__fields__.get(field)) and f.outer_type_ == bool: + return ":white_check_mark:" if val else "" + if isinstance(val, (builtins.list, tuple)): + return ", ".join([str(v) for v in val]) + return str(val) + + rows = [] + for mf in pm.iter_manifests(): + row = [_get_field(mf, field) for field in _fields] + rows.append(row) + + for row in sorted(rows, key=lambda r: r[sort_index]): table.add_row(*row) console = Console() From 680e55035ca73b8178b5449f7bc80db3fa6a32f8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 22 Jun 2022 08:00:48 -0400 Subject: [PATCH 03/10] remove line --- npe2/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/npe2/cli.py b/npe2/cli.py index e105265b..fed24bd3 100644 --- a/npe2/cli.py +++ b/npe2/cli.py @@ -61,7 +61,6 @@ def validate( ), ): """Validate manifest for a distribution name or manifest filepath.""" - err: Optional[Exception] = None try: pm = PluginManifest._from_package_or_name(name) @@ -323,8 +322,7 @@ def cache( from npe2.manifest._npe1_adapter import ADAPTER_CACHE, clear_cache if clear: - _cleared = clear_cache(names) - if _cleared: + if _cleared := clear_cache(names): nf = "\n".join(f" - {i.name}" for i in _cleared) typer.secho("Cleared these files from cache:") typer.secho(nf, fg=typer.colors.RED) From 9c798c82076d3b1b1a728d88650ef3b44df2952e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 22 Jun 2022 08:04:47 -0400 Subject: [PATCH 04/10] better error --- npe2/cli.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/npe2/cli.py b/npe2/cli.py index fed24bd3..8dfe5f52 100644 --- a/npe2/cli.py +++ b/npe2/cli.py @@ -135,16 +135,20 @@ def list(fields: str = "name,version,npe2,contributions", sort: str = "0"): _fields = [f.lower() for f in fields.split(",")] + bad_param_msg = ( + f"Invalid sort value {sort!r}. " + f"Must be column index (<{len(_fields)}) or one of: " + ", ".join(_fields) + ) try: _sort_by = int(sort) sort_index = _sort_by + if sort_index >= len(_fields): + raise typer.BadParameter(bad_param_msg) except ValueError: - sort_index = _fields.index(sort.lower()) - if sort_index >= len(_fields): - raise typer.BadParameter( - f"Invalid sort value {sort!r}. " - f"Must be integer <{len(_fields)} or one of: " + ", ".join(_fields) - ) + try: + sort_index = _fields.index(sort.lower()) + except ValueError: + raise typer.BadParameter(bad_param_msg) pm = PluginManager.instance() pm.discover(include_npe1=True) From a3fb468393c33732b01fc6cc61301787b1addb1c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 24 Jun 2022 08:01:27 -0400 Subject: [PATCH 05/10] update format options --- npe2/cli.py | 178 ++++++++++++++++++++++++++++++++-------------------- setup.cfg | 3 +- 2 files changed, 111 insertions(+), 70 deletions(-) diff --git a/npe2/cli.py b/npe2/cli.py index 8dfe5f52..b371ece3 100644 --- a/npe2/cli.py +++ b/npe2/cli.py @@ -1,14 +1,15 @@ import builtins import warnings from enum import Enum -from itertools import cycle from pathlib import Path -from textwrap import indent -from typing import Any, List, Optional +from typing import TYPE_CHECKING, Any, List, Optional, Sequence import typer -from npe2 import PluginManifest +from npe2 import PluginManager, PluginManifest + +if TYPE_CHECKING: + from rich.console import RenderableType app = typer.Typer() @@ -21,29 +22,59 @@ class Format(str, Enum): toml = "toml" +class ListFormat(str, Enum): + table = "table" + json = "json" # alias for json in pandas "records" format + yaml = "yaml" + compact = "compact" + + def _pprint_formatted(string, format: Format = Format.yaml): # pragma: no cover """Print yaml nicely, depending on available modules.""" - try: - from rich.console import Console - from rich.syntax import Syntax + from rich.console import Console + from rich.syntax import Syntax - Console().print(Syntax(string, format.value, theme="fruity")) - except ImportError: - typer.echo(string) + Console().print(Syntax(string, format.value, theme="fruity")) def _pprint_exception(err: Exception): + from rich.console import Console + from rich.traceback import Traceback + e_info = (type(err), err, err.__traceback__) - try: - from rich.console import Console - from rich.traceback import Traceback - trace = Traceback.extract(*e_info, show_locals=True) - Console().print(Traceback(trace)) - except ImportError: - import traceback + trace = Traceback.extract(*e_info, show_locals=True) + Console().print(Traceback(trace)) - typer.echo("\n" + "".join(traceback.format_exception(*e_info))) + +def _pprint_table( + headers: Sequence["RenderableType"], rows: Sequence[Sequence["RenderableType"]] +): + from itertools import cycle + + from rich.console import Console + from rich.table import Table + + COLORS = ["cyan", "magenta", "green", "yellow"] + EMOJI_TRUE = ":white_check_mark:" + EMOJI_FALSE = "" + + table = Table() + for head, color in zip(headers, cycle(COLORS)): + table.add_column(head, style=color) + for row in rows: + strings = [] + for r in row: + val = "" + if isinstance(r, dict): + val = ", ".join(f"{k} ({v})" for k, v in r.items()) + elif r: + val = str(r).replace("True", EMOJI_TRUE).replace("False", EMOJI_FALSE) + strings.append(val) + table.add_row(*strings) + + console = Console() + console.print(table) @app.command() @@ -126,78 +157,87 @@ def parse( _pprint_formatted(manifest_string, fmt) +def _get_field(mf: PluginManifest, field: str): + if field == "contributions": + return {k: len(v) for k, v in mf.contributions.dict().items() if v} + + negate = False + if field.startswith("!"): + field = field[1:] + negate = True + + parts = field.split(".") + try: + val: Any = mf + while parts: + val = getattr(val, parts.pop(0)) + except AttributeError as e: + raise typer.BadParameter(f"Unknown field name: {field!r}") from e + + if negate: + val = not val + if isinstance(val, (builtins.list, tuple)): + return ", ".join([str(v) for v in val]) + return val + + @app.command() -def list(fields: str = "name,version,npe2,contributions", sort: str = "0"): - from rich.console import Console - from rich.table import Table +def list( + fields: str = "name,version,npe2,contributions", + sort: str = "0", + format: ListFormat = ListFormat.table, +): - from npe2 import PluginManager + if format == ListFormat.compact: + fields = "name,version,npe2,contributions" _fields = [f.lower() for f in fields.split(",")] - bad_param_msg = ( + bad_sort_param_msg = ( f"Invalid sort value {sort!r}. " f"Must be column index (<{len(_fields)}) or one of: " + ", ".join(_fields) ) try: - _sort_by = int(sort) - sort_index = _sort_by - if sort_index >= len(_fields): - raise typer.BadParameter(bad_param_msg) + if (sort_index := int(sort)) >= len(_fields): + raise typer.BadParameter(bad_sort_param_msg) except ValueError: try: sort_index = _fields.index(sort.lower()) - except ValueError: - raise typer.BadParameter(bad_param_msg) + except ValueError as e: + raise typer.BadParameter(bad_sort_param_msg) from e pm = PluginManager.instance() pm.discover(include_npe1=True) - table = Table() - - for f, color in zip(_fields, cycle(["cyan", "magenta", "green", "yellow"])): - name = f.split(".")[-1].replace("_", " ").title() - table.add_column(name, style=color) - - _aliases = {"version": "package_version", "npe2": "!npe1_shim", "npe1": "npe1_shim"} - - def _get_field(mf: PluginManifest, field: str): - field = _aliases.get(field, field) - if field == "contributions": - return ", ".join( - [f"{k}({len(v)})" for k, v in mf.contributions.dict().items() if v] - ) - negate = False - if field.startswith("!"): - field = field[1:] - negate = True - - parts = field.split(".") - try: - val: Any = mf - while parts: - val = getattr(val, parts.pop(0)) - except AttributeError as e: - raise typer.BadParameter(f"Unknown field name: {field!r}") from e - - if negate: - val = not val - if (f := mf.__fields__.get(field)) and f.outer_type_ == bool: - return ":white_check_mark:" if val else "" - if isinstance(val, (builtins.list, tuple)): - return ", ".join([str(v) for v in val]) - return str(val) + ALIASES = {"version": "package_version", "npe2": "!npe1_shim", "npe1": "npe1_shim"} rows = [] for mf in pm.iter_manifests(): - row = [_get_field(mf, field) for field in _fields] + row = [_get_field(mf, ALIASES.get(f, f)) for f in _fields] rows.append(row) - for row in sorted(rows, key=lambda r: r[sort_index]): - table.add_row(*row) + rows = sorted(rows, key=lambda r: r[sort_index]) - console = Console() - console.print(table) + if format == ListFormat.table: + headers = [f.split(".")[-1].replace("_", " ").title() for f in _fields] + _pprint_table(headers=headers, rows=rows) + return + + # [{column -> value}, ... , {column -> value}] + data: List[dict] = [dict(zip(_fields, row)) for row in rows] + if format == ListFormat.json: + import json + + _pprint_formatted(json.dumps(data, indent=1), Format.json) + elif format in (ListFormat.yaml): + import yaml + + _pprint_formatted(yaml.safe_dump(data, sort_keys=False), Format.yaml) + elif format in (ListFormat.compact): + template = "- {name}: {version} ({ncontrib} contributions)" + for r in data: + ncontrib = sum(r.get("contributions", {}).values()) + typer.echo(template.format(**r, ncontrib=ncontrib)) @app.command() @@ -276,6 +316,8 @@ def convert( else: pm = manifest_from_npe1(str(path)) if w: + from textwrap import indent + typer.secho("Some issues occured:", fg=typer.colors.RED, bold=False) for r in w: typer.secho( diff --git a/setup.cfg b/setup.cfg index dfe5f43b..1533b0fc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ install_requires = psygnal>=0.3.0 pydantic pytomlpp + rich typer python_requires = >=3.8 include_package_data = True @@ -58,7 +59,6 @@ dev = pydocstyle pytest pytest-cov - rich typer docs = Jinja2 @@ -69,7 +69,6 @@ testing = numpy pytest pytest-cov - rich [bdist_wheel] universal = 1 From 2003f188798db20a3fcbde774e3dc0ed4ecd4a08 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 24 Jun 2022 08:04:22 -0400 Subject: [PATCH 06/10] add space to compact --- npe2/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npe2/cli.py b/npe2/cli.py index b371ece3..9bc63b32 100644 --- a/npe2/cli.py +++ b/npe2/cli.py @@ -234,7 +234,7 @@ def list( _pprint_formatted(yaml.safe_dump(data, sort_keys=False), Format.yaml) elif format in (ListFormat.compact): - template = "- {name}: {version} ({ncontrib} contributions)" + template = " - {name}: {version} ({ncontrib} contributions)" for r in data: ncontrib = sum(r.get("contributions", {}).values()) typer.echo(template.format(**r, ncontrib=ncontrib)) From 6d81327b1151f9c28b4d08cb4b195bd9a52cb263 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 24 Jun 2022 10:34:10 -0400 Subject: [PATCH 07/10] add `dict` method to plugin manager --- npe2/_plugin_manager.py | 107 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/npe2/_plugin_manager.py b/npe2/_plugin_manager.py index e10ea9bf..c85b2238 100644 --- a/npe2/_plugin_manager.py +++ b/npe2/_plugin_manager.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import ( TYPE_CHECKING, + AbstractSet, Any, Callable, DefaultDict, @@ -15,6 +16,7 @@ Iterable, Iterator, List, + Mapping, Optional, Sequence, Set, @@ -41,6 +43,12 @@ WidgetContribution, ) + IntStr = Union[int, str] + AbstractSetIntStr = AbstractSet[IntStr] + DictIntStrAny = Dict[IntStr, Any] + MappingIntStrAny = Mapping[IntStr, Any] + InclusionSet = Union[AbstractSetIntStr, MappingIntStrAny, None] + __all__ = ["PluginContext", "PluginManager"] PluginName = str # this is `PluginManifest.name` @@ -454,6 +462,65 @@ def iter_manifests( continue yield mf + def dict( + self, *, include: InclusionSet = None, exclude: InclusionSet = None + ) -> Dict[str, Any]: + """Return a dictionary with the state of the plugin manager. + + `include` and `exclude` will be passed to each `PluginManifest.dict()` + See pydantic documentation for details: + https://pydantic-docs.helpmanual.io/usage/exporting_models/#modeldict + + `include` and `exclude` may be a set of dotted strings, indicating + nested fields in the manifest model. For example: + + {'contributions.readers', 'package_metadata.description'} + + will be expanded to + + { + 'contributions': {'readers': True}, + 'package_metadata': {'description': True} + } + + This facilitates selection of nested fields on the command line. + + + Parameters + ---------- + include : InclusionSet, optional + A set of manifest fields to include, by default all fields are included. + exclude : InclusionSet, optional + A set of manifest fields to exclude, by default no fields are excluded. + + Returns + ------- + Dict[str, Any] + Dictionary with the state of the plugin manager. Keys will include + + - `'plugins'`: dict of `{name: manifest.dict()} for discovered plugins + - `'disabled'`: set of disabled plugins + - `'activated'`: set of activated plugins + + """ + # _include = + out: Dict[str, Any] = { + "plugins": { + mf.name: mf.dict( + include=_expand_dotted_set(include), + exclude=_expand_dotted_set(exclude), + ) + for mf in self.iter_manifests() + } + } + if not exclude or "disabled" not in exclude: + out["disabled"] = set(self._disabled_plugins) + if not exclude or "activated" not in exclude: + out["activated"] = { + name for name, ctx in self._contexts.items() if ctx._activated + } + return out + def __contains__(self, name: str) -> bool: return name in self._manifests @@ -616,3 +683,43 @@ def _call_python_name(python_name: PythonName, args=()) -> Any: func = import_python_name(python_name) if callable(func): return func(*args) + + +def _expand_dotted_set(inclusion_set: InclusionSet) -> InclusionSet: + """Expand a set of strings with dots to a dict of dicts. + + Examples + -------- + >>> _expand_dotted_set({'a.b', 'c', 'a.d'}) + {'a': {'b': True, 'd': True}, 'c': True} + + >>> _expand_dotted_set({'a.b', 'a.d.e', 'a'}) + {'a'} + + >>> _expand_dotted_set({'a.b', 'a.d', 'x.y.z'}) + {'x': {'y': {'z': True}}, 'a': {'d': True, 'b': True}} + """ + if not isinstance(inclusion_set, set) or all( + "." not in str(s) for s in inclusion_set + ): + return inclusion_set + + result: Dict[IntStr, Any] = {} + # sort the strings based on the number of dots, + # so that higher level keys take precedence + # e.g. {'a.b', 'a.d.e', 'a'} -> {'a'} + for key in sorted(inclusion_set, key=lambda i: i.count("."), reverse=True): + if isinstance(key, str): + parts = key.split(".") + if len(parts) == 1: + result[key] = True + else: + cur = result + for part in parts[:-1]: + # integer keys are used in pydantic for lists + # they must remain integers + _p: IntStr = int(part) if part.isdigit() else part + cur = cur.setdefault(_p, {}) + cur[parts[-1]] = True + + return result From cd2f8e45222f5c767910e9afd99293df58fc424f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 24 Jun 2022 10:46:41 -0400 Subject: [PATCH 08/10] add tests --- npe2/plugin_manager.py | 10 ++++++++-- tests/test_plugin_manager.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/npe2/plugin_manager.py b/npe2/plugin_manager.py index e88220d7..06d509cf 100644 --- a/npe2/plugin_manager.py +++ b/npe2/plugin_manager.py @@ -5,10 +5,10 @@ if TYPE_CHECKING: from os import PathLike - from typing import Iterator, List, NewType, Optional, Sequence, Tuple, Union + from typing import Any, Iterator, List, NewType, Optional, Sequence, Tuple, Union from npe2 import PluginManifest - from npe2._plugin_manager import PluginContext + from npe2._plugin_manager import InclusionSet, PluginContext from npe2.manifest import contributions from ._plugin_manager import PluginManager @@ -27,6 +27,12 @@ def discover(paths: Sequence[str] = (), clear=False, include_npe1=False) -> None """Discover and index plugin manifests in the environment.""" +def dict( + self, *, include: InclusionSet = None, exclude: InclusionSet = None +) -> Dict[str, Any]: + """Return a dictionary with the state of the plugin manager.""" + + def index_npe1_adapters() -> None: """Import and index any/all npe1 adapters.""" diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 1bef546c..9dc867eb 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -198,3 +198,21 @@ def test_warn_on_register_disabled(uses_sample_plugin, plugin_manager: PluginMan plugin_manager._manifests.pop(SAMPLE_PLUGIN_NAME) # NOT good way to "unregister" with pytest.warns(UserWarning): plugin_manager.register(mf) + + +def test_plugin_manager_dict(uses_sample_plugin, plugin_manager: PluginManager): + """Test exporting the plugin manager state with `dict()`.""" + d = plugin_manager.dict() + assert SAMPLE_PLUGIN_NAME in d["plugins"] + assert "disabled" in d + assert "activated" in d + + d = plugin_manager.dict( + include={"contributions", "package_metadata.version"}, + exclude={"contributions.writers", "contributions.readers"}, + ) + plugin_dict = d["plugins"][SAMPLE_PLUGIN_NAME] + assert set(plugin_dict) == {"contributions", "package_metadata"} + contribs = set(plugin_dict["contributions"]) + assert "readers" not in contribs + assert "writers" not in contribs From c04a92ebdbbc6f42573b71f5361d796a0e5ebcc9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 24 Jun 2022 11:44:51 -0400 Subject: [PATCH 09/10] add docs --- npe2/cli.py | 120 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 80 insertions(+), 40 deletions(-) diff --git a/npe2/cli.py b/npe2/cli.py index 9bc63b32..3562a5c8 100644 --- a/npe2/cli.py +++ b/npe2/cli.py @@ -2,7 +2,7 @@ import warnings from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Any, List, Optional, Sequence +from typing import TYPE_CHECKING, Iterator, List, Optional, Sequence import typer @@ -23,6 +23,8 @@ class Format(str, Enum): class ListFormat(str, Enum): + """Valid out formats for `npe2 list`.""" + table = "table" json = "json" # alias for json in pandas "records" format yaml = "yaml" @@ -157,74 +159,112 @@ def parse( _pprint_formatted(manifest_string, fmt) -def _get_field(mf: PluginManifest, field: str): - if field == "contributions": - return {k: len(v) for k, v in mf.contributions.dict().items() if v} +def _make_rows(pm_dict: dict, normed_fields: Sequence[str]) -> Iterator[List]: + """Cleanup output from pm.dict() into rows for table. - negate = False - if field.startswith("!"): - field = field[1:] - negate = True + outside of just extracting the fields we care about, this also: - parts = field.split(".") - try: - val: Any = mf - while parts: - val = getattr(val, parts.pop(0)) - except AttributeError as e: - raise typer.BadParameter(f"Unknown field name: {field!r}") from e + - handles nested fields expressed as dotted strings: `packge_metadata.version` + - negates fields that are prefixed with `!` + - simplifies contributions to a {name: count} dict. + + """ + for info in pm_dict["plugins"].values(): + row = [] + for field in normed_fields: + val = info.get(field.lstrip("!")) - if negate: - val = not val - if isinstance(val, (builtins.list, tuple)): - return ", ".join([str(v) for v in val]) - return val + # extact nested fields + if not val and "." in field: + parts = field.split(".") + val = info + while parts: + val = val[parts.pop(0)] + + # negate fields starting with ! + if field.startswith("!"): + val = not val + + # simplify contributions to just the number of contributions + if field == "contributions": + val = {k: len(v) for k, v in val.items() if v} + + row.append(val) + yield row @app.command() def list( - fields: str = "name,version,npe2,contributions", - sort: str = "0", - format: ListFormat = ListFormat.table, + fields: str = typer.Option( + "name,version,npe2,contributions", + help="Comma seperated list of fields to include in the output." + "Names may contain dots, indicating nested manifest fields " + "(`contributions.readers`). Fields names prefixed with `!` will be " + "negated in the output. Fields will appear in the table in the order in " + "which they are provided.", + metavar="FIELDS", + ), + sort: str = typer.Option( + "0", + "-s", + "--sort", + help="Field name or (int) index on which to sort.", + metavar="KEY", + ), + format: ListFormat = typer.Option( + "table", + "-f", + "--format", + help="Out format to use. When using 'compact', `--fields` is ignored ", + ), ): + """List currently installed plugins.""" if format == ListFormat.compact: - fields = "name,version,npe2,contributions" + fields = "name,version,contributions" - _fields = [f.lower() for f in fields.split(",")] + requested_fields = [f.lower() for f in fields.split(",")] + # check for sort values that will not work bad_sort_param_msg = ( f"Invalid sort value {sort!r}. " - f"Must be column index (<{len(_fields)}) or one of: " + ", ".join(_fields) + f"Must be column index (<{len(requested_fields)}) or one of: " + + ", ".join(requested_fields) ) try: - if (sort_index := int(sort)) >= len(_fields): + if (sort_index := int(sort)) >= len(requested_fields): raise typer.BadParameter(bad_sort_param_msg) except ValueError: try: - sort_index = _fields.index(sort.lower()) + sort_index = requested_fields.index(sort.lower()) except ValueError as e: raise typer.BadParameter(bad_sort_param_msg) from e + # some convenience aliases + ALIASES = { + "version": "package_metadata.version", + "summary": "package_metadata.summary", + "license": "package_metadata.license", + "author": "package_metadata.author", + "npe2": "!npe1_shim", + "npe1": "npe1_shim", + } + normed_fields = [ALIASES.get(f, f) for f in requested_fields] + pm = PluginManager.instance() pm.discover(include_npe1=True) - - ALIASES = {"version": "package_version", "npe2": "!npe1_shim", "npe1": "npe1_shim"} - - rows = [] - for mf in pm.iter_manifests(): - row = [_get_field(mf, ALIASES.get(f, f)) for f in _fields] - rows.append(row) - - rows = sorted(rows, key=lambda r: r[sort_index]) + pm_dict = pm.dict(include={f.lstrip("!") for f in normed_fields}) + rows = sorted(_make_rows(pm_dict, normed_fields), key=lambda r: r[sort_index]) if format == ListFormat.table: - headers = [f.split(".")[-1].replace("_", " ").title() for f in _fields] - _pprint_table(headers=headers, rows=rows) + heads = [f.split(".")[-1].replace("_", " ").title() for f in requested_fields] + _pprint_table(headers=heads, rows=rows) return + # standard records format used for the other formats # [{column -> value}, ... , {column -> value}] - data: List[dict] = [dict(zip(_fields, row)) for row in rows] + data: List[dict] = [dict(zip(requested_fields, row)) for row in rows] + if format == ListFormat.json: import json From b0be59856fa03d5ca3934f79d7f5db9cfd9205dd Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 24 Jun 2022 12:23:48 -0400 Subject: [PATCH 10/10] finish coverage --- tests/test_cli.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5192f772..fc339229 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -175,3 +175,28 @@ def test_cli_cache_clear_named(mock_cache): result = runner.invoke(app, ["cache", "--clear", "not-a-plugin"]) assert result.stdout == "Nothing to clear for plugins: not-a-plugin\n" assert result.exit_code == 0 + + +@pytest.mark.parametrize("format", ["table", "compact", "yaml", "json"]) +@pytest.mark.parametrize("fields", [None, "name,version,author"]) +def test_cli_list(format, fields, uses_npe1_plugin): + result = runner.invoke(app, ["list", "-f", format, "--fields", fields]) + assert result.exit_code == 0 + assert "npe1-plugin" in result.output + if fields and "author" in fields and format != "compact": + assert "author" in result.output.lower() + else: + assert "author" not in result.output.lower() + + +def test_cli_list_sort(uses_npe1_plugin): + result = runner.invoke(app, ["list", "--sort", "version"]) + assert result.exit_code == 0 + + result = runner.invoke(app, ["list", "--sort", "7"]) + assert result.exit_code + assert "Invalid sort value '7'" in result.output + + result = runner.invoke(app, ["list", "--sort", "notaname"]) + assert result.exit_code + assert "Invalid sort value 'notaname'" in result.output