Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-cyclopts for openSUSE:Factory 
checked in at 2026-10-07 19:32:53
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-cyclopts (Old)
 and      /work/SRC/openSUSE:Factory/.python-cyclopts.new.3698736 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-cyclopts"

Wed Oct  7 19:32:53 2026 rev:22 rq:1382967 version:5.2.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-cyclopts/python-cyclopts.changes  
2026-10-01 22:21:14.169926553 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-cyclopts.new.3698736/python-cyclopts.changes 
    2026-10-07 19:33:24.119637019 +0200
@@ -1,0 +2,8 @@
+Wed Oct  7 07:31:02 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 5.2.0 (PyPI release 2026-10-06):
+  * Add PowerShell shell completion support
+  * Rename completion env var CYCLOPTS_COMPLETE_WORDS to
+    CYCLOPTS_COMPLETION_WORDS
+
+-------------------------------------------------------------------

Old:
----
  cyclopts-5.1.1.tar.gz

New:
----
  cyclopts-5.2.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-cyclopts.spec ++++++
--- /var/tmp/diff_new_pack.G8tC3r/_old  2026-10-07 19:33:24.733662662 +0200
+++ /var/tmp/diff_new_pack.G8tC3r/_new  2026-10-07 19:33:24.735662746 +0200
@@ -18,7 +18,7 @@
 
 %bcond_without libalternatives
 Name:           python-cyclopts
-Version:        5.1.1
+Version:        5.2.0
 Release:        0
 Summary:        Intuitive, easy CLIs based on python type hints
 License:        Apache-2.0

++++++ cyclopts-5.1.1.tar.gz -> cyclopts-5.2.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/PKG-INFO new/cyclopts-5.2.0/PKG-INFO
--- old/cyclopts-5.1.1/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.5
 Name: cyclopts
-Version: 5.1.1
+Version: 5.2.0
 Summary: Intuitive, easy CLIs based on type hints.
 Project-URL: Homepage, https://github.com/BrianPugh/cyclopts
 Project-URL: Repository, https://github.com/BrianPugh/cyclopts
@@ -43,7 +43,7 @@
 Requires-Dist: sphinx-copybutton<1.0,>=0.5; extra == 'docs'
 Requires-Dist: sphinx-rtd-dark-mode<2.0.0,>=1.3.0; extra == 'docs'
 Requires-Dist: sphinx-rtd-theme<4.0.0,>=3.0.0; extra == 'docs'
-Requires-Dist: sphinx<8.2.0,>=7.4.7; extra == 'docs'
+Requires-Dist: sphinx<10.0.0,>=7.4.7; extra == 'docs'
 Provides-Extra: mkdocs
 Requires-Dist: markdown>=3.3; extra == 'mkdocs'
 Requires-Dist: mkdocs>=1.4.0; extra == 'mkdocs'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/_version.py 
new/cyclopts-5.2.0/cyclopts/_version.py
--- old/cyclopts-5.1.1/cyclopts/_version.py     2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-5.2.0/cyclopts/_version.py     2020-02-02 01:00:00.000000000 
+0100
@@ -18,7 +18,7 @@
 commit_id: str | None
 __commit_id__: str | None
 
-__version__ = version = '5.1.1'
-__version_tuple__ = version_tuple = (5, 1, 1)
+__version__ = version = '5.2.0'
+__version_tuple__ = version_tuple = (5, 2, 0)
 
 __commit_id__ = commit_id = None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/completion/_base.py 
new/cyclopts-5.2.0/cyclopts/completion/_base.py
--- old/cyclopts-5.1.1/cyclopts/completion/_base.py     2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/completion/_base.py     2020-02-02 
01:00:00.000000000 +0100
@@ -21,6 +21,7 @@
 
 if TYPE_CHECKING:
     from cyclopts import App
+    from cyclopts.command_spec import CommandSpec
 
 
 class CompletionAction(Enum):
@@ -53,6 +54,13 @@
         Arguments contributed by *this path's* plain ``@app.default``. These
         are alternatives to the subcommand at the same word slot and must
         never shift it.
+    help_flags : tuple[str, ...]
+        Help flags of the command at this path (dispatch uses the command's 
own,
+        which may differ from the root's).
+    version_flags : tuple[str, ...]
+        Version flags of the command at this path.
+    end_of_options_delimiter : str
+        Resolved end-of-options delimiter at this path (``""`` when disabled).
     """
 
     arguments: "ArgumentCollection"
@@ -60,6 +68,9 @@
     help_format: str
     launcher_arguments: "ArgumentCollection" = 
field(factory=ArgumentCollection)
     own_arguments: "ArgumentCollection" = field(factory=ArgumentCollection)
+    help_flags: tuple[str, ...] = ()
+    version_flags: tuple[str, ...] = ()
+    end_of_options_delimiter: str = "--"
 
 
 def visible_commands(app: "App") -> list[RegisteredCommand]:
@@ -73,6 +84,19 @@
     return commands
 
 
+def app_description(cmd_app: "App | CommandSpec", help_format: str) -> str:
+    """Plain-text short description of a command (not shell-escaped)."""
+    from cyclopts.help.help import docstring_parse
+
+    try:
+        parsed = docstring_parse(cmd_app.help, "plaintext")
+        text = parsed.short_description or ""
+    except Exception:
+        text = str(cmd_app.help or "")
+
+    return strip_markup(text, format=help_format)
+
+
 def extract_completion_data(app: "App") -> dict[tuple[str, ...], 
CompletionData]:
     """Recursively extract completion data for app and all subcommands.
 
@@ -131,6 +155,7 @@
                     launcher_arguments.extend(app_arguments)  # this path's 
meta launcher
                 else:
                     own_arguments.extend(app_arguments)  # plain @app.default
+            end_of_options_delimiter = 
app.app_stack.resolve("end_of_options_delimiter", fallback="--") or ""
 
         commands = visible_commands(command_app)
 
@@ -142,6 +167,9 @@
             help_format=help_format,
             launcher_arguments=launcher_arguments,
             own_arguments=own_arguments,
+            help_flags=tuple(command_app.help_flags),
+            version_flags=tuple(command_app.version_flags),
+            end_of_options_delimiter=end_of_options_delimiter,
         )
 
         for registered_command in commands:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/completion/_engine.py 
new/cyclopts-5.2.0/cyclopts/completion/_engine.py
--- old/cyclopts-5.1.1/cyclopts/completion/_engine.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/completion/_engine.py   2020-02-02 
01:00:00.000000000 +0100
@@ -1,27 +1,48 @@
 """Runtime engine for dynamic (Python-invoked) shell completion.
 
-The static ``bash``/``zsh``/``fish`` generators bake command, option, and 
static
-choice names into a script at install time. When an argument has a
-:attr:`.Parameter.completer`, that script also calls back at TAB time through 
the
-reserved ``__complete`` command, which routes here to run the completer and 
emit
-its candidate values.
+The static ``bash``/``zsh``/``fish``/``powershell`` generators bake command,
+option, and static choice names into a script at install time. When an argument
+has a :attr:`.Parameter.completer`, that script also calls back at TAB time
+through the reserved ``__complete`` command, which routes here to run the
+completer and emit its candidate values.
+
+The PowerShell script requests *full* completion
+(:func:`compute_full_completions`) for a completer-backed slot, so this engine
+also supplies that slot's static choices, ``=`` prefix, and file directive.
 """
 
 import os
 import sys
