Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions docs/source/cli/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ config edit

usage: firewheel config edit [-e EDITOR]

Edit the FIREWHEEL configuration with a text editor. The user must set either the VISUAL or EDITOR
environment variable or use the provided flag to override these environment variables.
Edit the FIREWHEEL configuration with a text editor. The user must set either the VISUAL or EDITOR environment variable or use the provided flag to override these environment variables.

options:

-e EDITOR, --editor EDITOR
Use the specified text editor.

Expand All @@ -67,10 +67,19 @@ positional arguments:
command: ``firewheel config get logging.level``.

options:

-a, --all Get the entire FIREWHEEL configuration.



.. _command_config_path:

config path
^^^^^^^^^^^

Prints the path to the current FIREWHEEL configuration.


.. _command_config_reset:

config reset
Expand All @@ -90,13 +99,21 @@ positional arguments:
config set
^^^^^^^^^^

usage: firewheel config set (-f FILE | -s SETTING [VALUE ...])
usage: firewheel config set (-f FILE | -j JSON | -s SETTING [VALUE ...])

Set a FIREWHEEL configuration.

options:
-f FILE, --file FILE Add config from a file.


-j JSON, --json JSON Pass in a JSON string that can set/replace a subset of the configuration.
This should include the top-level config key as well as any sub-keys.
Any keys or sub-keys not present will not be impacted.
For example, to change the value for the config key ``logging.level``, you
can use the command:
``firewheel config set -j '{"logging":{"level":"INFO"}}'``.

-s SETTING [VALUE ...], --single SETTING [VALUE ...]
Set (or create) a particular configuration value. Nested settings
can be used with a period separating them. For example, to change
Expand Down Expand Up @@ -356,3 +373,4 @@ Example:
$ firewheel version
2.6.0


11 changes: 7 additions & 4 deletions docs/source/cli/helper_docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -725,16 +725,18 @@ The repository should be an existing directory on the filesystem.
The path may be specified absolute or relative.
If the path does not exist, an error message is printed.

Some Model Components may provide an additional install script called ``INSTALL`` which can be executed to perform other setup steps (e.g. installing an extra python package or downloading an external VM resource).
INSTALL scripts can be can be any executable file type as defined by a `shebang <https://en.wikipedia.org/wiki/Shebang_(Unix)>`_ line.
Some Model Components may provide additional installation details which can be executed to perform other setup steps (e.g., installing an extra Python package or downloading an external VM resource).
This takes the form of either an ``INSTALL`` directory with a ``vars.yml`` and a ``tasks.yml`` that are Ansible tasks which can be executed.
Alternatively, it can be a single ``INSTALL`` script that can be can be any executable file type as defined by a `shebang <https://en.wikipedia.org/wiki/Shebang_(Unix)>`_ line.

.. warning::

The execution of Model Component ``INSTALL`` scripts can be a **DANGEROUS** operation. Please ensure that you **fully trust** the repository developer prior to executing these scripts.
The execution of Model Component ``INSTALL`` scripts can be a **DANGEROUS** operation.
Please ensure that you **fully trust** the repository developer prior to executing these scripts.

.. seealso::

See :ref:`mc_install` for more information on INSTALL scripts.
See :ref:`mc_install` for more information on ``INSTALL`` scripts.

When installing a Model Component, users will have a variety of choices to select:

Expand Down Expand Up @@ -1509,3 +1511,4 @@ Example

``firewheel vm resume --all``


69 changes: 69 additions & 0 deletions src/firewheel/cli/configure_firewheel.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import cmd
import json
import shlex
import pprint
import argparse
Expand Down Expand Up @@ -143,6 +144,24 @@ def do_reset(self, args: str = "") -> None:
fw_config = Config(config_path=cmd_args.config_path)
fw_config.generate_config_from_defaults()

def _argparse_check_json_type(self, json_string):
"""
Parse a JSON string into a Python dictionary.

Args:
json_string (str): A string representation of a JSON object.

Returns:
dict: The parsed JSON object as a Python dictionary.

Raises:
argparse.ArgumentTypeError: If the input string is not a valid JSON.
"""
try:
return json.loads(json_string)
except json.decoder.JSONDecodeError as exc:
raise argparse.ArgumentTypeError(f"Invalid JSON string: {json_string}\n\n") from exc

