From e908d3328cfda75ccb702667c0810253bf778f7f Mon Sep 17 00:00:00 2001 From: crivella Date: Mon, 2 Feb 2026 15:09:39 +0100 Subject: [PATCH 1/6] Automatically write module footers for click autocompletion for specified binaries --- easybuild/easyblocks/generic/pythonbundle.py | 19 +++ easybuild/easyblocks/generic/pythonpackage.py | 117 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/easybuild/easyblocks/generic/pythonbundle.py b/easybuild/easyblocks/generic/pythonbundle.py index c9cdb46c2f9..4a0436fdc28 100644 --- a/easybuild/easyblocks/generic/pythonbundle.py +++ b/easybuild/easyblocks/generic/pythonbundle.py @@ -32,9 +32,11 @@ from easybuild.easyblocks.generic.bundle import Bundle from easybuild.easyblocks.generic.pythonpackage import EXTS_FILTER_PYTHON_PACKAGES, run_pip_check, set_py_env_vars from easybuild.easyblocks.generic.pythonpackage import PythonPackage, get_pylibdirs, find_python_cmd_from_ec +from easybuild.easyblocks.generic.pythonpackage import click_lua_autocomplete_script, click_tcl_autocomplete_script from easybuild.tools.build_log import EasyBuildError from easybuild.tools.config import build_option, PYTHONPATH, EBPYTHONPREFIXES from easybuild.tools.modules import get_software_root +from easybuild.tools.module_generator import ModuleGeneratorLua, ModuleGeneratorTcl from easybuild.tools.filetools import search_file @@ -224,3 +226,20 @@ def _sanity_check_step_extensions(self): if sanity_pip_check: run_pip_check(python_cmd=self.python_cmd, unversioned_packages=all_unversioned_packages) + + def make_module_footer(self): + footer = super().make_module_footer() + + click_autocomplete_bins = [] + for _, _, ext_data in self.cfg['exts_list']: + click_autocomplete_bins += ext_data.get('click_autocomplete_bins', []) + + extra_footer = [] + for click_bin in click_autocomplete_bins: + extra_footer += PythonPackage._make_click_module_footer(self, click_bin) + + if extra_footer: + extra_footer = '\n'.join(extra_footer) + footer += '\n' + extra_footer + '\n' + + return footer diff --git a/easybuild/easyblocks/generic/pythonpackage.py b/easybuild/easyblocks/generic/pythonpackage.py index 0e946a73aa4..7eb9b05a36c 100644 --- a/easybuild/easyblocks/generic/pythonpackage.py +++ b/easybuild/easyblocks/generic/pythonpackage.py @@ -51,6 +51,7 @@ from easybuild.tools.config import build_option, PYTHONPATH, EBPYTHONPREFIXES from easybuild.tools.filetools import change_dir, mkdir, read_file, remove_dir, symlink, which, write_file, search_file from easybuild.tools.modules import ModEnvVarType, get_software_root +from easybuild.tools.module_generator import ModuleGeneratorLua, ModuleGeneratorTcl from easybuild.tools.run import run_shell_cmd from easybuild.tools.utilities import nub from easybuild.tools.hooks import CONFIGURE_STEP, BUILD_STEP, TEST_STEP, INSTALL_STEP @@ -74,6 +75,90 @@ PY_INSTALL_SCHEME_POSIX_LOCAL, ] +CLICK_LUA_AUTOCOMPLETE_TEMPLATE = """ +local shell = myShellName() + +if (shell == "bash") or (shell == "sh") then + execute{{cmd="eval \\"$(_{_click_bin_envvar}_COMPLETE=bash_source {_click_bin})\\"", modeA={{"load"}}}} + execute{{cmd="complete -r {_click_bin} && unset _{_click_bin_nomin}_completion_setup && unset \ +_{_click_bin_nomin}_completion", modeA={{"unload"}}}} +elseif (shell == "zsh") then + execute{{cmd="eval \\"$(_{_click_bin_envvar}_COMPLETE=zsh_source {_click_bin})\\"", modeA={{"load"}}}} + execute{{cmd="unset '_comps[{_click_bin}]' && unset -f _{_click_bin_nomin}_completion", modeA={{"unload"}}}} +elseif (shell == "fish") then + execute{{cmd="eval (env _{_click_bin_envvar}_COMPLETE=fish_source {_click_bin})", modeA={{"load"}}}} + execute{{cmd="complete -e {_click_bin} && functions --erase _{_click_bin_nomin}_completion", modeA={{"unload"}}}} +else + LmodMessage("Autocompletion cannot be setup automatically for shell: " .. shell) +end +""" + +CLICK_TCL_AUTOCOMPLETE_TEMPLATE = """ +set shell [module-info shell] +if {{$shell in {{bash fish zsh}}}} {{ + # using "puts stdout" to send command to shell to evaluate requires EnvModules or Lmod >=8.6.18 + if {{![info exists ::env(LMOD_VERSION)] || \\ + [string equal [lindex [lsort -dictionary [list 8.6.18 $::env(LMOD_VERSION)]] 0] 8.6.18] \\ + }} {{ + switch -- [module-info mode] {{ + load {{ + switch -- $shell {{ + bash {{ + puts stdout "eval \\"\\$(_{_click_bin_envvar}_COMPLETE=bash_source {_click_bin})\\"" + }} + zsh {{ + puts stdout "eval \\"\\$(_{_click_bin_envvar}_COMPLETE=zsh_source {_click_bin})\\"" + }} + fish {{ + puts stdout "eval (env _{_click_bin_envvar}_COMPLETE=fish_source {_click_bin})" + }} + }} + }} + remove - unload {{ + switch -- $shell {{ + bash {{ + puts stdout {{unset -f _{_click_bin_nomin}_completion 2>/dev/null || true}} + puts stdout {{unset -f _{_click_bin_nomin}_completion_setup 2>/dev/null || true}} + puts stdout {{complete -r {_click_bin}}} + }} + zsh {{ + puts stdout {{unset -f _{_click_bin_nomin}_completion 2>/dev/null || true}} + puts stdout {{unset '_comps[{_click_bin}]'}} + }} + fish {{ + puts stdout {{functions -e _{_click_bin_nomin}_completion}} + puts stdout {{complete -e -c {_click_bin}}} + }} + }} + }} + }} + }} +}} else {{ + puts stderr "Autocompletion of `aiida-pseudo` cannot be setup automatically for shell: $shell" +}} +""" + +def click_lua_autocomplete_script(bin_name): + """Generate Lua script for setting up autocompletion for Click-based command line tools.""" + bin_name_nomin = bin_name.replace('-', '_') + click_bin_envvar = bin_name_nomin.upper() + lua_script = CLICK_LUA_AUTOCOMPLETE_TEMPLATE.format( + _click_bin=bin_name, + _click_bin_nomin=bin_name_nomin, + _click_bin_envvar=click_bin_envvar, + ) + return lua_script + +def click_tcl_autocomplete_script(bin_name): + """"Generate Tcl script for setting up autocompletion for Click-based command line tools.""" + bin_name_nomin = bin_name.replace('-', '_') + click_bin_envvar = bin_name_nomin.upper() + tcl_script = CLICK_TCL_AUTOCOMPLETE_TEMPLATE.format( + _click_bin=bin_name, + _click_bin_nomin=bin_name_nomin, + _click_bin_envvar=click_bin_envvar, + ) + return tcl_script def det_python_version(python_cmd): """Determine version of specified 'python' command.""" @@ -394,6 +479,9 @@ def extra_options(extra_vars=None): "Otherwise it will be used as-is. A value of None then skips the build step. " "The template %(python)s will be replace by the currently used Python binary.", CUSTOM], 'check_ldshared': [None, 'Check Python value of $LDSHARED, correct if needed to "$CC -shared"', CUSTOM], + 'click_autocomplete_bins': [None, "List of command line tools installed by the package that use " + "the 'click' package and for which autocompletion scripts should be " + "generated (default: None)", CUSTOM], 'download_dep_fail': [None, "Fail if downloaded dependencies are detected. " "Defaults to True unless 'use_pip_for_deps' or 'use_pip_requirement' is True.", CUSTOM], @@ -460,6 +548,8 @@ def __init__(self, *args, **kwargs): self.pylibdir = UNKNOWN self.all_pylibdirs = [UNKNOWN] + self.click_autocomplete_bins = self.cfg['click_autocomplete_bins'] or [] + self.install_cmd_output = '' # make sure there's no site.cfg in $HOME, because setup.py will find it and use it @@ -1155,3 +1245,30 @@ def make_module_extra(self, *args, **kwargs): txt += self.module_generator.prepend_paths(PYTHONPATH, path) return super().make_module_extra(*args, **kwargs) + txt + + def _make_click_module_footer(self, click_bin): + """Generate Click autocomplete script for module footer.""" + extra_footer = [] + if isinstance(self.module_generator, ModuleGeneratorTcl): + self.log.debug("Adding Click autocomplete for '%s' in Tcl module", click_bin) + extra_footer.append(click_tcl_autocomplete_script(click_bin)) + elif isinstance(self.module_generator, ModuleGeneratorLua): + self.log.debug("Adding Click autocomplete for '%s' in Lua module", click_bin) + extra_footer.append(click_lua_autocomplete_script(click_bin)) + else: + self.log.warning("Not adding Click autocomplete for '%s' in unknown module syntax", click_bin) + + return extra_footer + + def make_module_footer(self): + footer = super().make_module_footer() + + extra_footer = [] + for click_bin in self.click_autocomplete_bins: + extra_footer += self._make_click_module_footer(click_bin) + + if extra_footer: + extra_footer = '\n'.join(extra_footer) + footer += '\n' + extra_footer + '\n' + + return footer From 008fc81aa9582c1a4f25c3118e9fa85c026e7a4f Mon Sep 17 00:00:00 2001 From: crivella Date: Mon, 2 Feb 2026 15:24:21 +0100 Subject: [PATCH 2/6] Add sanity-check to check that the completion script is being properly generated --- easybuild/easyblocks/generic/pythonpackage.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/easybuild/easyblocks/generic/pythonpackage.py b/easybuild/easyblocks/generic/pythonpackage.py index 7eb9b05a36c..8fc9678fb97 100644 --- a/easybuild/easyblocks/generic/pythonpackage.py +++ b/easybuild/easyblocks/generic/pythonpackage.py @@ -1133,6 +1133,7 @@ def sanity_check_step(self, *args, **kwargs): """ success, fail_msg = True, '' + custom_commands = kwargs.pop('custom_commands', []) # load module early ourselves rather than letting parent sanity_check_step method do so, # since custom actions taken below require that environment is set up properly already @@ -1222,6 +1223,14 @@ def sanity_check_step(self, *args, **kwargs): self.clean_up_fake_module(self.fake_mod_data) self.sanity_check_module_loaded = False + for click_bin in self.click_autocomplete_bins: + click_bin_nomin = click_bin.replace('-', '_') + click_bin_envvar = click_bin_nomin.upper() + custom_commands.append( + f'_{click_bin_envvar}_COMPLETE=bash_source {click_bin} | grep _{click_bin_nomin}_completion' + ) + + kwargs['custom_commands'] = custom_commands parent_success, parent_fail_msg = super().sanity_check_step(*args, **kwargs) if parent_fail_msg: From 57597bcfaf3b819803f22ab6b4fc2059fc57a88a Mon Sep 17 00:00:00 2001 From: crivella Date: Mon, 2 Feb 2026 15:24:46 +0100 Subject: [PATCH 3/6] Remove unused --- easybuild/easyblocks/generic/pythonbundle.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/easybuild/easyblocks/generic/pythonbundle.py b/easybuild/easyblocks/generic/pythonbundle.py index 4a0436fdc28..102f44e89bb 100644 --- a/easybuild/easyblocks/generic/pythonbundle.py +++ b/easybuild/easyblocks/generic/pythonbundle.py @@ -32,11 +32,9 @@ from easybuild.easyblocks.generic.bundle import Bundle from easybuild.easyblocks.generic.pythonpackage import EXTS_FILTER_PYTHON_PACKAGES, run_pip_check, set_py_env_vars from easybuild.easyblocks.generic.pythonpackage import PythonPackage, get_pylibdirs, find_python_cmd_from_ec -from easybuild.easyblocks.generic.pythonpackage import click_lua_autocomplete_script, click_tcl_autocomplete_script from easybuild.tools.build_log import EasyBuildError from easybuild.tools.config import build_option, PYTHONPATH, EBPYTHONPREFIXES from easybuild.tools.modules import get_software_root -from easybuild.tools.module_generator import ModuleGeneratorLua, ModuleGeneratorTcl from easybuild.tools.filetools import search_file From 50460766a5d672b1c2d2ad1b73d7b949a60025ce Mon Sep 17 00:00:00 2001 From: crivella Date: Mon, 2 Feb 2026 15:25:43 +0100 Subject: [PATCH 4/6] Fix indent for lint --- easybuild/easyblocks/generic/pythonpackage.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/easybuild/easyblocks/generic/pythonpackage.py b/easybuild/easyblocks/generic/pythonpackage.py index 8fc9678fb97..cf2a679ccc1 100644 --- a/easybuild/easyblocks/generic/pythonpackage.py +++ b/easybuild/easyblocks/generic/pythonpackage.py @@ -138,6 +138,7 @@ }} """ + def click_lua_autocomplete_script(bin_name): """Generate Lua script for setting up autocompletion for Click-based command line tools.""" bin_name_nomin = bin_name.replace('-', '_') @@ -149,6 +150,7 @@ def click_lua_autocomplete_script(bin_name): ) return lua_script + def click_tcl_autocomplete_script(bin_name): """"Generate Tcl script for setting up autocompletion for Click-based command line tools.""" bin_name_nomin = bin_name.replace('-', '_') @@ -160,6 +162,7 @@ def click_tcl_autocomplete_script(bin_name): ) return tcl_script + def det_python_version(python_cmd): """Determine version of specified 'python' command.""" pycode = 'import sys; print("%s.%s.%s" % sys.version_info[:3])' @@ -480,8 +483,8 @@ def extra_options(extra_vars=None): "The template %(python)s will be replace by the currently used Python binary.", CUSTOM], 'check_ldshared': [None, 'Check Python value of $LDSHARED, correct if needed to "$CC -shared"', CUSTOM], 'click_autocomplete_bins': [None, "List of command line tools installed by the package that use " - "the 'click' package and for which autocompletion scripts should be " - "generated (default: None)", CUSTOM], + "the 'click' package and for which autocompletion scripts should be " + "generated (default: None)", CUSTOM], 'download_dep_fail': [None, "Fail if downloaded dependencies are detected. " "Defaults to True unless 'use_pip_for_deps' or 'use_pip_requirement' is True.", CUSTOM], From ae1ec7f8a173dade20f39e6a923cb9f4927974f2 Mon Sep 17 00:00:00 2001 From: crivella Date: Mon, 2 Feb 2026 17:28:18 +0100 Subject: [PATCH 5/6] Allow for case where extension might be a single string instead of a tuple of (name, version, data) --- easybuild/easyblocks/generic/pythonbundle.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/easybuild/easyblocks/generic/pythonbundle.py b/easybuild/easyblocks/generic/pythonbundle.py index 102f44e89bb..e9cac21861f 100644 --- a/easybuild/easyblocks/generic/pythonbundle.py +++ b/easybuild/easyblocks/generic/pythonbundle.py @@ -229,7 +229,12 @@ def make_module_footer(self): footer = super().make_module_footer() click_autocomplete_bins = [] - for _, _, ext_data in self.cfg['exts_list']: + for extension in self.cfg['exts_list']: + try: + _, _, ext_data = extension + except ValueError: + self.log.warning("Could not unpack extension data for extension '%s'", str(extension)) + ext_data = {} click_autocomplete_bins += ext_data.get('click_autocomplete_bins', []) extra_footer = [] From 586f94296a93ed5beda1f2bcf6166c3931bef945 Mon Sep 17 00:00:00 2001 From: Davide Grassano <34096612+Crivella@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:29:39 +0100 Subject: [PATCH 6/6] Apply suggestion from @lorisercole Co-authored-by: Loris Ercole <30901257+lorisercole@users.noreply.github.com> --- easybuild/easyblocks/generic/pythonpackage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easybuild/easyblocks/generic/pythonpackage.py b/easybuild/easyblocks/generic/pythonpackage.py index cf2a679ccc1..f6ccb484c9b 100644 --- a/easybuild/easyblocks/generic/pythonpackage.py +++ b/easybuild/easyblocks/generic/pythonpackage.py @@ -134,7 +134,7 @@ }} }} }} else {{ - puts stderr "Autocompletion of `aiida-pseudo` cannot be setup automatically for shell: $shell" + puts stderr "Autocompletion of `{_click_bin}` cannot be setup automatically for shell: $shell" }} """