|
10 | 10 | from pathlib import Path |
11 | 11 | from typing import TYPE_CHECKING |
12 | 12 | from typing import ClassVar |
| 13 | +from typing import cast |
13 | 14 |
|
14 | 15 | from cleo import helpers |
15 | 16 | from cleo._compat import shell_quote |
16 | 17 | from cleo.commands.command import Command |
17 | 18 | from cleo.commands.completions.templates import TEMPLATES |
| 19 | +from cleo.exceptions import CleoRuntimeError |
18 | 20 |
|
19 | 21 |
|
20 | 22 | if TYPE_CHECKING: |
@@ -138,10 +140,32 @@ def render(self, shell: str) -> str: |
138 | 140 |
|
139 | 141 | raise RuntimeError(f"Unrecognized shell: {shell}") |
140 | 142 |
|
| 143 | + @staticmethod |
| 144 | + def _get_prog_name_from_stack() -> str: |
| 145 | + package_name = "" |
| 146 | + frame = inspect.currentframe() |
| 147 | + f_back = frame.f_back if frame is not None else None |
| 148 | + f_globals = f_back.f_globals if f_back is not None else None |
| 149 | + # break reference cycle |
| 150 | + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack |
| 151 | + del frame |
| 152 | + |
| 153 | + if f_globals is not None: |
| 154 | + package_name = cast(str, f_globals.get("__name__")) |
| 155 | + |
| 156 | + if package_name == "__main__": |
| 157 | + package_name = cast(str, f_globals.get("__package__")) |
| 158 | + |
| 159 | + if package_name: |
| 160 | + package_name = package_name.partition(".")[0] |
| 161 | + |
| 162 | + if not package_name: |
| 163 | + raise CleoRuntimeError("Can not determine package name") |
| 164 | + |
| 165 | + return package_name |
| 166 | + |
141 | 167 | def _get_script_name_and_path(self) -> tuple[str, str]: |
142 | | - # FIXME: when generating completions via `python -m script completions`, |
143 | | - # we incorrectly infer `script_name` as `__main__.py` |
144 | | - script_name = self._io.input.script_name or inspect.stack()[-1][1] |
| 168 | + script_name = self._io.input.script_name or self._get_prog_name_from_stack() |
145 | 169 | script_path = posixpath.realpath(script_name) |
146 | 170 | script_name = Path(script_path).name |
147 | 171 |
|
@@ -250,34 +274,62 @@ def sanitize(s: str) -> str: |
250 | 274 | # Commands + options |
251 | 275 | cmds = [] |
252 | 276 | cmds_opts = [] |
253 | | - cmds_names = [] |
| 277 | + namespaces = set() |
254 | 278 | for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""): |
255 | 279 | if cmd.hidden or not cmd.enabled or not cmd.name: |
256 | 280 | continue |
257 | | - command_name = shell_quote(cmd.name) if " " in cmd.name else cmd.name |
258 | | - cmds.append( |
259 | | - f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' " |
260 | | - f"-a {command_name} -d '{sanitize(cmd.description)}'" |
261 | | - ) |
| 281 | + cmd_path = cmd.name.split(" ") |
| 282 | + namespace = cmd_path[0] |
| 283 | + cmd_name = cmd_path[-1] if " " in cmd.name else cmd.name |
| 284 | + |
| 285 | + # We either have a command like `poetry add` or a nested (namespaced) |
| 286 | + # command like `poetry cache clear`. |
| 287 | + if len(cmd_path) == 1: |
| 288 | + cmds.append( |
| 289 | + f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' " |
| 290 | + f"-a {cmd_name} -d '{sanitize(cmd.description)}'" |
| 291 | + ) |
| 292 | + condition = f"__fish_seen_subcommand_from {cmd_name}" |
| 293 | + else: |
| 294 | + # Complete the namespace first |
| 295 | + if namespace not in namespaces: |
| 296 | + cmds.append( |
| 297 | + f"complete -c {script_name} -f -n " |
| 298 | + f"'__fish{function}_no_subcommand' -a {namespace}" |
| 299 | + ) |
| 300 | + # Now complete the command |
| 301 | + subcmds = [ |
| 302 | + name.split(" ")[-1] for name in self.application.all(namespace) |
| 303 | + ] |
| 304 | + cmds.append( |
| 305 | + f"complete -c {script_name} -f -n '__fish_seen_subcommand_from " |
| 306 | + f"{namespace}; and not __fish_seen_subcommand_from {' '.join(subcmds)}' " |
| 307 | + f"-a {cmd_name} -d '{sanitize(cmd.description)}'" |
| 308 | + ) |
| 309 | + condition = ( |
| 310 | + f"__fish_seen_subcommand_from {namespace}; " |
| 311 | + f"and __fish_seen_subcommand_from {cmd_name}" |
| 312 | + ) |
| 313 | + |
262 | 314 | cmds_opts += [ |
263 | | - f"# {command_name}", |
| 315 | + f"# {cmd.name}", |
264 | 316 | *[ |
265 | | - f"complete -c {script_name} -A " |
266 | | - f"-n '__fish_seen_subcommand_from {sanitize(command_name)}' " |
| 317 | + f"complete -c {script_name} " |
| 318 | + f"-n '{condition}' " |
267 | 319 | f"-l {opt.name} -d '{sanitize(opt.description)}'" |
268 | 320 | for opt in sorted(cmd.definition.options, key=lambda o: o.name) |
269 | 321 | ], |
270 | 322 | "", # newline |
271 | 323 | ] |
272 | | - cmds_names.append(command_name) |
| 324 | + namespaces.add(namespace) |
273 | 325 |
|
274 | 326 | return TEMPLATES["fish"] % { |
275 | 327 | "script_name": script_name, |
276 | 328 | "function": function, |
277 | 329 | "opts": "\n".join(opts), |
278 | 330 | "cmds": "\n".join(cmds), |
279 | 331 | "cmds_opts": "\n".join(cmds_opts[:-1]), # trim trailing newline |
280 | | - "cmds_names": " ".join(cmds_names), |
| 332 | + "cmds_names": " ".join(sorted(namespaces)), |
281 | 333 | } |
282 | 334 |
|
283 | 335 | def get_shell_type(self) -> str: |
|
0 commit comments