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,45 +119,50 @@ 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': (
'For example: ' 'Create a new project with the name NAME specified as argument. '
'`idf.py create-project new_proj` ' 'For example: '
'will create a new project in subdirectory called `new_proj` ' '`idf.py create-project new_proj` '
'of the current working directory. ' 'will create a new project in subdirectory called `new_proj` '
"For specifying the new project's path, use either the option --path for specifying the " 'of the current working directory. '
'destination directory, or the global option -C if the project should be created as a ' "For specifying the new project's path, use either the option --path for specifying the "
'subdirectory of the specified directory. ' 'destination directory, or the global option -C if the project should be created as a '
'If the target path does not exist it will be created. If the target folder is not empty ' 'subdirectory of the specified directory. '
'then the operation will fail with return code 3. ' 'If the target path does not exist it will be created. If the target folder is not empty '
'If the target path is not a folder, the script will fail with return code 4. ' 'then the operation will fail with return code 3. '
'After the execution idf.py terminates ' 'If the target path is not a folder, the script will fail with return code 4. '
'so this operation should be used alone.'), 'After the execution idf.py terminates '
'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': (
'For example: ' 'Create a new component with the name NAME specified as argument. '
'`idf.py create-component new_comp` ' 'For example: '
'will create a new component in subdirectory called `new_comp` ' '`idf.py create-component new_comp` '
'of the current working directory. ' 'will create a new component in subdirectory called `new_comp` '
"For specifying the new component's path use the option -C. " 'of the current working directory. '
'If the target path does not exist then it will be created. ' "For specifying the new component's path use the option -C. "
'If the target folder is not empty ' 'If the target path does not exist then it will be created. '
'then the operation will fail with return code 3. ' 'If the target folder is not empty '
'If the target path is not a folder, the script will fail with return code 4. ' 'then the operation will fail with return code 3. '
'After the execution idf.py terminates ' 'If the target path is not a folder, the script will fail with return code 4. '
'so this operation should be used alone.'), 'After the execution idf.py terminates '
'so this operation should be used alone.'
),
'arguments': [{'names': ['name']}], 'arguments': [{'names': ['name']}],
} },
} }
} }