Skip to content

Commit 2ad8d56

Browse files
committed
Merge branch 'feat/upload_examples_defined_in_manifest_separately' into 'main'
feat: Upload examples defined in manifest separately See merge request espressif/idf-component-manager!491
2 parents bee6e21 + e9d8f27 commit 2ad8d56

File tree

9 files changed

+176
-127
lines changed

9 files changed

+176
-127
lines changed

idf_component_manager/core.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import functools
66
import os
77
import re
8+
import secrets
89
import shutil
910
import tarfile
1011
import tempfile
@@ -78,7 +79,7 @@
7879
ProgressBar,
7980
_create_manifest_if_missing,
8081
archive_filename,
81-
copy_examples_folders,
82+
check_examples_folder,
8283
dist_name,
8384
get_validated_manifest,
8485
parse_example,
@@ -358,7 +359,7 @@ def pack_component(
358359
repository: t.Optional[str] = None,
359360
commit_sha: t.Optional[str] = None,
360361
repository_path: t.Optional[str] = None,
361-
) -> t.Tuple[str, Manifest]:
362+
) -> t.Tuple[str, t.Optional[str], Manifest]:
362363
dest_path = self.path / dest_dir if dest_dir else self.default_dist_path
363364

364365
if version == 'git':
@@ -401,24 +402,43 @@ def pack_component(
401402
exclude=exclude_set,
402403
)
403404

404-
if manifest.examples:
405-
copy_examples_folders(
406-
manifest.examples,
407-
Path(self.path),
408-
dest_temp_dir,
409-
use_gitignore=manifest.use_gitignore,
410-
include=manifest.include_set,
411-
exclude=manifest.exclude_set,
412-
)
413-
414405
manifest_manager.dump(str(dest_temp_dir))
415406

416407
get_validated_manifest(manifest_manager, str(dest_temp_dir))
417408

418409
archive_filepath = os.path.join(dest_path, archive_filename(name, manifest.version))
419-
notice(f'Saving archive to "{archive_filepath}"')
410+
notice(f'Saving component archive to "{archive_filepath}"')
420411
pack_archive(str(dest_temp_dir), archive_filepath)
421-
return archive_filepath, manifest
412+
413+
if not manifest.examples:
414+
return archive_filepath, None, manifest
415+
416+
check_examples_folder(manifest.examples, Path(self.path))
417+
418+
# Create a destination directory for examples defined in the manifest
419+
examples_dest_dir = dest_path / f'{name}_{manifest.version}_examples'
420+
examples_dest_dir.mkdir(parents=True, exist_ok=True)
421+
422+
for example in manifest.examples:
423+
example_path = (self.path / Path(list(example.values())[0])).resolve()
424+
# Do not consider examples from the `examples` directory
425+
if Path(os.path.relpath(example_path, self.path)).parts[0] == 'examples':
426+
continue
427+
428+
# Create a random directory to avoid conflicts with other examples
429+
copy_filtered_directory(
430+
example_path.as_posix(),
431+
(examples_dest_dir / secrets.token_hex(4) / example_path.name).as_posix(),
432+
use_gitignore=manifest.use_gitignore,
433+
include=manifest.include_set,
434+
exclude=exclude_set,
435+
)
436+
437+
examples_archive_filepath = f'{examples_dest_dir}.tgz'
438+
pack_archive(examples_dest_dir.as_posix(), examples_archive_filepath)
439+
notice(f'Saving examples archive to "{examples_archive_filepath}"')
440+
441+
return archive_filepath, examples_archive_filepath, manifest
422442

