Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions docs/guide/01_install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion editors/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 143 additions & 0 deletions scripts/install-vscode-extension.ps1
Original file line number Diff line number Diff line change
@@ -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
43 changes: 35 additions & 8 deletions scripts/update.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).

Expand All @@ -62,6 +72,8 @@
.\scripts\update.ps1 -Dev -Test
.EXAMPLE
.\scripts\update.ps1 -NoPull -Rebuild -RecreateVenv
.EXAMPLE
.\scripts\update.ps1 -NoVSCode
#>
[CmdletBinding()]
param(
Expand All @@ -75,6 +87,8 @@ param(
[switch]$RecreateVenv,
[switch]$NoPath,
[switch]$AddToPath,
[switch]$NoVSCode,
[string]$Code,
[string]$CaFile
)

Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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'))
Expand Down
90 changes: 90 additions & 0 deletions tests/test_vscode_install_script.py
Original file line number Diff line number Diff line change
@@ -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
Loading