Skip to content

Commit 7991606

Browse files
Merge pull request #657 from linode/dev
Release v5.53.0
2 parents 700b935 + 268d689 commit 7991606

File tree

10 files changed

+177
-31
lines changed

10 files changed

+177
-31
lines changed

.github/workflows/e2e-suite.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ jobs:
196196
"fields": [
197197
{
198198
"type": "mrkdwn",
199-
"text": "*Build Result:*\n${{ steps.integration_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}"
199+
"text": "*Build Result:*\n${{ needs.integration_tests.result == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}"
200200
},
201201
{
202202
"type": "mrkdwn",

linodecli/configuration/config.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,10 @@ def update(
290290
):
291291
print(f"User {username} is not configured.")
292292
sys.exit(ExitCodes.USERNAME_ERROR)
293-
if not self.config.has_section(username) or allowed_defaults is None:
293+
if (
294+
not self.config.has_section(username)
295+
and self.config.default_section is None
296+
) or allowed_defaults is None:
294297
return namespace
295298

296299
warn_dict = {}
@@ -335,12 +338,6 @@ def write_config(self):
335338
to save values they've set, and is used internally to update the config
336339
on disk when a new user if configured.
337340
"""
338-
339-
# Create the config path isf necessary
340-
config_path = f"{os.path.expanduser('~')}/.config"
341-
if not os.path.exists(config_path):
342-
os.makedirs(config_path)
343-
344341
with open(_get_config_path(), "w", encoding="utf-8") as f:
345342
self.config.write(f)
346343

linodecli/configuration/helpers.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"XDG_CONFIG_HOME", f"{os.path.expanduser('~')}/.config"
1616
)
1717

18+
ENV_CONFIG_FILE_PATH = "LINODE_CLI_CONFIG"
19+
1820
# this is a list of browser that _should_ work for web-based auth. This is mostly
1921
# intended to exclude lynx and other terminal browsers which could be opened, but
2022
# won't work.
@@ -38,11 +40,23 @@ def _get_config_path() -> str:
3840
:returns: The path to the local config file.
3941
:rtype: str
4042
"""
43+
custom_path = os.getenv(ENV_CONFIG_FILE_PATH, None)
44+
45+
if custom_path is not None:
46+
custom_path = os.path.expanduser(custom_path)
47+
if not os.path.exists(custom_path):
48+
os.makedirs(os.path.dirname(custom_path), exist_ok=True)
49+
return custom_path
50+
4151
path = f"{LEGACY_CONFIG_DIR}/{LEGACY_CONFIG_NAME}"
4252
if os.path.exists(path):
4353
return path
4454

45-
return f"{CONFIG_DIR}/{CONFIG_NAME}"
55+
path = f"{CONFIG_DIR}/{CONFIG_NAME}"
56+
if not os.path.exists(path):
57+
os.makedirs(CONFIG_DIR, exist_ok=True)
58+
59+
return path
4660

4761

4862
def _get_config(load: bool = True):

linodecli/help_pages.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"(e.g. 'v4beta')",
3131
"LINODE_CLI_API_SCHEME": "Overrides the target scheme used for API requests. "
3232
"(e.g. 'https')",
33+
"LINODE_CLI_CONFIG": "Overrides the default configuration file path. "
34+
"(e.g '~/.linode/my-cli-config')",
3335
}
3436

3537
HELP_TOPICS = {

linodecli/overrides.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
large changes to the OpenAPI spec.
55
"""
66

7-
from typing import Dict
7+
from typing import Dict, List
88

99
from rich import box
1010
from rich import print as rprint
@@ -57,6 +57,15 @@ def handle_types_region_prices_list(
5757
return linode_types_with_region_prices(operation, output_handler, json_data)
5858

5959

60+
@output_override("images", "replicate", OutputMode.table)
61+
def handle_image_replicate(operation, output_handler, json_data) -> bool:
62+
# pylint: disable=unused-argument
63+
"""
64+
Override the output of 'linode-cli images replicate'.
65+
"""
66+
return image_replicate_output(json_data)
67+
68+
6069
def linode_types_with_region_prices(
6170
operation, output_handler, json_data
6271
) -> bool:
@@ -137,3 +146,45 @@ def format_region_prices(data: Dict[str, any]) -> any:
137146
sub_table.add_row(*region_price_row)
138147

139148
return sub_table
149+
150+
151+
def build_replicas_output(replicas: List) -> Table:
152+
"""
153+
Format nested replicas list to a sub-table.
154+
"""
155+
replicas_output = Table(show_header=False, box=None)
156+
replicas_headers = replicas[0].keys()
157+
for replica in replicas:
158+
row = []
159+
for h in replicas_headers:
160+
row.append(Align(str(replica[h]), align="left"))
161+
replicas_output.add_row(*row)
162+
163+
return replicas_output
164+
165+
166+
def image_replicate_output(json_data) -> bool:
167+
"""
168+
Parse and format the image replicate output table.
169+
"""
170+
output = Table(
171+
header_style="bold",
172+
show_lines=True,
173+
)
174+
175+
row = []
176+
for header in json_data.keys():
177+
if header == "regions" and len(json_data[header]) > 0:
178+
# leverage `replicas` in output for readability
179+
output.add_column("replicas", justify="center")
180+
row.append(build_replicas_output(json_data[header]))
181+
elif json_data[header] is not None:
182+
output.add_column(header, justify="center")
183+
row.append(Align(str(json_data[header]), align="left"))
184+
185+
output.add_row(*row)
186+
187+
console = Console()
188+
console.print(output)
189+
190+
return False

linodecli/plugins/get-kubeconfig.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def call(args, context):
9090
else cluster_config
9191
)
9292
if parsed.dry_run:
93-
print(cluster_config)
93+
print(yaml.dump(cluster_config))
9494
else:
9595
_dump_config(kubeconfig_path, cluster_config)
9696

@@ -146,27 +146,35 @@ def _dump_config(filepath, data):
146146
yaml.dump(data, file_descriptor)
147147

148148

149-
# Merges the lists in the provided dicts. If non-list properties of the two
150-
# dicts differ, uses the value from the first dict.
151149
def _merge_dict(dict_1, dict_2):
150+
"""
151+
Merges two dicts:
152+
* Lists that are present in both dicts are merged together by their "name" key
153+
(preferring duplicate values in the first dict)
154+
* `None` or missing keys in the first dict are set to the second dict's value
155+
* Other values are preferred from the first dict
156+
"""
152157
# Return a new dict to prevent any accidental mutations
153158
result = {}
154159

155-
for key in dict_1:
156-
if not isinstance(dict_1[key], list):
157-
result[key] = dict_1[key]
158-
continue
159-
160-
merge_map = {sub["name"]: sub for sub in dict_1[key]}
161-
162-
for sub in dict_2[key]:
163-
# If the name is already in the merge map, skip
164-
if sub["name"] in merge_map:
165-
continue
166-
167-
merge_map[sub["name"]] = sub
168-
169-
# Convert back to a list
170-
result[key] = list(merge_map.values())
160+
for key, dict_1_value in dict_1.items():
161+
if dict_1_value is None and (dict_2_value := dict_2.get(key)):
162+
# Replace null value in previous config
163+
result[key] = dict_2_value
164+
elif isinstance(dict_1_value, list) and (
165+
dict_2_value := dict_2.get(key)
166+
):
167+
merge_map = {sub["name"]: sub for sub in dict_1_value}
168+
for list_2_item in dict_2_value:
169+
if (list_2_name := list_2_item["name"]) not in merge_map:
170+
merge_map[list_2_name] = list_2_item
171+
# Convert back to a list
172+
result[key] = list(merge_map.values())
173+
else:
174+
result[key] = dict_1_value
175+
176+
# Process keys missing in dict_1
177+
for key in set(dict_2.keys()).difference(dict_1.keys()):
178+
result[key] = dict_2[key]
171179

172180
return result

tests/unit/test_configuration.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,3 +609,26 @@ def test_bool_input_default(self, monkeypatch):
609609
output = stdout_buf.getvalue()
610610
assert "foo [y/N]: " in output
611611
assert result
612+
613+
def test_custom_config_path(self, monkeypatch, tmp_path):
614+
"""
615+
Test use a custom configuration path
616+
"""
617+
conf = self._build_test_config()
618+
custom_path = tmp_path / "test-cli-config"
619+
620+
with (
621+
patch.dict(
622+
os.environ,
623+
{"LINODE_CLI_CONFIG": custom_path.absolute().as_posix()},
624+
),
625+
):
626+
conf.write_config()
627+
628+
configs = custom_path.read_text().splitlines()
629+
expected_configs = self.mock_config_file.splitlines()
630+
631+
assert len(configs) == len(expected_configs) + 1
632+
633+
for i, _ in enumerate(expected_configs):
634+
assert expected_configs[i] == configs[i]

tests/unit/test_plugin_kubeconfig.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
- item:
4343
property-1: a
4444
property-2: b
45-
property-3: c
4645
name: item-1
4746
- item:
4847
property-1: d
@@ -51,6 +50,12 @@
5150
dictionary: {"foo": "bar"}
5251
"""
5352

53+
TEST_YAML_EMPTY_CONFIG = """
54+
name: testing-kubeconfig
55+
things: null
56+
items: null
57+
"""
58+
5459

5560
# Test the output of --help
5661
def test_print_help():
@@ -199,6 +204,37 @@ def test_merge(mock_cli, fake_kubeconfig_file):
199204
assert result["dictionary"] == yaml_a["dictionary"]
200205

201206

207+
def test_merge_to_empty_config(mock_cli, fake_kubeconfig_file_without_entries):
208+
stdout_buf = io.StringIO()
209+
mock_cli.call_operation = mock_call_operation
210+
211+
file_path = fake_kubeconfig_file_without_entries
212+
213+
try:
214+
with contextlib.redirect_stdout(stdout_buf):
215+
plugin.call(
216+
[
217+
"--label",
218+
"nonempty_data",
219+
"--kubeconfig",
220+
file_path,
221+
"--dry-run",
222+
],
223+
PluginContext("REALTOKEN", mock_cli),
224+
)
225+
except SystemExit as err:
226+
assert err.code == 0
227+
228+
result = yaml.safe_load(stdout_buf.getvalue())
229+
yaml_a = yaml.safe_load(TEST_YAML_EMPTY_CONFIG)
230+
yaml_b = yaml.safe_load(TEST_YAML_CONTENT_B)
231+
232+
assert result["name"] == yaml_a["name"]
233+
assert result["things"] is None
234+
assert result["items"] == yaml_b["items"]
235+
assert result["dictionary"] == yaml_b["dictionary"]
236+
237+
202238
@pytest.fixture(scope="session", autouse=True)
203239
def fake_kubeconfig_file():
204240
with tempfile.NamedTemporaryFile(delete=False) as fp:
@@ -220,6 +256,17 @@ def fake_empty_file():
220256
os.remove(file_path)
221257

222258

259+
@pytest.fixture(scope="session", autouse=True)
260+
def fake_kubeconfig_file_without_entries():
261+
with tempfile.NamedTemporaryFile("wt", delete=False) as fp:
262+
fp.write(TEST_YAML_EMPTY_CONFIG)
263+
file_path = fp.name
264+
265+
yield file_path
266+
267+
os.remove(file_path)
268+
269+
223270
def mock_call_operation(command, action, **kwargs):
224271
if (
225272
command == "lke"

wiki/Configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ environment variable.
4141
If you wish to hide the API Version warning you can use the `LINODE_CLI_SUPPRESS_VERSION_WARNING`
4242
environment variable.
4343

44+
You may also specify a custom configuration path using the `LINODE_CLI_CONFIG` environment variable
45+
to replace the default path `~/.config/linode-cli`.
46+
4447
## Configurable API URL
4548

4649
In some cases you may want to run linode-cli against a non-default Linode API URL.

wiki/development/Development - Overview.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ configure the following:
5151
- Overrides for the target API URL (hostname, version, scheme, etc.)
5252

5353
This command serves as an interactive prompt and outputs a configuration file to `~/.config/linode-cli`.
54-
This file is in a simple INI format and can be easily modified manually by users.
54+
This file is in a simple INI format and can be easily modified manually by users.
55+
You may also specify a custom configuration file path using the `LINODE_CLI_CONFIG` environment variable.
5556

5657
Additionally, multiple users can be created for the CLI which can be designated when running commands using the `--as-user` argument
5758
or using the `default-user` config variable.

0 commit comments

Comments
 (0)