Merge branch 'fix/create_project_read_only' into 'master'

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

Closes IDFGH-15364 and IDFGH-15305

See merge request espressif/esp-idf!39751
This commit is contained in:
Roland Dobai
2025-06-12 10:50:29 +02:00

View File

@@ -1,13 +1,15 @@
# SPDX-FileCopyrightText: 2022-2024 Espressif Systems (Shanghai) CO LTD # SPDX-FileCopyrightText: 2022-2025 Espressif Systems (Shanghai) CO LTD
# 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
@@ -29,23 +31,50 @@ def is_empty_and_create(path: str, action: str) -> None:
if not os.path.exists(abspath): if not os.path.exists(abspath):
os.makedirs(abspath) os.makedirs(abspath)
elif not os.path.isdir(abspath): elif not os.path.isdir(abspath):
print('Your target path is not a directory. Please remove the', os.path.abspath(abspath), print(
'or use different target path.') f'Your target path is not a directory.'
f'Please remove the {os.path.abspath(abspath)} or use different target path.'
)
sys.exit(4) sys.exit(4)
elif len(os.listdir(path)) > 0: elif len(os.listdir(path)) > 0:
print('The directory', abspath, 'is not empty. To create a', get_type(action), print(
'you must empty the directory or choose a different path.') f'The directory {abspath} is not empty. To create a {get_type(action)} you must '
f'empty the directory or choose a different path.'
)
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)
@@ -56,13 +85,16 @@ 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.path.join(target_path, 'include', 'main.h'), os.rename(
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')))
)
replace_in_file(os.path.join(target_path, '.'.join((name, 'c'))), 'main', name) replace_in_file(os.path.join(target_path, '.'.join((name, 'c'))), 'main', name)
replace_in_file(os.path.join(target_path, 'CMakeLists.txt'), 'main', name) replace_in_file(os.path.join(target_path, 'CMakeLists.txt'), 'main', name)
@@ -87,7 +119,8 @@ def action_extensions(base_actions: Dict, project_path: str) -> Dict:
'create-project': { 'create-project': {
'callback': create_new, 'callback': create_new,
'short_help': 'Create a new project.', 'short_help': 'Create a new project.',
'help': ('Create a new project with the name NAME specified as argument. ' 'help': (
'Create a new project with the name NAME specified as argument. '
'For example: ' 'For example: '
'`idf.py create-project new_proj` ' '`idf.py create-project new_proj` '
'will create a new project in subdirectory called `new_proj` ' 'will create a new project in subdirectory called `new_proj` '
@@ -99,21 +132,24 @@ def action_extensions(base_actions: Dict, project_path: str) -> Dict:
'then the operation will fail with return code 3. ' 'then the operation will fail with return code 3. '
'If the target path is not a folder, the script will fail with return code 4. ' 'If the target path is not a folder, the script will fail with return code 4. '
'After the execution idf.py terminates ' 'After the execution idf.py terminates '
'so this operation should be used alone.'), 'so this operation should be used alone.'
),
'arguments': [{'names': ['name']}], 'arguments': [{'names': ['name']}],
'options': [ 'options': [
{ {
'names': ['-p', '--path'], 'names': ['-p', '--path'],
'help': ('Set the path for the new project. The project ' 'help': (
'will be created directly in the given folder if it does not contain anything'), 'Set the path for the new project. The project '
'will be created directly in the given folder if it does not contain anything'
),
}, },
], ],
}, },
'create-component': { 'create-component': {
'callback': create_new, 'callback': create_new,
'short_help': 'Create a new component.', 'short_help': 'Create a new component.',
'help': ('Create a new component with the name NAME specified as argument. ' 'help': (
'Create a new component with the name NAME specified as argument. '
'For example: ' 'For example: '
'`idf.py create-component new_comp` ' '`idf.py create-component new_comp` '
'will create a new component in subdirectory called `new_comp` ' 'will create a new component in subdirectory called `new_comp` '
@@ -124,8 +160,9 @@ def action_extensions(base_actions: Dict, project_path: str) -> Dict:
'then the operation will fail with return code 3. ' 'then the operation will fail with return code 3. '
'If the target path is not a folder, the script will fail with return code 4. ' 'If the target path is not a folder, the script will fail with return code 4. '
'After the execution idf.py terminates ' 'After the execution idf.py terminates '
'so this operation should be used alone.'), 'so this operation should be used alone.'
),
'arguments': [{'names': ['name']}], 'arguments': [{'names': ['name']}],
} },
} }
} }