Skip to content

Commit 9e4d64a

Browse files
authored
Create penv_setup.py
1 parent e0f2822 commit 9e4d64a

File tree

1 file changed

+344
-0
lines changed

1 file changed

+344
-0
lines changed

builder/penv_setup.py

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
# Copyright 2014-present PlatformIO <[email protected]>
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import json
16+
import os
17+
import re
18+
import site
19+
import semantic_version
20+
import subprocess
21+
import sys
22+
23+
from platformio.package.version import pepver_to_semver
24+
from platformio.compat import IS_WINDOWS
25+
26+
# Python dependencies required for the build process
27+
python_deps = {
28+
"uv": ">=0.1.0",
29+
"platformio": "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.18.zip",
30+
"pyyaml": ">=6.0.2",
31+
"rich-click": ">=1.8.6",
32+
"zopfli": ">=0.2.2",
33+
"intelhex": ">=2.3.0",
34+
"rich": ">=14.0.0",
35+
"cryptography": ">=45.0.3",
36+
"ecdsa": ">=0.19.1",
37+
"bitstring": ">=4.3.1",
38+
"reedsolo": ">=1.5.3,<1.8",
39+
"esp-idf-size": ">=1.6.1"
40+
}
41+
42+
43+
def get_executable_path(penv_dir, executable_name):
44+
"""
45+
Get the path to an executable based on the penv_dir.
46+
"""
47+
exe_suffix = ".exe" if IS_WINDOWS else ""
48+
scripts_dir = "Scripts" if IS_WINDOWS else "bin"
49+
50+
return os.path.join(penv_dir, scripts_dir, f"{executable_name}{exe_suffix}")
51+
52+
53+
def setup_pipenv_in_package(env, penv_dir):
54+
"""
55+
Checks if 'penv' folder exists in platformio dir and creates virtual environment if not.
56+
"""
57+
if not os.path.exists(penv_dir):
58+
env.Execute(
59+
env.VerboseAction(
60+
'"$PYTHONEXE" -m venv --clear "%s"' % penv_dir,
61+
"Creating pioarduino Python virtual environment: %s" % penv_dir,
62+
)
63+
)
64+
assert os.path.isfile(
65+
get_executable_path(penv_dir, "pip")
66+
), "Error: Failed to create a proper virtual environment. Missing the `pip` binary!"
67+
68+
69+
def setup_python_paths(penv_dir):
70+
"""Setup Python module search paths using the penv_dir."""
71+
# Add penv_dir to module search path
72+
site.addsitedir(penv_dir)
73+
74+
# Add site-packages directory
75+
python_ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
76+
site_packages = (
77+
os.path.join(penv_dir, "Lib", "site-packages") if IS_WINDOWS
78+
else os.path.join(penv_dir, "lib", python_ver, "site-packages")
79+
)
80+
81+
if os.path.isdir(site_packages):
82+
site.addsitedir(site_packages)
83+
84+
85+
def get_packages_to_install(deps, installed_packages):
86+
"""
87+
Generator for Python packages that need to be installed.
88+
Compares package names case-insensitively.
89+
90+
Args:
91+
deps (dict): Dictionary of package names and version specifications
92+
installed_packages (dict): Dictionary of currently installed packages (keys should be lowercase)
93+
94+
Yields:
95+
str: Package name that needs to be installed
96+
"""
97+
for package, spec in deps.items():
98+
name = package.lower()
99+
if name not in installed_packages:
100+
yield package
101+
elif name == "platformio":
102+
# Enforce the version from the direct URL if it looks like one.
103+
# If version can't be parsed, fall back to accepting any installed version.
104+
m = re.search(r'/v?(\d+\.\d+\.\d+(?:\.\d+)?)(?:\.(?:zip|tar\.gz|tar\.bz2))?$', spec)
105+
if m:
106+
expected_ver = pepver_to_semver(m.group(1))
107+
if installed_packages.get(name) != expected_ver:
108+
# Reinstall to align with the pinned URL version
109+
yield package
110+
else:
111+
continue
112+
else:
113+
version_spec = semantic_version.SimpleSpec(spec)
114+
if not version_spec.match(installed_packages[name]):
115+
yield package
116+
117+
118+
def install_python_deps(python_exe, uv_executable):
119+
"""
120+
Ensure uv package manager is available and install required Python dependencies.
121+
122+
Returns:
123+
bool: True if successful, False otherwise
124+
"""
125+
try:
126+
result = subprocess.run(
127+
[uv_executable, "--version"],
128+
capture_output=True,
129+
text=True,
130+
timeout=3
131+
)
132+
uv_available = result.returncode == 0
133+
except (FileNotFoundError, subprocess.TimeoutExpired):
134+
uv_available = False
135+
136+
if not uv_available:
137+
try:
138+
result = subprocess.run(
139+
[python_exe, "-m", "pip", "install", "uv>=0.1.0", "-q", "-q", "-q"],
140+
capture_output=True,
141+
text=True,
142+
timeout=30 # 30 second timeout
143+
)
144+
if result.returncode != 0:
145+
if result.stderr:
146+
print(f"Error output: {result.stderr.strip()}")
147+
return False
148+
149+
except subprocess.TimeoutExpired:
150+
print("Error: uv installation timed out")
151+
return False
152+
except FileNotFoundError:
153+
print("Error: Python executable not found")
154+
return False
155+
except Exception as e:
156+
print(f"Error installing uv package manager: {e}")
157+
return False
158+
159+
160+
def _get_installed_uv_packages():
161+
"""
162+
Get list of installed packages in virtual env 'penv' using uv.
163+
164+
Returns:
165+
dict: Dictionary of installed packages with versions
166+
"""
167+
result = {}
168+
try:
169+
cmd = [uv_executable, "pip", "list", f"--python={python_exe}", "--format=json"]
170+
result_obj = subprocess.run(
171+
cmd,
172+
capture_output=True,
173+
text=True,
174+
encoding='utf-8',
175+
timeout=30 # 30 second timeout
176+
)
177+
178+
if result_obj.returncode == 0:
179+
content = result_obj.stdout.strip()
180+
if content:
181+
packages = json.loads(content)
182+
for p in packages:
183+
result[p["name"].lower()] = pepver_to_semver(p["version"])
184+
else:
185+
print(f"Warning: uv pip list failed with exit code {result_obj.returncode}")
186+
if result_obj.stderr:
187+
print(f"Error output: {result_obj.stderr.strip()}")
188+
189+
except subprocess.TimeoutExpired:
190+
print("Warning: uv pip list command timed out")
191+
except (json.JSONDecodeError, KeyError) as e:
192+
print(f"Warning: Could not parse package list: {e}")
193+
except FileNotFoundError:
194+
print("Warning: uv command not found")
195+
except Exception as e:
196+
print(f"Warning! Couldn't extract the list of installed Python packages: {e}")
197+
198+
return result
199+
200+
installed_packages = _get_installed_uv_packages()
201+
packages_to_install = list(get_packages_to_install(python_deps, installed_packages))
202+
203+
if packages_to_install:
204+
packages_list = []
205+
for p in packages_to_install:
206+
spec = python_deps[p]
207+
if spec.startswith(('http://', 'https://', 'git+', 'file://')):
208+
packages_list.append(spec)
209+
else:
210+
packages_list.append(f"{p}{spec}")
211+
212+
cmd = [
213+
uv_executable, "pip", "install",
214+
f"--python={python_exe}",
215+
"--quiet", "--upgrade"
216+
] + packages_list
217+
218+
try:
219+
result = subprocess.run(
220+
cmd,
221+
capture_output=True,
222+
text=True,
223+
timeout=30 # 30 second timeout for package installation
224+
)
225+
226+
if result.returncode != 0:
227+
print(f"Error: Failed to install Python dependencies (exit code: {result.returncode})")
228+
if result.stderr:
229+
print(f"Error output: {result.stderr.strip()}")
230+
return False
231+
232+
except subprocess.TimeoutExpired:
233+
print("Error: Python dependencies installation timed out")
234+
return False
235+
except FileNotFoundError:
236+
print("Error: uv command not found")
237+
return False
238+
except Exception as e:
239+
print(f"Error installing Python dependencies: {e}")
240+
return False
241+
242+
return True
243+
244+
245+
def install_esptool(env, platform, python_exe, uv_executable):
246+
"""
247+
Install esptool from package folder "tool-esptoolpy" using uv package manager.
248+
Ensures esptool is installed from the specific tool-esptoolpy package directory.
249+
250+
Args:
251+
env: SCons environment object
252+
platform: PlatformIO platform object
253+
python_exe (str): Path to Python executable in virtual environment
254+
uv_executable (str): Path to uv executable
255+
256+
Raises:
257+
SystemExit: If esptool installation fails or package directory not found
258+
"""
259+
esptool_repo_path = env.subst(platform.get_package_dir("tool-esptoolpy") or "")
260+
if not esptool_repo_path or not os.path.isdir(esptool_repo_path):
261+
sys.exit(1)
262+
263+
# Check if esptool is already installed from the correct path
264+
try:
265+
result = subprocess.run(
266+
[python_exe, "-c",
267+
"import esptool; "
268+
"import os; "
269+
f"expected_path = os.path.normpath(r'{esptool_repo_path}'); "
270+
"actual_path = os.path.normpath(os.path.dirname(esptool.__file__)); "
271+
"print('MATCH' if expected_path in actual_path else 'MISMATCH')"],
272+
capture_output=True,
273+
text=True,
274+
timeout=5
275+
)
276+
277+
if result.returncode == 0 and "MATCH" in result.stdout:
278+
return
279+
280+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
281+
pass
282+
283+
try:
284+
subprocess.check_call([
285+
uv_executable, "pip", "install", "--quiet", "--force-reinstall",
286+
f"--python={python_exe}",
287+
"-e", esptool_repo_path
288+
])
289+
290+
except subprocess.CalledProcessError:
291+
sys.exit(1)
292+
293+
294+
def setup_python_environment(env, platform, platformio_dir):
295+
"""
296+
Main function to setup the Python virtual environment and dependencies.
297+
298+
Args:
299+
env: SCons environment object
300+
platform: PlatformIO platform object
301+
platformio_dir (str): Path to PlatformIO core directory
302+
303+
Returns:
304+
tuple[str, str]: (Path to penv Python executable, Path to esptool script)
305+
306+
Raises:
307+
SystemExit: If Python version < 3.10 or dependency installation fails
308+
"""
309+
# Check Python version requirement
310+
if sys.version_info < (3, 10):
311+
sys.stderr.write(
312+
f"Error: Python 3.10 or higher is required. "
313+
f"Current version: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\n"
314+
f"Please update your Python installation.\n"
315+
)
316+
sys.exit(1)
317+
318+
penv_dir = os.path.join(platformio_dir, "penv")
319+
320+
# Setup virtual environment if needed
321+
setup_pipenv_in_package(env, penv_dir)
322+
323+
# Set Python Scons Var to env Python
324+
penv_python = get_executable_path(penv_dir, "python")
325+
env.Replace(PYTHONEXE=penv_python)
326+
327+
# check for python binary, exit with error when not found
328+
assert os.path.isfile(penv_python), f"Python executable not found: {penv_python}"
329+
330+
# Setup Python module search paths
331+
setup_python_paths(penv_dir)
332+
333+
# Set executable paths from tools
334+
esptool_binary_path = get_executable_path(penv_dir, "esptool")
335+
uv_executable = get_executable_path(penv_dir, "uv")
336+
337+
# Install espressif32 Python dependencies
338+
if not install_python_deps(penv_python, uv_executable):
339+
sys.stderr.write("Error: Failed to install Python dependencies into penv\n")
340+
sys.exit(1)
341+
# Install esptool after dependencies
342+
install_esptool(env, platform, penv_python, uv_executable)
343+
344+
return penv_python, esptool_binary_path

0 commit comments

Comments
 (0)