Skip to content

Commit b72c263

Browse files
committed
Merge branch 'feat/new_cli_command' into 'main'
feat(cli): add "config" group to compote CLI Closes PACMAN-865 See merge request espressif/idf-component-manager!455
2 parents 0c9dd74 + fc6f870 commit b72c263

File tree

6 files changed

+461
-4
lines changed

6 files changed

+461
-4
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
5+
import typing as t
6+
from pathlib import Path
7+
8+
import click
9+
from pydantic import ValidationError
10+
from ruamel.yaml import YAML, CommentedMap
11+
12+
from idf_component_manager.cli.validations import validate_name
13+
from idf_component_tools.config import ConfigError, ConfigManager, ProfileItem
14+
from idf_component_tools.errors import FatalError
15+
from idf_component_tools.utils import polish_validation_error
16+
17+
18+
def tuple_to_list(_ctx, _param, value) -> t.List[str]:
19+
if value:
20+
return list(value)
21+
return value
22+
23+
24+
# It is used to avoid validation of the config file while unsetting the profile or its fields
25+
def _write_config(path: Path, data: CommentedMap, yaml: YAML) -> None:
26+
path.parent.mkdir(parents=True, exist_ok=True)
27+
with open(path, mode='w', encoding='utf-8') as f:
28+
yaml.dump(data, f)
29+
30+
31+
def init_config():
32+
@click.group()
33+
def config():
34+
"""
35+
Group of commands to edit the global configuration of the IDF Component Manager by editing "idf_component_manager.yml".
36+
By default, the configuration file is located in the ".espressif" directory in your home directory,
37+
but the path can be configured with the environment variable "IDF_TOOLS_PATH".
38+
"""
39+
pass
40+
41+
@config.command()
42+
def path():
43+
"""
44+
Show path to config file.
45+
"""
46+
print(ConfigManager().config_path)
47+
48+
@config.command(name='list')
49+
def list_command():
50+
"""
51+
List all profiles with tokens hidden.
52+
53+
If the config file looks like this:
54+
profiles:
55+
default:
56+
default_namespace: namespace
57+
empty_profile:
58+
another_profile:
59+
default_namespace: another_namespace
60+
some_other_profile:
61+
aaa: bbb
62+
The 'empty_profile' and 'some_other_profile' will not be printed.
63+
"""
64+
config = ConfigManager().load()
65+
66+
for profile_name, profile in config.profiles.items():
67+
if profile is not None:
68+
# Convert the ProfileItem to a dictionary
69+
profile_dict = profile.model_dump(exclude_none=False)
70+
# If profile does not have fields from ProfileItem, but has something else, it is not None
71+
if all(value is None for value in profile_dict.values()):
72+
continue
73+
# Mask the 'api_token' field
74+
if profile_dict['api_token'] is not None:
75+
profile_dict['api_token'] = '***hidden***' # noqa: S105
76+
77+
# Print profile details, excluding None values
78+
print(f'\nProfile: {profile_name}')
79+
for key, value in profile_dict.items():
80+
if value is not None:
81+
print(f'\t{key.replace("_", " ").title():<20}: {value}')
82+
83+
@config.command()
84+
@click.option(
85+
'--profile',
86+
default='default',
87+
help='The name of the profile to change or add. If not provided, the default profile will be used.',
88+
)
89+
@click.option(
90+
'--registry-url',
91+
help='Set URL of the Component Registry.',
92+
)
93+
@click.option(
94+
'--storage-url',
95+
help='Set one or more storage URLs. To set a list of values, use this argument multiple times: --storage-url <url1> --storage-url <url2>',
96+
callback=tuple_to_list,
97+
multiple=True,
98+
)
99+
@click.option(
100+
'--local-storage-url',
101+
help='Set one or more local storage URLs. To set a list of values, use this argument multiple times: --local-storage-url <url1> --local-storage-url <url2>',
102+
callback=tuple_to_list,
103+
multiple=True,
104+
)
105+
@click.option('--api-token', help='Set API token.')
106+
@click.option(
107+
'--default-namespace', help='Set default namespace for components.', callback=validate_name
108+
)
109+
@click.pass_context
110+
def set(ctx, profile, **kwargs):
111+
"""
112+
Set one or more configuration values in a profile.
113+
Creates the profile if it doesn't exist.
114+
"""
115+
116+
# Skip fields with None or empty lists/tuples
117+
set_fields = {
118+
k: v for k, v in ctx.params.items() if k != 'profile' and v not in (None, [], ())
119+
}
120+
if profile and not set_fields:
121+
raise FatalError('Please provide a parameter you want to change.')
122+
123+
config_manager = ConfigManager()
124+
config = config_manager.load()
125+
126+
if profile not in config.profiles or config.profiles[profile] is None:
127+
config.profiles[profile] = ProfileItem()
128+
129+
try:
130+
for field, value in set_fields.items():
131+
setattr(config.profiles[profile], field, value)
132+
133+
config_manager.dump(config)
134+
except ValidationError as e:
135+
raise ConfigError(f'Invalid input!\n{polish_validation_error(e)}')
136+
137+
print(f"Profile '{profile}' updated with provided values.")
138+
139+
@config.command()
140+
@click.option(
141+
'--profile',
142+
default='default',
143+
help='The name of the profile to change. If not provided, the default profile will be used.',
144+
)
145+
@click.option(
146+
'--registry-url',
147+
help='Remove URL of the Component Registry.',
148+
is_flag=True,
149+
default=False,
150+
)
151+
@click.option(
152+
'--storage-url',
153+
help='Remove storage URLs.',
154+
is_flag=True,
155+
default=False,
156+
)
157+
@click.option(
158+
'--local-storage-url',
159+
help='Remove local storage URLs.',
160+
is_flag=True,
161+
default=False,
162+
)
163+
@click.option('--api-token', help='Remove API token.', is_flag=True, default=False)
164+
@click.option(
165+
'--default-namespace',
166+
help='Remove default namespace',
167+
is_flag=True,
168+
default=False,
169+
)
170+
@click.option(
171+
'--all',
172+
is_flag=True,
173+
default=False,
174+
help='Remove the profile entirely from the config file.',
175+
)
176+
@click.pass_context
177+
def unset(ctx, profile, all, **kwargs):
178+
"""
179+
Unset specific configuration fields or remove the entire profile from the config file.
180+
Use `--all` to delete the entire profile, be carefull if you have unsoported/your own fileds under profile.
181+
"""
182+
183+
config_manager = ConfigManager()
184+
config_path = config_manager.config_path
185+
yaml = YAML()
186+
187+
# Filter out only True flags (fields to unset)
188+
fields_to_unset = [
189+
key for key, value in ctx.params.items() if key not in ('profile', 'all') and value
190+
]
191+
192+
if not fields_to_unset and not all:
193+
raise FatalError(
194+
'Please provide at least one field to unset or use --all to remove the profile.'
195+
)
196+
197+
# Load raw data
198+
try:
199+
raw_data = config_manager.data
200+
except ConfigError as e:
201+
raise FatalError(str(e))
202+
203+
if profile not in raw_data.get('profiles', CommentedMap()):
204+
raise ConfigError(f"Profile '{profile}' does not exist.")
205+
206+
if all:
207+
del raw_data['profiles'][profile]
208+
_write_config(config_path, raw_data, yaml)
209+
print(f'Profile "{profile}" was completely removed from the config file.')
210+
return
211+
212+
for field in fields_to_unset:
213+
del raw_data['profiles'][profile][field]
214+
215+
# Remove the profile if it becomes empty
216+
if not raw_data['profiles'][profile]:
217+
del raw_data['profiles'][profile]
218+
_write_config(config_path, raw_data, yaml)
219+
220+
print(f'Successfully removed {", ".join(fields_to_unset)} from the profile "{profile}".')
221+
222+
return config

