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