Skip to content

Environment Setup

environment

Python environment and configuration provisioning — Steps 3-4.

Creates the Python virtual environment and copies the minimum configuration files needed for the rest of the install:

  • venv creation (Step 3): tries uv first (auto-downloads Python 3.11-3.13), falls back to system Python.
  • Provisioning (Step 4): copies dependencies.json and the model catalog to the install directory.

setup_environment(install_path, install_type, log)

Create the Python virtual environment.

Strategy (in order):

  1. uv venv with Python >=3.11,<3.14 auto-managed.
  2. System conda (Miniconda/Anaconda) with a local prefix.
  3. System Python 3.11-3.13 (detected via platform abstraction).
  4. Auto-install Python on Windows (if user agrees).

After creation, verifies the expected python executable exists inside the venv.

Parameters:

Name Type Description Default
install_path Path

Root installation directory.

required
install_type InstallType

:attr:InstallType.VENV or :attr:InstallType.CONDA.

required
log InstallerLogger

Installer logger for user-facing messages.

required

Returns:

Type Description
Path

Absolute path to the Python executable inside the environment.

Raises:

Type Description
InstallerFatalError

If no usable Python 3.11+ can be found or created.

Source code in src/installer/environment.py
def setup_environment(
    install_path: Path,
    install_type: InstallType,
    log: InstallerLogger,
) -> Path:
    """Create the Python virtual environment.

    Strategy (in order):

    1. ``uv venv`` with Python >=3.11,<3.14 auto-managed.
    2. System conda (Miniconda/Anaconda) with a local prefix.
    3. System Python 3.11-3.13 (detected via platform abstraction).
    4. Auto-install Python on Windows (if user agrees).

    After creation, verifies the expected ``python`` executable
    exists inside the venv.

    Args:
        install_path: Root installation directory.
        install_type: :attr:`InstallType.VENV` or
            :attr:`InstallType.CONDA`.
        log: Installer logger for user-facing messages.

    Returns:
        Absolute path to the Python executable inside the environment.

    Raises:
        InstallerFatalError: If no usable Python 3.11+ can be found or created.
    """
    scripts_dir = install_path / "scripts"
    scripts_dir.mkdir(parents=True, exist_ok=True)

    log.item(f"Install path: {install_path}")

    if install_type is InstallType.VENV:
        venv_path = scripts_dir / "venv"

        if venv_path.exists():
            log.sub("Virtual environment already exists.", style="success")
        else:
            # Find uv: PATH first, then local scripts/uv/
            uv_cmd: str | Path | None = None
            if check_command_exists("uv"):
                uv_cmd = "uv"
            else:
                local_uv = scripts_dir / "uv" / ("uv.exe" if sys.platform == "win32" else "uv")
                if local_uv.exists():
                    uv_cmd = local_uv

            if uv_cmd is not None:
                log.item("Creating Python environment...")
                _create_venv_with_uv(uv_cmd, venv_path, log)

            # Fallback: use system Python if uv didn't create the venv
            if not venv_path.exists():
                platform = get_platform()
                python_path = None
                for try_version in ("3.13", "3.12", "3.11"):
                    python_path = platform.detect_python(try_version)
                    if python_path:
                        break

                if python_path is None:
                    log.error("Python 3.11+ is required but could not be acquired.")
                    log.item("If 'uv' failed, this may be due to network or Antivirus restrictions.")
                    log.item("Please install Python 3.11-3.13 from https://www.python.org/downloads/")
                    raise InstallerFatalError("Python 3.11+ is required but could not be acquired.")

                log.item(f"Creating venv with {python_path}...")
                run_and_log(str(python_path), ["-m", "venv", str(venv_path)])
                log.sub("Virtual environment created.", style="success")

        # Return the venv python and verify it exists
        if sys.platform == "win32":
            python_exe = venv_path / "Scripts" / "python.exe"
        else:
            python_exe = venv_path / "bin" / "python"

        if not python_exe.exists():
            log.error(f"Venv python not found at expected path: {python_exe}")
            log.item(f"Venv directory: {venv_path}")
            raise InstallerFatalError(f"Venv python not found at expected path: {python_exe}")

        log.sub(f"Venv python: {python_exe}", style="success")
        return python_exe

    elif install_type is InstallType.CONDA:
        conda_env_path = scripts_dir / "conda_env"

        # Determine Python executable path based on OS
        if sys.platform == "win32":
            python_exe = conda_env_path / "python.exe"
        else:
            python_exe = conda_env_path / "bin" / "python"

        if conda_env_path.exists() and python_exe.exists():
            log.sub("Conda environment already exists.", style="success")
            log.sub(f"Conda python: {python_exe}", style="success")
            return python_exe

        # Find or install Conda
        conda_exe = _find_conda(log)
        if not conda_exe:
            if sys.platform == "win32" and confirm("Conda not found. Install Miniconda automatically?"):
                conda_exe = _install_miniconda_windows(log)

            if not conda_exe:
                log.error("Conda is required for this installation type.")
                log.item("Please install Miniconda from https://docs.anaconda.com/free/miniconda/")
                raise InstallerFatalError("Conda is required for this installation type.")

        # Copy environment.yml from source scripts directory
        # (it's a deferred file, not copied by provision_scripts)
        source_dir = find_source_scripts()
        if source_dir is None:
            log.error("Source scripts directory not found.")
            raise InstallerFatalError("Source scripts directory not found.")

        env_yml_src = source_dir / "environment.yml"
        env_yml = scripts_dir / "environment.yml"

        if env_yml_src.exists():
            shutil.copy2(env_yml_src, env_yml)
            log.sub("environment.yml: copied from source", style="success")
        elif not env_yml.exists():
            log.error(f"environment.yml not found in source ({source_dir}) or destination ({env_yml})")
            raise InstallerFatalError(f"environment.yml not found at {env_yml}")

        log.item(f"Creating local Conda environment at {conda_env_path}...")
        try:
            # Force UTF-8 to prevent UnicodeEncodeError in conda's pip subprocess
            # (known conda bug on Windows with non-Latin console code pages)
            utf8_env = {"PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
            run_and_log(
                str(conda_exe),
                ["env", "create", "-p", str(conda_env_path), "-f", str(env_yml), "-y"],
                env=utf8_env,
            )
            log.sub("Conda environment created.", style="success")
        except CommandError:
            log.error("Failed to create Conda environment.")
            raise InstallerFatalError("Failed to create Conda environment.") from None

        if not python_exe.exists():
            log.error(f"Conda python not found at expected path: {python_exe}")
            raise InstallerFatalError(f"Conda python not found at expected path: {python_exe}")

        log.sub(f"Conda python: {python_exe}", style="success")
        return python_exe

    else:
        raise InstallerFatalError(f"Unknown install type: {install_type}")

find_source_scripts()

Locate the source scripts/ directory containing config files.

Search order:

  1. Wheel install: embedded src.scripts package data (via :mod:importlib.resources) — self-contained wheel, no local checkout needed.
  2. Editable install: ../../scripts/ relative to this file (i.e. the scripts/ directory at the project root).
  3. CI / manual run: Path.cwd() / "scripts" as a last resort.

Returns:

Type Description
Path | None

Path to the scripts directory, or None if not found.

Path | None

Callers are responsible for handling None gracefully.

Source code in src/installer/environment.py
def find_source_scripts() -> Path | None:
    """Locate the source ``scripts/`` directory containing config files.

    Search order:

    1. **Wheel install**: embedded ``src.scripts`` package data
       (via :mod:`importlib.resources`) — self-contained wheel, no local
       checkout needed.
    2. **Editable install**: ``../../scripts/`` relative to this file
       (i.e. the ``scripts/`` directory at the project root).
    3. **CI / manual run**: ``Path.cwd() / "scripts"`` as a last resort.

    Returns:
        Path to the scripts directory, or ``None`` if not found.
        Callers are responsible for handling ``None`` gracefully.
    """
    # 1. Wheel install: use embedded package data (importlib.resources)
    try:
        ref = importlib.resources.files("src.scripts")
        embedded = Path(str(ref))
        if (embedded / "dependencies.json").exists():
            return embedded
    except (TypeError, AttributeError, ModuleNotFoundError):
        pass

    # 2. Editable / source install: scripts/ adjacent to the project root
    package_root = Path(__file__).resolve().parent.parent.parent
    candidate = package_root / "scripts"
    if candidate.exists() and (candidate / "dependencies.json").exists():
        return candidate

    # 3. CWD fallback (CI, manual run from a checkout)
    cwd_candidate = Path.cwd() / "scripts"
    if cwd_candidate.exists() and (cwd_candidate / "dependencies.json").exists():
        return cwd_candidate

    return None

provision_scripts(install_path, log)

Copy bootstrap config files to the install directory.

Only copies files listed in BOOTSTRAP_FILES (currently just dependencies.json). Other configs like custom_nodes.json are resolved on-demand by later steps from the source directory.

Also copies model_manifest.json to install_path/scripts/ so the model downloader can find it.

Parameters:

Name Type Description Default
install_path Path

Root installation directory.

required
log InstallerLogger

Installer logger for user-facing messages.

required
Source code in src/installer/environment.py
def provision_scripts(install_path: Path, log: InstallerLogger) -> None:
    """Copy bootstrap config files to the install directory.

    Only copies files listed in ``BOOTSTRAP_FILES`` (currently just
    ``dependencies.json``). Other configs like ``custom_nodes.json``
    are resolved on-demand by later steps from the source directory.

    Also copies ``model_manifest.json`` to ``install_path/scripts/``
    so the model downloader can find it.

    Args:
        install_path: Root installation directory.
        log: Installer logger for user-facing messages.
    """

    source_dir = find_source_scripts()
    if source_dir is None:
        msg = (
            "Source scripts directory not found. "
            "If installing from a wheel, please report this as a bug."
        )
        log.error(msg)
        raise InstallerFatalError(msg)

    dest_dir = install_path / "scripts"
    dest_dir.mkdir(parents=True, exist_ok=True)

    copied = 0
    for filename in BOOTSTRAP_FILES:
        src = source_dir / filename
        dst = dest_dir / filename

        if not src.exists():
            log.sub(f"  {filename}: [dim]not in source (skipped)[/dim]")
            continue

        if dst.exists():
            # Update if source is newer
            if src.stat().st_mtime > dst.stat().st_mtime:
                shutil.copy2(src, dst)
                log.sub(f"  {filename}: updated", style="cyan")
                copied += 1
            else:
                log.sub(f"  {filename}: already up to date", style="success")
        else:
            shutil.copy2(src, dst)
            log.sub(f"  {filename}: copied", style="success")
            copied += 1

    # Download model_manifest.json from Assets repo (HF primary, ModelScope fallback)
    _provision_bundles_manifest(dest_dir, log)

    # Download tools_manifest.json for wheel checksum verification
    _provision_tools_manifest(dest_dir, log)

    log.item(f"{copied} config file(s) provisioned.")

load_tools_manifest(install_path)

Load tools_manifest.json from the install scripts directory.

Returns the parsed JSON as a dict, or an empty dict if the file is not found or unparseable.

Parameters:

Name Type Description Default
install_path Path

Root installation directory.

required

Returns:

Type Description
dict

Parsed manifest dict (or empty dict on failure).

Source code in src/installer/environment.py
def load_tools_manifest(install_path: Path) -> dict:
    """Load ``tools_manifest.json`` from the install scripts directory.

    Returns the parsed JSON as a dict, or an empty dict if the file
    is not found or unparseable.

    Args:
        install_path: Root installation directory.

    Returns:
        Parsed manifest dict (or empty dict on failure).
    """
    import json

    manifest_path = install_path / "scripts" / "tools_manifest.json"
    if not manifest_path.exists():
        return {}
    try:
        return json.loads(manifest_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}

lookup_wheel_checksum(manifest, wheel_url)

Look up the SHA-256 checksum for a wheel from the tools manifest.

Searches through all entries in the whl section for a matching filename. Compares the path suffix (everything after the last whl/ segment) to handle per-architecture subdirectories (e.g. whl/sm89/sageattention-2.2.0-cp313-cp313-linux_x86_64.whl).

Falls back to basename-only comparison for backwards compatibility with manifests that don't use subdirectories.

Parameters:

Name Type Description Default
manifest dict

Parsed tools_manifest.json dict.

required
wheel_url str

Full URL of the wheel to look up.

required

Returns:

Type Description
str | None

SHA-256 hex digest string, or None if not found.

Source code in src/installer/environment.py
def lookup_wheel_checksum(
    manifest: dict,
    wheel_url: str,
) -> str | None:
    """Look up the SHA-256 checksum for a wheel from the tools manifest.

    Searches through all entries in the ``whl`` section for a matching
    filename.  Compares the path suffix (everything after the last
    ``whl/`` segment) to handle per-architecture subdirectories
    (e.g. ``whl/sm89/sageattention-2.2.0-cp313-cp313-linux_x86_64.whl``).

    Falls back to basename-only comparison for backwards compatibility
    with manifests that don't use subdirectories.

    Args:
        manifest: Parsed ``tools_manifest.json`` dict.
        wheel_url: Full URL of the wheel to look up.

    Returns:
        SHA-256 hex digest string, or ``None`` if not found.
    """
    if not manifest:
        return None

    # Extract the path after "whl/" from the URL for comparison
    # e.g. "https://...Assets/resolve/main/whl/sm89/sageattention-2.2.0-cp313-...whl"
    #   → "sm89/sageattention-2.2.0-cp313-...whl"
    url_whl_path = ""
    if "/whl/" in wheel_url:
        url_whl_path = wheel_url.split("/whl/", 1)[-1]

    url_basename = wheel_url.rsplit("/", 1)[-1] if "/" in wheel_url else wheel_url

    whl_section = manifest.get("whl", {})

    # --- Pass 1: full path match (handles per-arch subdirectories) ---
    if url_whl_path:
        for _pkg_name, pkg_data in whl_section.items():
            if not isinstance(pkg_data, dict):
                continue
            files = pkg_data.get("files", {})
            for _key, file_info in files.items():
                if not isinstance(file_info, dict):
                    continue
                manifest_filename = file_info.get("filename", "")
                if "whl/" in manifest_filename:
                    manifest_whl_path = manifest_filename.split("whl/", 1)[-1]
                    if manifest_whl_path == url_whl_path:
                        return file_info.get("sha256")

    # --- Pass 2: basename fallback (backwards compat) ---
    for _pkg_name, pkg_data in whl_section.items():
        if not isinstance(pkg_data, dict):
            continue
        files = pkg_data.get("files", {})
        for _key, file_info in files.items():
            if not isinstance(file_info, dict):
                continue
            manifest_filename = file_info.get("filename", "")
            manifest_basename = manifest_filename.rsplit("/", 1)[-1]
            if manifest_basename == url_basename:
                return file_info.get("sha256")

    return None