diff --git a/builder/frameworks/_embed_files.py b/builder/frameworks/_embed_files.py index b114fbcee..4e2ba2329 100644 --- a/builder/frameworks/_embed_files.py +++ b/builder/frameworks/_embed_files.py @@ -21,6 +21,8 @@ Import("env") board = env.BoardConfig() +mcu = board.get("build.mcu", "esp32") +is_xtensa = mcu in ("esp32", "esp32s2", "esp32s3") # # Embedded files helpers @@ -101,8 +103,7 @@ def transform_to_asm(target, source, env): files = [join("$BUILD_DIR", s.name + ".S") for s in source] return files, source - -mcu = board.get("build.mcu", "esp32") + env.Append( BUILDERS=dict( TxtToBin=Builder( @@ -110,14 +111,14 @@ def transform_to_asm(target, source, env): " ".join( [ "riscv32-esp-elf-objcopy" - if mcu in ("esp32c2","esp32c3","esp32c5","esp32c6","esp32h2","esp32p4") - else "xtensa-%s-elf-objcopy" % mcu, + if not is_xtensa + else f"xtensa-{mcu}-elf-objcopy", "--input-target", "binary", "--output-target", - "elf32-littleriscv" if mcu in ("esp32c2","esp32c3","esp32c5","esp32c6","esp32h2","esp32p4") else "elf32-xtensa-le", + "elf32-littleriscv" if not is_xtensa else "elf32-xtensa-le", "--binary-architecture", - "riscv" if mcu in ("esp32c2","esp32c3","esp32c5","esp32c6","esp32h2","esp32p4") else "xtensa", + "riscv" if not is_xtensa else "xtensa", "--rename-section", ".data=.rodata.embedded", "$SOURCE", diff --git a/builder/frameworks/ulp.py b/builder/frameworks/ulp.py index 22a591cfb..22a4559a2 100644 --- a/builder/frameworks/ulp.py +++ b/builder/frameworks/ulp.py @@ -31,32 +31,35 @@ BUILD_DIR, "esp-idf", project_config["name"].replace("__idf_", ""), "ulp_main" ) +is_xtensa = idf_variant in ("esp32", "esp32s2", "esp32s3") def prepare_ulp_env_vars(env): ulp_env.PrependENVPath("IDF_PATH", FRAMEWORK_DIR) toolchain_path = platform.get_package_dir( "toolchain-xtensa-esp-elf" - if idf_variant not in ("esp32c5","esp32c6", "esp32p4") + if is_xtensa else "toolchain-riscv32-esp" ) toolchain_path_ulp = platform.get_package_dir( "toolchain-esp32ulp" if sdk_config.get("ULP_COPROC_TYPE_FSM", False) - else "" + else None ) + python_dir = os.path.dirname(ulp_env.subst("$PYTHONEXE")) or "" additional_packages = [ toolchain_path, toolchain_path_ulp, platform.get_package_dir("tool-ninja"), os.path.join(platform.get_package_dir("tool-cmake"), "bin"), - os.path.dirname(where_is_program("python")), + python_dir, ] for package in additional_packages: - ulp_env.PrependENVPath("PATH", package) + if package and os.path.isdir(package): + ulp_env.PrependENVPath("PATH", package) def collect_ulp_sources(): @@ -85,7 +88,7 @@ def _generate_ulp_configuration_action(env, target, source): riscv_ulp_enabled = sdk_config.get("ULP_COPROC_TYPE_RISCV", False) lp_core_ulp_enabled = sdk_config.get("ULP_COPROC_TYPE_LP_CORE", False) - if lp_core_ulp_enabled == False: + if not lp_core_ulp_enabled: ulp_toolchain = "toolchain-%sulp%s.cmake"% ( "" if riscv_ulp_enabled else idf_variant + "-", "-riscv" if riscv_ulp_enabled else "", @@ -93,9 +96,9 @@ def _generate_ulp_configuration_action(env, target, source): else: ulp_toolchain = "toolchain-lp-core-riscv.cmake" - comp_includes = ";".join(get_component_includes(target_config)) - plain_includes = ";".join(app_includes["plain_includes"]) - comp_includes = comp_includes + plain_includes + comp_includes_list = get_component_includes(target_config) + plain_includes_list = app_includes["plain_includes"] + comp_includes = ";".join(comp_includes_list + plain_includes_list) cmd = ( os.path.join(platform.get_package_dir("tool-cmake"), "bin", "cmake"), @@ -112,7 +115,7 @@ def _generate_ulp_configuration_action(env, target, source): "-DULP_S_SOURCES=%s" % ";".join([fs.to_unix_path(s.get_abspath()) for s in source]), "-DULP_APP_NAME=ulp_main", "-DCOMPONENT_DIR=" + os.path.join(ulp_env.subst("$PROJECT_DIR"), "ulp"), - "-DCOMPONENT_INCLUDES=" + comp_includes, + "-DCOMPONENT_INCLUDES=%s" % comp_includes, "-DIDF_TARGET=%s" % idf_variant, "-DIDF_PATH=" + fs.to_unix_path(FRAMEWORK_DIR), "-DSDKCONFIG_HEADER=" + os.path.join(BUILD_DIR, "config", "sdkconfig.h"), diff --git a/builder/main.py b/builder/main.py index 197634114..fffe7a28f 100644 --- a/builder/main.py +++ b/builder/main.py @@ -13,11 +13,8 @@ # limitations under the License. import locale -import json import os import re -import site -import semantic_version import shlex import subprocess import sys @@ -33,34 +30,9 @@ ) from platformio.project.helpers import get_project_dir -from platformio.package.version import pepver_to_semver from platformio.util import get_serial_ports from platformio.compat import IS_WINDOWS - -# Check Python version requirement -if sys.version_info < (3, 10): - sys.stderr.write( - f"Error: Python 3.10 or higher is required. " - f"Current version: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\n" - f"Please update your Python installation.\n" - ) - sys.exit(1) - -# Python dependencies required for the build process -python_deps = { - "uv": ">=0.1.0", - "platformio": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip", - "pyyaml": ">=6.0.2", - "rich-click": ">=1.8.6", - "zopfli": ">=0.2.2", - "intelhex": ">=2.3.0", - "rich": ">=14.0.0", - "cryptography": ">=45.0.3", - "ecdsa": ">=0.19.1", - "bitstring": ">=4.3.1", - "reedsolo": ">=1.5.3,<1.8", - "esp-idf-size": ">=1.6.1" -} +from penv_setup import setup_python_environment # Initialize environment and configuration env = DefaultEnvironment() @@ -70,270 +42,8 @@ FRAMEWORK_DIR = platform.get_package_dir("framework-arduinoespressif32") platformio_dir = projectconfig.get("platformio", "core_dir") -# Global Python executable path, replaced later with venv python path -PYTHON_EXE = env.subst("$PYTHONEXE") -penv_dir = os.path.join(platformio_dir, "penv") - - -def get_executable_path(executable_name): - """ - Get the path to an executable based on the penv_dir. - """ - exe_suffix = ".exe" if IS_WINDOWS else "" - scripts_dir = "Scripts" if IS_WINDOWS else "bin" - - return os.path.join(penv_dir, scripts_dir, f"{executable_name}{exe_suffix}") - - -def setup_pipenv_in_package(): - """ - Checks if 'penv' folder exists in platformio dir and creates virtual environment if not. - """ - if not os.path.exists(penv_dir): - env.Execute( - env.VerboseAction( - '"$PYTHONEXE" -m venv --clear "%s"' % penv_dir, - "Creating pioarduino Python virtual environment: %s" % penv_dir, - ) - ) - assert os.path.isfile( - get_executable_path("pip") - ), "Error: Failed to create a proper virtual environment. Missing the `pip` binary!" - - -# Setup virtual environment if needed -setup_pipenv_in_package() - -# Set Python Scons Var to env Python -penv_python = get_executable_path("python") -env.Replace(PYTHONEXE=penv_python) -PYTHON_EXE = penv_python - -# check for python binary, exit with error when not found -assert os.path.isfile(PYTHON_EXE), f"Python executable not found: {PYTHON_EXE}" - - -def setup_python_paths(): - """Setup Python module search paths using the penv_dir.""" - # Add penv_dir to module search path - site.addsitedir(penv_dir) - - # Add site-packages directory - python_ver = f"python{sys.version_info.major}.{sys.version_info.minor}" - site_packages = ( - os.path.join(penv_dir, "Lib", "site-packages") if IS_WINDOWS - else os.path.join(penv_dir, "lib", python_ver, "site-packages") - ) - - if os.path.isdir(site_packages): - site.addsitedir(site_packages) - - -setup_python_paths() - -# Set executable paths from tools -esptool_binary_path = get_executable_path("esptool") -uv_executable = get_executable_path("uv") - - -def get_packages_to_install(deps, installed_packages): - """ - Generator for Python packages that need to be installed. - - Args: - deps (dict): Dictionary of package names and version specifications - installed_packages (dict): Dictionary of currently installed packages - - Yields: - str: Package name that needs to be installed - """ - for package, spec in deps.items(): - if package not in installed_packages: - yield package - elif package == "platformio": - # Enforce the version from the direct URL if it looks like one. - # If version can't be parsed, fall back to accepting any installed version. - m = re.search(r'/v?(\d+\.\d+\.\d+(?:\.\d+)?)(?:\.(?:zip|tar\.gz|tar\.bz2))?$', spec) - if m: - expected_ver = semantic_version.Version(m.group(1)) - if installed_packages.get(package) != expected_ver: - # Reinstall to align with the pinned URL version - yield package - else: - continue - else: - version_spec = semantic_version.Spec(spec) - if not version_spec.match(installed_packages[package]): - yield package - - -def install_python_deps(): - """ - Ensure uv package manager is available and install required Python dependencies. - - Returns: - bool: True if successful, False otherwise - """ - try: - result = subprocess.run( - [uv_executable, "--version"], - capture_output=True, - text=True, - timeout=3 - ) - uv_available = result.returncode == 0 - except (FileNotFoundError, subprocess.TimeoutExpired): - uv_available = False - - if not uv_available: - try: - result = subprocess.run( - [PYTHON_EXE, "-m", "pip", "install", "uv>=0.1.0", "-q", "-q", "-q"], - capture_output=True, - text=True, - timeout=30 # 30 second timeout - ) - if result.returncode != 0: - if result.stderr: - print(f"Error output: {result.stderr.strip()}") - return False - - except subprocess.TimeoutExpired: - print("Error: uv installation timed out") - return False - except FileNotFoundError: - print("Error: Python executable not found") - return False - except Exception as e: - print(f"Error installing uv package manager: {e}") - return False - - - def _get_installed_uv_packages(): - """ - Get list of installed packages in virtual env 'penv' using uv. - - Returns: - dict: Dictionary of installed packages with versions - """ - result = {} - try: - cmd = [uv_executable, "pip", "list", f"--python={PYTHON_EXE}", "--format=json"] - result_obj = subprocess.run( - cmd, - capture_output=True, - text=True, - encoding='utf-8', - timeout=30 # 30 second timeout - ) - - if result_obj.returncode == 0: - content = result_obj.stdout.strip() - if content: - packages = json.loads(content) - for p in packages: - result[p["name"]] = pepver_to_semver(p["version"]) - else: - print(f"Warning: uv pip list failed with exit code {result_obj.returncode}") - if result_obj.stderr: - print(f"Error output: {result_obj.stderr.strip()}") - - except subprocess.TimeoutExpired: - print("Warning: uv pip list command timed out") - except (json.JSONDecodeError, KeyError) as e: - print(f"Warning: Could not parse package list: {e}") - except FileNotFoundError: - print("Warning: uv command not found") - except Exception as e: - print(f"Warning! Couldn't extract the list of installed Python packages: {e}") - - return result - - installed_packages = _get_installed_uv_packages() - packages_to_install = list(get_packages_to_install(python_deps, installed_packages)) - - if packages_to_install: - packages_list = [] - for p in packages_to_install: - spec = python_deps[p] - if spec.startswith(('http://', 'https://', 'git+', 'file://')): - packages_list.append(spec) - else: - packages_list.append(f"{p}{spec}") - - cmd = [ - uv_executable, "pip", "install", - f"--python={PYTHON_EXE}", - "--quiet", "--upgrade" - ] + packages_list - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30 # 30 second timeout for package installation - ) - - if result.returncode != 0: - print(f"Error: Failed to install Python dependencies (exit code: {result.returncode})") - if result.stderr: - print(f"Error output: {result.stderr.strip()}") - return False - - except subprocess.TimeoutExpired: - print("Error: Python dependencies installation timed out") - return False - except FileNotFoundError: - print("Error: uv command not found") - return False - except Exception as e: - print(f"Error installing Python dependencies: {e}") - return False - - return True - - -def install_esptool(): - """ - Install esptool from package folder "tool-esptoolpy" using uv package manager. - - Raises: - SystemExit: If esptool installation fails - """ - try: - subprocess.check_call( - [PYTHON_EXE, "-c", "import esptool"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - return - except (subprocess.CalledProcessError, FileNotFoundError): - pass - - esptool_repo_path = env.subst(platform.get_package_dir("tool-esptoolpy") or "") - if not esptool_repo_path or not os.path.isdir(esptool_repo_path): - print("Error: esptool package directory not found") - sys.exit(1) - - try: - subprocess.check_call([ - uv_executable, "pip", "install", "--quiet", - f"--python={PYTHON_EXE}", - "-e", esptool_repo_path - ]) - - return - - except subprocess.CalledProcessError as e: - print(f"Error: Failed to install esptool: {e}") - sys.exit(1) - - -# Install espressif32 Python dependencies -install_python_deps() -# Install esptool after dependencies -install_esptool() +# Setup Python virtual environment and get executable paths +PYTHON_EXE, esptool_binary_path = setup_python_environment(env, platform, platformio_dir) def BeforeUpload(target, source, env): @@ -704,11 +414,12 @@ def switch_off_ldf(): # Initialize board configuration and MCU settings board = env.BoardConfig() mcu = board.get("build.mcu", "esp32") +is_xtensa = mcu in ("esp32", "esp32s2", "esp32s3") toolchain_arch = "xtensa-%s" % mcu filesystem = board.get("build.filesystem", "littlefs") # Set toolchain architecture for RISC-V based ESP32 variants -if mcu in ("esp32c2", "esp32c3", "esp32c5", "esp32c6", "esp32h2", "esp32p4"): +if not is_xtensa: toolchain_arch = "riscv32-esp" # Initialize integration extra data if not present @@ -716,7 +427,7 @@ def switch_off_ldf(): env["INTEGRATION_EXTRA_DATA"] = {} # Take care of possible whitespaces in path -objcopy_value = ( +uploader_path = ( f'"{esptool_binary_path}"' if ' ' in esptool_binary_path else esptool_binary_path @@ -736,21 +447,14 @@ def switch_off_ldf(): GDB=join( platform.get_package_dir( "tool-riscv32-esp-elf-gdb" - if mcu in ( - "esp32c2", - "esp32c3", - "esp32c5", - "esp32c6", - "esp32h2", - "esp32p4", - ) + if not is_xtensa else "tool-xtensa-esp-elf-gdb" ) or "", "bin", "%s-elf-gdb" % toolchain_arch, ), - OBJCOPY=objcopy_value, + OBJCOPY=uploader_path, RANLIB="%s-elf-gcc-ranlib" % toolchain_arch, SIZETOOL="%s-elf-size" % toolchain_arch, ARFLAGS=["rc"], @@ -760,7 +464,7 @@ def switch_off_ldf(): SIZECHECKCMD="$SIZETOOL -A -d $SOURCES", SIZEPRINTCMD="$SIZETOOL -B -d $SOURCES", ERASEFLAGS=["--chip", mcu, "--port", '"$UPLOAD_PORT"'], - ERASECMD='"$OBJCOPY" $ERASEFLAGS erase-flash', + ERASECMD='$OBJCOPY $ERASEFLAGS erase-flash', # mkspiffs package contains two different binaries for IDF and Arduino MKFSTOOL="mk%s" % filesystem + ( @@ -892,7 +596,6 @@ def firmware_metrics(target, source, env): dash_index = sys.argv.index("--") if dash_index + 1 < len(sys.argv): cli_args = sys.argv[dash_index + 1:] - cmd.extend(cli_args) # Add CLI arguments before the map file if cli_args: @@ -910,10 +613,7 @@ def firmware_metrics(target, source, env): if result.returncode != 0: print(f"Warning: esp-idf-size exited with code {result.returncode}") - - except ImportError: - print("Error: esp-idf-size module not found.") - print("Install with: pip install esp-idf-size") + except FileNotFoundError: print("Error: Python executable not found.") print("Check your Python installation.") @@ -1016,7 +716,7 @@ def firmware_metrics(target, source, env): # Configure upload protocol: esptool elif upload_protocol == "esptool": env.Replace( - UPLOADER=objcopy_value, + UPLOADER=uploader_path, UPLOADERFLAGS=[ "--chip", mcu, @@ -1065,7 +765,7 @@ def firmware_metrics(target, source, env): "detect", "$FS_START", ], - UPLOADCMD='"$UPLOADER" $UPLOADERFLAGS $SOURCE', + UPLOADCMD='$UPLOADER $UPLOADERFLAGS $SOURCE', ) upload_actions = [ @@ -1091,7 +791,7 @@ def firmware_metrics(target, source, env): "-Q", "-D", ], - UPLOADCMD='"$UPLOADER" $UPLOADERFLAGS "$SOURCE"', + UPLOADCMD='$UPLOADER $UPLOADERFLAGS "$SOURCE"', ) # Configure upload protocol: Debug tools (OpenOCD) diff --git a/builder/penv_setup.py b/builder/penv_setup.py new file mode 100644 index 000000000..568220d9a --- /dev/null +++ b/builder/penv_setup.py @@ -0,0 +1,361 @@ +# Copyright 2014-present PlatformIO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os +import re +import site +import semantic_version +import subprocess +import sys + +from platformio.package.version import pepver_to_semver +from platformio.compat import IS_WINDOWS + +PLATFORMIO_URL_VERSION_RE = re.compile( + r'/v?(\d+\.\d+\.\d+(?:[.-]\w+)?(?:\.\d+)?)(?:\.(?:zip|tar\.gz|tar\.bz2))?$', + re.IGNORECASE, +) + +# Python dependencies required for the build process +python_deps = { + "uv": ">=0.1.0", + "platformio": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip", + "pyyaml": ">=6.0.2", + "rich-click": ">=1.8.6", + "zopfli": ">=0.2.2", + "intelhex": ">=2.3.0", + "rich": ">=14.0.0", + "cryptography": ">=45.0.3", + "ecdsa": ">=0.19.1", + "bitstring": ">=4.3.1", + "reedsolo": ">=1.5.3,<1.8", + "esp-idf-size": ">=1.6.1" +} + + +def get_executable_path(penv_dir, executable_name): + """ + Get the path to an executable based on the penv_dir. + """ + exe_suffix = ".exe" if IS_WINDOWS else "" + scripts_dir = "Scripts" if IS_WINDOWS else "bin" + + return os.path.join(penv_dir, scripts_dir, f"{executable_name}{exe_suffix}") + + +def setup_pipenv_in_package(env, penv_dir): + """ + Checks if 'penv' folder exists in platformio dir and creates virtual environment if not. + """ + if not os.path.exists(penv_dir): + env.Execute( + env.VerboseAction( + '"$PYTHONEXE" -m venv --clear "%s"' % penv_dir, + "Creating pioarduino Python virtual environment: %s" % penv_dir, + ) + ) + assert os.path.isfile( + get_executable_path(penv_dir, "pip") + ), "Error: Failed to create a proper virtual environment. Missing the `pip` binary!" + + +def setup_python_paths(penv_dir): + """Setup Python module search paths using the penv_dir.""" + # Add penv_dir to module search path + site.addsitedir(penv_dir) + + # Add site-packages directory + python_ver = f"python{sys.version_info.major}.{sys.version_info.minor}" + site_packages = ( + os.path.join(penv_dir, "Lib", "site-packages") if IS_WINDOWS + else os.path.join(penv_dir, "lib", python_ver, "site-packages") + ) + + if os.path.isdir(site_packages): + site.addsitedir(site_packages) + + +def get_packages_to_install(deps, installed_packages): + """ + Generator for Python packages that need to be installed. + Compares package names case-insensitively. + + Args: + deps (dict): Dictionary of package names and version specifications + installed_packages (dict): Dictionary of currently installed packages (keys should be lowercase) + + Yields: + str: Package name that needs to be installed + """ + for package, spec in deps.items(): + name = package.lower() + if name not in installed_packages: + yield package + elif name == "platformio": + # Enforce the version from the direct URL if it looks like one. + # If version can't be parsed, fall back to accepting any installed version. + m = PLATFORMIO_URL_VERSION_RE.search(spec) + if m: + expected_ver = pepver_to_semver(m.group(1)) + if installed_packages.get(name) != expected_ver: + # Reinstall to align with the pinned URL version + yield package + else: + continue + else: + version_spec = semantic_version.SimpleSpec(spec) + if not version_spec.match(installed_packages[name]): + yield package + + +def install_python_deps(python_exe, uv_executable): + """ + Ensure uv package manager is available and install required Python dependencies. + + Returns: + bool: True if successful, False otherwise + """ + try: + result = subprocess.run( + [uv_executable, "--version"], + capture_output=True, + text=True, + timeout=3 + ) + uv_available = result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + uv_available = False + + if not uv_available: + try: + result = subprocess.run( + [python_exe, "-m", "pip", "install", "uv>=0.1.0", "-q", "-q", "-q"], + capture_output=True, + text=True, + timeout=30 # 30 second timeout + ) + if result.returncode != 0: + if result.stderr: + print(f"Error output: {result.stderr.strip()}") + return False + + except subprocess.TimeoutExpired: + print("Error: uv installation timed out") + return False + except FileNotFoundError: + print("Error: Python executable not found") + return False + except Exception as e: + print(f"Error installing uv package manager: {e}") + return False + + + def _get_installed_uv_packages(): + """ + Get list of installed packages in virtual env 'penv' using uv. + + Returns: + dict: Dictionary of installed packages with versions + """ + result = {} + try: + cmd = [uv_executable, "pip", "list", f"--python={python_exe}", "--format=json"] + result_obj = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding='utf-8', + timeout=30 # 30 second timeout + ) + + if result_obj.returncode == 0: + content = result_obj.stdout.strip() + if content: + packages = json.loads(content) + for p in packages: + result[p["name"].lower()] = pepver_to_semver(p["version"]) + else: + print(f"Warning: uv pip list failed with exit code {result_obj.returncode}") + if result_obj.stderr: + print(f"Error output: {result_obj.stderr.strip()}") + + except subprocess.TimeoutExpired: + print("Warning: uv pip list command timed out") + except (json.JSONDecodeError, KeyError) as e: + print(f"Warning: Could not parse package list: {e}") + except FileNotFoundError: + print("Warning: uv command not found") + except Exception as e: + print(f"Warning! Couldn't extract the list of installed Python packages: {e}") + + return result + + installed_packages = _get_installed_uv_packages() + packages_to_install = list(get_packages_to_install(python_deps, installed_packages)) + + if packages_to_install: + packages_list = [] + for p in packages_to_install: + spec = python_deps[p] + if spec.startswith(('http://', 'https://', 'git+', 'file://')): + packages_list.append(spec) + else: + packages_list.append(f"{p}{spec}") + + cmd = [ + uv_executable, "pip", "install", + f"--python={python_exe}", + "--quiet", "--upgrade" + ] + packages_list + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 # 30 second timeout for package installation + ) + + if result.returncode != 0: + print(f"Error: Failed to install Python dependencies (exit code: {result.returncode})") + if result.stderr: + print(f"Error output: {result.stderr.strip()}") + return False + + except subprocess.TimeoutExpired: + print("Error: Python dependencies installation timed out") + return False + except FileNotFoundError: + print("Error: uv command not found") + return False + except Exception as e: + print(f"Error installing Python dependencies: {e}") + return False + + return True + + +def install_esptool(env, platform, python_exe, uv_executable): + """ + Install esptool from package folder "tool-esptoolpy" using uv package manager. + Ensures esptool is installed from the specific tool-esptoolpy package directory. + + Args: + env: SCons environment object + platform: PlatformIO platform object + python_exe (str): Path to Python executable in virtual environment + uv_executable (str): Path to uv executable + + Raises: + SystemExit: If esptool installation fails or package directory not found + """ + esptool_repo_path = env.subst(platform.get_package_dir("tool-esptoolpy") or "") + if not esptool_repo_path or not os.path.isdir(esptool_repo_path): + sys.stderr.write( + f"Error: 'tool-esptoolpy' package directory not found: {esptool_repo_path!r}\n" + ) + sys.exit(1) + + # Check if esptool is already installed from the correct path + try: + result = subprocess.run( + [ + python_exe, + "-c", + ( + "import esptool, os, sys; " + "expected_path = os.path.normcase(os.path.realpath(sys.argv[1])); " + "actual_path = os.path.normcase(os.path.realpath(os.path.dirname(esptool.__file__))); " + "print('MATCH' if actual_path.startswith(expected_path) else 'MISMATCH')" + ), + esptool_repo_path, + ], + capture_output=True, + check=True, + text=True, + timeout=5 + ) + + if result.stdout.strip() == "MATCH": + return + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + try: + subprocess.check_call([ + uv_executable, "pip", "install", "--quiet", "--force-reinstall", + f"--python={python_exe}", + "-e", esptool_repo_path + ]) + + except subprocess.CalledProcessError as e: + sys.stderr.write( + f"Error: Failed to install esptool from {esptool_repo_path} (exit {e.returncode})\n" + ) + sys.exit(1) + + +def setup_python_environment(env, platform, platformio_dir): + """ + Main function to setup the Python virtual environment and dependencies. + + Args: + env: SCons environment object + platform: PlatformIO platform object + platformio_dir (str): Path to PlatformIO core directory + + Returns: + tuple[str, str]: (Path to penv Python executable, Path to esptool script) + + Raises: + SystemExit: If Python version < 3.10 or dependency installation fails + """ + # Check Python version requirement + if sys.version_info < (3, 10): + sys.stderr.write( + f"Error: Python 3.10 or higher is required. " + f"Current version: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\n" + f"Please update your Python installation.\n" + ) + sys.exit(1) + + penv_dir = os.path.join(platformio_dir, "penv") + + # Setup virtual environment if needed + setup_pipenv_in_package(env, penv_dir) + + # Set Python Scons Var to env Python + penv_python = get_executable_path(penv_dir, "python") + env.Replace(PYTHONEXE=penv_python) + + # check for python binary, exit with error when not found + assert os.path.isfile(penv_python), f"Python executable not found: {penv_python}" + + # Setup Python module search paths + setup_python_paths(penv_dir) + + # Set executable paths from tools + esptool_binary_path = get_executable_path(penv_dir, "esptool") + uv_executable = get_executable_path(penv_dir, "uv") + + # Install espressif32 Python dependencies + if not install_python_deps(penv_python, uv_executable): + sys.stderr.write("Error: Failed to install Python dependencies into penv\n") + sys.exit(1) + # Install esptool after dependencies + install_esptool(env, platform, penv_python, uv_executable) + + return penv_python, esptool_binary_path