-from collections.abc import Iterable, Mapping, Sequence
+from collections.abc import Callable, Iterable, Mapping, Sequence
 from contextlib import contextmanager, redirect_stdout
 from functools import partial
 from typing import TYPE_CHECKING, Any, NamedTuple
 
 from cyclopts.argument import ArgumentCollection
 from cyclopts.bind import _parse_configs, _parse_env, _parse_kw_and_flags, 
_parse_pos
+from cyclopts.completion._base import (
+    CompletionAction,
+    app_description,
+    get_completion_action,
+    strip_markup,
+    visible_commands,
+)
 from cyclopts.exceptions import CycloptsError, MissingArgumentError, 
RepeatArgumentError
+from cyclopts.field_info import VAR_KEYWORD
 from cyclopts.utils import UNSET, frozen, is_option_like
 
 if TYPE_CHECKING:
     from cyclopts import App
     from cyclopts.argument import Argument
+    from cyclopts.group_extractors import RegisteredCommand
+
+#: Set by a generated script to request *full* completion from ``__complete``.
+#: Its value carries the command-line words, each terminated by ``\x1f``, in
+#: place of the command's arguments. Windows PowerShell 5.1 drops empty-string
+#: arguments and mangles embedded quotes when calling a native program, and an
+#: environment variable avoids both. The terminator keeps the value non-empty
+#: (Windows deletes a variable assigned ``""``).
+COMPLETION_WORDS_ENV_VAR = "CYCLOPTS_COMPLETION_WORDS"
 
 
 def completion_debug_enabled() -> bool:
@@ -301,7 +322,7 @@
     command_app = execution_path[-1]
     arguments = ArgumentCollection()
     launcher_arguments = ArgumentCollection()
-    for subapp, collection in 
_iter_resolution_argument_collections(execution_path, parse_docstring=False):
+    for subapp, collection in 
_iter_resolution_argument_collections(execution_path, parse_docstring=True):
         if subapp is command_app:
             arguments.extend(collection)
             continue
@@ -509,3 +530,100 @@
     # match, which is why substring/fuzzy completion isn't possible. 
``incomplete``
     # is for narrowing expensive lookups, not for filtering the result.
     return [Completion(value, help) for value, help in 
dynamic_candidates(slot)]
+
+
+def static_candidates(slot: Slot) -> list[tuple[str, "Argument | 
RegisteredCommand | None"]]:
+    """Command names, option names, and static choices for ``slot``, each with 
the object it names.
+
+    Pass that object to :func:`describe` for the candidate's description; it is
+    returned instead of the description because rendering help markup is slow
+    enough to be worth skipping for candidates that get filtered out.
+    """
+    root = slot.app
+    commands = visible_commands(slot.command_app)
+    flags = (*slot.command_app.help_flags, *slot.command_app.version_flags)
+
+    if slot.option_name:
+        return [
+            *(
+                (name, a)
+                for a in slot.arguments
+                if a.show and a.field_info.kind is not VAR_KEYWORD and not 
a.is_positional_only()
+                for name in a.names
+            ),
+            *((name, rc) for rc in commands for name in rc.names if 
name.startswith("-")),
+            *((flag, None) for flag in flags),
+        ]
+
+    candidates: list[tuple[str, Argument | RegisteredCommand | None]] = []
+    if not slot.unused:
+        candidates += [(name, rc) for rc in commands for name in rc.names if 
not name.startswith("-")]
+        if not slot.prior and root.app_stack.overrides.get("remap_flags"):
+            # ``interactive_shell`` accepts the bare word (``help`` for 
``--help``) at the root.
+            candidates += [(flag[2:], None) for flag in flags if 
root._is_remappable_flag(flag)]
+    if slot.active is not None:
+        candidates += [(choice, None) for choice in 
slot.active.get_choices(force=True) or ()]
+    return candidates
+
+
+def describe(source: "Argument | RegisteredCommand | None", help_format: str) 
-> str:
+    """Plain-text description of a :func:`static_candidates` source."""
+    from cyclopts.argument import Argument
+
+    if isinstance(source, Argument):
+        return strip_markup(source.parameter.help or "", format=help_format)
+    if source is not None:
+        return app_description(source.app, help_format)
+    return ""
+
+
+@frozen
+class FullCompletions:
+    """Every candidate for the word under the cursor, already prefix-filtered.
+
+    ``files`` asks the shell to add its own path completions. ``prefix`` is the
+    ``--opt=`` part of the word (empty unless completing an ``=``-form value);
+    the shell inserts it before each candidate, including paths.
+    """
+
+    candidates: list[Completion]
+    files: bool = False
+    prefix: str = ""
+
+
+def compute_full_completions(app: "App", words: list[str]) -> FullCompletions:
+    """Compute every completion candidate (static and dynamic) for a partial 
command line.
+
+    Unlike :func:`compute_completions`, this filters by the typed prefix, since
+    its caller hands over the whole active slot and adds no candidates of its 
own.
+    """
+    slot = resolve_slot(app, words)
+    if slot is None:
+        return FullCompletions([])
+    with slot.app.app_stack(slot.execution_path):
+        help_format = slot.command_app.app_stack.resolve("help_format", 
fallback="markdown")
+
+    seen: set[str] = set()
+    candidates: list[Completion] = []
+    # PowerShell completion is case-insensitive; the inserted candidate 
restores the real case.
+    incomplete = slot.incomplete.casefold()
+
+    def add(value: str, description: "str | Callable[[], str]") -> None:
+        if value in seen or not value.casefold().startswith(incomplete):
+            return
+        seen.add(value)
+        if callable(description):
+            description = description()
+        candidates.append(Completion(value, description))
+
+    for value, source in static_candidates(slot):
+        add(value, partial(describe, source, help_format))
+    for value, description in dynamic_candidates(slot):
+        add(value, description)
+
+    files = (
+        slot.active is not None
+        and not slot.option_name
+        and get_completion_action(slot.active.hint) is CompletionAction.FILES
+    )
+    return FullCompletions(candidates, files=files, prefix=slot.prefix)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/completion/_readline.py 
new/cyclopts-5.2.0/cyclopts/completion/_readline.py
--- old/cyclopts-5.1.1/cyclopts/completion/_readline.py 2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/completion/_readline.py 2020-02-02 
01:00:00.000000000 +0100
@@ -1,9 +1,10 @@
 """Tab completion for :meth:`.App.interactive_shell` via the stdlib 
``readline`` module.
 
-The bash/zsh/fish generators handle command names, option names, static 
choices,
-and paths in the generated script; only :attr:`.Parameter.completer` values 
come
-from Python. The interactive shell has no script, so this module produces the
-static candidates in Python and merges in the dynamic ones.
+The bash/zsh/fish/PowerShell generators handle command names, option names,
+static choices, and paths in the generated script; only
+:attr:`.Parameter.completer` values come from Python. The interactive shell has
+no script, so this module takes the static candidates from the engine, adds
+paths, and merges in the dynamic ones.
 """
 
 import glob
@@ -14,9 +15,8 @@
 from pathlib import Path
 from typing import TYPE_CHECKING
 
-from cyclopts.completion._base import CompletionAction, get_completion_action, 
visible_commands
-from cyclopts.completion._engine import Slot, _exc, debug, dynamic_candidates, 
resolve_slot
-from cyclopts.field_info import VAR_KEYWORD
+from cyclopts.completion._base import CompletionAction, get_completion_action
+from cyclopts.completion._engine import Slot, _exc, debug, dynamic_candidates, 
resolve_slot, static_candidates
 
 if TYPE_CHECKING:
     from cyclopts import App
@@ -50,31 +50,9 @@
 
 
 def _static_candidates(slot: Slot) -> list[str]:
-    root = slot.app
-    command_names = [name for rc in visible_commands(slot.command_app) for 
name in rc.names]
-    flags = (*root.help_flags, *root.version_flags)
-
-    if slot.option_name:
-        return [
-            *(
-                name
-                for a in slot.arguments
-                if a.show and a.field_info.kind is not VAR_KEYWORD and not 
a.is_positional_only()
-                for name in a.names
-            ),
-            *(name for name in command_names if name.startswith("-")),
-            *flags,
-        ]
-
-    candidates = []
-    if not slot.unused:
-        candidates += [name for name in command_names if not 
name.startswith("-")]
-        if not slot.prior and root.app_stack.overrides.get("remap_flags"):
-            # ``interactive_shell`` accepts the bare word (``help`` for 
``--help``) at the root.
-            candidates += [flag[2:] for flag in flags if 
root._is_remappable_flag(flag)]
-    if slot.active is None:
+    candidates = [value for value, _ in static_candidates(slot)]
+    if slot.option_name or slot.active is None:
         return candidates