def define_set_parser(self) -> argparse.ArgumentParser:
"""Create an :py:class:`argparse.ArgumentParser` for :ref:`command_config_set`.

Expand All @@ -163,6 +182,19 @@ def define_set_parser(self) -> argparse.ArgumentParser:
type=argparse.FileType("r"),
help="Add config from a file.",
)
group.add_argument(
"-j",
"--json",
type=self._argparse_check_json_type,
help=(
"Pass in a JSON string that can set/replace a subset of the configuration.\n"
"This should include the top-level config key as well as any sub-keys.\n"
"Any keys or sub-keys not present will not be impacted.\n"
"For example, to change the value for the config key ``logging.level``, you\n"
"can use the command:\n"
'``firewheel config set -j \'{"logging":{"level":"INFO"}}\'``.'
),
)
group.add_argument(
"-s",
"--single",
Expand All @@ -178,6 +210,32 @@ def define_set_parser(self) -> argparse.ArgumentParser:
)
return parser

def _update_nested_dict(self, original: dict, updates: dict) -> dict:
"""
This function recursively updates the original dictionary with values
from the updates dictionary. If a key in the ``updates`` dictionary is a
nested dictionary, it will update the corresponding key in the original
dictionary without removing any existing keys that are not specified in updates.

Args:
original (dict): The original dictionary to be updated.
updates (dict): A dictionary containing the updates to apply.

Returns:
dict: The updated original dictionary.
"""
for key, value in updates.items():
if isinstance(value, dict):
if key in original and isinstance(original[key], dict):
# If the key exists and both values are dictionaries, recurse
self._update_nested_dict(original[key], value)
else:
original[key] = value
else:
# Update or add the key in the original dictionary
original[key] = value
return original

def do_set(self, args: str) -> None: # noqa: DOC502
"""Enable a user to set a particular FIREWHEEL configuration option.

Expand Down Expand Up @@ -211,6 +269,15 @@ def do_set(self, args: str) -> None: # noqa: DOC502
fw_config.resolve_set(key, value)
fw_config.write()

if cmd_args.json is not None:
value = cmd_args.json
self.log.debug("Setting the FIREWHEEL config value for `%s`", value)
fw_config = Config(writable=True)
curr_config = fw_config.get_config()
future_config = self._update_nested_dict(curr_config, value)
fw_config.set_config(future_config)
fw_config.write()