idf_component_manager/cli/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .autocompletion import init_autocomplete
1616
from .cache import init_cache
1717
from .component import init_component
18+
from .config import init_config
1819
from .manifest import init_manifest
1920
from .project import init_project
2021
from .registry import init_registry
@@ -56,6 +57,7 @@ def version():
5657
cli.add_command(init_manifest())
5758
cli.add_command(init_project())
5859
cli.add_command(init_registry())
60+
cli.add_command(init_config())
5961

6062
return cli
6163

idf_component_manager/cli/validations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def validate_existing_dir(ctx, param, value): # noqa: ARG001
3535

3636

3737
def validate_url(ctx, param, value): # noqa: ARG001
38-
if value is not None:
38+
if value:
3939
result = urlparse(value)
4040
if not result.scheme or not result.hostname:
4141
raise click.BadParameter('Invalid URL.')

idf_component_tools/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,18 @@ def _update_data(self, config: Config) -> None:
190190
# Add new profile
191191
profiles[profile_name] = profile_values
192192

193-
# Remove subkeys with None values
193+
empty_profiles = []
194+
# Clean up subkeys with None values and collect empty profiles
194195
for profile_name, profile_values in profiles.items():
195196
if profile_values is not None:
196197
for field_name in [k for k, v in profile_values.items() if v is None]:
197198
del profile_values[field_name]
199+
if not profile_values:
200+
empty_profiles.append(profile_name)
201+
202+
# Delete empty profiles outside the loop
203+
for profile_name in empty_profiles:
204+
del profiles[profile_name]
198205

199206
self._raw_data['profiles'] = profiles
200207

0 commit comments

Comments
 (0)