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
26 changes: 20 additions & 6 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -631,19 +631,33 @@ jobs:
try {
$shortcut = $shell.CreateShortcut($summary.shortcut_path)
try {
$expectedArguments = (
'-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}"' -f
$summary.launcher_path
$contract = Get-Content -LiteralPath $contractPath -Raw |
ConvertFrom-Json
$environment = @(
Get-ChildItem `
-LiteralPath (Join-Path $installRoot "environments") `
-Directory
)
if ($shortcut.TargetPath -ne $powerShellExecutable) {
if ($environment.Count -ne 1) {
throw "Windows install did not retain exactly one environment."
}
$expectedGuiExecutable = Join-Path `
$environment[0].FullName `
("Scripts\{0}.exe" -f $contract.gui_entry_point)
if ($shortcut.TargetPath -ne $expectedGuiExecutable) {
throw "Desktop shortcut targets '$($shortcut.TargetPath)'."
}
if ($shortcut.Arguments -ne $expectedArguments) {
throw "Desktop shortcut arguments do not target the launch adapter."
if (-not [string]::IsNullOrEmpty($shortcut.Arguments)) {
throw "Desktop shortcut unexpectedly supplies console arguments."
}
if ($shortcut.WorkingDirectory -ne $installRoot) {
throw "Desktop shortcut working directory is not the install root."
}
$guiSubsystem = python -c "import struct,sys; from pathlib import Path; data=Path(sys.argv[1]).read_bytes(); pe=struct.unpack_from('<I',data,0x3c)[0]; assert data[pe:pe+4]==b'PE\0\0'; print(struct.unpack_from('<H',data,pe+24+68)[0])" `
$shortcut.TargetPath
if ($guiSubsystem.Trim() -ne "2") {
throw "Desktop shortcut target is not a GUI-subsystem executable."
}
}
finally {
[Runtime.InteropServices.Marshal]::FinalReleaseComObject($shortcut) |
Expand Down
1 change: 1 addition & 0 deletions packaging/installers/installer_contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"python_version": "3.12",
"package_requirement": "openhcs[gui,viz,bioformats,mcp,cellprofiler-compat]",
"entry_point": "openhcs",
"gui_entry_point": "openhcs-gui",
"uv_release": {
"version": "0.11.28",
"base_url": "https://astral.sh/uv"
Expand Down
25 changes: 20 additions & 5 deletions packaging/installers/windows/Install-OpenHCS.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ function Read-InstallerContract {
$pythonVersion = Get-RequiredTextProperty $contract "python_version"
$packageRequirement = Get-RequiredTextProperty $contract "package_requirement"
$entryPoint = Get-RequiredTextProperty $contract "entry_point"
$guiEntryPoint = Get-RequiredTextProperty $contract "gui_entry_point"

if ($schemaVersion -ne $script:SupportedContractSchema) {
throw "Unsupported installer contract schema '$schemaVersion'."
Expand All @@ -152,6 +153,12 @@ function Read-InstallerContract {
if ($entryPoint -notmatch "^[A-Za-z0-9][A-Za-z0-9_.-]*$") {
throw "Installer contract entry_point has an unsafe executable-name format."
}
if ($guiEntryPoint -notmatch "^[A-Za-z0-9][A-Za-z0-9_.-]*$") {
throw (
"Installer contract gui_entry_point has an unsafe " +
"executable-name format."
)
}

$uvReleaseProperty = $contract.PSObject.Properties["uv_release"]
if ($null -eq $uvReleaseProperty -or $null -eq $uvReleaseProperty.Value) {
Expand Down Expand Up @@ -192,6 +199,7 @@ function Read-InstallerContract {
PythonVersion = $pythonVersion
PackageRequirement = $packageRequirement
EntryPoint = $entryPoint
GuiEntryPoint = $guiEntryPoint
UvVersion = $uvVersion
UvInstallerUrl = $uvInstallerUrl
}
Expand Down Expand Up @@ -611,6 +619,16 @@ function Publish-LaunchAdapterAndShortcut {

$launcherPath = Get-StableLauncherPath $Contract $ResolvedInstallRoot
$powerShellExecutable = Get-WindowsPowerShellExecutable
$guiExecutable = [IO.Path]::Combine(
$ResolvedInstallRoot,
"environments",
$EnvironmentName,
"Scripts",
"$($Contract.GuiEntryPoint).exe"
)
if (-not (Test-Path -LiteralPath $guiExecutable -PathType Leaf)) {
throw "Installed GUI entry point is unavailable: $guiExecutable"
}
$stableLaunchCommandJson = ConvertTo-Json -Compress -InputObject @(
$powerShellExecutable,
"-NoProfile",
Expand Down Expand Up @@ -652,11 +670,8 @@ function Publish-LaunchAdapterAndShortcut {
try {
$shortcut = $shell.CreateShortcut($shortcutCandidate)
try {
$shortcut.TargetPath = $powerShellExecutable
$shortcut.Arguments = (
'-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}"' -f
$launcherPath
)
$shortcut.TargetPath = $guiExecutable
$shortcut.Arguments = ""
$shortcut.WorkingDirectory = $ResolvedInstallRoot
$shortcut.Description = "Launch $($Contract.ProductName)"
$shortcut.IconLocation = "$powerShellExecutable,0"
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,6 @@ Documentation = "https://openhcs.readthedocs.io/"
# Main interfaces - the top-level dispatcher defaults to the GUI and exposes
# stable named commands such as `openhcs mcp` for package registries.
openhcs = "openhcs.cli:main"
openhcs-gui = "openhcs.pyqt_gui.__main__:main"
# openhcs-tui = "openhcs.textual_tui.__main__:main" # TUI deprecated, not in PyPI

# Utility scripts
Expand All @@ -301,6 +300,9 @@ openhcs-mcp-http = "openhcs.mcp.http:main"
openhcs-mcp-dev = "openhcs.mcp.dev_client:main"
openhcs-mcp-demo = "openhcs.mcp.installed_demo:main"

[project.gui-scripts]
openhcs-gui = "openhcs.pyqt_gui.__main__:main"

[project.entry-points."napari.manifest"]
openhcs = "openhcs:napari.yaml"

Expand Down
8 changes: 8 additions & 0 deletions scripts/render_installer_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ def validate_contract(contract: object) -> dict[str, object]:
):
raise ValueError("Installer contract entry_point has an invalid format")

gui_entry_point = contract.get("gui_entry_point")
if not isinstance(gui_entry_point, str) or not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9._-]*", gui_entry_point
):
raise ValueError(
"Installer contract gui_entry_point has an invalid format"
)

uv_release = contract.get("uv_release")
if not isinstance(uv_release, dict) or set(uv_release) != {
"version",
Expand Down
6 changes: 6 additions & 0 deletions tests/installer/test_installer_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ def test_installer_contract_queries_published_project_authorities() -> None:
assert requirement.extras <= project["optional-dependencies"].keys()
assert contract["entry_point"] == project["name"]
assert contract["entry_point"] in project["scripts"]
assert contract["gui_entry_point"] in project["gui-scripts"]
assert (
project["gui-scripts"][contract["gui_entry_point"]]
== "openhcs.pyqt_gui.__main__:main"
)
assert python_version in SpecifierSet(project["requires-python"])
uv_release = contract["uv_release"]
assert set(uv_release) == {"version", "base_url"}
Expand Down Expand Up @@ -135,6 +140,7 @@ def test_render_contract_changes_only_the_package_requirement(tmp_path: Path) ->
("python_version", "python3"),
("package_requirement", "openhcs @ https://example.invalid/pkg.whl"),
("entry_point", "openhcs-gui && nope"),
("gui_entry_point", "openhcs-gui && nope"),
(
"uv_release",
{"version": "latest", "base_url": "http://example.invalid/uv"},
Expand Down
7 changes: 7 additions & 0 deletions tests/installer/test_windows_simple_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def test_windows_installer_fails_closed_on_validated_shared_contract() -> None:
assert '"installer_contract.json"' in source
assert "ConvertFrom-Json" in source
assert 'Get-RequiredTextProperty $contract "entry_point"' in source
assert 'Get-RequiredTextProperty $contract "gui_entry_point"' in source
assert '"openhcs.installer.v2"' in source
assert "Expected exactly one installer_contract.json" in source
assert "Uri]::TryCreate" in source
Expand Down Expand Up @@ -176,8 +177,11 @@ def test_windows_installer_delegates_runtime_to_declared_entrypoint() -> None:

assert '"Scripts"' in source
assert '"$($Contract.EntryPoint).exe"' in source
assert '"$($Contract.GuiEntryPoint).exe"' in source
assert "WScript.Shell" in source
assert "CreateShortcut" in source
assert "$shortcut.TargetPath = $guiExecutable" in source
assert '$shortcut.Arguments = ""' in source
assert '$env:OPENHCS_CPU_ONLY = "true"' in source
assert (
'"environments\\{0}\\Scripts\\{1}.exe") @args' in source
Expand Down Expand Up @@ -494,6 +498,9 @@ def test_windows_installer_ci_has_an_absolute_safety_ceiling() -> None:
assert '"openhcs-installer-cancel-{0}.marker"' in smoke_step
assert '"-CancellationPath", $CancellationMarker' in smoke_step
assert '"-RegisterMcpClients"' in smoke_step
assert "$contract.gui_entry_point" in smoke_step
assert "$shortcut.TargetPath -ne $expectedGuiExecutable" in smoke_step
assert "Desktop shortcut target is not a GUI-subsystem executable." in smoke_step
assert '$env:CODEX_HOME = Join-Path $env:RUNNER_TEMP "codex-home"' in smoke_step
assert "Windows installer did not register the stable OpenHCS MCP launcher." in (
smoke_step
Expand Down
Loading