423443
@general_error_handler
424444
def delete_version(
@@ -518,6 +538,8 @@ def upload_component(
518538
"""
519539
api_client = get_api_client(namespace=namespace, profile_name=profile_name)
520540

541+
examples_archive = []
542+
521543
if archive:
522544
if version:
523545
raise FatalError(
@@ -539,7 +561,7 @@ def upload_component(
539561
finally:
540562
shutil.rmtree(tempdir)
541563
else:
542-
archive, manifest = self.pack_component(
564+
archive, examples_archive, manifest = self.pack_component(
543565
name=name,
544566
version=version,
545567
dest_dir=dest_dir,
@@ -586,10 +608,15 @@ def callback(monitor: MultipartEncoderMonitor) -> None:
586608
memo['progress'] = monitor.bytes_read
587609

588610
if dry_run:
589-
job_id = api_client.validate_version(file_path=archive, callback=callback)
611+
job_id = api_client.validate_version(
612+
file_path=archive, callback=callback, example_file_path=examples_archive
613+
)
590614
else:
591615
job_id = api_client.upload_version(
592-
component_name=component_name, file_path=archive, callback=callback
616+
component_name=component_name,
617+
file_path=archive,
618+
example_file_path=examples_archive,
619+
callback=callback,
593620
)
594621

595622
progress_bar.close()

idf_component_manager/core_utils.py

Lines changed: 11 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# SPDX-FileCopyrightText: 2022-2025 Espressif Systems (Shanghai) CO LTD
22
# SPDX-License-Identifier: Apache-2.0
3-
import os
43
import re
54
import shutil
65
import typing as t
@@ -13,8 +12,6 @@
1312
from idf_component_tools.errors import ComponentModifiedError, FatalError, ModifiedComponent
1413
from idf_component_tools.file_tools import (
1514
check_unexpected_component_files,
16-
copy_directories,
17-
filtered_paths,
1815
)
1916
from idf_component_tools.manager import ManifestManager, UploadMode
2017
from idf_component_tools.manifest import Manifest
@@ -176,45 +173,27 @@ def parse_component_name_spec(
176173

177174

178175
def collect_directories(dir_path: Path) -> t.List[str]:
179-
directories: t.List[str] = []
180176
if not dir_path.is_dir():
181-
return directories
177+
return []
182178

183-
for directory in os.listdir(str(dir_path)):
184-
if directory.startswith('.') or not (dir_path / directory).is_dir():
185-
continue
186-
187-
directories.append(directory)
188-
189-
return directories
179+
return [
180+
entry.name
181+
for entry in dir_path.iterdir()
182+
if entry.is_dir() and not entry.name.startswith('.')
183+
]
190184

191185

192-
def detect_duplicate_examples(example_folders, example_path, example_name):
193-
for key, value in example_folders.items():
194-
if example_name in value:
195-
return key, example_path, example_name
196-
return
197-
198-
199-
def copy_examples_folders(
186+
def check_examples_folder(
200187
examples_manifest: t.List[t.Dict[str, str]],
201188
working_path: Path,
202-
dist_dir: Path,
203-
use_gitignore: bool = False,
204-
include: t.Optional[t.Set[str]] = None,
205-
exclude: t.Optional[t.Set[str]] = None,
206189
) -> None:
207-
examples_path = working_path / 'examples'
208-
example_folders = {'examples': collect_directories(examples_path)}
190+
example_folders = {'examples': collect_directories(working_path / 'examples')}
209191
error_paths = []
210-
duplicate_paths = []
211192
for example_info in examples_manifest:
212193
example_path = example_info['path']
213-
example_name = Path(example_path).name
214-
full_example_path = working_path / example_path
215194

216-
if not full_example_path.is_dir():
217-
error_paths.append(str(full_example_path))
195+
if not (working_path / example_path).is_dir():
196+
error_paths.append(str(working_path / example_path))
218197
continue
219198

220199
if example_path in example_folders.keys():
@@ -223,32 +202,11 @@ def copy_examples_folders(
223202
'Please make paths unique and delete duplicate paths'.format(example_path)
224203
)
225204

226-
duplicates = detect_duplicate_examples(example_folders, example_path, example_name)
227-
if duplicates:
228-
duplicate_paths.append(duplicates)
229-
continue
230-
231-
example_folders[example_path] = [example_name]
232-
233-
paths = filtered_paths(
234-
full_example_path, use_gitignore=use_gitignore, include=include, exclude=exclude
235-
)
236-
copy_directories(str(full_example_path), str(dist_dir / 'examples' / example_name), paths)
205+
example_folders[example_path] = [Path(example_path).name]
237206

238207
if error_paths:
239208
raise FatalError(
240209
"Example directory doesn't exist: {}.\n"
241210
'Please check the path of the custom example folder in `examples` field '
242211
'in `idf_component.yml` file'.format(', '.join(error_paths))
243212
)
244-
245-
if duplicate_paths:
246-
error_messages = []
247-
for first_path, second_path, example_name in duplicate_paths:
248-
error_messages.append(
249-
f'Examples from "{first_path}" and "{second_path}" '
250-
f'have the same name: {example_name}.'
251-
)
252-
error_messages.append('Please rename one of them, or delete if there are the same')
253-
254-
raise FatalError('\n'.join(error_messages))

idf_component_tools/build_system_tools.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# SPDX-FileCopyrightText: 2022-2024 Espressif Systems (Shanghai) CO LTD
1+
# SPDX-FileCopyrightText: 2022-2025 Espressif Systems (Shanghai) CO LTD
22
# SPDX-License-Identifier: Apache-2.0
33
"""Tools for interaction with IDF build system"""
44

@@ -52,7 +52,7 @@ def get_idf_version():
5252
idf_version = subprocess.check_output([sys.executable, idf_py_path, '--version']) # noqa: S603
5353
except subprocess.CalledProcessError:
5454
raise RunningEnvironmentError(
55-
'Could not get IDF version from calling "idf.py --version".\n' 'idf.py path: {}'.format(
55+
'Could not get IDF version from calling "idf.py --version".\nidf.py path: {}'.format(
5656
idf_py_path
5757
)
5858
)
@@ -70,7 +70,7 @@ def get_idf_version():
7070
return str(Version.coerce(res[0]))
7171
else:
7272
raise RunningEnvironmentError(
73-
'Could not parse IDF version from calling "idf.py --version".\n' 'Output: {}'.format(
73+
'Could not parse IDF version from calling "idf.py --version".\nOutput: {}'.format(
7474
idf_version
7575
)
7676
)

idf_component_tools/registry/api_client.py

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
# SPDX-FileCopyrightText: 2022-2024 Espressif Systems (Shanghai) CO LTD
1+
# SPDX-FileCopyrightText: 2022-2025 Espressif Systems (Shanghai) CO LTD
22
# SPDX-License-Identifier: Apache-2.0
33
"""Classes to work with ESP Component Registry"""
44

5-
import os
65
import typing as t
76
from functools import wraps
7+
from pathlib import Path
88
from ssl import SSLEOFError
99

1010
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor
@@ -112,30 +112,50 @@ def revoke_current_token(self, request: t.Callable) -> None:
112112
"""Revoke current token"""
113113
request('delete', ['tokens', 'current'])
114114

115-
def _upload_version_to_endpoint(self, request, file_path, endpoint, callback=None):
116-
with open(file_path, 'rb') as file:
117-
filename = os.path.basename(file_path)
118-
119-
encoder = MultipartEncoder({'file': (filename, file, 'application/octet-stream')})
120-
headers = {'Content-Type': encoder.content_type}
121-
data = MultipartEncoderMonitor(encoder, callback)
115+
def _upload_version_to_endpoint(
116+
self, request, file_path, example_file_path, endpoint, callback=None
117+
):
118+
version_archive_file_handler = open(file_path, 'rb')
119+
file_handlers = [version_archive_file_handler]
120+
files = {
121+
'file': (Path(file_path).name, version_archive_file_handler, 'application/octet-stream')
122+
}
123+
124+
# Handling of example archives defined in the manifest
125+
if example_file_path:
126+
example_archive_file_handler = open(example_file_path, 'rb')
127+
file_handlers.append(example_archive_file_handler)
128+
files['example_file'] = (
129+
Path(example_file_path).name,
130+
example_archive_file_handler,
131+
'application/octet-stream',
132+
)
122133

123-
try:
124-
return request(
125-
'post',
126-
endpoint,
127-
data=data,
128-
headers=headers,
129-
schema=VersionUpload,
130-
timeout=UPLOAD_COMPONENT_TIMEOUT,
131-
)['job_id']
132-
# Python 3.10+ can't process 413 error - https://github.com/urllib3/urllib3/issues/2733
133-
except (SSLEOFError, ContentTooLargeError):
134-
raise APIClientError(
135-
'The component archive exceeds the maximum allowed size. Please consider '
136-
'excluding unnecessary files from your component. If you think your component '
137-
'should be uploaded as it is, please contact [email protected]'
138-
)
134+
# Encode the archives into a multipart form
135+
encoder = MultipartEncoder(files)
136+
headers = {'Content-Type': encoder.content_type}
137+
data = MultipartEncoderMonitor(encoder, callback)
138+
139+
try:
140+
req = request(
141+
'post',
142+
endpoint,
143+
data=data,
144+
headers=headers,
145+
schema=VersionUpload,
146+
timeout=UPLOAD_COMPONENT_TIMEOUT,
147+
)
148+
for file_handler in file_handlers:
149+
file_handler.close()
150+
151+
return req['job_id']
152+
# Python 3.10+ can't process 413 error - https://github.com/urllib3/urllib3/issues/2733
153+
except (SSLEOFError, ContentTooLargeError):
154+
raise APIClientError(
155+
'The component archive exceeds the maximum allowed size. Please consider '
156+
'excluding unnecessary files from your component. If you think your component '
157+
'should be uploaded as it is, please contact [email protected]'
158+
)
139159

140160
@_request
141161
def get_component_response(
@@ -147,18 +167,21 @@ def get_component_response(
147167

148168
@auth_required
149169
@_request
150-
def upload_version(self, request, component_name, file_path, callback=None):
170+
def upload_version(
171+
self, request, component_name, file_path, example_file_path=None, callback=None
172+
):
151173
return self._upload_version_to_endpoint(
152174
request,
153175
file_path,
176+
example_file_path,
154177
['components', component_name.lower(), 'versions'],
155178
callback,
156179
)
157180

158181
@_request
159-
def validate_version(self, request, file_path, callback=None):
182+
def validate_version(self, request, file_path, example_file_path=None, callback=None):
160183
return self._upload_version_to_endpoint(
161-
request, file_path, ['components', 'validate'], callback
184+
request, file_path, example_file_path, ['components', 'validate'], callback
162185
)
163186

164187
@auth_required

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,15 @@ def release_component_path(fixtures_path):
182182
)
183183

184184

185+
@pytest.fixture(scope='session')
186+
def cmp_with_example(fixtures_path):
187+
return os.path.join(
188+
fixtures_path,
189+
'components',
190+
'cmp_with_example',
191+
)
192+
193+
185194
@pytest.fixture(scope='session')
186195
def example_component_path(fixtures_path):
187196
return os.path.join(

0 commit comments

Comments
 (0)