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
14 changes: 8 additions & 6 deletions constructor/fcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,9 @@ def _solve_precs(
conda_exe="conda.exe",
extra_env=False,
input_dir="",
base_needs_python=True,
):
# Add python to specs, since all installers need a python interpreter. In the future we'll
# probably want to add conda too.
# JRG: This only applies to the `base` environment; `extra_envs` are exempt
if not extra_env:
if not extra_env and base_needs_python:
specs = (*specs, "python")
if environment:
logger.debug("specs: <from existing environment '%s'>", environment)
Expand Down Expand Up @@ -312,8 +310,8 @@ def _solve_precs(
if python_prec:
precs.remove(python_prec)
precs.insert(0, python_prec)
elif not extra_env:
# the base environment must always have python; this has been addressed
elif not extra_env and base_needs_python:
# the base environment may require python; this has been addressed
# at the beginning of _main() but we can still get here through the
# environment_file option
sys.exit("python MUST be part of the base environment")
Expand Down Expand Up @@ -392,6 +390,7 @@ def _main(
extra_envs=None,
check_path_spaces=True,
input_dir="",
base_needs_python=True,
):
precs = _solve_precs(
name,
Expand All @@ -408,6 +407,7 @@ def _main(
verbose=verbose,
conda_exe=conda_exe,
input_dir=input_dir,
base_needs_python=base_needs_python,
)
extra_envs = extra_envs or {}
conda_in_base: PackageCacheRecord = next((prec for prec in precs if prec.name == "conda"), None)
Expand Down Expand Up @@ -496,6 +496,7 @@ def main(info, verbose=True, dry_run=False, conda_exe="conda.exe"):
transmute_file_type = info.get("transmute_file_type", "")
extra_envs = info.get("extra_envs", {})
check_path_spaces = info.get("check_path_spaces", True)
base_needs_python = info.get("_win_install_needs_python_exe", False)

if not channel_urls and not channels_remap and not (environment or environment_file):
sys.exit("Error: at least one entry in 'channels' or 'channels_remap' is required")
Expand Down Expand Up @@ -548,6 +549,7 @@ def main(info, verbose=True, dry_run=False, conda_exe="conda.exe"):
extra_envs,
check_path_spaces,
input_dir,
base_needs_python,
)

info["_all_pkg_records"] = pkg_records # full PackageRecord objects
Expand Down
16 changes: 16 additions & 0 deletions constructor/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import json
import logging
import os
import subprocess
import sys
from os.path import abspath, expanduser, isdir, join
from pathlib import Path
Expand Down Expand Up @@ -76,6 +77,18 @@ def get_output_filename(info):
)


def _win_install_needs_python_exe(conda_exe: str) -> bool:
results = subprocess.run(
[conda_exe, "constructor", "windows", "--help"],
capture_output=True,
check=False,
)
# Argparse uses return code 2 if a subcommand does not exist
# If the windows subcommand does not exist, python.exe is still
# required in the base environment.
return results.returncode == 2


def main_build(
dir_path,
output_dir=".",
Expand Down Expand Up @@ -275,6 +288,9 @@ def is_conda_meta_frozen(path_str: str) -> bool:
"enable_currentUserHome": "true",
}

if osname == "win":
info["_win_install_needs_python_exe"] = _win_install_needs_python_exe(info["_conda_exe"])

