From b080d486dba4ac317e5cd554c95fcd2152e1ca4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 24 Aug 2026 01:11:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(completion):=20un=20nom=20qui=20dit=20ce?= =?UTF-8?q?=20qu'il=20fait,=20et=20un=20premier=20Tab=20qui=20r=C3=A9pond?= =?UTF-8?q?=20(0.1.62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux issues, un seul geste : installer la complétion. Le premier Tab d'une session ne proposait rien, le second fonctionnait. zsh charge le fichier #compdef à la première tabulation et attend qu'il produise les propositions de cette invocation-là ; le script que typer génère se contente de définir la fonction puis de l'enregistrer pour la suite. Un Tab muet se lit comme « la complétion ne marche pas », et personne ne rappuie pour vérifier une fonctionnalité qu'il croit absente : le coût est un abandon silencieux. Le script installé appelle donc sa fonction après l'avoir enregistrée, et la raison de cette divergence avec l'amont est écrite dans le fichier posé. Le cas est reproduit et vérifié dans un zsh réel sous pseudo-terminal, avant et après : un appel direct au mécanisme de complétion ne traverse pas la couche en cause. dsoxlab completion install et completion show apparaissent. dsoxlab install reste, prévient qu'il est déprécié, annonce son retrait en 0.3.0 et fait le même travail. Il n'écrit plus de wrapper dans ~/.local/bin. Deux défauts vécus tenaient à ce fichier : un chemin contenant une espace cassait le exec faute de quoting, et write_text() sur un lien symbolique écrit dans la cible, donc le binaire réel d'uv était remplacé par un script pointant sur lui-même. uv tool install et pipx posent déjà leur lanceur exactement là. Les deux tests qui éprouvaient le wrapper sont remplacés par un test de la décision qui le retire : garder un test sur un fichier qui n'existe plus le rendrait vert sans rien mesurer. Closes #90 Closes #134 --- CHANGELOG.fr.md | 38 +++++++ CHANGELOG.md | 37 +++++++ README.fr.md | 4 +- README.md | 4 +- pyproject.toml | 2 +- src/dsoxlab/cli.py | 109 +++++++++++++------ src/dsoxlab/i18n/strings/en.py | 34 +++++- src/dsoxlab/i18n/strings/fr.py | 34 +++++- tests/test_install_et_contexte.py | 172 ++++++++++++++++++++---------- uv.lock | 2 +- 10 files changed, 332 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 428db15..0f13380 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,44 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +## [0.1.62] - 2026-08-24 + +### Corrigé + +- **Le premier `Tab` d'une session ne proposait rien**, le second fonctionnait. + zsh charge le fichier `#compdef` à la première tabulation et attend qu'il + produise les propositions **de cette invocation-là** ; le script que typer + génère se contente de définir la fonction, puis de l'enregistrer pour la + suite. Un `Tab` muet se lit comme « la complétion ne marche pas », et personne + ne rappuie une seconde fois pour vérifier une fonctionnalité qu'il croit + absente : le coût est un abandon silencieux, pas une gêne. Le script installé + appelle désormais sa fonction après l'avoir enregistrée, et la raison de cette + divergence avec l'amont est écrite **dans le fichier posé**, pour que personne + ne la retire un jour sans savoir pourquoi elle existe. Reproduit et vérifié + dans un zsh réel sous pseudo-terminal, avant et après : un appel direct au + mécanisme de complétion ne traverse pas la couche en cause. + +### Ajouté + +- **`dsoxlab completion install` et `dsoxlab completion show`.** Le premier + installe l'auto-complétion, le second imprime le script sans rien écrire, pour + qui veut le poser lui-même. + +### Déprécié + +- **`dsoxlab install` est déprécié, et sera retiré en 0.3.0.** C'était le premier + nom de commande que voyait un utilisateur dans l'aide, et il promettait + d'installer l'outil, déjà installé. Il continue de faire ce que fait + `completion install`, en le signalant. + + Il **n'écrit plus de wrapper** dans `~/.local/bin`. Deux défauts vécus tenaient + à ce fichier : un chemin contenant une espace cassait le `exec` faute de + quoting, et surtout `write_text()` sur un lien symbolique écrit dans **la + cible**, donc le binaire réel d'`uv` était remplacé par un script pointant sur + lui-même. `uv tool install` et `pipx` posent déjà leur lanceur exactement là : + le remplacer ne faisait que défaire ce que leur prochaine mise à jour + remettrait. + ## [0.1.61] - 2026-08-24 ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index 7526819..db0a9d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.62] - 2026-08-24 + +### Fixed + +- **The first `Tab` of a session proposed nothing**, the second one worked. zsh + loads the `#compdef` file on the first tab and expects it to produce the + completions **for that very invocation**; the script typer generates only + defines the function and registers it for later. A silent `Tab` reads as + "completion does not work", and nobody presses a second time to check a + feature they believe is missing: the cost is a silent give-up, not an + annoyance. The installed script now calls its function after registering it, + and the reason for that divergence from upstream is written **inside the file + it drops**, so nobody removes it later without knowing why it is there. + Reproduced and verified in a real zsh under a pseudo-terminal, before and + after: calling the completion mechanism directly does not cross the layer at + fault. + +### Added + +- **`dsoxlab completion install` and `dsoxlab completion show`.** The first + installs shell completion, the second prints the script without writing + anything, for those who would rather place it themselves. + +### Deprecated + +- **`dsoxlab install` is deprecated and will be removed in 0.3.0.** It was the + first command name a user saw in the help, and it promised to install the + tool, which is already installed. It still does what `completion install` + does, and says so. + + It **no longer writes a wrapper** in `~/.local/bin`. Two real defects came + from that file: a path containing a space broke the `exec` for lack of + quoting, and above all `write_text()` on a symlink writes into **the target**, + so uv's real binary was replaced by a script pointing at itself. `uv tool + install` and `pipx` already put their launcher exactly there: replacing it + only undid what their next update would restore. + ## [0.1.61] - 2026-08-24 ### Added diff --git a/README.fr.md b/README.fr.md index 622b917..c3405aa 100644 --- a/README.fr.md +++ b/README.fr.md @@ -272,6 +272,8 @@ test référencés sont présents. | `dsoxlab challenge` | Affiche la mission du challenge (challenge/README.md). | | `dsoxlab check` | Exécute les tests, calcule le score (hints déduits) et enregistre le résultat. | | `dsoxlab clean` | Supprime toutes les ressources créées par le lab. | +| `dsoxlab completion install` | Installe l'auto-complétion pour le shell courant (zsh, bash). | +| `dsoxlab completion show` | Imprime le script de complétion sur la sortie standard, sans rien écrire. | | `dsoxlab course` | Affiche une section du cours, ou le sommaire si aucune section n'est précisée. | | `dsoxlab demo` | Installe un catalogue de démonstration et joue un premier lab, sans rien cloner ni provisionner. | | `dsoxlab destroy` | Détruit l'infrastructure du lab (terraform destroy), machines restées hors du state comprises. | @@ -279,7 +281,7 @@ test référencés sont présents. | `dsoxlab fullhelp` | Affiche le guide complet de la plateforme (concepts, workflow, commandes). | | `dsoxlab guide` | Ouvre le guide en ligne du lab dans le navigateur. | | `dsoxlab hint` | Affiche le prochain indice du challenge (déduit des points au score final). | -| `dsoxlab install` | Installe le wrapper dsoxlab dans ~/.local/bin et l'auto-complétion shell. | +| `dsoxlab install` | Déprécié : utilise « dsoxlab completion install ». Installe l'auto-complétion. | | `dsoxlab instructor bootstrap` | Génère la clé SSH du lab (si absente) et vérifie que terraform/ansible-runner sont installés. | | `dsoxlab list-labs` | Liste tous les labs disponibles (filtrés par contexte actif si défini). | | `dsoxlab next` | Recommande le prochain lab ou challenge à compléter dans le contexte actif. | diff --git a/README.md b/README.md index 8029cba..444cdc9 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,8 @@ scripts and test files are present. | `dsoxlab challenge` | Display the challenge mission for this lab (challenge/README.md). | | `dsoxlab check` | Run tests, calculate score (hints deducted) and record result. | | `dsoxlab clean` | Remove all resources created by the lab. | +| `dsoxlab completion install` | Install completion for the current shell (zsh, bash). | +| `dsoxlab completion show` | Print the completion script on stdout, writing nothing. | | `dsoxlab course` | Display a course section, or the table of contents if no section is given. | | `dsoxlab demo` | Install a demonstration catalog and play a first lab, with nothing to clone and nothing to provision. | | `dsoxlab destroy` | Destroy the lab infrastructure (terraform destroy), including machines left outside the state. | @@ -320,7 +322,7 @@ scripts and test files are present. | `dsoxlab fullhelp` | Show the complete platform guide (concepts, workflow, commands). | | `dsoxlab guide` | Open the lab's online guide in your web browser. | | `dsoxlab hint` | Show the next challenge hint (deducts points from final score). | -| `dsoxlab install` | Install the dsoxlab wrapper in ~/.local/bin and shell auto-completion. | +| `dsoxlab install` | Deprecated: use `dsoxlab completion install`. Installs shell completion. | | `dsoxlab instructor bootstrap` | Generate the lab SSH key (if missing) and check that terraform/ansible-runner are installed. | | `dsoxlab list-labs` | List all available labs (filtered by active context if set). | | `dsoxlab next` | Recommend the next lab or challenge to complete in the active context. | diff --git a/pyproject.toml b/pyproject.toml index 9fcbeec..bbd436a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.1.61" +version = "0.1.62" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dsoxlab/cli.py b/src/dsoxlab/cli.py index 3979f24..0b3f55e 100644 --- a/src/dsoxlab/cli.py +++ b/src/dsoxlab/cli.py @@ -24,7 +24,6 @@ import atexit import logging import os -import shlex import shutil import subprocess import sys @@ -201,6 +200,17 @@ def invoke(self, ctx: Any) -> Any: ) app.add_typer(instructor_app, name="instructor") +# ── Sous-application 'completion' ───────────────────────────────────────────── + +completion_app = typer.Typer( + name="completion", + help=_("cmd_completion_help"), + no_args_is_help=True, + rich_markup_mode="rich", + cls=_I18nGroup, +) +app.add_typer(completion_app, name="completion") + # ── Option globale lab-home ─────────────────────────────────────────────────── LabHomeOption = Annotated[ @@ -585,35 +595,38 @@ def _notify_update_available() -> None: _COMPLETE_VAR = f"_{_PROG_NAME.replace('-', '_').upper()}_COMPLETE" -@app.command("install", help=_("cmd_install_help")) -def install() -> None: - """Install the dsoxlab wrapper in ~/.local/bin and shell completion.""" +#: Ce que dsoxlab ajoute au script que typer génère pour zsh, et pourquoi. +#: +#: Le commentaire part DANS le fichier installé, à dessein : sans lui, la ligne +#: ressemble à une scorie qu'un lecteur pressé retirerait, et le défaut +#: reviendrait sans que personne ne comprenne pourquoi. +_ZSH_PREMIER_TAB = """ +# ── ajouté par dsoxlab, et pas par typer ────────────────────────────────────── +# zsh autoload ce fichier au PREMIER Tab, et attend qu'il produise les +# propositions de cette invocation-là. Le script amont se contente de définir la +# fonction puis de l'enregistrer pour la suite : la première tabulation ne rend +# donc rien, et la seconde fonctionne. Un Tab muet se lit comme « la complétion +# ne marche pas », et personne ne rappuie pour vérifier. +# Ne pas retirer cette ligne sans rejouer le cas dans un zsh réel. +_dsoxlab_completion "$@" +""" + + +def _script_completion(shell: str) -> str: + """Le script de complétion pour ``shell``, corrigé pour zsh (#134).""" from typer.completion import get_completion_script - # ── 1. Wrapper script in ~/.local/bin ───────────────────────────────────── - local_bin = Path.home() / ".local" / "bin" - local_bin.mkdir(parents=True, exist_ok=True) + script = get_completion_script( + prog_name=_PROG_NAME, complete_var=_COMPLETE_VAR, shell=shell + ) + # bash source son script au démarrage du shell, fish le charge par fichier + # de complétion : ni l'un ni l'autre ne passe par l'autoload qui pose + # problème. La divergence ne vaut donc que pour zsh. + return script + _ZSH_PREMIER_TAB if shell == "zsh" else script - venv_binary = Path(sys.argv[0]).resolve() - wrapper = local_bin / "dsoxlab" - # Ne pas écraser un lanceur qui mène déjà à ce binaire. `uv tool install` et - # `pipx` en posent un exactement ici : le remplacer par un script shell ne - # fait que défaire ce que leur prochaine mise à jour remettra. Surtout, si ce - # lanceur EST le fichier qu'on vient de résoudre (cas d'un vrai fichier - # plutôt que d'un lien), le wrapper s'exécuterait lui-même, en boucle. - if wrapper.exists() and wrapper.resolve() == venv_binary: - info(_("install_wrapper_deja", path=str(wrapper))) - else: - # shlex.quote : un chemin d'installation contenant une espace - # (« /home/moi/My Tools/… ») produisait un `exec` découpé en plusieurs - # arguments, donc un wrapper qui échouait sur « not found ». - cible = shlex.quote(str(venv_binary)) - wrapper.write_text(f'#!/bin/sh\nexec {cible} "$@"\n') - wrapper.chmod(0o755) - success(_("install_wrapper", path=str(wrapper), source=str(venv_binary))) - - # ── 2. Shell completion ──────────────────────────────────────────────────── +def _installer_completion() -> None: + """Pose le script de complétion du shell courant, et raccorde son rc.""" shell_name = Path(os.environ.get("SHELL", "bash")).name if shell_name == "zsh": @@ -622,10 +635,7 @@ def install() -> None: # Le nom du fichier compte : zsh autoload la fonction `_dsoxlab` pour # compléter `dsoxlab`, et cherche donc un fichier de ce nom exact. comp_file = zfunc_dir / f"_{_PROG_NAME}" - script = get_completion_script( # noqa: S604 — `shell` = nom du shell Typer ("zsh"), pas un subprocess shell=True - prog_name=_PROG_NAME, complete_var=_COMPLETE_VAR, shell="zsh" - ) - comp_file.write_text(script) + comp_file.write_text(_script_completion("zsh")) success(_("install_completion", path=str(comp_file))) zshrc = Path.home() / ".zshrc" @@ -644,10 +654,7 @@ def install() -> None: bash_comp_dir = Path.home() / ".bash_completion.d" bash_comp_dir.mkdir(exist_ok=True) comp_file = bash_comp_dir / "dsoxlab" - script = get_completion_script( # noqa: S604 — `shell` = nom du shell Typer ("bash"), pas un subprocess shell=True - prog_name=_PROG_NAME, complete_var=_COMPLETE_VAR, shell="bash" - ) - comp_file.write_text(script) + comp_file.write_text(_script_completion("bash")) success(_("install_completion", path=str(comp_file))) bashrc = Path.home() / ".bashrc" @@ -660,7 +667,39 @@ def install() -> None: else: info(_("install_completion_unsupported", shell=shell_name)) - info(_("install_reload")) + info(_("install_reload")) + + +@completion_app.command("install", help=_("cmd_completion_install_help")) +def completion_install() -> None: + _installer_completion() + + +@completion_app.command("show", help=_("cmd_completion_show_help")) +def completion_show( + shell: Annotated[str | None, typer.Option("--shell", help=_("opt_completion_shell"))] = None, +) -> None: + """Imprime le script, sans rien écrire : à rediriger où l'on veut.""" + nom = shell or Path(os.environ.get("SHELL", "bash")).name + if nom not in ("zsh", "bash", "fish"): + error(_("install_completion_unsupported", shell=nom)) + raise typer.Exit(2) + # `print` et non `info` : c'est une sortie destinée à être redirigée, elle + # ne doit porter ni couleur ni encadrement. + print(_script_completion(nom)) + + +@app.command("install", help=_("cmd_install_help")) +def install() -> None: + """Déprécié depuis 0.1.62, retiré en 0.3.0 : voir `completion install`. + + Le nom promettait d'installer l'outil, déjà installé. La commande posait en + plus un wrapper dans ``~/.local/bin``, exactement où ``uv tool install`` et + ``pipx`` posent le leur : le remplacer ne faisait que défaire ce que leur + prochaine mise à jour remettrait. Il n'est donc plus écrit du tout. + """ + warn(_("install_deprecie")) + _installer_completion() # ── use ────────────────────────────────────────────────────────────────────── diff --git a/src/dsoxlab/i18n/strings/en.py b/src/dsoxlab/i18n/strings/en.py index d8a51a4..4dfee81 100644 --- a/src/dsoxlab/i18n/strings/en.py +++ b/src/dsoxlab/i18n/strings/en.py @@ -90,7 +90,7 @@ "Say what the engine is doing, on standard error. Repeatable: -v for information, -vv for full detail.", "opt_debug": "Same as -vv. The full log is written to ~/.local/state/dsoxlab/dsoxlab.log either way.", "opt_version_help": "Show the dsoxlab version and exit.", - "cmd_install_help": "Install the dsoxlab wrapper in ~/.local/bin and shell auto-completion.", + "cmd_install_help": "Deprecated: use `dsoxlab completion install`. Installs shell completion.", "cmd_demo_help": "Install a demonstration catalog and play a first lab, with nothing to " "clone and nothing to provision.", @@ -402,8 +402,13 @@ Exits non-zero if any of them remains. [dim]--yes[/dim] Do not ask for confirmation, orphan machines included. - [cyan]install[/cyan] Install dsoxlab in [bold]~/.local/bin[/bold] + shell auto-completion. - Supports bash and zsh. Reload your shell after running. + [cyan]completion install[/cyan] Install shell auto-completion (bash, zsh). + Reload your shell afterwards: [bold]exec $SHELL[/bold] + [cyan]completion show[/cyan] Print the script on stdout, writing nothing. + [dim]--shell [/dim] zsh, bash or fish. Default: the current shell. + + [cyan]install[/cyan] [bold]Deprecated[/bold] since 0.1.62, removed in 0.3.0. + Does what [bold]completion install[/bold] does, and warns. [cyan]support[/cyan] Diagnostic report to paste into an issue: versions, tools, catalog, latest traces. Anonymised by @@ -1061,4 +1066,27 @@ # ── #132 : un « 0 lab » muet oblige à chercher ailleurs ── "detail_labs_ecart": "{ecart} of the {presents} lab.yaml files on disk could not be loaded. `dsoxlab list-labs` names them and says why.", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "cmd_completion_help": + "Install or print the shell completion script.", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "cmd_completion_install_help": + "Install completion for the current shell (zsh, bash).", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "cmd_completion_show_help": + "Print the completion script on stdout, writing nothing.", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "opt_completion_shell": + "Shell to generate for (zsh, bash, fish). Default: the current shell.", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "install_deprecie": + "`dsoxlab install` is deprecated since 0.1.62 and will be removed in " + "0.3.0. Use `dsoxlab completion install`, which does the same thing " + "under a name that says it. The wrapper in ~/.local/bin is no longer " + "written: `uv tool install` and `pipx` already put theirs there.", } diff --git a/src/dsoxlab/i18n/strings/fr.py b/src/dsoxlab/i18n/strings/fr.py index a7caa55..fc7acee 100644 --- a/src/dsoxlab/i18n/strings/fr.py +++ b/src/dsoxlab/i18n/strings/fr.py @@ -90,7 +90,7 @@ "Détaille ce que fait le moteur, sur la sortie d'erreur. Répétable : -v pour les informations, -vv pour le détail complet.", "opt_debug": "Équivaut à -vv. Le journal complet est de toute façon écrit dans ~/.local/state/dsoxlab/dsoxlab.log.", "opt_version_help": "Affiche la version de dsoxlab et quitte.", - "cmd_install_help": "Installe le wrapper dsoxlab dans ~/.local/bin et l'auto-complétion shell.", + "cmd_install_help": "Déprécié : utilise « dsoxlab completion install ». Installe l'auto-complétion.", "cmd_demo_help": "Installe un catalogue de démonstration et joue un premier lab, sans " "rien cloner ni provisionner.", @@ -405,8 +405,13 @@ Terraform. Sort en code non nul s'il en reste une. [dim]--yes[/dim] Ne demande pas confirmation, machines orphelines comprises. - [cyan]install[/cyan] Installe dsoxlab dans [bold]~/.local/bin[/bold] + auto-complétion shell. - Supporte bash et zsh. Rechargez le shell après exécution. + [cyan]completion install[/cyan] Installe l'auto-complétion du shell (bash, zsh). + Rechargez ensuite votre shell : [bold]exec $SHELL[/bold] + [cyan]completion show[/cyan] Imprime le script sur la sortie standard, sans rien écrire. + [dim]--shell [/dim] zsh, bash ou fish. Par défaut, le shell courant. + + [cyan]install[/cyan] [bold]Déprécié[/bold] depuis 0.1.62, retiré en 0.3.0. + Fait ce que fait [bold]completion install[/bold], et le signale. [cyan]support[/cyan] Rapport de diagnostic à coller dans une issue : versions, outils, catalogue, dernières traces. Anonymisé @@ -1076,4 +1081,27 @@ # ── #132 : un « 0 lab » muet oblige à chercher ailleurs ── "detail_labs_ecart": "{ecart} des {presents} fichiers lab.yaml présents sur le disque n'ont pas pu être chargés. « dsoxlab list-labs » les nomme et dit pourquoi.", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "cmd_completion_help": + "Installe ou imprime le script d'auto-complétion du shell.", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "cmd_completion_install_help": + "Installe l'auto-complétion pour le shell courant (zsh, bash).", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "cmd_completion_show_help": + "Imprime le script de complétion sur la sortie standard, sans rien écrire.", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "opt_completion_shell": + "Shell visé (zsh, bash, fish). Par défaut, le shell courant.", + + # ── #90 / #134 : la complétion sous un nom qui la nomme ── + "install_deprecie": + "« dsoxlab install » est déprécié depuis 0.1.62 et sera retiré en 0.3.0. " + "Utilise « dsoxlab completion install », qui fait la même chose sous " + "un nom qui le dit. Le wrapper de ~/.local/bin n'est plus écrit : " + "uv tool install et pipx y posent déjà le leur.", } diff --git a/tests/test_install_et_contexte.py b/tests/test_install_et_contexte.py index 0942e35..780972b 100644 --- a/tests/test_install_et_contexte.py +++ b/tests/test_install_et_contexte.py @@ -8,8 +8,6 @@ from __future__ import annotations import json -import stat -import subprocess from pathlib import Path import pytest @@ -65,89 +63,145 @@ def test_le_fichier_zsh_porte_le_nom_que_zsh_cherche( assert not (tmp_path / ".zfunc" / "_dsoxl").exists() -# ── le wrapper cassait sur un chemin contenant une espace ───────────────────── +# ── le wrapper n'est plus écrit du tout ─────────────────────────────────────── -def _ecrire_faux_binaire(chemin: Path) -> None: - """Un exécutable qui prouve, en s'exécutant, qu'il a bien été appelé.""" - chemin.parent.mkdir(parents=True, exist_ok=True) - chemin.write_text('#!/bin/sh\necho "APPELE:$*"\n', encoding="utf-8") - chemin.chmod(chemin.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) +def test_install_n_ecrit_plus_de_wrapper( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Deux défauts vécus tenaient à ce fichier, et le retirer les clôt. + + `dsoxlab install` posait un `exec` dans `~/.local/bin`, exactement où + `uv tool install` et `pipx` posent leur lanceur. Le remplacer ne faisait que + défaire ce que leur prochaine mise à jour remettrait, et le danger était pire + qu'un écrasement : `write_text()` sur un lien symbolique écrit dans **la + cible**, donc on remplaçait le binaire réel de uv par un script pointant sur + lui-même. Il a fallu une mutation pour le voir (#68). + + Un chemin contenant une espace cassait par ailleurs le `exec`, faute de + quoting, et le shell répondait « not found ». + + Les deux disparaissent en n'écrivant plus rien, et c'est ce que ce test + vérifie. Il remplace les deux tests d'exécution du wrapper, devenus sans + objet : garder un test sur un fichier qui n'existe plus le rendrait vert + sans rien mesurer. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("SHELL", "/bin/bash") + monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: tmp_path)) + resultat = runner.invoke(cli.app, ["install"]) + assert resultat.exit_code == 0, resultat.output + + assert not (tmp_path / ".local" / "bin" / "dsoxlab").exists() + # La complétion, elle, est bien posée : la commande n'est pas devenue vide. + assert (tmp_path / ".bash_completion.d" / "dsoxlab").is_file() -def test_le_wrapper_fonctionne_avec_une_espace_dans_le_chemin( + +def test_install_annonce_sa_depreciation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Le seul test qui prouve quelque chose ici : on EXÉCUTE le wrapper. + """Le nom promettait d'installer l'outil, déjà installé. - Sans quoting, `exec /home/moi/My Tools/dsoxlab "$@"` se découpe en deux - arguments et le shell répond « not found ». Vérifier le contenu du fichier - ne l'aurait pas montré. + Il reste un cycle de version, mais il doit dire par quoi il est remplacé : + une dépréciation muette ne déplace personne. """ - binaire = tmp_path / "My Tools" / "dsoxlab-reel" - _ecrire_faux_binaire(binaire) - monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("SHELL", "/bin/bash") monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: tmp_path)) - monkeypatch.setattr(cli.sys, "argv", [str(binaire)]) resultat = runner.invoke(cli.app, ["install"]) - assert resultat.exit_code == 0, resultat.output - wrapper = tmp_path / ".local" / "bin" / "dsoxlab" - assert wrapper.is_file() - - # check=False : si le wrapper ne s'exécute pas, l'assertion doit pouvoir - # afficher son contenu — c'est ce qui rend le diagnostic possible. - joue = subprocess.run( - [str(wrapper), "check", "un-lab"], capture_output=True, text=True, check=False, - ) - assert joue.returncode == 0, ( - f"le wrapper ne s'exécute pas : {joue.stderr.strip()}\n" - f"contenu : {wrapper.read_text(encoding='utf-8')!r}" - ) - assert "APPELE:check un-lab" in joue.stdout, ( - "les arguments doivent parvenir au binaire réel" - ) + assert resultat.exit_code == 0, resultat.output + sortie = " ".join(resultat.output.split()) + assert "completion install" in sortie, sortie + assert "0.3.0" in sortie, "la version de retrait doit être annoncée" -def test_un_lanceur_existant_n_est_pas_ecrase( +def test_completion_install_fait_le_meme_travail( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """`uv tool install` pose son lanceur exactement là, et c'est un lien. + """Le nouveau nom doit poser exactement ce que l'ancien posait.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("SHELL", "/usr/bin/zsh") + monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: tmp_path)) - Le danger est plus grave qu'un simple écrasement, et il a fallu une - mutation pour le voir : `write_text()` sur un lien symbolique écrit dans - **la cible**. Sans garde, on ne remplace donc pas le lien, on remplace le - binaire réel de uv par un script `exec` qui pointe sur lui-même. Le lien - survit, `resolve()` ne bouge pas, et la commande boucle à l'infini. + resultat = runner.invoke(cli.app, ["completion", "install"]) - Ce test compare donc le CONTENU du binaire réel, seule chose qui change. - """ - reel = tmp_path / "outils" / "dsoxlab" - _ecrire_faux_binaire(reel) - contenu_avant = reel.read_text(encoding="utf-8") + assert resultat.exit_code == 0, resultat.output + assert (tmp_path / ".zfunc" / "_dsoxlab").is_file() - lanceur = tmp_path / ".local" / "bin" / "dsoxlab" - lanceur.parent.mkdir(parents=True, exist_ok=True) - lanceur.symlink_to(reel) +def test_completion_show_n_ecrit_rien_sur_le_disque( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`show` sert à rediriger : il ne doit poser aucun script ni toucher un rc. + + Le contrôle ne peut pas être « le HOME reste vide » : toute commande dsoxlab + ouvre son journal au démarrage et crée donc `~/.local/state/dsoxlab/`, ce qui + n'a rien à voir avec `show`. On vise les fichiers que `show` pourrait écrire + et ne doit pas écrire. + """ monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setenv("SHELL", "/bin/bash") monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: tmp_path)) - monkeypatch.setattr(cli.sys, "argv", [str(lanceur)]) - resultat = runner.invoke(cli.app, ["install"]) + resultat = runner.invoke(cli.app, ["completion", "show", "--shell", "zsh"]) + assert resultat.exit_code == 0, resultat.output + assert "#compdef dsoxlab" in resultat.output + assert not (tmp_path / ".zfunc").exists(), "aucun script de complétion posé" + assert not (tmp_path / ".bash_completion.d").exists() + assert not (tmp_path / ".zshrc").exists(), "aucun rc touché" + assert not (tmp_path / ".bashrc").exists() - assert lanceur.is_symlink(), "le lien de uv/pipx doit rester un lien" - assert reel.read_text(encoding="utf-8") == contenu_avant, ( - "le binaire réel a été réécrit à travers le lien : il s'exec désormais " - "lui-même, donc il boucle" - ) - assert "exec" not in reel.read_text(encoding="utf-8").split("\n")[1], ( - "le binaire ne doit pas être devenu un wrapper vers lui-même" - ) + +def test_completion_show_refuse_un_shell_inconnu() -> None: + """Le contre-cas : sans lui, `show` rendrait un script vide en silence.""" + resultat = runner.invoke(cli.app, ["completion", "show", "--shell", "csh"]) + + assert resultat.exit_code == 2, resultat.output + + +# ── le premier Tab d'une session ne proposait rien ──────────────────────────── + +def test_le_script_zsh_repond_des_la_premiere_tabulation() -> None: + """Reproduit dans un zsh réel : tabulation 1 muette, tabulation 2 correcte. + + zsh autoload le fichier `#compdef` au PREMIER Tab et attend qu'il produise + les propositions de cette invocation-là. Le script amont se contente de + définir la fonction puis de l'enregistrer pour la suite. L'appel final est + donc la correction, et il est **après** l'enregistrement : les deux chemins, + première tabulation et suivantes, doivent marcher. + + Ce test ne remplace pas la vérification sous pseudo-terminal, qui est la + seule à traverser la couche en cause ; il empêche que la ligne disparaisse + d'un coup d'éditeur, ce qu'aucun test unitaire de complétion ne verrait. + """ + script = cli._script_completion("zsh") + + assert script.rstrip().endswith('_dsoxlab_completion "$@"'), script + assert script.index("compdef _dsoxlab_completion") < script.index( + '_dsoxlab_completion "$@"' + ), "l'appel doit suivre l'enregistrement" + # La raison part dans le fichier installé : sans elle, la ligne ressemble à + # une scorie, et le défaut revient. + assert "typer" in script.lower() + + +def test_les_autres_shells_restent_ceux_de_typer() -> None: + """La divergence ne vaut que pour zsh, et il faut que ça reste vrai. + + bash source son script au démarrage, fish le charge par fichier de + complétion : ni l'un ni l'autre ne passe par l'autoload en cause. Y ajouter + la ligne n'aurait aucun effet utile et nous éloignerait de l'amont sans + raison. + """ + from typer.completion import get_completion_script + + for shell in ("bash", "fish"): + attendu = get_completion_script( + prog_name=cli._PROG_NAME, complete_var=cli._COMPLETE_VAR, shell=shell + ) + assert cli._script_completion(shell) == attendu # ── un fichier d'état corrompu ne doit pas emporter la CLI ──────────────────── diff --git a/uv.lock b/uv.lock index c0c751f..2bdaacd 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.1.61" +version = "0.1.62" source = { editable = "." } dependencies = [ { name = "ansible-core", version = "2.19.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },