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",