info["installer_type"] = itypes[0]
fcp_main(info, verbose=verbose, dry_run=dry_run, conda_exe=conda_exe)
if dry_run:
Expand Down
107 changes: 70 additions & 37 deletions constructor/nsis/main.nsi.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ ${Using:StrFunc} StrStr
!define ARCH {{ arch }}
!define PLATFORM {{ installer_platform }}
!define CONSTRUCTOR_VERSION {{ constructor_version }}
{%- if has_python %}
!define PY_VER {{ pyver_components[:2] | join(".") }}
!define PYVERSION_JUSTDIGITS {{ pyver_components | join("") }}
!define PYVERSION {{ pyver_components | join(".") }}
!define PYVERSION_MAJOR {{ pyver_components[0] }}
{%- endif %}
!define DEFAULT_PREFIX {{ default_prefix }}
!define DEFAULT_PREFIX_DOMAIN_USER {{ default_prefix_domain_user }}
!define DEFAULT_PREFIX_ALL_USERS {{ default_prefix_all_users }}
Expand Down Expand Up @@ -304,7 +306,9 @@ FunctionEnd
/InstallationType=AllUsers [default: JustMe]$\n\
/AddToPath=[0|1] [default: 0]$\n\
/KeepPkgCache=[0|1] [default: {{ 1 if keep_pkgs else 0 }}]$\n\
{%- if has_python %}
/RegisterPython=[0|1] [default: AllUsers: 1, JustMe: 0]$\n\
{%- endif %}
/NoRegistry=[0|1] [default: AllUsers: 0, JustMe: 0]$\n\
/NoScripts=[0|1] [default: 0]$\n\
/NoShortcuts=[0|1] [default: 0]$\n\
Expand All @@ -323,9 +327,14 @@ FunctionEnd
Install for all users, but don't add to PATH env var:$\n\
> $EXEFILE /InstallationType=AllUsers$\n\
$\n\
{%- if has_python %}
Install for just me, add to PATH and register as system Python:$\n\
> $EXEFILE /RegisterPython=1 /AddToPath=1$\n\
$\n\
{%- endif %}
Install for just me and add to PATH:$\n\
> $EXEFILE /AddToPath=1$\n\
$\n\
Install for just me, with no registry modification (for CI):$\n\
> $EXEFILE /NoRegistry=1$\n\
$\n\
Expand All @@ -349,6 +358,7 @@ FunctionEnd
${EndIf}

ClearErrors
{%- if has_python %}
${GetOptions} $ARGV "/RegisterPython=" $ARGV_RegisterPython
${IfNot} ${Errors}
${If} $ARGV_RegisterPython = "1"
Expand All @@ -357,6 +367,7 @@ FunctionEnd
StrCpy $Ana_RegisterSystemPython_State ${BST_UNCHECKED}
${EndIf}
${EndIf}
{%- endif %}

ClearErrors
${GetOptions} $ARGV "/KeepPkgCache=" $ARGV_KeepPkgCache
Expand Down Expand Up @@ -1142,6 +1153,7 @@ Function OnDirectoryLeave
UnicodePathTest::UnicodePathTest $INSTDIR
Pop $R1

{%- if has_python %}
# Python 3 can be installed in a CP_ACP path until MKL is Unicode capable.
# (mkl_rt.dll calls LoadLibraryA() to load mkl_intel_thread.dll)
# Python 2 can only be installed to an ASCII path.
Expand All @@ -1159,6 +1171,7 @@ Function OnDirectoryLeave
abort

valid_path:
{%- endif %}

Push $R1
${IsWritable} $INSTDIR $R1
Expand Down Expand Up @@ -1241,6 +1254,50 @@ FunctionEnd
!insertmacro AbortRetryNSExecWaitMacro ""
!insertmacro AbortRetryNSExecWaitMacro "un."

