From 3e58c0f6c1f1a05f47ba09b7bd85bc94f0c19d6c Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Fri, 13 Feb 2026 18:23:25 -0800 Subject: [PATCH 1/5] Add PowerShell tab completion support (#1) * Add PowerShell tab completion support Implement PowerShell as a fourth supported shell alongside bash, zsh, and tcsh. The generated script uses Register-ArgumentCompleter -Native with a ScriptBlock that parses the command AST, tracks parser state (subcommands, options, positionals, nargs), and returns CompletionResult objects. Key changes: - Add complete_powershell() with @mark_completer("powershell") decorator - Add get_powershell_commands() for recursive parser traversal - Add PowerShell serialization helpers for hashtables/arrays - Add CHOICE_FUNCTIONS entries for powershell file/dir completion - Update examples/customcomplete.py with PowerShell preamble - Update tests with PowerShell assertions (all 78 tests pass) - Update README, docs/index.md, docs/use.md with PowerShell instructions - Include PLAN-powershell-support.md documenting the architecture Note: option-specific choices/compgens completion is stubbed with a TODO marker for future implementation. https://claude.ai/code/session_01F9QZN9wfoaU7gujvy9Kva9 * Implement option-specific choices/compgens in PowerShell completer Track a $currentActionKey through the state machine that gets updated on subparser reset, option match, and nargs exhaustion. Use it in the completion generation section so that option-specific choices (e.g. --shell bash/zsh/tcsh/powershell) and compgen functions are properly completed, matching the bash implementation's behavior. https://claude.ai/code/session_01F9QZN9wfoaU7gujvy9Kva9 * Add SHTAB_DEBUG env var for PowerShell completion debugging When $env:SHTAB_DEBUG is set to 'true', the completer prints the internal state (wordToComplete, prefix, actionKey, etc.) to help diagnose completion issues. https://claude.ai/code/session_01F9QZN9wfoaU7gujvy9Kva9 --------- Co-authored-by: Claude --- README.rst | 6 +- docs/index.md | 2 +- docs/use.md | 38 ++++- examples/customcomplete.py | 12 +- shtab/__init__.py | 336 ++++++++++++++++++++++++++++++++++++- tests/test_shtab.py | 9 +- 6 files changed, 391 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index e80e075..923a2ed 100644 --- a/README.rst +++ b/README.rst @@ -21,6 +21,7 @@ Features - ``bash`` - ``zsh`` - ``tcsh`` + - ``powershell`` - Supports @@ -60,8 +61,8 @@ There are two ways of using ``shtab``: - `Library Usage `_: as a library integrated into your CLI application - adds a couple of lines to your application - - argument mode: end-users execute ``your_cli_app --print-completion {bash,zsh,tcsh}`` - - subparser mode: end-users execute ``your_cli_app completion {bash,zsh,tcsh}`` + - argument mode: end-users execute ``your_cli_app --print-completion {bash,zsh,tcsh,powershell}`` + - subparser mode: end-users execute ``your_cli_app completion {bash,zsh,tcsh,powershell}`` Examples -------- @@ -98,7 +99,6 @@ Contributions Please do open `issues `_ & `pull requests `_! Some ideas: - support ``fish`` (`#174 `_) -- support ``powershell`` See `CONTRIBUTING.md `_ diff --git a/docs/index.md b/docs/index.md index 6d71c0e..e63bf4a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,6 +18,7 @@ - `bash` - `zsh` - `tcsh` + - `powershell` - Supports - [argparse](https://docs.python.org/library/argparse) - [docopt](https://pypi.org/project/docopt) (via [argopt](https://pypi.org/project/argopt)) @@ -110,7 +111,6 @@ application. Use `-u, --error-unimportable` to noisily complain. Please do open [issues][GH-issue] & [pull requests][GH-pr]! Some ideas: - support `fish` ([#174](https://github.com/iterative/shtab/pull/174)) -- support `powershell` See [CONTRIBUTING.md](https://github.com/iterative/shtab/tree/main/CONTRIBUTING.md) diff --git a/docs/use.md b/docs/use.md index 5fdee70..691492e 100644 --- a/docs/use.md +++ b/docs/use.md @@ -7,8 +7,8 @@ There are two ways of using `shtab`: - end-users execute `shtab your_cli_app.your_parser_object` - [Library Usage](#library-usage): as a library integrated into your CLI application - adds a couple of lines to your application - - argument mode: end-users execute `your_cli_app --print-completion {bash,zsh,tcsh}` - - subparser mode: end-users execute `your_cli_app completion {bash,zsh,tcsh}` + - argument mode: end-users execute `your_cli_app --print-completion {bash,zsh,tcsh,powershell}` + - subparser mode: end-users execute `your_cli_app completion {bash,zsh,tcsh,powershell}` ## CLI Usage @@ -98,6 +98,32 @@ Below are various examples of enabling `shtab`'s own tab completion scripts. | sudo tee /etc/profile.d/eager-completion.csh ``` +=== "powershell" + + ```powershell + shtab --shell=powershell shtab.main.get_main_parser --error-unimportable ` + | Out-File -FilePath ~\shtab_completion.ps1 + . ~\shtab_completion.ps1 + ``` + +=== "Eager powershell" + + Add the following to your PowerShell profile (`$PROFILE`): + + ```powershell + shtab --shell=powershell shtab.main.get_main_parser ` + | Out-String | Invoke-Expression + ``` + + Or save to a file and dot-source it from your profile: + + ```powershell + shtab --shell=powershell shtab.main.get_main_parser ` + | Out-File -FilePath ~\shtab_completion.ps1 + # Add to $PROFILE: + . ~\shtab_completion.ps1 + ``` + !!! tip See the [examples/](https://github.com/iterative/shtab/tree/main/examples) folder for more. @@ -145,6 +171,14 @@ Assuming this code example is installed in `MY_PROG.command.main`, simply run: | sudo tee /etc/profile.d/MY_PROG.completion.csh ``` +=== "powershell" + + ```powershell + shtab --shell=powershell -u MY_PROG.command.main.get_main_parser ` + | Out-File -FilePath ~\MY_PROG_completion.ps1 + . ~\MY_PROG_completion.ps1 + ``` + ## Library Usage !!! tip diff --git a/examples/customcomplete.py b/examples/customcomplete.py index 1646845..d4f2f85 100755 --- a/examples/customcomplete.py +++ b/examples/customcomplete.py @@ -10,7 +10,7 @@ TXT_FILE = { "bash": "_shtab_greeter_compgen_TXTFiles", "zsh": "_files -g '(*.txt|*.TXT)'", - "tcsh": "f:*.txt"} + "tcsh": "f:*.txt", "powershell": "_shtab_greeter_compgen_TXTFiles"} PREAMBLE = { "bash": """ # $1=COMP_WORDS[1] @@ -19,7 +19,15 @@ compgen -f -X '!*?.txt' -- $1 compgen -f -X '!*?.TXT' -- $1 } -""", "zsh": "", "tcsh": ""} +""", "zsh": "", "tcsh": "", "powershell": """ +function _shtab_greeter_compgen_TXTFiles { + param([string]$WordToComplete) + Get-ChildItem -Path "$WordToComplete*" -Include '*.txt','*.TXT' -File -ErrorAction SilentlyContinue | + ForEach-Object { $_.Name } + Get-ChildItem -Path "$WordToComplete*" -Directory -ErrorAction SilentlyContinue | + ForEach-Object { $_.Name + [System.IO.Path]::DirectorySeparatorChar } +} +"""} def process(args): diff --git a/shtab/__init__.py b/shtab/__init__.py index e29a2e9..b277bff 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -38,8 +38,10 @@ SUPPORTED_SHELLS: List[str] = [] _SUPPORTED_COMPLETERS = {} CHOICE_FUNCTIONS: Dict[str, Dict[str, str]] = { - "file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f"}, - "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d"}} + "file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", + "powershell": "_shtab_powershell_compgen_files"}, + "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", + "powershell": "_shtab_powershell_compgen_dirs"}} FILE = CHOICE_FUNCTIONS["file"] DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"] FLAG_OPTION = ( @@ -800,6 +802,336 @@ def recurse_parser(cparser, positional_idx, requirements=None): optionals_special_str=' \\\n '.join(specials)) +def _powershell_escape(string: str) -> str: + """Escape a string for use inside a PowerShell single-quoted string.""" + return str(string).replace("'", "''") + + +def _powershell_list(items): + """Serialize a list of strings to a PowerShell array literal.""" + if not items: + return "@()" + escaped = ", ".join(f"'{_powershell_escape(i)}'" for i in items) + return f"@({escaped})" + + +def _powershell_hashtable(d): + """Serialize a dict[str, list[str]] to PowerShell @{} syntax.""" + if not d: + return "@{}" + entries = [] + for key, values in sorted(d.items()): + entries.append(f" '{_powershell_escape(key)}' = {_powershell_list(values)}") + return "@{\n" + "\n".join(entries) + "\n}" + + +def _powershell_flat_hashtable(d): + """Serialize a dict[str, str] to PowerShell @{} syntax.""" + if not d: + return "@{}" + entries = [] + for key, value in sorted(d.items()): + entries.append(f" '{_powershell_escape(key)}' = '{_powershell_escape(value)}'") + return "@{\n" + "\n".join(entries) + "\n}" + + +def get_powershell_commands(root_parser, root_prefix, choice_functions=None): + """ + Recursive subcommand parser traversal, returning dicts of information on + commands (formatted for output to the PowerShell completions script). + + Returns: + subparsers : dict mapping prefix -> list of subparser names + option_strings : dict mapping prefix -> list of option strings + compgens : dict mapping action key -> completer function name + choices : dict mapping action key -> list of choice strings + nargs : dict mapping action key -> nargs value (string) + """ + choice_type2fn = {k: v["powershell"] for k, v in CHOICE_FUNCTIONS.items()} + if choice_functions: + choice_type2fn.update(choice_functions) + + all_subparsers = {} + all_option_strings = {} + all_compgens = {} + all_choices = {} + all_nargs = {} + + def get_option_strings_list(parser): + """Flattened list of all `parser`'s option strings.""" + return sum( + (opt.option_strings for opt in parser._get_optional_actions() if opt.help != SUPPRESS), + [], + ) + + def recurse(parser, prefix): + discovered_subparsers = [] + + for i, positional in enumerate(parser._get_positional_actions()): + if positional.help == SUPPRESS: + continue + + action_key = f"{prefix}_pos_{i}" + + if hasattr(positional, "complete"): + comp_pattern = complete2pattern(positional.complete, "powershell", choice_type2fn) + if comp_pattern: + all_compgens[action_key] = comp_pattern + + if positional.choices: + log.debug(f"choices:{prefix}:{sorted(positional.choices)}") + this_positional_choices = [] + + for choice in positional.choices: + if isinstance(choice, Choice): + log.debug(f"Choice.{choice.type}:{prefix}:{positional.dest}") + all_compgens[action_key] = choice_type2fn[choice.type] + elif isinstance(positional.choices, dict): + log.debug("subcommand:%s", choice) + public_cmds = get_public_subcommands(positional) + if choice in public_cmds: + discovered_subparsers.append(str(choice)) + this_positional_choices.append(str(choice)) + recurse( + positional.choices[choice], + f"{prefix}_{wordify(choice)}", + ) + else: + log.debug("skip:subcommand:%s", choice) + else: + this_positional_choices.append(str(choice)) + + if this_positional_choices: + all_choices[action_key] = this_positional_choices + + if positional.nargs not in (None, "1", "?"): + all_nargs[action_key] = str(positional.nargs) + + if discovered_subparsers: + all_subparsers[prefix] = discovered_subparsers + log.debug(f"subcommands:{prefix}:{discovered_subparsers}") + + all_option_strings[prefix] = get_option_strings_list(parser) + + for optional in parser._get_optional_actions(): + if optional == SUPPRESS: + continue + for option_string in optional.option_strings: + opt_key = f"{prefix}_{wordify(option_string)}" + + if hasattr(optional, "complete"): + comp_pattern = complete2pattern(optional.complete, "powershell", choice_type2fn) + if comp_pattern: + all_compgens[opt_key] = comp_pattern + + if optional.choices: + this_optional_choices = [] + for choice in optional.choices: + if isinstance(choice, Choice): + log.debug(f"Choice.{choice.type}:{prefix}:{optional.dest}") + all_compgens[opt_key] = choice_type2fn[choice.type] + else: + this_optional_choices.append(str(choice)) + if this_optional_choices: + all_choices[opt_key] = this_optional_choices + + if optional.nargs is not None and optional.nargs != 1: + all_nargs[opt_key] = str(optional.nargs) + + recurse(root_parser, root_prefix) + return all_subparsers, all_option_strings, all_compgens, all_choices, all_nargs + + +@mark_completer("powershell") +def complete_powershell(parser, root_prefix=None, preamble="", choice_functions=None): + """ + Returns PowerShell syntax autocompletion script. + + See `complete` for arguments. + """ + root_prefix = wordify(f"_shtab_{root_prefix or parser.prog}") + subparsers, option_strings, compgens, choices, nargs = get_powershell_commands( + parser, root_prefix, choice_functions=choice_functions) + + # References: + # - https://learn.microsoft.com/en-us/powershell/module/ + # microsoft.powershell.core/register-argumentcompleter + # - https://learn.microsoft.com/en-us/powershell/scripting/ + # learn/shell/tab-completion + return Template("""\ +# AUTOMATICALLY GENERATED by `shtab` + +${preamble} +# --- Completion data --- +$$${root_prefix}_subparsers = ${subparsers_ht} +$$${root_prefix}_option_strings = ${option_strings_ht} +$$${root_prefix}_compgens = ${compgens_ht} +$$${root_prefix}_choices = ${choices_ht} +$$${root_prefix}_nargs = ${nargs_ht} + +# --- Helper functions --- + +function _shtab_powershell_compgen_files { + param([string]$$WordToComplete) + Get-ChildItem -Path "$$WordToComplete*" -File -ErrorAction SilentlyContinue | + ForEach-Object { $$_.Name } + Get-ChildItem -Path "$$WordToComplete*" -Directory -ErrorAction SilentlyContinue | + ForEach-Object { $$_.Name + [System.IO.Path]::DirectorySeparatorChar } +} + +function _shtab_powershell_compgen_dirs { + param([string]$$WordToComplete) + Get-ChildItem -Path "$$WordToComplete*" -Directory -ErrorAction SilentlyContinue | + ForEach-Object { $$_.Name + [System.IO.Path]::DirectorySeparatorChar } +} + +function _shtab_powershell_replace_nonword { + param([string]$$Text) + $$Text -replace '[^\\w]', '_' +} + +# --- Main completer --- + +Register-ArgumentCompleter -Native -CommandName ${prog} -ScriptBlock { + param($$wordToComplete, $$commandAst, $$cursorPosition) + + # Tokenize the command line (skip program name) + $$allTokens = @() + if ($$commandAst.CommandElements.Count -gt 1) { + $$allTokens = $$commandAst.CommandElements[1..($$commandAst.CommandElements.Count - 1)] | + ForEach-Object { $$_.ToString() } + } + + # Determine which tokens are "completed" (before the word being typed) + # The last token is the one currently being completed if it matches wordToComplete + $$tokens = @() + if ($$allTokens.Count -gt 0) { + if ($$wordToComplete -and $$allTokens[-1] -eq $$wordToComplete) { + if ($$allTokens.Count -gt 1) { + $$tokens = $$allTokens[0..($$allTokens.Count - 2)] + } + } else { + $$tokens = $$allTokens + } + } + + # State tracking + $$prefix = '${root_prefix}' + $$completedPositionals = 0 + $$currentActionKey = "$${prefix}_pos_0" + $$currentActionNargs = 1 + $$currentActionArgsConsumed = 0 + $$currentActionIsPositional = $$true + $$posOnly = $$false + + # Helper: look up nargs for a given action key (default 1) + function Get-ActionNargs($$actionKey) { + $$n = $$${root_prefix}_nargs[$$actionKey] + if ($$n) { return $$n } else { return '1' } + } + + # Walk completed tokens to determine current parser state + foreach ($$token in $$tokens) { + if ($$posOnly -or $$token -ne '--') { + # Check for subparser match + $$currentSubparsers = $$${root_prefix}_subparsers[$$prefix] + if ($$currentSubparsers -and $$currentSubparsers -contains $$token) { + $$prefix = $$prefix + '_' + (_shtab_powershell_replace_nonword $$token) + $$completedPositionals = 0 + $$currentActionKey = "$${prefix}_pos_0" + $$currentActionNargs = Get-ActionNargs $$currentActionKey + $$currentActionArgsConsumed = 0 + $$currentActionIsPositional = $$true + continue + } + + # Check for option string match + $$currentOptions = $$${root_prefix}_option_strings[$$prefix] + if ($$currentOptions -and $$currentOptions -contains $$token) { + $$currentActionKey = $$prefix + '_' + (_shtab_powershell_replace_nonword $$token) + $$currentActionNargs = Get-ActionNargs $$currentActionKey + $$currentActionArgsConsumed = 0 + $$currentActionIsPositional = $$false + continue + } + + # Consume argument for current action + $$currentActionArgsConsumed++ + if ($$currentActionNargs -ne '*' -and + $$currentActionNargs -ne '+' -and + $$currentActionNargs -ne '?' -and + $$currentActionNargs -notlike '*...*') { + if ($$currentActionArgsConsumed -ge [int]$$currentActionNargs) { + if ($$currentActionIsPositional) { $$completedPositionals++ } + $$currentActionKey = "$${prefix}_pos_$${completedPositionals}" + $$currentActionNargs = Get-ActionNargs $$currentActionKey + $$currentActionArgsConsumed = 0 + $$currentActionIsPositional = $$true + } + } + } else { + $$posOnly = $$true + } + } + + # --- Generate completions --- + if ($$env:SHTAB_DEBUG -eq 'true') { + Write-Host "shtab: wordToComplete='$$wordToComplete' prefix='$$prefix' actionKey='$$currentActionKey' isPositional='$$currentActionIsPositional' posOnly='$$posOnly'" + } + + $$completions = @() + + if (-not $$posOnly -and $$wordToComplete -like '-*') { + # Complete option strings + $$opts = $$${root_prefix}_option_strings[$$prefix] + if ($$opts) { + $$completions = @($$opts | Where-Object { $$_ -like "$$wordToComplete*" }) + } + } else { + # Complete subparsers (only when current action is positional) + if ($$currentActionIsPositional) { + $$subs = $$${root_prefix}_subparsers[$$prefix] + if ($$subs) { + $$completions += @($$subs | Where-Object { $$_ -like "$$wordToComplete*" }) + } + } + + # Complete choices for current action (positional or option) + $$actionChoices = $$${root_prefix}_choices[$$currentActionKey] + if ($$actionChoices) { + $$completions += @($$actionChoices | Where-Object { $$_ -like "$$wordToComplete*" }) + } + + # Complete using compgen function for current action + $$actionCompgen = $$${root_prefix}_compgens[$$currentActionKey] + if ($$actionCompgen) { + $$completions += @(& $$actionCompgen $$wordToComplete) + } + } + + # Deduplicate and emit CompletionResult objects + $$completions | Select-Object -Unique | ForEach-Object { + [System.Management.Automation.CompletionResult]::new( + $$_, # completionText + $$_, # listItemText + 'ParameterValue', # resultType + $$_ # toolTip + ) + } +} +""").safe_substitute( + subparsers_ht=_powershell_hashtable(subparsers), + option_strings_ht=_powershell_hashtable(option_strings), + compgens_ht=_powershell_flat_hashtable(compgens), + choices_ht=_powershell_hashtable(choices), + nargs_ht=_powershell_flat_hashtable(nargs), + preamble=("\n# Custom Preamble\n" + preamble + + "\n# End Custom Preamble\n" if preamble else ""), + root_prefix=root_prefix, + prog=parser.prog, + ) + + def complete(parser: ArgumentParser, shell: str = "bash", root_prefix: Opt[str] = None, preamble: Union[str, Dict[str, str]] = "", choice_functions: Opt[Any] = None) -> str: """ diff --git a/tests/test_shtab.py b/tests/test_shtab.py index d730151..9ff2332 100644 --- a/tests/test_shtab.py +++ b/tests/test_shtab.py @@ -76,7 +76,8 @@ def test_main_self_completion(shell, caplog, capsys): assert not captured.err expected = { "bash": "complete -o filenames -F _shtab_shtab shtab", "zsh": "_shtab_shtab_commands()", - "tcsh": "complete shtab"} + "tcsh": "complete shtab", + "powershell": "Register-ArgumentCompleter -Native -CommandName shtab"} assert expected[shell] in captured.out assert not caplog.record_tuples @@ -96,7 +97,8 @@ def test_main_output_path(shell, caplog, capsys, change_dir, output): assert not captured.err expected = { "bash": "complete -o filenames -F _shtab_shtab shtab", "zsh": "_shtab_shtab_commands()", - "tcsh": "complete shtab"} + "tcsh": "complete shtab", + "powershell": "Register-ArgumentCompleter -Native -CommandName shtab"} if output in ("-", "stdout"): assert expected[shell] in captured.out @@ -139,6 +141,9 @@ def test_prog_scripts(shell, caplog, capsys): "compdef _shtab_shtab -N script.py"] elif shell == "tcsh": assert script_py == ["complete script.py \\"] + elif shell == "powershell": + assert script_py == [ + "Register-ArgumentCompleter -Native -CommandName script.py -ScriptBlock {"] else: raise NotImplementedError(shell) From adf3300338144856f0f1f511f56c4032298061cb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 02:27:16 +0000 Subject: [PATCH 2/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- shtab/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index b277bff..e974033 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -38,10 +38,11 @@ SUPPORTED_SHELLS: List[str] = [] _SUPPORTED_COMPLETERS = {} CHOICE_FUNCTIONS: Dict[str, Dict[str, str]] = { - "file": {"bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", - "powershell": "_shtab_powershell_compgen_files"}, - "directory": {"bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", - "powershell": "_shtab_powershell_compgen_dirs"}} + "file": { + "bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", + "powershell": "_shtab_powershell_compgen_files"}, "directory": { + "bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", + "powershell": "_shtab_powershell_compgen_dirs"}} FILE = CHOICE_FUNCTIONS["file"] DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"] FLAG_OPTION = ( @@ -920,7 +921,8 @@ def recurse(parser, prefix): opt_key = f"{prefix}_{wordify(option_string)}" if hasattr(optional, "complete"): - comp_pattern = complete2pattern(optional.complete, "powershell", choice_type2fn) + comp_pattern = complete2pattern(optional.complete, "powershell", + choice_type2fn) if comp_pattern: all_compgens[opt_key] = comp_pattern From 2840c5a38af70635f9eaed357f92f3c7ff68de89 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 11 Mar 2026 16:54:56 -0700 Subject: [PATCH 3/5] Apply suggestions from code review Thank you for the code suggestions! I'll get the rest fixed shortly. Gotta remember to keep things pythonic :P Co-authored-by: Philipp Hahn --- shtab/__init__.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index e974033..927467f 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -40,9 +40,11 @@ CHOICE_FUNCTIONS: Dict[str, Dict[str, str]] = { "file": { "bash": "_shtab_compgen_files", "zsh": "_files", "tcsh": "f", - "powershell": "_shtab_powershell_compgen_files"}, "directory": { - "bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", - "powershell": "_shtab_powershell_compgen_dirs"}} + "powershell": "_shtab_powershell_compgen_files"}, + "directory": { + "bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", + "powershell": "_shtab_powershell_compgen_dirs"}, +} FILE = CHOICE_FUNCTIONS["file"] DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"] FLAG_OPTION = ( @@ -810,8 +812,6 @@ def _powershell_escape(string: str) -> str: def _powershell_list(items): """Serialize a list of strings to a PowerShell array literal.""" - if not items: - return "@()" escaped = ", ".join(f"'{_powershell_escape(i)}'" for i in items) return f"@({escaped})" @@ -820,9 +820,10 @@ def _powershell_hashtable(d): """Serialize a dict[str, list[str]] to PowerShell @{} syntax.""" if not d: return "@{}" - entries = [] - for key, values in sorted(d.items()): - entries.append(f" '{_powershell_escape(key)}' = {_powershell_list(values)}") + entries = [ + f" '{_powershell_escape(key)}' = {_powershell_list(values)}" + for key, values in sorted(d.items()) + ] return "@{\n" + "\n".join(entries) + "\n}" @@ -830,9 +831,10 @@ def _powershell_flat_hashtable(d): """Serialize a dict[str, str] to PowerShell @{} syntax.""" if not d: return "@{}" - entries = [] - for key, value in sorted(d.items()): - entries.append(f" '{_powershell_escape(key)}' = '{_powershell_escape(value)}'") + entries = [ + f" '{_powershell_escape(key)}' = '{_powershell_escape(value)}'" + for key, value in sorted(d.items()) + ] return "@{\n" + "\n".join(entries) + "\n}" @@ -869,9 +871,6 @@ def recurse(parser, prefix): discovered_subparsers = [] for i, positional in enumerate(parser._get_positional_actions()): - if positional.help == SUPPRESS: - continue - action_key = f"{prefix}_pos_{i}" if hasattr(positional, "complete"): From ea506904b446e953a1855c1f1efac27a3db7f9b7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:55:05 +0000 Subject: [PATCH 4/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- shtab/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 927467f..54b483a 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -43,8 +43,7 @@ "powershell": "_shtab_powershell_compgen_files"}, "directory": { "bash": "_shtab_compgen_dirs", "zsh": "_files -/", "tcsh": "d", - "powershell": "_shtab_powershell_compgen_dirs"}, -} + "powershell": "_shtab_powershell_compgen_dirs"},} FILE = CHOICE_FUNCTIONS["file"] DIRECTORY = DIR = CHOICE_FUNCTIONS["directory"] FLAG_OPTION = ( @@ -822,8 +821,7 @@ def _powershell_hashtable(d): return "@{}" entries = [ f" '{_powershell_escape(key)}' = {_powershell_list(values)}" - for key, values in sorted(d.items()) - ] + for key, values in sorted(d.items())] return "@{\n" + "\n".join(entries) + "\n}" @@ -833,8 +831,7 @@ def _powershell_flat_hashtable(d): return "@{}" entries = [ f" '{_powershell_escape(key)}' = '{_powershell_escape(value)}'" - for key, value in sorted(d.items()) - ] + for key, value in sorted(d.items())] return "@{\n" + "\n".join(entries) + "\n}" From 93e34e9a931623e7a632cced5ce3a36e48797443 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Mon, 16 Mar 2026 19:15:10 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(powershell):=20=F0=9F=90=9B=20improve?= =?UTF-8?q?=20string=20escaping=20in=20`=5Fpowershell=5Fescape`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced the `_powershell_escape` function to handle smart quotes. - Updated the function to return a properly quoted string for PowerShell. - Adjusted related functions to ensure consistent escaping behavior. --- shtab/__init__.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/shtab/__init__.py b/shtab/__init__.py index 54b483a..1d89f00 100644 --- a/shtab/__init__.py +++ b/shtab/__init__.py @@ -805,13 +805,22 @@ def recurse_parser(cparser, positional_idx, requirements=None): def _powershell_escape(string: str) -> str: - """Escape a string for use inside a PowerShell single-quoted string.""" - return str(string).replace("'", "''") + """ + Quote a string for PowerShell (single-quoted, like ``shlex.quote``). + In single-quoted strings the only special characters are quote chars. + PowerShell treats smart/curly single quotes (\u2018, \u2019) the same as + regular single quotes, so they must be doubled too. + See https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules + """ + s = str(string) + for ch in ("'", "\u2018", "\u2019"): + s = s.replace(ch, ch * 2) + return "'" + s + "'" def _powershell_list(items): """Serialize a list of strings to a PowerShell array literal.""" - escaped = ", ".join(f"'{_powershell_escape(i)}'" for i in items) + escaped = ", ".join(_powershell_escape(i) for i in items) return f"@({escaped})" @@ -820,7 +829,7 @@ def _powershell_hashtable(d): if not d: return "@{}" entries = [ - f" '{_powershell_escape(key)}' = {_powershell_list(values)}" + f" {_powershell_escape(key)} = {_powershell_list(values)}" for key, values in sorted(d.items())] return "@{\n" + "\n".join(entries) + "\n}" @@ -830,7 +839,7 @@ def _powershell_flat_hashtable(d): if not d: return "@{}" entries = [ - f" '{_powershell_escape(key)}' = '{_powershell_escape(value)}'" + f" {_powershell_escape(key)} = {_powershell_escape(value)}" for key, value in sorted(d.items())] return "@{\n" + "\n".join(entries) + "\n}"