diff --git a/gallery/cli.py b/gallery/cli.py index 970a577..4464774 100644 --- a/gallery/cli.py +++ b/gallery/cli.py @@ -2,12 +2,17 @@ Command-line interface for gallery generation. Provides a CLI entry point for gallery generation and config management. +Shell autocomplete: add the following line to your .bashrc / .zshrc: + eval "$(register-python-argcomplete gallery)" """ import argparse +import os import sys from pathlib import Path +import argcomplete + from gallery import generate from gallery.config import ConfigManager, GalleryConfig, GallerySource, default_config_path @@ -22,7 +27,7 @@ Getting started: gallery config set paths.web_folder /path/to/your/public_html 2. Add one or more plot sources: - gallery config add-source --name my_plots --path /path/to/plots + gallery config add-source --path /path/to/plots 3. Generate your gallery: gallery generate @@ -31,8 +36,8 @@ Config commands: gallery config list Show all settings gallery config get Get a single value (e.g. gallery.png_dpi) gallery config set Update a setting (e.g. paths.web_folder /my/web) - gallery config add-source --name X --path P Add a plot source - gallery config remove-source Remove a plot source + gallery config add-source --path P Add a plot source (--name defaults to dir name) + gallery config remove-source Remove a plot source gallery config sources List configured sources gallery config path Show config file location @@ -43,6 +48,9 @@ Generate commands: gallery generate --source /path/to/plots Regenerate one source only gallery generate --verbose Print detailed output +Shell autocomplete (run once after install): + gallery install-completion + Config file: {config_path} """ @@ -50,51 +58,140 @@ Config file: {config_path} def _is_configured(config_path: Path) -> bool: """Return True if web_folder is set to a non-empty value.""" try: - mgr = ConfigManager(config_path) - web_folder = mgr.get("paths.web_folder") + web_folder = ConfigManager(config_path).get("paths.web_folder") return bool(web_folder and str(web_folder).strip()) except Exception: return False +def _source_names(prefix, parsed_args, **kwargs): + """Autocomplete helper: return configured source names.""" + try: + config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else default_config_path() + return [s["name"] for s in ConfigManager(config_path).list_sources()] + except Exception: + return [] + + +def _config_keys(prefix, parsed_args, **kwargs): + """Autocomplete helper: return known dot-notation config keys.""" + try: + config_path = Path(parsed_args.config) if getattr(parsed_args, "config", None) else default_config_path() + data = ConfigManager(config_path).list_all() + keys = [] + for section, values in data.items(): + if isinstance(values, dict): + for k in values: + keys.append(f"{section}.{k}") + else: + keys.append(section) + return [k for k in keys if k.startswith(prefix)] + except Exception: + return [] + + # --------------------------------------------------------------------------- -# Config subcommand +# Parser construction (separated so TUI can reuse the structure) # --------------------------------------------------------------------------- -def _cmd_config(argv: list) -> int: +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="gallery config", - description="Read and write gallery configuration", + prog="gallery", + description="Scientific Plot Gallery Generator", + formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--config", type=str, default=None, - help="Path to config file (default: package config)", + metavar="FILE", + help="Path to config file (default: package bundled config)", + ).completer = argcomplete.completers.FilesCompleter(["yaml", "yml"]) + + sub = parser.add_subparsers(dest="command", metavar="COMMAND") + + # --- generate ----------------------------------------------------------- + gen = sub.add_parser( + "generate", + help="Generate the gallery", + description="Generate scientific gallery from plot collections", ) - sub = parser.add_subparsers(dest="action", metavar="ACTION") + gen.add_argument("--clean", action="store_true", help="Clean gallery directory before generation") + gen.add_argument( + "--source", + type=str, + default=None, + metavar="DIR", + help="Only recompute a specific source directory (name defaults to dir name)", + ).completer = argcomplete.completers.DirectoriesCompleter() + gen.add_argument("-v", "--verbose", action="store_true", help="Print verbose output") - sub.add_parser("list", help="Print all config values") - sub.add_parser("path", help="Print the resolved config file path") + # --- config ------------------------------------------------------------- + cfg = sub.add_parser( + "config", + help="Read and write configuration", + description="Read and write gallery configuration", + ) + cfg_sub = cfg.add_subparsers(dest="action", metavar="ACTION") - p_get = sub.add_parser("get", help="Get a config value by key") - p_get.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi") + sub.add_parser("install-completion", help="Install shell tab-completion (bash/zsh)") - p_set = sub.add_parser("set", help="Set a config value") - p_set.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi") + cfg_sub.add_parser("list", help="Print all config values") + cfg_sub.add_parser("path", help="Print the resolved config file path") + cfg_sub.add_parser("sources", help="List all configured sources") + + p_get = cfg_sub.add_parser("get", help="Get a config value") + p_get.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi").completer = _config_keys + + p_set = cfg_sub.add_parser("set", help="Set a config value") + p_set.add_argument("key", help="Dot-separated key, e.g. gallery.png_dpi").completer = _config_keys p_set.add_argument("value", help="New value (YAML-parsed: use true/false for bools)") - p_add = sub.add_parser("add-source", help="Add a plot source") + p_add = cfg_sub.add_parser("add-source", help="Add a plot source") p_add.add_argument("--name", default=None, help="Source name (default: bottom-level directory name)") - p_add.add_argument("--path", required=True, help="Path to source directory") + p_add.add_argument("--path", required=True, metavar="DIR", + help="Path to source directory").completer = argcomplete.completers.DirectoriesCompleter() - p_rm = sub.add_parser("remove-source", help="Remove a plot source by name") - p_rm.add_argument("name", help="Source name to remove") + p_rm = cfg_sub.add_parser("remove-source", help="Remove a plot source by name") + p_rm.add_argument("name", help="Source name to remove").completer = _source_names - sub.add_parser("sources", help="List all configured sources") + return parser - args = parser.parse_args(argv) +# --------------------------------------------------------------------------- +# Command handlers +# --------------------------------------------------------------------------- + +def _run_generate(args: argparse.Namespace) -> int: + config_path = Path(args.config) if args.config else default_config_path() + try: + config = GalleryConfig.from_yaml(config_path) + + source_to_update = None + if args.source: + source_path = Path(args.source).resolve() + matching = next((s for s in config.sources if Path(s.path).resolve() == source_path), None) + if matching is None: + source_to_update = GallerySource(name=source_path.name, path=source_path) + config.sources.append(source_to_update) + if args.verbose: + print(f"Source {args.source} not in config. Adding temporarily as '{source_path.name}'") + else: + source_to_update = matching + + success = generate(config=config, clean_first=args.clean, verbose=args.verbose, + source_to_update=source_to_update) + return 0 if success else 1 + + except (FileNotFoundError, ValueError) as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def _run_config(args: argparse.Namespace) -> int: config_path = Path(args.config) if args.config else default_config_path() mgr = ConfigManager(config_path) @@ -105,10 +202,17 @@ def _cmd_config(argv: list) -> int: import yaml print(yaml.dump(mgr.list_all(), default_flow_style=False).rstrip()) + elif args.action == "sources": + sources = mgr.list_sources() + if not sources: + print("No sources configured.") + else: + for s in sources: + print(f" {s['name']}: {s['path']}") + elif args.action == "get": try: - value = mgr.get(args.key) - print(value) + print(mgr.get(args.key)) except KeyError as e: print(f"Error: {e}", file=sys.stderr) return 1 @@ -135,94 +239,41 @@ def _cmd_config(argv: list) -> int: print(f"Error: {e}", file=sys.stderr) return 1 - elif args.action == "sources": - sources = mgr.list_sources() - if not sources: - print("No sources configured.") - else: - for s in sources: - print(f" {s['name']}: {s['path']}") - else: - parser.print_help() + # `gallery config` with no action → show config help + build_parser().parse_args(["config", "--help"]) return 0 # --------------------------------------------------------------------------- -# Generate subcommand (existing behaviour) +# install-completion handler # --------------------------------------------------------------------------- -def _cmd_generate(argv: list) -> int: - parser = argparse.ArgumentParser( - prog="gallery generate", - description="Generate scientific gallery from plot collections", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - gallery generate # Generate using package config - gallery generate --config myconfig.yaml # Use custom config file - gallery generate --clean # Clean and regenerate - gallery generate --source /path/to/plots # Generate only specific source - """, - ) +_COMPLETION_LINE = 'eval "$(register-python-argcomplete gallery)"' - parser.add_argument( - "--config", - type=str, - default=None, - help="Path to config.yaml (default: package bundled config)", - ) - parser.add_argument("--clean", action="store_true", help="Clean gallery directory before generation") - parser.add_argument( - "--source", - type=str, - default=None, - help="Only recompute a specific source directory", - ) - parser.add_argument("-v", "--verbose", action="store_true", help="Print verbose output") +_SHELL_RC = { + "zsh": ".zshrc", + "bash": ".bashrc", + "fish": ".config/fish/config.fish", +} - args = parser.parse_args(argv) - config_path = Path(args.config) if args.config else default_config_path() +def _run_install_completion() -> int: + shell = Path(os.environ.get("SHELL", "")).name # e.g. "bash", "zsh" + rc_name = _SHELL_RC.get(shell, ".bashrc") + rc = Path.home() / rc_name - try: - config = GalleryConfig.from_yaml(config_path) + if rc.exists() and _COMPLETION_LINE in rc.read_text(): + print(f"Shell completion already configured in {rc}") + return 0 - source_to_update = None - if args.source: - source_path = Path(args.source).resolve() - matching_source = None - for source in config.sources: - if Path(source.path).resolve() == source_path: - matching_source = source - break - if matching_source is None: - source_name = source_path.name - source_to_update = GallerySource(name=source_name, path=source_path) - config.sources.append(source_to_update) - if args.verbose: - print(f"Source {args.source} not in config. Adding temporarily as '{source_name}'") - else: - source_to_update = matching_source + with open(rc, "a") as f: + f.write(f"\n# gallery shell completion\n{_COMPLETION_LINE}\n") - success = generate( - config=config, - clean_first=args.clean, - verbose=args.verbose, - source_to_update=source_to_update, - ) - return 0 if success else 1 - - except FileNotFoundError as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - except ValueError as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - except Exception as e: - print(f"Error: {e}", file=sys.stderr) - return 1 + print(f"Shell completion installed to {rc}") + print(f"Reload with: source {rc}") + return 0 # --------------------------------------------------------------------------- @@ -230,10 +281,16 @@ Examples: # --------------------------------------------------------------------------- def main(): - if len(sys.argv) > 1 and sys.argv[1] == "config": - sys.exit(_cmd_config(sys.argv[2:])) - elif len(sys.argv) > 1 and sys.argv[1] == "generate": - sys.exit(_cmd_generate(sys.argv[2:])) + parser = build_parser() + argcomplete.autocomplete(parser) # no-op when not completing; exits during completion + args = parser.parse_args() + + if args.command == "generate": + sys.exit(_run_generate(args)) + elif args.command == "config": + sys.exit(_run_config(args)) + elif args.command == "install-completion": + sys.exit(_run_install_completion()) else: print(_WELCOME.format(config_path=default_config_path())) sys.exit(0) diff --git a/pyproject.toml b/pyproject.toml index 6a96f5b..e147e49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ dependencies = [ "Jinja2>=3.0.0", "PyYAML>=5.0", + "argcomplete>=3.0", "pytest", ] diff --git a/uv.lock b/uv.lock index 016eeca..34fb7e2 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,15 @@ resolution-markers = [ "python_full_version < '3.9'", ] +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + [[package]] name = "astroid" version = "3.2.4" @@ -272,7 +281,7 @@ version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -281,9 +290,10 @@ wheels = [ [[package]] name = "gallery" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ + { name = "argcomplete" }, { name = "jinja2" }, { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, @@ -309,6 +319,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "argcomplete", specifier = ">=3.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=22.0" }, { name = "jinja2", specifier = ">=3.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=0.900" },