def define_get_parser(self) -> argparse.ArgumentParser:
"""Create an :py:class:`argparse.ArgumentParser` for :ref:`command_config_get`.

Expand Down Expand Up @@ -327,6 +394,8 @@ def get_docs(self) -> str:
for num, line in enumerate(help_list):
if line.startswith(" -") and help_list[num + 1].startswith(" -"):
clean_text += line + "\n"
elif line.startswith(" -"):
clean_text += "\n" + line
else:
clean_text += line
clean_text += "\n"
Expand Down
4 changes: 2 additions & 2 deletions src/firewheel/cli/helpers/repository/install
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ The repository should be an existing directory on the filesystem.
The path may be specified absolute or relative.
If the path does not exist, an error message is printed.

Some Model Components may provide an additional installation details which can be executed to perform other setup steps (e.g. installing an extra python package or downloading an external VM resource).
This takes the form of either a ``INSTALL`` directory with a ``vars.yml`` and a ``tasks.yml`` that are Ansible tasks which can be executed.
Some Model Components may provide additional installation details which can be executed to perform other setup steps (e.g., installing an extra Python package or downloading an external VM resource).
This takes the form of either an ``INSTALL`` directory with a ``vars.yml`` and a ``tasks.yml`` that are Ansible tasks which can be executed.
Alternatively, it can be a single ``INSTALL`` script that can be can be any executable file type as defined by a `shebang <https://en.wikipedia.org/wiki/Shebang_(Unix)>`_ line.

.. warning::
Expand Down
124 changes: 123 additions & 1 deletion src/firewheel/tests/unit/cli/test_cli_configure.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import io
import os
import copy
import argparse
import tempfile
import unittest
import unittest.mock
Expand Down Expand Up @@ -190,7 +191,7 @@ def test_do_edit_param_invalid(self, mock_stdout):
msg = "Error: Failed to open FIREWHEEL configuration with"
self.assertIn(msg, mock_stdout.getvalue())

@unittest.mock.patch.dict(os.environ, {'EDITOR': '', 'VISUAL': ''})
@unittest.mock.patch.dict(os.environ, {"EDITOR": "", "VISUAL": ""})
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_do_edit_none(self, mock_stdout):
args = ""
Expand Down Expand Up @@ -245,3 +246,124 @@ def test_help_help(self, mock_stdout):

msg = "Prints help for the different sub-commands"
self.assertIn(msg, mock_stdout.getvalue())

def test_valid_json(self):
json_string = '{"key": "value", "number": 123}'
expected = {"key": "value", "number": 123}
result = self.cli._argparse_check_json_type(json_string)
self.assertEqual(result, expected)

def test_empty_json(self):
json_string = "{}"
expected = {}
result = self.cli._argparse_check_json_type(json_string)
self.assertEqual(result, expected)

def test_invalid_json(self):
invalid_json_cases = [
'{"key": "value", "number": 123', # Missing brace
'{"key": "value", "number": "123" "extra": "value"}', # Missing comma
'{"key": "value", "number": [1, 2, 3,}', # Trailing comma
'{key: "value"}', # Missing quotes
'["value1", "value2",]', # Trailing comma
"{'key': 'value'}", # Single quotes
]

for json_string in invalid_json_cases:
with self.subTest(json_string=json_string):
with self.assertRaises(argparse.ArgumentTypeError) as context:
self.cli._argparse_check_json_type(json_string)
self.assertIn("Invalid JSON string", str(context.exception))

def test_json_with_nested_structure(self):
json_string = '{"outer": {"inner": "value"}}'
expected = {"outer": {"inner": "value"}}
result = self.cli._argparse_check_json_type(json_string)
self.assertEqual(result, expected)

def test_json_with_array(self):
json_string = '{"array": [1, 2, 3]}'
expected = {"array": [1, 2, 3]}
result = self.cli._argparse_check_json_type(json_string)
self.assertEqual(result, expected)

@unittest.mock.patch("firewheel.cli.configure_firewheel.Config")
def test_do_set_with_json(self, mock_config_cls):
mock_config_cls().get_config.return_value = {"key": "value"}
mock_config_cls().set_config = unittest.mock.Mock()
mock_config_cls().write = unittest.mock.Mock()

json_input = '{"key": "new_value", "nested": {"inner_key": "inner_value"}}'
args = f"--json '{json_input}'"

self.cli.do_set(args)

expected_config = {"key": "new_value", "nested": {"inner_key": "inner_value"}}
mock_config_cls().set_config.assert_called_once_with(expected_config)
mock_config_cls().write.assert_called_once()

@unittest.mock.patch("firewheel.cli.configure_firewheel.Config")
def test_do_set_with_invalid_json(self, mock_config_cls):
json_input = '{"key": "value", "nested": {"inner_key": "inner_value"'
args = f'--json "{json_input}"'

with self.assertRaises(SystemExit):
self.cli.do_set(args)

@unittest.mock.patch("firewheel.cli.configure_firewheel.Config")
def test_do_set_with_empty_json(self, mock_config_cls):
mock_config_cls().get_config.return_value = {"key": "value"}
mock_config_cls().set_config = unittest.mock.Mock()
mock_config_cls().write = unittest.mock.Mock()

json_input = "{}"
args = f'--json "{json_input}"' # Create a string that simulates command-line input

self.cli.do_set(args) # Pass the string instead of a Mock

expected_config = {"key": "value"} # No changes should be made
mock_config_cls().set_config.assert_called_once_with(expected_config)
mock_config_cls().write.assert_called_once()

def test_update_existing_key(self):
original = {"key": "value", "nested": {"inner_key": "inner_value"}}
updates = {"key": "new_value"}
expected = {"key": "new_value", "nested": {"inner_key": "inner_value"}}
result = self.cli._update_nested_dict(original, updates)
self.assertEqual(result, expected)

def test_update_nested_key(self):
original = {"nested": {"inner_key": "inner_value"}}
updates = {"nested": {"inner_key": "new_inner_value"}}
expected = {"nested": {"inner_key": "new_inner_value"}}
result = self.cli._update_nested_dict(original, updates)
self.assertEqual(result, expected)

def test_add_new_key(self):
original = {"key": "value"}
updates = {"new_key": "new_value"}
expected = {"key": "value", "new_key": "new_value"}
result = self.cli._update_nested_dict(original, updates)
self.assertEqual(result, expected)

def test_update_with_non_nested_key(self):
original = {"key": "value"}
updates = {"key": {"sub_key": "sub_value"}}
expected = {"key": {"sub_key": "sub_value"}}
result = self.cli._update_nested_dict(original, updates)
self.assertEqual(result, expected)

def test_update_with_nested_and_non_nested_keys(self):
original = {"key": "value", "nested": {"inner_key": "inner_value"}}
updates = {
"key": "new_value",
"nested": {"inner_key": "new_inner_value"},
"new_key": "new_value",
}
expected = {
"key": "new_value",
"nested": {"inner_key": "new_inner_value"},
"new_key": "new_value",
}
result = self.cli._update_nested_dict(original, updates)
self.assertEqual(result, expected)
Loading