From d5cb8a7a6f3b013e8764e1b303eb4cbb51207ff7 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Sun, 29 Mar 2026 08:33:25 +0530 Subject: [PATCH 01/10] feat(cli): add prefix-based command matching and autocomplete module --- lean/commands/__init__.py | 4 + lean/commands/autocomplete.py | 269 ++++++++++++++++++ lean/commands/cloud/__init__.py | 3 +- lean/commands/config/__init__.py | 3 +- lean/commands/data/__init__.py | 3 +- lean/commands/library/__init__.py | 3 +- lean/commands/private_cloud/__init__.py | 3 +- .../util/click_aliased_command_group.py | 16 +- .../util/click_group_default_command.py | 4 +- lean/main.py | 3 + 10 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 lean/commands/autocomplete.py diff --git a/lean/commands/__init__.py b/lean/commands/__init__.py index dfa40a41..1d209abb 100644 --- a/lean/commands/__init__.py +++ b/lean/commands/__init__.py @@ -12,6 +12,7 @@ # limitations under the License. from lean.commands.lean import lean +from lean.commands.autocomplete import autocomplete, enable_autocomplete, disable_autocomplete from lean.commands.backtest import backtest from lean.commands.build import build from lean.commands.cloud import cloud @@ -36,6 +37,9 @@ from lean.commands.private_cloud import private_cloud lean.add_command(config) +lean.add_command(autocomplete) +lean.add_command(enable_autocomplete) +lean.add_command(disable_autocomplete) lean.add_command(cloud) lean.add_command(data) lean.add_command(decrypt) diff --git a/lean/commands/autocomplete.py b/lean/commands/autocomplete.py new file mode 100644 index 00000000..938af3cd --- /dev/null +++ b/lean/commands/autocomplete.py @@ -0,0 +1,269 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation. + +import os +import subprocess +from pathlib import Path +from platform import system +from click import group, argument, Choice, echo, option, command +from lean.components.util.click_aliased_command_group import AliasedCommandGroup + + +def get_all_commands(grp, path=''): + import click + res = [] + if isinstance(grp, click.Group): + for name, sub in grp.commands.items(): + full_path = (path + name).strip() + res.append(full_path) # always add the command/group itself + if isinstance(sub, click.Group): + res.extend(get_all_commands(sub, path + name + ' ')) # drill into subcommands + return res + + +def detect_shell() -> str: + """Auto-detect the current shell environment.""" + if system() == 'Windows': + # On Windows, default to powershell + parent = os.environ.get('PSModulePath', '') + if parent: + return 'powershell' + return 'powershell' # CMD falls back to powershell + else: + # Unix: check $SHELL env var + shell_path = os.environ.get('SHELL', '/bin/bash') + shell_name = Path(shell_path).name.lower() + if 'zsh' in shell_name: + return 'zsh' + elif 'fish' in shell_name: + return 'fish' + return 'bash' + + +def get_powershell_script(): + from lean.commands.lean import lean + commands_list = get_all_commands(lean) + commands_csv = ','.join(commands_list) + script = rf""" +Register-ArgumentCompleter -Native -CommandName lean -ScriptBlock {{ + param($wordToComplete, $commandAst, $cursorPosition) + + $lean_commands = '{commands_csv}' -split ',' + + $cmdLine = $commandAst.ToString().TrimStart() + $cmdLine = $cmdLine -replace '^(lean)\s*', '' + + if (-not $wordToComplete) {{ + $prefix = $cmdLine + }} else {{ + if ($cmdLine.EndsWith($wordToComplete)) {{ + $prefix = $cmdLine.Substring(0, $cmdLine.Length - $wordToComplete.Length).TrimEnd() + }} else {{ + $prefix = $cmdLine + }} + }} + + $possible = @() + if (-not $prefix) {{ + $possible = $lean_commands | Where-Object {{ $_ -notmatch ' ' }} + }} else {{ + $possible = $lean_commands | Where-Object {{ $_.StartsWith($prefix + ' ') }} | ForEach-Object {{ + $suffix = $_.Substring($prefix.Length + 1) + $suffix.Split(' ')[0] + }} + }} + + $validPossible = $possible | Select-Object -Unique + if ($wordToComplete) {{ + $validPossible = $validPossible | Where-Object {{ $_.StartsWith($wordToComplete) }} + }} + + $validPossible | ForEach-Object {{ + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + }} +}} + +try {{ + Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction SilentlyContinue + Set-PSReadLineOption -PredictionViewStyle InlineView -ErrorAction SilentlyContinue +}} catch {{}} + +try {{ + Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete -ErrorAction SilentlyContinue +}} catch {{}} +""" + return script.strip() + + +def get_bash_zsh_script(shell: str) -> str: + from lean.commands.lean import lean + commands_list = get_all_commands(lean) + commands_csv = ' '.join(commands_list) + + script = f""" +# lean CLI autocomplete +_lean_complete() {{ + local IFS=$'\\n' + local LEAN_COMMANDS=({commands_csv}) + local cur="${{COMP_WORDS[*]:1:${{#COMP_WORDS[@]}}-1}}" + cur="${{cur% }}" # strip trailing space + local word="${{COMP_WORDS[$COMP_CWORD]}}" + local prefix="${{cur% $word}}" + + local possible=() + if [ -z "$prefix" ]; then + for cmd in "${{LEAN_COMMANDS[@]}}"; do + if [[ "$cmd" != *" "* ]]; then + possible+=("$cmd") + fi + done + else + for cmd in "${{LEAN_COMMANDS[@]}}"; do + if [[ "$cmd" == "$prefix "* ]]; then + local suffix="${{cmd#$prefix }}" + local next_word="${{suffix%% *}}" + possible+=("$next_word") + fi + done + fi + + local filtered=() + for p in "${{possible[@]}}"; do + if [[ "$p" == "$word"* ]]; then + filtered+=("$p") + fi + done + + COMPREPLY=("${{filtered[@]}}") +}} +complete -F _lean_complete lean +""" + return script.strip() + + +def get_fish_script() -> str: + from lean.commands.lean import lean + commands_list = get_all_commands(lean) + lines = [] + for cmd in commands_list: + parts = cmd.split(' ') + if len(parts) == 1: + lines.append(f"complete -c lean -f -n '__fish_use_subcommand' -a '{cmd}'") + elif len(parts) == 2: + lines.append(f"complete -c lean -f -n '__fish_seen_subcommand_from {parts[0]}' -a '{parts[1]}'") + return '\n'.join(lines) + + +def get_script_for_shell(shell: str) -> str: + if shell == 'powershell': + return get_powershell_script() + elif shell == 'fish': + return get_fish_script() + else: + return get_bash_zsh_script(shell) + + +def get_profile_path(shell: str) -> Path: + if shell == 'powershell': + try: + path = subprocess.check_output( + ['powershell', '-NoProfile', '-Command', 'Write-Host $PROFILE'], + stderr=subprocess.DEVNULL + ).decode('utf-8').strip() + return Path(path) + except Exception: + return Path(os.path.expanduser(r'~\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1')) + elif shell == 'zsh': + return Path(os.path.expanduser('~/.zshrc')) + elif shell == 'fish': + return Path(os.path.expanduser('~/.config/fish/completions/lean.fish')) + else: + return Path(os.path.expanduser('~/.bashrc')) + + +def manage_profile(shell: str, action: str): + marker_start = "# >>> lean autocomplete >>>\n" + marker_end = "# <<< lean autocomplete <<<\n" + + profile_path = get_profile_path(shell) + script_content = get_script_for_shell(shell) + "\n" + + content = "" + if profile_path.exists(): + content = profile_path.read_text(encoding='utf-8') + + if action == "install": + if marker_start in content: + echo(f"Autocomplete is already installed in {profile_path}.") + return + + profile_path.parent.mkdir(parents=True, exist_ok=True) + block = f"\n{marker_start}{script_content}{marker_end}" + with profile_path.open('a', encoding='utf-8') as f: + f.write(block) + echo(f"✓ Installed autocomplete to {profile_path}") + echo(" Restart your terminal (or open a new window) for changes to take effect.") + + elif action == "uninstall": + if marker_start not in content: + echo(f"Autocomplete is not installed in {profile_path}.") + return + + start_idx = content.find(marker_start) + end_idx = content.find(marker_end) + len(marker_end) + new_content = content[:start_idx].rstrip('\n') + "\n" + content[end_idx:].lstrip('\n') + + profile_path.write_text(new_content, encoding='utf-8') + echo(f"✓ Uninstalled autocomplete from {profile_path}") + + +@group(name="autocomplete", cls=AliasedCommandGroup) +def autocomplete() -> None: + """Manage shell autocomplete for Lean CLI. + + Auto-detects your shell. Supports: powershell, bash, zsh, fish. + + \b + Enable autocomplete (auto-detects shell): + lean enable-autocomplete + + \b + Enable for a specific shell: + lean enable-autocomplete --shell bash + + \b + Disable autocomplete: + lean disable-autocomplete + """ + pass + + +SHELL_OPTION = option( + '--shell', '-s', + type=Choice(['powershell', 'bash', 'zsh', 'fish'], case_sensitive=False), + default=None, + help='Target shell. Auto-detected if not specified.' +) + + +@autocomplete.command(name="show", help="Print the autocomplete script for your shell") +@SHELL_OPTION +def show(shell: str) -> None: + shell = shell or detect_shell() + echo(get_script_for_shell(shell)) + + +@command(name="enable-autocomplete", help="Install autocomplete into your shell profile") +@SHELL_OPTION +def enable_autocomplete(shell: str) -> None: + shell = shell or detect_shell() + echo(f"Detected shell: {shell}") + manage_profile(shell, "install") + + +@command(name="disable-autocomplete", help="Remove autocomplete from your shell profile") +@SHELL_OPTION +def disable_autocomplete(shell: str) -> None: + shell = shell or detect_shell() + echo(f"Detected shell: {shell}") + manage_profile(shell, "uninstall") diff --git a/lean/commands/cloud/__init__.py b/lean/commands/cloud/__init__.py index 1f13f325..731c7d76 100644 --- a/lean/commands/cloud/__init__.py +++ b/lean/commands/cloud/__init__.py @@ -12,6 +12,7 @@ # limitations under the License. from click import group +from lean.components.util.click_aliased_command_group import AliasedCommandGroup from lean.commands.cloud.backtest import backtest from lean.commands.cloud.live.live import live @@ -21,7 +22,7 @@ from lean.commands.cloud.status import status from lean.commands.cloud.object_store import object_store -@group() +@group(cls=AliasedCommandGroup) def cloud() -> None: """Interact with the QuantConnect cloud.""" # This method is intentionally empty diff --git a/lean/commands/config/__init__.py b/lean/commands/config/__init__.py index c33a6a5c..c4d77d41 100644 --- a/lean/commands/config/__init__.py +++ b/lean/commands/config/__init__.py @@ -12,6 +12,7 @@ # limitations under the License. from click import group +from lean.components.util.click_aliased_command_group import AliasedCommandGroup from lean.commands.config.get import get from lean.commands.config.list import list @@ -19,7 +20,7 @@ from lean.commands.config.unset import unset -@group() +@group(cls=AliasedCommandGroup) def config() -> None: """Configure Lean CLI options.""" # This method is intentionally empty diff --git a/lean/commands/data/__init__.py b/lean/commands/data/__init__.py index a27149db..343a78cf 100644 --- a/lean/commands/data/__init__.py +++ b/lean/commands/data/__init__.py @@ -12,12 +12,13 @@ # limitations under the License. from click import group +from lean.components.util.click_aliased_command_group import AliasedCommandGroup from lean.commands.data.download import download from lean.commands.data.generate import generate -@group() +@group(cls=AliasedCommandGroup) def data() -> None: """Download or generate data for local use.""" # This method is intentionally empty diff --git a/lean/commands/library/__init__.py b/lean/commands/library/__init__.py index 762ab097..b1711e90 100644 --- a/lean/commands/library/__init__.py +++ b/lean/commands/library/__init__.py @@ -12,12 +12,13 @@ # limitations under the License. from click import group +from lean.components.util.click_aliased_command_group import AliasedCommandGroup from lean.commands.library.add import add from lean.commands.library.remove import remove -@group() +@group(cls=AliasedCommandGroup) def library() -> None: """Manage custom libraries in a project.""" # This method is intentionally empty diff --git a/lean/commands/private_cloud/__init__.py b/lean/commands/private_cloud/__init__.py index b154688c..9ac8b552 100644 --- a/lean/commands/private_cloud/__init__.py +++ b/lean/commands/private_cloud/__init__.py @@ -12,13 +12,14 @@ # limitations under the License. from click import group +from lean.components.util.click_aliased_command_group import AliasedCommandGroup from lean.commands.private_cloud.start import start from lean.commands.private_cloud.stop import stop from lean.commands.private_cloud.add_compute import add_compute -@group() +@group(cls=AliasedCommandGroup) def private_cloud() -> None: """Interact with a QuantConnect private cloud.""" # This method is intentionally empty diff --git a/lean/components/util/click_aliased_command_group.py b/lean/components/util/click_aliased_command_group.py index 68e90cf1..66d25db8 100644 --- a/lean/components/util/click_aliased_command_group.py +++ b/lean/components/util/click_aliased_command_group.py @@ -15,7 +15,21 @@ class AliasedCommandGroup(Group): - """A click.Group wrapper that implements command aliasing.""" + """A click.Group wrapper that implements command aliasing and auto-completion/prefix matching.""" + + def get_command(self, ctx, cmd_name): + rv = super().get_command(ctx, cmd_name) + if rv is not None: + return rv + + matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + + if not matches: + return None + elif len(matches) == 1: + return super().get_command(ctx, matches[0]) + + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") def command(self, *args, **kwargs): aliases = kwargs.pop('aliases', []) diff --git a/lean/components/util/click_group_default_command.py b/lean/components/util/click_group_default_command.py index 7d094d61..38e26d0f 100644 --- a/lean/components/util/click_group_default_command.py +++ b/lean/components/util/click_group_default_command.py @@ -11,9 +11,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -from click import Group +from lean.components.util.click_aliased_command_group import AliasedCommandGroup -class DefaultCommandGroup(Group): +class DefaultCommandGroup(AliasedCommandGroup): """allow a default command for a group""" def command(self, *args, **kwargs): diff --git a/lean/main.py b/lean/main.py index 061c721b..86d25e8f 100644 --- a/lean/main.py +++ b/lean/main.py @@ -88,6 +88,7 @@ def _ensure_win32_available() -> None: from lean.container import container +import click def main() -> None: """This function is the entrypoint when running a Lean command in a terminal.""" try: @@ -96,6 +97,8 @@ def main() -> None: temp_manager = container.temp_manager if temp_manager.delete_temporary_directories_when_done: temp_manager.delete_temporary_directories() + except click.exceptions.Exit as e: + exit(e.exit_code) except Exception as exception: from traceback import format_exc from click import UsageError, Abort From ca3401348860532f7b4991ec6d1b2c1175ee30d7 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Wed, 15 Apr 2026 22:18:45 +0530 Subject: [PATCH 02/10] feat(cli): add Click-native shell completion and prefix-based command matching --- lean/commands/__init__.py | 6 +- lean/commands/autocomplete.py | 269 ------------------ lean/commands/completion.py | 42 +++ lean/commands/init.py | 1 + lean/commands/lean.py | 3 + .../util/click_aliased_command_group.py | 26 +- .../components/util/click_shell_completion.py | 112 ++++++++ lean/main.py | 5 +- tests/test_click_aliased_command_group.py | 34 +++ tests/test_completion.py | 58 ++++ tests/test_main.py | 23 ++ 11 files changed, 294 insertions(+), 285 deletions(-) delete mode 100644 lean/commands/autocomplete.py create mode 100644 lean/commands/completion.py create mode 100644 lean/components/util/click_shell_completion.py create mode 100644 tests/test_completion.py diff --git a/lean/commands/__init__.py b/lean/commands/__init__.py index 1d209abb..c5d5b4d3 100644 --- a/lean/commands/__init__.py +++ b/lean/commands/__init__.py @@ -12,7 +12,7 @@ # limitations under the License. from lean.commands.lean import lean -from lean.commands.autocomplete import autocomplete, enable_autocomplete, disable_autocomplete +from lean.commands.completion import completion from lean.commands.backtest import backtest from lean.commands.build import build from lean.commands.cloud import cloud @@ -37,9 +37,7 @@ from lean.commands.private_cloud import private_cloud lean.add_command(config) -lean.add_command(autocomplete) -lean.add_command(enable_autocomplete) -lean.add_command(disable_autocomplete) +lean.add_command(completion) lean.add_command(cloud) lean.add_command(data) lean.add_command(decrypt) diff --git a/lean/commands/autocomplete.py b/lean/commands/autocomplete.py deleted file mode 100644 index 938af3cd..00000000 --- a/lean/commands/autocomplete.py +++ /dev/null @@ -1,269 +0,0 @@ -# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. -# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation. - -import os -import subprocess -from pathlib import Path -from platform import system -from click import group, argument, Choice, echo, option, command -from lean.components.util.click_aliased_command_group import AliasedCommandGroup - - -def get_all_commands(grp, path=''): - import click - res = [] - if isinstance(grp, click.Group): - for name, sub in grp.commands.items(): - full_path = (path + name).strip() - res.append(full_path) # always add the command/group itself - if isinstance(sub, click.Group): - res.extend(get_all_commands(sub, path + name + ' ')) # drill into subcommands - return res - - -def detect_shell() -> str: - """Auto-detect the current shell environment.""" - if system() == 'Windows': - # On Windows, default to powershell - parent = os.environ.get('PSModulePath', '') - if parent: - return 'powershell' - return 'powershell' # CMD falls back to powershell - else: - # Unix: check $SHELL env var - shell_path = os.environ.get('SHELL', '/bin/bash') - shell_name = Path(shell_path).name.lower() - if 'zsh' in shell_name: - return 'zsh' - elif 'fish' in shell_name: - return 'fish' - return 'bash' - - -def get_powershell_script(): - from lean.commands.lean import lean - commands_list = get_all_commands(lean) - commands_csv = ','.join(commands_list) - script = rf""" -Register-ArgumentCompleter -Native -CommandName lean -ScriptBlock {{ - param($wordToComplete, $commandAst, $cursorPosition) - - $lean_commands = '{commands_csv}' -split ',' - - $cmdLine = $commandAst.ToString().TrimStart() - $cmdLine = $cmdLine -replace '^(lean)\s*', '' - - if (-not $wordToComplete) {{ - $prefix = $cmdLine - }} else {{ - if ($cmdLine.EndsWith($wordToComplete)) {{ - $prefix = $cmdLine.Substring(0, $cmdLine.Length - $wordToComplete.Length).TrimEnd() - }} else {{ - $prefix = $cmdLine - }} - }} - - $possible = @() - if (-not $prefix) {{ - $possible = $lean_commands | Where-Object {{ $_ -notmatch ' ' }} - }} else {{ - $possible = $lean_commands | Where-Object {{ $_.StartsWith($prefix + ' ') }} | ForEach-Object {{ - $suffix = $_.Substring($prefix.Length + 1) - $suffix.Split(' ')[0] - }} - }} - - $validPossible = $possible | Select-Object -Unique - if ($wordToComplete) {{ - $validPossible = $validPossible | Where-Object {{ $_.StartsWith($wordToComplete) }} - }} - - $validPossible | ForEach-Object {{ - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) - }} -}} - -try {{ - Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction SilentlyContinue - Set-PSReadLineOption -PredictionViewStyle InlineView -ErrorAction SilentlyContinue -}} catch {{}} - -try {{ - Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete -ErrorAction SilentlyContinue -}} catch {{}} -""" - return script.strip() - - -def get_bash_zsh_script(shell: str) -> str: - from lean.commands.lean import lean - commands_list = get_all_commands(lean) - commands_csv = ' '.join(commands_list) - - script = f""" -# lean CLI autocomplete -_lean_complete() {{ - local IFS=$'\\n' - local LEAN_COMMANDS=({commands_csv}) - local cur="${{COMP_WORDS[*]:1:${{#COMP_WORDS[@]}}-1}}" - cur="${{cur% }}" # strip trailing space - local word="${{COMP_WORDS[$COMP_CWORD]}}" - local prefix="${{cur% $word}}" - - local possible=() - if [ -z "$prefix" ]; then - for cmd in "${{LEAN_COMMANDS[@]}}"; do - if [[ "$cmd" != *" "* ]]; then - possible+=("$cmd") - fi - done - else - for cmd in "${{LEAN_COMMANDS[@]}}"; do - if [[ "$cmd" == "$prefix "* ]]; then - local suffix="${{cmd#$prefix }}" - local next_word="${{suffix%% *}}" - possible+=("$next_word") - fi - done - fi - - local filtered=() - for p in "${{possible[@]}}"; do - if [[ "$p" == "$word"* ]]; then - filtered+=("$p") - fi - done - - COMPREPLY=("${{filtered[@]}}") -}} -complete -F _lean_complete lean -""" - return script.strip() - - -def get_fish_script() -> str: - from lean.commands.lean import lean - commands_list = get_all_commands(lean) - lines = [] - for cmd in commands_list: - parts = cmd.split(' ') - if len(parts) == 1: - lines.append(f"complete -c lean -f -n '__fish_use_subcommand' -a '{cmd}'") - elif len(parts) == 2: - lines.append(f"complete -c lean -f -n '__fish_seen_subcommand_from {parts[0]}' -a '{parts[1]}'") - return '\n'.join(lines) - - -def get_script_for_shell(shell: str) -> str: - if shell == 'powershell': - return get_powershell_script() - elif shell == 'fish': - return get_fish_script() - else: - return get_bash_zsh_script(shell) - - -def get_profile_path(shell: str) -> Path: - if shell == 'powershell': - try: - path = subprocess.check_output( - ['powershell', '-NoProfile', '-Command', 'Write-Host $PROFILE'], - stderr=subprocess.DEVNULL - ).decode('utf-8').strip() - return Path(path) - except Exception: - return Path(os.path.expanduser(r'~\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1')) - elif shell == 'zsh': - return Path(os.path.expanduser('~/.zshrc')) - elif shell == 'fish': - return Path(os.path.expanduser('~/.config/fish/completions/lean.fish')) - else: - return Path(os.path.expanduser('~/.bashrc')) - - -def manage_profile(shell: str, action: str): - marker_start = "# >>> lean autocomplete >>>\n" - marker_end = "# <<< lean autocomplete <<<\n" - - profile_path = get_profile_path(shell) - script_content = get_script_for_shell(shell) + "\n" - - content = "" - if profile_path.exists(): - content = profile_path.read_text(encoding='utf-8') - - if action == "install": - if marker_start in content: - echo(f"Autocomplete is already installed in {profile_path}.") - return - - profile_path.parent.mkdir(parents=True, exist_ok=True) - block = f"\n{marker_start}{script_content}{marker_end}" - with profile_path.open('a', encoding='utf-8') as f: - f.write(block) - echo(f"✓ Installed autocomplete to {profile_path}") - echo(" Restart your terminal (or open a new window) for changes to take effect.") - - elif action == "uninstall": - if marker_start not in content: - echo(f"Autocomplete is not installed in {profile_path}.") - return - - start_idx = content.find(marker_start) - end_idx = content.find(marker_end) + len(marker_end) - new_content = content[:start_idx].rstrip('\n') + "\n" + content[end_idx:].lstrip('\n') - - profile_path.write_text(new_content, encoding='utf-8') - echo(f"✓ Uninstalled autocomplete from {profile_path}") - - -@group(name="autocomplete", cls=AliasedCommandGroup) -def autocomplete() -> None: - """Manage shell autocomplete for Lean CLI. - - Auto-detects your shell. Supports: powershell, bash, zsh, fish. - - \b - Enable autocomplete (auto-detects shell): - lean enable-autocomplete - - \b - Enable for a specific shell: - lean enable-autocomplete --shell bash - - \b - Disable autocomplete: - lean disable-autocomplete - """ - pass - - -SHELL_OPTION = option( - '--shell', '-s', - type=Choice(['powershell', 'bash', 'zsh', 'fish'], case_sensitive=False), - default=None, - help='Target shell. Auto-detected if not specified.' -) - - -@autocomplete.command(name="show", help="Print the autocomplete script for your shell") -@SHELL_OPTION -def show(shell: str) -> None: - shell = shell or detect_shell() - echo(get_script_for_shell(shell)) - - -@command(name="enable-autocomplete", help="Install autocomplete into your shell profile") -@SHELL_OPTION -def enable_autocomplete(shell: str) -> None: - shell = shell or detect_shell() - echo(f"Detected shell: {shell}") - manage_profile(shell, "install") - - -@command(name="disable-autocomplete", help="Remove autocomplete from your shell profile") -@SHELL_OPTION -def disable_autocomplete(shell: str) -> None: - shell = shell or detect_shell() - echo(f"Detected shell: {shell}") - manage_profile(shell, "uninstall") diff --git a/lean/commands/completion.py b/lean/commands/completion.py new file mode 100644 index 00000000..ab6aaa9e --- /dev/null +++ b/lean/commands/completion.py @@ -0,0 +1,42 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional + +from click import Choice, command, echo, option + +from lean.components.util.click_shell_completion import get_completion_script + + +@command() +@option("--shell", + "-s", + type=Choice(["powershell", "bash", "zsh", "fish"], case_sensitive=False), + default=None, + help="Target shell. Auto-detected if not specified.") +def completion(shell: Optional[str]) -> None: + """Print the native shell completion script for your shell. + + \b + PowerShell (current session): + lean completion --shell powershell | Out-String | Invoke-Expression + + \b + Bash or Zsh (current session): + eval "$(lean completion --shell bash)" + + \b + Fish (current session): + lean completion --shell fish | source + """ + echo(get_completion_script(shell)) diff --git a/lean/commands/init.py b/lean/commands/init.py index 89dd4a76..87ea1ea0 100644 --- a/lean/commands/init.py +++ b/lean/commands/init.py @@ -207,6 +207,7 @@ def init(organization: Optional[str], language: Optional[str]) -> None: - Synchronizing projects with the cloud: https://www.lean.io/docs/v2/lean-cli/projects/cloud-synchronization Here are some commands to get you going: +- Run `lean completion --shell powershell | Out-String | Invoke-Expression` to enable PowerShell completion in the current session - Run `lean create-project "My Project"` to create a new project with starter code - Run `lean cloud pull` to download all your QuantConnect projects to your local drive - Run `lean backtest "My Project"` to backtest a project locally with the data in {DEFAULT_DATA_DIRECTORY_NAME}/ diff --git a/lean/commands/lean.py b/lean/commands/lean.py index 749000bb..eb1ecc6d 100644 --- a/lean/commands/lean.py +++ b/lean/commands/lean.py @@ -16,9 +16,12 @@ from lean import __version__ from lean.click import verbose_option from lean.components.util.click_aliased_command_group import AliasedCommandGroup +from lean.components.util.click_shell_completion import register_shell_completion from lean.container import container from lean.models.errors import MoreInfoError +register_shell_completion() + @group(cls=AliasedCommandGroup, invoke_without_command=True) @option("--version", is_flag=True, is_eager=True, help="Show the version and exit.") diff --git a/lean/components/util/click_aliased_command_group.py b/lean/components/util/click_aliased_command_group.py index 66d25db8..3267fbef 100644 --- a/lean/components/util/click_aliased_command_group.py +++ b/lean/components/util/click_aliased_command_group.py @@ -34,24 +34,30 @@ def get_command(self, ctx, cmd_name): def command(self, *args, **kwargs): aliases = kwargs.pop('aliases', []) - if not args: - cmd_name = kwargs.pop("name", "") - else: - cmd_name = args[0] - args = args[1:] - - alias_help = f"Alias for '{cmd_name}'" + if not aliases: + return super().command(*args, **kwargs) def _decorator(f): + if args: + cmd_name = args[0] + cmd_args = args[1:] + else: + cmd_name = kwargs.get("name", f.__name__.lower().replace("_", "-")) + cmd_args = () + + alias_help = f"Alias for '{cmd_name}'" + cmd_kwargs = dict(kwargs) + cmd_kwargs.pop("name", None) + # Add the main command - cmd = super(AliasedCommandGroup, self).command(name=cmd_name, *args, **kwargs)(f) + cmd = super(AliasedCommandGroup, self).command(*cmd_args, name=cmd_name, **cmd_kwargs)(f) # Add a command to the group for each alias with the same callback but using the alias as name for alias in aliases: alias_cmd = super(AliasedCommandGroup, self).command(name=alias, short_help=alias_help, - *args, - **kwargs)(f) + *cmd_args, + **cmd_kwargs)(f) alias_cmd.params = cmd.params return cmd diff --git a/lean/components/util/click_shell_completion.py b/lean/components/util/click_shell_completion.py new file mode 100644 index 00000000..c58c6c7c --- /dev/null +++ b/lean/components/util/click_shell_completion.py @@ -0,0 +1,112 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os +from pathlib import Path +from platform import system +from typing import Optional + +from click.shell_completion import ShellComplete, add_completion_class, get_completion_class, split_arg_string + +_SOURCE_POWERSHELL = r""" +Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $Env:%(complete_var)s = "powershell_complete" + $Env:COMP_WORDS = $commandAst.ToString() + $Env:COMP_CWORD = $wordToComplete + + try { + & %(prog_name)s | ForEach-Object { + if ([string]::IsNullOrWhiteSpace($_)) { + return + } + + $item = $_ | ConvertFrom-Json + $tooltip = if ($item.help) { $item.help } else { $item.value } + + [System.Management.Automation.CompletionResult]::new( + $item.value, + $item.value, + 'ParameterValue', + $tooltip + ) + } + } finally { + Remove-Item Env:%(complete_var)s -ErrorAction SilentlyContinue + Remove-Item Env:COMP_WORDS -ErrorAction SilentlyContinue + Remove-Item Env:COMP_CWORD -ErrorAction SilentlyContinue + } +} +""" + + +class PowerShellComplete(ShellComplete): + """Shell completion for PowerShell.""" + + name = "powershell" + source_template = _SOURCE_POWERSHELL + + def get_completion_args(self) -> tuple[list[str], str]: + cwords = split_arg_string(os.environ.get("COMP_WORDS", "")) + incomplete = os.environ.get("COMP_CWORD", "") + args = cwords[1:] + + if incomplete and args and args[-1] == incomplete: + args.pop() + + return args, incomplete + + def format_completion(self, item) -> str: + return json.dumps({ + "type": item.type, + "value": item.value, + "help": item.help or "" + }, separators=(",", ":")) + + +def register_shell_completion() -> None: + if get_completion_class(PowerShellComplete.name) is None: + add_completion_class(PowerShellComplete) + + +def detect_shell() -> str: + """Auto-detect the current shell environment.""" + if system() == "Windows": + return "powershell" + + shell_path = os.environ.get("SHELL", "/bin/bash") + shell_name = Path(shell_path).name.lower() + + if "zsh" in shell_name: + return "zsh" + + if "fish" in shell_name: + return "fish" + + return "bash" + + +def get_completion_script(shell: Optional[str], prog_name: str = "lean") -> str: + register_shell_completion() + + shell_name = (shell or detect_shell()).lower() + complete_var = f"_{prog_name.replace('-', '_').replace('.', '_')}_COMPLETE".upper() + completion_class = get_completion_class(shell_name) + + if completion_class is None: + supported_shells = ", ".join(sorted(["bash", "fish", "powershell", "zsh"])) + raise RuntimeError(f"Unsupported shell '{shell_name}'. Supported shells: {supported_shells}") + + return completion_class(None, {}, prog_name, complete_var).source() diff --git a/lean/main.py b/lean/main.py index 86d25e8f..2ee9044d 100644 --- a/lean/main.py +++ b/lean/main.py @@ -88,16 +88,17 @@ def _ensure_win32_available() -> None: from lean.container import container -import click def main() -> None: """This function is the entrypoint when running a Lean command in a terminal.""" + from click.exceptions import Exit + try: lean.main(standalone_mode=False) temp_manager = container.temp_manager if temp_manager.delete_temporary_directories_when_done: temp_manager.delete_temporary_directories() - except click.exceptions.Exit as e: + except Exit as e: exit(e.exit_code) except Exception as exception: from traceback import format_exc diff --git a/tests/test_click_aliased_command_group.py b/tests/test_click_aliased_command_group.py index 33c62cd8..023a7da8 100644 --- a/tests/test_click_aliased_command_group.py +++ b/tests/test_click_aliased_command_group.py @@ -80,3 +80,37 @@ def command() -> None: assert len(aliases_help) == len(aliases_help) assert all(f"Alias for '{command_name}'" in alias_help for alias_help in aliases_help) assert main_command_doc in main_command_help + + +def test_aliased_command_group_resolves_unique_prefix_match() -> None: + @click.group(cls=AliasedCommandGroup) + def group() -> None: + pass + + @group.command() + def cloud() -> None: + click.echo("cloud") + + result = CliRunner().invoke(group, ["cl"]) + + assert result.exit_code == 0 + assert result.output == "cloud\n" + + +def test_aliased_command_group_fails_when_prefix_is_ambiguous() -> None: + @click.group(cls=AliasedCommandGroup) + def group() -> None: + pass + + @group.command() + def cloud() -> None: + pass + + @group.command() + def config() -> None: + pass + + result = CliRunner().invoke(group, ["c"]) + + assert result.exit_code != 0 + assert "Too many matches: cloud, config" in result.output diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 00000000..9dd5b1f1 --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,58 @@ +# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. +# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +from click.testing import CliRunner + +from lean.commands import lean + + +def test_completion_command_prints_powershell_script() -> None: + result = CliRunner().invoke(lean, ["completion", "--shell", "powershell"]) + + assert result.exit_code == 0 + assert "Register-ArgumentCompleter -Native -CommandName lean" in result.output + assert "_LEAN_COMPLETE" in result.output + + +def test_click_shell_completion_prints_powershell_source_script() -> None: + result = CliRunner().invoke(lean, [], prog_name="lean", env={ + "_LEAN_COMPLETE": "powershell_source" + }) + + assert result.exit_code == 0 + assert "Register-ArgumentCompleter -Native -CommandName lean" in result.output + + +def test_click_shell_completion_returns_powershell_completions() -> None: + result = CliRunner().invoke(lean, [], prog_name="lean", env={ + "_LEAN_COMPLETE": "powershell_complete", + "COMP_WORDS": "lean cl", + "COMP_CWORD": "cl" + }) + + assert result.exit_code == 0 + + completions = [json.loads(line) for line in result.output.strip().splitlines()] + completion_values = [item["value"] for item in completions] + assert "cloud" in completion_values + + +def test_click_shell_completion_prints_bash_source_script() -> None: + result = CliRunner().invoke(lean, [], prog_name="lean", env={ + "_LEAN_COMPLETE": "bash_source" + }) + + assert result.exit_code == 0 + assert "complete -o nosort -F" in result.output diff --git a/tests/test_main.py b/tests/test_main.py index ffea5974..da206bef 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -35,3 +35,26 @@ def test_lean_shows_error_when_running_unknown_command() -> None: assert result.exit_code != 0 assert "No such command" in result.output + + +def test_lean_runs_top_level_commands_by_unique_prefix() -> None: + result = CliRunner().invoke(lean, ["cl", "--help"]) + + assert result.exit_code == 0 + assert "Interact with the QuantConnect cloud." in result.output + assert "backtest" in result.output + + +def test_lean_runs_nested_commands_by_unique_prefix() -> None: + result = CliRunner().invoke(lean, ["cloud", "st", "--help"]) + + assert result.exit_code == 0 + assert "Show the live trading status of a project in the cloud." in result.output + assert "PROJECT" in result.output + + +def test_lean_reports_ambiguous_prefixes() -> None: + result = CliRunner().invoke(lean, ["c"]) + + assert result.exit_code != 0 + assert "Too many matches: cloud, completion, config, create-project" in result.output From 08832328a5c7291bfa3473f23da2c17230d8cb2e Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Wed, 15 Apr 2026 22:30:55 +0530 Subject: [PATCH 03/10] feat(cli): add Click-native shell completion with simple on/off commands --- lean/commands/completion.py | 50 +++++++++++--- .../components/util/click_shell_completion.py | 69 +++++++++++++++++++ tests/test_completion.py | 32 +++++++++ 3 files changed, 142 insertions(+), 9 deletions(-) diff --git a/lean/commands/completion.py b/lean/commands/completion.py index ab6aaa9e..418001bf 100644 --- a/lean/commands/completion.py +++ b/lean/commands/completion.py @@ -13,18 +13,23 @@ from typing import Optional -from click import Choice, command, echo, option +from click import Choice, Context, echo, group, option, pass_context -from lean.components.util.click_shell_completion import get_completion_script +from lean.components.util.click_aliased_command_group import AliasedCommandGroup +from lean.components.util.click_shell_completion import get_completion_script, install_completion, uninstall_completion -@command() -@option("--shell", - "-s", - type=Choice(["powershell", "bash", "zsh", "fish"], case_sensitive=False), - default=None, - help="Target shell. Auto-detected if not specified.") -def completion(shell: Optional[str]) -> None: +SHELL_OPTION = option("--shell", + "-s", + type=Choice(["powershell", "bash", "zsh", "fish"], case_sensitive=False), + default=None, + help="Target shell. Auto-detected if not specified.") + + +@group(cls=AliasedCommandGroup, invoke_without_command=True) +@SHELL_OPTION +@pass_context +def completion(ctx: Context, shell: Optional[str]) -> None: """Print the native shell completion script for your shell. \b @@ -39,4 +44,31 @@ def completion(shell: Optional[str]) -> None: Fish (current session): lean completion --shell fish | source """ + if ctx.invoked_subcommand is None: + echo(get_completion_script(shell)) + + +@completion.command(name="show", help="Print the native shell completion script for your shell") +@SHELL_OPTION +def show(shell: Optional[str]) -> None: echo(get_completion_script(shell)) + + +@completion.command(name="on", help="Enable shell completion in your shell profile") +@SHELL_OPTION +def on(shell: Optional[str]) -> None: + profile_path = install_completion(shell) + echo(f"Enabled shell completion in {profile_path}") + echo("Open a new terminal session for the change to take effect.") + + +@completion.command(name="off", help="Disable shell completion in your shell profile") +@SHELL_OPTION +def off(shell: Optional[str]) -> None: + profile_path, removed = uninstall_completion(shell) + + if removed: + echo(f"Disabled shell completion in {profile_path}") + echo("Open a new terminal session for the change to take effect.") + else: + echo(f"Shell completion was not enabled in {profile_path}") diff --git a/lean/components/util/click_shell_completion.py b/lean/components/util/click_shell_completion.py index c58c6c7c..be963a1a 100644 --- a/lean/components/util/click_shell_completion.py +++ b/lean/components/util/click_shell_completion.py @@ -110,3 +110,72 @@ def get_completion_script(shell: Optional[str], prog_name: str = "lean") -> str: raise RuntimeError(f"Unsupported shell '{shell_name}'. Supported shells: {supported_shells}") return completion_class(None, {}, prog_name, complete_var).source() + + +def get_profile_path(shell: Optional[str]) -> Path: + shell_name = (shell or detect_shell()).lower() + + if shell_name == "powershell": + if system() == "Windows": + return Path.home() / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + + return Path.home() / ".config" / "powershell" / "Microsoft.PowerShell_profile.ps1" + + if shell_name == "zsh": + return Path.home() / ".zshrc" + + if shell_name == "fish": + return Path.home() / ".config" / "fish" / "completions" / "lean.fish" + + return Path.home() / ".bashrc" + + +def install_completion(shell: Optional[str], prog_name: str = "lean") -> Path: + profile_path = get_profile_path(shell) + marker_start = "# >>> lean completion >>>" + marker_end = "# <<< lean completion <<<" + script = get_completion_script(shell, prog_name).strip() + + content = profile_path.read_text(encoding="utf-8") if profile_path.exists() else "" + if marker_start in content: + return profile_path + + profile_path.parent.mkdir(parents=True, exist_ok=True) + block = f"\n{marker_start}\n{script}\n{marker_end}\n" + + with profile_path.open("a", encoding="utf-8") as file: + file.write(block) + + return profile_path + + +def uninstall_completion(shell: Optional[str]) -> tuple[Path, bool]: + profile_path = get_profile_path(shell) + marker_start = "# >>> lean completion >>>" + marker_end = "# <<< lean completion <<<" + + if not profile_path.exists(): + return profile_path, False + + content = profile_path.read_text(encoding="utf-8") + start_index = content.find(marker_start) + if start_index == -1: + return profile_path, False + + end_index = content.find(marker_end, start_index) + if end_index == -1: + return profile_path, False + + end_index += len(marker_end) + new_content = content[:start_index].rstrip("\n") + tail = content[end_index:].lstrip("\n") + + if new_content and tail: + new_content = f"{new_content}\n{tail}" + elif tail: + new_content = tail + elif new_content: + new_content = f"{new_content}\n" + + profile_path.write_text(new_content, encoding="utf-8") + return profile_path, True diff --git a/tests/test_completion.py b/tests/test_completion.py index 9dd5b1f1..9f32a53e 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -12,6 +12,7 @@ # limitations under the License. import json +from pathlib import Path from click.testing import CliRunner @@ -56,3 +57,34 @@ def test_click_shell_completion_prints_bash_source_script() -> None: assert result.exit_code == 0 assert "complete -o nosort -F" in result.output + + +def test_completion_on_writes_powershell_profile() -> None: + result = CliRunner().invoke(lean, ["completion", "on", "--shell", "powershell"]) + + assert result.exit_code == 0 + + profile_path = Path.home() / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + assert profile_path.exists() + + content = profile_path.read_text(encoding="utf-8") + assert "# >>> lean completion >>>" in content + assert "Register-ArgumentCompleter -Native -CommandName lean" in content + + +def test_completion_off_removes_powershell_profile_block() -> None: + profile_path = Path.home() / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + profile_path.parent.mkdir(parents=True, exist_ok=True) + profile_path.write_text( + "# before\n# >>> lean completion >>>\nlean block\n# <<< lean completion <<<\n# after\n", + encoding="utf-8" + ) + + result = CliRunner().invoke(lean, ["completion", "off", "--shell", "powershell"]) + + assert result.exit_code == 0 + + content = profile_path.read_text(encoding="utf-8") + assert "# >>> lean completion >>>" not in content + assert "# before" in content + assert "# after" in content From 573d4c1528ffb1216c0c59b0a3f0cf3a075fe2d3 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Tue, 28 Apr 2026 15:45:00 +0530 Subject: [PATCH 04/10] feat(cli): improve Click-native shell completion --- lean/commands/__init__.py | 6 ++ lean/commands/completion.py | 5 +- lean/commands/lean.py | 3 - .../components/util/click_shell_completion.py | 9 +++ tests/test_completion.py | 55 +++++++++++-------- 5 files changed, 52 insertions(+), 26 deletions(-) diff --git a/lean/commands/__init__.py b/lean/commands/__init__.py index c5d5b4d3..c9d1427b 100644 --- a/lean/commands/__init__.py +++ b/lean/commands/__init__.py @@ -11,6 +11,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from os import environ + +if environ.get("_LEAN_COMPLETE", "").startswith("powershell_"): + from lean.components.util.click_shell_completion import register_shell_completion + register_shell_completion() + from lean.commands.lean import lean from lean.commands.completion import completion from lean.commands.backtest import backtest diff --git a/lean/commands/completion.py b/lean/commands/completion.py index 418001bf..7dbf525f 100644 --- a/lean/commands/completion.py +++ b/lean/commands/completion.py @@ -16,7 +16,6 @@ from click import Choice, Context, echo, group, option, pass_context from lean.components.util.click_aliased_command_group import AliasedCommandGroup -from lean.components.util.click_shell_completion import get_completion_script, install_completion, uninstall_completion SHELL_OPTION = option("--shell", @@ -45,18 +44,21 @@ def completion(ctx: Context, shell: Optional[str]) -> None: lean completion --shell fish | source """ if ctx.invoked_subcommand is None: + from lean.components.util.click_shell_completion import get_completion_script echo(get_completion_script(shell)) @completion.command(name="show", help="Print the native shell completion script for your shell") @SHELL_OPTION def show(shell: Optional[str]) -> None: + from lean.components.util.click_shell_completion import get_completion_script echo(get_completion_script(shell)) @completion.command(name="on", help="Enable shell completion in your shell profile") @SHELL_OPTION def on(shell: Optional[str]) -> None: + from lean.components.util.click_shell_completion import install_completion profile_path = install_completion(shell) echo(f"Enabled shell completion in {profile_path}") echo("Open a new terminal session for the change to take effect.") @@ -65,6 +67,7 @@ def on(shell: Optional[str]) -> None: @completion.command(name="off", help="Disable shell completion in your shell profile") @SHELL_OPTION def off(shell: Optional[str]) -> None: + from lean.components.util.click_shell_completion import uninstall_completion profile_path, removed = uninstall_completion(shell) if removed: diff --git a/lean/commands/lean.py b/lean/commands/lean.py index eb1ecc6d..749000bb 100644 --- a/lean/commands/lean.py +++ b/lean/commands/lean.py @@ -16,12 +16,9 @@ from lean import __version__ from lean.click import verbose_option from lean.components.util.click_aliased_command_group import AliasedCommandGroup -from lean.components.util.click_shell_completion import register_shell_completion from lean.container import container from lean.models.errors import MoreInfoError -register_shell_completion() - @group(cls=AliasedCommandGroup, invoke_without_command=True) @option("--version", is_flag=True, is_eager=True, help="Show the version and exit.") diff --git a/lean/components/util/click_shell_completion.py b/lean/components/util/click_shell_completion.py index be963a1a..af800008 100644 --- a/lean/components/util/click_shell_completion.py +++ b/lean/components/util/click_shell_completion.py @@ -49,6 +49,15 @@ Remove-Item Env:COMP_CWORD -ErrorAction SilentlyContinue } } + +try { + Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction SilentlyContinue + Set-PSReadLineOption -PredictionViewStyle InlineView -ErrorAction SilentlyContinue +} catch {} + +try { + Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete -ErrorAction SilentlyContinue +} catch {} """ diff --git a/tests/test_completion.py b/tests/test_completion.py index 9f32a53e..262ee851 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -12,11 +12,14 @@ # limitations under the License. import json +from tempfile import TemporaryDirectory from pathlib import Path +from unittest.mock import patch from click.testing import CliRunner from lean.commands import lean +from lean.components.util.click_shell_completion import register_shell_completion def test_completion_command_prints_powershell_script() -> None: @@ -25,9 +28,13 @@ def test_completion_command_prints_powershell_script() -> None: assert result.exit_code == 0 assert "Register-ArgumentCompleter -Native -CommandName lean" in result.output assert "_LEAN_COMPLETE" in result.output + assert "Set-PSReadLineOption -PredictionSource HistoryAndPlugin" in result.output + assert "Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete" in result.output def test_click_shell_completion_prints_powershell_source_script() -> None: + register_shell_completion() + result = CliRunner().invoke(lean, [], prog_name="lean", env={ "_LEAN_COMPLETE": "powershell_source" }) @@ -37,6 +44,8 @@ def test_click_shell_completion_prints_powershell_source_script() -> None: def test_click_shell_completion_returns_powershell_completions() -> None: + register_shell_completion() + result = CliRunner().invoke(lean, [], prog_name="lean", env={ "_LEAN_COMPLETE": "powershell_complete", "COMP_WORDS": "lean cl", @@ -60,31 +69,33 @@ def test_click_shell_completion_prints_bash_source_script() -> None: def test_completion_on_writes_powershell_profile() -> None: - result = CliRunner().invoke(lean, ["completion", "on", "--shell", "powershell"]) + with TemporaryDirectory() as directory, patch.object(Path, "home", return_value=Path(directory)): + result = CliRunner().invoke(lean, ["completion", "on", "--shell", "powershell"]) - assert result.exit_code == 0 + assert result.exit_code == 0 - profile_path = Path.home() / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" - assert profile_path.exists() + profile_path = Path(directory) / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + assert profile_path.exists() - content = profile_path.read_text(encoding="utf-8") - assert "# >>> lean completion >>>" in content - assert "Register-ArgumentCompleter -Native -CommandName lean" in content + content = profile_path.read_text(encoding="utf-8") + assert "# >>> lean completion >>>" in content + assert "Register-ArgumentCompleter -Native -CommandName lean" in content def test_completion_off_removes_powershell_profile_block() -> None: - profile_path = Path.home() / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" - profile_path.parent.mkdir(parents=True, exist_ok=True) - profile_path.write_text( - "# before\n# >>> lean completion >>>\nlean block\n# <<< lean completion <<<\n# after\n", - encoding="utf-8" - ) - - result = CliRunner().invoke(lean, ["completion", "off", "--shell", "powershell"]) - - assert result.exit_code == 0 - - content = profile_path.read_text(encoding="utf-8") - assert "# >>> lean completion >>>" not in content - assert "# before" in content - assert "# after" in content + with TemporaryDirectory() as directory, patch.object(Path, "home", return_value=Path(directory)): + profile_path = Path(directory) / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + profile_path.parent.mkdir(parents=True, exist_ok=True) + profile_path.write_text( + "# before\n# >>> lean completion >>>\nlean block\n# <<< lean completion <<<\n# after\n", + encoding="utf-8" + ) + + result = CliRunner().invoke(lean, ["completion", "off", "--shell", "powershell"]) + + assert result.exit_code == 0 + + content = profile_path.read_text(encoding="utf-8") + assert "# >>> lean completion >>>" not in content + assert "# before" in content + assert "# after" in content From 4376bb8085dda8077e3020492a1e6f260b1ad7d2 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Tue, 28 Apr 2026 15:52:34 +0530 Subject: [PATCH 05/10] fix(cli): stop PowerShell completion from using history predictions --- lean/components/util/click_shell_completion.py | 5 ----- tests/test_completion.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/lean/components/util/click_shell_completion.py b/lean/components/util/click_shell_completion.py index af800008..990ecffe 100644 --- a/lean/components/util/click_shell_completion.py +++ b/lean/components/util/click_shell_completion.py @@ -50,11 +50,6 @@ } } -try { - Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction SilentlyContinue - Set-PSReadLineOption -PredictionViewStyle InlineView -ErrorAction SilentlyContinue -} catch {} - try { Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete -ErrorAction SilentlyContinue } catch {} diff --git a/tests/test_completion.py b/tests/test_completion.py index 262ee851..1a093592 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -28,7 +28,7 @@ def test_completion_command_prints_powershell_script() -> None: assert result.exit_code == 0 assert "Register-ArgumentCompleter -Native -CommandName lean" in result.output assert "_LEAN_COMPLETE" in result.output - assert "Set-PSReadLineOption -PredictionSource HistoryAndPlugin" in result.output + assert "Set-PSReadLineOption -PredictionSource HistoryAndPlugin" not in result.output assert "Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete" in result.output From 6a3eaa84ed52637ca4c7d415fbb1e062ec188441 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Tue, 28 Apr 2026 15:54:32 +0530 Subject: [PATCH 06/10] fix(cli): handle denied shell profile updates --- lean/commands/completion.py | 22 +++++++++++++++++++--- tests/test_completion.py | 10 ++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/lean/commands/completion.py b/lean/commands/completion.py index 7dbf525f..4c8a502c 100644 --- a/lean/commands/completion.py +++ b/lean/commands/completion.py @@ -13,7 +13,7 @@ from typing import Optional -from click import Choice, Context, echo, group, option, pass_context +from click import Choice, ClickException, Context, echo, group, option, pass_context from lean.components.util.click_aliased_command_group import AliasedCommandGroup @@ -25,6 +25,15 @@ help="Target shell. Auto-detected if not specified.") +def _profile_permission_error(exception: PermissionError) -> ClickException: + path = exception.filename or "the shell profile" + return ClickException( + f"Unable to update {path}. " + "Please close any editor or terminal using that profile, or update the profile manually. " + "For the current session, run `lean completion --shell powershell | Out-String | Invoke-Expression`." + ) + + @group(cls=AliasedCommandGroup, invoke_without_command=True) @SHELL_OPTION @pass_context @@ -59,7 +68,11 @@ def show(shell: Optional[str]) -> None: @SHELL_OPTION def on(shell: Optional[str]) -> None: from lean.components.util.click_shell_completion import install_completion - profile_path = install_completion(shell) + try: + profile_path = install_completion(shell) + except PermissionError as exception: + raise _profile_permission_error(exception) + echo(f"Enabled shell completion in {profile_path}") echo("Open a new terminal session for the change to take effect.") @@ -68,7 +81,10 @@ def on(shell: Optional[str]) -> None: @SHELL_OPTION def off(shell: Optional[str]) -> None: from lean.components.util.click_shell_completion import uninstall_completion - profile_path, removed = uninstall_completion(shell) + try: + profile_path, removed = uninstall_completion(shell) + except PermissionError as exception: + raise _profile_permission_error(exception) if removed: echo(f"Disabled shell completion in {profile_path}") diff --git a/tests/test_completion.py b/tests/test_completion.py index 1a093592..26681d0a 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -99,3 +99,13 @@ def test_completion_off_removes_powershell_profile_block() -> None: assert "# >>> lean completion >>>" not in content assert "# before" in content assert "# after" in content + + +def test_completion_off_shows_clear_error_when_profile_cannot_be_updated() -> None: + with patch("lean.components.util.click_shell_completion.uninstall_completion", + side_effect=PermissionError(13, "Permission denied", "profile.ps1")): + result = CliRunner().invoke(lean, ["completion", "off", "--shell", "powershell"]) + + assert result.exit_code != 0 + assert "Unable to update profile.ps1" in result.output + assert "lean completion --shell powershell | Out-String | Invoke-Expression" in result.output From 795c667d301adc1e951db881f715a5332d875869 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Tue, 28 Apr 2026 16:01:38 +0530 Subject: [PATCH 07/10] fix(cli): add current-session completion cleanup --- lean/commands/completion.py | 15 ++++++++++++--- lean/components/util/click_shell_completion.py | 15 +++++++++++++++ tests/test_completion.py | 11 ++++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lean/commands/completion.py b/lean/commands/completion.py index 4c8a502c..daa191e9 100644 --- a/lean/commands/completion.py +++ b/lean/commands/completion.py @@ -30,7 +30,8 @@ def _profile_permission_error(exception: PermissionError) -> ClickException: return ClickException( f"Unable to update {path}. " "Please close any editor or terminal using that profile, or update the profile manually. " - "For the current session, run `lean completion --shell powershell | Out-String | Invoke-Expression`." + "For the current PowerShell session, run " + "`lean completion off --shell powershell --current-session | Out-String | Invoke-Expression`." ) @@ -78,9 +79,15 @@ def on(shell: Optional[str]) -> None: @completion.command(name="off", help="Disable shell completion in your shell profile") +@option("--current-session", is_flag=True, help="Print a script that disables completion in the current shell session.") @SHELL_OPTION -def off(shell: Optional[str]) -> None: - from lean.components.util.click_shell_completion import uninstall_completion +def off(shell: Optional[str], current_session: bool) -> None: + from lean.components.util.click_shell_completion import get_completion_cleanup_script, uninstall_completion + + if current_session: + echo(get_completion_cleanup_script(shell)) + return + try: profile_path, removed = uninstall_completion(shell) except PermissionError as exception: @@ -89,5 +96,7 @@ def off(shell: Optional[str]) -> None: if removed: echo(f"Disabled shell completion in {profile_path}") echo("Open a new terminal session for the change to take effect.") + echo("To disable it in this session, run:") + echo("lean completion off --shell powershell --current-session | Out-String | Invoke-Expression") else: echo(f"Shell completion was not enabled in {profile_path}") diff --git a/lean/components/util/click_shell_completion.py b/lean/components/util/click_shell_completion.py index 990ecffe..205f276d 100644 --- a/lean/components/util/click_shell_completion.py +++ b/lean/components/util/click_shell_completion.py @@ -116,6 +116,21 @@ def get_completion_script(shell: Optional[str], prog_name: str = "lean") -> str: return completion_class(None, {}, prog_name, complete_var).source() +def get_completion_cleanup_script(shell: Optional[str], prog_name: str = "lean") -> str: + shell_name = (shell or detect_shell()).lower() + + if shell_name == "powershell": + return f""" +Register-ArgumentCompleter -Native -CommandName {prog_name} -ScriptBlock {{ @() }} + +try {{ + Set-PSReadLineOption -PredictionSource None -ErrorAction SilentlyContinue +}} catch {{}} +""".strip() + + return "" + + def get_profile_path(shell: Optional[str]) -> Path: shell_name = (shell or detect_shell()).lower() diff --git a/tests/test_completion.py b/tests/test_completion.py index 26681d0a..fff0f784 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -99,6 +99,15 @@ def test_completion_off_removes_powershell_profile_block() -> None: assert "# >>> lean completion >>>" not in content assert "# before" in content assert "# after" in content + assert "lean completion off --shell powershell --current-session" in result.output + + +def test_completion_off_current_session_prints_powershell_cleanup_script() -> None: + result = CliRunner().invoke(lean, ["completion", "off", "--shell", "powershell", "--current-session"]) + + assert result.exit_code == 0 + assert "Register-ArgumentCompleter -Native -CommandName lean -ScriptBlock { @() }" in result.output + assert "Set-PSReadLineOption -PredictionSource None" in result.output def test_completion_off_shows_clear_error_when_profile_cannot_be_updated() -> None: @@ -108,4 +117,4 @@ def test_completion_off_shows_clear_error_when_profile_cannot_be_updated() -> No assert result.exit_code != 0 assert "Unable to update profile.ps1" in result.output - assert "lean completion --shell powershell | Out-String | Invoke-Expression" in result.output + assert "lean completion off --shell powershell --current-session | Out-String | Invoke-Expression" in result.output From 569acedd4e0ef959a4dd1c0855d7d30434156dcc Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Tue, 28 Apr 2026 16:17:40 +0530 Subject: [PATCH 08/10] refactor(cli): rename completion command to autocomplete --- lean/commands/__init__.py | 11 ++- .../{completion.py => autocomplete.py} | 49 +++++----- lean/commands/init.py | 2 +- .../util/click_aliased_command_group.py | 53 ++++++++--- ...pletion.py => click_shell_autocomplete.py} | 89 +++++++++++++++---- ...est_completion.py => test_autocomplete.py} | 86 ++++++++++++++---- tests/test_click_aliased_command_group.py | 22 +++++ tests/test_main.py | 2 +- 8 files changed, 238 insertions(+), 76 deletions(-) rename lean/commands/{completion.py => autocomplete.py} (57%) rename lean/components/util/{click_shell_completion.py => click_shell_autocomplete.py} (70%) rename tests/{test_completion.py => test_autocomplete.py} (50%) diff --git a/lean/commands/__init__.py b/lean/commands/__init__.py index c9d1427b..cbaff0ea 100644 --- a/lean/commands/__init__.py +++ b/lean/commands/__init__.py @@ -12,13 +12,14 @@ # limitations under the License. from os import environ +from copy import copy if environ.get("_LEAN_COMPLETE", "").startswith("powershell_"): - from lean.components.util.click_shell_completion import register_shell_completion - register_shell_completion() + from lean.components.util.click_shell_autocomplete import register_shell_autocomplete + register_shell_autocomplete() from lean.commands.lean import lean -from lean.commands.completion import completion +from lean.commands.autocomplete import autocomplete from lean.commands.backtest import backtest from lean.commands.build import build from lean.commands.cloud import cloud @@ -43,6 +44,10 @@ from lean.commands.private_cloud import private_cloud lean.add_command(config) +lean.add_command(autocomplete) +completion = copy(autocomplete) +completion.name = "completion" +completion.hidden = True lean.add_command(completion) lean.add_command(cloud) lean.add_command(data) diff --git a/lean/commands/completion.py b/lean/commands/autocomplete.py similarity index 57% rename from lean/commands/completion.py rename to lean/commands/autocomplete.py index daa191e9..186b126b 100644 --- a/lean/commands/completion.py +++ b/lean/commands/autocomplete.py @@ -30,73 +30,72 @@ def _profile_permission_error(exception: PermissionError) -> ClickException: return ClickException( f"Unable to update {path}. " "Please close any editor or terminal using that profile, or update the profile manually. " - "For the current PowerShell session, run " - "`lean completion off --shell powershell --current-session | Out-String | Invoke-Expression`." + "If Lean autocomplete is loaded in this PowerShell session, run `lean autocomplete off --shell powershell` again " + "after reloading the updated script." ) @group(cls=AliasedCommandGroup, invoke_without_command=True) @SHELL_OPTION @pass_context -def completion(ctx: Context, shell: Optional[str]) -> None: - """Print the native shell completion script for your shell. +def autocomplete(ctx: Context, shell: Optional[str]) -> None: + """Print the native shell autocomplete script for your shell. \b PowerShell (current session): - lean completion --shell powershell | Out-String | Invoke-Expression + lean autocomplete --shell powershell | Out-String | Invoke-Expression \b Bash or Zsh (current session): - eval "$(lean completion --shell bash)" + eval "$(lean autocomplete --shell bash)" \b Fish (current session): - lean completion --shell fish | source + lean autocomplete --shell fish | source """ if ctx.invoked_subcommand is None: - from lean.components.util.click_shell_completion import get_completion_script - echo(get_completion_script(shell)) + from lean.components.util.click_shell_autocomplete import get_autocomplete_script + echo(get_autocomplete_script(shell)) -@completion.command(name="show", help="Print the native shell completion script for your shell") +@autocomplete.command(name="show", help="Print the native shell autocomplete script for your shell") @SHELL_OPTION def show(shell: Optional[str]) -> None: - from lean.components.util.click_shell_completion import get_completion_script - echo(get_completion_script(shell)) + from lean.components.util.click_shell_autocomplete import get_autocomplete_script + echo(get_autocomplete_script(shell)) -@completion.command(name="on", help="Enable shell completion in your shell profile") +@autocomplete.command(name="on", help="Enable shell autocomplete in your shell profile") @SHELL_OPTION def on(shell: Optional[str]) -> None: - from lean.components.util.click_shell_completion import install_completion + from lean.components.util.click_shell_autocomplete import install_autocomplete try: - profile_path = install_completion(shell) + profile_path = install_autocomplete(shell) except PermissionError as exception: raise _profile_permission_error(exception) - echo(f"Enabled shell completion in {profile_path}") + echo(f"Enabled shell autocomplete in {profile_path}") echo("Open a new terminal session for the change to take effect.") -@completion.command(name="off", help="Disable shell completion in your shell profile") -@option("--current-session", is_flag=True, help="Print a script that disables completion in the current shell session.") +@autocomplete.command(name="off", help="Disable shell autocomplete in your shell profile") +@option("--current-session", is_flag=True, help="Print a script that disables autocomplete in the current shell session.") @SHELL_OPTION def off(shell: Optional[str], current_session: bool) -> None: - from lean.components.util.click_shell_completion import get_completion_cleanup_script, uninstall_completion + from lean.components.util.click_shell_autocomplete import get_autocomplete_cleanup_script, uninstall_autocomplete if current_session: - echo(get_completion_cleanup_script(shell)) + echo(get_autocomplete_cleanup_script(shell)) return try: - profile_path, removed = uninstall_completion(shell) + profile_path, removed = uninstall_autocomplete(shell) except PermissionError as exception: raise _profile_permission_error(exception) if removed: - echo(f"Disabled shell completion in {profile_path}") + echo(f"Disabled shell autocomplete in {profile_path}") echo("Open a new terminal session for the change to take effect.") - echo("To disable it in this session, run:") - echo("lean completion off --shell powershell --current-session | Out-String | Invoke-Expression") + echo("To disable it in this PowerShell session, run `lean autocomplete off --shell powershell` again after reloading the updated script.") else: - echo(f"Shell completion was not enabled in {profile_path}") + echo(f"Shell autocomplete was not enabled in {profile_path}") diff --git a/lean/commands/init.py b/lean/commands/init.py index 87ea1ea0..450330e8 100644 --- a/lean/commands/init.py +++ b/lean/commands/init.py @@ -207,7 +207,7 @@ def init(organization: Optional[str], language: Optional[str]) -> None: - Synchronizing projects with the cloud: https://www.lean.io/docs/v2/lean-cli/projects/cloud-synchronization Here are some commands to get you going: -- Run `lean completion --shell powershell | Out-String | Invoke-Expression` to enable PowerShell completion in the current session +- Run `lean autocomplete --shell powershell | Out-String | Invoke-Expression` to enable PowerShell autocomplete in the current session - Run `lean create-project "My Project"` to create a new project with starter code - Run `lean cloud pull` to download all your QuantConnect projects to your local drive - Run `lean backtest "My Project"` to backtest a project locally with the data in {DEFAULT_DATA_DIRECTORY_NAME}/ diff --git a/lean/components/util/click_aliased_command_group.py b/lean/components/util/click_aliased_command_group.py index 3267fbef..416f4843 100644 --- a/lean/components/util/click_aliased_command_group.py +++ b/lean/components/util/click_aliased_command_group.py @@ -11,18 +11,28 @@ # See the License for the specific language governing permissions and # limitations under the License. -from click import Group +from typing import Any, Callable, Optional, Union, overload + +from click import Command, Context, Group + + +CommandCallback = Callable[..., Any] +CommandDecorator = Callable[[CommandCallback], Command] class AliasedCommandGroup(Group): - """A click.Group wrapper that implements command aliasing and auto-completion/prefix matching.""" + """A click.Group wrapper that implements command aliasing and autocomplete prefix matching.""" - def get_command(self, ctx, cmd_name): + def get_command(self, ctx: Context, cmd_name: str) -> Optional[Command]: rv = super().get_command(ctx, cmd_name) if rv is not None: return rv - matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + matches = [] + for name in self.list_commands(ctx): + command = super().get_command(ctx, name) + if command is not None and not command.hidden and name.startswith(cmd_name): + matches.append(name) if not matches: return None @@ -31,23 +41,41 @@ def get_command(self, ctx, cmd_name): ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") - def command(self, *args, **kwargs): + @overload + def command(self, __func: CommandCallback) -> Command: + ... + + @overload + def command(self, *args: Any, **kwargs: Any) -> CommandDecorator: + ... + + def command(self, *args: Any, **kwargs: Any) -> Union[CommandDecorator, Command]: aliases = kwargs.pop('aliases', []) if not aliases: return super().command(*args, **kwargs) - def _decorator(f): + func = None + if args and callable(args[0]): + assert len(args) == 1, "Use 'command(**kwargs)(callable)' to provide arguments." + func = args[0] + args = () + + def _decorator(f: CommandCallback) -> Command: + cmd_kwargs = dict(kwargs) + cmd_name = cmd_kwargs.pop("name", None) + if args: - cmd_name = args[0] - cmd_args = args[1:] + if cmd_name is None: + cmd_name = args[0] + cmd_args = args[1:] + else: + cmd_args = args else: - cmd_name = kwargs.get("name", f.__name__.lower().replace("_", "-")) + cmd_name = cmd_name or f.__name__.lower().replace("_", "-") cmd_args = () alias_help = f"Alias for '{cmd_name}'" - cmd_kwargs = dict(kwargs) - cmd_kwargs.pop("name", None) # Add the main command cmd = super(AliasedCommandGroup, self).command(*cmd_args, name=cmd_name, **cmd_kwargs)(f) @@ -62,4 +90,7 @@ def _decorator(f): return cmd + if func is not None: + return _decorator(func) + return _decorator diff --git a/lean/components/util/click_shell_completion.py b/lean/components/util/click_shell_autocomplete.py similarity index 70% rename from lean/components/util/click_shell_completion.py rename to lean/components/util/click_shell_autocomplete.py index 205f276d..639b15c8 100644 --- a/lean/components/util/click_shell_completion.py +++ b/lean/components/util/click_shell_autocomplete.py @@ -53,11 +53,51 @@ try { Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete -ErrorAction SilentlyContinue } catch {} + +function __LeanCliExecutable { + $command = Get-Command %(prog_name)s -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + return $command.Source + } + + return "%(prog_name)s" +} + +function lean-autocomplete-off { + Register-ArgumentCompleter -Native -CommandName lean -ScriptBlock { @() } + + try { + Set-PSReadLineOption -PredictionSource None -ErrorAction SilentlyContinue + } catch {} +} + +function lean-autocomplete-on { + $lean = __LeanCliExecutable + & $lean autocomplete --shell powershell | Out-String | Invoke-Expression +} + +function lean { + $lean = __LeanCliExecutable + + if ($args.Count -ge 2 -and ($args[0] -eq "completion" -or $args[0] -eq "autocomplete") -and $args[1] -eq "off") { + & $lean @args + lean-autocomplete-off + return + } + + if ($args.Count -ge 2 -and ($args[0] -eq "completion" -or $args[0] -eq "autocomplete") -and $args[1] -eq "on") { + & $lean @args + lean-autocomplete-on + return + } + + & $lean @args +} """ class PowerShellComplete(ShellComplete): - """Shell completion for PowerShell.""" + """Shell autocomplete for PowerShell.""" name = "powershell" source_template = _SOURCE_POWERSHELL @@ -80,7 +120,7 @@ def format_completion(self, item) -> str: }, separators=(",", ":")) -def register_shell_completion() -> None: +def register_shell_autocomplete() -> None: if get_completion_class(PowerShellComplete.name) is None: add_completion_class(PowerShellComplete) @@ -102,8 +142,8 @@ def detect_shell() -> str: return "bash" -def get_completion_script(shell: Optional[str], prog_name: str = "lean") -> str: - register_shell_completion() +def get_autocomplete_script(shell: Optional[str], prog_name: str = "lean") -> str: + register_shell_autocomplete() shell_name = (shell or detect_shell()).lower() complete_var = f"_{prog_name.replace('-', '_').replace('.', '_')}_COMPLETE".upper() @@ -116,7 +156,7 @@ def get_completion_script(shell: Optional[str], prog_name: str = "lean") -> str: return completion_class(None, {}, prog_name, complete_var).source() -def get_completion_cleanup_script(shell: Optional[str], prog_name: str = "lean") -> str: +def get_autocomplete_cleanup_script(shell: Optional[str], prog_name: str = "lean") -> str: shell_name = (shell or detect_shell()).lower() if shell_name == "powershell": @@ -126,6 +166,10 @@ def get_completion_cleanup_script(shell: Optional[str], prog_name: str = "lean") try {{ Set-PSReadLineOption -PredictionSource None -ErrorAction SilentlyContinue }} catch {{}} + +function lean-autocomplete-on {{ + & {prog_name} autocomplete --shell powershell | Out-String | Invoke-Expression +}} """.strip() return "" @@ -149,11 +193,11 @@ def get_profile_path(shell: Optional[str]) -> Path: return Path.home() / ".bashrc" -def install_completion(shell: Optional[str], prog_name: str = "lean") -> Path: +def install_autocomplete(shell: Optional[str], prog_name: str = "lean") -> Path: profile_path = get_profile_path(shell) - marker_start = "# >>> lean completion >>>" - marker_end = "# <<< lean completion <<<" - script = get_completion_script(shell, prog_name).strip() + marker_start = "# >>> lean autocomplete >>>" + marker_end = "# <<< lean autocomplete <<<" + script = get_autocomplete_script(shell, prog_name).strip() content = profile_path.read_text(encoding="utf-8") if profile_path.exists() else "" if marker_start in content: @@ -168,21 +212,30 @@ def install_completion(shell: Optional[str], prog_name: str = "lean") -> Path: return profile_path -def uninstall_completion(shell: Optional[str]) -> tuple[Path, bool]: +def uninstall_autocomplete(shell: Optional[str]) -> tuple[Path, bool]: profile_path = get_profile_path(shell) - marker_start = "# >>> lean completion >>>" - marker_end = "# <<< lean completion <<<" if not profile_path.exists(): return profile_path, False content = profile_path.read_text(encoding="utf-8") - start_index = content.find(marker_start) - if start_index == -1: - return profile_path, False - - end_index = content.find(marker_end, start_index) - if end_index == -1: + markers = [ + ("# >>> lean autocomplete >>>", "# <<< lean autocomplete <<<"), + ("# >>> lean completion >>>", "# <<< lean completion <<<") + ] + + start_index = -1 + end_index = -1 + for marker_start, marker_end in markers: + start_index = content.find(marker_start) + if start_index == -1: + continue + + end_index = content.find(marker_end, start_index) + if end_index != -1: + break + + if start_index == -1 or end_index == -1: return profile_path, False end_index += len(marker_end) diff --git a/tests/test_completion.py b/tests/test_autocomplete.py similarity index 50% rename from tests/test_completion.py rename to tests/test_autocomplete.py index fff0f784..ba235556 100644 --- a/tests/test_completion.py +++ b/tests/test_autocomplete.py @@ -19,10 +19,10 @@ from click.testing import CliRunner from lean.commands import lean -from lean.components.util.click_shell_completion import register_shell_completion +from lean.components.util.click_shell_autocomplete import register_shell_autocomplete -def test_completion_command_prints_powershell_script() -> None: +def test_hidden_completion_alias_prints_powershell_script() -> None: result = CliRunner().invoke(lean, ["completion", "--shell", "powershell"]) assert result.exit_code == 0 @@ -30,10 +30,30 @@ def test_completion_command_prints_powershell_script() -> None: assert "_LEAN_COMPLETE" in result.output assert "Set-PSReadLineOption -PredictionSource HistoryAndPlugin" not in result.output assert "Set-PSReadLineKeyHandler -Key Tab -Function MenuComplete" in result.output + assert "function lean-autocomplete-off" in result.output + assert "function lean-autocomplete-on" in result.output + assert "function lean {" in result.output + assert '__LeanCliExecutable' in result.output -def test_click_shell_completion_prints_powershell_source_script() -> None: - register_shell_completion() +def test_autocomplete_command_prints_powershell_script() -> None: + result = CliRunner().invoke(lean, ["autocomplete", "--shell", "powershell"]) + + assert result.exit_code == 0 + assert "Register-ArgumentCompleter -Native -CommandName lean" in result.output + assert "& $lean autocomplete --shell powershell | Out-String | Invoke-Expression" in result.output + + +def test_lean_help_shows_autocomplete_not_hidden_completion_alias() -> None: + result = CliRunner().invoke(lean, ["--help"]) + + assert result.exit_code == 0 + assert "autocomplete" in result.output + assert "completion" not in result.output + + +def test_click_shell_autocomplete_prints_powershell_source_script() -> None: + register_shell_autocomplete() result = CliRunner().invoke(lean, [], prog_name="lean", env={ "_LEAN_COMPLETE": "powershell_source" @@ -43,8 +63,8 @@ def test_click_shell_completion_prints_powershell_source_script() -> None: assert "Register-ArgumentCompleter -Native -CommandName lean" in result.output -def test_click_shell_completion_returns_powershell_completions() -> None: - register_shell_completion() +def test_click_shell_autocomplete_returns_powershell_completions() -> None: + register_shell_autocomplete() result = CliRunner().invoke(lean, [], prog_name="lean", env={ "_LEAN_COMPLETE": "powershell_complete", @@ -59,7 +79,7 @@ def test_click_shell_completion_returns_powershell_completions() -> None: assert "cloud" in completion_values -def test_click_shell_completion_prints_bash_source_script() -> None: +def test_click_shell_autocomplete_prints_bash_source_script() -> None: result = CliRunner().invoke(lean, [], prog_name="lean", env={ "_LEAN_COMPLETE": "bash_source" }) @@ -68,7 +88,7 @@ def test_click_shell_completion_prints_bash_source_script() -> None: assert "complete -o nosort -F" in result.output -def test_completion_on_writes_powershell_profile() -> None: +def test_hidden_completion_alias_on_writes_powershell_profile() -> None: with TemporaryDirectory() as directory, patch.object(Path, "home", return_value=Path(directory)): result = CliRunner().invoke(lean, ["completion", "on", "--shell", "powershell"]) @@ -78,11 +98,25 @@ def test_completion_on_writes_powershell_profile() -> None: assert profile_path.exists() content = profile_path.read_text(encoding="utf-8") - assert "# >>> lean completion >>>" in content + assert "# >>> lean autocomplete >>>" in content + assert "Register-ArgumentCompleter -Native -CommandName lean" in content + + +def test_autocomplete_on_writes_powershell_profile() -> None: + with TemporaryDirectory() as directory, patch.object(Path, "home", return_value=Path(directory)): + result = CliRunner().invoke(lean, ["autocomplete", "on", "--shell", "powershell"]) + + assert result.exit_code == 0 + + profile_path = Path(directory) / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + assert profile_path.exists() + + content = profile_path.read_text(encoding="utf-8") + assert "# >>> lean autocomplete >>>" in content assert "Register-ArgumentCompleter -Native -CommandName lean" in content -def test_completion_off_removes_powershell_profile_block() -> None: +def test_hidden_completion_alias_off_removes_legacy_powershell_profile_block() -> None: with TemporaryDirectory() as directory, patch.object(Path, "home", return_value=Path(directory)): profile_path = Path(directory) / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" profile_path.parent.mkdir(parents=True, exist_ok=True) @@ -99,22 +133,40 @@ def test_completion_off_removes_powershell_profile_block() -> None: assert "# >>> lean completion >>>" not in content assert "# before" in content assert "# after" in content - assert "lean completion off --shell powershell --current-session" in result.output + assert "lean autocomplete off --shell powershell" in result.output + + +def test_autocomplete_off_removes_powershell_profile_block() -> None: + with TemporaryDirectory() as directory, patch.object(Path, "home", return_value=Path(directory)): + profile_path = Path(directory) / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + profile_path.parent.mkdir(parents=True, exist_ok=True) + profile_path.write_text( + "# before\n# >>> lean autocomplete >>>\nlean block\n# <<< lean autocomplete <<<\n# after\n", + encoding="utf-8" + ) + result = CliRunner().invoke(lean, ["autocomplete", "off", "--shell", "powershell"]) + + assert result.exit_code == 0 + assert "# >>> lean autocomplete >>>" not in profile_path.read_text(encoding="utf-8") + assert "lean autocomplete off --shell powershell" in result.output -def test_completion_off_current_session_prints_powershell_cleanup_script() -> None: - result = CliRunner().invoke(lean, ["completion", "off", "--shell", "powershell", "--current-session"]) + +def test_autocomplete_off_current_session_prints_powershell_cleanup_script() -> None: + result = CliRunner().invoke(lean, ["autocomplete", "off", "--shell", "powershell", "--current-session"]) assert result.exit_code == 0 assert "Register-ArgumentCompleter -Native -CommandName lean -ScriptBlock { @() }" in result.output assert "Set-PSReadLineOption -PredictionSource None" in result.output + assert "function lean-autocomplete-on" in result.output + assert "autocomplete --shell powershell" in result.output -def test_completion_off_shows_clear_error_when_profile_cannot_be_updated() -> None: - with patch("lean.components.util.click_shell_completion.uninstall_completion", +def test_autocomplete_off_shows_clear_error_when_profile_cannot_be_updated() -> None: + with patch("lean.components.util.click_shell_autocomplete.uninstall_autocomplete", side_effect=PermissionError(13, "Permission denied", "profile.ps1")): - result = CliRunner().invoke(lean, ["completion", "off", "--shell", "powershell"]) + result = CliRunner().invoke(lean, ["autocomplete", "off", "--shell", "powershell"]) assert result.exit_code != 0 assert "Unable to update profile.ps1" in result.output - assert "lean completion off --shell powershell --current-session | Out-String | Invoke-Expression" in result.output + assert "lean autocomplete off --shell powershell" in result.output diff --git a/tests/test_click_aliased_command_group.py b/tests/test_click_aliased_command_group.py index 023a7da8..569bc77b 100644 --- a/tests/test_click_aliased_command_group.py +++ b/tests/test_click_aliased_command_group.py @@ -114,3 +114,25 @@ def config() -> None: assert result.exit_code != 0 assert "Too many matches: cloud, config" in result.output + + +def test_aliased_command_group_ignores_hidden_commands_for_prefix_matching() -> None: + @click.group(cls=AliasedCommandGroup) + def group() -> None: + pass + + @group.command(hidden=True) + def completion() -> None: + click.echo("completion") + + @group.command() + def cloud() -> None: + click.echo("cloud") + + prefix_result = CliRunner().invoke(group, ["c"]) + exact_result = CliRunner().invoke(group, ["completion"]) + + assert prefix_result.exit_code == 0 + assert prefix_result.output == "cloud\n" + assert exact_result.exit_code == 0 + assert exact_result.output == "completion\n" diff --git a/tests/test_main.py b/tests/test_main.py index da206bef..9acef3f5 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -57,4 +57,4 @@ def test_lean_reports_ambiguous_prefixes() -> None: result = CliRunner().invoke(lean, ["c"]) assert result.exit_code != 0 - assert "Too many matches: cloud, completion, config, create-project" in result.output + assert "Too many matches: cloud, config, create-project" in result.output From 7c6afe9805ba8c3c1f1d2901a934a876a7491692 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Tue, 28 Apr 2026 16:21:41 +0530 Subject: [PATCH 09/10] refactor(cli): make autocomplete the primary command --- lean/commands/autocomplete.py | 4 ++-- tests/test_autocomplete.py | 37 ++++++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lean/commands/autocomplete.py b/lean/commands/autocomplete.py index 186b126b..ec4cddce 100644 --- a/lean/commands/autocomplete.py +++ b/lean/commands/autocomplete.py @@ -30,7 +30,7 @@ def _profile_permission_error(exception: PermissionError) -> ClickException: return ClickException( f"Unable to update {path}. " "Please close any editor or terminal using that profile, or update the profile manually. " - "If Lean autocomplete is loaded in this PowerShell session, run `lean autocomplete off --shell powershell` again " + "If Lean autocomplete is loaded in this PowerShell session, run `lean autocomplete off` again " "after reloading the updated script." ) @@ -96,6 +96,6 @@ def off(shell: Optional[str], current_session: bool) -> None: if removed: echo(f"Disabled shell autocomplete in {profile_path}") echo("Open a new terminal session for the change to take effect.") - echo("To disable it in this PowerShell session, run `lean autocomplete off --shell powershell` again after reloading the updated script.") + echo("To disable it in this PowerShell session, run `lean autocomplete off` again after reloading the updated script.") else: echo(f"Shell autocomplete was not enabled in {profile_path}") diff --git a/tests/test_autocomplete.py b/tests/test_autocomplete.py index ba235556..3c32019b 100644 --- a/tests/test_autocomplete.py +++ b/tests/test_autocomplete.py @@ -116,6 +116,19 @@ def test_autocomplete_on_writes_powershell_profile() -> None: assert "Register-ArgumentCompleter -Native -CommandName lean" in content +def test_autocomplete_on_auto_detects_powershell_profile() -> None: + with TemporaryDirectory() as directory, \ + patch.object(Path, "home", return_value=Path(directory)), \ + patch("lean.components.util.click_shell_autocomplete.system", return_value="Windows"): + result = CliRunner().invoke(lean, ["autocomplete", "on"]) + + assert result.exit_code == 0 + + profile_path = Path(directory) / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + assert profile_path.exists() + assert "# >>> lean autocomplete >>>" in profile_path.read_text(encoding="utf-8") + + def test_hidden_completion_alias_off_removes_legacy_powershell_profile_block() -> None: with TemporaryDirectory() as directory, patch.object(Path, "home", return_value=Path(directory)): profile_path = Path(directory) / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" @@ -133,7 +146,7 @@ def test_hidden_completion_alias_off_removes_legacy_powershell_profile_block() - assert "# >>> lean completion >>>" not in content assert "# before" in content assert "# after" in content - assert "lean autocomplete off --shell powershell" in result.output + assert "lean autocomplete off" in result.output def test_autocomplete_off_removes_powershell_profile_block() -> None: @@ -149,7 +162,25 @@ def test_autocomplete_off_removes_powershell_profile_block() -> None: assert result.exit_code == 0 assert "# >>> lean autocomplete >>>" not in profile_path.read_text(encoding="utf-8") - assert "lean autocomplete off --shell powershell" in result.output + assert "lean autocomplete off" in result.output + + +def test_autocomplete_off_auto_detects_powershell_profile() -> None: + with TemporaryDirectory() as directory, \ + patch.object(Path, "home", return_value=Path(directory)), \ + patch("lean.components.util.click_shell_autocomplete.system", return_value="Windows"): + profile_path = Path(directory) / "Documents" / "PowerShell" / "Microsoft.PowerShell_profile.ps1" + profile_path.parent.mkdir(parents=True, exist_ok=True) + profile_path.write_text( + "# before\n# >>> lean autocomplete >>>\nlean block\n# <<< lean autocomplete <<<\n# after\n", + encoding="utf-8" + ) + + result = CliRunner().invoke(lean, ["autocomplete", "off"]) + + assert result.exit_code == 0 + assert "# >>> lean autocomplete >>>" not in profile_path.read_text(encoding="utf-8") + assert "lean autocomplete off" in result.output def test_autocomplete_off_current_session_prints_powershell_cleanup_script() -> None: @@ -169,4 +200,4 @@ def test_autocomplete_off_shows_clear_error_when_profile_cannot_be_updated() -> assert result.exit_code != 0 assert "Unable to update profile.ps1" in result.output - assert "lean autocomplete off --shell powershell" in result.output + assert "lean autocomplete off" in result.output From 862f51d8ee735f082aba636703f645341d93c105 Mon Sep 17 00:00:00 2001 From: shreejaykurhade Date: Tue, 28 Apr 2026 16:28:05 +0530 Subject: [PATCH 10/10] Fixed the failure path. --- lean/commands/autocomplete.py | 5 ++--- lean/components/util/click_shell_autocomplete.py | 13 ++++++++++++- tests/test_autocomplete.py | 4 +++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lean/commands/autocomplete.py b/lean/commands/autocomplete.py index ec4cddce..31010a2b 100644 --- a/lean/commands/autocomplete.py +++ b/lean/commands/autocomplete.py @@ -29,9 +29,8 @@ def _profile_permission_error(exception: PermissionError) -> ClickException: path = exception.filename or "the shell profile" return ClickException( f"Unable to update {path}. " - "Please close any editor or terminal using that profile, or update the profile manually. " - "If Lean autocomplete is loaded in this PowerShell session, run `lean autocomplete off` again " - "after reloading the updated script." + "The current PowerShell session is still disabled if Lean autocomplete was loaded there. " + "To remove it permanently, close any editor or terminal using that profile, or remove the Lean autocomplete block manually." ) diff --git a/lean/components/util/click_shell_autocomplete.py b/lean/components/util/click_shell_autocomplete.py index 639b15c8..437c0eab 100644 --- a/lean/components/util/click_shell_autocomplete.py +++ b/lean/components/util/click_shell_autocomplete.py @@ -69,6 +69,12 @@ try { Set-PSReadLineOption -PredictionSource None -ErrorAction SilentlyContinue } catch {} + + Write-Output "Disabled Lean autocomplete for this PowerShell session." + Remove-Item Function:\lean -ErrorAction SilentlyContinue + Remove-Item Function:\lean-autocomplete-on -ErrorAction SilentlyContinue + Remove-Item Function:\lean-autocomplete-off -ErrorAction SilentlyContinue + Remove-Item Function:\__LeanCliExecutable -ErrorAction SilentlyContinue } function lean-autocomplete-on { @@ -80,8 +86,8 @@ $lean = __LeanCliExecutable if ($args.Count -ge 2 -and ($args[0] -eq "completion" -or $args[0] -eq "autocomplete") -and $args[1] -eq "off") { - & $lean @args lean-autocomplete-off + & $lean @args return } @@ -167,6 +173,11 @@ def get_autocomplete_cleanup_script(shell: Optional[str], prog_name: str = "lean Set-PSReadLineOption -PredictionSource None -ErrorAction SilentlyContinue }} catch {{}} +Remove-Item Function:\\lean -ErrorAction SilentlyContinue +Remove-Item Function:\\lean-autocomplete-on -ErrorAction SilentlyContinue +Remove-Item Function:\\lean-autocomplete-off -ErrorAction SilentlyContinue +Remove-Item Function:\\__LeanCliExecutable -ErrorAction SilentlyContinue + function lean-autocomplete-on {{ & {prog_name} autocomplete --shell powershell | Out-String | Invoke-Expression }} diff --git a/tests/test_autocomplete.py b/tests/test_autocomplete.py index 3c32019b..f7aadb00 100644 --- a/tests/test_autocomplete.py +++ b/tests/test_autocomplete.py @@ -34,6 +34,7 @@ def test_hidden_completion_alias_prints_powershell_script() -> None: assert "function lean-autocomplete-on" in result.output assert "function lean {" in result.output assert '__LeanCliExecutable' in result.output + assert result.output.index("lean-autocomplete-off") < result.output.index("& $lean @args") def test_autocomplete_command_prints_powershell_script() -> None: @@ -189,6 +190,7 @@ def test_autocomplete_off_current_session_prints_powershell_cleanup_script() -> assert result.exit_code == 0 assert "Register-ArgumentCompleter -Native -CommandName lean -ScriptBlock { @() }" in result.output assert "Set-PSReadLineOption -PredictionSource None" in result.output + assert "Remove-Item Function:\\lean" in result.output assert "function lean-autocomplete-on" in result.output assert "autocomplete --shell powershell" in result.output @@ -200,4 +202,4 @@ def test_autocomplete_off_shows_clear_error_when_profile_cannot_be_updated() -> assert result.exit_code != 0 assert "Unable to update profile.ps1" in result.output - assert "lean autocomplete off" in result.output + assert "The current PowerShell session is still disabled" in result.output