diff --git a/.authors.yml b/.authors.yml index 42f8efbac..dba9e7267 100644 --- a/.authors.yml +++ b/.authors.yml @@ -264,7 +264,7 @@ github: chenghlee - name: conda-bot email: ad-team+condabot@anaconda.com - num_commits: 52 + num_commits: 58 first_commit: 2022-01-25 21:38:28 alternate_emails: - 18747875+conda-bot@users.noreply.github.com @@ -277,7 +277,7 @@ aliases: - Jaime RGP - jaimergp - num_commits: 108 + num_commits: 111 first_commit: 2022-01-08 14:56:53 github: jaimergp - name: Tom Hören @@ -357,7 +357,7 @@ github: RahulARanger - name: Marco Esters email: mesters@anaconda.com - num_commits: 49 + num_commits: 60 first_commit: 2023-05-12 11:44:12 github: marcoesters - name: Darryl Miles @@ -372,7 +372,7 @@ github: deepeshaburse - name: pre-commit-ci[bot] email: 66853113+pre-commit-ci[bot]@users.noreply.github.com - num_commits: 53 + num_commits: 72 first_commit: 2023-05-02 12:01:43 github: pre-commit-ci[bot] - name: Matthias Kuhn @@ -382,7 +382,7 @@ github: m-kuhn - name: dependabot[bot] email: 49699333+dependabot[bot]@users.noreply.github.com - num_commits: 43 + num_commits: 63 github: dependabot[bot] first_commit: 2024-05-07 10:16:05 - name: Julien Jerphanion @@ -405,5 +405,22 @@ github: Jrice1317 alternate_emails: - 100002667+Jrice1317@users.noreply.github.com - num_commits: 2 + num_commits: 4 first_commit: 2025-07-30 14:27:00 +- name: dionizijefa + github: dionizijefa + email: dionizije.fa@hotmail.com + num_commits: 1 + first_commit: 2025-08-27 17:15:44 +- name: Robin Andersson + aliases: + - Robin + github: lrandersson + email: 34315751+lrandersson@users.noreply.github.com + num_commits: 13 + first_commit: 2025-10-21 08:30:00 +- name: David Laehnemann + github: dlaehnemann + email: 1379875+dlaehnemann@users.noreply.github.com + num_commits: 1 + first_commit: 2025-12-15 15:37:06 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index faa269f02..49a93859e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,7 +26,7 @@ jobs: run: shell: bash -el {0} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 with: diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 3fd3be3a0..b34a5dad7 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -23,7 +23,7 @@ jobs: GLOBAL: https://raw.githubusercontent.com/conda/infra/main/.github/global.yml LOCAL: .github/labels.yml steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - id: has_local uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 65f8f40b3..a5bbc9331 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -17,7 +17,7 @@ jobs: if: '!github.event.repository.fork' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 + - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 with: # Number of days of inactivity before a closed issue is locked issue-inactive-days: 180 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e053e5c40..e9203347a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,44 +40,44 @@ jobs: include: # UBUNTU - os: ubuntu-latest - python-version: "3.9" + python-version: "3.10" conda-standalone: conda-standalone - os: ubuntu-latest - python-version: "3.10" + python-version: "3.11" conda-standalone: conda-standalone-nightly - os: ubuntu-latest - python-version: "3.11" + python-version: "3.12" conda-standalone: micromamba - os: ubuntu-latest - python-version: "3.12" + python-version: "3.13" conda-standalone: conda-standalone-onedir check-docs-schema: true # MACOS - - os: macos-13 - python-version: "3.9" - conda-standalone: conda-standalone-nightly - - os: macos-13 + - os: macos-15-intel python-version: "3.10" + conda-standalone: conda-standalone-nightly + - os: macos-15-intel + python-version: "3.11" conda-standalone: conda-standalone-onedir - os: macos-latest - python-version: "3.11" + python-version: "3.12" conda-standalone: conda-standalone - os: macos-latest - python-version: "3.12" + python-version: "3.13" conda-standalone: micromamba # WINDOWS - os: windows-2022 - python-version: "3.9" + python-version: "3.10" conda-standalone: conda-standalone-nightly - os: windows-2022 - python-version: "3.10" + python-version: "3.11" conda-standalone: conda-standalone - os: windows-2022 - python-version: "3.11" + python-version: "3.12" # conda-standalone: micromamba conda-standalone: conda-standalone-nightly - os: windows-2022 - python-version: "3.12" + python-version: "3.13" # conda-standalone: micromamba conda-standalone: conda-standalone-onedir @@ -85,7 +85,7 @@ jobs: PYTHONUNBUFFERED: "1" steps: - name: Retrieve the source code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - uses: conda-incubator/setup-miniconda@835234971496cad1653abb28a638a281cf32541f # v3.2.0 @@ -107,7 +107,6 @@ jobs: && files+=(--file "tests/extra-requirements-${{ runner.os }}.txt") conda install ${files[@]} -y echo "NSIS_USING_LOG_BUILD=1" >> $GITHUB_ENV - echo "NSIS_SCRIPTS_RAISE_ERRORS=1" >> $GITHUB_ENV pip install -e . --no-deps --no-build-isolation - name: Set up conda executable run: | @@ -135,12 +134,13 @@ jobs: run: conda list - name: conda config run: conda config --show-sources + - name: Run unit tests run: | pytest -vv --cov=constructor --cov-branch tests/ -m "not examples" coverage run --branch --append -m constructor -V coverage json - - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} flags: unit @@ -153,12 +153,13 @@ 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 run: | rm -rf coverage.json pytest -vv --cov=constructor --cov-branch tests/test_examples.py coverage run --branch --append -m constructor -V coverage json - - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} flags: integration @@ -169,8 +170,8 @@ jobs: python constructor/_schema.py git diff --exit-code - name: Upload the example installers as artifacts - if: github.event_name == 'pull_request' && matrix.python-version == '3.9' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: github.event_name == 'pull_request' && matrix.python-version == '3.10' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: installers-${{ runner.os }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} path: "${{ runner.temp }}/examples_artifacts" @@ -183,7 +184,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Retrieve the source code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Report failures uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2 env: @@ -209,7 +210,7 @@ jobs: include: - runner: ubuntu-latest subdir: linux-64 - - runner: macos-13 + - runner: macos-15-intel subdir: osx-64 - runner: macos-latest subdir: osx-arm64 @@ -219,13 +220,13 @@ jobs: steps: # Clean checkout of specific git ref needed for package metadata version # which needs env vars GIT_DESCRIBE_TAG and GIT_BUILD_STR: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.ref }} clean: true fetch-depth: 0 - - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 - name: Build Python sdist and wheel run: | python -m pip install build diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ed159bfeb..28c857650 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -38,7 +38,7 @@ jobs: with: path: https://raw.githubusercontent.com/conda/infra/main/.github/messages.yml - - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 id: stale with: # Only issues with these labels are checked whether they are stale diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index e238f8ef5..3ee42ebc0 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -44,7 +44,7 @@ jobs: echo REPOSITORY=$(curl --silent ${{ github.event.issue.pull_request.url }} | jq --raw-output '.head.repo.full_name') >> $GITHUB_ENV echo REF=$(curl --silent ${{ github.event.issue.pull_request.url }} | jq --raw-output '.head.ref') >> $GITHUB_ENV - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: repository: ${{ env.REPOSITORY || github.repository }} ref: ${{ env.REF || '' }} @@ -80,7 +80,7 @@ jobs: - if: github.event.comment.body != '@conda-bot render' id: create # no-op if no commits were made - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: push-to-fork: ${{ env.FORK }} token: ${{ secrets.SYNC_TOKEN }} diff --git a/.gitignore b/.gitignore index 22609e17e..9733f24b1 100644 --- a/.gitignore +++ b/.gitignore @@ -150,8 +150,13 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# VS Code .vscode/ +# macOS +.DS_Store + # Rever rever/ diff --git a/.mailmap b/.mailmap index dbd21b652..bccc50941 100644 --- a/.mailmap +++ b/.mailmap @@ -19,6 +19,7 @@ Chris Burr Chris Burr Daniel Bast <2790401+dbast@users.noreply.github.com> Darryl Miles +David Laehnemann <1379875+dlaehnemann@users.noreply.github.com> Deepesha Burse <87636253+deepeshaburse@users.noreply.github.com> Eric Dill Eric Prestat @@ -63,6 +64,7 @@ Pradipta Ghosh Rachel Rigdon rrigdon <45607889+rrigdon@users.noreply.github.com> Ray Donnelly Richard Höchenberger +Robin Andersson <34315751+lrandersson@users.noreply.github.com> Robin <34315751+lrandersson@users.noreply.github.com> Ryan Sai Hanuma Rahul Sophia Castellarin soapy1 @@ -83,6 +85,7 @@ bkreider conda-bot Conda Bot <18747875+conda-bot@users.noreply.github.com> conda-bot conda bot <18747875+conda-bot@users.noreply.github.com> dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +dionizijefa guimondmm pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> y2kbugger diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6a8add2e8..43fda497f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.0 + rev: v0.14.9 hooks: # Run the linter. - id: ruff @@ -31,6 +31,6 @@ repos: - id: shellcheck exclude: ^(constructor/header.sh|constructor/osx/.*sh) - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.34.1 + rev: 0.35.0 hooks: - id: check-github-workflows diff --git a/AUTHORS.md b/AUTHORS.md index 219cf91ba..77d6c831d 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -11,6 +11,7 @@ Authors are sorted alphabetically. * Connor Martin * Daniel Bast * Darryl Miles +* David Laehnemann * Deepesha Burse * Eric Dill * Eric Prestat @@ -52,6 +53,7 @@ Authors are sorted alphabetically. * Rachel Rigdon * Ray Donnelly * Richard Höchenberger +* Robin Andersson * Ryan * Sai Hanuma Rahul * Sophia Castellarin @@ -71,6 +73,7 @@ Authors are sorted alphabetically. * bkreider * conda-bot * dependabot[bot] +* dionizijefa * guimondmm * pre-commit-ci[bot] * y2kbugger diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8f99055..b960d91fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,124 @@ [//]: # (current developments) +## 2025-12-15 3.14.3: +### Bug fixes + +* Force symbolic linking of `_conda` in SH and PKG installers. This fixes a regression introduced by #1090 that made installations fail if `_conda` already exists in the target location. (#1135) +* EXE: Update calls to built-in function `LogSet` to instead call the intended macro definition `${LogSet}`. (#1141) + +### Contributors + +* @dlaehnemann +* @lrandersson + + + +## 2025-12-10 3.14.2: +### Enhancements + +* Improve logging experience for EXE installers: (#1108) + - Use `cmd.exe` to run commands so that outputs are captured. + - Output command output in CLI installations. + - Prevent log builds from writing to log before installation directory exists. + - Remove registry entries while installation directory still exists so that errors are logged. + +### Bug fixes + +* EXE: Fixed an issue for silent installers where command-line argument `/KeepPkgCache` was ignored and `/NoRegistry` would reset the default value. (#1132) + +### Contributors + +* @marcoesters +* @lrandersson + + + +## 2025-12-08 3.14.1: +### Bug fixes + +* EXE: Fix a regression with uninitialized variables that prevented installations from being added to the "Add/Remove Programs" list. (#1124) + +### Contributors + +* @marcoesters +* @lranderssons + + + +## 2025-12-02 3.14.0: +### Enhancements + +* Replace custom Python script with `conda-standalone` calls. + This removes Python as an implicit dependency from installers. (#549 via #1089) +* EXE: Improve handling of options `initialize_conda`, `register_python` with their corresponding default values. The behavior of these options + with respect to `initialize_by_default` and `register_python_default` is now consistent with `.sh` and `.pkg` installers. + Windows CLI installations now don't add `conda` to `PATH` or register Python by default, and command-line arguments are + only parsed when installing in silent mode (enabled with the flag `/S`). (#1003, #1004 via #1105) + +### Bug fixes + +* Ensure cached repodata files are shipped in SH installers. (#1119 via #1121). + +### Contributors + +* @jaimergp +* @marcoesters +* @lrandersson + + + +## 2025-11-10 3.13.1: +### Bug fixes + +* SH: Resolve malformed text displayed during installation. (#1104) + +### Contributors + +* @lrandersson + + + +## 2025-11-03 3.13.0: +### Enhancements + +* Add support for installing [protected (frozen) conda environments](https://conda.org/learn/ceps/cep-0022#specification). (#1058) +* Ship `conda-meta/initial-state.explicit.txt` as a copy of the lockfile that provisions the initial state of each environment. (#1052 via #1059) +* Remove unused functions from `_nsis.py`. (#1068) +* Port script execution, AutoRun manipulation, and directory creation functions from `_nsis.py` to NSIS. (#1069) +* Unset additional environment variables in shell-based installers to avoid accidental loading of external libraries. (#1082) +* Include the license file in PKG installers. (#1074 via #1085) + +### Bug fixes + +* SH: Fixed misleading wording for shell initialization in installation prompt. (#1039 via #1340) +* PKG: Restore the default value of `enable_currentUserHome` to the old default value (`true`). (#1070 via #1088) +* Rename mamba-based standalone binaries to `micromamba` and create a symbolic link to `_conda` for backwards compatibility. (#1033 via #1090) +* Add guards to macOS and `glibc` version checks. (#1094) +* EXE: Remove write access for users during the installation process. (`c368383710a7c2b81ad1b0ecb9724b38d3577447`) +* EXE: Remove write access for users except for the installing user from single-user installations. (`c368383710a7c2b81ad1b0ecb9724b38d3577447`) + +### Docs + +* Document that `check_path_length` defaults to `False` in line with prior behavior and declare + it as `bool` only in the schema. (#1036) +* PKG: Clarify that the profile of all available shells will be modified by default. (#1070 via #1088) + +### Other + +* Fix typo in license prompt message for SH installers. (#1035 via $1053) +* CI: Update signtool.exe path for Windows 2025 runner images (SDK 10.0.26100.0) (#1073) +* CI: Use `windows-2022` for integration tests. (#1077) + +### Contributors + +* @Jrice1317 +* @jaimergp +* @marcoesters +* @lrandersson +* @dionizijefa + + + ## 2025-08-06 3.12.2: ### Bug fixes diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 77155d3e9..04943a353 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -235,6 +235,7 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -317,8 +318,11 @@ Name of the company/entity responsible for the installer. ### `reverse_domain_identifier` Unique identifier for this package, formatted with reverse domain notation. This is -used internally in the PKG installers to handle future updates and others. If not -provided, it will default to `io.continuum`. (MacOS only) +used internally in the MSI and PKG installers to handle future updates and others. +If not provided, it will default to: + +* In MSI installers: `io.continuum` followed by an ID derived from the `name`. +* In PKG installers: `io.continuum`. ### `uninstall_name` @@ -525,17 +529,19 @@ See also `initialize_by_default`. ### `initialize_by_default` -Default value for the option added by `initialize_conda`. The default -is true for GUI installers (EXE, PKG) and false for shell installers. The user -is able to change the default during interactive installation. NOTE: For Windows, -`AddToPath` is disabled when `InstallationType=AllUsers`. +Default value for the option added by `initialize_conda`. The default is +true for PKG installers, and false for EXE and SH shell installers. +The user is able to change the default during interactive installations. +Non-interactive installations are not affected by this value: users must explicitly request +to add to `PATH` via CLI options. +NOTE: For Windows, `/AddToPath` is disabled when `/InstallationType=AllUsers`. Only applies if `initialize_conda` is not false. ### `register_python` -Whether to offer the user an option to register the installed Python instance as the -system's default Python. (Windows only) +If the installer installs a Python instance, offer the user an option to register the installed Python instance as the +system's default Python. Defaults to `true` for GUI and `false` for CLI installations. (Windows only) ### `register_python_default` diff --git a/HOW_WE_USE_GITHUB.md b/HOW_WE_USE_GITHUB.md index 88aced25a..51289afdd 100644 --- a/HOW_WE_USE_GITHUB.md +++ b/HOW_WE_USE_GITHUB.md @@ -39,6 +39,8 @@ This document seeks to outline how we as a community use GitHub Issues to track - [What is "Issue Sorting"?](#what-is-issue-sorting) - [Issue Sorting Procedures](#issue-sorting-procedures) + - [Development Processes](#development-processes) + - [Code Review and Merging](#code-review-and-merging) - [Commit Signing](#commit-signing) - [Types of Issues](#types-of-issues) - [Standard Issue](#standard-issue) @@ -265,21 +267,92 @@ Community support can be found elsewhere, though, and we encourage you to explor In order to not have to manually type or copy/paste the above repeatedly, note that it's possible to add text for the most commonly-used responses via [GitHub's "Add Saved Reply" option][docs-saved-reply]. -## Commit Signing +## Development Processes -For all maintainers, we require commit signing and strongly recommend it for all others wishing to contribute. More information about how to set this up within GitHub can be found here: +The following are practices the conda organization encourages for feature +development. While we recommend projects under the conda organization adopt +these practices, they are not strictly required. -- [GitHub's signing commits docs][docs-commit-signing] +### How should we approach feature development? + +For new features, first open an issue if one doesn’t exist. Once the feature request +has been accepted (indicated by the issue's status transitioning from "Sorting" to +"Refinement"), create a specification to gather early feedback. This can include +mockups, API/command references, a written plan in the issue, and sample CLI +arguments (without functionality). + +### What is our change process? + +For larger features, break down the work into smaller, manageable issues +that are added to the backlog. As long as a feature remains on the roadmap +or backlog, do not create long-lived feature branches that span multiple +pull requests. Instead, you should integrate small slices of an overall +feature directly into the main branch to avoid complex integration challenges. + +### Should we make unrelated changes at the same time? + +When making changes, try to follow the Campsite Rule to leave things better +than when you found them. You should enhance the code you encounter, even if +primary goal is unrelated. This could involve refactoring small sections, +improving readability, or fixing minor bugs. + +## Code Review and Merging + +### What are the review requirements? + +#### Standard Review + +Most code changes require one reviewer from someone on the maintainer team for +the repository. Instead of waiting for someone on the team to review it, +directly requesting a review from the person you previously identified to work +with is preferred to optimize teamwork. If you paired with them during +development, continuous review counts as this requirement. + +#### Second Review + +Required only when the code author or the first reviewer feels like it is +necessary to get another set of eyes on a proposed change. In this case, they +add someone specific through GitHub's Request Review feature with a comment on +what they want the person to look for. + +### What are the code review best practices? + +If you are conducting a review, adhere to these best practices: + +- Provide comprehensive feedback in the first review to minimize review rounds +- Reserve Request Changes for blocking issues (bugs or other major problems) — + Select Comment for suggestions and improvements +- Follow-up reviews should focus on whether requested changes resolve original + comments +- Code should be production-ready and maintainable when merged, but doesn't + need to be perfect +- If providing feedback outside the core review focus (nitpicks, tips, + suggestions), clearly mark these as non-blocking comments that don't need to + be addressed before merging. + +### How do we merge code? + +If you are the approving reviewer (typically the first reviewer, or the second +reviewer when needed) and you have completed your review and approved the +changes, you should merge the code immediately to maintain development +velocity. + +Normally, we use squash and merge to keep a clean git history. If you are +merging a pull request, help ensure that the pull request title is updated. ## Types of Issues ### Standard Issue -TODO +Standard issues represent typical bug reports, feature requests, or other work +items that have a clear definition and expected outcome. ### Epics -TODO +Epics are large work items that can be broken down into smaller, more +manageable issues. They typically represent major features or significant +changes that span multiple iterations or releases. Relate the smaller +issues to the epic using the sub-issues feature in GitHub. ### Spikes diff --git a/constructor/_schema.py b/constructor/_schema.py index c77a00cf2..4ea916ad7 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -40,6 +40,7 @@ class WinSignTools(StrEnum): class InstallerTypes(StrEnum): ALL = "all" EXE = "exe" + MSI = "msi" PKG = "pkg" SH = "sh" @@ -188,13 +189,13 @@ class LicensesBuildOutput(BaseModel): licenses: _LicensesBuildOutputOptions -BuildOutputConfigs: TypeAlias = Union[ - HashBuildOutput, - InfoJsonBuildOutput, - PkgsListBuildOutput, - LockfileBuildOutput, - LicensesBuildOutput, -] +BuildOutputConfigs: TypeAlias = ( + HashBuildOutput + | InfoJsonBuildOutput + | PkgsListBuildOutput + | LockfileBuildOutput + | LicensesBuildOutput +) class ConstructorConfiguration(BaseModel): @@ -401,6 +402,7 @@ class ConstructorConfiguration(BaseModel): - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS + - `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -484,8 +486,11 @@ class ConstructorConfiguration(BaseModel): reverse_domain_identifier: NonEmptyStr | None = None """ Unique identifier for this package, formatted with reverse domain notation. This is - used internally in the PKG installers to handle future updates and others. If not - provided, it will default to `io.continuum`. (MacOS only) + used internally in the MSI and PKG installers to handle future updates and others. + If not provided, it will default to: + + * In MSI installers: `io.continuum` followed by an ID derived from the `name`. + * In PKG installers: `io.continuum`. """ uninstall_name: NonEmptyStr | None = None """ @@ -610,7 +615,7 @@ class ConstructorConfiguration(BaseModel): Internally, this is passed to `pkgbuild --install-location`. macOS only. """ - pkg_domains: dict[PkgDomains, bool] = {"enable_anywhere": True, "enable_currentUserHome": False} + pkg_domains: dict[PkgDomains, bool] = {"enable_anywhere": True, "enable_currentUserHome": True} """ The domains the package can be installed into. For a detailed explanation, see: https://developer.apple.com/library/archive/documentation/DeveloperTools/Reference/DistributionDefinitionRef/Chapters/Distribution_XML_Ref.html @@ -692,17 +697,19 @@ class ConstructorConfiguration(BaseModel): """ initialize_by_default: bool | None = None """ - Default value for the option added by `initialize_conda`. The default - is true for GUI installers (EXE, PKG) and false for shell installers. The user - is able to change the default during interactive installation. NOTE: For Windows, - `AddToPath` is disabled when `InstallationType=AllUsers`. + Default value for the option added by `initialize_conda`. The default is + true for PKG installers, and false for EXE and SH shell installers. + The user is able to change the default during interactive installations. + Non-interactive installations are not affected by this value: users must explicitly request + to add to `PATH` via CLI options. + NOTE: For Windows, `/AddToPath` is disabled when `/InstallationType=AllUsers`. Only applies if `initialize_conda` is not false. """ register_python: bool = True """ - Whether to offer the user an option to register the installed Python instance as the - system's default Python. (Windows only) + If the installer installs a Python instance, offer the user an option to register the installed Python instance as the + system's default Python. Defaults to `true` for GUI and `false` for CLI installations. (Windows only) """ register_python_default: bool | None = False """ diff --git a/constructor/briefcase.py b/constructor/briefcase.py new file mode 100644 index 000000000..2f659c0ef --- /dev/null +++ b/constructor/briefcase.py @@ -0,0 +1,225 @@ +""" +Logic to build installers using Briefcase. +""" + +import logging +import re +import shutil +import sys +import sysconfig +import tempfile +from pathlib import Path +from subprocess import run + +IS_WINDOWS = sys.platform == "win32" +if IS_WINDOWS: + import tomli_w +else: + tomli_w = None # This file is only intended for Windows use + +from . import preconda +from .utils import DEFAULT_REVERSE_DOMAIN_ID, copy_conda_exe, filename_dist + +BRIEFCASE_DIR = Path(__file__).parent / "briefcase" +EXTERNAL_PACKAGE_PATH = "external" + +# Default to a low version, so that if a valid version is provided in the future, it'll +# be treated as an upgrade. +DEFAULT_VERSION = "0.0.1" + +logger = logging.getLogger(__name__) + + +def get_name_version(info): + if not (name := info.get("name")): + raise ValueError("Name is empty") + if not (version := info.get("version")): + raise ValueError("Version is empty") + + # Briefcase requires version numbers to be in the canonical Python format, and some + # installer types use the version to distinguish between upgrades, downgrades and + # reinstalls. So try to produce a consistent ordering by extracting the last valid + # version from the Constructor version string. + # + # Hyphens aren't allowed in this format, but for compatibility with Miniconda's + # version format, we treat them as dots. + matches = list( + re.finditer( + r"(\d+!)?\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?", + version.lower().replace("-", "."), + ) + ) + if not matches: + logger.warning( + f"Version {version!r} contains no valid version numbers; " + f"defaulting to {DEFAULT_VERSION}" + ) + return f"{name} {version}", DEFAULT_VERSION + + match = matches[-1] + version = match.group() + + # Treat anything else in the version string as part of the name. + start, end = match.span() + strip_chars = " .-_" + before = info["version"][:start].strip(strip_chars) + after = info["version"][end:].strip(strip_chars) + name = " ".join(s for s in [name, before, after] if s) + + return name, version + + +# Takes an arbitrary string with at least one alphanumeric character, and makes it into +# a valid Python package name. +def make_app_name(name, source): + app_name = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + if not app_name: + raise ValueError(f"{source} contains no alphanumeric characters") + return app_name + + +# Some installer types use the reverse domain ID to detect when the product is already +# installed, so it should be both unique between different products, and stable between +# different versions of a product. +def get_bundle_app_name(info, name): + # If reverse_domain_identifier is provided, use it as-is, + if (rdi := info.get("reverse_domain_identifier")) is not None: + if "." not in rdi: + raise ValueError(f"reverse_domain_identifier {rdi!r} contains no dots") + bundle, app_name = rdi.rsplit(".", 1) + + # Ensure that the last component is a valid Python package name, as Briefcase + # requires. + if not re.fullmatch( + r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", app_name, flags=re.IGNORECASE + ): + app_name = make_app_name( + app_name, f"Last component of reverse_domain_identifier {rdi!r}" + ) + + # If reverse_domain_identifier isn't provided, generate it from the name. + else: + bundle = DEFAULT_REVERSE_DOMAIN_ID + app_name = make_app_name(name, f"Name {name!r}") + + return bundle, app_name + + +def get_license(info): + """Retrieve the specified license as a dict or return a placeholder if not set.""" + + if "license_file" in info: + return {"file": info["license_file"]} + + placeholder_license = Path(__file__).parent / "nsis" / "placeholder_license.txt" + return {"file": str(placeholder_license)} # convert to str for TOML serialization + + +def create_install_options_list(info: dict) -> list[dict]: + """Returns a list of dicts with data formatted for the installation options page.""" + options = [] + register_python = info.get("register_python", True) + if register_python: + python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + options.append( + { + "name": "register_python", + "title": f"Register {info['name']} as my default Python {python_version}.", + "description": "Allows other programs, such as VSCode, PyCharm, etc. to automatically " + f"detect {info['name']} as the primary Python {python_version} on the system.", + "default": info.get("register_python_default", False), + } + ) + initialize_conda = info.get("initialize_conda", "classic") + if initialize_conda: + if initialize_conda == "condabin": + description = "Adds condabin, which only contains the 'conda' executables, to PATH. " + "Does not require special shortcuts but activation needs " + "to be performed manually." + else: + description = "NOT recommended. This can lead to conflicts with other applications. " + "Instead, use the Commmand Prompt and Powershell menus added to the Windows Start Menu." + options.append( + { + "name": "initialize_conda", + "title": "Add installation to my PATH environment variable", + "description": description, + "default": info.get("initialize_by_default", False), + } + ) + + return options + + +# Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja +# template allows us to avoid escaping strings everywhere. +def write_pyproject_toml(tmp_dir, info): + name, version = get_name_version(info) + bundle, app_name = get_bundle_app_name(info, name) + + config = { + "project_name": name, + "bundle": bundle, + "version": version, + "license": get_license(info), + "app": { + app_name: { + "formal_name": f"{info['name']} {info['version']}", + "description": "", # Required, but not used in the installer. + "external_package_path": EXTERNAL_PACKAGE_PATH, + "use_full_install_path": False, + "install_launcher": False, + "post_install_script": str(BRIEFCASE_DIR / "run_installation.bat"), + "install_option": create_install_options_list(info), + } + }, + } + + if "company" in info: + config["author"] = info["company"] + + (tmp_dir / "pyproject.toml").write_text(tomli_w.dumps({"tool": {"briefcase": config}})) + + +def create(info, verbose=False): + if not IS_WINDOWS: + raise Exception(f"Invalid platform '{sys.platform}'. Only Windows is supported.") + + tmp_dir = Path(tempfile.mkdtemp()) + write_pyproject_toml(tmp_dir, info) + + external_dir = tmp_dir / EXTERNAL_PACKAGE_PATH + external_dir.mkdir() + preconda.write_files(info, external_dir) + preconda.copy_extra_files(info.get("extra_files", []), external_dir) + + download_dir = Path(info["_download_dir"]) + pkgs_dir = external_dir / "pkgs" + for dist in info["_dists"]: + shutil.copy(download_dir / filename_dist(dist), pkgs_dir) + + copy_conda_exe(external_dir, "_conda.exe", info["_conda_exe"]) + + briefcase = Path(sysconfig.get_path("scripts")) / "briefcase.exe" + if not briefcase.exists(): + raise FileNotFoundError( + f"Dependency 'briefcase' does not seem to be installed.\nTried: {briefcase}" + ) + logger.info("Building installer") + run( + [briefcase, "package"] + (["-v"] if verbose else []), + cwd=tmp_dir, + check=True, + ) + + dist_dir = tmp_dir / "dist" + msi_paths = list(dist_dir.glob("*.msi")) + if len(msi_paths) != 1: + raise RuntimeError(f"Found {len(msi_paths)} MSI files in {dist_dir}") + + outpath = Path(info["_outpath"]) + outpath.unlink(missing_ok=True) + shutil.move(msi_paths[0], outpath) + + if not info.get("_debug"): + shutil.rmtree(tmp_dir) diff --git a/constructor/briefcase/run_installation.bat b/constructor/briefcase/run_installation.bat new file mode 100644 index 000000000..267907bec --- /dev/null +++ b/constructor/briefcase/run_installation.bat @@ -0,0 +1,10 @@ +set "PREFIX=%cd%" +_conda constructor --prefix "%PREFIX%" --extract-conda-pkgs + +set CONDA_PROTECT_FROZEN_ENVS=0 +set CONDA_ROOT_PREFIX=%PREFIX% +set CONDA_SAFETY_CHECKS=disabled +set CONDA_EXTRA_SAFETY_CHECKS=no +set CONDA_PKGS_DIRS=%PREFIX%\pkgs + +_conda install --offline --file "%PREFIX%\conda-meta\initial-state.explicit.txt" -yp "%PREFIX%" diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 5914be40e..09e860f6e 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -224,6 +224,7 @@ "enum": [ "all", "exe", + "msi", "pkg", "sh" ], @@ -750,7 +751,7 @@ } ], "default": null, - "description": "Default value for the option added by `initialize_conda`. The default is true for GUI installers (EXE, PKG) and false for shell installers. The user is able to change the default during interactive installation. NOTE: For Windows, `AddToPath` is disabled when `InstallationType=AllUsers`.\nOnly applies if `initialize_conda` is not false.", + "description": "Default value for the option added by `initialize_conda`. The default is true for PKG installers, and false for EXE and SH shell installers. The user is able to change the default during interactive installations. Non-interactive installations are not affected by this value: users must explicitly request to add to `PATH` via CLI options. NOTE: For Windows, `/AddToPath` is disabled when `/InstallationType=AllUsers`.\nOnly applies if `initialize_conda` is not false.", "title": "Initialize By Default" }, "initialize_conda": { @@ -824,7 +825,7 @@ } ], "default": null, - "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", + "description": "The type of the installer being created. Possible values are:\n- `sh`: shell-based installer for Linux or macOS\n- `pkg`: macOS GUI installer built with Apple's `pkgbuild`\n- `exe`: Windows GUI installer built with NSIS\n- `msi`: Windows GUI installer built with Briefcase and WiX\nThe default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well as `sh` on Linux and `exe` on Windows.", "title": "Installer Type" }, "keep_pkgs": { @@ -921,7 +922,7 @@ }, "default": { "enable_anywhere": true, - "enable_currentUserHome": false + "enable_currentUserHome": true }, "description": "The domains the package can be installed into. For a detailed explanation, see: https://developer.apple.com/library/archive/documentation/DeveloperTools/Reference/DistributionDefinitionRef/Chapters/Distribution_XML_Ref.html constructor defaults to `enable_anywhere=true` and `enable_currentUserHome=true`. `enable_localSystem` should not be set to true unless `default_location_pkg` is set as well. macOS only.", "propertyNames": { @@ -1076,7 +1077,7 @@ }, "register_python": { "default": true, - "description": "Whether to offer the user an option to register the installed Python instance as the system's default Python. (Windows only)", + "description": "If the installer installs a Python instance, offer the user an option to register the installed Python instance as the system's default Python. Defaults to `true` for GUI and `false` for CLI installations. (Windows only)", "title": "Register Python", "type": "boolean" }, @@ -1104,7 +1105,7 @@ } ], "default": null, - "description": "Unique identifier for this package, formatted with reverse domain notation. This is used internally in the PKG installers to handle future updates and others. If not provided, it will default to `io.continuum`. (MacOS only)", + "description": "Unique identifier for this package, formatted with reverse domain notation. This is used internally in the MSI and PKG installers to handle future updates and others. If not provided, it will default to:\n* In MSI installers: `io.continuum` followed by an ID derived from the `name`. * In PKG installers: `io.continuum`.", "title": "Reverse Domain Identifier" }, "script_env_variables": { diff --git a/constructor/fcp.py b/constructor/fcp.py index 45b93bcd1..4ad7f53ca 100644 --- a/constructor/fcp.py +++ b/constructor/fcp.py @@ -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: ", environment) @@ -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") @@ -392,6 +390,7 @@ def _main( extra_envs=None, check_path_spaces=True, input_dir="", + base_needs_python=True, ): precs = _solve_precs( name, @@ -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) @@ -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") @@ -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 diff --git a/constructor/header.sh b/constructor/header.sh index 81cf0bf2e..e560b5be4 100644 --- a/constructor/header.sh +++ b/constructor/header.sh @@ -10,10 +10,9 @@ set -eu {%- if osx %} -unset DYLD_LIBRARY_PATH DYLD_FALLBACK_LIBRARY_PATH +unset DYLD_LIBRARY_PATH DYLD_FALLBACK_LIBRARY_PATH DYLD_INSERT_LIBRARIES DYLD_FRAMEWORK_PATH {%- else %} -export OLD_LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" -unset LD_LIBRARY_PATH +unset LD_LIBRARY_PATH LD_PRELOAD LD_AUDIT {%- endif %} if ! echo "$0" | grep '\.sh$' > /dev/null; then @@ -22,49 +21,53 @@ if ! echo "$0" | grep '\.sh$' > /dev/null; then fi {%- if osx and min_osx_version %} -min_osx_version="{{ min_osx_version }}" -system_osx_version="${CONDA_OVERRIDE_OSX:-$(SYSTEM_VERSION_COMPAT=0 sw_vers -productVersion)}" -# shellcheck disable=SC2183 disable=SC2046 -int_min_osx_version="$(printf "%02d%02d%02d" $(echo "$min_osx_version" | sed 's/\./ /g'))" -# shellcheck disable=SC2183 disable=SC2046 -int_system_osx_version="$(printf "%02d%02d%02d" $(echo "$system_osx_version" | sed 's/\./ /g'))" -if [ "$int_system_osx_version" -lt "$int_min_osx_version" ]; then - echo "Installer requires macOS >=${min_osx_version}, but system has ${system_osx_version}." - exit 1 +if [ "$(uname)" = "Darwin" ]; then + min_osx_version="{{ min_osx_version }}" + system_osx_version="${CONDA_OVERRIDE_OSX:-$(SYSTEM_VERSION_COMPAT=0 sw_vers -productVersion)}" + # shellcheck disable=SC2183 disable=SC2046 + int_min_osx_version="$(printf "%02d%02d%02d" $(echo "$min_osx_version" | sed 's/\./ /g'))" + # shellcheck disable=SC2183 disable=SC2046 + int_system_osx_version="$(printf "%02d%02d%02d" $(echo "$system_osx_version" | sed 's/\./ /g'))" + if [ "$int_system_osx_version" -lt "$int_min_osx_version" ]; then + echo "Installer requires macOS >=${min_osx_version}, but system has ${system_osx_version}." + exit 1 + fi fi {%- elif linux and min_glibc_version %} -min_glibc_version="{{ min_glibc_version }}" -system_glibc_version="${CONDA_OVERRIDE_GLIBC:-}" -if [ "${system_glibc_version}" = "" ]; then - case "$(ldd --version 2>&1)" in - *musl*) - # musl ldd will report musl version; call libc.so directly - # see https://github.com/conda/constructor/issues/850#issuecomment-2343756454 - libc_so="$(find /lib /usr/local/lib /usr/lib -name 'libc.so.*' -print -quit 2>/dev/null)" - if [ -z "${libc_so}" ]; then - libc_so="$(strings /etc/ld.so.cache | grep '^/.*/libc\.so.*' | head -1)" - fi - if [ -z "${libc_so}" ]; then - echo "Warning: Couldn't find libc.so; won't be able to determine GLIBC version!" >&2 - echo "Override by setting CONDA_OVERRIDE_GLIBC" >&2 - system_glibc_version="0.0" - else - system_glibc_version=$("${libc_so}" --version | awk 'NR==1{ sub(/\.$/, ""); print $NF}') - fi - ;; - *) - # ldd reports glibc in the last field of the first line - system_glibc_version=$(ldd --version | awk 'NR==1{print $NF}') - ;; - esac -fi -# shellcheck disable=SC2183 disable=SC2046 -int_min_glibc_version="$(printf "%02d%02d%02d" $(echo "$min_glibc_version" | sed 's/\./ /g'))" -# shellcheck disable=SC2183 disable=SC2046 -int_system_glibc_version="$(printf "%02d%02d%02d" $(echo "$system_glibc_version" | sed 's/\./ /g'))" -if [ "$int_system_glibc_version" -lt "$int_min_glibc_version" ]; then - echo "Installer requires GLIBC >=${min_glibc_version}, but system has ${system_glibc_version}." - exit 1 +if [ "$(uname)" = "Linux" ]; then + min_glibc_version="{{ min_glibc_version }}" + system_glibc_version="${CONDA_OVERRIDE_GLIBC:-}" + if [ "${system_glibc_version}" = "" ]; then + case "$(ldd --version 2>&1)" in + *musl*) + # musl ldd will report musl version; call libc.so directly + # see https://github.com/conda/constructor/issues/850#issuecomment-2343756454 + libc_so="$(find /lib /usr/local/lib /usr/lib -name 'libc.so.*' -print -quit 2>/dev/null)" + if [ -z "${libc_so}" ]; then + libc_so="$(strings /etc/ld.so.cache | grep '^/.*/libc\.so.*' | head -1)" + fi + if [ -z "${libc_so}" ]; then + echo "Warning: Couldn't find libc.so; won't be able to determine GLIBC version!" >&2 + echo "Override by setting CONDA_OVERRIDE_GLIBC" >&2 + system_glibc_version="0.0" + else + system_glibc_version=$("${libc_so}" --version | awk 'NR==1{ sub(/\.$/, ""); print $NF}') + fi + ;; + *) + # ldd reports glibc in the last field of the first line + system_glibc_version=$(ldd --version | awk 'NR==1{print $NF}') + ;; + esac + fi + # shellcheck disable=SC2183 disable=SC2046 + int_min_glibc_version="$(printf "%02d%02d%02d" $(echo "$min_glibc_version" | sed 's/\./ /g'))" + # shellcheck disable=SC2183 disable=SC2046 + int_system_glibc_version="$(printf "%02d%02d%02d" $(echo "$system_glibc_version" | sed 's/\./ /g'))" + if [ "$int_system_glibc_version" -lt "$int_min_glibc_version" ]; then + echo "Installer requires GLIBC >=${min_glibc_version}, but system has ${system_glibc_version}." + exit 1 + fi fi {%- endif %} @@ -492,9 +495,15 @@ unset PYTHON_SYSCONFIGDATA_NAME _CONDA_PYTHON_SYSCONFIGDATA_NAME # the first binary payload: the standalone conda executable printf "Unpacking bootstrapper...\n" -CONDA_EXEC="$PREFIX/_conda" +CONDA_EXEC="$PREFIX/{{ conda_exe_name }}" extract_range "${boundary0}" "${boundary1}" > "$CONDA_EXEC" chmod +x "$CONDA_EXEC" + +{%- if conda_exe_name != "_conda" %} +# In case there are packages that depend on _conda +ln -s -f "$CONDA_EXEC" "$PREFIX"/_conda +{%- endif %} + {%- for filename, (start, end, executable) in conda_exe_payloads|items %} mkdir -p "$(dirname "$PREFIX/{{ filename }}")" {%- if start == end %} @@ -769,7 +778,7 @@ if [ "$BATCH" = "0" ]; then printf "Note: You can undo this later by running \`conda init --reverse \$SHELL\`\\n" printf "\\n" printf "Proceed with initialization? [yes|no]\\n" - printf "[%s] >>> " "$DEFAULT"„ + printf "[%s] >>> " "$DEFAULT" read -r ans if [ "$ans" = "" ]; then ans=$DEFAULT diff --git a/constructor/main.py b/constructor/main.py index d7b02e7ce..a1e3ae1c6 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -15,9 +15,11 @@ import json import logging import os +import subprocess import sys from os.path import abspath, expanduser, isdir, join from pathlib import Path +from tempfile import TemporaryDirectory from textwrap import dedent from . import __version__ @@ -38,7 +40,7 @@ def get_installer_type(info): osname, unused_arch = info["_platform"].split("-") - os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)} + os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe", "msi")} all_allowed = set(sum(os_allowed.values(), ("all",))) itype = info.get("installer_type") @@ -76,6 +78,33 @@ def get_output_filename(info): ) +def _conda_exe_supports_logging(conda_exe: str, conda_exe_type: StandaloneExe | None) -> bool: + """Test if the standalone binary supports the the --log-file argument. + + Only available for conda-standalone. + """ + if not conda_exe_type: + return False + with TemporaryDirectory() as tmpdir: + logfile = Path(tmpdir, "conda.log") + subprocess.run([conda_exe, "--version", f"--log-file={logfile}"]) + return logfile.exists() + + +def _win_install_needs_python_exe(conda_exe: str, conda_exe_type: StandaloneExe | None) -> bool: + if not conda_exe_type: + return True + 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=".", @@ -257,6 +286,11 @@ def is_conda_meta_frozen(path_str: str) -> bool: else: info["_ignore_condarcs_arg"] = "" + info["_conda_exe_supports_logging"] = _conda_exe_supports_logging( + info["_conda_exe"], + info["_conda_exe_type"], + ) + if "pkg" in itypes: if (domains := info.get("pkg_domains")) is not None: domains = {key: str(val).lower() for key, val in domains.items()} @@ -275,6 +309,12 @@ 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["_conda_exe_type"], + ) + info["installer_type"] = itypes[0] fcp_main(info, verbose=verbose, dry_run=dry_run, conda_exe=conda_exe) if dry_run: @@ -317,6 +357,10 @@ def is_conda_meta_frozen(path_str: str) -> bool: from .winexe import create as winexe_create create = winexe_create + elif itype == "msi": + from .briefcase import create as briefcase_create + + create = briefcase_create info["installer_type"] = itype info["_outpath"] = abspath(join(output_dir, get_output_filename(info))) create(info, verbose=verbose) diff --git a/constructor/nsis/OptionsDialog.nsh b/constructor/nsis/OptionsDialog.nsh index 0b5f15c6d..11c94e5a3 100644 --- a/constructor/nsis/OptionsDialog.nsh +++ b/constructor/nsis/OptionsDialog.nsh @@ -7,30 +7,90 @@ Var mui_AnaCustomOptions Var mui_AnaCustomOptions.AddToPath -Var mui_AnaCustomOptions.RegisterSystemPython Var mui_AnaCustomOptions.PostInstall Var mui_AnaCustomOptions.PreInstall Var mui_AnaCustomOptions.ClearPkgCache Var mui_AnaCustomOptions.CreateShortcuts # These are the checkbox states, to be used by the installer -Var Ana_AddToPath_State -Var Ana_RegisterSystemPython_State Var Ana_PostInstall_State Var Ana_PreInstall_State Var Ana_ClearPkgCache_State Var Ana_CreateShortcuts_State Var Ana_AddToPath_Label -Var Ana_RegisterSystemPython_Label Var Ana_ClearPkgCache_Label Var Ana_PostInstall_Label Var Ana_PreInstall_Label + +!if ${REGISTER_PYTHON_OPTION} == 1 + Var mui_AnaCustomOptions.RegisterSystemPython + Var Ana_RegisterSystemPython_Label + + Function RegisterSystemPython_OnClick + Pop $0 + + # Sync UI with variable + ${NSD_GetState} $0 $1 + ${If} $1 == ${BST_CHECKED} + StrCpy $REG_PY 1 + ${Else} + StrCpy $REG_PY 0 + ${EndIf} + + ShowWindow $Ana_RegisterSystemPython_Label ${SW_HIDE} + ${If} $REG_PY == 1 + SetCtlColors $Ana_RegisterSystemPython_Label ff0000 transparent + ${Else} + SetCtlColors $Ana_RegisterSystemPython_Label 000000 transparent + ${EndIf} + ShowWindow $Ana_RegisterSystemPython_Label ${SW_SHOW} + + # If the button was checked, make sure we're not conflicting + # with another system installed Python + ${If} $REG_PY == 1 + # Check if a Python of the version we're installing + # already exists, in which case warn the user before + # proceeding. + ReadRegStr $2 SHCTX "Software\Python\PythonCore\${PY_VER}\InstallPath" "" + ${If} "$2" != "" + ${AndIf} ${FileExists} "$2\Python.exe" + MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "A version of Python ${PY_VER} (${ARCH}) is already at$\n\ + $2$\n\ + We recommend that if you want ${NAME} registered as your $\n\ + system Python, you unregister this Python first. If you really$\n\ + know this is what you want, click OK, otherwise$\n\ + click cancel to continue.$\n$\n\ + NOTE: Anaconda 1.3 and earlier lacked an uninstall, if$\n\ + you are upgrading an old Anaconda, please delete the$\n\ + directory manually." \ + IDOK KeepSettingLabel + # If they don't click OK, uncheck it + StrCpy $REG_PY 0 + ${NSD_Uncheck} $0 + + KeepSettingLabel: + + ${EndIf} + ${EndIf} + FunctionEnd +!endif + Function mui_AnaCustomOptions_InitDefaults - # Initialize defaults - ${If} $Ana_AddToPath_State == "" - StrCpy $Ana_AddToPath_State ${BST_UNCHECKED} + # AddToPath / conda init default + !if ${INIT_CONDA_OPTION} == 1 + # Ensure we initialize from compile-time default value + StrCpy $INIT_CONDA ${INIT_CONDA_DEFAULT_VALUE} + !else + StrCpy $INIT_CONDA 0 + !endif + + # Register Python default while accounting for existing installations + !if ${REGISTER_PYTHON_OPTION} == 1 + # Ensure we initialize from compile-time default value + StrCpy $REG_PY ${REGISTER_PYTHON_DEFAULT_VALUE} # Default whether to register as system python as: # Enabled - if no system python is registered, OR # a system python which does not exist is registered. @@ -38,11 +98,13 @@ Function mui_AnaCustomOptions_InitDefaults ReadRegStr $2 SHCTX "Software\Python\PythonCore\${PY_VER}\InstallPath" "" ${If} "$2" != "" ${AndIf} ${FileExists} "$2\Python.exe" - StrCpy $Ana_RegisterSystemPython_State ${BST_UNCHECKED} - ${Else} - StrCpy $Ana_RegisterSystemPython_State ${BST_CHECKED} + StrCpy $REG_PY 0 ${EndIf} - ${EndIf} + !else + StrCpy $REG_PY 0 + !endif + + # Shortcuts defaults ${If} $Ana_CreateShortcuts_State == "" ${If} "${ENABLE_SHORTCUTS}" == "yes" StrCpy $Ana_CreateShortcuts_State ${BST_CHECKED} @@ -57,7 +119,7 @@ FunctionEnd Function mui_AnaCustomOptions_Show ; Enforce that the defaults were initialized - ${If} $Ana_AddToPath_State == "" + ${If} $INIT_CONDA == "" Abort ${EndIf} @@ -84,7 +146,7 @@ Function mui_AnaCustomOptions_Show ${NSD_OnClick} $mui_AnaCustomOptions.CreateShortcuts CreateShortcuts_OnClick ${EndIf} - ${If} "${SHOW_ADD_TO_PATH}" != "no" + !if ${INIT_CONDA_OPTION} == 1 # AddToPath is only an option for JustMe installations; it is disabled for AllUsers # installations. (Addresses CVE-2022-26526) ${If} $InstMode = ${JUST_ME} @@ -92,9 +154,18 @@ Function mui_AnaCustomOptions_Show environment variable" IntOp $5 $5 + 11 Pop $mui_AnaCustomOptions.AddToPath - ${NSD_SetState} $mui_AnaCustomOptions.AddToPath $Ana_AddToPath_State + + # Set state of check-box + ${If} $INIT_CONDA == 1 + ${NSD_Check} $mui_AnaCustomOptions.AddToPath + ${Else} + ${NSD_Uncheck} $mui_AnaCustomOptions.AddToPath + ${EndIf} + ${NSD_OnClick} $mui_AnaCustomOptions.AddToPath AddToPath_OnClick - ${If} "${SHOW_ADD_TO_PATH}" == "condabin" + + # Account for the conda mode + ${If} "${INIT_CONDA_MODE}" == "condabin" ${NSD_CreateLabel} 5% "$5u" 90% 20u \ "Adds condabin/, which only contains the 'conda' executables, to PATH. \ Does not require special shortcuts but activation needs \ @@ -107,26 +178,54 @@ Function mui_AnaCustomOptions_Show ${EndIf} IntOp $5 $5 + 20 Pop $Ana_AddToPath_Label + + # Color the label if needed; even if the user has not interacted with the checkbox yet + ${If} $INIT_CONDA = 1 + ${If} "${INIT_CONDA_MODE}" == "classic" + SetCtlColors $Ana_AddToPath_Label ff0000 transparent + ${Else} + # Here INIT_CONDA_MODE equals condabin + SetCtlColors $Ana_AddToPath_Label 000000 transparent + ${EndIf} + ${Else} + SetCtlColors $Ana_AddToPath_Label 000000 transparent + ${EndIf} ${EndIf} - ${EndIf} + !endif - ${If} "${SHOW_REGISTER_PYTHON}" == "yes" + !if ${REGISTER_PYTHON_OPTION} == 1 ${If} $InstMode = ${JUST_ME} StrCpy $1 "my default" ${Else} StrCpy $1 "the system" ${EndIf} + ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "&Register ${NAME} as $1 Python ${PY_VER}" IntOp $5 $5 + 11 Pop $mui_AnaCustomOptions.RegisterSystemPython - ${NSD_SetState} $mui_AnaCustomOptions.RegisterSystemPython $Ana_RegisterSystemPython_State + + # Set state of check-box + ${If} $REG_PY == 1 + ${NSD_Check} $mui_AnaCustomOptions.RegisterSystemPython + ${Else} + ${NSD_Uncheck} $mui_AnaCustomOptions.RegisterSystemPython + ${EndIf} + ${NSD_OnClick} $mui_AnaCustomOptions.RegisterSystemPython RegisterSystemPython_OnClick + ${NSD_CreateLabel} 5% "$5u" 90% 20u \ "Allows other programs, such as VSCode, PyCharm, etc. to automatically \ detect ${NAME} as the primary Python ${PY_VER} on the system." IntOp $5 $5 + 20 Pop $Ana_RegisterSystemPython_Label - ${EndIf} + + # Color the label if needed; even if the user has not interacted with the checkbox yet + ${If} $REG_PY = 1 + SetCtlColors $Ana_RegisterSystemPython_Label ff0000 transparent + ${Else} + SetCtlColors $Ana_RegisterSystemPython_Label 000000 transparent + ${EndIf} + !endif ${NSD_CreateCheckbox} 0 "$5u" 100% 11u "Clear the package cache upon completion" @@ -167,58 +266,24 @@ FunctionEnd Function AddToPath_OnClick Pop $0 - ShowWindow $Ana_AddToPath_Label ${SW_HIDE} - ${NSD_GetState} $0 $Ana_AddToPath_State - ${If} $Ana_AddToPath_State == ${BST_UNCHECKED} - ${Else} - ${If} "${SHOW_ADD_TO_PATH}" == "condabin" - SetCtlColors $Ana_AddToPath_Label 000000 transparent - ${Else} - SetCtlColors $Ana_AddToPath_Label ff0000 transparent - ${EndIf} - ${EndIf} - ShowWindow $Ana_AddToPath_Label ${SW_SHOW} -FunctionEnd - -Function RegisterSystemPython_OnClick - Pop $0 - - ShowWindow $Ana_RegisterSystemPython_Label ${SW_HIDE} - ${NSD_GetState} $0 $Ana_RegisterSystemPython_State - ${If} $Ana_RegisterSystemPython_State == ${BST_UNCHECKED} - SetCtlColors $Ana_RegisterSystemPython_Label ff0000 transparent + # Sync UI with variable + ${NSD_GetState} $0 $1 + ${If} $1 == ${BST_CHECKED} + StrCpy $INIT_CONDA 1 ${Else} - SetCtlColors $Ana_RegisterSystemPython_Label 000000 transparent + StrCpy $INIT_CONDA 0 ${EndIf} - ShowWindow $Ana_RegisterSystemPython_Label ${SW_SHOW} - - # If the button was checked, make sure we're not conflicting - # with another system installed Python - ${If} $Ana_RegisterSystemPython_State == ${BST_CHECKED} - # Check if a Python of the version we're installing - # already exists, in which case warn the user before - # proceeding. - ReadRegStr $2 SHCTX "Software\Python\PythonCore\${PY_VER}\InstallPath" "" - ${If} "$2" != "" - ${AndIf} ${FileExists} "$2\Python.exe" - MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ - "A version of Python ${PY_VER} (${ARCH}) is already at$\n\ - $2$\n\ - We recommend that if you want ${NAME} registered as your $\n\ - system Python, you unregister this Python first. If you really$\n\ - know this is what you want, click OK, otherwise$\n\ - click cancel to continue.$\n$\n\ - NOTE: Anaconda 1.3 and earlier lacked an uninstall, if$\n\ - you are upgrading an old Anaconda, please delete the$\n\ - directory manually." \ - IDOK KeepSettingLabel - # If they don't click OK, uncheck it - StrCpy $Ana_RegisterSystemPython_State ${BST_UNCHECKED} - ${NSD_SetState} $0 $Ana_RegisterSystemPython_State -KeepSettingLabel: + ShowWindow $Ana_AddToPath_Label ${SW_HIDE} + # Only color it red if it's classic + ${If} $INIT_CONDA == 1 + ${If} "${INIT_CONDA_MODE}" == "classic" + SetCtlColors $Ana_AddToPath_Label ff0000 transparent ${EndIf} + ${Else} + SetCtlColors $Ana_AddToPath_Label 000000 transparent ${EndIf} + ShowWindow $Ana_AddToPath_Label ${SW_SHOW} FunctionEnd Function PostInstall_OnClick diff --git a/constructor/nsis/Utils.nsh b/constructor/nsis/Utils.nsh index 986bf4344..d7ecd1ea0 100644 --- a/constructor/nsis/Utils.nsh +++ b/constructor/nsis/Utils.nsh @@ -1,128 +1,5 @@ # Miscellaneous helpers. -# We're not using RIndexOf at the moment, so ifdef it out for now (which -# prevents the compiler warnings about an unused function). -!ifdef INDEXOF -Function IndexOf - Exch $R0 - Exch - Exch $R1 - Push $R2 - Push $R3 - - StrCpy $R3 $R0 - StrCpy $R0 -1 - IntOp $R0 $R0 + 1 - - StrCpy $R2 $R3 1 $R0 - StrCmp $R2 "" +2 - StrCmp $R2 $R1 +2 -3 - - StrCpy $R0 -1 - - Pop $R3 - Pop $R2 - Pop $R1 - Exch $R0 -FunctionEnd - -!macro IndexOf Var Str Char - Push "${Char}" - Push "${Str}" - Call IndexOf - Pop "${Var}" - !macroend -!define IndexOf "!insertmacro IndexOf" - -Function RIndexOf - Exch $R0 - Exch - Exch $R1 - Push $R2 - Push $R3 - - StrCpy $R3 $R0 - StrCpy $R0 0 - IntOp $R0 $R0 + 1 - StrCpy $R2 $R3 1 -$R0 - StrCmp $R2 "" +2 - StrCmp $R2 $R1 +2 -3 - - StrCpy $R0 -1 - - Pop $R3 - Pop $R2 - Pop $R1 - Exch $R0 -FunctionEnd - -!macro RIndexOf Var Str Char - Push "${Char}" - Push "${Str}" - Call RIndexOf - Pop "${Var}" -!macroend - -!define RIndexOf "!insertmacro RIndexOf" -!endif - -!macro StrStr - Exch $R1 ; st=haystack,old$R1, $R1=needle - Exch ; st=old$R1,haystack - Exch $R2 ; st=old$R1,old$R2, $R2=haystack - Push $R3 - Push $R4 - Push $R5 - StrLen $R3 $R1 - StrCpy $R4 0 - ; $R1=needle - ; $R2=haystack - ; $R3=len(needle) - ; $R4=cnt - ; $R5=tmp - loop: - StrCpy $R5 $R2 $R3 $R4 - StrCmp $R5 $R1 done - StrCmp $R5 "" done - IntOp $R4 $R4 + 1 - Goto loop - done: - StrCpy $R1 $R2 "" $R4 - Pop $R5 - Pop $R4 - Pop $R3 - Pop $R2 - Exch $R1 -!macroend - -!macro GetShortPathName - Pop $0 - # Return the 8.3 short path name for $0. We ensure $0 exists by calling - # SetOutPath first (kernel32::GetShortPathName() fails otherwise). - SetOutPath $0 - Push $0 - Push ' ' - Call StrStr - Pop $1 - ${If} $1 != "" - # Our installation directory has a space, so use the short name from - # here in. (This ensures no directories with spaces are written to - # registry values or configuration files.) After GetShortPathName(), - # $0 will have the new name and $1 will have the length (if it's 0, - # assume an error occurred and leave $INSTDIR as it is). - System::Call "kernel32::GetShortPathName(\ - t'$RootDir', \ - t.R0, \ - i${NSIS_MAX_STRLEN}) i.R1" - - ${If} $R1 > 0 - Push $R0 - ${EndIf} - ${Else} - Push $0 - ${EndIf} -!macroend - ; Slightly modified version of http://nsis.sourceforge.net/IsWritable Function IsWritable !define IsWritable `!insertmacro IsWritableCall` diff --git a/constructor/nsis/_nsis.py b/constructor/nsis/_nsis.py index 82b872d21..a7e260308 100644 --- a/constructor/nsis/_nsis.py +++ b/constructor/nsis/_nsis.py @@ -8,14 +8,8 @@ # be tested in an installation. import os -import re import sys -from os.path import exists, isfile, join - -try: - import winreg -except ImportError: - import _winreg as winreg +from os.path import exists, join ROOT_PREFIX = sys.prefix @@ -41,114 +35,7 @@ def gui_excepthook(exctype, value, tb): sys.excepthook = gui_excepthook -# If pythonw is being run, there may be no write function -if sys.stdout and sys.stdout.write: - out = sys.stdout.write - err = sys.stderr.write -else: - import ctypes - OutputDebugString = ctypes.windll.kernel32.OutputDebugStringW - OutputDebugString.argtypes = [ctypes.c_wchar_p] - - def out(x): - OutputDebugString('_nsis.py: ' + x) - - def err(x): - OutputDebugString('_nsis.py: Error: ' + x) - - -class NSISReg: - def __init__(self, reg_path): - self.reg_path = reg_path - if exists(join(ROOT_PREFIX, '.nonadmin')): - self.main_key = winreg.HKEY_CURRENT_USER - else: - self.main_key = winreg.HKEY_LOCAL_MACHINE - - def set(self, name, value): - try: - winreg.CreateKey(self.main_key, self.reg_path) - registry_key = winreg.OpenKey(self.main_key, self.reg_path, 0, - winreg.KEY_WRITE) - winreg.SetValueEx(registry_key, name, 0, winreg.REG_SZ, value) - winreg.CloseKey(registry_key) - return True - except WindowsError: - return False - - def get(self, name): - try: - registry_key = winreg.OpenKey(self.main_key, self.reg_path, 0, - winreg.KEY_READ) - value, regtype = winreg.QueryValueEx(registry_key, name) - winreg.CloseKey(registry_key) - return value - except WindowsError: - return None - - -def mk_dirs(): - envs_dir = join(ROOT_PREFIX, 'envs') - if not exists(envs_dir): - os.mkdir(envs_dir) - - -def run_post_install(): - """ - call the post install script, if the file exists - """ - path = join(ROOT_PREFIX, 'pkgs', 'post_install.bat') - if not isfile(path): - return - env = os.environ.copy() - env.setdefault('PREFIX', str(ROOT_PREFIX)) - cmd_exe = os.path.join(os.environ['SystemRoot'], 'System32', 'cmd.exe') - if not os.path.isfile(cmd_exe): - cmd_exe = os.path.join(os.environ['windir'], 'System32', 'cmd.exe') - if not os.path.isfile(cmd_exe): - err("Error: running %s failed. cmd.exe could not be found. " - "Looked in SystemRoot and windir env vars.\n" % path) - if os.environ.get("NSIS_SCRIPTS_RAISE_ERRORS"): - sys.exit(1) - args = [cmd_exe, '/d', '/c', path] - import subprocess - try: - subprocess.check_call(args, env=env) - except subprocess.CalledProcessError: - err("Error: running %s failed\n" % path) - if os.environ.get("NSIS_SCRIPTS_RAISE_ERRORS"): - sys.exit(1) - - -def run_pre_uninstall(): - """ - call the pre uninstall script, if the file exists - """ - path = join(ROOT_PREFIX, 'pre_uninstall.bat') - if not isfile(path): - return - env = os.environ.copy() - env.setdefault('PREFIX', str(ROOT_PREFIX)) - cmd_exe = os.path.join(os.environ['SystemRoot'], 'System32', 'cmd.exe') - if not os.path.isfile(cmd_exe): - cmd_exe = os.path.join(os.environ['windir'], 'System32', 'cmd.exe') - if not os.path.isfile(cmd_exe): - err("Error: running %s failed. cmd.exe could not be found. " - "Looked in SystemRoot and windir env vars.\n" % path) - if os.environ.get("NSIS_SCRIPTS_RAISE_ERRORS"): - sys.exit(1) - args = [cmd_exe, '/d', '/c', path] - import subprocess - try: - subprocess.check_call(args, env=env) - except subprocess.CalledProcessError: - err("Error: running %s failed\n" % path) - if os.environ.get("NSIS_SCRIPTS_RAISE_ERRORS"): - sys.exit(1) - - allusers = (not exists(join(ROOT_PREFIX, '.nonadmin'))) -# out('allusers is %s\n' % allusers) # This must be the same as conda's binpath_from_arg() in conda/cli/activate.py PATH_SUFFIXES = ('', @@ -192,7 +79,7 @@ def add_to_path(pyversion, arch): except IOError: old_prefixes = [] for prefix in old_prefixes: - out('Removing old installation at %s from PATH (if any entries get found)\n' % (prefix)) + print(f"Removing old installation at {prefix} from PATH (if any entries get found)") remove_from_path(prefix) # add Anaconda to the path @@ -217,29 +104,9 @@ def add_condabin_to_path(): broadcast_environment_settings_change() -def rm_regkeys(): - cmdproc_reg_entry = NSISReg(r'Software\Microsoft\Command Processor') - cmdproc_autorun_val = cmdproc_reg_entry.get('AutoRun') - conda_hook_regex_pat = r'((\s+&\s+)?(if +exist)?(\s*?\"[^\"]*?conda[-_]hook\.bat\"))' - if join(ROOT_PREFIX, 'condabin') in (cmdproc_autorun_val or ''): - cmdproc_autorun_newval = re.sub(conda_hook_regex_pat, '', - cmdproc_autorun_val) - try: - cmdproc_reg_entry.set('AutoRun', cmdproc_autorun_newval) - except Exception: - # Hey, at least we made an attempt to cleanup - pass - - def main(): cmd = sys.argv[1].strip() - if cmd == 'post_install': - run_post_install() - elif cmd == 'rmreg': - rm_regkeys() - elif cmd == 'mkdirs': - mk_dirs() - elif cmd == 'addpath': + if cmd == 'addpath': # These checks are probably overkill, but could be useful # if I forget to update something that uses this code. if len(sys.argv) > 2: @@ -257,8 +124,6 @@ def main(): add_condabin_to_path() elif cmd == 'rmpath': remove_from_path() - elif cmd == 'pre_uninstall': - run_pre_uninstall() else: sys.exit("ERROR: did not expect %r" % cmd) diff --git a/constructor/nsis/_system_path.py b/constructor/nsis/_system_path.py index ad391be86..ff35fe20e 100644 --- a/constructor/nsis/_system_path.py +++ b/constructor/nsis/_system_path.py @@ -11,28 +11,10 @@ import ctypes import os import re -import sys from ctypes import wintypes from os import path -if sys.version_info[0] >= 3: - import winreg as reg -else: - import _winreg as reg - -# If pythonw is being run, there may be no write function -if sys.stdout and sys.stdout.write: - out = sys.stdout.write - err = sys.stderr.write -else: - OutputDebugString = ctypes.windll.kernel32.OutputDebugStringW - OutputDebugString.argtypes = [ctypes.c_wchar_p] - - def out(x): - OutputDebugString('_nsis.py: ' + x) - - def err(x): - OutputDebugString('_nsis.py: Error: ' + x) +import winreg as reg HWND_BROADCAST = 0xffff WM_SETTINGCHANGE = 0x001A @@ -159,9 +141,6 @@ def add_to_system_path(paths, allusers=True, path_env_var='PATH'): final_value = final_value.replace('"', '') # Warn about directories that do not exist. directories = final_value.split(';') - for directory in directories: - if '%' not in directory and not os.path.exists(directory): - out("WARNING: Old PATH entry '%s' does not exist\n" % (directory)) reg.SetValueEx(key, path_env_var, 0, reg_type, final_value) finally: diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index 69aec1d3f..3ebfe1a07 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -17,6 +17,16 @@ Unicode true !define LogSet "!insertmacro LogSetMacro" !macro LogSetMacro SETTING !ifdef ENABLE_LOGGING + ${If} ${SETTING} == "on" + ${IfNot} ${FileExists} "$INSTDIR\install.log" + # Enforce UTF-16 encoding in the log file + # NSIS doesn't write the correct BOM to log files, + # so each character will be followed by NUL. + FileOpen $R0 "$INSTDIR\install.log" w + FileWrite $R0 "" + FileClose $R0 + ${EndIf} + ${EndIf} LogSet ${SETTING} !endif !macroend @@ -31,9 +41,12 @@ Unicode true var /global QuietMode # "0" = print normally, "1" = do not print var /global StdOutHandle var /global StdOutHandleSet -!define Print "!insertmacro PrintMacro" -!macro PrintMacro INPUT_TEXT - DetailPrint "${INPUT_TEXT}" +# Print and PrintToConsole are macros because it makes them easier to call. +# However, that also means that registers must be handled with caution because +# they will be overwritten in the macro. It is best to use $R* registers if +# temporary variables need to be used. +!define PrintToConsole "!insertmacro PrintToConsoleMacro" +!macro PrintToConsoleMacro INPUT_TEXT ${If} ${Silent} ${AndIf} $QuietMode != "1" ${IfNot} $StdOutHandleSet == "1" @@ -47,10 +60,24 @@ var /global StdOutHandleSet StrCpy $StdOutHandle $0 StrCpy $StdOutHandleSet "1" ${EndIf} - FileWrite $StdOutHandle "${INPUT_TEXT}$\n" + # Only add newline if input text doesn't have it already + StrLen $2 "${INPUT_TEXT}" + IntOp $2 $2 - 1 + StrCpy $2 "${INPUT_TEXT}" 1 $2 + ${If} $2 == "$\n" + FileWrite $StdOutHandle "${INPUT_TEXT}" + ${Else} + FileWrite $StdOutHandle "${INPUT_TEXT}$\n" + ${EndIf} ${EndIf} !macroend +!define Print "!insertmacro PrintMacro" +!macro PrintMacro INPUT_TEXT + DetailPrint "${INPUT_TEXT}" + ${PrintToConsole} "${INPUT_TEXT}" +!macroend + !include "WinMessages.nsh" !include "WordFunc.nsh" !include "LogicLib.nsh" @@ -59,6 +86,8 @@ var /global StdOutHandleSet !include "x64.nsh" !include "FileFunc.nsh" +!include "StrFunc.nsh" +${Using:StrFunc} StrStr !insertmacro GetParameters !insertmacro GetOptions @@ -71,28 +100,41 @@ var /global StdOutHandleSet !include "StandaloneUninstallerOptions.nsh" {%- endif %} -!define NAME {{ installer_name }} -!define VERSION {{ installer_version }} -!define COMPANY {{ company }} -!define ARCH {{ arch }} -!define PLATFORM {{ installer_platform }} -!define CONSTRUCTOR_VERSION {{ constructor_version }} -!define PY_VER {{ pyver_components[:2] | join(".") }} -!define PYVERSION_JUSTDIGITS {{ pyver_components | join("") }} -!define PYVERSION {{ pyver_components | join(".") }} -!define PYVERSION_MAJOR {{ pyver_components[0] }} -!define DEFAULT_PREFIX {{ default_prefix }} -!define DEFAULT_PREFIX_DOMAIN_USER {{ default_prefix_domain_user }} -!define DEFAULT_PREFIX_ALL_USERS {{ default_prefix_all_users }} -!define PRE_INSTALL_DESC {{ pre_install_desc }} -!define POST_INSTALL_DESC {{ post_install_desc }} -!define ENABLE_SHORTCUTS {{ enable_shortcuts }} -!define SHOW_REGISTER_PYTHON {{ show_register_python }} -!define SHOW_ADD_TO_PATH {{ show_add_to_path }} -!define PRODUCT_NAME "${NAME} ${VERSION} (${ARCH})" -!define UNINSTALL_NAME "{{ UNINSTALL_NAME }}" -!define UNINSTREG "SOFTWARE\Microsoft\Windows\CurrentVersion\ - \Uninstall\${UNINSTALL_NAME}" +!define NAME {{ installer_name }} +!define VERSION {{ installer_version }} +!define COMPANY {{ company }} +!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 }} +!define PRE_INSTALL_DESC {{ pre_install_desc }} +!define POST_INSTALL_DESC {{ post_install_desc }} +!define ENABLE_SHORTCUTS {{ enable_shortcuts }} +!define REGISTER_PYTHON_OPTION {{ '1' if register_python and has_python else '0' }} +!define REGISTER_PYTHON_DEFAULT_VALUE {{ '1' if register_python_default else '0' }} +!define INIT_CONDA_OPTION {{ '1' if initialize_conda else '0' }} +!define INIT_CONDA_MODE "{{ 'condabin' if initialize_conda == 'condabin' else 'classic' }}" +!define INIT_CONDA_DEFAULT_VALUE {{ '1' if initialize_by_default else '0' }} +!define PRODUCT_NAME "${NAME} ${VERSION} (${ARCH})" +!define UNINSTALL_NAME "{{ UNINSTALL_NAME }}" +!define UNINSTREG "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_NAME}" +# Silent installations do not output to the console and outputs to stdout +# are not written into install.log. STEP_LOG creates an intermittent file +# that is output into these streams after the commands finish. +!define STEP_LOG "$INSTDIR\.step.log" + +var /global INIT_CONDA +var /global REG_PY + +var /global NO_REGISTRY var /global INSTDIR_JUSTME var /global INSTALLER_VERSION @@ -108,7 +150,9 @@ var /global ARGV_Help var /global ARGV_InstallationType var /global ARGV_AddToPath var /global ARGV_KeepPkgCache +{%- if has_python %} var /global ARGV_RegisterPython +{%- endif %} var /global ARGV_NoRegistry var /global ARGV_NoScripts var /global ARGV_NoShortcuts @@ -132,6 +176,26 @@ var /global InstMode # 0 = Just Me, 1 = All Users. !define JUST_ME 0 !define ALL_USERS 1 +var /global CMD_EXE +var /global ICACLS_EXE + +!macro FindWindowsBinaries + # Find cmd.exe + ReadEnvStr $R0 SystemRoot + ReadEnvStr $R1 windir + ${If} ${FileExists} "$R0" + StrCpy $CMD_EXE "$R0\System32\cmd.exe" + StrCpy $ICACLS_EXE "$R0\System32\icacls.exe" + ${ElseIf} ${FileExists} "$R1" + StrCpy $CMD_EXE "$R1\System32\cmd.exe" + StrCpy $ICACLS_EXE "$R1\System32\icacls.exe" + ${Else} + # Cross our fingers binaries are in PATH + StrCpy $CMD_EXE "cmd.exe" + StrCpy $ICACLS_EXE "icacls.exe" + ${EndIf} +!macroend + # Include this one after our defines !include "OptionsDialog.nsh" @@ -222,12 +286,23 @@ UninstPage Custom un.UninstCustomOptions_Show !insertmacro MUI_LANGUAGE "English" Function SkipPageIfUACInnerInstance - ${LogSet} on ${If} ${UAC_IsInnerInstance} Abort ${EndIf} FunctionEnd +Function InitializeVariables + StrCpy $CheckPathLength "{{ 1 if check_path_length else 0 }}" + StrCpy $NO_REGISTRY "0" + + # Package cache option + StrCpy $Ana_ClearPkgCache_State {{ '${BST_UNCHECKED}' if keep_pkgs else '${BST_CHECKED}' }} + + # Pre/post install + StrCpy $Ana_PreInstall_State {{ '${BST_CHECKED}' if pre_install_exists else '${BST_UNCHECKED}' }} + StrCpy $Ana_PostInstall_State {{ '${BST_CHECKED}' if post_install_exists else '${BST_UNCHECKED}' }} +FunctionEnd + !macro DoElevation GetDlgItem $1 $HWNDParent 1 System::Call user32::GetFocus()i.s @@ -279,14 +354,18 @@ FunctionEnd OPTIONS$\n\ -------$\n\ $\n\ - /InstallationType=AllUsers [default: JustMe]$\n\ + /InstallationType=[AllUsers|JustMe] [default: JustMe]$\n\ +{%- if initialize_conda %} /AddToPath=[0|1] [default: 0]$\n\ +{%- endif %} /KeepPkgCache=[0|1] [default: {{ 1 if keep_pkgs else 0 }}]$\n\ - /RegisterPython=[0|1] [default: AllUsers: 1, JustMe: 0]$\n\ +{%- if has_python and register_python %} + /RegisterPython=[0|1] [default: 0]$\n\ +{%- endif %} /NoRegistry=[0|1] [default: AllUsers: 0, JustMe: 0]$\n\ /NoScripts=[0|1] [default: 0]$\n\ /NoShortcuts=[0|1] [default: 0]$\n\ - /CheckPathLength=[0|1] [default: 1]$\n\ + /CheckPathLength=[0|1] [default: {{ 1 if check_path_length else 0 }}]$\n\ /? (show this help message)$\n\ /S (run in CLI/headless mode)$\n\ /Q (quiet mode, do not print output to console)$\n\ @@ -301,9 +380,16 @@ FunctionEnd Install for all users, but don't add to PATH env var:$\n\ > $EXEFILE /InstallationType=AllUsers$\n\ $\n\ - Install for just me, add to PATH and register as system Python:$\n\ - > $EXEFILE /RegisterPython=1 /AddToPath=1$\n\ +{%- if has_python and register_python %} + Install for just me, and register as system Python:$\n\ + > $EXEFILE /RegisterPython=1$\n\ + $\n\ +{%- endif %} +{%- if initialize_conda %} + Install for just me and add to PATH:$\n\ + > $EXEFILE /AddToPath=1$\n\ $\n\ +{%- endif %} Install for just me, with no registry modification (for CI):$\n\ > $EXEFILE /NoRegistry=1$\n\ $\n\ @@ -326,26 +412,65 @@ FunctionEnd ${EndIf} ${EndIf} - ClearErrors - ${GetOptions} $ARGV "/RegisterPython=" $ARGV_RegisterPython - ${IfNot} ${Errors} - ${If} $ARGV_RegisterPython = "1" - StrCpy $Ana_RegisterSystemPython_State ${BST_CHECKED} - ${ElseIf} $ARGV_RegisterPython = "0" - StrCpy $Ana_RegisterSystemPython_State ${BST_UNCHECKED} + + !if ${REGISTER_PYTHON_OPTION} == 1 + ClearErrors + ${GetOptions} $ARGV "/RegisterPython=" $ARGV_RegisterPython + ${IfNot} ${Errors} + ${If} $ARGV_RegisterPython == "1" + StrCpy $REG_PY 1 + ${ElseIf} $ARGV_RegisterPython == "0" + StrCpy $REG_PY 0 + ${EndIf} + ${Else} + # If we have Errors, the option is not explicitly set by the user + StrCpy $REG_PY 0 ${EndIf} - ${EndIf} + + !endif + + !if ${INIT_CONDA_OPTION} == 1 + ClearErrors + ${GetOptions} $ARGV "/AddToPath=" $ARGV_AddToPath + ${IfNot} ${Errors} + ${If} $ARGV_AddToPath = "1" + # To address CVE-2022-26526. + # In AllUsers install mode, do not allow AddToPath as an option. + ${If} $InstMode == ${ALL_USERS} + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "/AddToPath=1 is disabled and ignored in 'All Users' installations" /SD IDOK + ${Print} "/AddToPath=1 is disabled and ignored in 'All Users' installations" + StrCpy $INIT_CONDA 0 + ${Else} + StrCpy $INIT_CONDA 1 + ${EndIf} + ${ElseIf} $ARGV_AddToPath = "0" + StrCpy $INIT_CONDA 0 + ${EndIf} + ${Else} + # If we have Errors, the option is not explicitly set by the user + StrCpy $INIT_CONDA 0 + ${EndIf} + !endif ClearErrors ${GetOptions} $ARGV "/KeepPkgCache=" $ARGV_KeepPkgCache - ${If} ${Errors} - StrCpy $ARGV_KeepPkgCache "{{ 1 if keep_pkgs else 0 }}" + ${IfNot} ${Errors} + ${If} $ARGV_KeepPkgCache = "1" + StrCpy $Ana_ClearPkgCache_State ${BST_UNCHECKED} + ${ElseIf} $ARGV_KeepPkgCache = "0" + StrCpy $Ana_ClearPkgCache_State ${BST_CHECKED} + ${EndIf} ${EndIf} ClearErrors ${GetOptions} $ARGV "/NoRegistry=" $ARGV_NoRegistry - ${If} ${Errors} - StrCpy $ARGV_NoRegistry "0" + ${IfNot} ${Errors} + ${If} $ARGV_NoRegistry = "1" + StrCpy $NO_REGISTRY "1" + ${ElseIf} $ARGV_NoRegistry = "0" + StrCpy $NO_REGISTRY "0" + ${EndIf} ${EndIf} ClearErrors @@ -390,33 +515,7 @@ FunctionEnd !macroend -Function OnInit_Release - ${LogSet} on - !insertmacro ParseCommandLineArgs - - # Parsing the AddToPath option here (and not in ParseCommandLineArgs) to prevent the MessageBox from showing twice. - # For more context, see https://github.com/conda/constructor/pull/584#issuecomment-1347688020 - ClearErrors - ${GetOptions} $ARGV "/AddToPath=" $ARGV_AddToPath - ${IfNot} ${Errors} - ${If} $ARGV_AddToPath = "1" - ${If} $InstMode == ${ALL_USERS} - # To address CVE-2022-26526. - # In AllUsers install mode, do not allow AddToPath as an option. - MessageBox MB_OK|MB_ICONEXCLAMATION "/AddToPath=1 is disabled and ignored in 'All Users' installations" /SD IDOK - ${Print} "/AddToPath=1 is disabled and ignored in 'All Users' installations" - StrCpy $Ana_AddToPath_State ${BST_UNCHECKED} - ${Else} - StrCpy $Ana_AddToPath_State ${BST_CHECKED} - ${EndIf} - ${ElseIf} $ARGV_AddToPath = "0" - StrCpy $Ana_AddToPath_State ${BST_UNCHECKED} - ${EndIf} - ${EndIf} -FunctionEnd - Function InstModePage_RadioButton_OnClick - ${LogSet} on Exch $0 Push $1 Push $2 @@ -432,7 +531,6 @@ Function InstModePage_RadioButton_OnClick FunctionEnd Function InstModePage_Create - ${LogSet} on Push $0 Push $1 Push $2 @@ -479,7 +577,6 @@ Function InstModePage_Create FunctionEnd Function DisableBackButtonIfUACInnerInstance - ${LogSet} on Push $0 ${If} ${UAC_IsInnerInstance} GetDlgItem $0 $HWNDParent 3 @@ -489,7 +586,6 @@ Function DisableBackButtonIfUACInnerInstance FunctionEnd Function RemoveNextBtnShield - ${LogSet} on Push $0 GetDlgItem $0 $HWNDParent 1 SendMessage $0 ${BCM_SETSHIELD} 0 0 @@ -497,7 +593,6 @@ Function RemoveNextBtnShield FunctionEnd Function InstModeChanged - ${LogSet} on # When using the installer with /S (silent mode), the /D option sets $INSTDIR, # and it is therefore important not to overwrite $INSTDIR here, but it is also # important that we do call SetShellVarContext with the appropriate value. @@ -528,7 +623,6 @@ FunctionEnd !macroend Function InstModePage_Leave - ${LogSet} on Push $0 Push $1 Push $2 @@ -549,13 +643,21 @@ Function InstModePage_Leave FunctionEnd Function .onInit - ${LogSet} on Push $0 Push $1 Push $2 Push $R1 Push $R2 + # 1. Initialize core options default values and other variables + Call mui_AnaCustomOptions_InitDefaults + Call InitializeVariables + + # 2. Account finally for CLI to potentially override core default values + ${If} ${Silent} + !insertmacro ParseCommandLineArgs + ${EndIf} + InitPluginsDir {%- if TEMP_EXTRA_FILES | length != 0 %} SetOutPath $PLUGINSDIR @@ -563,7 +665,6 @@ Function .onInit File "{{ file }}" {%- endfor %} {%- endif %} - !insertmacro ParseCommandLineArgs # Select the correct registry to look at, depending # on whether it's a 32-bit or 64-bit installer @@ -695,37 +796,6 @@ Function .onInit ${EndIf} ${EndIf} - ; Set default value - ${If} $CheckPathLength == "" - StrCpy $CheckPathLength "1" - ${EndIf} - - # Initialize the default settings for the anaconda custom options - Call mui_AnaCustomOptions_InitDefaults - # Override custom options with explicitly given values from construct.yaml. - # If initialize_by_default / register_python_default - # are None, do nothing. Note that these variables exist even when the construct.yaml - # settings are disabled, and the installer will respect them later! -{%- if initialize_conda %} - {%- if initialize_by_default %} - ${If} $InstMode == ${JUST_ME} - StrCpy $Ana_AddToPath_State ${BST_CHECKED} - ${EndIf} - {%- else %} - StrCpy $Ana_AddToPath_State ${BST_UNCHECKED} - {%- endif %} -{%- endif %} - -{%- if register_python %} - StrCpy $Ana_RegisterSystemPython_State {{ '${BST_CHECKED}' if register_python_default else '${BST_UNCHECKED}' }} -{%- endif %} - StrCpy $CheckPathLength "{{ 1 if check_path_length else 0 }}" - StrCpy $Ana_ClearPkgCache_State {{ '${BST_UNCHECKED}' if keep_pkgs else '${BST_CHECKED}' }} - StrCpy $Ana_PreInstall_State {{ '${BST_CHECKED}' if pre_install_exists else '${BST_UNCHECKED}' }} - StrCpy $Ana_PostInstall_State {{ '${BST_CHECKED}' if post_install_exists else '${BST_UNCHECKED}' }} - - Call OnInit_Release - ${Print} "Welcome to ${NAME} ${VERSION}$\n" Pop $R2 @@ -919,7 +989,6 @@ FunctionEnd # http://nsis.sourceforge.net/Check_for_spaces_in_a_directory_path Function CheckForSpaces - ${LogSet} on Exch $R0 Push $R1 Push $R2 @@ -943,7 +1012,6 @@ FunctionEnd # http://nsis.sourceforge.net/StrCSpn,_StrCSpnReverse:_Scan_strings_for_characters Function StrCSpn - ${LogSet} on Exch $R0 ; string to check Exch Exch $R1 ; string of chars @@ -1003,7 +1071,6 @@ Pop $0 Function OnDirectoryLeave - ${LogSet} on ${If} ${IsNonEmptyDirectory} "$InstDir" ${Print} "::error:: Directory '$INSTDIR' is not empty, please choose a different location." MessageBox MB_OK|MB_ICONEXCLAMATION \ @@ -1022,7 +1089,7 @@ Function OnDirectoryLeave ; With windows 10, we can enable support for long path, for earlier ; version, suggest user to use shorter installation path ${If} ${AtLeastWin10} - ${AndIfNot} $ARGV_NoRegistry = "1" + ${AndIfNot} $NO_REGISTRY = "1" ; If we have admin right, we enable long path on windows ${If} ${UAC_IsAdmin} WriteRegDWORD HKLM "SYSTEM\CurrentControlSet\Control\FileSystem" "LongPathsEnabled" 1 @@ -1120,6 +1187,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. @@ -1137,6 +1205,7 @@ Function OnDirectoryLeave abort valid_path: +{%- endif %} Push $R1 ${IsWritable} $INSTDIR $R1 @@ -1156,7 +1225,6 @@ Function OnDirectoryLeave FunctionEnd Function .onVerifyInstDir - ${LogSet} on StrLen $0 $Desktop StrCpy $0 $INSTDIR $0 StrCmp $0 $Desktop 0 PathGood @@ -1175,37 +1243,85 @@ Function un.OnDirectoryLeave confirmed_yes: FunctionEnd -# Make function available for both installer and uninstaller +# Make functions available for both installer and uninstaller # Uninstaller functions need an `un.` prefix, so we use a macro to do both # see https://nsis.sourceforge.io/Sharing_functions_between_Installer_and_Uninstaller -!macro AbortRetryNSExecWaitMacro un +!macro FunctionTemplates un + Function ${un}PrintFromStepLog + Exch $R0 + Push $R1 + Push $R2 + ClearErrors + FileOpen $R1 "${STEP_LOG}" r + IfErrors close_file + read_line: + FileRead $R1 $R2 + IfErrors close_file + ${If} $R0 == "ToConsole" + ${OrIf} $R0 == "both" + ${PrintToConsole} "$R2" + ${EndIf} + ${If} $R0 == "ToLog" + ${OrIf} $R0 == "both" + ${LogText} "$R2" + ${EndIf} + goto read_line + close_file: + FileClose $R1 + Pop $R2 + Pop $R1 + Pop $R0 + FunctionEnd + Function ${un}AbortRetryNSExecWait # This function expects three arguments in the stack - # $1: 'WithLog' or 'NoLog': Use ExecToLog or just Exec, respectively - # $2: The message to show if an error occurred - # $3: The command to run, quoted + # $R1: 'WithLog' or 'NoLog': Use ExecToLog or just Exec, respectively + # $R2: The message to show if an error occurred + # $R3: The command to run, quoted # Note that the args need to be pushed to the stack in reverse order! # Search 'AbortRetryNSExecWait' in this script to see examples - ${LogSet} on - Pop $1 - Pop $2 - Pop $3 + Pop $R1 + Pop $R2 + Pop $R3 ${Do} - ${If} $1 == "WithLog" - nsExec::ExecToLog $3 - ${ElseIf} $1 == "NoLog" - nsExec::Exec $3 + # Execute command inside a subshell to catch issues with command execution. + # When binaries are executed with Exec or ExectToLog, only the output of these + # commands are logged. If they do not start successfully (e.g., if they don't exist), + # no error messages are returned. Using a subshell reveals these kinds of issues. + # Enable delayed expansion so that error levels are parsed at runtime instead of + # parse time. + StrCpy $R3 '"$CMD_EXE" /V:ON /D /C "$R3"' + ${If} $R1 == "WithLog" + nsExec::ExecToLog $R3 + ${ElseIf} $R1 == "NoLog" + nsExec::Exec $R3 ${Else} - ${Print} "::error:: AbortRetryNSExecWait: 1st argument must be 'WithLog' or 'NoLog'. You used: $1" + ${Print} "::error:: AbortRetryNSExecWait: 1st argument must be 'WithLog' or 'NoLog'. You used: $R1" Abort ${EndIf} - pop $0 - ${If} $0 != "0" - ${Print} "::error:: $2" - MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON3 \ - $2 /SD IDIGNORE IDABORT abort IDRETRY retry + pop $R0 + ${If} $R1 == "WithLog" + ${AndIf} ${FileExists} "${STEP_LOG}" + ${If} ${Silent} + push "both" + Call ${un}PrintFromStepLog + ${Else} + push "ToLog" + Call ${un}PrintFromStepLog + ${EndIf} + ${EndIf} + ${If} $R0 != "0" + # Always print on error + ${If} $R1 == "NoLog" + ${AndIf} ${FileExists} "${STEP_LOG}" + Push "both" + Call ${un}PrintFromStepLog + ${EndIf} + ${Print} "::error:: $R2" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + $R2 /SD IDABORT IDABORT abort IDRETRY retry ; IDIGNORE: Continue anyway - StrCpy $0 "0" + StrCpy $R0 "0" goto retry abort: ; Abort installation @@ -1213,22 +1329,139 @@ FunctionEnd retry: ; Retry the nsExec command ${EndIf} - ${LoopWhile} $0 != "0" + ${If} ${FileExists} "${STEP_LOG}" + Delete "${STEP_LOG}" + ${EndIf} + ${LoopWhile} $R0 != "0" FunctionEnd !macroend -!insertmacro AbortRetryNSExecWaitMacro "" -!insertmacro AbortRetryNSExecWaitMacro "un." +!insertmacro FunctionTemplates "" +!insertmacro FunctionTemplates "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} + # `type` is used to simulate a `tee`-like output in cmd.exe + push '"$INSTDIR\python.exe" -E -s "$INSTDIR\Lib\_nsis.py" $R0 > "${STEP_LOG}" 2>&1 & SET ERR=!ERRORLEVEL! & type "${STEP_LOG}" & EXIT /B !ERR!' + 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 }} {{ CONDA_LOG_ARG }}' + 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: + # AU - authenticated users (NT AUTHORITY\Authenticated Users) + # BU - built-in (local) users (BUILTIN\Users) + # DU - domain users (\DOMAIN USERS) + # This also applies for single-user installations to avoid giving other users + # full access on shared drives. + ${If} ${UAC_IsAdmin} + StrCpy $0 "(AU) (BU) (DU)" + ${Else} + # Not every directory grants write access to Users (e.g., %USERPROFILE%), + # so test whether user groups have the necessary rights. + nsExec::ExecToStack '"$ICACLS_EXE" "$INSTDIR"' + Pop $R0 + Pop $R1 + ${If} $R0 != "0" + StrCpy $R1 \ + "Unable to determine the defaults permissions of the installation directory. "\ + "Ensure that you have read access to $INSTDIR and icacls.exe is in your PATH." + ${Print} $R1 + MessageBox MB_ICONSTOP $R1 + Abort + ${EndIf} + StrCpy $0 "" + StrCpy $R2 "NT AUTHORITY\Authenticated Users|BUILTIN\Users|\Domain Users" + StrCpy $R3 "(AU)|(BU)|(DU)" + StrCpy $R4 1 + loop_single_user_default_access: + ${WordFind} $R2 "|" "E+$R4" $R5 + ${WordFind} $R3 "|" "E+$R4" $R6 + IfErrors endloop_single_user_default_access + ${StrStr} $R7 $R1 $R5 + ${If} $R7 == "" + goto increment_loop_single_user_default_access + ${EndIf} + # If the user group has a deny permission directive, do not change permissions. + # Granting (RX) permissions may increase the permissions that are inherited and + # it is very unlikely that a user is granted write permissions but denied others. + ${StrStr} $R7 $R1 "$R5:(D)" + ${If} $R7 != "" + goto increment_loop_single_user_default_access + ${EndIf} + StrCpy $0 "$0 $R6" + increment_loop_single_user_default_access: + IntOp $R4 $R4 + 1 + goto loop_single_user_default_access + endloop_single_user_default_access: + ${EndIf} + AccessControl::DisableFileInheritance "$INSTDIR" + ${If} $0 != "" + StrCpy $1 1 + loop_access: + ${WordFind} $0 " " "E+$1" $2 + IfErrors endloop_access + AccessControl::RevokeOnFile "$INSTDIR" "$2" "GenericWrite" + AccessControl::SetOnFile "$INSTDIR" "$2" "GenericRead + GenericExecute" + IntOp $1 $1 + 1 + goto loop_access + endloop_access: + ${EndIf} + ${IfNot} ${UAC_IsAdmin} + # Ensure that creator has full access + ReadEnvStr $R0 USERDOMAIN + ReadEnvStr $R1 USERNAME + ${If} $R0 == "" + AccessControl::SetOnFile "$INSTDIR" "$R1" "FullAccess" + ${Else} + AccessControl::SetOnFile "$INSTDIR" "$R0\$R1" "FullAccess" + ${EndIf} + ${EndIf} +!macroend # Installer sections Section "Install" - ${LogSet} on ${If} ${Silent} call OnDirectoryLeave ${EndIf} - SetOutPath "$INSTDIR\Lib" - File "{{ NSIS_DIR }}\_nsis.py" - File "{{ NSIS_DIR }}\_system_path.py" + !insertmacro FindWindowsBinaries + + SetOutPath "$INSTDIR" + ${LogSet} on # Resolve INSTDIR so that paths and registry keys do not contain '..' or similar strings. # $0 is empty if the directory doesn't exist, but the File commands should have created it already. @@ -1239,6 +1472,17 @@ Section "Install" ${EndIf} StrCpy $INSTDIR $0 + # Restrict permissions immediately after creating $INSTDIR + # If not, the installation directory may inherit write-permissions + # 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" File {{ licensefile }} @@ -1329,7 +1573,7 @@ Section "Install" System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_SOLVER", "classic").r0' SetDetailsPrint TextOnly ${Print} "Checking virtual specs compatibility: {{ VIRTUAL_SPECS_DEBUG }}" - push '"$INSTDIR\_conda.exe" create --dry-run --prefix "$INSTDIR\envs\_virtual_specs_checks" --offline {{ VIRTUAL_SPECS }} {{ NO_RCS_ARG }}' + push '"$INSTDIR\_conda.exe" create --dry-run --prefix "$INSTDIR\envs\_virtual_specs_checks" --offline {{ VIRTUAL_SPECS }} {{ NO_RCS_ARG }} {{ CONDA_LOG_ARG }}' push 'Failed to check virtual specs: {{ VIRTUAL_SPECS_DEBUG }}' push 'WithLog' call AbortRetryNSExecWait @@ -1341,30 +1585,17 @@ Section "Install" File {{ dist }} {%- endfor %} - SetDetailsPrint TextOnly ${Print} "Setting up the package cache..." - push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR" --extract-conda-pkgs' + push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR" --extract-conda-pkgs {{ CONDA_LOG_ARG }}' push 'Failed to extract packages' - push 'NoLog' - # We use NoLog here because TQDM progress bars are parsed as a single line in NSIS 3.08 - # These can crash the installer if they get too long (a few packages is enough!) + push 'WithLog' call AbortRetryNSExecWait - SetDetailsPrint both IfFileExists "$INSTDIR\pkgs\pre_install.bat" 0 NoPreInstall - ${Print} "Running pre_install scripts..." - ReadEnvStr $5 SystemRoot - ReadEnvStr $6 windir - # This 'FileExists' also returns True for directories - ${If} ${FileExists} "$5" - push '"$5\System32\cmd.exe" /D /C "$INSTDIR\pkgs\pre_install.bat"' - ${ElseIf} ${FileExists} "$6" - push '"$6\System32\cmd.exe" /D /C "$INSTDIR\pkgs\pre_install.bat"' - ${Else} - # Cross our fingers CMD is in PATH - push 'cmd.exe /D /C "$INSTDIR\pkgs\pre_install.bat"' - ${EndIf} - push "Failed to run pre_install" + ${Print} "Running pre-install script..." + # `type` is used to simulate a `tee`-like output in cmd.exe + push '"$INSTDIR\pkgs\pre_install.bat" > "${STEP_LOG}" 2>&1 & SET ERR=!ERRORLEVEL! & type "${STEP_LOG}" & EXIT /B !ERR!' + push "Failed to run pre-install script." push 'WithLog' call AbortRetryNSExecWait NoPreInstall: @@ -1391,10 +1622,10 @@ Section "Install" # Run conda install ${If} $Ana_CreateShortcuts_State = ${BST_CHECKED} ${Print} "Installing packages for {{ env.name }}, creating shortcuts if necessary..." - push '"$INSTDIR\_conda.exe" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile_txt }}" {{ env.shortcuts }} {{ env.no_rcs_arg }}' + push '"$INSTDIR\_conda.exe" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile_txt }}" {{ env.shortcuts }} {{ env.no_rcs_arg }} {{ CONDA_LOG_ARG }}' ${Else} ${Print} "Installing packages for {{ env.name }}..." - push '"$INSTDIR\_conda.exe" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile_txt }}" --no-shortcuts {{ env.no_rcs_arg }}' + push '"$INSTDIR\_conda.exe" install --offline -yp "{{ env.prefix }}" --file "{{ env.lockfile_txt }}" --no-shortcuts {{ env.no_rcs_arg }} {{ CONDA_LOG_ARG }}' ${EndIf} push 'Failed to link extracted packages to {{ env.prefix }}!' push 'WithLog' @@ -1415,48 +1646,44 @@ Section "Install" AddSize {{ SIZE }} {%- if has_conda %} - ${Print} "Initializing conda directories..." - push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" mkdirs' - push 'Failed to initialize conda directories' - push 'WithLog' - call AbortRetryNSExecWait + StrCpy $R0 "$INSTDIR\envs" + ${IfNot} ${FileExists} "$R0" + CreateDirectory "$R0" + ${EndIf} {%- endif %} - ${If} $Ana_PostInstall_State = ${BST_CHECKED} - ${Print} "Running post install..." - push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" post_install' - push 'Failed to run post install script' - push 'WithLog' - call AbortRetryNSExecWait + ${If} ${FileExists} "$INSTDIR\pkgs\post_install.bat" + ${If} $Ana_PostInstall_State = ${BST_CHECKED} + ${Print} "Running post-install..." + # `type` is used to simulate a `tee`-like output in cmd.exe + push '"$INSTDIR\pkgs\post_install.bat" > "${STEP_LOG}" 2>&1 & SET ERR=!ERRORLEVEL! & type "${STEP_LOG}" & call exit !ERR!' + push "Failed to run post-install script." + push 'WithLog' + call AbortRetryNSExecWait + ${EndIf} ${EndIf} ${If} $Ana_ClearPkgCache_State = ${BST_CHECKED} ${Print} "Clearing package cache..." - push '"$INSTDIR\_conda.exe" clean --all --force-pkgs-dirs --yes {{ NO_RCS_ARG }}' + push '"$INSTDIR\_conda.exe" clean --all --force-pkgs-dirs --yes {{ NO_RCS_ARG }} {{ CONDA_LOG_ARG }}' push 'Failed to clear package cache' push 'WithLog' call AbortRetryNSExecWait ${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 - ${EndIf} -{%- endif %} + !if ${INIT_CONDA_OPTION} == 1 + ${If} ${FileExists} "$INSTDIR\.nonadmin" + ${If} $INIT_CONDA = 1 + !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}" - ${If} $Ana_RegisterSystemPython_State == ${BST_CHECKED} + ${If} $REG_PY == 1 WriteRegStr SHCTX "${PYREG}\Help\Main Python Documentation" \ "Main Python Documentation" \ "$INSTDIR\Doc\python${PYVERSION_JUSTDIGITS}.chm" @@ -1470,8 +1697,9 @@ Section "Install" WriteRegStr SHCTX "${PYREG}\PythonPath" \ "" "$INSTDIR\Lib;$INSTDIR\DLLs" ${EndIf} +{%- endif %} - ${If} $ARGV_NoRegistry == "0" + ${If} $NO_REGISTRY == "0" # Registry uninstall info WriteRegStr SHCTX "${UNINSTREG}" "DisplayName" "${UNINSTALL_NAME}" WriteRegStr SHCTX "${UNINSTREG}" "DisplayVersion" "${VERSION}" @@ -1489,61 +1717,26 @@ Section "Install" WriteUninstaller "$INSTDIR\Uninstall-${NAME}.exe" - # To address CVE-2022-26526. - # Revoke the write permission on directory "$INSTDIR" for Users if this is - # being run with administrative privileges. Users are: - # AU - authenticated users - # BU - built-in (local) users - # DU - domain users - ${If} ${UAC_IsAdmin} - ${Print} "Setting installation directory permissions..." - AccessControl::DisableFileInheritance "$INSTDIR" - # Enable inheritance on all files inside $INSTDIR. - # Use icacls because it is much faster than custom NSIS solutions. - # We continue on error because icacls fails on broken links. - ReadEnvStr $0 SystemRoot - ReadEnvStr $1 windir - ${If} ${FileExists} "$0" - push '"$0\System32\icacls.exe" "$INSTDIR\*" /inheritance:e /T /C /Q' - ${ElseIf} ${FileExists} "$1" - push '"$1\System32\icacls.exe" "$INSTDIR\*" /inheritance:e /T /C /Q' - ${Else} - # Cross our fingers icacls is in PATH - push 'icacls.exe "$INSTDIR\*" /inheritance:e /T /C /Q' - ${EndIf} - push 'Failed to enable inheritance for all files in the installation directory.' - push 'NoLog' - call AbortRetryNSExecWait - AccessControl::RevokeOnFile "$INSTDIR" "(AU)" "GenericWrite" - AccessControl::RevokeOnFile "$INSTDIR" "(DU)" "GenericWrite" - AccessControl::RevokeOnFile "$INSTDIR" "(BU)" "GenericWrite" - AccessControl::SetOnFile "$INSTDIR" "(BU)" "GenericRead + GenericExecute" - AccessControl::SetOnFile "$INSTDIR" "(DU)" "GenericRead + GenericExecute" - ${EndIf} + ${Print} "Setting installation directory permissions..." + # Enable inheritance on all files inside $INSTDIR. + # Use icacls because it is much faster than custom NSIS solutions. + # We continue on error because icacls fails on broken links. + # `type` is used to simulate a `tee`-like output in cmd.exe + push '"$ICACLS_EXE" "$INSTDIR\*" /inheritance:e /T /C /Q > "${STEP_LOG}" 2>&1 & SET ERR=!ERRORLEVEL! & type "${STEP_LOG}" & EXIT /B !ERR!' + push 'Failed to enable inheritance for all files in the installation directory.' + push 'WithLog' + call AbortRetryNSExecWait ${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} !insertmacro un.ParseCommandLineArgs ${EndIf} + !insertmacro FindWindowsBinaries + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_ROOT_PREFIX", "$INSTDIR")".r0' # ensure that MSVC runtime DLLs are on PATH during uninstallation @@ -1560,6 +1753,9 @@ Section "Uninstall" # For long installation times, this may cause a buffer overflow, crashing the installer. System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_QUIET", "1")".r0' + # Remove registry entries first because they are difficult to clean + # up manually if the uninstallation irrecoverably fails. + # Read variables the uninstaller needs from the registry StrCpy $R0 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" StrLen $R1 "Uninstall-${NAME}.exe" @@ -1582,12 +1778,33 @@ Section "Uninstall" goto loop_path endloop_path: + ${If} $INSTALLER_NAME_FULL != "" + DeleteRegKey SHCTX "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$INSTALLER_NAME_FULL" + ${EndIf} + + # If Anaconda was registered as the official Python for this version, + # remove it from the registry + StrCpy $R0 "SOFTWARE\Python\PythonCore" + StrCpy $0 0 + loop_py: + EnumRegKey $1 SHCTX $R0 $0 + StrCmp $1 "" endloop_py + ReadRegStr $2 SHCTX "$R0\$1\InstallPath" "" + ${If} $2 == $INSTDIR + StrCpy $R1 $1 + DeleteRegKey SHCTX "$R0\$1" + goto endloop_py + ${EndIf} + IntOp $0 $0 + 1 + goto loop_py + endloop_py: + # Extra info for pre_uninstall scripts System::Call 'kernel32::SetEnvironmentVariable(t,t)i("PREFIX", "$INSTDIR").r0' 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' @@ -1598,11 +1815,19 @@ Section "Uninstall" System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_UNATTENDED", "0").r0' ${EndIf} -{%- if uninstall_with_conda_exe %} - !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall" - !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath" - !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg" + ${If} ${FileExists} "$INSTDIR\pkgs\pre_uninstall.bat" + ${Print} "Running pre-uninstall script..." + # `type` is used to simulate a `tee`-like output in cmd.exe + push '"$INSTDIR\pkgs\pre_uninstall.bat" > "${STEP_LOG}" 2>&1 & SET ERR=!ERRORLEVEL! & type "${STEP_LOG}" & EXIT /B !ERR!' + push "Failed to run pre-uninstall scripts." + push 'WithLog' + call un.AbortRetryNSExecWait + ${EndIf} + ${If} ${FileExists} "$INSTDIR\.nonadmin" + !insertmacro AddRemovePath "remove" "un." + ${EndIf} +{%- if uninstall_with_conda_exe %} # Parse arguments StrCpy $R0 "" @@ -1625,7 +1850,7 @@ Section "Uninstall" ${EndIf} ${Print} "Removing files and folders..." - push '"$INSTDIR\_conda.exe" constructor uninstall $R0 --prefix "$INSTDIR"' + push '"$INSTDIR\_conda.exe" constructor uninstall $R0 --prefix "$INSTDIR" {{ CONDA_LOG_ARG }}' push 'Failed to remove files and folders. Please see the log for more information.' push 'WithLog' SetDetailsPrint listonly @@ -1635,6 +1860,9 @@ Section "Uninstall" # The uninstallation may leave the install.log, the uninstaller, # and .conda_trash files behind, so remove those manually. ${If} ${FileExists} "$INSTDIR" + # Stop logging or the uninstaller will not remove the install.log file + # without requiring a reboot + ${LogSet} off RMDir /r /REBOOTOK "$INSTDIR" ${EndIf} {%- else %} @@ -1643,45 +1871,36 @@ Section "Uninstall" SetDetailsPrint both ${Print} "Deleting ${NAME} menus in {{ env.name }}..." SetDetailsPrint listonly - push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR{{ subdir }}" --rm-menus' + push '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR{{ subdir }}" --rm-menus {{ CONDA_LOG_ARG }}' push 'Failed to delete menus in {{ env.name }}' push 'WithLog' call un.AbortRetryNSExecWait SetDetailsPrint both {%- endfor %} - !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall" - !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath" - !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg" +{%- if has_conda %} + ${If} ${FileExists} "$INSTDIR\.nonadmin" + StrCpy $R0 "user" + ${Else} + StrCpy $R0 "system" + ${EndIf} + # `type` is used to simulate a `tee`-like output in cmd.exe + push '"$INSTDIR\condabin\conda.bat" init cmd.exe --reverse --$R0 > "${STEP_LOG}" 2>&1 & SET ERR=!ERRORLEVEL! & type "${STEP_LOG}" & EXIT /B !ERR!' + push 'Failed to clean AutoRun' + push 'WithLog' + call un.AbortRetryNSExecWait +{%- endif %} ${Print} "Removing files and folders..." nsExec::Exec 'cmd.exe /D /C RMDIR /Q /S "$INSTDIR"' + # Stop logging or the uninstaller will not remove the install.log file + # without requiring a reboot + ${LogSet} off # In case the last command fails, run the slow method to remove leftover RMDir /r /REBOOTOK "$INSTDIR" {%- endif %} - ${If} $INSTALLER_NAME_FULL != "" - DeleteRegKey SHCTX "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$INSTALLER_NAME_FULL" - ${EndIf} - - # If Anaconda was registered as the official Python for this version, - # remove it from the registry - StrCpy $R0 "SOFTWARE\Python\PythonCore" - StrCpy $0 0 - loop_py: - EnumRegKey $1 SHCTX $R0 $0 - StrCmp $1 "" endloop_py - ReadRegStr $2 SHCTX "$R0\$1\InstallPath" "" - ${If} $2 == $INSTDIR - StrCpy $R1 $1 - DeleteRegKey SHCTX "$R0\$1" - goto endloop_py - ${EndIf} - IntOp $0 $0 + 1 - goto loop_py - endloop_py: - ${Print} "Done!" ${If} ${Silent} # give it some time so users can read the last lines diff --git a/constructor/osx/prepare_installation.sh b/constructor/osx/prepare_installation.sh index cf14cbf65..0e974c83f 100644 --- a/constructor/osx/prepare_installation.sh +++ b/constructor/osx/prepare_installation.sh @@ -22,7 +22,7 @@ PREFIX="$2/{{ pkg_name_lower }}" PREFIX=$(cd "$PREFIX"; pwd) export PREFIX echo "PREFIX=$PREFIX" -CONDA_EXEC="$PREFIX/_conda" +CONDA_EXEC="$PREFIX/{{ conda_exe_name }}" # Installers should ignore pre-existing configuration files. unset CONDARC unset MAMBARC @@ -30,6 +30,11 @@ unset MAMBARC chmod +x "$CONDA_EXEC" +{%- if conda_exe_name != "_conda" %} +# In case there are packages that depend on _conda +ln -s -f "$CONDA_EXEC" "$PREFIX"/_conda +{%- endif %} + # Create a blank history file so conda thinks this is an existing env mkdir -p "$PREFIX/conda-meta" touch "$PREFIX/conda-meta/history" diff --git a/constructor/osx/readme_header.rtf b/constructor/osx/readme_header.rtf index c77b114c6..0a4faa668 100644 --- a/constructor/osx/readme_header.rtf +++ b/constructor/osx/readme_header.rtf @@ -10,7 +10,7 @@ \f0\fs30 \cf0 Anaconda is the most popular Python data science platform. See {\field{\*\fldinst{HYPERLINK "https://www.anaconda.com/downloads"}}{\fldrslt https://www.anaconda.com/downloads}}/.\ \ -By default, this installer modifies your bash profile to activate the base environment of __NAME__ when your shell starts up. To disable this, choose "Customize" at the "Installation Type" phase, and disable the "Modify PATH" option. If you decline this option, the executables installed by this installer will not be available on PATH. You will need to use the full executable path to run commands, or otherwise initialize the base environment of __NAME__ on your own. \ +By default, this installer modifies all available shells to activate the base environment of __NAME__ when the shell starts up. To disable this, choose "Customize" at the "Installation Type" phase, and disable the "Modify PATH" option. If you decline this option, the executables installed by this installer will not be available on PATH. You will need to use the full executable path to run commands, or otherwise initialize the base environment of __NAME__ on your own. \ \ To install to a different location, select "Change Install Location..." at the "Installation Type" phase, then choose "Install on a specific disk...", choose the disk you wish to install on, and click "Choose Folder...". The "Install for me only" option will install __NAME__ to the default location, ~/__NAME_LOWER__.\ \ diff --git a/constructor/osx/run_installation.sh b/constructor/osx/run_installation.sh index 00afff654..80975cd5e 100644 --- a/constructor/osx/run_installation.sh +++ b/constructor/osx/run_installation.sh @@ -20,13 +20,13 @@ logger -p "install.info" "$1" || echo "$1" {%- set channels = final_channels|join(",") %} -unset DYLD_LIBRARY_PATH +unset DYLD_LIBRARY_PATH DYLD_FALLBACK_LIBRARY_PATH DYLD_INSERT_LIBRARIES DYLD_FRAMEWORK_PATH PREFIX="$2/{{ pkg_name_lower }}" PREFIX=$(cd "$PREFIX"; pwd) export PREFIX echo "PREFIX=$PREFIX" -CONDA_EXEC="$PREFIX/_conda" +CONDA_EXEC="$PREFIX/{{ conda_exe_name }}" # Installers should ignore pre-existing configuration files. unset CONDARC unset MAMBARC @@ -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" diff --git a/constructor/osx/run_user_script.sh b/constructor/osx/run_user_script.sh index 2d00b1c75..6209a5b0f 100644 --- a/constructor/osx/run_user_script.sh +++ b/constructor/osx/run_user_script.sh @@ -22,7 +22,7 @@ PREFIX="$2/{{ pkg_name_lower }}" PREFIX=$(cd "$PREFIX"; pwd) export PREFIX echo "PREFIX=$PREFIX" -CONDA_EXEC="$PREFIX/_conda" +CONDA_EXEC="$PREFIX/{{ conda_exe_name }}" # /COMMON UTILS # Expose these to user scripts as well diff --git a/constructor/osxpkg.py b/constructor/osxpkg.py index 3785ac617..2afaddbc1 100644 --- a/constructor/osxpkg.py +++ b/constructor/osxpkg.py @@ -21,10 +21,12 @@ from .jinja import render_template from .signing import CodeSign from .utils import ( + DEFAULT_REVERSE_DOMAIN_ID, add_condarc, approx_size_kb, copy_conda_exe, explained_check_call, + format_conda_exe_name, get_final_channels, parse_virtual_specs, rm_rf, @@ -364,6 +366,7 @@ def move_script(src, dst, info, ensure_shebang=False, user_script_type=None): variables["no_rcs_arg"] = info.get("_ignore_condarcs_arg", "") variables["script_env_variables"] = info.get("script_env_variables", {}) variables["initialize_conda"] = info.get("initialize_conda", "classic") + variables["conda_exe_name"] = format_conda_exe_name(info["_conda_exe"]) data = render_template(data, **variables) @@ -390,7 +393,7 @@ def fresh_dir(dir_path): def pkgbuild(name, identifier=None, version=None, install_location=None): "see `man pkgbuild` for the meaning of optional arguments" if identifier is None: - identifier = "io.continuum" + identifier = DEFAULT_REVERSE_DOMAIN_ID args = [ "pkgbuild", "--root", @@ -556,7 +559,7 @@ def create(info, verbose=False): # 1. Prepare installation # The 'prepare_installation' package contains the prepopulated package cache, the modified - # conda-meta metadata staged into pkgs/conda-meta, _conda (conda-standalone), + # conda-meta metadata staged into pkgs/conda-meta, _conda (conda-standalone, [--conda-exe]), # Optionally, extra files and the user-provided scripts. # We first populate PACKAGE_ROOT with everything needed, and then run pkg build on that dir fresh_dir(PACKAGE_ROOT) @@ -565,6 +568,10 @@ def create(info, verbose=False): os.makedirs(pkgs_dir) preconda.write_files(info, prefix) preconda.copy_extra_files(info.get("extra_files", []), prefix) + + # Add potential license file + if license_file := info.get("license_file"): + preconda.copy_extra_files([license_file], prefix) # These are the user-provided scripts, maybe patched to have a shebang # They will be called by a wrapping script added later, if present if info.get("pre_install"): @@ -589,7 +596,8 @@ def create(info, verbose=False): for dist in all_dists: os.link(join(CACHE_DIR, dist), join(pkgs_dir, dist)) - copy_conda_exe(prefix, "_conda", info["_conda_exe"]) + exe_name = format_conda_exe_name(info["_conda_exe"]) + copy_conda_exe(prefix, exe_name, info["_conda_exe"]) # Sign conda-standalone so it can pass notarization codesigner = None @@ -604,7 +612,7 @@ def create(info, verbose=False): "com.apple.security.cs.disable-library-validation": True, "com.apple.security.cs.allow-dyld-environment-variables": True, } - codesigner.sign_bundle(join(prefix, "_conda"), entitlements=entitlements) + codesigner.sign_bundle(join(prefix, exe_name), entitlements=entitlements) # This script checks to see if the install location already exists and/or contains spaces # Not to be confused with the user-provided pre_install! diff --git a/constructor/preconda.py b/constructor/preconda.py index 2beeb3dc6..12201ee77 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -328,18 +328,24 @@ def copy_extra_files( Returns: list[os.PathLike]: List of normalized paths of copied locations. """ + + def validate_file_path(file_path: str) -> Path: + fpath = Path(file_path) + if not fpath.exists(): + raise FileNotFoundError(f"File {file_path} does not exist.") + return fpath + if not extra_files: return [] copied = [] for path in extra_files: if isinstance(path, str): - copied.append(shutil.copy(path, workdir)) + orig_path = validate_file_path(path) + copied.append(shutil.copy(orig_path, workdir)) elif isinstance(path, dict): assert len(path) == 1 origin, destination = next(iter(path.items())) - orig_path = Path(origin) - if not orig_path.exists(): - raise FileNotFoundError(f"File {origin} does not exist.") + orig_path = validate_file_path(origin) dest_path = Path(workdir) / destination dest_path.parent.mkdir(parents=True, exist_ok=True) copied.append(shutil.copy(orig_path, dest_path)) diff --git a/constructor/shar.py b/constructor/shar.py index f6361a0f9..745f95c61 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -30,6 +30,7 @@ approx_size_kb, copy_conda_exe, filename_dist, + format_conda_exe_name, get_final_channels, hash_files, parse_virtual_specs, @@ -110,6 +111,7 @@ def get_header(conda_exec, tarball, info): virtual_specs = parse_virtual_specs(info) min_osx_version = virtual_specs.get("__osx", {}).get("min") or "" variables["min_osx_version"] = min_osx_version + variables["conda_exe_name"] = format_conda_exe_name(info["_conda_exe"]) min_glibc_version = virtual_specs.get("__glibc", {}).get("min") or "" variables["min_glibc_version"] = min_glibc_version @@ -148,7 +150,7 @@ def create(info, verbose=False): "pkgs/%s.sh" % key, filter=make_executable if has_shebang(info[key]) else None, ) - cache_dir = join(tmp_dir, "cache") + cache_dir = join(tmp_dir, "pkgs", "cache") if isdir(cache_dir): for cf in os.listdir(cache_dir): if cf.endswith(".json"): diff --git a/constructor/utils.py b/constructor/utils.py index 705a42bd7..7766264bb 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -26,6 +26,8 @@ from conda.models.version import VersionOrder from ruamel.yaml import YAML +DEFAULT_REVERSE_DOMAIN_ID = "io.continuum" + logger = logging.getLogger(__name__) yaml = YAML(typ="rt") yaml.default_flow_style = False @@ -344,6 +346,31 @@ def identify_conda_exe(conda_exe: str | Path | None = None) -> tuple[StandaloneE return None, None +def format_conda_exe_name(conda_exe: str | Path) -> str: + """Return a formatted alias for given stand-alone executable. + + - If given executable cannot be identified, returns the basename of given executable. + - If stand-alone conda is identified, returns '_conda'. + - If stand-alone mamba/micromamba is identified, returns 'micromamba'. + + Parameters:: + - conda_exe: str | Path + Path to the conda executable to be accounted for. + """ + conda_exe_name, _ = identify_conda_exe(conda_exe) + if conda_exe_name is None: + # This implies that identify_conda_exe failed + return Path(conda_exe).name + if conda_exe_name == StandaloneExe.CONDA: + return "_conda" + elif conda_exe_name == StandaloneExe.MAMBA: + return "micromamba" + else: + # This should never happen, but as a safe-guard in case `identify_conda_exe` is changed without + # accounting for this function. + raise RuntimeError("Unable to format conda exe name") + + def check_version( exe_version: str | VersionOrder | None = None, min_version: str | None = None, diff --git a/constructor/winexe.py b/constructor/winexe.py index 3c137f5c1..cd1cf05aa 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -166,8 +166,6 @@ def make_nsi( "pre_install_desc": info["pre_install_desc"], "post_install_desc": info["post_install_desc"], "enable_shortcuts": "yes" if info["_enable_shortcuts"] is True else "no", - "show_register_python": "yes" if info.get("register_python", True) else "no", - "show_add_to_path": info.get("initialize_conda", "classic") or "no", "outfile": info["_outpath"], "vipv": make_VIProductVersion(info["version"]), "constructor_version": info["CONSTRUCTOR_VERSION"], @@ -225,16 +223,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) + 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 @@ -247,10 +256,14 @@ 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") # UPPERCASE variables are unescaped (and unquoted) + variables["CONDA_LOG_ARG"] = ( + '--log-file "${STEP_LOG}"' if info.get("_conda_exe_supports_logging") else "" + ) variables["NAME"] = name variables["NSIS_DIR"] = NSIS_DIR variables["BITS"] = str(arch) @@ -259,9 +272,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() diff --git a/dev/extra-requirements-windows.txt b/dev/extra-requirements-windows.txt index 1f405685d..d382e69cb 100644 --- a/dev/extra-requirements-windows.txt +++ b/dev/extra-requirements-windows.txt @@ -1 +1,3 @@ +conda-forge::briefcase>=0.3.26 conda-forge::nsis>=3.08=*_log_* +conda-forge::tomli-w>=1.2.0 diff --git a/docs/source/cli-options.md b/docs/source/cli-options.md index 97bd1ea8b..cad946d87 100644 --- a/docs/source/cli-options.md +++ b/docs/source/cli-options.md @@ -72,7 +72,7 @@ Windows installers have the following CLI options available: - `/NoShortcuts=[0|1]`: If set to `1`, the installer will not create any shortcuts. Defaults to `0`. - `/RegisterPython=[0|1]`: Whether to register Python as default in the Windows registry. Defaults - to `1`. This is preferred to `AddToPath`. + to `0`. This is preferred to `AddToPath`. - `/D` (directory): sets the default installation directory. Note that even if the path contains spaces, it must be the last parameter used in the command line and must not contain any quotes. Only absolute paths are supported. diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 77155d3e9..04943a353 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -235,6 +235,7 @@ The type of the installer being created. Possible values are: - `sh`: shell-based installer for Linux or macOS - `pkg`: macOS GUI installer built with Apple's `pkgbuild` - `exe`: Windows GUI installer built with NSIS +- `msi`: Windows GUI installer built with Briefcase and WiX The default type is `sh` on Linux and macOS, and `exe` on Windows. A special value of `all` builds _both_ `sh` and `pkg` installers on macOS, as well @@ -317,8 +318,11 @@ Name of the company/entity responsible for the installer. ### `reverse_domain_identifier` Unique identifier for this package, formatted with reverse domain notation. This is -used internally in the PKG installers to handle future updates and others. If not -provided, it will default to `io.continuum`. (MacOS only) +used internally in the MSI and PKG installers to handle future updates and others. +If not provided, it will default to: + +* In MSI installers: `io.continuum` followed by an ID derived from the `name`. +* In PKG installers: `io.continuum`. ### `uninstall_name` @@ -525,17 +529,19 @@ See also `initialize_by_default`. ### `initialize_by_default` -Default value for the option added by `initialize_conda`. The default -is true for GUI installers (EXE, PKG) and false for shell installers. The user -is able to change the default during interactive installation. NOTE: For Windows, -`AddToPath` is disabled when `InstallationType=AllUsers`. +Default value for the option added by `initialize_conda`. The default is +true for PKG installers, and false for EXE and SH shell installers. +The user is able to change the default during interactive installations. +Non-interactive installations are not affected by this value: users must explicitly request +to add to `PATH` via CLI options. +NOTE: For Windows, `/AddToPath` is disabled when `/InstallationType=AllUsers`. Only applies if `initialize_conda` is not false. ### `register_python` -Whether to offer the user an option to register the installed Python instance as the -system's default Python. (Windows only) +If the installer installs a Python instance, offer the user an option to register the installed Python instance as the +system's default Python. Defaults to `true` for GUI and `false` for CLI installations. (Windows only) ### `register_python_default` diff --git a/docs/source/howto.md b/docs/source/howto.md index 8087a2803..c255b6e84 100644 --- a/docs/source/howto.md +++ b/docs/source/howto.md @@ -7,10 +7,12 @@ which it is running. In other words, if you run constructor on a Windows computer, you can only generate Windows installers. This is largely because OS-native tools are needed to generate the Windows `.exe` files and macOS `.pkg` files. There is a key in `construct.yaml`, `installer_type`, which dictates -the type of installer that gets generated. This is primarily only useful for -macOS, where you can generate either `.pkg` or `.sh` installers. When not set in -`construct.yaml`, this value defaults to `.sh` on Unix platforms, and `.exe` on -Windows. Using this key is generally done with selectors. For example, to +the type of installer that gets generated. This is useful for macOS, where you can +generate either `.pkg` or `.sh` installers, and Windows, where you can generate +either `.exe` or `.msi` installers. + +When not set in`construct.yaml`, this value defaults to `.sh` on Unix platforms, and +`.exe` on Windows. Using this key is generally done with selectors. For example, to build a `.pkg` installer on MacOS, but fall back to default behavior on other platforms: diff --git a/examples/azure_signtool/construct.yaml b/examples/azure_signtool/construct.yaml index f40c2efa3..96498a702 100644 --- a/examples/azure_signtool/construct.yaml +++ b/examples/azure_signtool/construct.yaml @@ -2,10 +2,10 @@ "$schema": "../../constructor/data/construct.schema.json" name: Signed_AzureSignTool -version: X +version: 1.0.0 installer_type: exe channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python windows_signing_tool: azuresigntool # [win] diff --git a/examples/custom_nsis_template/construct.yaml b/examples/custom_nsis_template/construct.yaml index 4b8eab0b4..4a59f423a 100644 --- a/examples/custom_nsis_template/construct.yaml +++ b/examples/custom_nsis_template/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: custom -version: X +version: 1.0.0 ignore_duplicate_files: True installer_filename: {{ name }}-installer.exe installer_type: exe diff --git a/examples/custom_nsis_template/custom.nsi.tmpl b/examples/custom_nsis_template/custom.nsi.tmpl index ff6526fc9..1ed5b41f3 100644 --- a/examples/custom_nsis_template/custom.nsi.tmpl +++ b/examples/custom_nsis_template/custom.nsi.tmpl @@ -55,8 +55,6 @@ Unicode "true" # OptionsDialog.nsh plug-in constructor uses !define PRE_INSTALL_DESC __PRE_INSTALL_DESC__ !define POST_INSTALL_DESC __POST_INSTALL_DESC__ -!define SHOW_REGISTER_PYTHON __SHOW_REGISTER_PYTHON__ -!define SHOW_ADD_TO_PATH __SHOW_ADD_TO_PATH__ !define PRODUCT_NAME "${NAME} Uninstaller Patch" !define UNINSTREG "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\" diff --git a/examples/customize_controls/construct.yaml b/examples/customize_controls/construct.yaml index 074c6e8de..161ac88e9 100644 --- a/examples/customize_controls/construct.yaml +++ b/examples/customize_controls/construct.yaml @@ -3,10 +3,10 @@ name: NoCondaOptions version: X -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python diff --git a/examples/customized_welcome_conclusion/construct.yaml b/examples/customized_welcome_conclusion/construct.yaml index 79f55f943..f02e55265 100644 --- a/examples/customized_welcome_conclusion/construct.yaml +++ b/examples/customized_welcome_conclusion/construct.yaml @@ -2,10 +2,10 @@ "$schema": "../../constructor/data/construct.schema.json" name: CustomizedWelcomeConclusion -version: X +version: 1.0.0 installer_type: all channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python conclusion_file: custom_conclusion.nsi # [win] diff --git a/examples/exe_extra_pages/construct.yaml b/examples/exe_extra_pages/construct.yaml index 862cb1d9b..957e926c3 100644 --- a/examples/exe_extra_pages/construct.yaml +++ b/examples/exe_extra_pages/construct.yaml @@ -7,10 +7,10 @@ {% set name = "extraPageSingle" %} {% endif %} name: {{ name }} -version: X +version: 1.0.0 installer_type: all channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python {% if os.environ.get("POST_INSTALL_PAGES_LIST") %} diff --git a/examples/extra_envs/construct.yaml b/examples/extra_envs/construct.yaml index aedaf28ff..58d1d5c3a 100644 --- a/examples/extra_envs/construct.yaml +++ b/examples/extra_envs/construct.yaml @@ -2,20 +2,20 @@ "$schema": "../../constructor/data/construct.schema.json" name: ExtraEnvs -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - https://conda.anaconda.org/conda-forge specs: - - python=3.9 + - python=3.10 - conda # conda is required for extra_envs - - miniforge_console_shortcut # [win] + - miniforge_console_shortcut 1.* # [win] exclude: # [unix] - tk # [unix] extra_envs: - py310: + py311: specs: - - python=3.10 + - python=3.11 - pip channels: - conda-forge @@ -33,10 +33,10 @@ build_outputs: - info.json - pkgs_list - pkgs_list: - env: py310 + env: py311 - lockfile - lockfile: - env: py310 + env: py311 - licenses: include_text: True text_errors: replace diff --git a/examples/extra_envs/test_install.bat b/examples/extra_envs/test_install.bat index ecf8eeed7..d2d4336a5 100644 --- a/examples/extra_envs/test_install.bat +++ b/examples/extra_envs/test_install.bat @@ -1,12 +1,12 @@ echo Added by test-install script > "%PREFIX%\test_install_sentinel.txt" -:: base env has python 3.9 +:: base env has python 3.10 if not exist "%PREFIX%\conda-meta\history" exit 1 -"%PREFIX%\python.exe" -c "from sys import version_info; assert version_info[:2] == (3, 9)" || goto :error +"%PREFIX%\python.exe" -c "from sys import version_info; assert version_info[:2] == (3, 10)" || goto :error -:: extra env named 'py310' has python 3.10 -if not exist "%PREFIX%\envs\py310\conda-meta\history" exit 1 -"%PREFIX%\envs\py310\python.exe" -c "from sys import version_info; assert version_info[:2] == (3, 10)" || goto :error +:: extra env named 'py311' has python 3.11 +if not exist "%PREFIX%\envs\py311\conda-meta\history" exit 1 +"%PREFIX%\envs\py311\python.exe" -c "from sys import version_info; assert version_info[:2] == (3, 11)" || goto :error :: extra env named 'dav1d' only contains dav1d, no python if not exist "%PREFIX%\envs\dav1d\conda-meta\history" exit 1 diff --git a/examples/extra_envs/test_install.sh b/examples/extra_envs/test_install.sh index 7cfef8278..a3c84d744 100644 --- a/examples/extra_envs/test_install.sh +++ b/examples/extra_envs/test_install.sh @@ -4,9 +4,9 @@ set -euxo pipefail echo "Added by test-install script" > "$PREFIX/test_install_sentinel.txt" # tests -# base environment uses python 3.9 and excludes tk +# base environment uses python 3.10 and excludes tk test -f "$PREFIX/conda-meta/history" -"$PREFIX/bin/python" -c "from sys import version_info; assert version_info[:2] == (3, 9)" +"$PREFIX/bin/python" -c "from sys import version_info; assert version_info[:2] == (3, 10)" # we use python -m pip instead of the pip entry point # because the spaces break the shebang - this will be fixed # with a new conda release, but for now this is the workaround @@ -16,12 +16,12 @@ test -f "$PREFIX/conda-meta/history" "$PREFIX/bin/python" -m conda list -p "$PREFIX" | jq -e '.[] | select(.name == "tk")' && exit 1 echo "Previous test failed as expected" -# extra env named 'py310' uses python 3.10, has tk, but we removed setuptools -test -f "$PREFIX/envs/py310/conda-meta/history" -"$PREFIX/envs/py310/bin/python" -c "from sys import version_info; assert version_info[:2] == (3, 10)" +# extra env named 'py311' uses python 3.11, has tk, but we removed setuptools +test -f "$PREFIX/envs/py311/conda-meta/history" +"$PREFIX/envs/py311/bin/python" -c "from sys import version_info; assert version_info[:2] == (3, 11)" # setuptools shouldn't be listed by conda! -"$PREFIX/bin/python" -m conda list -p "$PREFIX/envs/py310" | jq -e '.[] | select(.name == "setuptools")' && exit 1 -"$PREFIX/envs/py310/bin/python" -c "import setuptools" && exit 1 +"$PREFIX/bin/python" -m conda list -p "$PREFIX/envs/py311" | jq -e '.[] | select(.name == "setuptools")' && exit 1 +"$PREFIX/envs/py311/bin/python" -c "import setuptools" && exit 1 echo "Previous test failed as expected" # this env only contains dav1d, no python; it should have been created with no errors, diff --git a/examples/extra_files/TEST_LICENSE.txt b/examples/extra_files/TEST_LICENSE.txt new file mode 100644 index 000000000..85588b6a7 --- /dev/null +++ b/examples/extra_files/TEST_LICENSE.txt @@ -0,0 +1 @@ +This file only exists for testing the .sh and .pkg installers. diff --git a/examples/extra_files/construct.yaml b/examples/extra_files/construct.yaml index 0bcbd2b6d..fa82dbfad 100644 --- a/examples/extra_files/construct.yaml +++ b/examples/extra_files/construct.yaml @@ -2,12 +2,13 @@ "$schema": "../../constructor/data/construct.schema.json" name: ExtraFiles -version: X +version: 1.0.0 installer_type: all +license_file: TEST_LICENSE.txt check_path_spaces: False check_path_length: False channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python extra_files: diff --git a/examples/extra_files/test_install.bat b/examples/extra_files/test_install.bat index 186b63724..d294cf364 100644 --- a/examples/extra_files/test_install.bat +++ b/examples/extra_files/test_install.bat @@ -2,3 +2,4 @@ echo Added by test-install script > "%PREFIX%\test_install_sentinel.txt" if not exist "%PREFIX%\more_data\README.md" exit 1 if not exist "%PREFIX%\something2.txt" exit 1 +if not exist "%PREFIX%\TEST_LICENSE.txt" exit 1 diff --git a/examples/extra_files/test_install.sh b/examples/extra_files/test_install.sh index e44f6fdb2..b2e99e55e 100644 --- a/examples/extra_files/test_install.sh +++ b/examples/extra_files/test_install.sh @@ -2,5 +2,33 @@ set -euxo pipefail echo "Added by test-install script" > "$PREFIX/test_install_sentinel.txt" -test -f "$PREFIX/more_data/README.md" -test -f "$PREFIX/something2.txt" +missing=false + +if [ ! -f "$PREFIX/more_data/README.md" ]; then + echo "Missing: $PREFIX/more_data/README.md" + missing=true +fi + +if [ ! -f "$PREFIX/something2.txt" ]; then + echo "Missing: $PREFIX/something2.txt" + missing=true +fi + +# Ideally we should test the .pkg and .sh installers separately since +# the current behavior for .sh-installers is to include but also rename the license file to LICENSE.txt, +# but for .pkg the name of the provided license file remains unchanged. +if [ "$INSTALLER_TYPE" = "SH" ]; then + if [ ! -f "$PREFIX/LICENSE.txt" ]; then + echo "Missing: $PREFIX/LICENSE.txt" + missing=true + fi +else # .pkg + if [ ! -f "$PREFIX/TEST_LICENSE.txt" ]; then + echo "Missing: $PREFIX/TEST_LICENSE.txt" + missing=true + fi +fi + +if [ "$missing" = true ]; then + exit 1 +fi diff --git a/examples/from_env_txt/construct.yaml b/examples/from_env_txt/construct.yaml index ee8412dc7..5cb7ae774 100644 --- a/examples/from_env_txt/construct.yaml +++ b/examples/from_env_txt/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: EnvironmentTXT -version: X +version: 1.0.0 installer_type: all environment_file: env.txt initialize_by_default: false diff --git a/examples/from_env_yaml/construct.yaml b/examples/from_env_yaml/construct.yaml index d86bdeafb..6711be0c9 100644 --- a/examples/from_env_yaml/construct.yaml +++ b/examples/from_env_yaml/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: EnvironmentYAML -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} environment_file: env.yaml initialize_by_default: false register_python: False diff --git a/examples/from_env_yaml/env.yaml b/examples/from_env_yaml/env.yaml index fc6253531..d919343da 100644 --- a/examples/from_env_yaml/env.yaml +++ b/examples/from_env_yaml/env.yaml @@ -2,5 +2,5 @@ name: testenv channels: - defaults dependencies: - - python=3.9 + - python=3.10 - conda=23.3 diff --git a/examples/from_existing_env/construct.yaml b/examples/from_existing_env/construct.yaml index e60d00945..b45540a0b 100644 --- a/examples/from_existing_env/construct.yaml +++ b/examples/from_existing_env/construct.yaml @@ -1,10 +1,10 @@ # yaml-language-server: $schema=../../constructor/data/construct.schema.json "$schema": "../../constructor/data/construct.schema.json" name: Existing -version: X +version: 1.0.0 installer_type: all environment: {{ os.environ.get("CONSTRUCTOR_TEST_EXISTING_ENV", os.environ["CONDA_PREFIX"]) }} channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ initialize_by_default: false register_python: False diff --git a/examples/from_explicit/construct.yaml b/examples/from_explicit/construct.yaml index 9137fa8f7..6e07790cd 100644 --- a/examples/from_explicit/construct.yaml +++ b/examples/from_explicit/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Explicit -version: X +version: 1.0.0 installer_type: all environment_file: explicit_linux-64.txt initialize_by_default: false diff --git a/examples/from_explicit/explicit_osx-arm64.txt b/examples/from_explicit/explicit_osx-arm64.txt deleted file mode 100644 index 891d899c9..000000000 --- a/examples/from_explicit/explicit_osx-arm64.txt +++ /dev/null @@ -1,19 +0,0 @@ -# This file may be used to create an environment using: -# $ conda create --name --file -# platform: osx-arm64 -@EXPLICIT -https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2021.5.30-h4653dfc_0.tar.bz2#21b35f488f8ccf40c7d3ae05303e24e5 -https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.2-h9aa5885_4.tar.bz2#8a4aa25a5741e65f2338204f8a45d8d8 -https://conda.anaconda.org/conda-forge/noarch/tzdata-2021a-he74cb21_0.tar.bz2#6f36861f102249fc54861ff9343c3fdd -https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.5-h642e427_1.tar.bz2#9ab2316785cb81c464ab9a99512dae71 -https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.2.11-h31e879b_1009.tar.bz2#96796f31644a5e13e12dc194284f7681 -https://conda.anaconda.org/conda-forge/osx-arm64/openssl-1.1.1k-h27ca646_0.tar.bz2#e056be01e85706eeb056d7b979b4a30d -https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.1-hfbdcbf2_0.tar.bz2#c9b0df52c6942e7c7066667fb4ec7404 -https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.10-hf7e6567_1.tar.bz2#42c39bbf010ef4fa5839c61a19535980 -https://conda.anaconda.org/conda-forge/osx-arm64/sqlite-3.35.5-hc49ca36_0.tar.bz2#f7357f92c4a3799f14763cad1d605e64 -https://conda.anaconda.org/conda-forge/osx-arm64/python-3.9.4-h5b20da3_0_cpython.tar.bz2#eee24d5270bca46d9f003f7be5f07de6 -https://conda.anaconda.org/conda-forge/osx-arm64/python_abi-3.9-1_cp39.tar.bz2#06e5b68ca789e3b7910c3f09cdffda7c -https://conda.anaconda.org/conda-forge/noarch/wheel-0.36.2-pyhd3deb0d_0.tar.bz2#768bfbe026426d0e76b377997d1f2b98 -https://conda.anaconda.org/conda-forge/osx-arm64/certifi-2021.5.30-py39h2804cbe_0.tar.bz2#402804274647d0b524c3de7e46b6162a -https://conda.anaconda.org/conda-forge/osx-arm64/setuptools-49.6.0-py39h2804cbe_3.tar.bz2#b2268b9c73391e6199dc6936da31cddf -https://conda.anaconda.org/conda-forge/noarch/pip-21.1.2-pyhd8ed1ab_0.tar.bz2#dbd830edaffe5fc9ae6c1d425db2b5f2 diff --git a/examples/grin/construct.yaml b/examples/grin/construct.yaml deleted file mode 100644 index 553ffa45e..000000000 --- a/examples/grin/construct.yaml +++ /dev/null @@ -1,66 +0,0 @@ -# yaml-language-server: $schema=../../constructor/data/construct.schema.json -"$schema": "../../constructor/data/construct.schema.json" - -# name and version (required) -name: test -version: 3 - -# channels to pull packages from -channels: &id1 - - http://repo.anaconda.com/pkgs/main/ - - https://conda.anaconda.org/ilan - -# specifications -specs: - - python - - grin - - sample # [osx] - -# exclude these packages (list of names) -exclude: - - openssl # [unix] - - readline # [unix] - - tk # [unix] - - python - -# explicitly listed packages -# pkgs.txt -packages: - - python-2.7.9-0.tar.bz2 - -keep_pkgs: True - -pre_install: hello.sh # [unix] -post_install: goodbye.sh # [unix] -post_install: test-post.bat # [win] - -# The conda default channels which are used when running a conda which -# was installed be the constructor created (requires conda in the -# specifications) installer -conda_default_channels: *id1 - -# type of the installer being created. Possible values are "sh", "pkg", -# and "exe". By default, the type is "sh" on Unix, and "exe" on Windows. -installer_type: pkg # [osx] - -# installer filename (a reasonable default filename will determined by -# the `name`, (optional) `version`, OS and installer type) -#installer_filename: grin.sh - -# a file with a license text, which is shown during the install process -license_file: eula.txt - -# default install prefix -#default_prefix: /opt/example - -# If `welcome_image` or `header_image` are not provided, their texts -# default to `name`, which may be overridden by the following keys -#welcome_image_text: |- -# multi-line -# welcome-text -#header_image_text: |- -# multi-line -# header-text - -check_path_spaces: False -check_path_length: False diff --git a/examples/grin/eula.txt b/examples/grin/eula.txt deleted file mode 100644 index aa06ac8a7..000000000 --- a/examples/grin/eula.txt +++ /dev/null @@ -1,24 +0,0 @@ -Copyright (c) 2016, ... -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of ... nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL ... BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/examples/grin/goodbye.sh b/examples/grin/goodbye.sh deleted file mode 100644 index 1afa24798..000000000 --- a/examples/grin/goodbye.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -euxo pipefail - -echo "Goodbye: PREFIX='$PREFIX'" diff --git a/examples/grin/hello.sh b/examples/grin/hello.sh deleted file mode 100644 index f34e57aaf..000000000 --- a/examples/grin/hello.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -set -euxo pipefail - -echo "Hello: PREFIX='$PREFIX'" -echo "LD_LIBRARY_PATH: ${LD_LIBRARY_PATH:-}" -echo "OLD_LD_LIBRARY_PATH: ${OLD_LD_LIBRARY_PATH:-}" diff --git a/examples/grin/pkgs.txt b/examples/grin/pkgs.txt deleted file mode 100644 index 51ad9c750..000000000 --- a/examples/grin/pkgs.txt +++ /dev/null @@ -1,17 +0,0 @@ -# This file may be used to create an environment using: -# $ conda create --name --file -# platform: osx-64 - -#conda=3.17.0=py27_1 - -conda=3.17.0=py27_0 -conda-build=1.17.0=py27_0 - -#https://repo.anaconda.com/pkgs/main/osx-64/openssl-1.0.2o-h26aff7b_0.tar.bz2 -https://repo.anaconda.com/pkgs/main/osx-64/pip-10.0.1-py27_0.tar.bz2 - -pycosat-0.6.1-py27_0.tar.bz2 - -#readline-6.2-2.tar.bz2#0801e644bd0c1cd7f0923b56c52eb7f7 - -https://repo.anaconda.com/pkgs/main/osx-64/yaml-0.1.7-hc338f04_2.tar.bz2#dab654341f57e56b615a678800262b0e diff --git a/examples/grin/test-post.bat b/examples/grin/test-post.bat deleted file mode 100644 index 8a667b30c..000000000 --- a/examples/grin/test-post.bat +++ /dev/null @@ -1 +0,0 @@ -echo "Hello World!" > %PREFIX%\HELLO.txt diff --git a/examples/initialization/construct.yaml b/examples/initialization/construct.yaml index 1e980532f..8f38e6631 100644 --- a/examples/initialization/construct.yaml +++ b/examples/initialization/construct.yaml @@ -18,4 +18,4 @@ initialize_by_default: true register_python: false check_path_spaces: true check_path_length: false -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} diff --git a/examples/jetsonconda/EULA.txt b/examples/jetsonconda/EULA.txt deleted file mode 100644 index a31699711..000000000 --- a/examples/jetsonconda/EULA.txt +++ /dev/null @@ -1,24 +0,0 @@ -Copyright (c) 2016, Anaconda, Inc. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of Anaconda, Inc. nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL ANACONDA, INC. BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/examples/jetsonconda/README.md b/examples/jetsonconda/README.md deleted file mode 100644 index bf403ae53..000000000 --- a/examples/jetsonconda/README.md +++ /dev/null @@ -1,33 +0,0 @@ -Jetsonconda example -================= - -In this example, we want to demonstrate how to build Jetsonconda installers for -Linux, Mac and Windows. - -We only want to construct installers which include: - - Python 3.5 (because Python 3 is the way of the future) - - `conda`, so people can install additional packages - - `numpy` (but not the MKL linked version, to save space) - - `scipy`, `pandas` - - the Jupyter `notebook` - - `matplotlib` (but not Qt and PyQt, again to save space) - - `lighttpd`, the web server, but only on Unix systems - -We also want to include our license file `EULA.txt`, which located in -this directory. -Also, we want to have a our own welcome image for the Windows installer. -This image `bird.png` is also located in this directory, and is re-sized -by constructor as well. - -Finally, to create a Jetsonconda installer, you simply run (in this directory): - - $ constructor . - ... - $ ls -lh Jetson* - -rwxr-xr-x 1 ilan staff 59M Feb 27 18:02 Jetsonconda-2.5.5-MacOSX-x86_64.sh - -This was done on Mac OS X. -A 60MB installer is not bad for all these packages, I would say. -Note that `constructor` will be default create an installer for the platform -which it is executed on. However, it is also possible to build installers -for other platforms, see the platform key. diff --git a/examples/jetsonconda/bird.png b/examples/jetsonconda/bird.png deleted file mode 100644 index 2efe0f30a..000000000 Binary files a/examples/jetsonconda/bird.png and /dev/null differ diff --git a/examples/jetsonconda/construct.yaml b/examples/jetsonconda/construct.yaml deleted file mode 100644 index ee1b5b49f..000000000 --- a/examples/jetsonconda/construct.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# yaml-language-server: $schema=../../constructor/data/construct.schema.json -"$schema": "../../constructor/data/construct.schema.json" - -name: JetsonConda -version: 0.1 - -channels: - - https://conda.anaconda.org/aarch64_gbox - -specs: - - python 3.6* - - conda # [aarch64] - - numpy # [aarch64] - - scipy # [aarch64] - - pandas # [aarch64] - - notebook # [aarch64] - - matplotlib # [aarch64] - - freetype # [aarch64] - -#license_file: EULA.txt - -# Welcome image for Windows installer -welcome_image: bird.png # [win] diff --git a/examples/miniconda/EULA.txt b/examples/miniconda/EULA.txt deleted file mode 100644 index a31699711..000000000 --- a/examples/miniconda/EULA.txt +++ /dev/null @@ -1,24 +0,0 @@ -Copyright (c) 2016, Anaconda, Inc. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of Anaconda, Inc. nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL ANACONDA, INC. BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/examples/miniconda/README.md b/examples/miniconda/README.md deleted file mode 100644 index 86087cf73..000000000 --- a/examples/miniconda/README.md +++ /dev/null @@ -1,34 +0,0 @@ -Miniconda example -================= - -In this example, we want to demonstrate how to build installers for -Linux, Mac and Windows, which are similar to Anaconda installers, but -significantly smaller in size. - -We only want to construct installers which include: - - Python 3.5 (because Python 3 is the way of the future) - - `conda`, so people can install additional packages - - `numpy` (but not the MKL linked version, to save space) - - `scipy`, `pandas` - - the Jupyter `notebook` - - `matplotlib` (but not Qt and PyQt, again to save space) - - `lighttpd`, the web server, but only on Unix systems - -We also want to include our license file `EULA.txt`, which located in -this directory. -Also, we want to have a our own welcome image for the Windows installer. -This image `bird.png` is also located in this directory, and is re-sized -by constructor as well. - -Finally, to create a Miniconda installer, you simply run (in this directory): - - $ constructor . - ... - $ ls -lh Mini* - -rwxr-xr-x 1 ilan staff 59M Feb 27 18:02 Miniconda-2.5.5-MacOSX-x86_64.sh - -This was done on Mac OS X. -A 60MB installer is not bad for all these packages, I would say. -Note that `constructor` will be default create an installer for the platform -which it is executed on. However, it is also possible to build installers -for other platforms, see the platform key. diff --git a/examples/miniconda/construct.yaml b/examples/miniconda/construct.yaml deleted file mode 100644 index 7c5dad48b..000000000 --- a/examples/miniconda/construct.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# yaml-language-server: $schema=../../constructor/data/construct.schema.json -"$schema": "../../constructor/data/construct.schema.json" - -name: MinicondaX -version: X -installer_type: all - -channels: - - http://repo.anaconda.com/pkgs/main/ - -specs: - - python - - conda diff --git a/examples/miniforge-mamba2/construct.yaml b/examples/miniforge-mamba2/construct.yaml index 98feefec3..becb523f4 100644 --- a/examples/miniforge-mamba2/construct.yaml +++ b/examples/miniforge-mamba2/construct.yaml @@ -21,7 +21,7 @@ specs: - miniforge_console_shortcut 1.* # [win] # Added for extra testing -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} post_install: test_install.sh # [unix] post_install: test_install.bat # [win] initialize_by_default: false diff --git a/examples/miniforge/construct.yaml b/examples/miniforge/construct.yaml index eb894cc91..52b961da9 100644 --- a/examples/miniforge/construct.yaml +++ b/examples/miniforge/construct.yaml @@ -21,7 +21,7 @@ specs: - miniforge_console_shortcut 1.* # [win] # Added for extra testing -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} post_install: test_install.sh # [unix] post_install: test_install.bat # [win] initialize_by_default: false diff --git a/examples/mirrored_channels/construct.yaml b/examples/mirrored_channels/construct.yaml index f105c6d0c..6e7ab9d81 100644 --- a/examples/mirrored_channels/construct.yaml +++ b/examples/mirrored_channels/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Mirrors -version: X +version: 1.0.0 channels: - conda-forge diff --git a/examples/newchan/construct.yaml b/examples/newchan/construct.yaml deleted file mode 100644 index bd32546ed..000000000 --- a/examples/newchan/construct.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# yaml-language-server: $schema=../../constructor/data/construct.schema.json -"$schema": "../../constructor/data/construct.schema.json" - -name: Funnychan -version: 2.5.5 - -channels: - - http://repo.anaconda.com/pkgs/main/ - - https://conda.anaconda.org/ilan - -# specifications -specs: - - python - - grin - - sample - -license_file: eula.txt - -check_path_spaces: False -check_path_length: False diff --git a/examples/newchan/eula.txt b/examples/newchan/eula.txt deleted file mode 100644 index aa06ac8a7..000000000 --- a/examples/newchan/eula.txt +++ /dev/null @@ -1,24 +0,0 @@ -Copyright (c) 2016, ... -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of ... nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL ... BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/examples/noconda/constructor_input.yaml b/examples/noconda/constructor_input.yaml index 5e3fa6fd3..b5641de01 100644 --- a/examples/noconda/constructor_input.yaml +++ b/examples/noconda/constructor_input.yaml @@ -2,10 +2,10 @@ "$schema": "../../constructor/data/construct.schema.json" name: NoConda -version: X +version: 1.0.0 installer_type: all channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python exclude: # [unix] diff --git a/examples/osxpkg/construct.yaml b/examples/osxpkg/construct.yaml index d9ddae385..7c786e0b0 100644 --- a/examples/osxpkg/construct.yaml +++ b/examples/osxpkg/construct.yaml @@ -10,7 +10,7 @@ default_location_pkg: Library pkg_name: "osx-pkg-test" channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ attempt_hardlinks: True diff --git a/examples/osxpkg_extra_pages/construct.yaml b/examples/osxpkg_extra_pages/construct.yaml index 201fe8969..2b6023892 100644 --- a/examples/osxpkg_extra_pages/construct.yaml +++ b/examples/osxpkg_extra_pages/construct.yaml @@ -10,7 +10,7 @@ default_location_pkg: Library pkg_name: "osx-pkg-test" channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ attempt_hardlinks: True diff --git a/examples/outputs/construct.yaml b/examples/outputs/construct.yaml index 01aa24a0a..9080dc36d 100644 --- a/examples/outputs/construct.yaml +++ b/examples/outputs/construct.yaml @@ -2,7 +2,7 @@ "$schema": "../../constructor/data/construct.schema.json" name: Outputs -version: X +version: 1.0.0 installer_type: sh # [unix] installer_type: exe # [win] channels: diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index c43044761..fcccc41f1 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -2,8 +2,8 @@ "$schema": "../../constructor/data/construct.schema.json" name: ProtectedBaseEnv -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - defaults diff --git a/examples/register_envs/construct.yaml b/examples/register_envs/construct.yaml index b55eae9ea..31d72c9f6 100644 --- a/examples/register_envs/construct.yaml +++ b/examples/register_envs/construct.yaml @@ -2,10 +2,10 @@ "$schema": "../../constructor/data/construct.schema.json" name: RegisterEnvs -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python register_envs: false diff --git a/examples/scripts/construct.yaml b/examples/scripts/construct.yaml index 935b1f40b..d55057bbd 100644 --- a/examples/scripts/construct.yaml +++ b/examples/scripts/construct.yaml @@ -2,10 +2,10 @@ "$schema": "../../constructor/data/construct.schema.json" name: Scripts -version: X -installer_type: all +version: 1.0.0 +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python diff --git a/examples/scripts/post_install.bat b/examples/scripts/post_install.bat index 7916add93..9739b6a5f 100644 --- a/examples/scripts/post_install.bat +++ b/examples/scripts/post_install.bat @@ -1,6 +1,6 @@ echo Added by post-install script > "%PREFIX%\post_install_sentinel.txt" if not "%INSTALLER_NAME%" == "Scripts" exit 1 -if not "%INSTALLER_VER%" == "X" exit 1 +if not "%INSTALLER_VER%" == "1.0.0" exit 1 if not "%INSTALLER_PLAT%" == "win-64" exit 1 if not "%INSTALLER_TYPE%" == "EXE" exit 1 if not "%INSTALLER_UNATTENDED%" == "1" exit 1 diff --git a/examples/scripts/post_install.sh b/examples/scripts/post_install.sh index 18d632a63..5880488c7 100644 --- a/examples/scripts/post_install.sh +++ b/examples/scripts/post_install.sh @@ -15,7 +15,7 @@ echo "CUSTOM_VARIABLE_2=${CUSTOM_VARIABLE_2}" echo "PREFIX=${PREFIX}" test "${INSTALLER_NAME}" = "Scripts" -test "${INSTALLER_VER}" = "X" +test "${INSTALLER_VER}" = "1.0.0" # shellcheck disable=SC2016 # String interpolation disabling is deliberate test "${CUSTOM_VARIABLE_1}" = 'FIR$T-CUSTOM_'\''STRING'\'' WITH SPACES AND @*! "CHARACTERS"' # shellcheck disable=SC2016 # String interpolation disabling is deliberate @@ -23,12 +23,43 @@ test "${CUSTOM_VARIABLE_2}" = '$ECOND-CUSTOM_'\''STRING'\'' WITH SPACES AND @*! test "${INSTALLER_UNATTENDED}" = "1" -if [[ $(uname -s) == Linux ]]; then +# Print to stderr if any of the input variables are set, and returns 1 - otherwise 0. +# Note that variables that are set but are empty strings will also trigger an error. +# All input variables are checked before exit. +verify_var_is_unset() { + local failed=0 + for var in "$@"; do + if [[ -n "${!var+x}" ]]; then + echo "Error: environment variable $var must be unset." >&2 + failed=1 + fi + done + return $failed +} + +if [[ $(uname -s) == "Linux" ]]; then if [[ ${INSTALLER_PLAT} != linux-* ]]; then + echo "Error: INSTALLER_PLAT must match 'linux-*' on Linux systems." + exit 1 + fi + + if ! verify_var_is_unset LD_LIBRARY_PATH LD_PRELOAD LD_AUDIT; then + echo "Error: One or more of LD_LIBRARY_PATH, LD_PRELOAD, or LD_AUDIT are set." exit 1 fi + else # macOS if [[ ${INSTALLER_PLAT} != osx-* ]]; then + echo "Error: INSTALLER_PLAT must match 'osx-*' on macOS systems." + exit 1 + fi + + if ! verify_var_is_unset \ + DYLD_LIBRARY_PATH \ + DYLD_FALLBACK_LIBRARY_PATH \ + DYLD_INSERT_LIBRARIES \ + DYLD_FRAMEWORK_PATH; then + echo "Error: One or more DYLD_* environment variables are set." exit 1 fi fi diff --git a/examples/scripts/pre_install.bat b/examples/scripts/pre_install.bat index ec4fce07c..22a529daa 100644 --- a/examples/scripts/pre_install.bat +++ b/examples/scripts/pre_install.bat @@ -1,5 +1,5 @@ if not "%INSTALLER_NAME%" == "Scripts" exit 1 -if not "%INSTALLER_VER%" == "X" exit 1 +if not "%INSTALLER_VER%" == "1.0.0" exit 1 if not "%INSTALLER_PLAT%" == "win-64" exit 1 if not "%INSTALLER_TYPE%" == "EXE" exit 1 if not "%INSTALLER_UNATTENDED%" == "1" exit 1 diff --git a/examples/scripts/pre_install.sh b/examples/scripts/pre_install.sh index 753db8121..88b111630 100644 --- a/examples/scripts/pre_install.sh +++ b/examples/scripts/pre_install.sh @@ -12,7 +12,7 @@ echo "CUSTOM_VARIABLE_2=${CUSTOM_VARIABLE_2}" echo "PREFIX=${PREFIX}" test "${INSTALLER_NAME}" = "Scripts" -test "${INSTALLER_VER}" = "X" +test "${INSTALLER_VER}" = "1.0.0" # shellcheck disable=SC2016 # String interpolation disabling is deliberate test "${CUSTOM_VARIABLE_1}" = 'FIR$T-CUSTOM_'\''STRING'\'' WITH SPACES AND @*! "CHARACTERS"' # shellcheck disable=SC2016 # String interpolation disabling is deliberate diff --git a/examples/shortcuts/construct.yaml b/examples/shortcuts/construct.yaml index b237e83c2..e7c8877f4 100644 --- a/examples/shortcuts/construct.yaml +++ b/examples/shortcuts/construct.yaml @@ -3,11 +3,11 @@ name: MinicondaWithShortcuts version: X -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} channels: - conda-test/label/menuinst-tests - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python diff --git a/examples/signing/construct.yaml b/examples/signing/construct.yaml index 06ce44d00..0889a3387 100644 --- a/examples/signing/construct.yaml +++ b/examples/signing/construct.yaml @@ -2,10 +2,10 @@ "$schema": "../../constructor/data/construct.schema.json" name: Signed -version: X +version: 1.0.0 installer_type: all channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - python # This certificate is generated and copied over on the spot during CI diff --git a/examples/use_channel_remap/construct.yaml b/examples/use_channel_remap/construct.yaml index bf4aa7f2b..667f6658a 100644 --- a/examples/use_channel_remap/construct.yaml +++ b/examples/use_channel_remap/construct.yaml @@ -10,10 +10,10 @@ keep_pkgs: True # we just remap the main conda channel, to show the idea of remap channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ channels_remap: - - src: http://repo.anaconda.com/pkgs/main/ + - src: https://repo.anaconda.com/pkgs/main/ dest: file:///usr/local/share/private_repo/ specs: diff --git a/examples/virtual_specs_failed/construct.yaml b/examples/virtual_specs_failed/construct.yaml index f3b554872..12d886b9f 100644 --- a/examples/virtual_specs_failed/construct.yaml +++ b/examples/virtual_specs_failed/construct.yaml @@ -22,4 +22,4 @@ initialize_by_default: false register_python: false check_path_spaces: false check_path_length: false -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} diff --git a/examples/virtual_specs_ok/construct.yaml b/examples/virtual_specs_ok/construct.yaml index 41635eefc..15655811b 100644 --- a/examples/virtual_specs_ok/construct.yaml +++ b/examples/virtual_specs_ok/construct.yaml @@ -22,4 +22,4 @@ initialize_by_default: false register_python: false check_path_spaces: false check_path_length: false -installer_type: all +installer_type: {{ "exe" if os.name == "nt" else "all" }} diff --git a/news/1036-check-path-length-docs b/news/1036-check-path-length-docs deleted file mode 100644 index 097b13c83..000000000 --- a/news/1036-check-path-length-docs +++ /dev/null @@ -1,20 +0,0 @@ -### Enhancements - -* - -### Bug fixes - -* - -### Deprecations - -* - -### Docs - -* Document that `check_path_length` defaults to `False` in line with prior behavior and declare - it as `bool` only in the schema. (#1036) - -### Other - -* diff --git a/news/1040-misleading-conda-init-prompt b/news/1040-misleading-conda-init-prompt deleted file mode 100644 index 8ca55535d..000000000 --- a/news/1040-misleading-conda-init-prompt +++ /dev/null @@ -1,19 +0,0 @@ -### Enhancements - -* - -### Bug fixes - -* SH: Fixed misleading wording for shell initialization in installation prompt. (#1039 via #1340) - -### Deprecations - -* - -### Docs - -* - -### Other - -* diff --git a/news/1053-fix-typo-sh b/news/1053-fix-typo-sh deleted file mode 100644 index aba0dd64d..000000000 --- a/news/1053-fix-typo-sh +++ /dev/null @@ -1,19 +0,0 @@ -### Enhancements - -* - -### Bug fixes - -* - -### Deprecations - -* - -### Docs - -* - -### Other - -* Fix typo in license prompt message for SH installers. (#1035 via $1053) diff --git a/news/1058-add-support-for-frozen-envs b/news/1058-add-support-for-frozen-envs deleted file mode 100644 index 1c8e37ef6..000000000 --- a/news/1058-add-support-for-frozen-envs +++ /dev/null @@ -1,19 +0,0 @@ -### Enhancements - -* Add support for installing [protected conda environments](https://conda.org/learn/ceps/cep-0022#specification). (#1058) - -### Bug fixes - -* - -### Deprecations - -* - -### Docs - -* - -### Other - -* diff --git a/news/1059-lockfiles b/news/1059-lockfiles deleted file mode 100644 index e4586e8ad..000000000 --- a/news/1059-lockfiles +++ /dev/null @@ -1,19 +0,0 @@ -### Enhancements - -* Ship `conda-meta/initial-state.explicit.txt` as a copy of the lockfile that provisions the initial state of each environment. (#1052 via #1059) - -### Bug fixes - -* - -### Deprecations - -* - -### Docs - -* - -### Other - -* diff --git a/news/1068-remove-obsolete-nsispy-functions b/news/1068-remove-obsolete-nsispy-functions deleted file mode 100644 index e1b9d3f77..000000000 --- a/news/1068-remove-obsolete-nsispy-functions +++ /dev/null @@ -1,19 +0,0 @@ -### Enhancements - -* Remove unused functions from `_nsis.py`. (#1068) - -### Bug fixes - -* - -### Deprecations - -* - -### Docs - -* - -### Other - -* diff --git a/news/1073-update-win-sdk b/news/1073-update-win-sdk deleted file mode 100644 index c0bc46d5f..000000000 --- a/news/1073-update-win-sdk +++ /dev/null @@ -1,18 +0,0 @@ -### Enhancements - -* - -### Bug fixes - -* - -### Deprecations - -* - -### Docs - -* - -### Other -* Update signtool.exe path for Windows 2025 runner images (SDK 10.0.26100.0) (#1073) diff --git a/news/1077-use-windows-2022 b/news/1145-python-test-deps similarity index 63% rename from news/1077-use-windows-2022 rename to news/1145-python-test-deps index 6e0eca5a7..f3179bf63 100644 --- a/news/1077-use-windows-2022 +++ b/news/1145-python-test-deps @@ -16,4 +16,4 @@ ### Other -* Use `windows-2022` for integration tests. (#1077) +* Remove Python `3.9` from the testing suite, include Python `3.13`. (#1145) diff --git a/pyproject.toml b/pyproject.toml index 8f48009ac..2c457356d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "constructor" description = "create installer from conda packages" readme = "README.md" license = {text = "BSD-3-Clause"} -requires-python = ">=3.8" +requires-python = ">=3.10" dynamic = [ "version", ] @@ -16,7 +16,9 @@ dependencies = [ "ruamel.yaml >=0.11.14,<0.19", "pillow >=3.1 ; platform_system=='Windows' or platform_system=='Darwin'", "jinja2", - "jsonschema >=4" + "jsonschema >=4", + "briefcase >=0.3.26 ; platform_system=='Windows'", + "tomli-w >=1.2.0 ; platform_system=='Windows'", ] [project.optional-dependencies] @@ -46,7 +48,7 @@ constructor = [ [tool.ruff] line-length = 100 -target-version = "py39" +target-version = "py310" exclude = [ "constructor/nsis/*.py", ] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index c5876d0cd..595ece483 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -17,19 +17,21 @@ build: requirements: host: - - python # >=3.8 + - python # >=3.10 - pip - setuptools >=70.1 - setuptools_scm >=6.2 run: - conda >=4.6 - - python # >=3.8 + - python # >=3.10 - ruamel.yaml >=0.11.14,<0.19 - - conda-standalone + - conda-standalone >=24.1.2 - jinja2 - jsonschema >=4 - pillow >=3.1 # [win or osx] - nsis >=3.08 # [win] + - briefcase >=0.3.26 # [win] + - tomli-w >=1.2.0 # [win] run_constrained: # [unix] - nsis >=3.08 # [unix] - conda-libmamba-solver !=24.11.0 diff --git a/scripts/run_examples.py b/scripts/run_examples.py deleted file mode 100644 index d0555ccff..000000000 --- a/scripts/run_examples.py +++ /dev/null @@ -1,344 +0,0 @@ -#!/usr/bin/env python -"""Run examples bundled with this repo.""" - -import argparse -import os -import platform -import shutil -import subprocess -import sys -import tempfile -import time -import warnings -from datetime import timedelta -from pathlib import Path - -from constructor.utils import rm_rf - -try: - import coverage # noqa - - COV_CMD = ["coverage", "run", "--branch", "--append", "-m"] -except ImportError: - COV_CMD = [] - - -warnings.warn( - "This script is now deprecated and will be removed soon. " - "Please use tests/test_examples.py with pytest.", - DeprecationWarning, -) - - -HERE = os.path.abspath(os.path.dirname(__file__)) -REPO_DIR = os.path.dirname(HERE) -EXAMPLES_DIR = os.path.join(REPO_DIR, "examples") -PY3 = sys.version_info[0] == 3 -WHITELIST = ["grin", "jetsonconda", "miniconda", "newchan"] -BLACKLIST = [] -WITH_SPACES = {"extra_files", "noconda", "signing", "scripts"} - -# .sh installers to also test in interactiv mode -# (require all to a have License = have same interactive input steps) -INTERACTIVE_TESTS = ["miniforge"] -# Test runs with even Python version are done in interactive mode, odd in batch mode -INTERACTIVE_TESTING = (sys.version_info.minor % 2) == 0 - - -def _execute(cmd, installer_input=None, **env_vars): - print(" ".join(cmd)) - t0 = time.time() - if env_vars: - env = os.environ.copy() - env.update(env_vars) - else: - env = None - p = subprocess.Popen( - cmd, - stdin=subprocess.PIPE if installer_input else None, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - ) - try: - stdout, stderr = p.communicate(input=installer_input, timeout=420) - errored = p.returncode != 0 - except subprocess.TimeoutExpired: - p.kill() - stdout, stderr = p.communicate() - print("--- TEST TIMEOUT ---") - errored = True - t1 = time.time() - if errored or "CONDA_VERBOSITY" in env_vars: - print(f"--- RETURNCODE: {p.returncode} ---") - if stdout: - print("--- STDOUT ---") - print(stdout) - if stderr: - print("--- STDERR ---") - print(stderr) - print("--- Done in", timedelta(seconds=t1 - t0)) - return errored - - -def run_examples(keep_artifacts=None, conda_exe=None, debug=False): - """Run examples bundled with the repository. - - Parameters - ---------- - keep_artifacts: str, optional=None - Path where the generated installers will be moved to. - Will be created if it doesn't exist. - - Returns - ------- - int - Number of failed examples - """ - if sys.platform.startswith("win") and "NSIS_USING_LOG_BUILD" not in os.environ: - print( - "! Warning !" - " Windows installers are tested with NSIS in silent mode, which does" - " not report errors on exit. You should use logging-enabled NSIS builds" - " to generate an 'install.log' file this script will search for errors" - " after completion." - ) - example_paths = [] - errored = 0 - if platform.system() != "Darwin": - BLACKLIST.append(os.path.join(EXAMPLES_DIR, "osxpkg")) - if keep_artifacts: - os.makedirs(keep_artifacts, exist_ok=True) - - whitelist = [os.path.join(EXAMPLES_DIR, p) for p in WHITELIST] - for fname in os.listdir(EXAMPLES_DIR): - fpath = os.path.join(EXAMPLES_DIR, fname) - if os.path.isdir(fpath) and fpath not in whitelist and fpath not in BLACKLIST: - if os.path.exists(os.path.join(fpath, "construct.yaml")): - example_paths.append(fpath) - - # NSIS won't error out when running scripts unless - # we set this custom environment variable - os.environ["NSIS_SCRIPTS_RAISE_ERRORS"] = "1" - - parent_output = tempfile.mkdtemp() - tested_files = set() - which_errored = {} - for example_path in sorted(example_paths): - example_name = Path(example_path).name - test_with_spaces = example_name in WITH_SPACES - print(example_name) - print("-" * len(example_name)) - if ( - sys.platform.startswith("win") - and conda_exe - and "micromamba" in os.path.basename(conda_exe).lower() - ): - print( - f"! Skipping {example_name}... Shortcut creation on Windows is " - "not supported with micromamba." - ) - continue - output_dir = tempfile.mkdtemp(prefix=f"{example_name}-", dir=parent_output) - # resolve path to avoid some issues with TEMPDIR on Windows - output_dir = str(Path(output_dir).resolve()) - is_fromenv = os.path.basename(example_path) == "fromenv" - if is_fromenv: - env_file = os.path.join(example_path, "environment.txt") - test_prefix = os.path.join(example_path, "tmp_prefix_fromenv") - cmd = ["conda", "create", "--prefix", test_prefix, "--file", env_file, "--yes"] - errored += _execute(cmd) - cmd = COV_CMD + ["constructor", "-v", example_path, "--output-dir", output_dir] - if conda_exe: - cmd += ["--conda-exe", conda_exe] - if debug: - cmd.append("--debug") - creation_errored = _execute(cmd) - if is_fromenv: - rm_rf(test_prefix) - errored += creation_errored - for fpath in os.listdir(output_dir): - ext = fpath.rsplit(".", 1)[-1] - if fpath in tested_files or ext not in ("sh", "exe", "pkg"): - continue - tested_files.add(fpath) - test_suffix = "s p a c e s" if test_with_spaces else None - env_dir = tempfile.mkdtemp(suffix=test_suffix, dir=output_dir) - rm_rf(env_dir) - fpath = os.path.join(output_dir, fpath) - print("--- Testing", os.path.basename(fpath)) - installer_input = None - if ext == "sh": - if INTERACTIVE_TESTING and example_name in INTERACTIVE_TESTS: - cmd = ["/bin/sh", fpath] - # Input: Enter, yes to the license, installation folder, no to initialize shells - installer_input = f"\nyes\n{env_dir}\nno\n" - else: - cmd = ["/bin/sh", fpath, "-b", "-p", env_dir] - elif ext == "pkg": - if os.environ.get("CI"): - # We want to run it in an arbitrary directory, but the options - # are limited here... We can only install to $HOME :shrug: - # but that will pollute ~, so we only do it if we are running on CI - cmd = [ - "installer", - "-pkg", - fpath, - "-dumplog", - "-target", - "CurrentUserHomeDirectory", - ] - else: - # This command only expands the PKG, but does not install - cmd = ["pkgutil", "--expand", fpath, env_dir] - elif ext == "exe": - # NSIS manual: - # > /D sets the default installation directory ($INSTDIR), overriding InstallDir - # > and InstallDirRegKey. It must be the last parameter used in the command line - # > and must not contain any quotes, even if the path contains spaces. Only - # > absolute paths are supported. - # Since subprocess.Popen WILL escape the spaces with quotes, we need to provide - # them as separate arguments. We don't care about multiple spaces collapsing into - # one, since the point is to just have spaces in the installation path -- one - # would be enough too :) - # This is why we have this weird .split() thingy down here: - cmd = ["cmd.exe", "/c", "start", "/wait", fpath, "/S", *f"/D={env_dir}".split()] - env = {"CONDA_VERBOSITY": "3"} if debug else {} - test_errored = _execute(cmd, installer_input=installer_input, **env) - # Windows EXEs never throw a non-0 exit code, so we need to check the logs, - # which are only written if a special NSIS build is used - win_error_lines = [] - if ext == "exe" and os.environ.get("NSIS_USING_LOG_BUILD"): - test_errored = 0 - try: - log_is_empty = True - with open(os.path.join(env_dir, "install.log"), encoding="utf-16-le") as f: - for line in f: - log_is_empty = False - if ":error:" in line.lower(): - win_error_lines.append(line) - test_errored = 1 - if log_is_empty: - test_errored = 1 - win_error_lines.append("Logfile was unexpectedly empty!") - except Exception as exc: - test_errored = 1 - win_error_lines.append( - f"Could not read logs! {exc.__class__.__name__}: {exc}\n" - "This usually means that the destination folder could not be created.\n" - "Possible causes: permissions, non-supported characters, long paths...\n" - "Consider setting 'check_path_spaces' and 'check_path_length' to 'False'." - ) - for script_prefix in "pre", "post", "test": - install_location = Path(env_dir) - if ext == "exe": - script_ext = "bat" - elif ext == "sh": - script_ext = "sh" - elif example_name == "osxpkg": # we only test one pkg example - script_ext = "sh" - install_location = Path("~/Library/osx-pkg-test").expanduser() - else: - continue - if (Path(example_path) / f"{script_prefix}_install.{script_ext}").exists() and not ( - install_location / f"{script_prefix}_install_sentinel.txt" - ).exists(): - # All pre/post scripts need to write a sentinel file so we can tell they did run - test_errored += 1 - which_errored.setdefault(example_path, []).append( - f"Did not find {script_prefix}_install.{script_ext} sentinel!" - ) - errored += test_errored - if test_errored: - which_errored.setdefault(example_path, []).append(fpath) - if win_error_lines: - print("--- LOGS ---") - for line in win_error_lines: - print(line.rstrip()) - if ext == "pkg" and os.environ.get("CI"): - # more complete logs are available under /var/log/install.log - print("--- LOGS ---") - print("Tip: Debug locally and check the full logs in the Installer UI") - print(" or check /var/log/install.log if run from the CLI.") - elif ext == "exe" and not test_with_spaces: - # The installer succeeded, test the uninstaller on Windows - # The un-installers are only tested when testing without spaces, as they hang during - # testing but work in UI mode. - uninstaller = next( - (p for p in os.listdir(env_dir) if p.startswith("Uninstall-")), None - ) - if uninstaller: - cmd = [ - "cmd.exe", - "/c", - "start", - "/wait", - os.path.join(env_dir, uninstaller), - # We need silent mode + "uninstaller location" (_?=...) so the command can - # be waited; otherwise, since the uninstaller copies itself to a different - # location so it can be auto-deleted, it returns immediately and it gives - # us problems with the tempdir cleanup later - f"/S _?={env_dir}", - ] - test_errored = _execute(cmd) - errored += test_errored - if test_errored: - which_errored.setdefault(example_path, []).append( - "Wrong uninstall exit code or timeout." - ) - paths_after_uninstall = os.listdir(env_dir) - if len(paths_after_uninstall) > 2: - # The debug installer writes to install.log too, which will only - # be deleted _after_ a reboot. Finding some files is ok, but more - # than two usually means a problem with the uninstaller. - # Note this is is not exhaustive, because we are not checking - # whether the registry was restored, menu items were deleted, etc. - # TODO :) - which_errored.setdefault(example_path, []).append( - "Uninstaller left too many files behind!\n - \n - ".join( - paths_after_uninstall - ) - ) - else: - which_errored.setdefault(example_path, []).append("Could not find uninstaller!") - - if keep_artifacts: - dest = os.path.join(keep_artifacts, os.path.basename(fpath)) - if os.path.isfile(dest): - os.unlink(dest) - shutil.move(fpath, keep_artifacts) - if creation_errored: - which_errored.setdefault(example_path, []).append("Could not create installer!") - print() - - print("-------------------------------") - if errored: - print("Some examples failed:") - for installer, reasons in which_errored.items(): - print(f"+ {os.path.basename(installer)}") - for reason in reasons: - print(f"---> {reason}") - print("Assets saved in:", keep_artifacts or parent_output) - else: - print("All examples ran successfully!") - shutil.rmtree(parent_output) - return errored - - -def cli(): - p = argparse.ArgumentParser() - p.add_argument("--keep-artifacts") - p.add_argument("--conda-exe") - p.add_argument("--debug", action="store_true", default=False) - return p.parse_args() - - -if __name__ == "__main__": - args = cli() - if args.conda_exe: - assert os.path.isfile(args.conda_exe) - n_errors = run_examples( - keep_artifacts=args.keep_artifacts, conda_exe=args.conda_exe, debug=args.debug - ) - sys.exit(n_errors) diff --git a/tests/test_briefcase.py b/tests/test_briefcase.py new file mode 100644 index 000000000..a858b6ae4 --- /dev/null +++ b/tests/test_briefcase.py @@ -0,0 +1,134 @@ +import pytest + +from constructor.briefcase import get_bundle_app_name, get_name_version + + +@pytest.mark.parametrize( + "name_in, version_in, name_expected, version_expected", + [ + # Valid versions + ("Miniconda", "1", "Miniconda", "1"), + ("Miniconda", "1.2", "Miniconda", "1.2"), + ("Miniconda", "1.2.3", "Miniconda", "1.2.3"), + ("Miniconda", "1.2a1", "Miniconda", "1.2a1"), + ("Miniconda", "1.2b2", "Miniconda", "1.2b2"), + ("Miniconda", "1.2rc3", "Miniconda", "1.2rc3"), + ("Miniconda", "1.2.post4", "Miniconda", "1.2.post4"), + ("Miniconda", "1.2.dev5", "Miniconda", "1.2.dev5"), + ("Miniconda", "1.2rc3.post4.dev5", "Miniconda", "1.2rc3.post4.dev5"), + # Hyphens are treated as dots + ("Miniconda", "1.2-3", "Miniconda", "1.2.3"), + ("Miniconda", "1.2-3.4-5.6", "Miniconda", "1.2.3.4.5.6"), + # Additional text before and after the last valid version should be treated as + # part of the name. + ("Miniconda", "1.2 3.4 5.6", "Miniconda 1.2 3.4", "5.6"), + ("Miniconda", "1.2_3.4_5.6", "Miniconda 1.2_3.4", "5.6"), + ("Miniconda", "1.2c3", "Miniconda 1.2c", "3"), + ("Miniconda", "1.2rc3.dev5.post4", "Miniconda 1.2rc3.dev5.post", "4"), + ("Miniconda", "py313", "Miniconda py", "313"), + ("Miniconda", "py.313", "Miniconda py", "313"), + ("Miniconda", "py3.13", "Miniconda py", "3.13"), + ("Miniconda", "py313_1.2", "Miniconda py313", "1.2"), + ("Miniconda", "1.2 and more", "Miniconda and more", "1.2"), + ("Miniconda", "1.2! and more", "Miniconda ! and more", "1.2"), + ("Miniconda", "py313 1.2 and more", "Miniconda py313 and more", "1.2"), + # Numbers in the name are not added to the version. + ("Miniconda3", "1", "Miniconda3", "1"), + ], +) +def test_name_version(name_in, version_in, name_expected, version_expected): + name_actual, version_actual = get_name_version( + {"name": name_in, "version": version_in}, + ) + assert (name_actual, version_actual) == (name_expected, version_expected) + + +@pytest.mark.parametrize( + "info", + [ + {}, + {"name": ""}, + ], +) +def test_name_empty(info): + with pytest.raises(ValueError, match="Name is empty"): + get_name_version(info) + + +@pytest.mark.parametrize( + "info", + [ + {"name": "Miniconda"}, + {"name": "Miniconda", "version": ""}, + ], +) +def test_version_empty(info): + with pytest.raises(ValueError, match="Version is empty"): + get_name_version(info) + + +@pytest.mark.parametrize("version_in", ["x", ".", " ", "hello"]) +def test_version_invalid(version_in, caplog): + name_actual, version_actual = get_name_version( + {"name": "Miniconda3", "version": version_in}, + ) + assert name_actual == f"Miniconda3 {version_in}" + assert version_actual == "0.0.1" + assert caplog.messages == [ + f"Version {version_in!r} contains no valid version numbers; defaulting to 0.0.1" + ] + + +@pytest.mark.parametrize( + "rdi, name, bundle_expected, app_name_expected", + [ + # Valid rdi + ("org.conda", "ignored", "org", "conda"), + ("org.Conda", "ignored", "org", "Conda"), + ("org.conda-miniconda", "ignored", "org", "conda-miniconda"), + ("org.conda_miniconda", "ignored", "org", "conda_miniconda"), + ("org-conda.miniconda", "ignored", "org-conda", "miniconda"), + ("org.conda.miniconda", "ignored", "org.conda", "miniconda"), + ("org.conda.1", "ignored", "org.conda", "1"), + # Invalid rdi + ("org.hello-", "Miniconda", "org", "hello"), + ("org.-hello", "Miniconda", "org", "hello"), + ("org.hello world", "Miniconda", "org", "hello-world"), + ("org.hello!world", "Miniconda", "org", "hello-world"), + # Missing rdi + (None, "x", "io.continuum", "x"), + (None, "X", "io.continuum", "x"), + (None, "1", "io.continuum", "1"), + (None, "Miniconda", "io.continuum", "miniconda"), + (None, "Miniconda3", "io.continuum", "miniconda3"), + (None, "Miniconda3 py313", "io.continuum", "miniconda3-py313"), + (None, "Hello, world!", "io.continuum", "hello-world"), + ], +) +def test_bundle_app_name(rdi, name, bundle_expected, app_name_expected): + bundle_actual, app_name_actual = get_bundle_app_name({"reverse_domain_identifier": rdi}, name) + assert (bundle_actual, app_name_actual) == (bundle_expected, app_name_expected) + + +@pytest.mark.parametrize("rdi", ["", "org"]) +def test_rdi_no_dots(rdi): + with pytest.raises(ValueError, match=f"reverse_domain_identifier '{rdi}' contains no dots"): + get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") + + +@pytest.mark.parametrize("rdi", ["org.", "org.hello.", "org.hello.-"]) +def test_rdi_invalid_package(rdi): + with pytest.raises( + ValueError, + match=( + f"Last component of reverse_domain_identifier '{rdi}' " + f"contains no alphanumeric characters" + ), + ): + get_bundle_app_name({"reverse_domain_identifier": rdi}, "ignored") + + +@pytest.mark.parametrize("name", ["", " ", "!", "-", "---"]) +def test_name_no_alphanumeric(name): + with pytest.raises(ValueError, match=f"Name '{name}' contains no alphanumeric characters"): + get_bundle_app_name({}, name) diff --git a/tests/test_examples.py b/tests/test_examples.py index 7d8913e2f..a8315eb5c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ctypes import getpass import json import os @@ -21,7 +22,12 @@ from conda.models.version import VersionOrder as Version from ruamel.yaml import YAML -from constructor.utils import StandaloneExe, check_version, identify_conda_exe +from constructor.utils import ( + StandaloneExe, + check_version, + format_conda_exe_name, + identify_conda_exe, +) if TYPE_CHECKING: from collections.abc import Generator, Iterable @@ -29,6 +35,8 @@ if sys.platform == "darwin": from constructor.osxpkg import calculate_install_dir elif sys.platform.startswith("win"): + import winreg + import ntsecuritycon as con import win32security @@ -44,6 +52,7 @@ REPO_DIR = Path(__file__).parent.parent ON_CI = bool(os.environ.get("CI")) and os.environ.get("CI") != "0" CONSTRUCTOR_CONDA_EXE = os.environ.get("CONSTRUCTOR_CONDA_EXE") +CONSTRUCTOR_VERBOSE = os.environ.get("CONSTRUCTOR_VERBOSE") CONDA_EXE, CONDA_EXE_VERSION = identify_conda_exe(CONSTRUCTOR_CONDA_EXE) if CONDA_EXE_VERSION is not None: CONDA_EXE_VERSION = Version(CONDA_EXE_VERSION) @@ -55,6 +64,49 @@ KEEP_ARTIFACTS_PATH = None +def _is_program_installed(partial_name: str) -> bool: + """ + Checks if a program is listed in the Windows 'Installed apps' menu. + We search by looking for a partial name to avoid having to account for Python version and arch. + Returns True if a match is found, otherwise False. + """ + + if not sys.platform.startswith("win"): + return False + + # For its current purpose HKEY_CURRENT_USER is sufficient, + # but additional registry locations could be added later. + UNINSTALL_PATHS = [ + (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"), + ] + + partial_name = partial_name.lower() + + for hive, path in UNINSTALL_PATHS: + try: + reg_key = winreg.OpenKey(hive, path) + except FileNotFoundError: + continue + + subkey_count = winreg.QueryInfoKey(reg_key)[0] + + for i in range(subkey_count): + try: + subkey_name = winreg.EnumKey(reg_key, i) + subkey = winreg.OpenKey(reg_key, subkey_name) + + display_name, _ = winreg.QueryValueEx(subkey, "DisplayName") + + if partial_name in display_name.lower(): + return True + + except (FileNotFoundError, OSError, TypeError): + # Some keys may lack DisplayName or have unexpected value types + continue + + return False + + def _execute( cmd: Iterable[str], installer_input=None, check=True, timeout=420, **env_vars ) -> subprocess.CompletedProcess: @@ -205,7 +257,7 @@ def _run_uninstaller_exe( f"_?={install_dir}", ] process = _execute(cmd, timeout=timeout, check=check) - if check: + if check and Path(install_dir, "install.log").exists(): _check_installer_log(install_dir) remaining_files = list(install_dir.iterdir()) if len(remaining_files) > 3: @@ -286,6 +338,98 @@ def _sentinel_file_checks(example_path, install_dir): ) +def is_admin() -> bool: + try: + return ctypes.windll.shell32.IsUserAnAdmin() + except Exception: + return False + + +def calculate_msi_install_path(installer: Path) -> Path: + """This is a temporary solution for now since we cannot choose the install location ourselves. + Installers are named --Windows-x86_64.msi. + """ + dir_name = installer.name.replace("-Windows-x86_64.msi", "").replace("-", " ") + if is_admin(): + root_dir = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) + else: + local_dir = os.environ.get("LOCALAPPDATA", str(Path.home() / r"AppData\Local")) + root_dir = Path(local_dir) / "Programs" + + assert root_dir.is_dir() # Sanity check to avoid strange unexpected errors + return Path(root_dir) / dir_name + + +def _run_installer_msi( + installer: Path, + install_dir: Path, + installer_input=None, + timeout=420, + check=True, + options: list | None = None, +): + """Runs specified MSI Installer via command line in silent mode. This is work in progress.""" + if not sys.platform.startswith("win"): + raise ValueError("Can only run .msi installers on Windows") + + # Currently we only have 1 test that specifies options, so this is a temporary "fix" + if options: + allusers = "/InstallationType=AllUsers" in options + else: + allusers = False + + cmd = [ + "msiexec.exe", + "/i", + str(installer), + "ALLUSERS=1" + if allusers + else "MSIINSTALLPERUSER=1", # For some reason tests fail on the CI system if "ALLUSERS=1" + "/qn", + ] + + log_path = Path(os.environ.get("TEMP")) / (install_dir.name + ".log") + cmd.extend(["/L*V", str(log_path)]) + try: + process = _execute(cmd, installer_input=installer_input, timeout=timeout, check=check) + except subprocess.CalledProcessError as e: + if log_path.exists(): + # When running on the CI system, it tries to decode a UTF-16 log file as UTF-8, + # therefore we need to specify encoding before printing. + print(f"\n=== MSI LOG {log_path} START ===") + print( + log_path.read_text(encoding="utf-16", errors="replace")[-15000:] + ) # last 15k chars + print(f"\n=== MSI LOG {log_path} END ===") + raise e + if check: + print("A check for MSI Installers not yet implemented") + return process + + +def _run_uninstaller_msi( + installer: Path, + install_dir: Path, + timeout: int = 420, + check: bool = True, +) -> subprocess.CompletedProcess | None: + cmd = [ + "msiexec.exe", + "/x", + str(installer), + "/qn", + ] + process = _execute(cmd, timeout=timeout, check=check) + if check: + # TODO: + # Check log and if there are remaining files, similar to the exe installers + pass + # This is temporary until uninstallation works fine + shutil.rmtree(str(install_dir), ignore_errors=True) + + return process + + def _run_installer( example_path: Path, installer: Path, @@ -331,12 +475,27 @@ def _run_installer( timeout=timeout, check=check_subprocess, ) + elif installer.suffix == ".msi": + process = _run_installer_msi( + installer, + install_dir, + installer_input=installer_input, + timeout=timeout, + check=check_subprocess, + options=options, + ) else: raise ValueError(f"Unknown installer type: {installer.suffix}") - if check_sentinels and not (installer.suffix == ".pkg" and ON_CI): + + if installer.suffix == ".msi": + print("sentinel_file_checks for MSI installers not yet implemented") + elif check_sentinels and not (installer.suffix == ".pkg" and ON_CI): _sentinel_file_checks(example_path, install_dir) - if uninstall and installer.suffix == ".exe": - _run_uninstaller_exe(install_dir, timeout=timeout, check=check_subprocess) + if uninstall: + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir, timeout=timeout, check=check_subprocess) + elif installer.suffix == ".exe": + _run_uninstaller_exe(install_dir, timeout=timeout, check=check_subprocess) return process @@ -356,16 +515,19 @@ def create_installer( output_dir = workspace / "installer" output_dir.mkdir(parents=True, exist_ok=True) - cmd = [ - *COV_CMD, - "constructor", - "-v", + cmd = [*COV_CMD, "constructor"] + # This flag will (if enabled) create a lot of output upon test failures for .exe-installers. + # If debugging generated NSIS templates, it can be worth to enable. + if CONSTRUCTOR_VERBOSE: + cmd.append("-v") + cmd += [ str(input_dir), "--output-dir", str(output_dir), "--config-filename", config_filename, ] + if conda_exe: cmd.extend(["--conda-exe", conda_exe]) if debug: @@ -379,18 +541,21 @@ def create_installer( def _sort_by_extension(path): "Return shell installers first so they are run before the GUI ones" - return {"sh": 1, "pkg": 2, "exe": 3}[path.suffix[1:]], path + return {"sh": 1, "pkg": 2, "exe": 3, "msi": 4}[path.suffix[1:]], path - installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".sh", ".pkg")) + installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".msi", ".sh", ".pkg")) for installer in sorted(installers, key=_sort_by_extension): if installer.suffix == ".pkg" and ON_CI: install_dir = Path("~").expanduser() / calculate_install_dir( input_dir / config_filename ) + elif installer.suffix == ".msi": + install_dir = calculate_msi_install_path(installer) else: install_dir = ( workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" ) + yield installer, install_dir if KEEP_ARTIFACTS_PATH: try: @@ -478,13 +643,23 @@ def test_example_extra_envs(tmp_path, request): assert "@EXPLICIT" in envtxt.read_text() if sys.platform.startswith("win"): - _run_uninstaller_exe(install_dir=install_dir) + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir=install_dir) def test_example_extra_files(tmp_path, request): input_path = _example_path("extra_files") for installer, install_dir in create_installer(input_path, tmp_path, with_spaces=True): - _run_installer(input_path, installer, install_dir, request=request) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_sentinels=CONSTRUCTOR_VERBOSE, + check_subprocess=CONSTRUCTOR_VERBOSE, + ) def test_example_mirrored_channels(tmp_path, request): @@ -542,6 +717,9 @@ def test_example_miniforge(tmp_path, request, example): check_sentinels=installer.suffix != ".pkg", uninstall=False, ) + # Check that key metadata files are in place + assert install_dir.glob("conda-meta/*.json") + assert install_dir.glob("pkgs/cache/*.json") # enables offline installs if installer.suffix == ".pkg" and ON_CI: basename = "Miniforge3" if example == "miniforge" else "Miniforge3-mamba2" _sentinel_file_checks(input_path, Path(os.environ["HOME"]) / basename) @@ -558,6 +736,10 @@ def test_example_miniforge(tmp_path, request, example): raise AssertionError("Could not find Start Menu folder for miniforge") _run_uninstaller_exe(install_dir) assert not list(start_menu_dir.glob("Miniforge*.lnk")) + elif installer.suffix == ".msi": + # TODO: Start menus + _run_uninstaller_msi(installer, install_dir) + raise NotImplementedError("Test needs to be implemented") def test_example_noconda(tmp_path, request): @@ -663,8 +845,9 @@ def test_macos_signing(tmp_path, self_signed_application_certificate_macos): # including binary archives like the PlugIns file cmd = ["pkgutil", "--expand-full", installer, expanded_path] _execute(cmd) + conda_exe_name = format_conda_exe_name(CONSTRUCTOR_CONDA_EXE) components = [ - Path(expanded_path, "prepare_installation.pkg", "Payload", "osx-pkg-test", "_conda"), + Path(expanded_path, "prepare_installation.pkg", "Payload", "osx-pkg-test", conda_exe_name), Path(expanded_path, "Plugins", "ExtraPage.bundle"), ] validated_signatures = [] @@ -720,7 +903,10 @@ def test_example_shortcuts(tmp_path, request): break else: raise AssertionError("No shortcuts found!") - _run_uninstaller_exe(install_dir) + if installer.suffix == ".msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir) assert not (package_1 / "A.lnk").is_file() assert not (package_1 / "B.lnk").is_file() elif sys.platform == "darwin": @@ -862,8 +1048,11 @@ def test_example_from_explicit(tmp_path, request): def test_register_envs(tmp_path, request): + """Verify that 'register_envs: False' results in the environment not being registered.""" input_path = _example_path("register_envs") for installer, install_dir in create_installer(input_path, tmp_path): + if installer.suffix == ".msi": + raise NotImplementedError("Test for 'register_envs' not yet implemented for MSI") _run_installer(input_path, installer, install_dir, request=request) environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() assert str(install_dir) not in environments_txt @@ -924,6 +1113,7 @@ def test_cross_osx_building(tmp_path): ) +@pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only") def test_cross_build_example(tmp_path, platform_conda_exe): platform, conda_exe = platform_conda_exe input_path = _example_path("virtual_specs_ok") @@ -939,6 +1129,7 @@ def test_cross_build_example(tmp_path, platform_conda_exe): def test_virtual_specs_failed(tmp_path, request): + """Verify that virtual packages listed via 'virtual_specs' are satisfied.""" input_path = _example_path("virtual_specs_failed") for installer, install_dir in create_installer(input_path, tmp_path): process = _run_installer( @@ -954,6 +1145,8 @@ def test_virtual_specs_failed(tmp_path, request): with pytest.raises(AssertionError, match="Failed to check virtual specs"): _check_installer_log(install_dir) continue + elif installer.suffix == ".msi": + raise NotImplementedError("Test for 'virtual_specs' not yet implemented for MSI") elif installer.suffix == ".pkg": if not ON_CI: continue @@ -1006,7 +1199,7 @@ def test_initialization(tmp_path, request, monkeypatch, method): request.addfinalizer( lambda: subprocess.run([sys.executable, "-m", "conda", "init", "--reverse"]) ) - monkeypatch.setenv("initialization_method", method) + monkeypatch.setenv("initialization_method", str(method).lower()) input_path = _example_path("initialization") initialize = method is not False for installer, install_dir in create_installer(input_path, tmp_path): @@ -1016,6 +1209,8 @@ def test_initialization(tmp_path, request, monkeypatch, method): # GHA runs on an admin user account, but AllUsers (admin) installs # do not add to PATH due to CVE-2022-26526, so force single user install options = ["/AddToPath=1", "/InstallationType=JustMe"] + elif installer.suffix == ".msi": + raise NotImplementedError("Test needs to be implemented") else: options = [] _run_installer( @@ -1029,8 +1224,6 @@ def test_initialization(tmp_path, request, monkeypatch, method): ) if installer.suffix == ".exe": try: - import winreg - paths = [] for root, keyname in ( (winreg.HKEY_CURRENT_USER, r"Environment"), @@ -1051,6 +1244,8 @@ def test_initialization(tmp_path, request, monkeypatch, method): finally: _run_uninstaller_exe(install_dir, check=True) + elif installer.suffix == ".msi": + raise NotImplementedError("Test needs to be implemented") else: # GHA's Ubuntu needs interactive, but macOS wants login :shrug: login_flag = "-i" if sys.platform.startswith("linux") else "-l" @@ -1304,7 +1499,8 @@ def test_uninstallation_standalone( check_subprocess=True, uninstall=False, ) - + if installer.suffix == ".msi": + raise NotImplementedError("Test needs to be implemented") # Set up files for removal. # Since conda-standalone is extensively tested upstream, # only set up a minimum set of files. @@ -1414,3 +1610,39 @@ def test_regressions(tmp_path, request): check_subprocess=True, uninstall=True, ) + + +@pytest.mark.parametrize("no_registry", (0, 1)) +@pytest.mark.skipif(not ON_CI, reason="CI only") +@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only") +def test_not_in_installed_menu_list_(tmp_path, request, no_registry): + """Verify the app is in the Installed Apps Menu (or not), based on the CLI arg '/NoRegistry'. + If NoRegistry=0, we expect to find the installer in the Menu, otherwise not. + """ + input_path = _example_path("register_envs") # The specific example we use here is not important + options = ["/InstallationType=JustMe", f"/NoRegistry={no_registry}"] + for installer, install_dir in create_installer(input_path, tmp_path): + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=False, + options=options, + ) + + # Use the installer file name for the registry search + installer_file_name_parts = Path(installer).name.split("-") + name = installer_file_name_parts[0] + version = installer_file_name_parts[1] + partial_name = f"{name} {version}" + + is_in_installed_apps_menu = _is_program_installed(partial_name) + _run_uninstaller_exe(install_dir) + + # If no_registry=0 we expect is_in_installed_apps_menu=True + # If no_registry=1 we expect is_in_installed_apps_menu=False + assert is_in_installed_apps_menu == (no_registry == 0), ( + f"Unable to find program '{partial_name}' in the 'Installed apps' menu" + ) diff --git a/tests/test_header.py b/tests/test_header.py index 94e5c906e..03c5b8a2f 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -70,6 +70,7 @@ def test_osxpkg_scripts_shellcheck(arch, check_path_spaces, script): no_rcs_arg="", script_env_variables={}, initialize_conda="condabin", + conda_exe_name="_conda", ) findings, returncode = run_shellcheck(processed) @@ -162,6 +163,7 @@ def test_template_shellcheck( "write_condarc": "", "conda_exe_payloads": conda_exe_payloads_and_size[0], "conda_exe_payloads_size": conda_exe_payloads_and_size[1], + "conda_exe_name": "_conda", }, ) diff --git a/tests/test_main.py b/tests/test_main.py index 71aa79fdb..e17f69f54 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,10 +7,10 @@ def test_dry_run(tmp_path): inputfile = dedent( """ name: test_schema_validation - version: X + version: 1.0.0 installer_type: all channels: - - http://repo.anaconda.com/pkgs/main/ + - https://repo.anaconda.com/pkgs/main/ specs: - ca-certificates """