-    candidates += slot.active.get_choices(force=True) or ()
     if get_completion_action(slot.active.hint) is CompletionAction.FILES:
         # ponytail: no ``~`` expansion; readline inserts the literal path.
         incomplete = slot.incomplete
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/completion/detect.py 
new/cyclopts-5.2.0/cyclopts/completion/detect.py
--- old/cyclopts-5.1.1/cyclopts/completion/detect.py    2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/completion/detect.py    2020-02-02 
01:00:00.000000000 +0100
@@ -7,6 +7,7 @@
 
 import os
 import subprocess
+import sys
 from pathlib import Path
 from typing import Literal
 
@@ -15,7 +16,7 @@
     """Raised when the shell type cannot be detected."""
 
 
-def _extract_shell_name(shell_string: str) -> Literal["zsh", "bash", "fish"] | 
None:
+def _extract_shell_name(shell_string: str) -> Literal["zsh", "bash", "fish", 
"powershell"] | None:
     """Extract shell name from a string (path or process name).
 
     Parameters
@@ -25,7 +26,7 @@
 
     Returns
     -------
-    Literal["zsh", "bash", "fish"] | None
+    Literal["zsh", "bash", "fish", "powershell"] | None
         The detected shell type, or None if not recognized.
     """
     shell_lower = shell_string.lower()
@@ -35,15 +36,31 @@
         return "bash"
     elif "fish" in shell_lower:
         return "fish"
+    elif "pwsh" in shell_lower or "powershell" in shell_lower:
+        return "powershell"
     return None
 
 
-def detect_shell() -> Literal["zsh", "bash", "fish"]:
+def _running_in_windows_powershell() -> bool:
+    """Whether a PowerShell session (either edition) launched us on Windows.
+
+    Windows has no ``ps`` for the parent-process check. Both PowerShell 
editions
+    add the user's own module directory (under their home) to ``PSModulePath``
+    for child processes, while the system-wide default (what ``cmd.exe`` sees)
+    has none.
+    """
+    if sys.platform != "win32":
+        return False
+    home = str(Path.home()).lower()
+    return any(path.lower().startswith(home) for path in 
os.environ.get("PSModulePath", "").split(os.pathsep) if path)
+
+
+def detect_shell() -> Literal["zsh", "bash", "fish", "powershell"]:
     """Detect the current shell type using multiple detection methods.
 
     Returns
     -------
-    Literal["zsh", "bash", "fish"]
+    Literal["zsh", "bash", "fish", "powershell"]
         The detected shell type.
 
     Raises
@@ -87,4 +104,7 @@
         if shell:
             return shell
 
+    if _running_in_windows_powershell():
+        return "powershell"
+
     raise ShellDetectionError("Unable to detect shell type.")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/completion/fish.py 
new/cyclopts-5.2.0/cyclopts/completion/fish.py
--- old/cyclopts-5.1.1/cyclopts/completion/fish.py      2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/completion/fish.py      2020-02-02 
01:00:00.000000000 +0100
@@ -11,6 +11,7 @@
 from cyclopts.completion._base import (
     CompletionAction,
     CompletionData,
+    app_description,
     clean_choice_text,
     extract_completion_data,
     get_completion_action,
@@ -19,7 +20,6 @@
 
 if TYPE_CHECKING:
     from cyclopts import App
-    from cyclopts.command_spec import CommandSpec
 
 
 def _completer_substitution(prog_name: str) -> str:
@@ -584,7 +584,7 @@
             if cmd_name.startswith("-"):
                 continue
 
-            desc = _get_description_from_app(registered_command.app, 
data.help_format)
+            desc = app_description(registered_command.app, data.help_format)
             escaped_desc = _escape_fish_description(desc)
             escaped_cmd = _escape_fish_string(cmd_name)
 
@@ -766,7 +766,7 @@
             if not cmd_name.startswith("-"):
                 continue
 
-            desc = _get_description_from_app(registered_command.app, 
help_format)
+            desc = app_description(registered_command.app, help_format)
             escaped_desc = _escape_fish_description(desc)
 
             if cmd_name.startswith("--"):
@@ -800,29 +800,3 @@
     func_name = f"__fish_{prog_name}_using_command"
     escaped_commands = " ".join(_escape_fish_string(cmd) for cmd in 
command_path)
     return f"-n '{func_name} {escaped_commands}'"
-
-
-def _get_description_from_app(cmd_app: "App | CommandSpec", help_format: str) 
-> str:
-    """Extract description from App.
-
-    Parameters
-    ----------
-    cmd_app : App | CommandSpec
-        Command app or spec.
-    help_format : str
-        Help text format.
-
-    Returns
-    -------
-    str
-        Description text.
-    """
-    from cyclopts.help.help import docstring_parse
-
-    try:
-        parsed = docstring_parse(cmd_app.help, "plaintext")
-        text = parsed.short_description or ""
-    except Exception:
-        text = str(cmd_app.help or "")
-
-    return strip_markup(text, format=help_format)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/completion/install.py 
new/cyclopts-5.2.0/cyclopts/completion/install.py
--- old/cyclopts-5.1.1/cyclopts/completion/install.py   2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/completion/install.py   2020-02-02 
01:00:00.000000000 +0100
@@ -5,11 +5,13 @@
 """
 
 import os
+import subprocess
 import sys
 from collections.abc import Callable
 from pathlib import Path
 from typing import Annotated, Literal
 
+from cyclopts.completion.powershell import _ps_string
 from cyclopts.parameter import Parameter
 
 
@@ -40,12 +42,55 @@
     return None
 
 
