diff --git a/docs/AUTHORS.rst b/docs/AUTHORS.rst index 85664281..7cca8dfe 100644 --- a/docs/AUTHORS.rst +++ b/docs/AUTHORS.rst @@ -6,3 +6,4 @@ Authors * Oliver Beckstein (`webpage-ob `_, `github-ob `_) * Lily Wang (`github-lw `_) * Henrik Jäger (`github-hj `_) +* Shreejan Dolai (`github-sd `_) diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index 988d7d54..97e4953b 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -2,6 +2,12 @@ Changelog ========= +Unreleased +---------- + +* Added argcomplete for tab completion +* Updated docs + v0.1.33 (2025-10-20) ------------------------------------------ diff --git a/docs/src/index.rst b/docs/src/index.rst index 4dfa6383..7dc15d66 100644 --- a/docs/src/index.rst +++ b/docs/src/index.rst @@ -11,6 +11,7 @@ installation philosophy usage + tab-completion contributing api changelog diff --git a/docs/src/tab-completion.rst b/docs/src/tab-completion.rst new file mode 100644 index 00000000..cb786fde --- /dev/null +++ b/docs/src/tab-completion.rst @@ -0,0 +1,119 @@ +============== +Tab-Completion +============== + +``mdacli`` includes built-in support for command-line tab-completion + +Activation +========== + +The activation method depends on your shell: + +Bash +---- + +**Temporary (current session only)**:: + + eval "$(register-python-argcomplete mda)" + +**Permanent (recommended)** + +Add to your ``~/.bashrc``:: + + echo 'eval "$(register-python-argcomplete mda)"' >> ~/.bashrc + source ~/.bashrc + +Zsh +--- + +Add to your ``~/.zshrc``:: + + autoload -U bashcompinit + bashcompinit + eval "$(register-python-argcomplete mda)" + +Then reload:: + + source ~/.zshrc + +Fish +---- + +Generate the completion file:: + + register-python-argcomplete --shell fish mda > ~/.config/fish/completions/mda.fish + +Restart your Fish shell or run:: + + source ~/.config/fish/config.fish + +Tcsh +---- + +Add to your shell startup file:: + + eval `register-python-argcomplete --shell tcsh mda` + +Usage Examples +============== + +Once enabled, tab-completion works for: + +**Module names**:: + + mda + # Shows: AlignTraj, AverageStructure, Contacts, DensityAnalysis, ... + +**Partial module names**:: + + mda RM + # Shows: RMSD, RMSF + +**Options and flags**:: + + mda RMSD - + # Shows: -s, -f, -atomgroup, -b, -e, -dt, -v, --debug, --version, ... + +**Case insensitive**:: + + mda rmsd # Also works + mda RmSd # Also works + +Troubleshooting +=============== + +Tab-completion not working +-------------------------- + +1. **Verify argcomplete is installed**:: + + python -c "import argcomplete; print(argcomplete.__version__)" + +2. **Check if activation command was added**:: + + grep "register-python-argcomplete mda" ~/.bashrc + +3. **Reload your shell**:: + + source ~/.bashrc # or restart terminal + +4. **Test basic completion**:: + + mda + +Still not working +----------------- + +- Make sure you've restarted your terminal or sourced the configuration file +- For Zsh, ensure ``bashcompinit`` is loaded before argcomplete +- Check that ``mda`` is in your PATH: ``which mda`` +- Try running the registration command manually in your current shell + +Global activation (for all Python scripts) +------------------------------------------- + +To enable argcomplete for all Python scripts at once:: + + activate-global-python-argcomplete + +This requires root/admin privileges and will enable completion system-wide. diff --git a/pyproject.toml b/pyproject.toml index f19d7695..c7b3fec8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ classifiers = [ dependencies = [ "MDAnalysis>=2.10.0", "threadpoolctl", + "argcomplete", ] [project.urls] diff --git a/src/mdacli/__main__.py b/src/mdacli/__main__.py index 7f38b12b..07d142fb 100644 --- a/src/mdacli/__main__.py +++ b/src/mdacli/__main__.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK # # Copyright (c) 2021 Authors and contributors # diff --git a/src/mdacli/cli.py b/src/mdacli/cli.py index c13604ab..92190d88 100644 --- a/src/mdacli/cli.py +++ b/src/mdacli/cli.py @@ -12,6 +12,7 @@ import traceback import warnings +import argcomplete from MDAnalysis.analysis.base import AnalysisBase from threadpoolctl import threadpool_limits @@ -89,6 +90,9 @@ def cli( ap = init_base_argparse(name=name, version=version, description=description) + setup_clients(ap, title=f"{name} Analysis Modules", members=modules) + argcomplete.autocomplete(ap) + if len(sys.argv) < 2: ap.error("A subcommand is required.") @@ -97,7 +101,6 @@ def cli( # i.e. for `mda RMSD` only the RMSD client should be build. # 2. for something like `mdacli -h` We do not have to build every # sub parser in complete detail. - setup_clients(ap, title=f"{name} Analysis Modules", members=modules) args = ap.parse_args() diff --git a/src/mdacli/libcli.py b/src/mdacli/libcli.py index 9372fc2b..fad9212d 100644 --- a/src/mdacli/libcli.py +++ b/src/mdacli/libcli.py @@ -246,6 +246,54 @@ def add_output_group(analysis_class_parser): ) +def create_extension_completer(extension_list): + """Create a completer function for MDAnalysis file formats. + + Parameters + ---------- + extension_list : list of str + List of file extensions (e.g., ['pdb', 'gro', 'psf']) + + Returns + ------- + function + Completer function for argcomplete that only shows files + with valid MDAnalysis extensions. + """ + + def completer(prefix, parsed_args, **kwargs): # noqa: ARG001 + """Complete only files with specified extensions.""" + valid_exts = tuple(f".{ext.lower()}" for ext in extension_list) + + prefix_path = Path(prefix) + + # Determine directory and file prefix + if prefix_path.parent.name: + directory = prefix_path.parent + file_prefix = prefix_path.name + else: + directory = Path() + file_prefix = prefix + + matches = [] + try: + for item in directory.iterdir(): + item_name = item.name + # Checking if item matches prefix and is a file with valid extension + if ( + item_name.startswith(file_prefix) + and item.is_file() + and item_name.lower().endswith(valid_exts) + ): + matches.append(str(item) if directory != Path() else item_name) + except (OSError, PermissionError): + pass + + return matches + + return completer + + def add_cli_universe(parser, name=""): """Add universe parameters to an given argparse.ArgumentParser. @@ -255,13 +303,13 @@ def add_cli_universe(parser, name=""): Parameters ---------- analysis_class_parser : argparse.ArgumentParser - The ArgumentsParser instance to which the run grorup is added + The ArgumentsParser instance to which the run group is added name : str suffix for the argument names """ name = f"_{name}" if name else "" - parser.add_argument( + topology_arg = parser.add_argument( f"-s{name}", dest=f"topology{name}", type=str, @@ -270,6 +318,7 @@ def add_cli_universe(parser, name=""): ", ".join(mda._PARSERS.keys()) ), ) + topology_arg.completer = create_extension_completer(mda._PARSERS) parser.add_argument( f"-top{name}", @@ -289,7 +338,7 @@ def add_cli_universe(parser, name=""): "(currently only LAMMPS parser). E.g. atom_style='id type x y z'.", ) - parser.add_argument( + trajectory_arg = parser.add_argument( f"-f{name}", dest=f"coordinates{name}", type=str, @@ -299,6 +348,7 @@ def add_cli_universe(parser, name=""): "The FORMATs {} are implemented in MDAnalysis." "".format(", ".join(mda._READERS.keys())), ) + trajectory_arg.completer = create_extension_completer(mda._READERS) parser.add_argument( f"-traj{name}", diff --git a/tests/test_cli.py b/tests/test_cli.py index aee122dc..67436798 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,7 @@ import subprocess import sys +from contextlib import suppress from pathlib import Path import pytest @@ -47,6 +48,83 @@ def test_case_insensitive_with_flags(args): subprocess.check_call(["mda", "--debug", args, "-h"]) +def test_subparser_setup_for_tab_completion(): + """Test that subparsers are correctly set up for tab-completion. + + This verifies that RMSF and RMSD modules are registered as subcommands, + which is what argcomplete needs for tab-completion to work. + """ + from MDAnalysis.analysis.base import AnalysisBase + + from src.mdacli.libcli import find_cls_members, init_base_argparse, setup_clients + + modules = find_cls_members(AnalysisBase, ["MDAnalysis.analysis.rms"]) + + parser = init_base_argparse( + name="MDAnalysis", version="0.1.0", description="Test CLI" + ) + + setup_clients(parser, title="MDAnalysis Analysis Modules", members=modules) + + subparser_action = [ + a for a in parser._subparsers._group_actions if hasattr(a, "choices") + ][0] + + choices = list(subparser_action.choices.keys()) + assert "RMSF" in choices + assert "RMSD" in choices + + +def test_argcomplete_working(): + """Test that argcomplete is properly registered and working.""" + import argparse + from unittest.mock import patch + + from MDAnalysis.analysis import __all__ + + import src.mdacli + from src.mdacli.cli import cli + + skip_mods = [ + "AnalysisFromFunction", + "HydrogenBondAnalysis", + "WaterBridgeAnalysis", + "Contacts", + "PersistenceLength", + "InterRDF_s", + ] + + with ( + patch("src.mdacli.cli.argcomplete.autocomplete") as mock_autocomplete, + patch("sys.argv", ["mda", "--help"]), + suppress(SystemExit), + ): + cli( + name="MDAnalysis", + module_list=[f"MDAnalysis.analysis.{m}" for m in __all__], + version=src.mdacli.__version__, + description="Test", + skip_modules=skip_mods, + ignore_warnings=True, + ) + + # Verify that argcomplete.autocomplete was called + assert mock_autocomplete.called, "argcomplete.autocomplete() was not called" + + # Verify it was called with an ArgumentParser instance + call_args = mock_autocomplete.call_args + assert call_args is not None, ( + "argcomplete.autocomplete() was called with no arguments" + ) + + parser_arg = call_args[0][0] + msg = ( + "argcomplete.autocomplete() should be called with ArgumentParser, " + f"got {type(parser_arg)}" + ) + assert isinstance(parser_arg, argparse.ArgumentParser), msg + + def test_running_analysis(tmpdir): """Test running a complete analysis.""" with tmpdir.as_cwd(): diff --git a/tox.ini b/tox.ini index 887cff90..29051904 100644 --- a/tox.ini +++ b/tox.ini @@ -27,8 +27,7 @@ setenv = PYTHONUNBUFFERED=yes usedevelop = true deps = - MDAnalysis>=2.1.0 - MDAnalysisTests>=2.1.0 + MDAnalysisTests>=2.10.0 coverage[toml] pytest pytest-cov