{%- set pathname = "$INSTDIR\\condabin" if initialize_conda == "condabin" else "$INSTDIR\\Scripts & Library\\bin" %}
!macro AddRemovePath add_remove un
{# python.exe is required if conda-standalone does not support the windows subcommand (<25.11.x) #}
{%- if needs_python_exe %}
${If} ${add_remove} == "add"
{%- if initialize_conda == 'condabin' %}
${Print} "Adding {{ pathname }} PATH..."
StrCpy $R0 "addcondabinpath"
{%- else %}
${Print} "Adding {{ pathname }} to PATH..."
StrCpy $R0 "addpath ${PYVERSION} ${NAME} ${VERSION} ${ARCH}"
{%- endif %}
StrCpy $R1 "Failed to add {{ NAME }} to PATH"
${Else}
${Print} "Running rmpath script..."
StrCpy $R0 "rmpath"
StrCpy $R1 "Failed to remove {{ NAME }} from PATH"
${EndIf}
${If} ${Silent}
push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" $R0'
${Else}
push '"$INSTDIR\python.exe" -E -s "$INSTDIR\Lib\_nsis.py" $R0'
${EndIf}
push $R1
push 'WithLog'
call ${un}AbortRetryNSExecWait
{%- else %}
{%- set pathflag = "--condabin" if initialize_conda == "condabin" else "--classic" %}
${If} ${add_remove} == "add"
${Print} "Adding {{ pathname }} to PATH..."
StrCpy $R0 "prepend"
StrCpy $R1 'Failed to add {{ NAME }} to PATH'
${Else}
${Print} "Removing {{ pathname }} from PATH..."
StrCpy $R0 "remove"
StrCpy $R1 'Failed to remove {{ NAME }} from PATH'
${EndIf}
push '"$INSTDIR\_conda.exe" constructor windows path --$R0=user --prefix "$INSTDIR" {{ pathflag }}'
push $R1
push 'WithLog'
call ${un}AbortRetryNSExecWait
{%- endif %}
!macroend

!macro setInstdirPermissions
# To address CVE-2022-26526.
# Revoke the write permission on directory "$INSTDIR" for Users. Users are:
Expand Down Expand Up @@ -1338,9 +1395,11 @@ Section "Install"
# for users even during an all-users installation.
!insertmacro setInstdirPermissions

{% if needs_python_exe %}
SetOutPath "$INSTDIR\Lib"
File "{{ NSIS_DIR }}\_nsis.py"
File "{{ NSIS_DIR }}\_system_path.py"
{% endif %}

{%- if has_license %}
SetOutPath "$INSTDIR"
Expand Down Expand Up @@ -1533,20 +1592,14 @@ Section "Install"
${EndIf}

{% if initialize_conda %}
${If} $Ana_AddToPath_State = ${BST_CHECKED}
{%- if initialize_conda == 'condabin' %}
${Print} "Adding $INSTDIR\condabin to PATH..."
push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" addcondabinpath'
{%- else %}
${Print} "Adding $INSTDIR\Scripts & Library\bin to PATH..."
push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" addpath ${PYVERSION} ${NAME} ${VERSION} ${ARCH}'
{%- endif %}
push 'Failed to add {{ NAME }} to PATH'
push 'WithLog'
call AbortRetryNSExecWait
${If} ${FileExists} "$INSTDIR\.nonadmin"
${If} $Ana_AddToPath_State = ${BST_CHECKED}
!insertmacro AddRemovePath "add" ""
${EndIf}
${EndIf}
{%- endif %}

{%- if has_python %}
# Create registry entries saying this is the system Python
# (for this version)
!define PYREG "Software\Python\PythonCore\${PY_VER}"
Expand All @@ -1564,6 +1617,7 @@ Section "Install"
WriteRegStr SHCTX "${PYREG}\PythonPath" \
"" "$INSTDIR\Lib;$INSTDIR\DLLs"
${EndIf}
{%- endif %}

${If} $ARGV_NoRegistry == "0"
# Registry uninstall info
Expand Down Expand Up @@ -1594,21 +1648,6 @@ Section "Install"
${Print} "Done!"
SectionEnd

!macro AbortRetryNSExecWaitLibNsisCmd cmd
SetDetailsPrint both
${Print} "Running ${cmd} scripts..."
SetDetailsPrint listonly
${If} ${Silent}
push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" ${cmd}'
${Else}
push '"$INSTDIR\python.exe" -E -s "$INSTDIR\Lib\_nsis.py" ${cmd}'
${EndIf}
push "Failed to run ${cmd}"
push 'WithLog'
call un.AbortRetryNSExecWait
SetDetailsPrint both
!macroend

Section "Uninstall"
${LogSet} on
${If} ${Silent}
Expand Down Expand Up @@ -1660,7 +1699,7 @@ Section "Uninstall"
System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_NAME", "${NAME}").r0'
StrCpy $0 ${VERSION}
${If} $INSTALLER_VERSION != ""
StrCpy $0 $INSTALLER_VERSION
StrCpy $0 $INSTALLER_VERSION
${EndIf}
System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_VER", "$0").r0'
System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_PLAT", "${PLATFORM}").r0'
Expand All @@ -1671,16 +1710,18 @@ Section "Uninstall"
System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_UNATTENDED", "0").r0'
${EndIf}

{%- if uninstall_with_conda_exe %}
${If} ${FileExists} "$INSTDIR\pkgs\pre_uninstall.bat"
${Print} "Running pre_uninstall scripts..."
push '"$CMD_EXE" /D /C "$INSTDIR\pkgs\pre_uninstall.bat"'
push "Failed to run pre_uninstall"
push 'WithLog'
call un.AbortRetryNSExecWait
${EndIf}
!insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath"
${If} ${FileExists} "$INSTDIR\.nonadmin"
!insertmacro AddRemovePath "remove" "un."
${EndIf}

{%- if uninstall_with_conda_exe %}
# Parse arguments
StrCpy $R0 ""

Expand Down Expand Up @@ -1727,14 +1768,6 @@ Section "Uninstall"
call un.AbortRetryNSExecWait
SetDetailsPrint both
{%- endfor %}
${If} ${FileExists} "$INSTDIR\pkgs\pre_uninstall.bat"
${Print} "Running pre_uninstall scripts..."
push '"$CMD_EXE" /D /C "$INSTDIR\pkgs\pre_uninstall.bat"'
push "Failed to run pre_uninstall"
push 'WithLog'
call un.AbortRetryNSExecWait
${EndIf}
!insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath"
{%- if has_conda %}
${If} ${FileExists} "$INSTDIR\.nonadmin"
StrCpy $R0 "user"
Expand Down
5 changes: 0 additions & 5 deletions constructor/osx/run_installation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,6 @@ find "$PREFIX/pkgs" -type d -empty -exec rmdir {} \; 2>/dev/null || :
{{ condarc }}
{%- endfor %}

if ! "$PREFIX/bin/python" -V; then
echo "ERROR running Python"
exit 1
fi

# This is not needed for the default install to ~, but if the user changes the
# install location, the permissions will default to root unless this is done.
chown -R "${USER}" "$PREFIX"
Expand Down
28 changes: 19 additions & 9 deletions constructor/winexe.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,16 +225,27 @@ def make_nsi(

# From now on, the items added to variables will NOT be escaped

py_name, py_version, _ = filename_dist(dists[0]).rsplit("-", 2)
assert py_name == "python"
variables["pyver_components"] = py_version.split(".")

# These are mostly booleans we use with if-checks
default_uninstall_name = "${NAME} ${VERSION}"
variables["has_python"] = False
for dist in dists:
py_name, py_version, _ = filename_dist(dist).rsplit("-", 2)
if py_name == "python":
variables["has_python"] = True
variables["pyver_components"] = py_version.split(".")
break

if variables["has_python"]:
variables["register_python"] = info.get("register_python", True)
variables["register_python_default"] = info.get("register_python_default", None)
Copy link
Contributor

@lrandersson lrandersson Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just curious, why is register_python_default typed as bool | None but by default False? Does None lead to any different behavior compared to False? To my understanding it seems like it could be a strict boolean and get rid of None.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way these flags are handled needs to be changed. See the issues I linked in the PR description. This is out of scope for this PR though.

default_uninstall_name += " (Python ${PYVERSION} ${ARCH})"
else:
variables["register_python"] = False
variables["register_python_default"] = None

variables.update(ns_platform(info["_platform"]))
variables["initialize_conda"] = info.get("initialize_conda", "classic")
variables["initialize_by_default"] = info.get("initialize_by_default", None)
variables["register_python"] = info.get("register_python", True)
variables["register_python_default"] = info.get("register_python_default", None)
variables["check_path_length"] = info.get("check_path_length", False)
variables["check_path_spaces"] = info.get("check_path_spaces", True)
variables["keep_pkgs"] = info.get("keep_pkgs") or False
Expand All @@ -247,6 +258,7 @@ def make_nsi(
variables["custom_conclusion"] = info.get("conclusion_file", "").endswith(".nsi")
variables["has_license"] = bool(info.get("license_file"))
variables["uninstall_with_conda_exe"] = bool(info.get("uninstall_with_conda_exe"))
variables["needs_python_exe"] = info.get("_win_install_needs_python_exe", True)

approx_pkgs_size_kb = approx_size_kb(info, "pkgs")

Expand All @@ -259,9 +271,7 @@ def make_nsi(
variables["SETUP_ENVS"] = setup_envs_commands(info, dir_path)
variables["WRITE_CONDARC"] = list(add_condarc(info))
variables["SIZE"] = approx_pkgs_size_kb
variables["UNINSTALL_NAME"] = info.get(
"uninstall_name", "${NAME} ${VERSION} (Python ${PYVERSION} ${ARCH})"
)
variables["UNINSTALL_NAME"] = info.get("uninstall_name", default_uninstall_name)
variables["EXTRA_FILES"] = get_extra_files(extra_files, dir_path)
variables["SCRIPT_ENV_VARIABLES"] = {
key: win_str_esc(val) for key, val in info.get("script_env_variables", {}).items()
Expand Down
Loading
Loading