-def get_default_completion_path(shell: Literal["zsh", "bash", "fish"], 
prog_name: str) -> Path:
+def _powershell_executable() -> str:
+    r"""``pwsh`` (PowerShell 7+) or ``powershell`` (Windows PowerShell 5.1), 
whichever is likely running us.
+
+    PowerShell 7 adds its own module directories (``...\PowerShell\Modules``) 
to
+    ``PSModulePath`` for child processes; Windows PowerShell's are all 
``...\WindowsPowerShell\...``.
+    """
+    if sys.platform != "win32":
+        return "pwsh"
+    paths = os.environ.get("PSModulePath", "").lower().split(os.pathsep)
+    return "pwsh" if any("\\powershell\\" in path for path in paths) else 
"powershell"
+
+
+# Redirected output otherwise uses the console code page, garbling a non-ASCII 
path.
+_PROFILE_COMMAND = (
+    "try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding 
$false } catch { }; "
+    "$PROFILE.CurrentUserAllHosts"
+)
+
+
+def powershell_profile() -> Path:
+    """The current user's all-hosts PowerShell profile 
(``$PROFILE.CurrentUserAllHosts``).
+
+    Asks PowerShell, since Windows may redirect Documents (e.g. into OneDrive).
+    """
+    executable = _powershell_executable()
+    try:
+        result = subprocess.run(
+            [executable, "-NoProfile", "-NonInteractive", "-Command", 
_PROFILE_COMMAND],
+            capture_output=True,
+            encoding="utf-8",
+            errors="replace",
+            timeout=10,
+        )
+        if result.returncode == 0 and result.stdout.strip():
+            return Path(result.stdout.strip())
+    except (subprocess.SubprocessError, OSError):
+        pass
+    home = Path.home()
+    if sys.platform != "win32":
+        return home / ".config" / "powershell" / "profile.ps1"
+    return home / "Documents" / ("PowerShell" if executable == "pwsh" else 
"WindowsPowerShell") / "profile.ps1"
+
+
+def get_default_completion_path(shell: Literal["zsh", "bash", "fish", 
"powershell"], prog_name: str) -> Path:
     """Get the default completion script path for a given shell.
 
     Parameters
     ----------
-    shell : Literal["zsh", "bash", "fish"]
+    shell : Literal["zsh", "bash", "fish", "powershell"]
         Shell type.
     prog_name : str
         Program name for the completion script.
@@ -81,15 +126,51 @@
         fish_completions = home / ".config" / "fish" / "completions"
         fish_completions.mkdir(parents=True, exist_ok=True)
         return fish_completions / f"{prog_name}.fish"
+    elif shell == "powershell":
+        powershell_completions = powershell_profile().parent / "Completions"
+        powershell_completions.mkdir(parents=True, exist_ok=True)
+        return powershell_completions / f"{prog_name}.ps1"
     else:
         raise ValueError(f"Unsupported shell: {shell}")
 
 
-def add_to_rc_file(script_path: Path, prog_name: str, shell: Literal["bash", 
"zsh"]) -> bool:
+def _powershell_quote(path: Path) -> str:
+    return "'" + str(path).replace("'", "''") + "'"
+
+
+def _codec_errors(encoding: str) -> str:
+    """Error handler that round-trips any bytes ``encoding`` can represent, so 
rewriting a file never alters it."""
+    return "surrogatepass" if encoding.startswith(("utf-16", "utf-32")) else 
"surrogateescape"
+
+
+def _read_text(path: Path, default_encoding: str) -> tuple[str, str]:
+    """Read ``path`` as ``(content, encoding)``, detecting a BOM (Windows 
PowerShell 5.1's ``>>`` writes UTF-16)."""
+    try:
+        data = path.read_bytes()
+    except FileNotFoundError:
+        data = b""
+    if not data:
+        encoding = default_encoding
+    elif data.startswith((b"\xff\xfe\x00\x00", b"\x00\x00\xfe\xff")):
+        encoding = "utf-32-le" if data[0] == 0xFF else "utf-32-be"
+    elif data.startswith(b"\xff\xfe"):
+        # Endian-specific codecs keep the BOM as U+FEFF, so it round-trips in 
the original byte order.
+        encoding = "utf-16-le"
+    elif data.startswith(b"\xfe\xff"):
+        encoding = "utf-16-be"
+    elif data.startswith(b"\xef\xbb\xbf"):
+        encoding = "utf-8-sig"
+    else:
+        encoding = "utf-8"
+    return data.decode(encoding, errors=_codec_errors(encoding)), encoding
+
+
+def add_to_rc_file(script_path: Path, prog_name: str, shell: Literal["bash", 
"zsh", "powershell"]) -> bool:
     """Add completion configuration to shell RC file.
 
     For bash, adds a source line to load the completion script.
     For zsh, adds the completion directory to fpath so compinit can find it.
+    For PowerShell, adds a dot-source line to the current user's all-hosts 
profile.
 
     Parameters
     ----------
@@ -97,7 +178,7 @@
         Path to the completion script.
     prog_name : str
         Program name for display in comments.
-    shell : Literal["bash", "zsh"]
+    shell : Literal["bash", "zsh", "powershell"]
         Shell type.
 
     Returns
@@ -105,6 +186,7 @@
     bool
         True if configuration was added, False if it already existed or on 
error.
     """
+    prog_name = prog_name.replace("\r", " ").replace("\n", " ")
     if shell == "bash":
         rc_file = Path.home() / ".bashrc"
         config_line = f'[ -f "{script_path}" ] && . "{script_path}"'
@@ -116,36 +198,49 @@
         completion_dir = script_path.parent
         config_line = f"fpath=({completion_dir} $fpath)"
         comment = f"# {prog_name} completions"
+    elif shell == "powershell":
+        rc_file = powershell_profile()
+        # Windows PowerShell 5.1 reads a BOM-less profile as ANSI, so keep the 
line ASCII.
+        quoted = _ps_string(str(script_path))
+        config_line = f"if (Test-Path -LiteralPath {quoted}) {{ . {quoted} }}"
+        comment = f"# Load {prog_name} completion"
     else:
         raise NotImplementedError
 
     rc_file = rc_file.resolve()
 
-    if rc_file.exists():
-        content = rc_file.read_text()
-        # For zsh, check if this directory is already in fpath configuration
-        # For bash, check if the exact source line exists
-        if shell == "zsh" and str(script_path.parent) in content and "fpath=" 
in content:
-            return False
-        elif config_line in content:
-            return False
-    else:
-        content = ""
+    # Windows PowerShell 5.1 reads a BOM-less profile as ANSI.
+    content, encoding = _read_text(rc_file, "utf-8-sig" if shell == 
"powershell" else "utf-8")
+    # For zsh, check if this directory is already in fpath configuration
+    # For bash, check if the exact source line exists
+    if shell == "zsh" and str(script_path.parent) in content and "fpath=" in 
content:
+        return False
+    elif config_line in content:
+        return False
 
     if shell == "zsh":
         # Prepend to ensure fpath is set before any compinit call
-        rc_file.write_text(f"{comment}\n{config_line}\n{content}")
+        content = f"{comment}\n{config_line}\n{content}"
     else:
-        # Bash: append
-        needs_newline = content and not content.endswith("\n")
-        with rc_file.open("a") as f:
-            if needs_newline:
-                f.write("\n")
-            f.write(f"{comment}\n{config_line}\n")
+        # Bash/PowerShell: append
+        if content and not content.endswith("\n"):
+            content += "\n"
+        content += f"{comment}\n{config_line}\n"
+    rc_file.parent.mkdir(parents=True, exist_ok=True)
+    rc_file.write_bytes(content.encode(encoding, 
errors=_codec_errors(encoding)))
 
     return True
 
 
+def _check_mark() -> str:
+    """``✓``, or ``*`` when stdout can't encode it (redirected output on 
Windows is typically cp1252)."""
+    try:
+        "✓".encode(sys.stdout.encoding or "utf-8")
+    except (UnicodeEncodeError, LookupError):
+        return "*"
+    return "✓"
+
+
 def create_install_completion_command(
     install_completion_fn: Callable[..., Path],
     add_to_startup: bool,
@@ -168,7 +263,7 @@
 
     def _install_completion_command(
         *,
-        shell: Annotated[Literal["zsh", "bash", "fish"] | None, Parameter()] = 
None,
+        shell: Annotated[Literal["zsh", "bash", "fish", "powershell"] | None, 
Parameter()] = None,
         output: Annotated[Path | None, Parameter(name=["-o", "--output"])] = 
None,
     ):
         """Install shell completion for this application.
@@ -179,7 +274,7 @@
 
         Parameters
         ----------
-        shell : Literal["zsh", "bash", "fish"] | None
+        shell : Literal["zsh", "bash", "fish", "powershell"] | None
             Shell type for completion. If not specified, attempts to 
auto-detect current shell.
         output : Path | None
             Output path for the completion script. If not specified, uses 
shell-specific default.
@@ -197,17 +292,18 @@
                 sys.exit(1)
 
         install_path = install_completion_fn(shell=shell, output=output, 
add_to_startup=add_to_startup)
+        mark = _check_mark()
 
-        print(f"✓ Completion script installed to {install_path}")
+        print(f"{mark} Completion script installed to {install_path}")
 
         if shell == "zsh":
             if _detect_omz_completions_dir():
-                print("✓ Detected oh-my-zsh: completions directory is already 
in $fpath.")
+                print(f"{mark} Detected oh-my-zsh: completions directory is 
already in $fpath.")
                 print("\nRestart your shell or run: exec zsh")
             elif add_to_startup:
                 zshrc = Path.home() / ".zshrc"
                 completion_dir = install_path.parent
-                print(f"✓ Added {completion_dir} to fpath in {zshrc}")
+                print(f"{mark} Added {completion_dir} to fpath in {zshrc}")
                 print("\nNote: Ensure compinit is configured in your .zshrc 
(most zsh setups already have this).")
                 print("Restart your shell or run: exec zsh")
             else:
@@ -220,7 +316,7 @@
         elif shell == "bash":
             if add_to_startup:
                 bashrc = Path.home() / ".bashrc"
-                print(f"✓ Added completion loader to {bashrc}")
+                print(f"{mark} Added completion loader to {bashrc}")
                 print("\nRestart your shell or run: source ~/.bashrc")
             else:
                 print("\nCompletions will be automatically loaded by 
bash-completion.")
@@ -234,6 +330,17 @@
         elif shell == "fish":
             print("\nCompletions are automatically loaded in fish.")
             print("Restart your shell or run: source 
~/.config/fish/config.fish")
+        elif shell == "powershell":
+            if add_to_startup:
+                profile = powershell_profile()
+                print(f"{mark} Added completion loader to {profile}")
+                print(f"\nRestart PowerShell or run: . 
{_powershell_quote(profile)}")
+            else:
+                print("\nTo enable completions, add this line to your 
PowerShell profile ($PROFILE):")
+                print(f"    . {_powershell_quote(install_path)}")
+            if sys.platform == "win32":
+                print("\nIf PowerShell reports that running scripts is 
disabled, allow local scripts with:")
+                print("    Set-ExecutionPolicy -Scope CurrentUser 
RemoteSigned")
         else:
             raise NotImplementedError
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/completion/powershell.py 
new/cyclopts-5.2.0/cyclopts/completion/powershell.py
--- old/cyclopts-5.1.1/cyclopts/completion/powershell.py        1970-01-01 
01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/completion/powershell.py        2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,424 @@
+"""PowerShell completion script generator.
+
+Like the bash/zsh/fish scripts, the PowerShell script carries the app's 
commands,
+options, and static choices, and completes them without starting Python. The
+data is emitted as nested hashtables walked by one fixed routine; only a
+parameter with a :attr:`.Parameter.completer` runs ``<prog> __complete`` (in
+*full* mode, see :meth:`.App._run_complete`), which also handles that slot's
+``=`` prefix and multi-value position.
+
+Works in PowerShell 7+ (``pwsh``) and Windows PowerShell 5.1 
(``powershell.exe``),
+so it avoids newer syntax (``??``, ternaries) and stays ASCII (5.1 reads a
+BOM-less script as ANSI).
+"""
+
+import re
+from typing import TYPE_CHECKING
+
+from cyclopts.completion._base import (
+    CompletionAction,
+    CompletionData,
+    app_description,
+    clean_choice_text,
+    extract_completion_data,
+    get_completion_action,
+    strip_markup,
+)
+
+if TYPE_CHECKING:
+    from cyclopts import App
+    from cyclopts.argument import Argument
+
+_TEMPLATE = r"""# PowerShell completion for __PROG_COMMENT__ (generated by 
cyclopts).
+& {
+__NODES__
+$tree = __ROOT__
+$programName = __PROG_INVOKE__
+
+Register-ArgumentCompleter -Native -CommandName __COMMAND_NAMES__ -ScriptBlock 
{
+    param($wordToComplete, $commandAst, $cursorPosition)
+
+    $separator = [char]0x1f
+    # Under 5.1, a caller's 'Stop' makes the completer's stderr output 
(2>$null) a terminating error.
+    $ErrorActionPreference = 'Continue'
+    $words = New-Object System.Collections.Generic.List[string]
+    $current = ''
+    $elements = $commandAst.CommandElements
+    for ($i = 1; $i -lt $elements.Count; $i++) {
+        $element = $elements[$i]
+        $extent = $element.Extent
+        if ($extent.StartOffset -ge $cursorPosition) { break }
+        $isString = $element -is 
[System.Management.Automation.Language.StringConstantExpressionAst]
+        if ($extent.EndOffset -lt $cursorPosition) {
+            if ($isString) { $words.Add($element.Value) } else { 
$words.Add($extent.Text) }
+        } elseif ($isString -and $extent.EndOffset -eq $cursorPosition) {
+            $current = $element.Value
+        } else {
+            $current = $extent.Text.Substring(0, $cursorPosition - 
$extent.StartOffset).Trim("'", '"')
+        }
+    }
+
+    $quote = ''
+    if ($wordToComplete.StartsWith("'") -or $wordToComplete.StartsWith('"')) { 
$quote = $wordToComplete.Substring(0, 1) }
+
+    $format = {
+        param($text)
+        if ($quote -eq '"') { return '"' + ($text -replace 
'([`"$\u201C-\u201E])', '`$1') + '"' }
+        if ($quote -eq "'" -or $text -match 
'[\s''"`$,;(){}@|&<>#*?\[\u2018-\u201E]') {
+            return "'" + ($text -replace '([''\u2018-\u201B])', '$1$1') + "'"
+        }
+        $text
+    }
+
+    # Option and command names are case-sensitive, so match them with -ceq.
+    $findOption = {
+        param($node, $name)
+        foreach ($option in $node.O) { foreach ($n in $option.N) { if ($n -ceq 
$name) { return $option } } }
+        # A dict option also takes --name.key.
+        foreach ($option in $node.O) { foreach ($n in $option.N) { if 
($name.StartsWith($n + '.', [StringComparison]::Ordinal)) { return $option } } }
+    }
+
+    # An option-like word ends an option's values; a negative number does not.
+    $isOption = { param($word) $word.Length -gt 1 -and $word.StartsWith('-') 
-and $word -notmatch '^-\.?\d' }
+
+    # Walk the words before the cursor: descend into commands, skip option 
values,
+    # count positionals, and note an option whose values are still being typed.
+    $node = $tree
+    $position = 0
+    $afterDelimiter = $false
+    $valueOption = $null
+    $i = 0
+    while ($i -lt $words.Count) {
+        $word = $words[$i]
+        $i++
+        if (-not $afterDelimiter -and $node.E -and $word -ceq $node.E) { 
$afterDelimiter = $true; continue }
+        if (-not $afterDelimiter -and (& $isOption $word)) {
+            $name = $word
+            $taken = 0
+            $equals = $word.IndexOf('=')
+            if ($equals -ge 0) {
+                $name = $word.Substring(0, $equals)
+                $taken = 1
+            }
+            $option = & $findOption $node $name
+            if (-not $option -or $option.A -eq 0) { continue }
+            if ($option.Q -and $taken -eq 0 -and $name.StartsWith('--')) { 
continue }
+            while ($i -lt $words.Count -and ($option.A -lt 0 -or $taken -lt 
$option.A) -and -not (& $isOption $words[$i])) {
+                $i++
+                $taken++
+            }
+            if ($i -ge $words.Count -and ($option.A -lt 0 -or $taken -lt 
$option.A)) { $valueOption = $option }
+            continue
+        }
+        if ($position -eq $node.L -and -not $afterDelimiter) {
+            $child = $null
+            foreach ($command in $node.C) { if ($command.N -ceq $word) { 
$child = $command.T; break } }
+            if ($child) {
+                $node = $child
+                $position = 0
+                continue
+            }
+        }
+        $position++
+    }
+
+    $prefix = ''
+    $incomplete = $current
+    $slot = $null
+    $listOptions = $false
+    $listCommands = $false
+    if ($valueOption -and -not (& $isOption $current)) {
+        $slot = $valueOption.V
+    } elseif (-not $afterDelimiter -and $current.StartsWith('-')) {
+        $equals = $current.IndexOf('=')
+        if ($equals -ge 0) {
+            $option = & $findOption $node $current.Substring(0, $equals)
+            if ($option -and $option.A -ne 0) {
+                $prefix = $current.Substring(0, $equals + 1)
+                $incomplete = $current.Substring($equals + 1)
+                $slot = $option.V
+            }
+        } else {
+            $listOptions = $true
+        }
+    } else {
+        $listCommands = $position -eq $node.L -and -not $afterDelimiter
+        if ($position -lt $node.P.Count) { $slot = $node.P[$position] } elseif 
($node.R) { $slot = $node.R }
+    }
+
+    $seen = New-Object 'System.Collections.Generic.HashSet[string]'
+    $emit = {
+        param($value, $description, $resultType, $suffix)
+        if (-not $value -or -not $value.StartsWith($incomplete, 
[StringComparison]::OrdinalIgnoreCase)) { return }
+        if (-not $seen.Add($value)) { return }
+        if (-not $description) { $description = $value }
+        New-Object System.Management.Automation.CompletionResult (& $format 
($prefix + $value + $suffix)), $value, $resultType, $description
+    }
+
+    $files = ''
+    if ($slot -and $slot.X) {
+        # Completer-backed: ask the program, which returns every candidate for 
this slot.
+        $words.Add($current)
+        $program = $programName
+        if ($elements[0] -is 
[System.Management.Automation.Language.StringConstantExpressionAst]) {
+            $resolved = Get-Command -Name $elements[0].Value -CommandType 
Application -ErrorAction Ignore | Select-Object -First 1
+            if ($resolved) { $program = $resolved.Source }
+        }
+        $savedWords = $env:CYCLOPTS_COMPLETION_WORDS
+        $savedEncoding = $env:PYTHONIOENCODING
+        $savedConsoleEncoding = $null
+        try {
+            $env:CYCLOPTS_COMPLETION_WORDS = -join ($words | ForEach-Object { 
$_ + $separator })
+            $env:PYTHONIOENCODING = 'utf-8'
+            try {
+                $savedConsoleEncoding = [Console]::OutputEncoding
+                [Console]::OutputEncoding = New-Object 
System.Text.UTF8Encoding $false
+            } catch { }
+            $lines = & $program __complete 2>$null
+        } finally {
+            $env:CYCLOPTS_COMPLETION_WORDS = $savedWords
+            $env:PYTHONIOENCODING = $savedEncoding
+            if ($null -ne $savedConsoleEncoding) {
+                try { [Console]::OutputEncoding = $savedConsoleEncoding } 
catch { }
+            }
+        }
+        $prefix = ''
+        $incomplete = ''
+        $started = $false
+        foreach ($line in $lines) {
+            $line = "$line".TrimEnd("`r")
+            if (-not $started) {
+                $started = $line -ceq ($separator + 'begin')
+                continue
+            }
+            $fields = $line.Split("`t")
+            if ($line.StartsWith($separator)) {
+                if ($fields[0] -ceq ($separator + 'prefix') -and $fields.Count 
-gt 1) { $prefix = $fields[1] }
+                elseif ($fields[0] -ceq ($separator + 'files')) { $files = 
'files' }
+                continue
+            }
+            $resultType = 'ParameterValue'
+            if ($fields[0].StartsWith('-') -and -not $prefix) { $resultType = 
'ParameterName' }
+            $description = ''
+            if ($fields.Count -gt 1) { $description = $fields[1] }
+            & $emit $fields[0] $description $resultType
+        }
+    } else {
+        if ($listOptions) {
+            foreach ($option in $node.O) {
+                if ($option.H) { continue }
+                foreach ($n in $option.N) {
+                    $suffix = ''
+                    if ($option.Q -and $n.StartsWith('--')) { $suffix = '=' }
+                    & $emit $n $option.D 'ParameterName' $suffix
+                }
+            }
+        }
+        if ($listCommands) {
+            foreach ($command in $node.C) { & $emit $command.N $command.D 
'ParameterValue' }
+        }
+        if ($slot) {
+            foreach ($choice in $slot.C) { & $emit $choice '' 'ParameterValue' 
}
+            $files = $slot.F
+        }
+    }
+
+    if ($files) {
+        $pathWord = $wordToComplete
+        if ($prefix -and $pathWord.StartsWith($prefix)) { $pathWord = 
$pathWord.Substring($prefix.Length) }
+        foreach ($path in 
[System.Management.Automation.CompletionCompleters]::CompleteFilename($pathWord))
 {
+            if ($files -eq 'dirs' -and $path.ResultType -ne 
'ProviderContainer') { continue }
+            # CompleteFilename quotes and wildcard-escapes for cmdlets (5.1 
leaves backticks bare); recover the literal path.
+            $raw = $path.CompletionText
+            if ($raw.Length -gt 1 -and $raw.StartsWith("'") -and 
$raw.EndsWith("'")) {
+                $raw = $raw.Substring(1, $raw.Length - 2) -replace 
'([''\u2018-\u201B])[''\u2018-\u201B]', '$1'
+            }
+            $raw = $raw -replace '`([\[\]`])', '$1'
+            New-Object System.Management.Automation.CompletionResult (& 
$format ($prefix + $raw)), $path.ListItemText, $path.ResultType, $path.ToolTip
+        }
+    }
+}.GetNewClosure()
+}
+"""
+
+_ACTIONS = {CompletionAction.FILES: "files", CompletionAction.DIRECTORIES: 
"dirs"}
+
+
+def _ps_string(text: str) -> str:
+    """PowerShell string expression for ``text``; non-ASCII becomes char codes 
so the script stays ASCII."""
+    if text.isascii():
+        return "'" + text.replace("'", "''") + "'"
+    units = text.encode("utf-16-le")
+    codes = ",".join(f"0x{int.from_bytes(units[i : i + 2], 'little'):x}" for i 
in range(0, len(units), 2))
+    return f"(-join [char[]]({codes}))"
+
+
+def _ps_array(items: "list[str]") -> str:
+    return "@(" + ", ".join(items) + ")"
+
+
+def _value_entry(argument: "Argument") -> str:
+    """``@{ C = choices; F = 'files'|'dirs'|''; X = has-completer }`` for an 
option value or positional slot."""
+    choices = [clean_choice_text(c) for c in argument.get_choices(force=True) 
or ()]
+    action = _ACTIONS.get(get_completion_action(argument.hint), "")
+    completer = "$true" if argument.parameter.completer is not None else 
"$false"
+    return f"@{{ C = {_ps_array([_ps_string(c) for c in choices if c])}; F = 
'{action}'; X = {completer} }}"
+
+
+def _value_count(argument: "Argument") -> int:
+    """Words an option consumes after its name; ``-1`` consumes until the next 
option-like word."""
+    if argument.is_flag():
+        return 0
+    count, consume_all = argument.token_count()
+    if consume_all and argument.parameter.consume_multiple:
+        return -1
+    return max(count, 1)
+
+
+def _option_entry(
+    names: "list[str]",
+    description: str,
+    value_count: int,
+    value: str = "$null",
+    hidden: bool = False,
+    requires_equals: bool = False,
+) -> str:
+    """``H`` keeps a hidden option's arity for the walker without listing it; 
``Q`` completes ``--name=``."""
+    return (
+        f"@{{ N = {_ps_array([_ps_string(n) for n in names])}; D = 
{_ps_string(description)}; "
+        f"A = {value_count}; V = {value}; H = {'$true' if hidden else 
'$false'}; "
+        f"Q = {'$true' if requires_equals else '$false'} }}"
+    )
+
+
+def _node_body(
+    data: CompletionData,
+    child_nodes: "dict[str, str]",
+    root_flag_descriptions: "dict[str, str]",
+) -> str:
+    """The ``@{ C = commands; O = options; P = positionals; R = rest; E = 
end-of-options; L = command slot }`` hashtable."""
+    fmt = data.help_format
+
+    options = []
+    for argument in data.arguments:
+        if argument.is_positional_only():
+            continue
+        hidden = not argument.show
+        description = "" if hidden else strip_markup(argument.parameter.help 
or "", format=fmt)
+        names = [n for n in argument.parameter.name or () if n.startswith("-")]
+        if names:
+            count = _value_count(argument)
+            value = _value_entry(argument) if count and not hidden else "$null"
+            requires_equals = bool(count and 
argument.parameter.requires_equals)
+            options.append(_option_entry(names, description, count, value, 
hidden, requires_equals))
+        negatives = [n for n in argument.negatives if n.startswith("-")]
+        if negatives:
+            options.append(_option_entry(negatives, description, 0, 
hidden=hidden))
+
+    flag_names: set[str] = set()
+    for registered_command in data.commands:
+        names = [n for n in registered_command.names if n.startswith("-")]
+        if names:
+            flag_names.update(names)
+            options.append(_option_entry(names, 
app_description(registered_command.app, fmt), 0))
+    for flag in (*data.help_flags, *data.version_flags):
+        if flag.startswith("-") and flag not in flag_names:
+            flag_names.add(flag)
+            options.append(_option_entry([flag], 
root_flag_descriptions.get(flag, ""), 0))
+
+    commands = [
+        f"@{{ N = {_ps_string(name)}; D = 
{_ps_string(app_description(registered_command.app, fmt))}; "
+        f"T = {child_nodes[name]} }}"
+        for registered_command in data.commands
+        for name in registered_command.names
+        if not name.startswith("-") and name in child_nodes
+    ]
+
+    # Inherited (ancestor-meta) positionals were consumed before this path's
+    # command name, so they never claim a slot here (same rule as bash).
+    positionals = sorted(
+        (a for a in data.launcher_arguments + data.own_arguments if a.index is 
not None),
+        key=lambda a: a.index or 0,
+    )
+    # An iterable positional (``list[X]``, ``*args``) answers every later 
position.
+    rest_index = next((i for i, a in enumerate(positionals) if 
a.is_var_positional()), None)
+    if rest_index is None:
+        rest_index = next((i for i, a in enumerate(positionals) if 
a.token_count()[1]), None)
+    head = positionals if rest_index is None else positionals[:rest_index]
+    # One slot per consumed word; a hidden positional still occupies its slots.
+    slots = [_value_entry(a) if a.show else "$null" for a in head for _ in 
range(max(a.token_count()[0], 1))]
+    rest_owner = None if rest_index is None else positionals[rest_index]
+
+    # The command name follows this path's meta-launcher positionals, when they
+    # occupy a definite number of words (all required, fixed, indices 0..k-1; 
same rule as zsh).
+    launcher = sorted((a for a in data.launcher_arguments if a.index is not 
None), key=lambda a: a.index or 0)
+    fixed = [a for a in launcher if not a.is_var_positional() and not 
a.token_count()[1]]
+    command_slot = 0
+    if [a.index for a in fixed] == list(range(len(fixed))) and all(a.required 
for a in fixed):
+        command_slot = sum(max(a.token_count()[0], 1) for a in fixed)
+    rest = _value_entry(rest_owner) if rest_owner is not None and 
rest_owner.show else "$null"
+
+    return (
+        "@{\n"
+        f"    C = {_ps_array(commands)}\n"
+        f"    O = {_ps_array(options)}\n"
+        f"    P = {_ps_array(slots)}\n"
+        f"    R = {rest}\n"
+        f"    E = {_ps_string(data.end_of_options_delimiter)}\n"
+        f"    L = {command_slot}\n"
+        "}"
+    )
+
+
+def generate_completion_script(app: "App", prog_name: str) -> str:
+    """Generate a PowerShell completion script.
+
+    Parameters
+    ----------
+    app : App
+        The Cyclopts application to generate completion for.
+    prog_name : str
+        Program name; also registered as ``<prog_name>.exe`` for Windows.
+
+    Returns
+    -------
+    str
+        PowerShell script registering a native argument completer.
+    """
+    completion_data = extract_completion_data(app)
+    root_data = completion_data[()]
+    root_flag_descriptions = {
+        name: app_description(rc.app, root_data.help_format)
+        for rc in root_data.commands
+        for name in rc.names
+        if name.startswith("-")
+    }
+
+    # Emit each distinct node once, children first; aliases share a variable.
+    statements: list[str] = []
+    variables: dict[str, str] = {}
+
+    def emit(path: tuple[str, ...]) -> str:
+        data = completion_data[path]
+        child_nodes = {}
+        for registered_command in data.commands:
+            for name in registered_command.names:
+                if not name.startswith("-") and path + (name,) in 
completion_data:
+                    child_nodes[name] = emit(path + (name,))
+        body = _node_body(data, child_nodes, root_flag_descriptions)
+        if body not in variables:
+            variables[body] = f"$n{len(variables)}"
+            statements.append(f"{variables[body]} = {body}")
+        return variables[body]
+
+    root = emit(())
+    names = [prog_name] if prog_name.lower().endswith(".exe") else [prog_name, 
f"{prog_name}.exe"]
+    substitutions = {
+        "NODES": "\n".join(statements),
+        "ROOT": root,
+        "PROG_COMMENT": prog_name.replace("\r", " ").replace("\n", " 
").encode("ascii", "backslashreplace").decode(),
+        "COMMAND_NAMES": ", ".join(_ps_string(name) for name in names),
+        "PROG_INVOKE": _ps_string(prog_name),
+    }
+    # One pass, so a placeholder spelled inside user text (help, names) is 
never substituted.
+    return re.sub(r"__([A-Z_]+?)__", lambda m: substitutions[m[1]], _TEMPLATE)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/completion/zsh.py 
new/cyclopts-5.2.0/cyclopts/completion/zsh.py
--- old/cyclopts-5.1.1/cyclopts/completion/zsh.py       2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/completion/zsh.py       2020-02-02 
01:00:00.000000000 +0100
@@ -13,13 +13,13 @@
 from cyclopts.completion._base import (
     CompletionAction,
     CompletionData,
+    app_description,
     clean_choice_text,
     escape_for_shell_pattern,
     extract_completion_data,
     get_completion_action,
     strip_markup,
 )
-from cyclopts.help.help import docstring_parse
 
 if TYPE_CHECKING:
     from cyclopts import App
@@ -1100,11 +1100,4 @@
     str
         Escaped plain text description (truncated to 80 chars).
     """
