Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ jobs:
AZURE_SIGNTOOL_KEY_VAULT_URL: ${{ secrets.AZURE_SIGNTOOL_KEY_VAULT_URL }}
CONSTRUCTOR_EXAMPLES_KEEP_ARTIFACTS: "${{ runner.temp }}/examples_artifacts"
CONSTRUCTOR_SIGNTOOL_PATH: "C:/Program Files (x86)/Windows Kits/10/bin/10.0.26100.0/x86/signtool.exe"
CONSTRUCTOR_VERBOSE: 1
CONSTRUCTOR_VERBOSE: 0
Copy link
Owner Author

Choose a reason for hiding this comment

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

This I'll change back to 1 later.

run: |
rm -rf coverage.json
pytest -vv --cov=constructor --cov-branch tests/test_examples.py
Expand Down
7 changes: 7 additions & 0 deletions constructor/briefcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import functools
import logging
import os
import re
import shutil
import sys
Expand Down Expand Up @@ -249,6 +250,10 @@ class Payload:
archive_name: str = "payload.tar.gz"
conda_exe_name: str = "_conda.exe"

# There might be other ways we want to enable `add_debug_logging`, but it has proven
# very useful at least for the CI environment.
add_debug_logging: bool = bool(os.environ.get("CI")) and os.environ.get("CI") != "0"

@functools.cached_property
def root(self) -> Path:
"""Create root upon first access and cache it."""
Expand Down Expand Up @@ -332,6 +337,8 @@ def render_templates(self) -> list[str: TemplateFile]:
context: dict[str, str] = {
"archive_name": self.archive_name,
"conda_exe_name": self.conda_exe_name,
"add_debug": self.add_debug_logging,
"register_envs": str(self.info.get("register_envs", True)).lower(),
}

# Render the templates now using jinja and the defined context
Expand Down
46 changes: 45 additions & 1 deletion constructor/briefcase/pre_uninstall.bat
Original file line number Diff line number Diff line change
@@ -1,9 +1,53 @@
set "INSTDIR=%cd%"
@echo {{ 'on' if add_debug else 'off' }}
setlocal

{%- macro error_block(message, code) -%}
echo [ERROR] {{ message }}
{%- if add_debug %}
>> "%LOG%" echo [ERROR] {{ message }}
{%- endif %}
exit /b {{ code }}
{%- endmacro -%}

rem Assign INSTDIR and normalize the path
set "INSTDIR=%~dp0.."
for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI"

set "BASE_PATH=%INSTDIR%\base"
set "PREFIX=%BASE_PATH%"
set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}"
set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}"

{%- if add_debug %}
rem Get the name of the install directory
for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI"
set "LOG=%TEMP%\%APPNAME%-preuninstall.log"
Copy link
Owner Author

@lrandersson lrandersson Feb 17, 2026

Choose a reason for hiding this comment

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

I'm not really happy with this solution of setting the file name here, since it is also explicitly set in test_examples.py, ideally it would be only defined in one place instead of two as it is right now (same also applies for postinstall.log). But it does suffice for now and helped tremendously while debugging last week, I also don't see any need to change this line in the near future since it just works.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there a way to access the log file for the MSI installer somehow?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Do you mean accessing it from the CI pipeline? Otherwise no, only by opening the file, I think it's a bit clunky, MSI has "support" for logging but it seems very generic since as you might have seen that we enable logging for MSI Installers via cmd.extend(["/L*V", str(log_path)]) - this doesn't include the logging for pre/post install/uninstall.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, that's why I'm asking if there is any avenue that we can access log_path from within these scripts.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Ah, it could work but it's risky. We can end-up with encoding mismatch, MSI log is usually UTF-16. cmd.exe echo writes in the console codepage (effectively “ANSI”/OEM), so you’ll end up mixing encodings in one file.
We can also end-up with weird/corrupted log files from the MSI engine trying to write at the same time as the scripts.

Copy link
Collaborator

Choose a reason for hiding this comment

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

A problem to solve for a different PR, but I would really like to see those logged in the same file. Maybe we need to extend the logger to detect the file encoding of an existing log file and change it accordingly? The documentation is a little sparse about what happens when encoding is None.

Is the risk for race-condition really there? Would an MSI installer even write to log while the script is running?

Copy link
Owner Author

Choose a reason for hiding this comment

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

To answer this correctly, I think we need to do a lot of testing. I agree something for a different PR.


