From 741b9f7f83f7bc24f67f719fbab20f192a32d493 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 14:16:06 +0000 Subject: [PATCH] update.ps1 installs and updates the VS Code extension A new step, before Docker: when VS Code is there, update.ps1 runs the new scripts/install-vscode-extension.ps1. It builds editors/vscode (npm ci and vsce) and installs it with code --install-extension when it is not installed, another version is, or any of its files changed since the last install (a hash kept in .py-doc/vscode-extension.sha256). Otherwise it says it is up to date. A failure is reported and the update goes on. -NoVSCode skips the step and -Code names VS Code's command line when it is not on the PATH. The script is tested against a fake VS Code command line where PowerShell and Node.js are present, as on GitHub's runners. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WHtcxeRPPmXcydfxoEHGJ4 --- README.md | 2 +- docs/guide/01_install.md | 3 + editors/vscode/README.md | 10 +- scripts/install-vscode-extension.ps1 | 143 +++++++++++++++++++++++++++ scripts/update.ps1 | 43 ++++++-- tests/test_vscode_install_script.py | 90 +++++++++++++++++ 6 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 scripts/install-vscode-extension.ps1 create mode 100644 tests/test_vscode_install_script.py diff --git a/README.md b/README.md index ae251e5..a8a7206 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ py-doc doctor # check that everything works A few scientific fences (`molecule`, `smith`, `plasmid` and others) are drawn in an optional second image, the science image. It is built the first time a document uses one, or ahead of time with `py-doc tools build --science`. -On Windows, `.\scripts\update.ps1` does all of that in a virtual environment of py-doc's own (`.venv`). Run `.\scripts\update.ps1` after every pull: it installs any new dependencies, rebuilds the tool image when it changed, and checks that everything works. +On Windows, `.\scripts\update.ps1` does all of that in a virtual environment of py-doc's own (`.venv`). Run `.\scripts\update.ps1` after every pull: it installs any new dependencies, rebuilds the tool image when it changed, and checks that everything works. When VS Code is there, it also installs or updates the [VS Code extension](editors/vscode/README.md). Linux and Windows are supported. See [Installation](docs/guide/01_install.md) for the tool container, proxies and offline machines. diff --git a/docs/guide/01_install.md b/docs/guide/01_install.md index 31e6aa3..f722ae7 100644 --- a/docs/guide/01_install.md +++ b/docs/guide/01_install.md @@ -81,11 +81,14 @@ After a pull, a change of dependencies or the tool image takes effect only after | Python | py-doc's own virtual environment, `.venv` in the repository, made from Python 3.10 or later when it is missing or broken. `-RecreateVenv` makes it again; `-System` uses the Python on the `PATH` and `-Python` another one instead | | Packages | `pip install -e .` (with `-Dev`, `.[dev]`) and `pip check`, so a new dependency is installed. In `.venv`, `pip check` sees only py-doc's packages | | Command | `py-doc` starts, and the `py-doc` on the `PATH` is this one. Its folder, `.venv\Scripts`, is put first on your user `PATH` when it is not already, so a new terminal runs it; `-NoPath` leaves the `PATH` alone. An older install found first is named, with the command that removes it | +| VS Code | When VS Code is installed, py-doc's [extension](../../editors/vscode/README.md) is built and installed into it. It is installed again when its version or any of its files changed, and left alone otherwise. Building it needs Node.js. A failure here is reported, and the update goes on. `-NoVSCode` skips the step, and `-Code` names VS Code's `code.cmd` when it is not on the `PATH` | | Docker | The container engine answers. `py-doc setup` installs it when it does not | | Tool image | Built when its definition changed, every layer again with `-Rebuild`, and older images removed unless `-KeepOldImages`. `-CaFile` passes a proxy's certificate to the build | | Doctor | `py-doc doctor` | | Tests | With `-Test`, the test suite and `py-doc lint docs --strict` | +`scripts/install-vscode-extension.ps1` does the VS Code step on its own, and `-Force` installs the extension even when it is up to date. + If Windows refuses to run the script, run it once with `powershell -ExecutionPolicy Bypass -File scripts\update.ps1`, or allow local scripts with `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`. On Linux the same steps are `git pull`, `pip install -e .`, `py-doc tools build` and `py-doc doctor`. ## Exit statuses diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 8ec4c26..062bcc1 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -9,7 +9,15 @@ Writing tools for documents, slide decks and posters written for [markbac/py-doc ## Install -The extension is not on the Marketplace yet, so you build it once and install the file. You need [Node.js](https://nodejs.org/) 18 or later and VS Code 1.75 or later. +The extension is not on the Marketplace yet, so it is built from this repository. You need [Node.js](https://nodejs.org/) 18 or later and VS Code 1.75 or later. + +On Windows, `scripts\update.ps1` at the root of the repository does it for you. When VS Code is installed, the script builds the extension and installs it, then installs it again after a pull that changed it. To do only that step: + +```powershell +.\scripts\install-vscode-extension.ps1 +``` + +By hand, on any platform, build the file and install it: ```bash cd editors/vscode diff --git a/scripts/install-vscode-extension.ps1 b/scripts/install-vscode-extension.ps1 new file mode 100644 index 0000000..e562be4 --- /dev/null +++ b/scripts/install-vscode-extension.ps1 @@ -0,0 +1,143 @@ +<# +.SYNOPSIS + Install py-doc's VS Code extension from this checkout, or update it when it changed. + +.DESCRIPTION + Finds VS Code's command line (code). When there is none, it says so and does nothing. Otherwise it + builds the extension in editors/vscode and installs it into VS Code when: + + - it is not installed, or + - the installed version is not the one in editors/vscode/package.json, or + - a file of the extension changed since this script last installed it (its hash is kept in + .py-doc/vscode-extension.sha256), or + - -Force is given. + + Building needs Node.js (npm). The build runs npm ci and @vscode/vsce in editors/vscode, and the + .vsix it makes is deleted once installed. update.ps1 runs this script; it can also run on its own. + + Exit status 0 when the extension is installed and up to date, or VS Code is not there; 1 when it + could not be built or installed, with the reason. + +.PARAMETER Code + VS Code's command line, when it is not on the PATH or in its usual place, such as + "C:\Program Files\Microsoft VS Code\bin\code.cmd". + +.PARAMETER Force + Build and install the extension even when it is up to date. + +.PARAMETER StateFile + Where the hash of the installed extension's files is kept. The default is + .py-doc/vscode-extension.sha256 in this checkout. + +.EXAMPLE + .\scripts\install-vscode-extension.ps1 +.EXAMPLE + .\scripts\install-vscode-extension.ps1 -Force +#> +[CmdletBinding()] +param( + [string]$Code, + [switch]$Force, + [string]$StateFile +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' # programs report failure through their exit status, checked below +$Root = Split-Path -Parent $PSScriptRoot +$Extension = Join-Path (Join-Path $Root 'editors') 'vscode' +$Id = 'py-doc.vscode-py-doc-preview' +$Vsce = '@vscode/vsce@3.6.2' +if (-not $StateFile) { $StateFile = Join-Path (Join-Path $Root '.py-doc') 'vscode-extension.sha256' } + +function Write-Ok([string]$Text) { Write-Host " ok $Text" -ForegroundColor Green } +function Write-Note([string]$Text) { Write-Host " note $Text" -ForegroundColor Yellow } +function Stop-Install([string]$Text, [string]$Advice) { + Write-Host " FAIL $Text" -ForegroundColor Red + if ($Advice) { Write-Host " $Advice" -ForegroundColor Red } + exit 1 +} + +# VS Code's command line: -Code, else code on the PATH, else where the Windows installers put it +function Find-Code { + if ($Code) { + $named = Get-Command $Code -ErrorAction SilentlyContinue + if (-not $named) { Stop-Install "-Code names $Code, which is not there" "Give the path of code.cmd (Windows) or code" } + return $named.Source + } + $onPath = Get-Command code -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($onPath) { return $onPath.Source } + $places = @() + if ($env:LOCALAPPDATA) { $places += Join-Path $env:LOCALAPPDATA 'Programs\Microsoft VS Code\bin\code.cmd' } + if ($env:ProgramFiles) { $places += Join-Path $env:ProgramFiles 'Microsoft VS Code\bin\code.cmd' } + foreach ($place in $places) { if (Test-Path -LiteralPath $place) { return $place } } + return $null +} + +# One hash of the extension's own files (not node_modules, the tests or a built .vsix), the same on every platform +function Get-ExtensionHash { + $lines = Get-ChildItem -LiteralPath $Extension -Recurse -File | + ForEach-Object { + $relative = $_.FullName.Substring($Extension.Length).TrimStart('\', '/').Replace('\', '/') + if ($relative -notmatch '^(node_modules|test)/' -and $relative -notmatch '\.vsix$') { + "$relative $((Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash)" + } + } | + Sort-Object -CaseSensitive + $bytes = [Text.Encoding]::UTF8.GetBytes(($lines -join "`n")) + $sha = [Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '') } finally { $sha.Dispose() } +} + +$codeExe = Find-Code +if (-not $codeExe) { + Write-Note "VS Code not found, so its extension is not installed. With VS Code elsewhere, pass -Code with its code.cmd" + exit 0 +} + +$version = (Get-Content -Raw -LiteralPath (Join-Path $Extension 'package.json') | ConvertFrom-Json).version +$hash = Get-ExtensionHash +$listed = & $codeExe --list-extensions --show-versions 2>$null +if ($LASTEXITCODE -ne 0) { Stop-Install "VS Code ($codeExe) did not list its extensions" "Start VS Code once, then run this again" } +$installed = @($listed | Where-Object { "$_" -like "$Id@*" } | ForEach-Object { "$_".Split('@')[1] }) | Select-Object -First 1 +$stamped = if (Test-Path -LiteralPath $StateFile) { (Get-Content -Raw -LiteralPath $StateFile).Trim() } else { '' } + +if ($Force) { + $why = "-Force" +} elseif (-not $installed) { + $why = "it is not installed" +} elseif ($installed -ne $version) { + $why = "$installed is installed and this checkout has $version" +} elseif ($stamped -ne $hash) { + $why = "its files changed since it was installed" +} else { + Write-Ok "py-doc Live Preview $version is installed in VS Code and up to date" + exit 0 +} + +Write-Host " installing py-doc Live Preview $version into VS Code: $why" +$npm = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 # npm.cmd on Windows +$npx = Get-Command npx -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $npm -or -not $npx) { + Stop-Install "Node.js is needed to build the extension, and npm is not on the PATH" "Install Node.js 18 or later from nodejs.org, open a new terminal and run this again" +} + +$vsix = Join-Path ([IO.Path]::GetTempPath()) "vscode-py-doc-preview-$version.vsix" +Push-Location $Extension +try { + & $npm.Source ci --ignore-scripts --no-audit --no-fund --loglevel=error + if ($LASTEXITCODE -ne 0) { Stop-Install "npm ci failed (exit status $LASTEXITCODE)" "npm's message is above; a proxy may need npm config set proxy" } + & $npx.Source --yes $Vsce package --out $vsix + if ($LASTEXITCODE -ne 0) { Stop-Install "building the extension failed (exit status $LASTEXITCODE)" "vsce's message is above" } +} finally { + Pop-Location +} + +& $codeExe --install-extension $vsix --force +$status = $LASTEXITCODE +Remove-Item -LiteralPath $vsix -ErrorAction SilentlyContinue +if ($status -ne 0) { Stop-Install "VS Code did not install the extension (exit status $status)" "VS Code's message is above" } + +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $StateFile) | Out-Null +Set-Content -LiteralPath $StateFile -Value $hash -Encoding ascii +Write-Ok "py-doc Live Preview $version installed in VS Code. In a VS Code window already open, run Developer: Reload Window" +exit 0 diff --git a/scripts/update.ps1 b/scripts/update.ps1 index 5065aef..0fb0153 100644 --- a/scripts/update.ps1 +++ b/scripts/update.ps1 @@ -15,11 +15,14 @@ sees only py-doc's own packages because the environment is py-doc's alone 4. Command py-doc runs, and the py-doc on the PATH is this one: its folder is put first on your user PATH when it is not already (-NoPath leaves the PATH alone) - 5. Docker the container engine answers (py-doc setup installs it when it does not) - 6. Tool image built when its definition changed (every layer again with -Rebuild), + 5. VS Code when VS Code is there, py-doc's extension is installed into it, and installed again + when it changed (install-vscode-extension.ps1, which needs Node.js). A failure here + is reported but does not stop the update. -NoVSCode skips it + 6. Docker the container engine answers (py-doc setup installs it when it does not) + 7. Tool image built when its definition changed (every layer again with -Rebuild), and the images of earlier versions removed unless -KeepOldImages - 7. Doctor py-doc doctor: the container runs the tools - 8. Tests with -Test: the test suite and the documentation lint + 8. Doctor py-doc doctor: the container runs the tools + 9. Tests with -Test: the test suite and the documentation lint .PARAMETER NoPull Do not pull: use the code as it is. @@ -53,6 +56,13 @@ .PARAMETER AddToPath No longer needed: the PATH is updated by default. Kept so older instructions still work. +.PARAMETER NoVSCode + Do not install or update py-doc's VS Code extension. + +.PARAMETER Code + VS Code's command line, when it is not on the PATH or in its usual place, such as + "C:\Program Files\Microsoft VS Code\bin\code.cmd". + .PARAMETER CaFile An extra certificate for image builds behind a TLS-inspecting proxy (passed to py-doc tools build). @@ -62,6 +72,8 @@ .\scripts\update.ps1 -Dev -Test .EXAMPLE .\scripts\update.ps1 -NoPull -Rebuild -RecreateVenv +.EXAMPLE + .\scripts\update.ps1 -NoVSCode #> [CmdletBinding()] param( @@ -75,6 +87,8 @@ param( [switch]$RecreateVenv, [switch]$NoPath, [switch]$AddToPath, + [switch]$NoVSCode, + [string]$Code, [string]$CaFile ) @@ -239,7 +253,20 @@ try { Write-Note "run without -NoPath to put $scripts first, or use $scripts\py-doc.exe" } - # 5. Docker ---------------------------------------------------------------------------------- + # 5. VS Code --------------------------------------------------------------------------------- + Write-Step "VS Code extension" + if ($NoVSCode) { + Write-Note "not installed or updated (-NoVSCode)" + } else { + $extensionArgs = @{} + if ($Code) { $extensionArgs['Code'] = $Code } + & (Join-Path $PSScriptRoot 'install-vscode-extension.ps1') @extensionArgs + if ($LASTEXITCODE -ne 0) { + Write-Note "the extension is left as it was, and the update goes on. Run scripts\install-vscode-extension.ps1 after fixing it" + } + } + + # 6. Docker ---------------------------------------------------------------------------------- Write-Step "Docker" $status = Invoke-PyDoc @('tools', 'status') 2>&1 | ForEach-Object { "$_" } $exit = $LASTEXITCODE @@ -250,7 +277,7 @@ try { } Write-Ok "the container engine answers" - # 6. Tool image ------------------------------------------------------------------------------ + # 7. Tool image ------------------------------------------------------------------------------ Write-Step "Tool image" $tag = ($imageLine -split '\s+')[1] $built = $imageLine -notmatch 'not built' @@ -273,12 +300,12 @@ try { if ($LASTEXITCODE -ne 0) { Write-Note "could not remove older images: $removed" } else { Write-Ok "$removed" } } - # 7. Doctor ---------------------------------------------------------------------------------- + # 8. Doctor ---------------------------------------------------------------------------------- Write-Step "Doctor" Invoke-Checked "py-doc doctor" "Each failing check above says what to do" $script:PythonExe ($script:PythonArgs + @('-m', 'py_doc', 'doctor')) Write-Ok "the tools run in the container" - # 8. Tests ----------------------------------------------------------------------------------- + # 9. Tests ----------------------------------------------------------------------------------- if ($Test) { Write-Step "Tests" Invoke-Checked "the test suite" "A failing test above names what broke" $script:PythonExe ($script:PythonArgs + @('-m', 'pytest', '-q', '-p', 'no:cacheprovider')) diff --git a/tests/test_vscode_install_script.py b/tests/test_vscode_install_script.py new file mode 100644 index 0000000..a35c22d --- /dev/null +++ b/tests/test_vscode_install_script.py @@ -0,0 +1,90 @@ +"""scripts/install-vscode-extension.ps1 installs the VS Code extension once, and again only when it changed. + +It runs against a fake VS Code command line, so it needs PowerShell and Node.js (npm), as GitHub's runners +have. It builds the real extension each time it installs. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parent.parent +SCRIPT = ROOT / "scripts" / "install-vscode-extension.ps1" +POWERSHELL = shutil.which("pwsh") or (shutil.which("powershell") if os.name == "nt" else None) +VERSION = json.loads((ROOT / "editors" / "vscode" / "package.json").read_text(encoding="utf-8"))["version"] + +pytestmark = pytest.mark.skipif(not POWERSHELL or not shutil.which("npm"), reason="needs PowerShell and Node.js (npm)") + +FAKE = """ +import os, sys +state, log = os.environ["FAKE_CODE_STATE"], os.environ["FAKE_CODE_LOG"] +if sys.argv[1] == "--list-extensions": + if os.path.exists(state): + print(open(state).read().strip()) +elif sys.argv[1] == "--install-extension": + open(log, "a").write(" ".join(os.path.basename(a) for a in sys.argv[1:]) + "\\n") + name = os.path.basename(sys.argv[2])[: -len(".vsix")] + open(state, "w").write("py-doc.vscode-py-doc-preview@" + name.rsplit("-", 1)[1] + "\\n") +""" + + +@pytest.fixture +def code(tmp_path) -> Path: + """A fake VS Code command line: it lists what it was given to install, and logs each install.""" + (tmp_path / "fake_code.py").write_text(FAKE, encoding="utf-8") + if os.name == "nt": + command = tmp_path / "code.cmd" + command.write_text(f'@"{sys.executable}" "{tmp_path / "fake_code.py"}" %*\n', encoding="utf-8") + else: + command = tmp_path / "code" + command.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{tmp_path / "fake_code.py"}" "$@"\n', encoding="utf-8") + command.chmod(0o755) + return command + + +def run(tmp_path, *arguments): + environment = {**os.environ, "FAKE_CODE_STATE": str(tmp_path / "state"), "FAKE_CODE_LOG": str(tmp_path / "log")} + return subprocess.run( + [POWERSHELL, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(SCRIPT), "-StateFile", str(tmp_path / "hash"), *arguments], + capture_output=True, text=True, env=environment, timeout=600, + ) # fmt: skip + + +def installs(tmp_path) -> list[str]: + log = tmp_path / "log" + return log.read_text(encoding="utf-8").splitlines() if log.exists() else [] + + +def test_it_installs_once_then_only_when_the_extension_changed(tmp_path, code): + first = run(tmp_path, "-Code", str(code)) + assert first.returncode == 0, first.stdout + first.stderr + assert "it is not installed" in first.stdout + assert installs(tmp_path) == [f"--install-extension vscode-py-doc-preview-{VERSION}.vsix --force"] + assert len((tmp_path / "hash").read_text(encoding="utf-8").strip()) == 64 + + again = run(tmp_path, "-Code", str(code)) + assert again.returncode == 0 and "up to date" in again.stdout, again.stdout + again.stderr + assert len(installs(tmp_path)) == 1, "nothing changed, so nothing is installed" + + (tmp_path / "hash").write_text("0" * 64, encoding="utf-8") # as if a file of the extension had changed + changed = run(tmp_path, "-Code", str(code)) + assert changed.returncode == 0 and "its files changed" in changed.stdout, changed.stdout + changed.stderr + assert len(installs(tmp_path)) == 2 + + +def test_another_version_installed_is_replaced(tmp_path, code): + (tmp_path / "state").write_text("py-doc.vscode-py-doc-preview@0.0.1\n", encoding="utf-8") + result = run(tmp_path, "-Code", str(code)) + assert result.returncode == 0 and f"0.0.1 is installed and this checkout has {VERSION}" in result.stdout + + +def test_a_code_that_is_not_there_is_a_failure_with_the_reason(tmp_path): + result = run(tmp_path, "-Code", str(tmp_path / "nowhere" / "code.cmd")) + assert result.returncode == 1 and "which is not there" in result.stdout