-    try:
-        parsed = docstring_parse(cmd_app.help, "plaintext")
-        text = parsed.short_description or ""
-    except Exception:
-        text = str(cmd_app.help or "")
-
-    text = strip_markup(text, format=help_format)
-    return _escape_zsh_description(text)
+    return _escape_zsh_description(app_description(cmd_app, help_format))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/cyclopts/core.py 
new/cyclopts-5.2.0/cyclopts/core.py
--- old/cyclopts-5.1.1/cyclopts/core.py 2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/cyclopts/core.py 2020-02-02 01:00:00.000000000 +0100
@@ -2745,12 +2745,33 @@
         To keep those field/line delimiters unambiguous, tabs and newlines in 
the
         value and description are flattened to spaces, and a leading ``\x1f`` 
is
         stripped, so completer-supplied data can never forge a field or 
directive.
+
+        *Full* mode, requested by setting ``CYCLOPTS_COMPLETION_WORDS`` (which 
then
+        supplies the words, see 
:data:`~cyclopts.completion._engine.COMPLETION_WORDS_ENV_VAR`),
+        serves the PowerShell script's completer-backed slots. Its records also
+        cover command names, option names, and static choices for that slot,
+        already filtered (case-insensitively) by the typed prefix. Two 
directives may precede the records:
+        ``\x1fprefix<TAB><text>`` (completing an ``=``-form value: insert 
``<text>``,
+        e.g. ``--opt=``, before every candidate) and ``\x1ffiles`` (add the
+        shell's own path completions).
         """
-        from cyclopts.completion._engine import completion_debug_enabled, 
compute_completions, stdout_to_stderr
+        from cyclopts.completion._engine import (
+            COMPLETION_WORDS_ENV_VAR,
+            FullCompletions,
+            completion_debug_enabled,
+            compute_completions,
+            compute_full_completions,
+            stdout_to_stderr,
+        )
 
         def sanitize(text: str) -> str:
             return text.replace("\t", " ").replace("\n", " ").replace("\r", " 
").lstrip("\x1f")
 
+        full_words = os.environ.pop(COMPLETION_WORDS_ENV_VAR, None)
+        if full_words is not None:
+            words = full_words.split("\x1f")[:-1]
+        full: FullCompletions | None = None
+
         # Leading newline: import-time output that ended without one would
         # otherwise glue onto the marker and hide it from the shell readers.
         print("\n\x1fbegin")
@@ -2758,13 +2779,22 @@
             # Completers are user code; anything they (or their subprocesses) 
print
             # must not be parsed as a record.
             with stdout_to_stderr():
-                completions = compute_completions(self, list(words))
+                if full_words is None:
+                    completions = compute_completions(self, list(words))
+                else:
+                    full = compute_full_completions(self, list(words))
+                    completions = full.candidates
         except Exception:
             if completion_debug_enabled():
                 import traceback
 
                 traceback.print_exc()
             return None
+        if full is not None:
+            if full.prefix:
+                print(f"\x1fprefix\t{sanitize(full.prefix)}")
+            if full.files:
+                print("\x1ffiles")
         for completion in completions:
             value = sanitize(completion.value)
             help = sanitize(completion.help)
@@ -2778,7 +2808,7 @@
         self,
         *,
         prog_name: str | None = None,
-        shell: Literal["zsh", "bash", "fish"] | None = None,
+        shell: Literal["zsh", "bash", "fish", "powershell"] | None = None,
     ) -> str:
         """Generate shell completion script for this application.
 
@@ -2786,9 +2816,9 @@
         ----------
         prog_name : str | None
             Program name for completion. If None, uses first name from 
app.name.
-        shell : Literal["zsh", "bash", "fish"] | None
+        shell : Literal["zsh", "bash", "fish", "powershell"] | None
             Shell type. If None, automatically detects current shell.
-            Supported shells: "zsh", "bash", "fish".
+            Supported shells: "zsh", "bash", "fish", "powershell".
 
         Returns
         -------
@@ -2843,13 +2873,17 @@
             from cyclopts.completion.fish import generate_completion_script
 
             return generate_completion_script(app, prog_name)
+        elif shell == "powershell":
+            from cyclopts.completion.powershell import 
generate_completion_script
+
+            return generate_completion_script(app, prog_name)
         else:
             raise ValueError(f"Unsupported shell: {shell}")
 
     def install_completion(
         self,
         *,
-        shell: Literal["zsh", "bash", "fish"] | None = None,
+        shell: Literal["zsh", "bash", "fish", "powershell"] | None = None,
         output: Path | None = None,
         add_to_startup: bool = True,
     ) -> Path:
@@ -2859,15 +2893,17 @@
 
         Parameters
         ----------
-        shell : Literal["zsh", "bash", "fish"] | None
+        shell : Literal["zsh", "bash", "fish", "powershell"] | None
             Shell type for completion. If not specified, attempts to 
auto-detect current shell.
         output : Path | None
             Output path for the completion script. If not specified, uses 
shell-specific default:
             - zsh: ~/.zsh/completions/_cyclopts_<prog_name> (or 
$ZSH_CUSTOM/completions/_cyclopts_<prog_name> with oh-my-zsh)
             - bash: ~/.local/share/bash-completion/completions/<prog_name>
             - fish: ~/.config/fish/completions/<prog_name>.fish
+            - powershell: Completions/<prog_name>.ps1 next to the PowerShell 
profile
         add_to_startup : bool
             If True (default), adds source line to shell RC file to ensure 
completion is loaded.
+            For PowerShell, this is the current user's all-hosts profile 
(``$PROFILE.CurrentUserAllHosts``).
             Set to False if completions are already configured to auto-load.
 
         Returns
@@ -2912,12 +2948,13 @@
 
         if output is None:
             output = get_default_completion_path(shell, self.name[0])
+        output = output.absolute()
 
         output.parent.mkdir(parents=True, exist_ok=True)
         output.write_text(script_content)
 
         # Fish does not need any startup script changes.
-        if add_to_startup and shell in ("bash", "zsh"):
+        if add_to_startup and shell in ("bash", "zsh", "powershell"):
             add_to_rc_file(output, self.name[0], shell)
 
         return output
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.1.1/pyproject.toml 
new/cyclopts-5.2.0/pyproject.toml
--- old/cyclopts-5.1.1/pyproject.toml   2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-5.2.0/pyproject.toml   2020-02-02 01:00:00.000000000 +0100
@@ -30,7 +30,7 @@
 trio = ["trio>=0.10.0"]
 yaml = ["pyyaml>=6.0.1"]
 docs = [
-    "sphinx>=7.4.7,<8.2.0",
+    "sphinx>=7.4.7,<10.0.0",
     "sphinx_rtd_theme>=3.0.0,<4.0.0",
     "gitpython>=3.1.31",
     "sphinx-copybutton>=0.5,<1.0",

Reply via email to