echo ==== pre_uninstall start ==== >> "%LOG%"
echo SCRIPT=%~f0 >> "%LOG%"
echo CWD=%CD% >> "%LOG%"
echo INSTDIR=%INSTDIR% >> "%LOG%"
echo BASE_PATH=%BASE_PATH% >> "%LOG%"
echo CONDA_EXE=%CONDA_EXE% >> "%LOG%"
echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%"
"%CONDA_EXE%" --version >> "%LOG%" 2>&1
{%- endif %}

{%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %}
Copy link
Collaborator

Choose a reason for hiding this comment

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

conda-standalone has logging functionality you could use - it essentially works like the Tee-Object commandlet.

{%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we should always create a log if possible. The log level should just determine what goes into the log. We do have to make sure it gets deleted in the end though.

Copy link
Owner Author

Choose a reason for hiding this comment

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

@marcoesters thanks for your first set of review comments. I thought I'd respond here first because the current design is due to some limitations I ran into last week, and I think we need to discuss this before I make any additional changes.

  1. I agree that we should always log to a file, unfortunately it doesn't seem possible to do this without redirecting all visible output from the user (the person who runs the installer) - when logging to a file, all you see is an empty console that closes after a while. I looked into this last week and the only way to use tee (or similar) is through PowerShell, which makes us "stuck" with above. Therefore I'm a bit reluctant to log all output to a file by default for the average user. Any thoughts on this?
  2. You mentioned; "conda-standalone has logging functionality you could use - it essentially works like the Tee-Object commandlet.". Will this also log exceptions that could occur when conda.exe is running? If yes this sounds like a good idea, the current approach will also make any stack traces from conda.exe visible when the tests are running in our CI environment. This is also how I discovered that we the canary builds use an older version of conda-standalone, because they caused stack traces that were not visible without redirecting.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This is how the logger is set up: https://github.com/conda/conda-standalone/blob/00deca6accc6fd0b9dae8346f125f1ad1d107524/src/entry_point.py#L134-L164

So, it should catch all stdout and stderr, but you could try by running conda.exe python -c 'raise RuntimeError("Error message")' --log-file <path to log file> and see what happens.

Copy link
Owner Author

Choose a reason for hiding this comment

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

I just tried it and unfortunately it doesn't add unhandled exceptions to the log file.

Copy link
Collaborator

Choose a reason for hiding this comment

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

As we found, that was an incorrect example, but it does work.


rem Consistency checks
if not exist "%CONDA_EXE%" (
{{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }}
)
rem Recreate an empty payload tar. This file was deleted during installation but the
rem MSI installer expects it to exist.
type nul > "%PAYLOAD_TAR%"
if errorlevel 1 (
{{ error_block('Failed to create "%PAYLOAD_TAR%"', '%errorlevel%') }}
)

"%CONDA_EXE%" constructor uninstall --prefix "%BASE_PATH%"{{ redir }}
if errorlevel 1 ( {{ dump_and_exit }} )

exit /b 0
69 changes: 61 additions & 8 deletions constructor/briefcase/run_installation.bat
Original file line number Diff line number Diff line change
@@ -1,22 +1,75 @@
set "INSTDIR=%cd%"
@echo {{ 'on' if add_debug else 'off' }}
setlocal

{%- macro error_block(message, code) -%}
echo [ERROR] {{ message }}
{%- if add_debug %}
>> "%LOG%" echo [ERROR] {{ message }}
{%- endif %}
exit /b {{ code }}
{%- endmacro -%}

rem Assign INSTDIR and normalize the path
set "INSTDIR=%~dp0.."
for %%I in ("%INSTDIR%") do set "INSTDIR=%%~fI"

set "BASE_PATH=%INSTDIR%\base"
set "PREFIX=%BASE_PATH%"
set "CONDA_EXE=%INSTDIR%\{{ conda_exe_name }}"
set "PAYLOAD_TAR=%INSTDIR%\{{ archive_name }}"

echo "Unpacking payload..."
"%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%"
"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs

set CONDA_EXTRA_SAFETY_CHECKS=no
set CONDA_PROTECT_FROZEN_ENVS=0
set "CONDA_ROOT_PREFIX=%BASE_PATH%"
set CONDA_REGISTER_ENVS={{ register_envs }}
set CONDA_SAFETY_CHECKS=disabled
set CONDA_EXTRA_SAFETY_CHECKS=no
set "CONDA_ROOT_PREFIX=%BASE_PATH%"
set "CONDA_PKGS_DIRS=%BASE_PATH%\pkgs"

"%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"
{%- if add_debug %}
rem Get the name of the install directory
for %%I in ("%INSTDIR%") do set "APPNAME=%%~nxI"
set "LOG=%TEMP%\%APPNAME%-postinstall.log"

echo ==== run_installation start ==== >> "%LOG%"
echo SCRIPT=%~f0 >> "%LOG%"
echo CWD=%CD% >> "%LOG%"
echo INSTDIR=%INSTDIR% >> "%LOG%"
echo BASE_PATH=%BASE_PATH% >> "%LOG%"
echo CONDA_EXE=%CONDA_EXE% >> "%LOG%"
echo PAYLOAD_TAR=%PAYLOAD_TAR% >> "%LOG%"
{%- endif %}

{%- set redir = ' >> "%LOG%" 2>&1' if add_debug else '' %}
{%- set dump_and_exit = 'type "%LOG%" & exit /b %errorlevel%' if add_debug else 'exit /b %errorlevel%' %}

rem Consistency checks
if not exist "%CONDA_EXE%" (
{{ error_block('CONDA_EXE not found: "%CONDA_EXE%"', 10) }}
)
if not exist "%PAYLOAD_TAR%" (
{{ error_block('PAYLOAD_TAR not found: "%PAYLOAD_TAR%"', 11) }}
)

echo Unpacking payload...
rem "%CONDA_EXE%" constructor extract --prefix "%INSTDIR%" --tar-from-stdin < "%PAYLOAD_TAR%"{{ redir }}
"%CONDA_EXE%" constructor --prefix "%INSTDIR%" --extract-tarball < "%PAYLOAD_TAR%"{{ redir }}
if errorlevel 1 ( {{ dump_and_exit }} )

rem "%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }}
"%CONDA_EXE%" constructor --prefix "%BASE_PATH%" --extract-conda-pkgs{{ redir }}
if errorlevel 1 ( {{ dump_and_exit }} )

if not exist "%BASE_PATH%" (
{{ error_block('"%BASE_PATH%" not found!', 12) }}
)

"%CONDA_EXE%" install --offline --file "%BASE_PATH%\conda-meta\initial-state.explicit.txt" -yp "%BASE_PATH%"{{ redir }}
if errorlevel 1 ( {{ dump_and_exit }} )

rem Delete the payload to save disk space.
rem A truncated placeholder of 0 bytes is recreated during uninstall
rem because MSI expects the file to be there to clean the registry.
del "%PAYLOAD_TAR%"
if errorlevel 1 ( {{ dump_and_exit }} )

exit /b 0
2 changes: 1 addition & 1 deletion examples/register_envs/construct.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

name: RegisterEnvs
version: 1.0.0
installer_type: {{ "exe" if os.name == "nt" else "all" }}
installer_type: all
channels:
- https://repo.anaconda.com/pkgs/main/
specs:
Expand Down
27 changes: 26 additions & 1 deletion tests/test_briefcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,12 @@ def test_payload_conda_exe():


@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
def test_payload_templates_are_rendered():
@pytest.mark.parametrize("debug_logging", [True, False])
def test_payload_templates_are_rendered(debug_logging):
"""Test that templates are rendered when the payload is prepared."""
info = mock_info.copy()
payload = Payload(info)
payload.add_debug_logging = debug_logging
rendered_templates = payload.render_templates()
assert len(rendered_templates) == 2 # There should be at least two files
for f in rendered_templates:
Expand All @@ -250,3 +252,26 @@ def test_payload_templates_are_rendered():
assert "{{" not in text and "}}" not in text
assert "{%" not in text and "%}" not in text
assert "{#" not in text and "#}" not in text


@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
@pytest.mark.parametrize("debug_logging", [True, False])
def test_templates_debug_mode(debug_logging):
"""Test that debug logging affects template generation."""
info = mock_info.copy()
payload = Payload(info)
payload.add_debug_logging = debug_logging
rendered_templates = payload.render_templates()
assert len(rendered_templates) == 2 # There should be at least two files

for f in rendered_templates:
assert f.dst.is_file()

with open(f.dst) as open_file:
lines = open_file.readlines()

# Check the first line.
expected = "@echo on\n" if debug_logging else "@echo off\n"
assert lines[0] == expected
# If debug_logging is True, we expect to find %LOG%, otherwise not.
assert debug_logging == any("%LOG%" in line for line in lines)
Loading
Loading