fix(tools): idf.py create-project works in read-only ESP-IDF

As the native copy function shutil.copyfile preserves directories metadata
such as file permissions, we need to ensure the copied destination
is writable for owner.

Closes https://github.com/espressif/esp-idf/issues/15964
Closes https://github.com/espressif/esp-idf/pull/16021
This commit is contained in:
Marek Fiala
2025-06-09 10:52:05 +02:00
parent dcdd823263
commit e7e7f8feb9

View File

@@ -2,13 +2,13 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
import os import os
import re import re
import stat
import sys import sys
from shutil import copyfile from shutil import copyfile
from shutil import copytree from shutil import copytree
from typing import Dict from typing import Dict
import click import click
from idf_py_actions.tools import PropertyDict from idf_py_actions.tools import PropertyDict
@@ -43,14 +43,37 @@ def is_empty_and_create(path: str, action: str) -> None:
sys.exit(3) sys.exit(3)
def make_directory_permissions_writable(root_path: str) -> None:
"""
Ensures all directories under `root_path` have write permission for the owner.
Skips files and doesn't override existing permissions unnecessarily.
Only applies to POSIX systems (Linux/macOS).
"""
if sys.platform == 'win32':
return
for current_root, dirs, _ in os.walk(root_path):
for dirname in dirs:
dir_path = os.path.join(current_root, dirname)
try:
current_perm = stat.S_IMODE(os.stat(dir_path).st_mode)
new_perm = current_perm | stat.S_IWUSR # mask permission for owner (write)
if new_perm != current_perm:
os.chmod(dir_path, new_perm)
except PermissionError:
continue
def create_project(target_path: str, name: str) -> None: def create_project(target_path: str, name: str) -> None:
copytree( copytree(
os.path.join(os.environ['IDF_PATH'], 'tools', 'templates', 'sample_project'), os.path.join(os.environ['IDF_PATH'], 'tools', 'templates', 'sample_project'),
target_path, target_path,
# 'copyfile' ensures only data are copied, without any metadata (file permissions) # 'copyfile' ensures only data are copied, without any metadata (file permissions) - for files only
copy_function=copyfile, copy_function=copyfile,
dirs_exist_ok=True, dirs_exist_ok=True,
) )
# since 'copyfile' does preserve directory metadata, we need to make sure the directories are writable
make_directory_permissions_writable(target_path)
main_folder = os.path.join(target_path, 'main') main_folder = os.path.join(target_path, 'main')
os.rename(os.path.join(main_folder, 'main.c'), os.path.join(main_folder, '.'.join((name, 'c')))) os.rename(os.path.join(main_folder, 'main.c'), os.path.join(main_folder, '.'.join((name, 'c'))))
replace_in_file(os.path.join(main_folder, 'CMakeLists.txt'), 'main', name) replace_in_file(os.path.join(main_folder, 'CMakeLists.txt'), 'main', name)
@@ -61,10 +84,12 @@ def create_component(target_path: str, name: str) -> None:
copytree( copytree(
os.path.join(os.environ['IDF_PATH'], 'tools', 'templates', 'sample_component'), os.path.join(os.environ['IDF_PATH'], 'tools', 'templates', 'sample_component'),
target_path, target_path,
# 'copyfile' ensures only data are copied, without any metadata (file permissions) # 'copyfile' ensures only data are copied, without any metadata (file permissions) - for files only
copy_function=copyfile, copy_function=copyfile,
dirs_exist_ok=True, dirs_exist_ok=True,
) )
# since 'copyfile' does preserve directory metadata, we need to make sure the directories are writable
make_directory_permissions_writable(target_path)
os.rename(os.path.join(target_path, 'main.c'), os.path.join(target_path, '.'.join((name, 'c')))) os.rename(os.path.join(target_path, 'main.c'), os.path.join(target_path, '.'.join((name, 'c'))))
os.rename( os.rename(
os.path.join(target_path, 'include', 'main.h'), os.path.join(target_path, 'include', '.'.join((name, 'h'))) os.path.join(target_path, 'include', 'main.h'), os.path.join(target_path, 'include', '.'.join((name, 'h')))