From 0e7b97e1c09339fe450a9002f66d19c2272cbd4d Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 4 Sep 2024 18:54:03 +0800 Subject: [PATCH 01/24] Add new Item `CMDSpecsPartialCommandGroup` to support Command Tree dynamic loading --- src/aaz_dev/cli/api/az.py | 4 +- src/aaz_dev/cli/api/portal.py | 2 +- .../controller/az_atomic_profile_builder.py | 4 +- .../cli/controller/portal_cli_generator.py | 2 +- src/aaz_dev/command/api/specs.py | 6 +- .../command/controller/command_tree.py | 548 ++++++++++++++++++ .../command/controller/specs_manager.py | 293 +--------- .../command/controller/workspace_manager.py | 6 +- .../tests/spec_tests/spec_portal_gen_test.py | 2 +- .../tests/spec_tests/test_command_tree.py | 306 ++++++++++ 10 files changed, 889 insertions(+), 284 deletions(-) create mode 100644 src/aaz_dev/command/controller/command_tree.py create mode 100644 src/aaz_dev/command/tests/spec_tests/test_command_tree.py diff --git a/src/aaz_dev/cli/api/az.py b/src/aaz_dev/cli/api/az.py index 0bed53ee..b0f254e7 100644 --- a/src/aaz_dev/cli/api/az.py +++ b/src/aaz_dev/cli/api/az.py @@ -151,7 +151,7 @@ def portal_generate_main_module(module_name): return aaz_spec_manager = AAZSpecsManager() - root = aaz_spec_manager.find_command_group() + root = aaz_spec_manager.tree.find_command_group() if not root: raise exceptions.ResourceNotFind("Command group not exist") portal_cli_generator = PortalCliGenerator() @@ -172,7 +172,7 @@ def portal_generate_extension_module(module_name): return aaz_spec_manager = AAZSpecsManager() - root = aaz_spec_manager.find_command_group() + root = aaz_spec_manager.tree.find_command_group() if not root: raise exceptions.ResourceNotFind("Command group not exist") portal_cli_generator = PortalCliGenerator() diff --git a/src/aaz_dev/cli/api/portal.py b/src/aaz_dev/cli/api/portal.py index 14d094c8..441497b7 100644 --- a/src/aaz_dev/cli/api/portal.py +++ b/src/aaz_dev/cli/api/portal.py @@ -48,7 +48,7 @@ def generate_module_command_portal(module): az_main_manager = AzMainManager() az_ext_manager = AzExtensionManager() aaz_spec_manager = AAZSpecsManager() - root = aaz_spec_manager.find_command_group() + root = aaz_spec_manager.tree.find_command_group() if not root: return "Command group spec root not exist" portal_cli_generator = PortalCliGenerator() diff --git a/src/aaz_dev/cli/controller/az_atomic_profile_builder.py b/src/aaz_dev/cli/controller/az_atomic_profile_builder.py index 1264b1de..6e6f32be 100644 --- a/src/aaz_dev/cli/controller/az_atomic_profile_builder.py +++ b/src/aaz_dev/cli/controller/az_atomic_profile_builder.py @@ -105,7 +105,7 @@ def _build_command(self, view_command, load_cfg): return command, client def _build_command_group_from_aaz(self, *names): - aaz_cg = self._aaz_spec_manager.find_command_group(*names) + aaz_cg = self._aaz_spec_manager.tree.find_command_group(*names) if not aaz_cg: raise ResourceNotFind("Command group '{}' not exist in AAZ".format(' '.join(names))) command_group = CLIAtomicCommandGroup() @@ -120,7 +120,7 @@ def _build_command_group_from_aaz(self, *names): return command_group def _build_command_from_aaz(self, *names, version_name, load_cfg=True): - aaz_cmd = self._aaz_spec_manager.find_command(*names) + aaz_cmd = self._aaz_spec_manager.tree.find_command(*names) if not aaz_cmd: raise ResourceNotFind("Command '{}' not exist in AAZ".format(' '.join(names))) version = None diff --git a/src/aaz_dev/cli/controller/portal_cli_generator.py b/src/aaz_dev/cli/controller/portal_cli_generator.py index 71e0e778..4f0f049f 100644 --- a/src/aaz_dev/cli/controller/portal_cli_generator.py +++ b/src/aaz_dev/cli/controller/portal_cli_generator.py @@ -361,7 +361,7 @@ def generate_cmds_portal_info(self, aaz_spec_manager, registered_cmds): node_names = cmd_name_version[:-2] leaf_name = cmd_name_version[-2] registered_version = cmd_name_version[-1] - leaf = aaz_spec_manager.find_command(*node_names, leaf_name) + leaf = aaz_spec_manager.tree.find_command(*node_names, leaf_name) if not leaf or not leaf.versions: logging.warning("Command group: " + " ".join(node_names) + " not exist") continue diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 4a170509..938135ce 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -15,7 +15,7 @@ def command_tree_node(node_names): node_names = node_names[1:] manager = AAZSpecsManager() - node = manager.find_command_group(*node_names) + node = manager.tree.find_command_group(*node_names) if not node: raise exceptions.ResourceNotFind("Command group not exist") @@ -30,7 +30,7 @@ def command_tree_leaf(node_names, leaf_name): node_names = node_names[1:] manager = AAZSpecsManager() - leaf = manager.find_command(*node_names, leaf_name) + leaf = manager.tree.find_command(*node_names, leaf_name) if not leaf: raise exceptions.ResourceNotFind("Command not exist") @@ -45,7 +45,7 @@ def aaz_command_in_version(node_names, leaf_name, version_name): node_names = node_names[1:] manager = AAZSpecsManager() - leaf = manager.find_command(*node_names, leaf_name) + leaf = manager.tree.find_command(*node_names, leaf_name) if not leaf: raise exceptions.ResourceNotFind("Command not exist") diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py new file mode 100644 index 00000000..bc6797ff --- /dev/null +++ b/src/aaz_dev/command/controller/command_tree.py @@ -0,0 +1,548 @@ +import json +import logging +import os +import re + +from command.model.configuration import CMDHelp, CMDCommandExample +from command.model.specs import CMDSpecsCommandGroup, CMDSpecsCommand, CMDSpecsResource, CMDSpecsCommandVersion, \ + CMDSpecsCommandTree +from utils import exceptions + +logger = logging.getLogger(__name__) + + +class CMDSpecsPartialCommandGroup: + def __init__(self, names, short_help, uri, aaz_path): + self.names = names + self.short_help = short_help + self.uri = uri + self.aaz_path = aaz_path + + @classmethod + def parse_command_group_info(cls, info, cg_names, aaz_path): + prev_line = None + title = None + short_help = None + long_help = [] + cur_sub_block = None + block_items = None + command_groups = [] + commands = [] + in_code_block = False + + for line in info.splitlines(keepends=False): + line = line.strip() + + if line.startswith('"""'): + in_code_block = not in_code_block + continue + elif in_code_block: + continue + + if line.startswith("# ") and not title: + title = line[2:] + elif line.startswith("## "): + cur_sub_block = line[3:] + if cur_sub_block in ["Groups", "Subgroups"]: + block_items = command_groups + elif cur_sub_block in ["Commands"]: + block_items = commands + else: + block_items = None + elif line and not cur_sub_block and not short_help: + short_help = line + elif line and not cur_sub_block and not long_help and prev_line: + short_help = short_help + '\n' + line + elif line and not cur_sub_block: + long_help.append(line) + elif line.startswith('- ['): + name = line[3:].split(']')[0] + uri = line.split('(')[-1].split(')')[0] + if cur_sub_block in ["Groups", "Subgroups"]: + item = CMDSpecsPartialCommandGroup([*cg_names, name], None, uri, aaz_path) + elif cur_sub_block in ["Commands"]: + item = CMDSpecsPartialCommand([*cg_names, name], None, uri, aaz_path) + else: + continue + if block_items is not None: + block_items.append((name, item)) + elif line.startswith(': '): + if block_items: + block_items[-1][1].short_help = line[2:] + elif line and prev_line: + if block_items: + block_items[-1][1].short_help += '\n' + line + prev_line = line + cg = CMDSpecsCommandGroup() + if not cg_names: + cg.names = ["aaz"] + else: + cg.names = list(cg_names) + if not short_help: + cg.help = None + else: + cg.help = CMDHelp() + cg.help.short = short_help + cg.help.lines = long_help if long_help else None + cg.command_groups = CMDSpecsCommandGroupDict(command_groups) + cg.commands = CMDSpecsCommandDict(commands) + return cg + + def load(self): + with open(self.aaz_path + self.uri, "r", encoding="utf-8") as f: + content = f.read() + if self.names and self.names[0] == "aaz": + names = self.names[1:] + else: + names = self.names + cg = self.parse_command_group_info(content, names, self.aaz_path) + return cg + + +class CMDSpecsCommandDict(dict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __getitem__(self, __key): + command = super().__getitem__(__key) + if isinstance(command, CMDSpecsPartialCommand): + command = command.load() + if command: + super().__setitem__(__key, command) + return command + + def items(self): + for key in self.keys(): + yield key, self[key] + + def values(self): + for key in self.keys(): + yield self[key] + + def get_raw_item(self, key): + return super().get(key) + + +class CMDSpecsCommandGroupDict(dict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __getitem__(self, __key): + cg = super().__getitem__(__key) + if isinstance(cg, CMDSpecsPartialCommandGroup): + cg = cg.load() + if cg: + super().__setitem__(__key, cg) + return cg + + def items(self): + for key in self.keys(): + yield key, self[key] + + def values(self): + for key in self.keys(): + yield self[key] + + def get_raw_item(self, key): + return super().get(key) + + +class CMDSpecsPartialCommand: + _COMMAND_INFO_RE = r"# \[Command\] _(?P[A-Za-z0-9- ]+)_\n" \ + r"\n(?P(.+\n)+)\n?" \ + r"(?P\n(^[^#].*\n)+)?" \ + r"\n## Versions\n" \ + r"(?P(\n### \[(?P[a-zA-Z0-9-]+)\]\(.*\) \*\*.*\*\*\n" \ + r"\n(\n)+" \ + r"(?P\n#### examples\n" \ + r"(\n- (?P.*)\n" \ + r" ```bash\n" \ + r"( (?P.*)\n)+" \ + r" ```\n)+)?)+)" + COMMAND_INFO_RE = re.compile(_COMMAND_INFO_RE, re.MULTILINE) + _RESOURCE_INFO_RE = r"\n" + RESOURCE_INFO_RE = re.compile(_RESOURCE_INFO_RE, re.MULTILINE) + _VERSION_INFO_RE = r"### \[(?P[a-zA-Z0-9-]+)\]\(.*\) \*\*(?P.*)\*\*\n" \ + r"\n(?P(\n)+)" \ + r"(?P\n#### examples\n" \ + r"(\n- (?P.*)\n" \ + r" ```bash\n" \ + r"( (?P.*)\n)+" \ + r" ```\n)+)?" + VERSION_INFO_RE = re.compile(_VERSION_INFO_RE, re.MULTILINE) + _EXAMPLE_INFO_RE = r"- (?P.*)\n" \ + r" ```bash\n" \ + r"(?P( (.*)\n)+)" \ + r" ```\n" + EXAMPLE_INFO_RE = re.compile(_EXAMPLE_INFO_RE, re.MULTILINE) + _EXAMPLE_LINE_RE = r" (?P.*)\n" + EXAMPLE_LINE_RE = re.compile(_EXAMPLE_LINE_RE, re.MULTILINE) + + def __init__(self, names, short_help, uri, aaz_path): + self.names = names + self.short_help = short_help + self.uri = uri + self.aaz_path = aaz_path + + def load(self): + with open(self.aaz_path + self.uri, "r", encoding="utf-8") as f: + content = f.read() + command = self.parse_command_info(content, self.names) + return command + + @classmethod + def parse_command_info(cls, info, cmd_names): + command_match = re.match(cls.COMMAND_INFO_RE, info) + if not command_match: + logger.warning(f"Invalid command info markdown: \n{info}") + return None + if command_match.group("lines_help"): + lines_help = command_match.group("lines_help").strip().split("\n") + else: + lines_help = None + help = CMDHelp() + help.short = command_match.group("short_help").strip() + help.lines = lines_help + versions = [] + for version_match in re.finditer(cls.VERSION_INFO_RE, info): + resources = [] + for resource_match in re.finditer(cls.RESOURCE_INFO_RE, version_match.group("resources")): + resource = { + "plane": resource_match.group("plane"), + "id": resource_match.group("id"), + "version": resource_match.group("version"), + } + if resource_match.group("subresource"): + resource["subresource"] = resource_match.group("subresource") + resources.append(CMDSpecsResource(raw_data=resource)) + examples = [] + for example_match in re.finditer(cls.EXAMPLE_INFO_RE, version_match.group("examples") or ''): + example_cmd = [] + for line_match in re.finditer(cls.EXAMPLE_LINE_RE, example_match.group("example_cmds") or ''): + example_cmd.append(line_match.group("example_cmd")) + example = { + "name": example_match.group("example_desc"), + "commands": example_cmd + } + examples.append(CMDCommandExample(raw_data=example)) + version = { + "name": version_match.group("version_name"), + "resources": resources, + "examples": examples + } + if version_match.group("stage"): + version["stage"] = version_match.group("stage") + versions.append(CMDSpecsCommandVersion(raw_data=version)) + command = CMDSpecsCommand() + command.names = cmd_names + command.help = help + command.versions = sorted(versions, key=lambda v: v.name) + return command + + +class CMDSpecsPartialCommandTree: + def __init__(self, aaz_path, root=None): + self.aaz_path = aaz_path + self._root = root or CMDSpecsPartialCommandGroup(names=["aaz"], short_help='', uri="/Commands/readme.md", + aaz_path=aaz_path).load() + self._modified_command_groups = set() + self._modified_commands = set() + + @property + def root(self): + if isinstance(self._root, CMDSpecsPartialCommandGroup): + self._root = self._root.load() + return self._root + + def find_command_group(self, *cg_names): + """ + Find command group node by names + + :param cg_names: command group names + :return: command group node + :rtype: CMDSpecsCommandGroup | None + """ + node = self.root + idx = 0 + while idx < len(cg_names): + name = cg_names[idx] + if not node.command_groups or name not in node.command_groups: + return None + node = node.command_groups[name] + idx += 1 + return node + + def find_command(self, *cmd_names): + if len(cmd_names) < 2: + raise exceptions.InvalidAPIUsage(f"Invalid command name: '{' '.join(cmd_names)}'") + + node = self.find_command_group(*cmd_names[:-1]) + if not node: + return None + name = cmd_names[-1] + if not node.commands or name not in node.commands: + return None + leaf = node.commands[name] + return leaf + + def iter_command_groups(self, *root_cg_names): + root = self.find_command_group(*root_cg_names) + if root: + nodes = [root] + i = 0 + while i < len(nodes): + yield nodes[i] + for node in (nodes[i].command_groups or {}).values(): + nodes.append(node) + i += 1 + + def iter_commands(self, *root_node_names): + for node in self.iter_command_groups(*root_node_names): + for leaf in (node.commands or {}).values(): + yield leaf + + def create_command_group(self, *cg_names): + if len(cg_names) < 1: + raise exceptions.InvalidAPIUsage(f"Invalid Command Group name: '{' '.join(cg_names)}'") + node = self.root + idx = 0 + while idx < len(cg_names): + name = cg_names[idx] + if node.commands and name in node.commands: + raise exceptions.InvalidAPIUsage(f"Invalid Command Group name: conflict with Command name: " + f"'{' '.join(cg_names[:idx+1])}'") + if not node.command_groups or name not in node.command_groups: + if not node.command_groups: + node.command_groups = {} + names = [*cg_names[:idx+1]] + node.command_groups[name] = CMDSpecsCommandGroup({ + "names": names + }) + self._modified_command_groups.add(cg_names[:idx+1]) + node = node.command_groups[name] + idx += 1 + return node + + def update_command_group_by_ws(self, ws_node): + command_group = self.create_command_group(*ws_node.names) + if ws_node.help: + if not command_group.help: + command_group.help = CMDHelp() + if ws_node.help.short: + command_group.help.short = ws_node.help.short + if ws_node.help.lines: + command_group.help.lines = [*ws_node.help.lines] + self._modified_command_groups.add(tuple([*ws_node.names])) + return command_group + + def delete_command_group(self, *cg_names): + for _ in self.iter_commands(*cg_names): + raise exceptions.ResourceConflict("Cannot delete command group with commands") + parent = self.find_command_group(*cg_names[:-1]) + name = cg_names[-1] + if not parent or not parent.command_groups or name not in parent.command_groups: + return False + del parent.command_groups[name] + if not parent.command_groups: + parent.command_groups = None + + self._modified_command_groups.add(cg_names) + + if not parent.command_groups and not parent.commands: + # delete empty parent command group + self.delete_command_group(*cg_names[:-1]) + return True + + def create_command(self, *cmd_names): + if len(cmd_names) < 2: + raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") + node = self.create_command_group(*cmd_names[:-1]) + name = cmd_names[-1] + if node.command_groups and name in node.command_groups: + raise exceptions.InvalidAPIUsage(f"Invalid Command name: conflict with Command Group name: " + f"'{' '.join(cmd_names)}'") + if not node.commands: + node.commands = {} + elif name in node.commands: + return node.commands[name] + + command = CMDSpecsCommand() + command.names = list(cmd_names) + node.commands[name] = command + self._modified_commands.add(cmd_names) + + return command + + def delete_command(self, *cmd_names): + if len(cmd_names) < 2: + raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") + parent = self.find_command_group(*cmd_names[:-1]) + name = cmd_names[-1] + if not parent or not parent.commands or name not in parent.commands: + return False + command = parent.commands[name] + if command.versions: + raise exceptions.ResourceConflict("Cannot delete command with versions") + del parent.commands[name] + if not parent.commands: + parent.commands = None + + self._modified_commands.add(cmd_names) + + if not parent.command_groups and not parent.commands: + # delete empty parent command group + self.delete_command_group(*cmd_names[:-1]) + return True + + def delete_command_version(self, *cmd_names, version): + if len(cmd_names) < 2: + raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") + command = self.find_command(*cmd_names) + if not command or not command.versions: + return False + match_idx = None + for idx, v in enumerate(command.versions): + if v.name == version: + match_idx = idx + break + if match_idx is None: + return False + + command.versions = command.versions[:match_idx] + command.versions[match_idx+1:] + + self._modified_commands.add(cmd_names) + + if not command.versions: + # delete empty command + self.delete_command(*cmd_names) + return True + + def update_command_version(self, *cmd_names, plane, cfg_cmd): + command = self.create_command(*cmd_names) + + version = None + for v in (command.versions or []): + if v.name == cfg_cmd.version: + version = v + break + + if not version: + version = CMDSpecsCommandVersion() + version.name = cfg_cmd.version + if not command.versions: + command.versions = [] + command.versions.append(version) + + # update version resources + version.resources = [] + for r in cfg_cmd.resources: + resource = CMDSpecsResource() + resource.plane = plane + resource.id = r.id + resource.version = r.version + resource.subresource = r.subresource + version.resources.append(resource) + + self._modified_commands.add(cmd_names) + + def update_command_by_ws(self, ws_leaf): + command = self.find_command(*ws_leaf.names) + if not command: + # make sure the command exist, if command not exist, then run update_resource_cfg first + raise exceptions.InvalidAPIUsage(f"Command isn't exist: '{' '.join(ws_leaf.names)}'") + + cmd_version = None + for v in (command.versions or []): + if v.name == ws_leaf.version: + cmd_version = v + break + if not cmd_version: + raise exceptions.InvalidAPIUsage(f"Command in version isn't exist: " + f"'{' '.join(ws_leaf.names)}' '{ws_leaf.version}'") + + # compare resources + leaf_resources = {(r.id, r.version) for r in ws_leaf.resources} + cmd_version_resources = {(r.id, r.version) for r in cmd_version.resources} + if leaf_resources != cmd_version_resources: + raise exceptions.InvalidAPIUsage(f"The resources in version don't match the resources of workspace leaf: " + f"{leaf_resources} != {cmd_version_resources}") + + # update stage + cmd_version.stage = ws_leaf.stage + + # update examples + if ws_leaf.examples: + cmd_version.examples = [CMDCommandExample(e.to_primitive()) for e in ws_leaf.examples] + + # update help + if ws_leaf.help: + if not command.help: + command.help = CMDHelp() + if ws_leaf.help.short: + command.help.short = ws_leaf.help.short + if ws_leaf.help.lines: + command.help.lines = [*ws_leaf.help.lines] + + self._modified_commands.add(tuple(command.names)) + return command + + def patch_partial_items(self, aaz_command_tree: CMDSpecsCommandTree): + aaz_command_tree = CMDSpecsPartialCommandTree(self.aaz_path, aaz_command_tree.root) + nodes = [self.root] + i = 0 + while i < len(nodes): + command_group = nodes[i] + if isinstance(command_group, CMDSpecsCommandGroup): + if isinstance(command_group.command_groups, CMDSpecsCommandGroupDict): + for key in command_group.command_groups.keys(): + raw_cg = command_group.command_groups.get_raw_item(key) + if isinstance(raw_cg, CMDSpecsCommandGroup): + nodes.append(raw_cg) + elif isinstance(raw_cg, CMDSpecsPartialCommandGroup): + command_group.command_groups[key] = aaz_command_tree.find_command_group(*raw_cg.names) + elif isinstance(command_group.command_groups, dict): + for cg in command_group.command_groups.values(): + nodes.append(cg) + if isinstance(command_group.commands, CMDSpecsCommandDict): + for key in command_group.commands.keys(): + raw_command = command_group.commands.get_raw_item(key) + if isinstance(raw_command, CMDSpecsPartialCommand): + command_group.commands[key] = aaz_command_tree.find_command(*raw_command.names) + i += 1 + + def patch(self): + tree_path = os.path.join(self.aaz_path, "Commands", "tree.json") + if not (os.path.exists(tree_path) and os.path.isfile(tree_path)): + return + try: + with open(tree_path, 'r', encoding="utf-8") as f: + data = json.load(f) + aaz_command_tree = CMDSpecsCommandTree(data) + self.patch_partial_items(aaz_command_tree) + except json.decoder.JSONDecodeError as e: + raise ValueError(f"Invalid Command Tree file: {tree_path}") from e + + def verify_command_tree(self): + details = {} + for group in self.iter_command_groups(): + if group == self.root: + continue + if not group.help or not group.help.short: + details[' '.join(group.names)] = { + 'type': 'group', + 'help': "Miss short summary." + } + + for cmd in self.iter_commands(): + if not cmd.help or not cmd.help.short: + details[' '.join(cmd.names)] = { + 'type': 'command', + 'help': "Miss short summary." + } + if details: + raise exceptions.VerificationError(message="Invalid Command Tree", details=details) + + def to_model(self): + tree = CMDSpecsCommandTree() + tree.root = self.root + return tree diff --git a/src/aaz_dev/command/controller/specs_manager.py b/src/aaz_dev/command/controller/specs_manager.py index 490262e5..810ce6a7 100644 --- a/src/aaz_dev/command/controller/specs_manager.py +++ b/src/aaz_dev/command/controller/specs_manager.py @@ -1,4 +1,5 @@ import json +import logging import os import re import shutil @@ -13,6 +14,9 @@ from .cfg_reader import CfgReader from .client_cfg_reader import ClientCfgReader from .cfg_validator import CfgValidator +from .command_tree import CMDSpecsPartialCommandTree + +logger = logging.getLogger('backend') class AAZSpecsManager: @@ -27,29 +31,23 @@ def __init__(self): self.folder = Config.AAZ_PATH self.resources_folder = os.path.join(self.folder, "Resources") self.commands_folder = os.path.join(self.folder, "Commands") - self.tree = None + self._tree = None self._modified_command_groups = set() self._modified_commands = set() self._modified_resource_cfgs = {} self._modified_resource_client_cfgs = {} - tree_path = self.get_tree_file_path() - if not os.path.exists(tree_path): - self.tree = CMDSpecsCommandTree() - self.tree.root = CMDSpecsCommandGroup({ - "names": [self.COMMAND_TREE_ROOT_NAME] - }) - return - - if not os.path.isfile(tree_path): - raise ValueError(f"Invalid Command Tree file path, expect a file: {tree_path}") - - try: - with open(tree_path, 'r', encoding="utf-8") as f: - data = json.load(f) - self.tree = CMDSpecsCommandTree(data) - except json.decoder.JSONDecodeError as e: - raise ValueError(f"Invalid Command Tree file: {tree_path}") from e + self._tree = CMDSpecsPartialCommandTree(self.folder) + + @property + def tree(self): + logger.info("Get Command Tree") + return self._tree + + @tree.setter + def tree(self, value): + logger.info("Set Command Tree") + self._tree = value # Commands folder def get_tree_file_path(self): @@ -118,47 +116,6 @@ def get_resource_versions(self, plane, resource_id): versions.add(file_name[:-3]) return sorted(versions, reverse=True) - # Command Tree - def find_command_group(self, *cg_names): - node = self.tree.root - idx = 0 - while idx < len(cg_names): - name = cg_names[idx] - if not node.command_groups or name not in node.command_groups: - return None - node = node.command_groups[name] - idx += 1 - return node - - def find_command(self, *cmd_names): - if len(cmd_names) < 2: - raise exceptions.InvalidAPIUsage(f"Invalid command name: '{' '.join(cmd_names)}'") - - node = self.find_command_group(*cmd_names[:-1]) - if not node: - return None - name = cmd_names[-1] - if not node.commands or name not in node.commands: - return None - leaf = node.commands[name] - return leaf - - def iter_command_groups(self, *root_cg_names): - root = self.find_command_group(*root_cg_names) - if root: - nodes = [root] - i = 0 - while i < len(nodes): - yield nodes[i] - for node in (nodes[i].command_groups or {}).values(): - nodes.append(node) - i += 1 - - def iter_commands(self, *root_node_names): - for node in self.iter_command_groups(*root_node_names): - for leaf in (node.commands or {}).values(): - yield leaf - def load_resource_cfg_reader(self, plane, resource_id, version): key = (plane, resource_id, version) if key in self._modified_resource_cfgs: @@ -222,151 +179,6 @@ def load_resource_cfg_reader_by_command_with_version(self, cmd, version): resource = version.resources[0] return self.load_resource_cfg_reader(resource.plane, resource.id, resource.version) - # command tree - def create_command_group(self, *cg_names): - if len(cg_names) < 1: - raise exceptions.InvalidAPIUsage(f"Invalid Command Group name: '{' '.join(cg_names)}'") - node = self.tree.root - idx = 0 - while idx < len(cg_names): - name = cg_names[idx] - if node.commands and name in node.commands: - raise exceptions.InvalidAPIUsage(f"Invalid Command Group name: conflict with Command name: " - f"'{' '.join(cg_names[:idx+1])}'") - if not node.command_groups or name not in node.command_groups: - if not node.command_groups: - node.command_groups = {} - names = [*cg_names[:idx+1]] - node.command_groups[name] = CMDSpecsCommandGroup({ - "names": names - }) - self._modified_command_groups.add(cg_names[:idx+1]) - node = node.command_groups[name] - idx += 1 - return node - - def update_command_group_by_ws(self, ws_node): - command_group = self.create_command_group(*ws_node.names) - if ws_node.help: - if not command_group.help: - command_group.help = CMDHelp() - if ws_node.help.short: - command_group.help.short = ws_node.help.short - if ws_node.help.lines: - command_group.help.lines = [*ws_node.help.lines] - self._modified_command_groups.add(tuple([*ws_node.names])) - return command_group - - def delete_command_group(self, *cg_names): - for _ in self.iter_commands(*cg_names): - raise exceptions.ResourceConflict("Cannot delete command group with commands") - parent = self.find_command_group(*cg_names[:-1]) - name = cg_names[-1] - if not parent or not parent.command_groups or name not in parent.command_groups: - return False - del parent.command_groups[name] - if not parent.command_groups: - parent.command_groups = None - - self._modified_command_groups.add(cg_names) - - if not parent.command_groups and not parent.commands: - # delete empty parent command group - self.delete_command_group(*cg_names[:-1]) - return True - - def create_command(self, *cmd_names): - if len(cmd_names) < 2: - raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") - node = self.create_command_group(*cmd_names[:-1]) - name = cmd_names[-1] - if node.command_groups and name in node.command_groups: - raise exceptions.InvalidAPIUsage(f"Invalid Command name: conflict with Command Group name: " - f"'{' '.join(cmd_names)}'") - if not node.commands: - node.commands = {} - elif name in node.commands: - return node.commands[name] - - command = CMDSpecsCommand() - command.names = list(cmd_names) - node.commands[name] = command - self._modified_commands.add(cmd_names) - - return command - - def delete_command(self, *cmd_names): - if len(cmd_names) < 2: - raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") - parent = self.find_command_group(*cmd_names[:-1]) - name = cmd_names[-1] - if not parent or not parent.commands or name not in parent.commands: - return False - command = parent.commands[name] - if command.versions: - raise exceptions.ResourceConflict("Cannot delete command with versions") - del parent.commands[name] - if not parent.commands: - parent.commands = None - - self._modified_commands.add(cmd_names) - - if not parent.command_groups and not parent.commands: - # delete empty parent command group - self.delete_command_group(*cmd_names[:-1]) - return True - - def delete_command_version(self, *cmd_names, version): - if len(cmd_names) < 2: - raise exceptions.InvalidAPIUsage(f"Invalid Command name: '{' '.join(cmd_names)}'") - command = self.find_command(*cmd_names) - if not command or not command.versions: - return False - match_idx = None - for idx, v in enumerate(command.versions): - if v.name == version: - match_idx = idx - break - if match_idx is None: - return False - - command.versions = command.versions[:match_idx] + command.versions[match_idx+1:] - - self._modified_commands.add(cmd_names) - - if not command.versions: - # delete empty command - self.delete_command(*cmd_names) - return True - - def update_command_version(self, *cmd_names, plane, cfg_cmd): - command = self.create_command(*cmd_names) - - version = None - for v in (command.versions or []): - if v.name == cfg_cmd.version: - version = v - break - - if not version: - version = CMDSpecsCommandVersion() - version.name = cfg_cmd.version - if not command.versions: - command.versions = [] - command.versions.append(version) - - # update version resources - version.resources = [] - for r in cfg_cmd.resources: - resource = CMDSpecsResource() - resource.plane = plane - resource.id = r.id - resource.version = r.version - resource.subresource = r.subresource - version.resources.append(resource) - - self._modified_commands.add(cmd_names) - def _remove_cfg(self, cfg): cfg_reader = CfgReader(cfg) @@ -377,7 +189,7 @@ def _remove_cfg(self, cfg): # update command tree for cmd_names, cmd in cfg_reader.iter_commands(): - self.delete_command_version(*cmd_names, version=cmd.version) + self.tree.delete_command_version(*cmd_names, version=cmd.version) def update_resource_cfg(self, cfg): cfg_reader = CfgReader(cfg=cfg) @@ -394,73 +206,12 @@ def update_resource_cfg(self, cfg): # add new command version for cmd_names, cmd in cfg_reader.iter_commands(): - self.update_command_version(*cmd_names, plane=cfg.plane, cfg_cmd=cmd) + self.tree.update_command_version(*cmd_names, plane=cfg.plane, cfg_cmd=cmd) for resource in cfg_reader.resources: key = (cfg.plane, resource.id, resource.version) self._modified_resource_cfgs[key] = cfg - def update_command_by_ws(self, ws_leaf): - command = self.find_command(*ws_leaf.names) - if not command: - # make sure the command exist, if command not exist, then run update_resource_cfg first - raise exceptions.InvalidAPIUsage(f"Command isn't exist: '{' '.join(ws_leaf.names)}'") - - cmd_version = None - for v in (command.versions or []): - if v.name == ws_leaf.version: - cmd_version = v - break - if not cmd_version: - raise exceptions.InvalidAPIUsage(f"Command in version isn't exist: " - f"'{' '.join(ws_leaf.names)}' '{ws_leaf.version}'") - - # compare resources - leaf_resources = {(r.id, r.version) for r in ws_leaf.resources} - cmd_version_resources = {(r.id, r.version) for r in cmd_version.resources} - if leaf_resources != cmd_version_resources: - raise exceptions.InvalidAPIUsage(f"The resources in version don't match the resources of workspace leaf: " - f"{leaf_resources} != {cmd_version_resources}") - - # update stage - cmd_version.stage = ws_leaf.stage - - # update examples - if ws_leaf.examples: - cmd_version.examples = [CMDCommandExample(e.to_primitive()) for e in ws_leaf.examples] - - # update help - if ws_leaf.help: - if not command.help: - command.help = CMDHelp() - if ws_leaf.help.short: - command.help.short = ws_leaf.help.short - if ws_leaf.help.lines: - command.help.lines = [*ws_leaf.help.lines] - - self._modified_commands.add(tuple(command.names)) - return command - - def verify_command_tree(self): - details = {} - for group in self.iter_command_groups(): - if group == self.tree.root: - continue - if not group.help or not group.help.short: - details[' '.join(group.names)] = { - 'type': 'group', - 'help': "Miss short summary." - } - - for cmd in self.iter_commands(): - if not cmd.help or not cmd.help.short: - details[' '.join(cmd.names)] = { - 'type': 'command', - 'help': "Miss short summary." - } - if details: - raise exceptions.VerificationError(message="Invalid Command Tree", details=details) - # client configuration def load_client_cfg_reader(self, plane): key = (plane, ) @@ -510,7 +261,7 @@ def update_client_cfg(self, cfg): self._modified_resource_client_cfgs[key] = cfg def save(self): - self.verify_command_tree() + self.tree.verify_command_tree() remove_files = [] remove_folders = [] @@ -518,11 +269,11 @@ def save(self): command_groups = set() tree_path = self.get_tree_file_path() - update_files[tree_path] = json.dumps(self.tree.to_primitive(), indent=2, sort_keys=True) + update_files[tree_path] = json.dumps(self.tree.to_model().to_primitive(), indent=2, sort_keys=True) # command for cmd_names in sorted(self._modified_commands): - cmd = self.find_command(*cmd_names) + cmd = self.tree.find_command(*cmd_names) file_path = self.get_command_readme_path(*cmd_names) if not cmd: # remove command file @@ -538,7 +289,7 @@ def save(self): # command groups for cg_names in sorted(command_groups): - cg = self.find_command_group(*cg_names) + cg = self.tree.find_command_group(*cg_names) if not cg: # remove command group folder remove_folders.append(self.get_command_group_folder(*cg_names)) diff --git a/src/aaz_dev/command/controller/workspace_manager.py b/src/aaz_dev/command/controller/workspace_manager.py index f31f5b26..d00d9271 100644 --- a/src/aaz_dev/command/controller/workspace_manager.py +++ b/src/aaz_dev/command/controller/workspace_manager.py @@ -285,7 +285,7 @@ def create_command_tree_nodes(self, *node_names): if not node.command_groups or name not in node.command_groups: if not node.command_groups: node.command_groups = {} - aaz_node = self.aaz_specs.find_command_group( + aaz_node = self.aaz_specs.tree.find_command_group( *node_names[:idx + 1]) if aaz_node is not None: new_node = CMDCommandTreeNode({ @@ -976,14 +976,14 @@ def generate_to_aaz(self): # update commands for ws_leaf in self.iter_command_tree_leaves(): - self.aaz_specs.update_command_by_ws(ws_leaf) + self.aaz_specs.tree.update_command_by_ws(ws_leaf) # update command groups for ws_node in self.iter_command_tree_nodes(): if ws_node == self.ws.command_tree: # ignore root node continue - self.aaz_specs.update_command_group_by_ws(ws_node) + self.aaz_specs.tree.update_command_group_by_ws(ws_node) self.aaz_specs.save() def _merge_sub_resources_in_aaz(self): diff --git a/src/aaz_dev/command/tests/spec_tests/spec_portal_gen_test.py b/src/aaz_dev/command/tests/spec_tests/spec_portal_gen_test.py index 06fbbc3d..924822bf 100644 --- a/src/aaz_dev/command/tests/spec_tests/spec_portal_gen_test.py +++ b/src/aaz_dev/command/tests/spec_tests/spec_portal_gen_test.py @@ -16,7 +16,7 @@ def test_aaz_cmd_portal_generate(self): node_names = node_names[1:] manager = AAZSpecsManager() - leaf = manager.find_command(*node_names, leaf_name) + leaf = manager.tree.find_command(*node_names, leaf_name) if not leaf: raise exceptions.ResourceNotFind("Command group not exist") diff --git a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py new file mode 100644 index 00000000..f2b86359 --- /dev/null +++ b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py @@ -0,0 +1,306 @@ +import os +import unittest + +from command.controller.command_tree import CMDSpecsPartialCommand, CMDSpecsPartialCommandGroup, \ + CMDSpecsPartialCommandTree +from command.model.configuration import CMDHelp + +COMMAND_INFO = """# [Command] _vm deallocate_ + +Deallocate a VM so that computing resources are no longer allocated (charges no longer apply). The status will change from 'Stopped' to 'Stopped (Deallocated)'. + +For an end-to-end tutorial, see https://docs.microsoft.com/azure/virtual-machines/linux/capture-image + +## Versions + +### [2017-03-30](/Resources/mgmt-plane/L3N1YnNjcmlwdGlvbnMve30vcmVzb3VyY2Vncm91cHMve30vcHJvdmlkZXJzL21pY3Jvc29mdC5jb21wdXRlL3ZpcnR1YWxtYWNoaW5lcy97fS9kZWFsbG9jYXRl/2017-03-30.xml) **Stable** + + + +#### examples + +- Deallocate, generalize, and capture a stopped virtual machine. + ```bash + vm deallocate -g MyResourceGroup -n MyVm + vm generalize -g MyResourceGroup -n MyVm + vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix + ``` + +- Deallocate, generalize, and capture multiple stopped virtual machines. + ```bash + vm deallocate --ids vms_ids + vm generalize --ids vms_ids + vm capture --ids vms_ids --vhd-name-prefix MyPrefix + ``` + +- Deallocate a VM. + ```bash + vm deallocate --name MyVm --no-wait --resource-group MyResourceGroup + ``` + +### [2017-12-01](/Resources/mgmt-plane/L3N1YnNjcmlwdGlvbnMve30vcmVzb3VyY2Vncm91cHMve30vcHJvdmlkZXJzL21pY3Jvc29mdC5jb21wdXRlL3ZpcnR1YWxtYWNoaW5lcy97fS9kZWFsbG9jYXRl/2017-12-01.xml) **Stable** + + + +#### examples + +- Deallocate, generalize, and capture a stopped virtual machine. + ```bash + vm deallocate -g MyResourceGroup -n MyVm + vm generalize -g MyResourceGroup -n MyVm + vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix + ``` + +- Deallocate, generalize, and capture multiple stopped virtual machines. + ```bash + vm deallocate --ids vms_ids + vm generalize --ids vms_ids + vm capture --ids vms_ids --vhd-name-prefix MyPrefix + ``` + +- Deallocate a VM. + ```bash + vm deallocate --name MyVm --no-wait --resource-group MyResourceGroup + ``` + +### [2020-06-01](/Resources/mgmt-plane/L3N1YnNjcmlwdGlvbnMve30vcmVzb3VyY2Vncm91cHMve30vcHJvdmlkZXJzL21pY3Jvc29mdC5jb21wdXRlL3ZpcnR1YWxtYWNoaW5lcy97fS9kZWFsbG9jYXRl/2020-06-01.xml) **Stable** + + + +#### examples + +- Deallocate, generalize, and capture a stopped virtual machine. + ```bash + vm deallocate -g MyResourceGroup -n MyVm + vm generalize -g MyResourceGroup -n MyVm + vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix + ``` + +- Deallocate, generalize, and capture multiple stopped virtual machines. + ```bash + vm deallocate --ids vms_ids + vm generalize --ids vms_ids + vm capture --ids vms_ids --vhd-name-prefix MyPrefix + ``` + +- Deallocate a VM. + ```bash + vm deallocate --name MyVm --no-wait --resource-group MyResourceGroup + ``` + +### [2022-11-01](/Resources/mgmt-plane/L3N1YnNjcmlwdGlvbnMve30vcmVzb3VyY2Vncm91cHMve30vcHJvdmlkZXJzL21pY3Jvc29mdC5jb21wdXRlL3ZpcnR1YWxtYWNoaW5lcy97fS9kZWFsbG9jYXRl/2022-11-01.xml) **Stable** + + + +#### examples + +- Deallocate, generalize, and capture a stopped virtual machine. + ```bash + vm deallocate -g MyResourceGroup -n MyVm + vm generalize -g MyResourceGroup -n MyVm + vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix + ``` + +- Deallocate, generalize, and capture multiple stopped virtual machines. + ```bash + vm deallocate --ids vms_ids + vm generalize --ids vms_ids + vm capture --ids vms_ids --vhd-name-prefix MyPrefix + ``` + +- Deallocate a VM. + ```bash + vm deallocate --name MyVm --no-wait --resource-group MyResourceGroup + ``` +""" + +GROUP_INFO = """# [Group] _voice-service_ + +Manage voice services + +## Subgroups + +- [gateway](/Commands/voice-service/gateway/readme.md) +: Manage communications gateway + +- [test-line](/Commands/voice-service/test-line/readme.md) +: Manage gateway test line + +## Commands + +- [check-name-availability](/Commands/voice-service/_check-name-availability.md) +: Check whether the resource name is available in the given region. +""" + +ROOT_INFO = """# Atomic Azure CLI Commands + +## Groups + +- [acat](/Commands/acat/readme.md) +: ACAT command group + +- [account](/Commands/account/readme.md) +: Manage Azure subscription information. + +- [afd](/Commands/afd/readme.md) +: Manage Azure Front Door Standard/Premium. + +- [alerts-management](/Commands/alerts-management/readme.md) +: Manage Azure Alerts Management Service Resource. + +- [amlfs](/Commands/amlfs/readme.md) +: Manage lustre file system + +- [aosm](/Commands/aosm/readme.md) +: Manage Azure Operator Service Manager resources. + +- [apic](/Commands/apic/readme.md) +: Manage Azure API Center services + +- [arc](/Commands/arc/readme.md) +: Manage Azure Arc Machines. + +- [astronomer](/Commands/astronomer/readme.md) +: Manage Azure Astronomer resources. + +- [attestation](/Commands/attestation/readme.md) +: Manage Microsoft Azure Attestation (MAA). + +- [automanage](/Commands/automanage/readme.md) +: Manage Automanage + +- [automation](/Commands/automation/readme.md) +: Manage Automation Account. + +- [billing](/Commands/billing/readme.md) +: Manage Azure Billing. + +- [billing-benefits](/Commands/billing-benefits/readme.md) +: Azure billing benefits commands + +- [blueprint](/Commands/blueprint/readme.md) +: Commands to manage blueprint. + +- [cache](/Commands/cache/readme.md) +: Azure Cache for Redis + +- [capacity](/Commands/capacity/readme.md) +: Manage capacity. + +- [cdn](/Commands/cdn/readme.md) +: Manage Azure Content Delivery Networks (CDNs). + +- [change-analysis](/Commands/change-analysis/readme.md) +: List changes for resources + +- [cloud-service](/Commands/cloud-service/readme.md) +: Manage cloud service + +- [communication](/Commands/communication/readme.md) +: Manage communication service with communication. + +- [compute](/Commands/compute/readme.md) +: Mange azure compute vm config + +- [compute-diagnostic](/Commands/compute-diagnostic/readme.md) +: Mange vm sku recommender info + +- [compute-recommender](/Commands/compute-recommender/readme.md) +: Manage sku/zone/region recommender info for compute resources + +- [confidentialledger](/Commands/confidentialledger/readme.md) +: Deploy and manage Azure confidential ledgers. + +- [confluent](/Commands/confluent/readme.md) +: Manage confluent organization + +- [connectedmachine](/Commands/connectedmachine/readme.md) +: Manage Azure Arc-Enabled Server. + +- [consumption](/Commands/consumption/readme.md) +: Manage consumption of Azure resources. +""" + + +class CommandTreeTest(unittest.TestCase): + def test_load_command(self): + command = CMDSpecsPartialCommand.parse_command_info(COMMAND_INFO, ["vm", "deallocate"]) + self.assertEqual(command.names, ["vm", "deallocate"]) + self.assertEqual(command.help.short, "Deallocate a VM so that computing resources are no longer allocated (charges no longer apply). The status will change from 'Stopped' to 'Stopped (Deallocated)'.") + self.assertListEqual(command.help.lines, [ + "For an end-to-end tutorial, see https://docs.microsoft.com/azure/virtual-machines/linux/capture-image" + ]) + self.assertEqual(len(command.versions), 4) + self.assertEqual(command.versions[0].name, "2017-03-30") + self.assertEqual(command.versions[0].stage, None) # Hidden when stable + self.assertEqual(len(command.versions[0].resources), 1) + self.assertEqual(command.versions[0].resources[0].plane, "mgmt-plane") + self.assertEqual(command.versions[0].resources[0].id, "/subscriptions/{}/resourcegroups/{}/providers/microsoft.compute/virtualmachines/{}/deallocate") + self.assertEqual(command.versions[0].resources[0].version, "2017-03-30") + self.assertEqual(command.versions[0].resources[0].subresource, None) + self.assertEqual(len(command.versions[0].examples), 3) + self.assertEqual(command.versions[0].examples[0].name, "Deallocate, generalize, and capture a stopped virtual machine.") + self.assertListEqual(command.versions[0].examples[0].commands, [ + "vm deallocate -g MyResourceGroup -n MyVm", + "vm generalize -g MyResourceGroup -n MyVm", + "vm capture -g MyResourceGroup -n MyVm --vhd-name-prefix MyPrefix" + ]) + command.validate() + + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") + def test_load_command_group(self): + aaz_folder = os.getenv("AAZ_FOLDER") + group = CMDSpecsPartialCommandGroup.parse_command_group_info(GROUP_INFO, ["voice-service"], aaz_folder) + self.assertIsInstance(group.command_groups.get_raw_item('gateway'), CMDSpecsPartialCommandGroup) + self.assertEqual(group.names, ["voice-service"]) + self.assertEqual(group.help.short, "Manage voice services") + self.assertEqual(group.help.lines, None) + self.assertEqual(len(group.command_groups), 2) + self.assertEqual(group.command_groups["gateway"].names, ["voice-service", "gateway"]) + self.assertEqual(group.command_groups["gateway"].help.short, "Manage communications gateway") + self.assertEqual(group.command_groups["test-line"].names, ["voice-service", "test-line"]) + self.assertEqual(group.command_groups["test-line"].help.short, "Manage gateway test line") + self.assertEqual(len(group.commands), 1) + self.assertEqual(group.commands["check-name-availability"].names, ["voice-service", "check-name-availability"]) + self.assertEqual(group.commands["check-name-availability"].help.short, "Check whether the resource name is available in the given region.") + group.validate() + + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") + def test_load_command_tree_from_disk(self): + aaz_folder = os.getenv("AAZ_FOLDER") + command_tree = CMDSpecsPartialCommandTree(aaz_folder) + self.assertIsNotNone(command_tree.root) + self.assertEqual(len(command_tree.root.commands), 0) + self.assertNotEqual(len(command_tree.root.command_groups), 0) + command_tree.iter_commands() + command_tree.to_model().validate() + command_tree_json = command_tree.to_model().to_primitive() + aaz_tree_path = os.path.join(aaz_folder, 'Commands', 'tree.json') + with open(aaz_tree_path, 'r', encoding='utf-8') as f: + import json + aaz_tree = json.load(f) + # command_tree_json_str = json.dumps(command_tree_json, sort_keys=True) + # aaz_tree_str = json.dumps(aaz_tree, sort_keys=True) + # with open(os.path.join(aaz_folder, 'Commands', 'tmp_tree.json'), 'w') as f: + # json.dump(command_tree_json, f, indent=2, sort_keys=True) + print("Dumped Command Tree String Len: " + str(len(json.dumps(command_tree_json, sort_keys=True)))) + print("Dumped AAZ Tree String Len: " + str(len(json.dumps(aaz_tree, sort_keys=True)))) + # self.assertEqual(command_tree_json_str, aaz_tree_str) + + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") + def test_patch(self): + aaz_folder = os.getenv("AAZ_FOLDER") + command_tree = CMDSpecsPartialCommandTree(aaz_folder) + cg = command_tree.create_command_group('fake_cg') + cg.help = CMDHelp() + cg.help.short = 'HELP' + command = command_tree.create_command('fake_cg', 'fake_new_command') + command.help = CMDHelp() + command.help.short = 'HELP' + for version in command_tree.find_command('acat', 'report', 'snapshot', 'download').versions: + command_tree.delete_command_version('acat', 'report', 'snapshot', 'download', version=version.name) + command_tree.delete_command('acat', 'report', 'snapshot', 'download') + + command_tree.patch() + self.assertNotIn('download', command_tree.find_command_group('acat', 'report', 'snapshot').commands) + self.assertIn('fake_new_command', command_tree.find_command_group('fake_cg').commands) From 142f9c5ef57b4e886473d5cfd421519347654be5 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 25 Sep 2024 15:11:24 +0800 Subject: [PATCH 02/24] Backend Support --- src/aaz_dev/command/api/specs.py | 9 +++- .../command/controller/command_tree.py | 52 +++++++++++++++++++ .../command/model/specs/_command_tree.py | 13 +++-- .../tests/spec_tests/test_command_tree.py | 13 ++++- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 938135ce..73788eab 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -1,4 +1,6 @@ from flask import Blueprint, jsonify, request + +from command.controller.command_tree import to_limited_primitive from utils import exceptions from utils.plane import PlaneEnum from command.controller.specs_manager import AAZSpecsManager @@ -19,7 +21,12 @@ def command_tree_node(node_names): if not node: raise exceptions.ResourceNotFind("Command group not exist") - result = node.to_primitive() + # Check for the 'limited' query parameter + limited = request.args.get('limited', 'false').lower() == 'true' + if limited: + result = to_limited_primitive(node) + else: + result = node.to_primitive() return jsonify(result) diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index bc6797ff..fe4a3725 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -122,6 +122,12 @@ def values(self): def get_raw_item(self, key): return super().get(key) + def raw_values(self): + return super().values() + + def raw_items(self): + return super().items() + class CMDSpecsCommandGroupDict(dict): def __init__(self, *args, **kwargs): @@ -146,6 +152,12 @@ def values(self): def get_raw_item(self, key): return super().get(key) + def raw_values(self): + return super().values() + + def raw_items(self): + return super().items() + class CMDSpecsPartialCommand: _COMMAND_INFO_RE = r"# \[Command\] _(?P[A-Za-z0-9- ]+)_\n" \ @@ -546,3 +558,43 @@ def to_model(self): tree = CMDSpecsCommandTree() tree.root = self.root return tree + + +def to_limited_primitive(command_group: CMDSpecsCommandGroup): + copied = CMDSpecsCommandGroup() + copied.names = command_group.names + copied.help = command_group.help + copied.commands = {} + copied.command_groups = {} + if isinstance(command_group.command_groups, CMDSpecsCommandGroupDict): + iterator = command_group.command_groups.raw_items() + else: + iterator = command_group.command_groups.items() + for k, v in iterator: + sub_cg = CMDSpecsCommandGroup() + sub_cg.names = v.names + sub_cg.commands = None + sub_cg.command_groups = None + if isinstance(v, CMDSpecsCommandGroup): + sub_cg.help = v.help + copied.command_groups[k] = sub_cg + elif isinstance(v, CMDSpecsPartialCommandGroup): + sub_cg.help = CMDHelp() + sub_cg.help.short = v.short_help + copied.command_groups[k] = sub_cg + if isinstance(command_group.commands, CMDSpecsCommandDict): + iterator = command_group.commands.raw_items() + else: + iterator = command_group.commands.items() + for k, v in iterator: + sub_command = CMDSpecsCommand() + sub_command.names = v.names + sub_command.versions = None + if isinstance(v, CMDSpecsCommand): + sub_command.help = v.help + copied.commands[k] = sub_command + elif isinstance(v, CMDSpecsPartialCommand): + sub_command.help = CMDHelp() + sub_command.help.short = v.short_help + copied.commands[k] = sub_command + return copied.to_primitive() diff --git a/src/aaz_dev/command/model/specs/_command_tree.py b/src/aaz_dev/command/model/specs/_command_tree.py index bab4bddd..9bdcc202 100644 --- a/src/aaz_dev/command/model/specs/_command_tree.py +++ b/src/aaz_dev/command/model/specs/_command_tree.py @@ -1,6 +1,7 @@ from command.model.configuration import CMDStageField, CMDHelp, CMDCommandExample from command.model.configuration._fields import CMDCommandNameField, CMDVersionField from schematics.models import Model +from schematics.common import NOT_NONE from schematics.types import ModelType, ListType, DictType from ._resource import CMDSpecsResource @@ -19,7 +20,11 @@ class Options: class CMDSpecsCommand(Model): names = ListType(field=CMDCommandNameField(), min_size=1, required=True) # full name of a command help = ModelType(CMDHelp, required=True) - versions = ListType(ModelType(CMDSpecsCommandVersion), required=True, min_size=1) + versions = ListType( # None only when the command is a partial command + ModelType(CMDSpecsCommandVersion), + min_size=1, + export_level=NOT_NONE, + ) class Options: serialize_when_none = False @@ -32,10 +37,12 @@ class CMDSpecsCommandGroup(Model): command_groups = DictType( field=ModelType("CMDSpecsCommandGroup"), serialized_name="commandGroups", - deserialize_from="commandGroups" + deserialize_from="commandGroups", + export_level=NOT_NONE, ) commands = DictType( - field=ModelType(CMDSpecsCommand) + field=ModelType(CMDSpecsCommand), + export_level=NOT_NONE, ) class Options: diff --git a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py index f2b86359..eb2f6f9f 100644 --- a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py +++ b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py @@ -2,7 +2,7 @@ import unittest from command.controller.command_tree import CMDSpecsPartialCommand, CMDSpecsPartialCommandGroup, \ - CMDSpecsPartialCommandTree + CMDSpecsPartialCommandTree, to_limited_primitive from command.model.configuration import CMDHelp COMMAND_INFO = """# [Command] _vm deallocate_ @@ -304,3 +304,14 @@ def test_patch(self): command_tree.patch() self.assertNotIn('download', command_tree.find_command_group('acat', 'report', 'snapshot').commands) self.assertIn('fake_new_command', command_tree.find_command_group('fake_cg').commands) + + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") + def test_partial_command_group_to_primitive(self): + aaz_folder = os.getenv("AAZ_FOLDER") + command_tree = CMDSpecsPartialCommandTree(aaz_folder) + cg = command_tree.find_command_group('acat') + self.assertIsInstance(cg.command_groups.get_raw_item('report'), CMDSpecsPartialCommandGroup) + primitive = to_limited_primitive(cg) + self.assertListEqual(primitive['names'], cg.names) + self.assertEqual(primitive['help']['short'], cg.help.short) + self.assertIsInstance(cg.command_groups.get_raw_item('report'), CMDSpecsPartialCommandGroup) From e8fcae24e1c9cb2babb6693db5d9e9efc5fedd02 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 25 Sep 2024 15:50:46 +0800 Subject: [PATCH 03/24] start hook --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 15 + src/web/src/views/cli/CLIModuleGenerator.tsx | 351 +++++++++++------- 2 files changed, 225 insertions(+), 141 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 54e3545f..4ad00076 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -250,8 +250,16 @@ interface ProfileCTCommandGroup { totalCommands: number; selectedCommands: number; + + loading: boolean; + selected: boolean; + expanded: boolean; }; +function isUnloadedCommandGroup(commandGroup: ProfileCTCommandGroup): boolean { + return commandGroup.commands === undefined && commandGroup.loading === false; +} + interface ProfileCTCommand { id: string; names: string[]; @@ -262,8 +270,15 @@ interface ProfileCTCommand { selectedVersion?: string; registered?: boolean; modified: boolean; + + loading: boolean; + selected: boolean; }; +function isUnloadedCommand(command: ProfileCTCommand): boolean { + return command.selectedVersion === undefined && command.loading === false; +} + interface ProfileCTCommandVersion { name: string; stage: string; diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 9cb633b3..4362c607 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -21,57 +21,140 @@ import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; import { CLIModView, CLIModViewProfiles } from "./CLIModuleCommon"; +interface CLISpecsHelp { + short: string, + lines?: string[], +} -interface CLIModuleGeneratorProps { - params: { - repoName: string; - moduleName: string; - }; +interface CLISpecsResource { + plane: string, + id: string, + version: string, + subresource?: string, } -interface CLIModuleGeneratorState { - loading: boolean; - invalidText?: string, - profiles: string[]; - commandTrees: ProfileCommandTree[]; - selectedProfileIdx?: number; - selectedCommandTree?: ProfileCommandTree; - showGenerateDialog: boolean; +interface CLISpecsCommandExample { + name: string, + commands: string[], } +interface CLISpecsCommandVersion { + name: string, + stage?: string, + resources: CLISpecsResource[], + examples?: CLISpecsCommandExample[], +} -class CLIModuleGenerator extends React.Component { +interface CLISpecsCommand { + names: string[], + help: CLISpecsHelp, + versions?: CLISpecsCommandVersion[], +} - constructor(props: CLIModuleGeneratorProps) { - super(props); - this.state = { - loading: false, - invalidText: undefined, - profiles: [], - commandTrees: [], - selectedProfileIdx: undefined, - selectedCommandTree: undefined, - showGenerateDialog: false, +function isCLISpecsPartialCommand(obj: CLISpecsCommand) { + return obj.versions === undefined; +} + +interface CLISpecsCommandGroup { + names: string[], + help?: CLISpecsHelp, + commands?: CLISpecsCommands, + commandGroups?: CLISpecsCommandGroups, +} + +function isCLISpecsPartialCommandGroup(obj: CLISpecsCommandGroup) { + return obj.commands === undefined || obj.commandGroups === undefined; +} + +interface CLISpecsCommandGroups { + [name: string]: Promise|CLISpecsCommandGroup, +} + +interface CLISpecsCommands { + [name: string]: Promise|CLISpecsCommand, +} + +const useSpecsCommandTree = () => { + const root: Promise = axios.get('/AAZ/Specs/CommandTree/Nodes/aaz').then(res => res.data); + const commandTree = React.useRef({root: root}); + + const ensuredCgOf = async (commandGroups: CLISpecsCommandGroups, name: string) => { + let cg = commandGroups[name]; + if (cg instanceof Promise) { + return await cg; + } else if (isCLISpecsPartialCommandGroup(cg)) { + let cg_promise = axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz/${cg.names.join('/')}?limited=true`).then(res => res.data); + commandGroups[name] = cg_promise; + return await cg_promise; + } else { + return cg; } } - componentDidMount() { - this.loadModule(); + const ensuredCommandOf = async (commands: CLISpecsCommands, name: string) => { + let command = commands[name]; + if (command instanceof Promise) { + return await command; + } else if (isCLISpecsPartialCommand(command)) { + let cg_names = command.names.slice(0, -1); + let command_name = command.names[command.names.length - 1]; + let command_promise = axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz/${cg_names.join('/')}/Leaves/${command_name}`).then(res => res.data); + commands[name] = command_promise; + return await command_promise + } else { + return command; + } } - loadModule = async () => { + const fetchCommandGroup = async (names: string[]) => { + let node = await commandTree.current.root; + for (const name of names) { + node = await ensuredCgOf(node.commandGroups!, name); + } + return node; + } + + const fetchCommand = async (names: string[]) => { + let parent_cg = await fetchCommandGroup(names.slice(0, -1)); + return ensuredCommandOf(parent_cg.commands!, names[names.length - 1]); + } + + return [fetchCommandGroup, fetchCommand]; +} + + +interface CLIModuleGeneratorProps { + params: { + repoName: string; + moduleName: string; + }; +} + +const CLIModuleGenerator: React.FC = ({ params }) => { + const [loading, setLoading] = React.useState(false); + const [invalidText, setInvalidText] = React.useState(undefined); + const [profiles, setProfiles] = React.useState([]); + const [commandTrees, setCommandTrees] = React.useState([]); + const [selectedProfileIdx, setSelectedProfileIdx] = React.useState(undefined); + const [showGenerateDialog, setShowGenerateDialog] = React.useState(false); + + const [fetchCommandGroup, fetchCommand] = useSpecsCommandTree(); + + React.useEffect(() => { + loadModule(); + }, []); + + const loadModule = async () => { try { - this.setState({ - loading: true, - }); + setLoading(true); let res = await axios.get(`/CLI/Az/Profiles`); let profiles: string[] = res.data; res = await axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz`); let commandTrees: ProfileCommandTree[] = profiles.map((profileName) => BuildProfileCommandTree(profileName, res.data)); - res = await axios.get(`/CLI/Az/${this.props.params.repoName}/Modules/${this.props.params.moduleName}`); - let modView: CLIModView = res.data + res = await axios.get(`/CLI/Az/${params.repoName}/Modules/${params.moduleName}`); + let modView: CLIModView = res.data; Object.keys(modView.profiles).forEach((profile) => { let idx = profiles.findIndex(v => v === profile); @@ -79,140 +162,126 @@ class CLIModuleGenerator extends React.Component 0 ? 0 : undefined; - let selectedCommandTree = selectedProfileIdx !== undefined ? commandTrees[selectedProfileIdx] : undefined; - this.setState({ - loading: false, - profiles: profiles, - commandTrees: commandTrees, - selectedProfileIdx: selectedProfileIdx, - selectedCommandTree: selectedCommandTree - }) + setProfiles(profiles); + setCommandTrees(commandTrees); + setSelectedProfileIdx(selectedProfileIdx); + setLoading(false); } catch (err: any) { console.error(err); if (err.response?.data?.message) { const data = err.response!.data!; - this.setState({ - invalidText: `ResponseError: ${data.message!}`, - }) + setInvalidText(`ResponseError: ${data.message!}`); } else { - this.setState({ - invalidText: `Error: ${err}`, - }) + setInvalidText(`Error: ${err}`); } + setLoading(false); } - } + }; - handleBackToHomepage = () => { + const selectedCommandTree = selectedProfileIdx ? commandTrees[selectedProfileIdx] : undefined; + + const handleBackToHomepage = () => { window.open('/?#/cli', "_blank"); - } + }; - handleGenerate = () => { - this.setState({ - showGenerateDialog: true - }) - } + const handleGenerate = () => { + setShowGenerateDialog(true); + }; - handleGenerationClose = (generated: boolean) => { - this.setState({ - showGenerateDialog: false - }) - } + const handleGenerationClose = (generated: boolean) => { + setShowGenerateDialog(false); + }; - onProfileChange = (selectedIdx: number) => { - this.setState(preState => { - return { - ...preState, - selectedProfileIdx: selectedIdx, - selectedCommandTree: preState.commandTrees[selectedIdx], - } - }) - } + const onProfileChange = (selectedIdx: number) => { + setSelectedProfileIdx(selectedIdx); + }; - onSelectedProfileTreeUpdate = (newTree: ProfileCommandTree) => { - this.setState(preState => { - return { - ...preState, - selectedCommandTree: newTree, - commandTrees: preState.commandTrees.map((value, idx) => {return idx === preState.selectedProfileIdx ? newTree : value}), - } - }) - } + const onSelectedProfileTreeUpdate = (newTree: ProfileCommandTree) => { + setCommandTrees(commandTrees.map((value, idx) => (idx === selectedProfileIdx ? newTree : value))); + }; - render() { - const { showGenerateDialog, selectedProfileIdx, selectedCommandTree, profiles, commandTrees } = this.state; - return ( - - - - - - {selectedProfileIdx !== undefined && } - - - - {selectedCommandTree !== undefined && } - + return ( + + + + + + {selectedProfileIdx !== undefined && ( + + )} + + + + {selectedCommandTree !== undefined && ( + + )} - {showGenerateDialog && + {showGenerateDialog && ( + } - theme.zIndex.drawer + 1 }} - open={this.state.loading} - > - {this.state.invalidText !== undefined && - + )} + theme.zIndex.drawer + 1 }} + open={loading} + > + {invalidText !== undefined ? ( + { - this.setState({ - invalidText: undefined, - loading: false, - }) - }} - > - {this.state.invalidText} - - } - {this.state.invalidText === undefined && } - - - ); - } - -} + variant="filled" + severity='error' + onClose={() => { + setInvalidText(undefined); + setLoading(false); + }} + > + {invalidText} + + ) : ( + + )} + + + ); +}; function GenerateDialog(props: { repoName: string; From 2633bb0b17e38bf6a3f34585a5fe425b8680f9cb Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Fri, 27 Sep 2024 17:01:26 +0800 Subject: [PATCH 04/24] UI update --- .../command/controller/specs_manager.py | 1 - .../cli/CLIModGeneratorProfileCommandTree.tsx | 885 +++++++++++++----- .../views/cli/CLIModGeneratorProfileTabs.tsx | 10 +- src/web/src/views/cli/CLIModuleGenerator.tsx | 122 ++- 4 files changed, 694 insertions(+), 324 deletions(-) diff --git a/src/aaz_dev/command/controller/specs_manager.py b/src/aaz_dev/command/controller/specs_manager.py index 810ce6a7..05946cac 100644 --- a/src/aaz_dev/command/controller/specs_manager.py +++ b/src/aaz_dev/command/controller/specs_manager.py @@ -41,7 +41,6 @@ def __init__(self): @property def tree(self): - logger.info("Get Command Tree") return self._tree @tree.setter diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 4ad00076..4cf02f3c 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -8,16 +8,7 @@ import FolderIcon from "@mui/icons-material/Folder"; import EditIcon from '@mui/icons-material/Edit'; import { Box, Checkbox, FormControl, Typography, Select, MenuItem, styled, TypographyProps, InputLabel, IconButton } from "@mui/material"; import { CLIModViewCommand, CLIModViewCommandGroup, CLIModViewCommandGroups, CLIModViewCommands, CLIModViewProfile } from "./CLIModuleCommon"; - - -interface CLIModGeneratorProfileCommandTreeProps { - profileCommandTree: ProfileCommandTree, - onChange: (newProfileCommandTree: ProfileCommandTree) => void, -} - -interface CLIModGeneratorProfileCommandTreeSate { - defaultExpanded: string[], -} +import { CLISpecsCommand, CLISpecsCommandGroup } from "./CLIModuleGenerator"; const CommandGroupTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, @@ -48,212 +39,355 @@ const UnregisteredTypography = styled(SelectionTypography)(({ t })) -class CLIModGeneratorProfileCommandTree extends React.Component { - - constructor(props: CLIModGeneratorProfileCommandTreeProps) { - super(props); - this.state = { - defaultExpanded: GetDefaultExpanded(this.props.profileCommandTree) - } - } - - onSelectCommandGroup = (commandGroupId: string, selected: boolean) => { - let newTree = updateProfileCommandTree(this.props.profileCommandTree, commandGroupId, selected); - this.props.onChange(newTree); - } - - onSelectCommand = (commandId: string, selected: boolean) => { - let newTree = updateProfileCommandTree(this.props.profileCommandTree, commandId, selected); - this.props.onChange(newTree); - } - - onSelectCommandVersion = (commandId: string, version: string) => { - let newTree = updateProfileCommandTree(this.props.profileCommandTree, commandId, true, version); - this.props.onChange(newTree); - } - - onSelectCommandRegistered = (commandId: string, registered: boolean) => { - let newTree = updateProfileCommandTree(this.props.profileCommandTree, commandId, true, undefined, registered); - this.props.onChange(newTree); - } +interface CommandItemProps { + command: ProfileCTCommand, + onSelectCommand: (names: string[], selected: boolean) => void, + onSelectCommandVersion: (names: string[], version: string) => void, + onSelectCommandRegistered: (names: string[], registered: boolean) => void, +} - render() { - const { defaultExpanded } = this.state; - const renderCommand = (command: ProfileCTCommand) => { - const leafName = command.names[command.names.length - 1]; - const selected = command.selectedVersion !== undefined; - return ( - = ({ + command, + onSelectCommand, + onSelectCommandVersion, + onSelectCommandRegistered, +}) => { + const leafName = command.names[command.names.length - 1]; + return ( + + { + onSelectCommand(command.names, !command.selected); + event.stopPropagation(); + event.preventDefault(); + }} + /> + - { - this.onSelectCommand(command.id, !selected); - event.stopPropagation(); - event.preventDefault(); - }} - /> - {/* */} + {leafName} - {leafName} - - {!command.modified && command.selectedVersion !== undefined && { - this.onSelectCommand(command.id, true); - }} - > - - } - {command.modified && } - + {!command.modified && command.selectedVersion !== undefined && { + onSelectCommand(command.names, true); + }} + > + + } + {command.modified && } - {command.selectedVersion !== undefined && + {command.selectedVersion !== undefined && + - - - Version - - - - Command table - { + onSelectCommandVersion(command.names, event.target.value); + }} + size="small" + > + {command.versions!.map((version) => ( + + {version.name} - - - } - + ))} + + + + Command table + + } - onClick={(event) => { - event.stopPropagation(); - event.preventDefault(); - }} - /> - ) - } - - const renderCommandGroup = (commandGroup: ProfileCTCommandGroup) => { - const nodeName = commandGroup.names[commandGroup.names.length - 1]; - const selected = commandGroup.selectedCommands > 0 && commandGroup.totalCommands === commandGroup.selectedCommands; - return ( - - 0} - onClick={(event) => { - this.onSelectCommandGroup(commandGroup.id, !selected); - event.stopPropagation(); - event.preventDefault(); - }} - /> - - {nodeName} + Loading... } + } + onClick={(event) => { + event.stopPropagation(); + event.preventDefault(); + }} + /> + ); +}; + +interface CommandGroupItemProps { + commandGroup: ProfileCTCommandGroup, + onSelectCommandGroup: (names: string[], selected: boolean) => void, + onToggleCommandGroupExpanded: (cnames: string[]) => void, + onSelectCommand: (names: string[], selected: boolean) => void, + onSelectCommandVersion: (names: string[], version: string) => void, + onSelectCommandRegistered: (names: string[], registered: boolean) => void, +} + +const CommandGroupItem: React.FC = ({ + commandGroup, + onSelectCommandGroup, + onToggleCommandGroupExpanded, + onSelectCommand, + onSelectCommandVersion, + onSelectCommandRegistered, +}) => { + const nodeName = commandGroup.names[commandGroup.names.length - 1]; + const selected = commandGroup.selected ?? false; + return ( + + { + onSelectCommandGroup(commandGroup.names, !selected); event.stopPropagation(); event.preventDefault(); }} - > - {commandGroup.commands !== undefined && commandGroup.commands.map((command) => renderCommand(command))} - {commandGroup.commandGroups !== undefined && commandGroup.commandGroups.map((group) => renderCommandGroup(group))} - - ) + /> + + {nodeName} + } + onClick={(event) => { + onToggleCommandGroupExpanded(commandGroup.names); + event.stopPropagation(); + event.preventDefault(); + }} + > + {commandGroup.commands !== undefined && Object.values(commandGroup.commands).map((command) => ( + + ))} + {commandGroup.commandGroups !== undefined && Object.values(commandGroup.commandGroups).map((group) => ( + + ))} + {commandGroup.loading === true && } + + ); +}; + +const LoadingItem: React.FC<{ name: string }> = ({ name }) => { + return ( + + Loading {name}... + + } />) +} + +interface CLIModGeneratorProfileCommandTreeProps { + profileCommandTree: ProfileCommandTree, + onChange: (updater: ((newProfileCommandTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => void, + onLoadCommandGroup: (names: string[]) => Promise, + onLoadCommand: (names: string[]) => Promise, +} + +const CLIModGeneratorProfileCommandTree: React.FC = ({ + profileCommandTree, + onChange, + onLoadCommandGroup, + onLoadCommand, +}) => { + const [expanded, setExpanded] = React.useState([]); + console.log("Rerender using ProfileCommandTree: ", profileCommandTree); + console.log("Rerender using Expanded State: ", expanded); + + React.useEffect(() => { + setExpanded(GetDefaultExpanded(profileCommandTree)); + }, []); + + const onSelectCommandGroup = (names: string[], selected: boolean) => { + onChange((profileCommandTree) => { + const newTree = updateProfileCommandTree(profileCommandTree, names, selected) + return genericUpdateCommandGroup(newTree, names, (commandGroup) => { + return loadAllNextLevel(commandGroup, onLoadCommand, onLoadCommandGroup, onLoadedCommand, onLoadedCommandGroup); + }) ?? newTree; + }); + }; + + const onSelectCommand = (names: string[], selected: boolean) => { + onChange((profileCommandTree) => { + const newTree = updateProfileCommandTree(profileCommandTree, names, selected); + return genericUpdateCommand(newTree, names, (command) => loadCommand(command, onLoadCommand, onLoadedCommand)) ?? newTree; + }); + }; + + const onSelectCommandVersion = (names: string[], version: string) => { + onChange((profileCommandTree) => updateProfileCommandTree(profileCommandTree, names, true, version)); + }; + + const onSelectCommandRegistered = (names: string[], registered: boolean) => { + onChange((profileCommandTree) => updateProfileCommandTree(profileCommandTree, names, true, undefined, registered)); + }; + + const onLoadedCommandGroup = React.useCallback((commandGroup: CLISpecsCommandGroup) => { + const names = commandGroup.names; + onChange((profileCommandTree) => { + return genericUpdateCommandGroup(profileCommandTree, names, (unloadedCommandGroup) => { + const newCommandGroup = decodeProfileCTCommandGroup(commandGroup, unloadedCommandGroup.selected) + if (newCommandGroup.selected) { + return loadAllNextLevel(newCommandGroup, onLoadCommand, onLoadCommandGroup, onLoadedCommand, onLoadedCommandGroup); + } + return newCommandGroup + })!; + }); + }, [onChange]); + + const onLoadedCommand = React.useCallback((command: CLISpecsCommand) => { + const names = command.names; + onChange((profileCommandTree) => { + return genericUpdateCommand(profileCommandTree, names, (unloadedCommand) => { + return decodeProfileCTCommand(command, unloadedCommand.selected, unloadedCommand.modified); + })!; + }); + }, [onChange]); + + const onToggleCommandGroupExpanded = (names: string[]) => { + console.log("onToggleCommandGroupExpanded", names); + const commandGroup = findCommandGroup(profileCommandTree, names); + setExpanded((prev) => { + console.log("Change Expaned of ", commandGroup); + console.log("Prev Expanded", prev); + if (prev.includes(commandGroup!.id)) { + return prev.filter((value) => value !== commandGroup!.id); + } else { + return [...prev, commandGroup!.id]; + } + }); + + if (!expanded.includes(commandGroup!.id)) { + onChange((profileCommandTree) => + genericUpdateCommandGroup(profileCommandTree, names, (commandGroup) => { + return loadCommandGroup(commandGroup, onLoadCommandGroup, onLoadedCommandGroup); + }) ?? profileCommandTree + ); } + }; + - return ( + return ( + } - defaultExpandIcon={}> - {this.props.profileCommandTree.commandGroups.map((commandGroup) => renderCommandGroup(commandGroup))} + defaultExpandIcon={} + > + {Object.values(profileCommandTree.commandGroups).map((commandGroup) => ( + + ))} - ) - } -} + + ); +}; interface ProfileCommandTree { name: string; - commandGroups: ProfileCTCommandGroup[]; + commandGroups: ProfileCTCommandGroups; }; +interface ProfileCTCommandGroups { + [name: string]: ProfileCTCommandGroup; +} + +interface ProfileCTCommands { + [name: string]: ProfileCTCommand; +} + interface ProfileCTCommandGroup { id: string; names: string[]; help: string; - commandGroups?: ProfileCTCommandGroup[]; - commands?: ProfileCTCommand[]; + commandGroups?: ProfileCTCommandGroups; + commands?: ProfileCTCommands; waitCommand?: CLIModViewCommand; - totalCommands: number; - selectedCommands: number; - loading: boolean; - selected: boolean; - expanded: boolean; + selected?: boolean; }; function isUnloadedCommandGroup(commandGroup: ProfileCTCommandGroup): boolean { @@ -265,7 +399,7 @@ interface ProfileCTCommand { names: string[]; help: string; - versions: ProfileCTCommandVersion[]; + versions?: ProfileCTCommandVersion[]; selectedVersion?: string; registered?: boolean; @@ -292,35 +426,50 @@ function decodeProfileCTCommandVersion(response: any): ProfileCTCommandVersion { } -function decodeProfileCTCommand(response: any): ProfileCTCommand { - let versions = response.versions.map((value: any) => decodeProfileCTCommandVersion(value)); - return { +function decodeProfileCTCommand(response: CLISpecsCommand, selected: boolean = false, modified: boolean = false): ProfileCTCommand { + const versions = response.versions?.map((value: any) => decodeProfileCTCommandVersion(value)); + const command = { id: response.names.join('/'), names: [...response.names], help: response.help.short, versions: versions, - modified: false, + modified: modified, + loading: false, + selected: selected, + } + if (selected) { + const selectedVersion = versions ? versions[0].name : undefined; + return { + ...command, + selectedVersion: selectedVersion, + } + } else { + return command; } } -function decodeProfileCTCommandGroup(response: any): ProfileCTCommandGroup { - let commands = response.commands !== undefined ? Object.keys(response.commands).map((name: string) => decodeProfileCTCommand(response.commands[name])) : undefined; - let commandGroups = response.commandGroups !== undefined ? Object.keys(response.commandGroups).map((name: string) => decodeProfileCTCommandGroup(response.commandGroups[name])) : undefined; - let totalCommands = commands?.length ?? 0; - totalCommands = commandGroups?.reduce((previousValue, value) => previousValue + value.totalCommands, totalCommands) ?? totalCommands; +function decodeProfileCTCommandGroup(response: CLISpecsCommandGroup, selected: boolean = false): ProfileCTCommandGroup { + const commands = response.commands !== undefined ? Object.fromEntries( + Object.entries(response.commands).map(([name, command]) => [name, decodeProfileCTCommand(command, selected, selected)]) + ) : undefined; + const commandGroups = response.commandGroups !== undefined ? Object.fromEntries( + Object.entries(response.commandGroups).map(([name, group]) => [name, decodeProfileCTCommandGroup(group, selected)]) + ) : undefined; return { id: response.names.join('/'), names: [...response.names], - help: response.help.short, + help: response.help?.short ?? '', commandGroups: commandGroups, commands: commands, - totalCommands: totalCommands, - selectedCommands: 0, + loading: false, + selected: selected, } } -function BuildProfileCommandTree(profileName: string, response: any): ProfileCommandTree { - let commandGroups: ProfileCTCommandGroup[] = response.commandGroups !== undefined ? Object.keys(response.commandGroups).map((name: string) => decodeProfileCTCommandGroup(response.commandGroups[name])) : []; +function BuildProfileCommandTree(profileName: string, response: CLISpecsCommandGroup): ProfileCommandTree { + const commandGroups = response.commandGroups !== undefined ? Object.fromEntries( + Object.entries(response.commandGroups).map(([name, group]) => [name, decodeProfileCTCommandGroup(group)]) + ) : {}; return { name: profileName, commandGroups: commandGroups, @@ -328,74 +477,249 @@ function BuildProfileCommandTree(profileName: string, response: any): ProfileCom } function getDefaultExpandedOfCommandGroup(commandGroup: ProfileCTCommandGroup): string[] { - let expandedIds = commandGroup.commandGroups?.flatMap(value => [value.id, ...getDefaultExpandedOfCommandGroup(value)]) ?? []; + let expandedIds = commandGroup.commandGroups ? Object.values(commandGroup.commandGroups).flatMap(value => value.selected !== false ? [value.id, ...getDefaultExpandedOfCommandGroup(value)] : []) : []; return expandedIds; } - function GetDefaultExpanded(tree: ProfileCommandTree): string[] { - return tree.commandGroups.flatMap(value => { + return Object.values(tree.commandGroups).flatMap(value => { let ids = getDefaultExpandedOfCommandGroup(value); - if (value.selectedCommands > 0) { + if (value.selected !== false) { ids.push(value.id); } return ids; }); } -function updateCommand(command: ProfileCTCommand, commandId: string, selected: boolean, version: string | undefined, registered: boolean | undefined): ProfileCTCommand { - if (command.id !== commandId) { - return command; +function findCommandGroup(tree: ProfileCommandTree, names: string[]): ProfileCTCommandGroup | undefined { + if (names.length === 0) { + return undefined; + } + let cg: ProfileCTCommandGroup | ProfileCommandTree = tree; + for (const name of names) { + if (cg.commandGroups === undefined) { + return undefined; + } + cg = cg.commandGroups[name]; + } + return cg as ProfileCTCommandGroup; +} + +function findCommand(tree: ProfileCommandTree, names: string[]): ProfileCTCommand | undefined { + if (names.length === 0) { + return undefined; + } + let cg: ProfileCTCommandGroup | ProfileCommandTree = tree; + for (const name of names.slice(0, -1)) { + if (cg.commandGroups === undefined) { + return undefined; + } + cg = cg.commandGroups[name] as ProfileCTCommandGroup; + } + cg = cg as ProfileCTCommandGroup; + if (cg.commands === undefined) { + return undefined; + } + return cg.commands[names[names.length - 1]]; +} + +function loadCommand(command: ProfileCTCommand, fetchCommand: (names: string[]) => Promise, onLoadedCommand: (command: CLISpecsCommand) => void): ProfileCTCommand | undefined { + if (isUnloadedCommand(command)) { + fetchCommand(command.names).then(onLoadedCommand); + return { ...command, loading: true }; + } else { + return undefined; + } +} + +function loadCommandGroup(commandGroup: ProfileCTCommandGroup, fetchCommandGroup: (names: string[]) => Promise, onLoadedCommandGroup: (commandGroup: CLISpecsCommandGroup) => void): ProfileCTCommandGroup | undefined { + if (isUnloadedCommandGroup(commandGroup)) { + fetchCommandGroup(commandGroup.names).then(onLoadedCommandGroup); + return { ...commandGroup, loading: true }; + } else { + return undefined; + } +} + +function loadAllNextLevel(commandGroup: ProfileCTCommandGroup, fetchCommand: (names: string[]) => Promise, fetchCommandGroup: (names: string[]) => Promise, onLoadedCommand: (command: CLISpecsCommand) => void, onLoadedCommandGroup: (commandGroup: CLISpecsCommandGroup) => void): ProfileCTCommandGroup { + if (isUnloadedCommandGroup(commandGroup)) { + fetchCommandGroup(commandGroup.names).then(onLoadedCommandGroup); + return { ...commandGroup, loading: true }; + } else { + const commandGroups = commandGroup.commandGroups ? Object.fromEntries(Object.entries(commandGroup.commandGroups).map(([name, group]) => [name, loadAllNextLevel(group, fetchCommand, fetchCommandGroup, onLoadedCommand, onLoadedCommandGroup)])) : undefined; + const commands = commandGroup.commands ? Object.fromEntries(Object.entries(commandGroup.commands).map(([name, command]) => [name, loadCommand(command, fetchCommand, onLoadedCommand) ?? command])) : undefined; + return { ...commandGroup, commandGroups: commandGroups, commands: commands }; + } +} + +function genericUpdateCommand(tree: ProfileCommandTree, names: string[], updater: (command: ProfileCTCommand) => ProfileCTCommand | undefined): ProfileCommandTree | undefined { + let nodes: ProfileCTCommandGroup[] = []; + for (const name of names.slice(0, -1)) { + const node = nodes.length === 0 ? tree : nodes[nodes.length - 1]; + if (node.commandGroups === undefined) { + throw new Error("Invalid names: " + names.join(' ')); + } + nodes.push(node.commandGroups[name]); + } + let currentCommandGroup = nodes[nodes.length - 1]; + const updatedCommand = updater(currentCommandGroup.commands![names[names.length - 1]]); + if (updatedCommand === undefined) { + return undefined; + } + const commands = { + ...currentCommandGroup.commands, + [names[names.length - 1]]: updatedCommand, + }; + const groupSelected = calculateSelected(commands, currentCommandGroup.commandGroups!); + currentCommandGroup = { + ...currentCommandGroup, + commands: commands, + selected: groupSelected, + }; + for (const node of nodes.reverse().slice(1)) { + const commandGroups = { + ...node.commandGroups, + [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, + } + const selected = calculateSelected(node.commands ?? {}, commandGroups); + currentCommandGroup = { + ...node, + commandGroups: commandGroups, + selected: selected, + } + } + return { + ...tree, + commandGroups: { + ...tree.commandGroups, + [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, + } + } +} + +function genericUpdateCommandGroup(tree: ProfileCommandTree, names: string[], updater: (commandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup | undefined): ProfileCommandTree | undefined { + let nodes: ProfileCTCommandGroup[] = []; + for (const name of names) { + const node = nodes.length === 0 ? tree : nodes[nodes.length - 1]; + if (node.commandGroups === undefined) { + throw new Error("Invalid names: " + names.join(' ')); + } + nodes.push(node.commandGroups[name]); + } + let currentCommandGroup = nodes[nodes.length - 1]; + const updatedCommandGroup = updater(currentCommandGroup); + if (updatedCommandGroup === undefined) { + return undefined; + } + currentCommandGroup = updatedCommandGroup; + for (const node of nodes.reverse().slice(1)) { + const commandGroups = { + ...node.commandGroups, + [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, + } + const selected = calculateSelected(node.commands ?? {}, commandGroups); + currentCommandGroup = { + ...node, + commandGroups: commandGroups, + selected: selected, + } + } + return { + ...tree, + commandGroups: { + ...tree.commandGroups, + [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, + } + } +} + +function calculateSelected(commands: ProfileCTCommands, commandGroups: ProfileCTCommandGroups): boolean | undefined { + const commandsAllSelected = Object.values(commands).reduce((pre, value) => { return pre && value.selected }, true); + const commandsAllUnselected = Object.values(commands).reduce((pre, value) => { return pre && !value.selected }, true); + const commandGroupsAllSelected = Object.values(commandGroups).reduce((pre, value) => { return pre && value.selected === true }, true); + const commandGroupsAllUnselected = Object.values(commandGroups).reduce((pre, value) => { return pre && value.selected === false }, true); + if (commandsAllUnselected && commandGroupsAllUnselected) { + return false; + } else if (commandsAllSelected && commandGroupsAllSelected) { + return true; + } else { + return undefined; } +} +function updateCommand(command: ProfileCTCommand, selected: boolean, version: string | undefined, registered: boolean | undefined): ProfileCTCommand { if (selected) { - let selectedVersion = version ?? command.selectedVersion ?? command.versions[0].name; - let registerCommand = registered ?? command.registered ?? true; return { ...command, - selectedVersion: selectedVersion, - registered: registerCommand, + selectedVersion: version ?? command.selectedVersion ?? (command.versions !== undefined ? command.versions[0].name : undefined), + registered: registered ?? command.registered ?? true, + selected: true, modified: true, } - } else { return { ...command, selectedVersion: undefined, registered: undefined, + selected: false, modified: true, } } } -function updateCommandGroup(commandGroup: ProfileCTCommandGroup, id: string, selected: boolean, version: string | undefined, registered: boolean | undefined): ProfileCTCommandGroup { - if (commandGroup.id !== id && !id.startsWith(`${commandGroup.id}/`)) { - return commandGroup; - } - let commands: ProfileCTCommand[] | undefined = undefined; - let commandGroups: ProfileCTCommandGroup[] | undefined = undefined; - - if (commandGroup.id === id) { - commands = commandGroup.commands?.map((value) => updateCommand(value, value.id, selected, version, registered)); - commandGroups = commandGroup.commandGroups?.map((value) => updateCommandGroup(value, value.id, selected, version, registered)); +function updateCommandGroup(commandGroup: ProfileCTCommandGroup, names: string[], selected: boolean, version: string | undefined, registered: boolean | undefined): ProfileCTCommandGroup { + if (names.length === 0) { + const commands = commandGroup.commands ? Object.fromEntries(Object.entries(commandGroup.commands).map(([key, command]) => [key, updateCommand(command, selected, version, registered)])) : undefined; + const commandGroups = commandGroup.commandGroups ? Object.fromEntries(Object.entries(commandGroup.commandGroups).map(([key, commandGroup]) => [key, updateCommandGroup(commandGroup, [], selected, version, registered)])) : undefined; + const groupSelected = commands !== undefined && commandGroups !== undefined ? calculateSelected(commands, commandGroups): selected; + return { + ...commandGroup, + commands: commands, + commandGroups: commandGroups, + selected: groupSelected, + } } else { - commands = commandGroup.commands?.map((value) => updateCommand(value, id, selected, version, registered)); - commandGroups = commandGroup.commandGroups?.map((value) => updateCommandGroup(value, id, selected, version, registered)); - } - - let selectedCommands = commands?.reduce((pre, value) => { return value.selectedVersion !== undefined ? pre + 1 : pre }, 0) ?? 0; - selectedCommands += commandGroups?.reduce((pre, value) => { return pre + value.selectedCommands }, 0) ?? 0; - - return { - ...commandGroup, - commands: commands, - commandGroups: commandGroups, - selectedCommands: selectedCommands, + let name = names[0]; + if (name in (commandGroup.commandGroups ?? {})) { + let subGroup = commandGroup.commandGroups![name]; + const commandGroups = { + ...commandGroup.commandGroups, + [name]: updateCommandGroup(subGroup, names.slice(1), selected, version, registered), + } + const commands = commandGroup.commands; + const groupSelected = calculateSelected(commands ?? {}, commandGroups); + return { + ...commandGroup, + commandGroups: commandGroups, + selected: groupSelected, + } + } else if (name in (commandGroup.commands ?? {}) && names.length === 1) { + let command = commandGroup.commands![name]; + const commands = { + ...commandGroup.commands, + [name]: updateCommand(command, selected, version, registered), + } + const commandGroups = commandGroup.commandGroups; + const groupSelected = calculateSelected(commands, commandGroups ?? {}); + return { + ...commandGroup, + commands: commands, + selected: groupSelected, + } + } else { + throw new Error("Invalid names: " + names.join(' ')); + } } + } -function updateProfileCommandTree(tree: ProfileCommandTree, id: string, selected: boolean, version: string | undefined = undefined, registered: boolean | undefined = undefined): ProfileCommandTree { - let commandGroups = tree.commandGroups.map((value) => updateCommandGroup(value, id, selected, version, registered)); +function updateProfileCommandTree(tree: ProfileCommandTree, names: string[], selected: boolean, version: string | undefined = undefined, registered: boolean | undefined = undefined): ProfileCommandTree { + const name = names[0]; + const commandGroup = tree.commandGroups[name]; + const commandGroups = { + ...tree.commandGroups, + [name]: updateCommandGroup(commandGroup, names.slice(1), selected, version, registered), + } return { ...tree, commandGroups: commandGroups @@ -410,6 +734,7 @@ function updateCommandByModView(command: ProfileCTCommand, view: CLIModViewComma ...command, selectedVersion: view.version, registered: view.registered, + selected: view.version !== undefined, } } @@ -419,15 +744,15 @@ function updateCommandGroupByModView(commandGroup: ProfileCTCommandGroup, view: } let commands = commandGroup.commands; if (view.commands !== undefined) { - let keys = new Set(Object.keys(view.commands!)); - commands = commandGroup.commands?.map((value) => { - if (keys.has(value.names[value.names.length - 1])) { - keys.delete(value.names[value.names.length - 1]) - return updateCommandByModView(value, view.commands![value.names[value.names.length - 1]]) + let keys = new Set(Object.keys(view.commands)); + commands = commandGroup.commands ? Object.fromEntries(Object.entries(commandGroup.commands).map(([key, value]) => { + if (keys.has(key)) { + keys.delete(key); + return [key, updateCommandByModView(value, view.commands![key])]; } else { - return value; + return [key, value]; } - }) + })) : undefined; if (keys.size > 0) { let commandNames: string[] = []; keys.forEach(key => { @@ -439,15 +764,15 @@ function updateCommandGroupByModView(commandGroup: ProfileCTCommandGroup, view: let commandGroups = commandGroup.commandGroups; if (view.commandGroups !== undefined) { - let keys = new Set(Object.keys(view.commandGroups!)); - commandGroups = commandGroup.commandGroups?.map((value) => { - if (keys.has(value.names[value.names.length - 1])) { - keys.delete(value.names[value.names.length - 1]) - return updateCommandGroupByModView(value, view.commandGroups![value.names[value.names.length - 1]]) + let keys = new Set(Object.keys(view.commandGroups)); + commandGroups = commandGroup.commandGroups ? Object.fromEntries(Object.entries(commandGroup.commandGroups).map(([key, subCg]) => { + if (keys.has(key)) { + keys.delete(key); + return [key, updateCommandGroupByModView(subCg, view.commandGroups![subCg.names[subCg.names.length - 1]])]; } else { - return value; + return [key, subCg]; } - }) + })) : undefined; if (keys.size > 0) { let commandGroupNames: string[] = []; keys.forEach(key => { @@ -457,14 +782,12 @@ function updateCommandGroupByModView(commandGroup: ProfileCTCommandGroup, view: } } - let selectedCommands = commands?.reduce((pre, value) => { return value.selectedVersion !== undefined ? pre + 1 : pre }, 0) ?? 0; - selectedCommands += commandGroups?.reduce((pre, value) => { return pre + value.selectedCommands }, 0) ?? 0; return { ...commandGroup, commands: commands, commandGroups: commandGroups, - selectedCommands: selectedCommands, waitCommand: view.waitCommand, + selected: calculateSelected(commands ?? {}, commandGroups ?? {}), } } @@ -472,14 +795,14 @@ function UpdateProfileCommandTreeByModView(tree: ProfileCommandTree, view: CLIMo let commandGroups = tree.commandGroups; if (view.commandGroups !== undefined) { let keys = new Set(Object.keys(view.commandGroups)); - commandGroups = tree.commandGroups.map((value) => { - if (keys.has(value.names[value.names.length - 1])) { - keys.delete(value.names[value.names.length - 1]) - return updateCommandGroupByModView(value, view.commandGroups![value.names[value.names.length - 1]]) + commandGroups = Object.fromEntries(Object.entries(tree.commandGroups).map(([key, value]) => { + if (keys.has(key)) { + keys.delete(key); + return [key, updateCommandGroupByModView(value, view.commandGroups![value.names[value.names.length - 1]])]; } else { - return value; + return [key, value]; } - }) + })); if (keys.size > 0) { let commandGroupNames: string[] = []; keys.forEach(key => { @@ -495,6 +818,66 @@ function UpdateProfileCommandTreeByModView(tree: ProfileCommandTree, view: CLIMo } } +async function initializeCommandGroupByModView(view: CLIModViewCommandGroup, fetchCommandGroup: (names: string[]) => Promise, fetchCommand: (names: string[]) => Promise): Promise { + let commandGroupPromise = fetchCommandGroup(view.names).then((value) => {return decodeProfileCTCommandGroup(value)}); + let viewSubGroupsPromise = Promise.all(Object.keys(view.commandGroups ?? {}).map(async (key) => { + return initializeCommandGroupByModView(view.commandGroups![key], fetchCommandGroup, fetchCommand); + })); + let viewCommandsPromise = Promise.all(Object.keys(view.commands ?? {}).map(async (key) => { + return updateCommandByModView(await fetchCommand(view.commands![key].names).then((value) => decodeProfileCTCommand(value)), view.commands![key]); + })); + let commandGroup = await commandGroupPromise; + let viewSubGroups = await viewSubGroupsPromise; + let subGroups = Object.fromEntries(Object.entries(commandGroup.commandGroups ?? {}).map(([key, value]) => { + let group = viewSubGroups.find((v) => v.id === value.id); + if (group !== undefined) { + return [key, group]; + } else { + return [key, value]; + } + })); + let viewCommands = await viewCommandsPromise; + let commands = Object.fromEntries(Object.entries(commandGroup.commands ?? {}).map(([key, value]) => { + let command = viewCommands.find((v) => v.id === value.id); + if (command !== undefined) { + return [key, command]; + } else { + return [key, value]; + } + })); + return { + ...(await commandGroupPromise), + commandGroups: subGroups, + commands: commands, + } +} + +async function InitializeCommandTreeByModView(profileName: string, view: CLIModViewProfile|null, fetchCommandGroup: (names: string[]) => Promise, fetchCommand: (names: string[]) => Promise): Promise { + let ctPromise = fetchCommandGroup([]).then((value) => {return BuildProfileCommandTree(profileName, value)}); + if (view && view.commandGroups !== undefined) { + let commandGroupsOnView = await Promise.all(Object.keys(view.commandGroups).map(async (key) => { + const value = await initializeCommandGroupByModView(view.commandGroups![key], fetchCommandGroup, fetchCommand); + return value; + })); + let commandTree = await ctPromise; + let commandGroups = Object.fromEntries(Object.entries(commandTree.commandGroups).map(([key, value]) => { + let group = commandGroupsOnView.find((v) => v.id === value.id); + if (group !== undefined) { + return [key, group]; + } else { + return [key, value]; + } + })); + commandTree = { + ...commandTree, + commandGroups: commandGroups ?? {}, + } + return UpdateProfileCommandTreeByModView(commandTree, view); + } else { + return await ctPromise; + } +} + function ExportModViewCommand(command: ProfileCTCommand): CLIModViewCommand | undefined { if (command.selectedVersion === undefined) { return undefined @@ -509,7 +892,7 @@ function ExportModViewCommand(command: ProfileCTCommand): CLIModViewCommand | un } function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModViewCommandGroup | undefined { - if (commandGroup.selectedCommands === 0) { + if (commandGroup.selected === false) { return undefined } @@ -517,7 +900,7 @@ function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModV if (commandGroup.commands !== undefined) { commands = {} - commandGroup.commands!.forEach(value => { + Object.values(commandGroup.commands!).forEach(value => { let view = ExportModViewCommand(value); if (view !== undefined) { commands![value.names[value.names.length - 1]] = view; @@ -529,7 +912,7 @@ function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModV if (commandGroup.commandGroups !== undefined) { commandGroups = {} - commandGroup.commandGroups!.forEach(value => { + Object.values(commandGroup.commandGroups!).forEach(value => { let view = ExportModViewCommandGroup(value); if (view !== undefined) { commandGroups![value.names[value.names.length - 1]] = view; @@ -548,7 +931,7 @@ function ExportModViewCommandGroup(commandGroup: ProfileCTCommandGroup): CLIModV function ExportModViewProfile(tree: ProfileCommandTree): CLIModViewProfile { let commandGroups: CLIModViewCommandGroups = {}; - tree.commandGroups.forEach(value => { + Object.values(tree.commandGroups).forEach(value => { let view = ExportModViewCommandGroup(value); if (view !== undefined) { commandGroups[value.names[value.names.length - 1]] = view; @@ -565,4 +948,4 @@ export default CLIModGeneratorProfileCommandTree; export type { ProfileCommandTree, } -export { BuildProfileCommandTree, UpdateProfileCommandTreeByModView, ExportModViewProfile } \ No newline at end of file +export { InitializeCommandTreeByModView, BuildProfileCommandTree, UpdateProfileCommandTreeByModView, ExportModViewProfile } \ No newline at end of file diff --git a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx index a128bbf0..2c5a090a 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx @@ -3,9 +3,9 @@ import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; interface CLIModGeneratorProfileTabsProps { - value: number; + value: string; profiles: string[]; - onChange: (newValue: number) => void; + onChange: (newValue: string) => void; } @@ -18,14 +18,14 @@ class CLIModGeneratorProfileTabs extends React.Component { - onChange(newValue) + onChange(newValue); }} aria-label="Vertical tabs example" sx={{ borderRight: 1, borderColor: "divider" }} > {profiles.map((profile, idx) => { - return ; + return ; })} ); diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 4362c607..e34094c9 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -16,7 +16,7 @@ import { import { useParams } from "react-router"; import axios from "axios"; import CLIModGeneratorToolBar from "./CLIModGeneratorToolBar"; -import CLIModGeneratorProfileCommandTree, { BuildProfileCommandTree, ExportModViewProfile, ProfileCommandTree, UpdateProfileCommandTreeByModView } from "./CLIModGeneratorProfileCommandTree"; +import CLIModGeneratorProfileCommandTree, { ExportModViewProfile, InitializeCommandTreeByModView, ProfileCommandTree } from "./CLIModGeneratorProfileCommandTree"; import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; import { CLIModView, CLIModViewProfiles } from "./CLIModuleCommon"; @@ -62,67 +62,48 @@ interface CLISpecsCommandGroup { commandGroups?: CLISpecsCommandGroups, } -function isCLISpecsPartialCommandGroup(obj: CLISpecsCommandGroup) { - return obj.commands === undefined || obj.commandGroups === undefined; -} - interface CLISpecsCommandGroups { - [name: string]: Promise|CLISpecsCommandGroup, + [name: string]: CLISpecsCommandGroup, } interface CLISpecsCommands { - [name: string]: Promise|CLISpecsCommand, + [name: string]: CLISpecsCommand, } -const useSpecsCommandTree = () => { - const root: Promise = axios.get('/AAZ/Specs/CommandTree/Nodes/aaz').then(res => res.data); - const commandTree = React.useRef({root: root}); - - const ensuredCgOf = async (commandGroups: CLISpecsCommandGroups, name: string) => { - let cg = commandGroups[name]; - if (cg instanceof Promise) { - return await cg; - } else if (isCLISpecsPartialCommandGroup(cg)) { - let cg_promise = axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz/${cg.names.join('/')}?limited=true`).then(res => res.data); - commandGroups[name] = cg_promise; - return await cg_promise; - } else { - return cg; - } - } - - const ensuredCommandOf = async (commands: CLISpecsCommands, name: string) => { - let command = commands[name]; - if (command instanceof Promise) { - return await command; - } else if (isCLISpecsPartialCommand(command)) { - let cg_names = command.names.slice(0, -1); - let command_name = command.names[command.names.length - 1]; - let command_promise = axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz/${cg_names.join('/')}/Leaves/${command_name}`).then(res => res.data); - commands[name] = command_promise; - return await command_promise - } else { - return command; - } - } +const useSpecsCommandTree: () => [(names: string[]) => Promise, (names: string[]) => Promise] = () => { + const commandCache = React.useRef(new Map>()); + const cgCache = React.useRef(new Map>()); const fetchCommandGroup = async (names: string[]) => { - let node = await commandTree.current.root; - for (const name of names) { - node = await ensuredCgOf(node.commandGroups!, name); + const cachedPromise = cgCache.current.get(names.join('/')); + const fullNames = ['aaz', ...names]; + const cgPromise: Promise = cachedPromise ?? axios.get(`/AAZ/Specs/CommandTree/Nodes/${fullNames.join('/')}?limited=true`).then(res => res.data); + if (!cachedPromise) { + cgCache.current.set(names.join('/'), cgPromise); } - return node; + return await cgPromise; } const fetchCommand = async (names: string[]) => { - let parent_cg = await fetchCommandGroup(names.slice(0, -1)); - return ensuredCommandOf(parent_cg.commands!, names[names.length - 1]); + const cachedPromise = commandCache.current.get(names.join('/')); + const fullNames = ['aaz', ...names]; + const cgNames = fullNames.slice(0, -1); + const commandName = fullNames[fullNames.length - 1]; + const commandPromise: Promise = cachedPromise ?? axios.get(`/AAZ/Specs/CommandTree/Nodes/${cgNames.join('/')}/Leaves/${commandName}`).then(res => res.data); + if (!cachedPromise) { + commandCache.current.set(names.join('/'), commandPromise); + } + return await commandPromise; } return [fetchCommandGroup, fetchCommand]; } +interface ProfileCommandTrees { + [name: string]: ProfileCommandTree, +} + interface CLIModuleGeneratorProps { params: { repoName: string; @@ -134,9 +115,10 @@ const CLIModuleGenerator: React.FC = ({ params }) => { const [loading, setLoading] = React.useState(false); const [invalidText, setInvalidText] = React.useState(undefined); const [profiles, setProfiles] = React.useState([]); - const [commandTrees, setCommandTrees] = React.useState([]); - const [selectedProfileIdx, setSelectedProfileIdx] = React.useState(undefined); + const [commandTrees, setCommandTrees] = React.useState({}); + const [selectedProfile, setSelectedProfile] = React.useState(undefined); const [showGenerateDialog, setShowGenerateDialog] = React.useState(false); + const [modView, setModView] = React.useState(undefined); const [fetchCommandGroup, fetchCommand] = useSpecsCommandTree(); @@ -147,27 +129,26 @@ const CLIModuleGenerator: React.FC = ({ params }) => { const loadModule = async () => { try { setLoading(true); - let res = await axios.get(`/CLI/Az/Profiles`); - let profiles: string[] = res.data; - - res = await axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz`); - let commandTrees: ProfileCommandTree[] = profiles.map((profileName) => BuildProfileCommandTree(profileName, res.data)); + const profiles: string[] = await axios.get(`/CLI/Az/Profiles`).then(res => res.data); - res = await axios.get(`/CLI/Az/${params.repoName}/Modules/${params.moduleName}`); - let modView: CLIModView = res.data; + const modView: CLIModView = await axios.get(`/CLI/Az/${params.repoName}/Modules/${params.moduleName}`).then(res => res.data); - Object.keys(modView.profiles).forEach((profile) => { + Object.keys(modView!.profiles).forEach((profile) => { let idx = profiles.findIndex(v => v === profile); if (idx === -1) { throw new Error(`Invalid profile ${profile}`); } - commandTrees[idx] = UpdateProfileCommandTreeByModView(commandTrees[idx], modView.profiles[profile]); }); - let selectedProfileIdx = profiles.length > 0 ? 0 : undefined; + const commandTrees = Object.fromEntries(await Promise.all(profiles.map(async (profile) => { + return InitializeCommandTreeByModView(profile, modView!.profiles[profile] ?? null, fetchCommandGroup, fetchCommand).then(tree => [profile, tree] as [string, ProfileCommandTree]); + }))); + + const selectedProfile = profiles.length > 0 ? profiles[0] : undefined; + setModView(modView); setProfiles(profiles); setCommandTrees(commandTrees); - setSelectedProfileIdx(selectedProfileIdx); + setSelectedProfile(selectedProfile); setLoading(false); } catch (err: any) { console.error(err); @@ -181,7 +162,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { } }; - const selectedCommandTree = selectedProfileIdx ? commandTrees[selectedProfileIdx] : undefined; + const selectedCommandTree = selectedProfile ? commandTrees[selectedProfile] : undefined; const handleBackToHomepage = () => { window.open('/?#/cli', "_blank"); @@ -195,12 +176,16 @@ const CLIModuleGenerator: React.FC = ({ params }) => { setShowGenerateDialog(false); }; - const onProfileChange = (selectedIdx: number) => { - setSelectedProfileIdx(selectedIdx); + const onProfileChange = (selectedProfile: string) => { + setSelectedProfile(selectedProfile); }; - const onSelectedProfileTreeUpdate = (newTree: ProfileCommandTree) => { - setCommandTrees(commandTrees.map((value, idx) => (idx === selectedProfileIdx ? newTree : value))); + const onSelectedProfileTreeUpdate = (updater: ((newTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => { + setCommandTrees((commandTrees) => { + const selectedCommandTree = commandTrees[selectedProfile!]; + const newTree = typeof updater === 'function' ? updater(selectedCommandTree!) : updater; + return { ...commandTrees, [selectedProfile!]: newTree } + }); }; return ( @@ -220,9 +205,9 @@ const CLIModuleGenerator: React.FC = ({ params }) => { }} > - {selectedProfileIdx !== undefined && ( + {selectedProfile !== undefined && ( @@ -240,6 +225,8 @@ const CLIModuleGenerator: React.FC = ({ params }) => { )} @@ -286,7 +273,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { function GenerateDialog(props: { repoName: string; moduleName: string; - profileCommandTrees: ProfileCommandTree[]; + profileCommandTrees: ProfileCommandTrees; open: boolean; onClose: (generated: boolean) => void; }) { @@ -301,7 +288,7 @@ function GenerateDialog(props: { const handleGenerateAll = () => { const profiles: CLIModViewProfiles = {}; - props.profileCommandTrees.forEach(tree => { + Object.values(props.profileCommandTrees).forEach(tree => { profiles[tree.name] = ExportModViewProfile(tree); }) const data = { @@ -333,7 +320,7 @@ function GenerateDialog(props: { const handleGenerateModified = () => { const profiles: CLIModViewProfiles = {}; - props.profileCommandTrees.forEach(tree => { + Object.values(props.profileCommandTrees).forEach(tree => { profiles[tree.name] = ExportModViewProfile(tree); }) const data = { @@ -391,4 +378,5 @@ const CLIModuleGeneratorWrapper = (props: any) => { return } +export type { CLISpecsCommandGroup, CLISpecsCommand }; export { CLIModuleGeneratorWrapper as CLIModuleGenerator }; From 32cf0d1823930e893a1cab20218ead38c29f1cc1 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Fri, 27 Sep 2024 17:51:25 +0800 Subject: [PATCH 05/24] Fix style --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 22 ++----------------- .../views/cli/CLIModGeneratorProfileTabs.tsx | 2 +- src/web/src/views/cli/CLIModuleGenerator.tsx | 6 ----- 3 files changed, 3 insertions(+), 27 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 11bd81db..e9afd1d4 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -90,7 +90,7 @@ const CommandItem: React.FC = ({ justifyContent: "center", }}> {!command.modified && command.selectedVersion !== undefined && { + onClick={(_event) => { onSelectCommand(command.names, true); }} > @@ -505,24 +505,6 @@ function findCommandGroup(tree: ProfileCommandTree, names: string[]): ProfileCTC return cg as ProfileCTCommandGroup; } -function findCommand(tree: ProfileCommandTree, names: string[]): ProfileCTCommand | undefined { - if (names.length === 0) { - return undefined; - } - let cg: ProfileCTCommandGroup | ProfileCommandTree = tree; - for (const name of names.slice(0, -1)) { - if (cg.commandGroups === undefined) { - return undefined; - } - cg = cg.commandGroups[name] as ProfileCTCommandGroup; - } - cg = cg as ProfileCTCommandGroup; - if (cg.commands === undefined) { - return undefined; - } - return cg.commands[names[names.length - 1]]; -} - function loadCommand(command: ProfileCTCommand, fetchCommand: (names: string[]) => Promise, onLoadedCommand: (command: CLISpecsCommand) => void): ProfileCTCommand | undefined { if (isUnloadedCommand(command)) { fetchCommand(command.names).then(onLoadedCommand); @@ -947,4 +929,4 @@ export default CLIModGeneratorProfileCommandTree; export type { ProfileCommandTree, } -export { InitializeCommandTreeByModView, BuildProfileCommandTree, UpdateProfileCommandTreeByModView, ExportModViewProfile } \ No newline at end of file +export { InitializeCommandTreeByModView, BuildProfileCommandTree, UpdateProfileCommandTreeByModView, ExportModViewProfile } diff --git a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx index e7134876..7cbd5ff4 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileTabs.tsx @@ -23,7 +23,7 @@ class CLIModGeneratorProfileTabs extends React.Component - {profiles.map((profile, idx) => { + {profiles.map((profile, _idx) => { return ; })} diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 7165cfaf..8073cf4e 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -51,10 +51,6 @@ interface CLISpecsCommand { versions?: CLISpecsCommandVersion[], } -function isCLISpecsPartialCommand(obj: CLISpecsCommand) { - return obj.versions === undefined; -} - interface CLISpecsCommandGroup { names: string[], help?: CLISpecsHelp, @@ -118,7 +114,6 @@ const CLIModuleGenerator: React.FC = ({ params }) => { const [commandTrees, setCommandTrees] = React.useState({}); const [selectedProfile, setSelectedProfile] = React.useState(undefined); const [showGenerateDialog, setShowGenerateDialog] = React.useState(false); - const [modView, setModView] = React.useState(undefined); const [fetchCommandGroup, fetchCommand] = useSpecsCommandTree(); @@ -145,7 +140,6 @@ const CLIModuleGenerator: React.FC = ({ params }) => { }))); const selectedProfile = profiles.length > 0 ? profiles[0] : undefined; - setModView(modView); setProfiles(profiles); setCommandTrees(commandTrees); setSelectedProfile(selectedProfile); From 649a2217eb927703f69a019d3dd932b211e2cea4 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Thu, 24 Oct 2024 15:24:46 +0800 Subject: [PATCH 06/24] New api to get simple tree --- src/aaz_dev/app/run.py | 4 +- src/aaz_dev/command/api/specs.py | 10 ++++ .../command/controller/command_tree.py | 51 +++++++++++++++++++ .../command/model/specs/_command_tree.py | 33 ++++++++++++ .../tests/spec_tests/test_command_tree.py | 8 ++- 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/aaz_dev/app/run.py b/src/aaz_dev/app/run.py index 49895438..0c26116d 100644 --- a/src/aaz_dev/app/run.py +++ b/src/aaz_dev/app/run.py @@ -3,7 +3,7 @@ import click from flask.cli import pass_script_info, show_server_banner, SeparatedPathType -from flask.helpers import get_debug_flag +from flask.helpers import get_debug_flag, get_env from utils.config import Config @@ -147,7 +147,7 @@ def run_command( if debugger is None: debugger = debug - show_server_banner(debug, info.app_import_path) + show_server_banner(get_env(), debug, info.app_import_path, None) app = info.load_app() if is_port_in_use(host, port): diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 73788eab..3f775c69 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -9,6 +9,16 @@ bp = Blueprint('specs', __name__, url_prefix='/AAZ/Specs') +# modules +@bp.route("/CommandTree/Simple", methods=("GET",)) +def command_tree_node(): + manager = AAZSpecsManager() + tree = manager.tree.simple_tree + if not tree: + raise exceptions.ResourceNotFind("Command group not exist") + return jsonify(tree) + + # modules @bp.route("/CommandTree/Nodes/", methods=("GET",)) def command_tree_node(node_names): diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index fe4a3725..cabd668e 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -6,11 +6,55 @@ from command.model.configuration import CMDHelp, CMDCommandExample from command.model.specs import CMDSpecsCommandGroup, CMDSpecsCommand, CMDSpecsResource, CMDSpecsCommandVersion, \ CMDSpecsCommandTree +from command.model.specs._command_tree import CMDSpecsSimpleCommand, CMDSpecsSimpleCommandGroup, \ + CMDSpecsSimpleCommandTree from utils import exceptions logger = logging.getLogger(__name__) +def _build_simple_command(names): + # uri = '/Commands/' + '/'.join(names[:-1]) + f'/_{names[-1]}.md' + command = CMDSpecsSimpleCommand() + command.names = names + return command + + +def _build_simple_command_group(names, aaz_path): + """ + Build Simple Command Group from directory + """ + rel_names = names + if len(names) == 1 and names[0] == 'aaz': + rel_names = [] + # uri = '/Commands/' + '/'.join(rel_names) + f'/readme.md' + full_path = os.path.join(aaz_path, 'Commands', *rel_names) + commands = {} + command_groups = {} + for dir in os.listdir(full_path): + if os.path.isfile(os.path.join(full_path, dir)): + if dir == 'readme.md' or dir == 'tree.json': + continue + command_name = dir[1:-3] + commands[command_name] = _build_simple_command(rel_names + [command_name]) + else: + cg_name = dir + command_groups[cg_name] = _build_simple_command_group(rel_names + [cg_name], aaz_path) + cg = CMDSpecsSimpleCommandGroup() + cg.names = names + cg.commands = commands + cg.command_groups = command_groups + return cg + + +def build_simple_command_tree(aaz_path): + root = _build_simple_command_group(['aaz'], aaz_path) + tree = CMDSpecsSimpleCommandTree() + tree.root = root + tree.validate() + return tree + + class CMDSpecsPartialCommandGroup: def __init__(self, names, short_help, uri, aaz_path): self.names = names @@ -266,6 +310,13 @@ def root(self): self._root = self._root.load() return self._root + @property + def simple_tree(self): + """ + Build and Return a Simple Command Tree from Folder Structure + """ + return build_simple_command_tree(self.aaz_path) + def find_command_group(self, *cg_names): """ Find command group node by names diff --git a/src/aaz_dev/command/model/specs/_command_tree.py b/src/aaz_dev/command/model/specs/_command_tree.py index 9bdcc202..14479806 100644 --- a/src/aaz_dev/command/model/specs/_command_tree.py +++ b/src/aaz_dev/command/model/specs/_command_tree.py @@ -7,6 +7,39 @@ from ._resource import CMDSpecsResource +class CMDSpecsSimpleCommand(Model): + names = ListType(field=CMDCommandNameField(), min_size=1, required=True) # full name of a command + + class Options: + serialize_when_none = False + + +class CMDSpecsSimpleCommandGroup(Model): + names = ListType(field=CMDCommandNameField(), min_size=1, required=True) # full name of a command group + command_groups = DictType( + field=ModelType("CMDSpecsSimpleCommandGroup"), + serialized_name="commandGroups", + deserialize_from="commandGroups", + export_level=NOT_NONE, + ) + commands = DictType( + field=ModelType(CMDSpecsSimpleCommand), + export_level=NOT_NONE, + ) + + class Options: + serialize_when_none = False + + +class CMDSpecsSimpleCommandTree(Model): + root = ModelType( + CMDSpecsSimpleCommandGroup + ) # the root node + + class Options: + serialize_when_none = False + + class CMDSpecsCommandVersion(Model): name = CMDVersionField(required=True) stage = CMDStageField() diff --git a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py index eb2f6f9f..d93f9f03 100644 --- a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py +++ b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py @@ -2,7 +2,7 @@ import unittest from command.controller.command_tree import CMDSpecsPartialCommand, CMDSpecsPartialCommandGroup, \ - CMDSpecsPartialCommandTree, to_limited_primitive + CMDSpecsPartialCommandTree, to_limited_primitive, build_simple_command_tree from command.model.configuration import CMDHelp COMMAND_INFO = """# [Command] _vm deallocate_ @@ -315,3 +315,9 @@ def test_partial_command_group_to_primitive(self): self.assertListEqual(primitive['names'], cg.names) self.assertEqual(primitive['help']['short'], cg.help.short) self.assertIsInstance(cg.command_groups.get_raw_item('report'), CMDSpecsPartialCommandGroup) + + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") + def test_simple_command_tree(self): + aaz_folder = os.getenv("AAZ_FOLDER") + simple_tree = build_simple_command_tree(aaz_folder) + print() From 3b815f497d745ff6e88b348b3c952e72b31015eb Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Mon, 28 Oct 2024 14:54:46 +0800 Subject: [PATCH 07/24] Add Simple Command Tree --- src/aaz_dev/app/run.py | 4 +- src/aaz_dev/command/api/specs.py | 3 +- .../cli/CLIModGeneratorProfileCommandTree.tsx | 458 ++++++++---------- src/web/src/views/cli/CLIModuleGenerator.tsx | 48 +- 4 files changed, 244 insertions(+), 269 deletions(-) diff --git a/src/aaz_dev/app/run.py b/src/aaz_dev/app/run.py index 0c26116d..49895438 100644 --- a/src/aaz_dev/app/run.py +++ b/src/aaz_dev/app/run.py @@ -3,7 +3,7 @@ import click from flask.cli import pass_script_info, show_server_banner, SeparatedPathType -from flask.helpers import get_debug_flag, get_env +from flask.helpers import get_debug_flag from utils.config import Config @@ -147,7 +147,7 @@ def run_command( if debugger is None: debugger = debug - show_server_banner(get_env(), debug, info.app_import_path, None) + show_server_banner(debug, info.app_import_path) app = info.load_app() if is_port_in_use(host, port): diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 3f775c69..8401158d 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -11,11 +11,12 @@ # modules @bp.route("/CommandTree/Simple", methods=("GET",)) -def command_tree_node(): +def simple_command_tree(): manager = AAZSpecsManager() tree = manager.tree.simple_tree if not tree: raise exceptions.ResourceNotFind("Command group not exist") + tree = tree.to_primitive() return jsonify(tree) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index e9afd1d4..08549af8 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -8,7 +8,7 @@ import FolderIcon from "@mui/icons-material/Folder"; import EditIcon from '@mui/icons-material/Edit'; import { Box, Checkbox, FormControl, Typography, Select, MenuItem, styled, TypographyProps, InputLabel, IconButton } from "@mui/material"; import { CLIModViewCommand, CLIModViewCommandGroup, CLIModViewCommandGroups, CLIModViewCommands, CLIModViewProfile } from "./CLIModuleCommon"; -import { CLISpecsCommand, CLISpecsCommandGroup } from "./CLIModuleGenerator"; +import { CLISpecsCommand, CLISpecsCommandGroup, CLISpecsSimpleCommand, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommandTree } from "./CLIModuleGenerator"; const CommandGroupTypography = styled(Typography)(({ theme }) => ({ color: theme.palette.primary.main, @@ -39,20 +39,78 @@ const UnregisteredTypography = styled(SelectionTypography)(() = })) +function useBatchedUpdate(batchedUpdater: (states: T[]) => void, delay: number) { + const [states, setStates] = React.useState([]); + const timeoutRef = React.useRef(null); + + const batchedCallback = React.useCallback((state: T) => { + setStates((prev) => [...prev, state]); + }, []); + + React.useEffect(() => { + if (states.length > 0) { + timeoutRef.current = setTimeout(() => { + batchedUpdater(states); + setStates([]); + }, delay); + } + + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + } + }, [states, batchedUpdater, delay]); + + return batchedCallback; +} + + interface CommandItemProps { command: ProfileCTCommand, - onSelectCommand: (names: string[], selected: boolean) => void, - onSelectCommandVersion: (names: string[], version: string) => void, - onSelectCommandRegistered: (names: string[], registered: boolean) => void, + onUpdateCommand: (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => void, } -const CommandItem: React.FC = ({ +const CommandItem: React.FC = React.memo(({ command, - onSelectCommand, - onSelectCommandVersion, - onSelectCommandRegistered, + onUpdateCommand, }) => { const leafName = command.names[command.names.length - 1]; + + React.useEffect(() =>{if (command.selected === true && command.versions === undefined && command.loading === false) { + }}, []) + + const selectCommand = React.useCallback((selected: boolean) => { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + selected: selected, + selectedVersion: selected ? (oldCommand.selectedVersion ? oldCommand.selectedVersion : (oldCommand.versions ? oldCommand.versions[0].name : undefined)) : oldCommand.selectedVersion, + modified: true, + } + }); + }, []); + + const selectVersion = React.useCallback((version: string) => { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + selectedVersion: version, + modified: true, + } + }); + }, []); + + const selectRegistered = React.useCallback((registered: boolean) => { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + registered: registered, + modified: true, + } + }); + }, []); + return ( = ({ disableRipple checked={command.selected} onClick={(event) => { - onSelectCommand(command.names, !command.selected); + selectCommand(!command.selected); event.stopPropagation(); event.preventDefault(); }} @@ -91,7 +149,7 @@ const CommandItem: React.FC = ({ }}> {!command.modified && command.selectedVersion !== undefined && { - onSelectCommand(command.names, true); + selectCommand(true); }} > @@ -99,7 +157,7 @@ const CommandItem: React.FC = ({ {command.modified && } - {command.selectedVersion !== undefined && = ({ id={`${command.id}-version-select`} value={command.selectedVersion} onChange={(event) => { - onSelectCommandVersion(command.names, event.target.value); + selectVersion(event.target.value); }} size="small" > @@ -135,7 +193,7 @@ const CommandItem: React.FC = ({ id={`${command.id}-register-select`} value={command.registered ? 1 : 0} onChange={(event) => { - onSelectCommandRegistered(command.names, event.target.value === 1); + selectRegistered(event.target.value === 1); }} size="small" > @@ -164,27 +222,85 @@ const CommandItem: React.FC = ({ }} /> ); -}; +}); interface CommandGroupItemProps { commandGroup: ProfileCTCommandGroup, - onSelectCommandGroup: (names: string[], selected: boolean) => void, - onToggleCommandGroupExpanded: (cnames: string[]) => void, - onSelectCommand: (names: string[], selected: boolean) => void, - onSelectCommandVersion: (names: string[], version: string) => void, - onSelectCommandRegistered: (names: string[], registered: boolean) => void, + onUpdateCommandGroup: (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => void, } -const CommandGroupItem: React.FC = ({ +const CommandGroupItem: React.FC = React.memo(({ commandGroup, - onSelectCommandGroup, - onToggleCommandGroupExpanded, - onSelectCommand, - onSelectCommandVersion, - onSelectCommandRegistered, + onUpdateCommandGroup, }) => { const nodeName = commandGroup.names[commandGroup.names.length - 1]; const selected = commandGroup.selected ?? false; + + console.log("Rendering Command Group: ", commandGroup.id); + + const onUpdateCommand = React.useCallback((name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + const commands = { + ...oldCommandGroup.commands, + [name]: updater(oldCommandGroup.commands![name]), + }; + const selected = calculateSelected(commands, oldCommandGroup.commandGroups ?? {}); + return { + ...oldCommandGroup, + commands: commands, + selected: selected, + } + }); + }, []); + + const onUpdateSubCommandGroup = React.useCallback((name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + const commandGroups = { + ...oldCommandGroup.commandGroups, + [name]: updater(oldCommandGroup.commandGroups![name]), + } + const commands = oldCommandGroup.commands; + const selected = calculateSelected(commands ?? {}, commandGroups); + return { + ...oldCommandGroup, + commandGroups: commandGroups, + selected: selected, + }; + }); + }, []); + + const updateCommandSelected = (command: ProfileCTCommand, selected: boolean): ProfileCTCommand => { + if (selected === command.selected) { + return command; + } + return { + ...command, + selected: selected, + selectedVersion: selected ? (command.selectedVersion ? command.selectedVersion : (command.versions ? command.versions[0].name : undefined)) : command.selectedVersion, + modified: true, + } + }; + + const updateGroupSelected = (group: ProfileCTCommandGroup, selected: boolean): ProfileCTCommandGroup => { + if (selected === group.selected) { + return group; + } + const commands = group.commands ? Object.fromEntries(Object.entries(group.commands).map(([key, value]) => [key, updateCommandSelected(value, selected)]) ) : undefined; + const commandGroups = group.commandGroups ? Object.fromEntries(Object.entries(group.commandGroups).map(([key, value]) => [key, updateGroupSelected(value, selected)]) ) : undefined; + return { + ...group, + commands: commands, + commandGroups: commandGroups, + selected: selected, + } + } + + const selectCommandGroup = React.useCallback((names: string[], selected: boolean) => { + onUpdateCommandGroup(nodeName, (oldCommandGroup) => { + return updateGroupSelected(oldCommandGroup, selected); + }); + }, []); + return ( = ({ checked={commandGroup.selected !== false} indeterminate={commandGroup.selected === undefined} onClick={(event) => { - onSelectCommandGroup(commandGroup.names, !selected); + selectCommandGroup(commandGroup.names, !selected); event.stopPropagation(); event.preventDefault(); }} @@ -206,94 +322,61 @@ const CommandGroupItem: React.FC = ({ {nodeName} } - onClick={(event) => { - onToggleCommandGroupExpanded(commandGroup.names); - event.stopPropagation(); - event.preventDefault(); - }} > {commandGroup.commands !== undefined && Object.values(commandGroup.commands).map((command) => ( ))} {commandGroup.commandGroups !== undefined && Object.values(commandGroup.commandGroups).map((group) => ( ))} - {commandGroup.loading === true && } ); -}; - -const LoadingItem: React.FC<{ name: string }> = ({ name }) => { - return ( - - Loading {name}... - - } />) -} +}); interface CLIModGeneratorProfileCommandTreeProps { + profile?: string, profileCommandTree: ProfileCommandTree, - onChange: (updater: ((newProfileCommandTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => void, + onChange: (updater: ((oldProfileCommandTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => void, onLoadCommandGroup: (names: string[]) => Promise, onLoadCommand: (names: string[]) => Promise, } const CLIModGeneratorProfileCommandTree: React.FC = ({ + profile, profileCommandTree, onChange, onLoadCommandGroup, onLoadCommand, }) => { const [expanded, setExpanded] = React.useState([]); - console.log("Rerender using ProfileCommandTree: ", profileCommandTree); - console.log("Rerender using Expanded State: ", expanded); React.useEffect(() => { setExpanded(GetDefaultExpanded(profileCommandTree)); - }, []); + }, [profile]); - const onSelectCommandGroup = (names: string[], selected: boolean) => { - onChange((profileCommandTree) => { - const newTree = updateProfileCommandTree(profileCommandTree, names, selected) - return genericUpdateCommandGroup(newTree, names, (commandGroup) => { - return loadAllNextLevel(commandGroup, onLoadCommand, onLoadCommandGroup, onLoadedCommand, onLoadedCommandGroup); - }) ?? newTree; - }); + const handleToggle = (_event: React.ChangeEvent<{}>, nodeIds: string[]) => { + setExpanded(nodeIds); }; - const onSelectCommand = (names: string[], selected: boolean) => { + const onUpdateCommandGroup = React.useCallback((name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { onChange((profileCommandTree) => { - const newTree = updateProfileCommandTree(profileCommandTree, names, selected); - return genericUpdateCommand(newTree, names, (command) => loadCommand(command, onLoadCommand, onLoadedCommand)) ?? newTree; + return { + ...profileCommandTree, + commandGroups: { + ...profileCommandTree.commandGroups, + [name]: updater(profileCommandTree.commandGroups[name]), + } + } }); - }; - - const onSelectCommandVersion = (names: string[], version: string) => { - onChange((profileCommandTree) => updateProfileCommandTree(profileCommandTree, names, true, version)); - }; - - const onSelectCommandRegistered = (names: string[], registered: boolean) => { - onChange((profileCommandTree) => updateProfileCommandTree(profileCommandTree, names, true, undefined, registered)); - }; + }, []); const onLoadedCommandGroup = React.useCallback((commandGroup: CLISpecsCommandGroup) => { const names = commandGroup.names; @@ -301,6 +384,7 @@ const CLIModGeneratorProfileCommandTree: React.FC { const newCommandGroup = decodeProfileCTCommandGroup(commandGroup, unloadedCommandGroup.selected) if (newCommandGroup.selected) { + // TODO: return loadAllNextLevel(newCommandGroup, onLoadCommand, onLoadCommandGroup, onLoadedCommand, onLoadedCommandGroup); } return newCommandGroup @@ -308,43 +392,25 @@ const CLIModGeneratorProfileCommandTree: React.FC { - const names = command.names; + const handleBatchedLoadedCommand = React.useCallback((commands: CLISpecsCommand[]) => { onChange((profileCommandTree) => { - return genericUpdateCommand(profileCommandTree, names, (unloadedCommand) => { - return decodeProfileCTCommand(command, unloadedCommand.selected, unloadedCommand.modified); - })!; - }); - }, [onChange]); - - const onToggleCommandGroupExpanded = (names: string[]) => { - console.log("onToggleCommandGroupExpanded", names); - const commandGroup = findCommandGroup(profileCommandTree, names); - setExpanded((prev) => { - console.log("Change Expaned of ", commandGroup); - console.log("Prev Expanded", prev); - if (prev.includes(commandGroup!.id)) { - return prev.filter((value) => value !== commandGroup!.id); - } else { - return [...prev, commandGroup!.id]; - } - }); - - if (!expanded.includes(commandGroup!.id)) { - onChange((profileCommandTree) => - genericUpdateCommandGroup(profileCommandTree, names, (commandGroup) => { - return loadCommandGroup(commandGroup, onLoadCommandGroup, onLoadedCommandGroup); - }) ?? profileCommandTree - ); - } - }; + return commands.reduce((tree, command) => { + return genericUpdateCommand(tree, command.names, (unloadedCommand) => { + return decodeProfileCTCommand(command, unloadedCommand.selected, unloadedCommand.modified); + }) ?? tree; + }, profileCommandTree); + }) + }, []); + const onLoadedCommand = useBatchedUpdate(handleBatchedLoadedCommand, 100); return ( } defaultExpandIcon={} > @@ -352,11 +418,7 @@ const CLIModGeneratorProfileCommandTree: React.FC ))} @@ -380,7 +442,9 @@ interface ProfileCTCommands { interface ProfileCTCommandGroup { id: string; names: string[]; - help: string; + // We use simple command tree now. + // `help` is not used. + // help: string; commandGroups?: ProfileCTCommandGroups; commands?: ProfileCTCommands; @@ -397,7 +461,7 @@ function isUnloadedCommandGroup(commandGroup: ProfileCTCommandGroup): boolean { interface ProfileCTCommand { id: string; names: string[]; - help: string; + // help: string; versions?: ProfileCTCommandVersion[]; @@ -431,7 +495,7 @@ function decodeProfileCTCommand(response: CLISpecsCommand, selected: boolean = f const command = { id: response.names.join('/'), names: [...response.names], - help: response.help.short, + // help: response.help.short, versions: versions, modified: modified, loading: false, @@ -458,7 +522,7 @@ function decodeProfileCTCommandGroup(response: CLISpecsCommandGroup, selected: b return { id: response.names.join('/'), names: [...response.names], - help: response.help?.short ?? '', + // help: response.help?.short ?? '', commandGroups: commandGroups, commands: commands, loading: false, @@ -707,155 +771,41 @@ function updateProfileCommandTree(tree: ProfileCommandTree, names: string[], sel } } -function updateCommandByModView(command: ProfileCTCommand, view: CLIModViewCommand): ProfileCTCommand { - if (command.id !== view.names.join('/')) { - throw new Error("Invalid command names: " + view.names.join(' ')) - } +function initializeCommandByModView(view: CLIModViewCommand | undefined, simpleCommand: CLISpecsSimpleCommand): ProfileCTCommand { return { - ...command, - selectedVersion: view.version, - registered: view.registered, - selected: view.version !== undefined, - } -} - -function updateCommandGroupByModView(commandGroup: ProfileCTCommandGroup, view: CLIModViewCommandGroup): ProfileCTCommandGroup { - if (commandGroup.id !== view.names.join('/')) { - throw new Error("Invalid command group names: " + view.names.join(' ')) - } - let commands = commandGroup.commands; - if (view.commands !== undefined) { - const keys = new Set(Object.keys(view.commands)); - commands = commandGroup.commands ? Object.fromEntries(Object.entries(commandGroup.commands).map(([key, value]) => { - if (keys.has(key)) { - keys.delete(key); - return [key, updateCommandByModView(value, view.commands![key])]; - } else { - return [key, value]; - } - })) : undefined; - if (keys.size > 0) { - const commandNames: string[] = []; - keys.forEach(key => { - commandNames.push('`az ' + view.commands![key].names.join(" ") + '`') - }) - throw new Error("Miss commands in aaz: " + commandNames.join(', ')) - } + id: simpleCommand.names.join('/'), + names: simpleCommand.names, + modified: false, + loading: true, + selected: view !== undefined && view.version !== undefined, + selectedVersion: view !== undefined ? view.version : undefined, + registered: view !== undefined ? view.registered : undefined, } +} - let commandGroups = commandGroup.commandGroups; - if (view.commandGroups !== undefined) { - const keys = new Set(Object.keys(view.commandGroups)); - commandGroups = commandGroup.commandGroups ? Object.fromEntries(Object.entries(commandGroup.commandGroups).map(([key, subCg]) => { - if (keys.has(key)) { - keys.delete(key); - return [key, updateCommandGroupByModView(subCg, view.commandGroups![subCg.names[subCg.names.length - 1]])]; - } else { - return [key, subCg]; - } - })) : undefined; - if (keys.size > 0) { - const commandGroupNames: string[] = []; - keys.forEach(key => { - commandGroupNames.push('`az ' + view.commandGroups![key].names.join(" ") + '`') - }) - throw new Error("Miss command groups in aaz: " + commandGroupNames.join(', ')) - } +function initializeCommandGroupByModView(view: CLIModViewCommandGroup | undefined, simpleCommandGroup: CLISpecsSimpleCommandGroup): ProfileCTCommandGroup { + if (simpleCommandGroup.names === undefined) { + console.log("simpleCommandGroup", simpleCommandGroup); } - + const commands = simpleCommandGroup.commands !== undefined ? Object.fromEntries(Object.entries(simpleCommandGroup.commands).map(([key, value]) => [key, initializeCommandByModView(view?.commands?.[key], value)]) ) : undefined; + const commandGroups = simpleCommandGroup.commandGroups !== undefined ? Object.fromEntries(Object.entries(simpleCommandGroup.commandGroups).map(([key, value]) => [key, initializeCommandGroupByModView(view?.commandGroups?.[key], value)]) ) : undefined; + const selected = calculateSelected(commands ?? {}, commandGroups ?? {}); return { - ...commandGroup, + id: simpleCommandGroup.names.join('/'), + names: simpleCommandGroup.names, commands: commands, commandGroups: commandGroups, - waitCommand: view.waitCommand, - selected: calculateSelected(commands ?? {}, commandGroups ?? {}), - } -} - -function UpdateProfileCommandTreeByModView(tree: ProfileCommandTree, view: CLIModViewProfile): ProfileCommandTree { - let commandGroups = tree.commandGroups; - if (view.commandGroups !== undefined) { - const keys = new Set(Object.keys(view.commandGroups)); - commandGroups = Object.fromEntries(Object.entries(tree.commandGroups).map(([key, value]) => { - if (keys.has(key)) { - keys.delete(key); - return [key, updateCommandGroupByModView(value, view.commandGroups![value.names[value.names.length - 1]])]; - } else { - return [key, value]; - } - })); - if (keys.size > 0) { - const commandGroupNames: string[] = []; - keys.forEach(key => { - commandGroupNames.push('`az ' + view.commandGroups![key].names.join(" ") + '`') - }) - throw new Error("Miss command groups in aaz: " + commandGroupNames.join(', ')) - } - } - - return { - ...tree, - commandGroups: commandGroups + waitCommand: view?.waitCommand, + loading: false, + selected: selected, } } -async function initializeCommandGroupByModView(view: CLIModViewCommandGroup, fetchCommandGroup: (names: string[]) => Promise, fetchCommand: (names: string[]) => Promise): Promise { - let commandGroupPromise = fetchCommandGroup(view.names).then((value) => {return decodeProfileCTCommandGroup(value)}); - let viewSubGroupsPromise = Promise.all(Object.keys(view.commandGroups ?? {}).map(async (key) => { - return initializeCommandGroupByModView(view.commandGroups![key], fetchCommandGroup, fetchCommand); - })); - let viewCommandsPromise = Promise.all(Object.keys(view.commands ?? {}).map(async (key) => { - return updateCommandByModView(await fetchCommand(view.commands![key].names).then((value) => decodeProfileCTCommand(value)), view.commands![key]); - })); - let commandGroup = await commandGroupPromise; - let viewSubGroups = await viewSubGroupsPromise; - let subGroups = Object.fromEntries(Object.entries(commandGroup.commandGroups ?? {}).map(([key, value]) => { - let group = viewSubGroups.find((v) => v.id === value.id); - if (group !== undefined) { - return [key, group]; - } else { - return [key, value]; - } - })); - let viewCommands = await viewCommandsPromise; - let commands = Object.fromEntries(Object.entries(commandGroup.commands ?? {}).map(([key, value]) => { - let command = viewCommands.find((v) => v.id === value.id); - if (command !== undefined) { - return [key, command]; - } else { - return [key, value]; - } - })); +function InitializeCommandTreeByModView(profileName: string, view: CLIModViewProfile | null, simpleTree: CLISpecsSimpleCommandTree): ProfileCommandTree { + const commandGroups = Object.fromEntries(Object.entries(simpleTree.root.commandGroups).map(([key, value]) => [key, initializeCommandGroupByModView(view?.commandGroups?.[key], value)])); return { - ...(await commandGroupPromise), - commandGroups: subGroups, - commands: commands, - } -} - -async function InitializeCommandTreeByModView(profileName: string, view: CLIModViewProfile|null, fetchCommandGroup: (names: string[]) => Promise, fetchCommand: (names: string[]) => Promise): Promise { - let ctPromise = fetchCommandGroup([]).then((value) => {return BuildProfileCommandTree(profileName, value)}); - if (view && view.commandGroups !== undefined) { - let commandGroupsOnView = await Promise.all(Object.keys(view.commandGroups).map(async (key) => { - const value = await initializeCommandGroupByModView(view.commandGroups![key], fetchCommandGroup, fetchCommand); - return value; - })); - let commandTree = await ctPromise; - let commandGroups = Object.fromEntries(Object.entries(commandTree.commandGroups).map(([key, value]) => { - let group = commandGroupsOnView.find((v) => v.id === value.id); - if (group !== undefined) { - return [key, group]; - } else { - return [key, value]; - } - })); - commandTree = { - ...commandTree, - commandGroups: commandGroups ?? {}, - } - return UpdateProfileCommandTreeByModView(commandTree, view); - } else { - return await ctPromise; + name: profileName, + commandGroups: commandGroups, } } @@ -929,4 +879,4 @@ export default CLIModGeneratorProfileCommandTree; export type { ProfileCommandTree, } -export { InitializeCommandTreeByModView, BuildProfileCommandTree, UpdateProfileCommandTreeByModView, ExportModViewProfile } +export { InitializeCommandTreeByModView, BuildProfileCommandTree, ExportModViewProfile } diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 8073cf4e..76ac47fd 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -20,6 +20,27 @@ import CLIModGeneratorProfileCommandTree, { ExportModViewProfile, InitializeComm import CLIModGeneratorProfileTabs from "./CLIModGeneratorProfileTabs"; import { CLIModView, CLIModViewProfiles } from "./CLIModuleCommon"; +interface CLISpecsSimpleCommand { + names: string[], +} + +interface CLISpecsSimpleCommands { + [name: string]: CLISpecsSimpleCommand, +} + +interface CLISpecsSimpleCommandGroups { + [name: string]: CLISpecsSimpleCommandGroup, +} + +interface CLISpecsSimpleCommandGroup { + names: string[], + commands: CLISpecsSimpleCommands, + commandGroups: CLISpecsSimpleCommandGroups, +} + +interface CLISpecsSimpleCommandTree { + root: CLISpecsSimpleCommandGroup, +} interface CLISpecsHelp { short: string, @@ -70,7 +91,7 @@ const useSpecsCommandTree: () => [(names: string[]) => Promise>()); const cgCache = React.useRef(new Map>()); - const fetchCommandGroup = async (names: string[]) => { + const fetchCommandGroup = React.useCallback(async (names: string[]) => { const cachedPromise = cgCache.current.get(names.join('/')); const fullNames = ['aaz', ...names]; const cgPromise: Promise = cachedPromise ?? axios.get(`/AAZ/Specs/CommandTree/Nodes/${fullNames.join('/')}?limited=true`).then(res => res.data); @@ -78,9 +99,9 @@ const useSpecsCommandTree: () => [(names: string[]) => Promise { + const fetchCommand = React.useCallback(async (names: string[]) => { const cachedPromise = commandCache.current.get(names.join('/')); const fullNames = ['aaz', ...names]; const cgNames = fullNames.slice(0, -1); @@ -90,7 +111,7 @@ const useSpecsCommandTree: () => [(names: string[]) => Promise = ({ params }) => { const modView: CLIModView = await axios.get(`/CLI/Az/${params.repoName}/Modules/${params.moduleName}`).then(res => res.data); + const simpleTree: CLISpecsSimpleCommandTree = await axios.get(`/AAZ/Specs/CommandTree/Simple`).then(res => res.data); + Object.keys(modView!.profiles).forEach((profile) => { let idx = profiles.findIndex(v => v === profile); if (idx === -1) { @@ -135,9 +158,9 @@ const CLIModuleGenerator: React.FC = ({ params }) => { } }); - const commandTrees = Object.fromEntries(await Promise.all(profiles.map(async (profile) => { - return InitializeCommandTreeByModView(profile, modView!.profiles[profile] ?? null, fetchCommandGroup, fetchCommand).then(tree => [profile, tree] as [string, ProfileCommandTree]); - }))); + const commandTrees = Object.fromEntries(profiles.map((profile) => { + return [profile, InitializeCommandTreeByModView(profile, modView!.profiles[profile] ?? null, simpleTree)]; + })); const selectedProfile = profiles.length > 0 ? profiles[0] : undefined; setProfiles(profiles); @@ -170,17 +193,17 @@ const CLIModuleGenerator: React.FC = ({ params }) => { setShowGenerateDialog(false); }; - const onProfileChange = (selectedProfile: string) => { + const onProfileChange = React.useCallback((selectedProfile: string) => { setSelectedProfile(selectedProfile); - }; + }, []); - const onSelectedProfileTreeUpdate = (updater: ((newTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => { + const onSelectedProfileTreeUpdate = React.useCallback((updater: ((oldTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => { setCommandTrees((commandTrees) => { const selectedCommandTree = commandTrees[selectedProfile!]; const newTree = typeof updater === 'function' ? updater(selectedCommandTree!) : updater; return { ...commandTrees, [selectedProfile!]: newTree } }); - }; + }, [selectedProfile]); return ( @@ -217,6 +240,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { {selectedCommandTree !== undefined && ( { return } -export type { CLISpecsCommandGroup, CLISpecsCommand }; +export type { CLISpecsCommandGroup, CLISpecsCommand, CLISpecsSimpleCommandTree, CLISpecsSimpleCommandGroup, CLISpecsSimpleCommand }; export { CLIModuleGeneratorWrapper as CLIModuleGenerator }; From cad2f898c462d86332caa9468184bc7f5638a83d Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Tue, 29 Oct 2024 17:19:41 +0800 Subject: [PATCH 08/24] Fix --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 275 ++++-------------- 1 file changed, 55 insertions(+), 220 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 08549af8..a632c807 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -40,27 +40,32 @@ const UnregisteredTypography = styled(SelectionTypography)(() = function useBatchedUpdate(batchedUpdater: (states: T[]) => void, delay: number) { - const [states, setStates] = React.useState([]); + const statesRef = React.useRef([]); const timeoutRef = React.useRef(null); - const batchedCallback = React.useCallback((state: T) => { - setStates((prev) => [...prev, state]); - }, []); + const handleTimeout = React.useCallback(() => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + batchedUpdater(statesRef.current); + timeoutRef.current = null; + statesRef.current = []; + }, [batchedUpdater]); - React.useEffect(() => { - if (states.length > 0) { - timeoutRef.current = setTimeout(() => { - batchedUpdater(states); - setStates([]); - }, delay); + const batchedCallback = React.useCallback((state: T) => { + statesRef.current = [...statesRef.current, state]; + if (timeoutRef.current === null) { + timeoutRef.current = setTimeout(handleTimeout, delay); } + }, [batchedUpdater, delay]); + React.useEffect(() => { return () => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } - } - }, [states, batchedUpdater, delay]); + }; + }, []); return batchedCallback; } @@ -69,21 +74,36 @@ function useBatchedUpdate(batchedUpdater: (states: T[]) => void, delay: numbe interface CommandItemProps { command: ProfileCTCommand, onUpdateCommand: (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => void, + onLoadCommand(names: string[]): Promise, } const CommandItem: React.FC = React.memo(({ command, onUpdateCommand, + onLoadCommand, }) => { const leafName = command.names[command.names.length - 1]; - React.useEffect(() =>{if (command.selected === true && command.versions === undefined && command.loading === false) { - }}, []) + React.useEffect(() =>{ + if (command.selected === true && command.versions === undefined && command.loading === false) { + onUpdateCommand(leafName, (oldCommand) => { + return { + ...oldCommand, + loading: true, + } + }); + onLoadCommand(command.names); + } + }, [command]) const selectCommand = React.useCallback((selected: boolean) => { onUpdateCommand(leafName, (oldCommand) => { + if (oldCommand.versions === undefined && selected === true) { + onLoadCommand(oldCommand.names); + } return { ...oldCommand, + loading: (selected && oldCommand.versions === undefined), selected: selected, selectedVersion: selected ? (oldCommand.selectedVersion ? oldCommand.selectedVersion : (oldCommand.versions ? oldCommand.versions[0].name : undefined)) : oldCommand.selectedVersion, modified: true, @@ -157,7 +177,7 @@ const CommandItem: React.FC = React.memo(({ {command.modified && } - {command.versions !== undefined && = React.memo(({ interface CommandGroupItemProps { commandGroup: ProfileCTCommandGroup, onUpdateCommandGroup: (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => void, + onLoadCommand: (names: string[]) => Promise, } const CommandGroupItem: React.FC = React.memo(({ commandGroup, onUpdateCommandGroup, + onLoadCommand, }) => { const nodeName = commandGroup.names[commandGroup.names.length - 1]; const selected = commandGroup.selected ?? false; - console.log("Rendering Command Group: ", commandGroup.id); - const onUpdateCommand = React.useCallback((name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => { onUpdateCommandGroup(nodeName, (oldCommandGroup) => { const commands = { @@ -273,8 +293,10 @@ const CommandGroupItem: React.FC = React.memo(({ if (selected === command.selected) { return command; } + onLoadCommand(command.names); return { ...command, + loading: (selected && command.versions === undefined), selected: selected, selectedVersion: selected ? (command.selectedVersion ? command.selectedVersion : (command.versions ? command.versions[0].name : undefined)) : command.selectedVersion, modified: true, @@ -295,7 +317,7 @@ const CommandGroupItem: React.FC = React.memo(({ } } - const selectCommandGroup = React.useCallback((names: string[], selected: boolean) => { + const selectCommandGroup = React.useCallback((selected: boolean) => { onUpdateCommandGroup(nodeName, (oldCommandGroup) => { return updateGroupSelected(oldCommandGroup, selected); }); @@ -314,7 +336,7 @@ const CommandGroupItem: React.FC = React.memo(({ checked={commandGroup.selected !== false} indeterminate={commandGroup.selected === undefined} onClick={(event) => { - selectCommandGroup(commandGroup.names, !selected); + selectCommandGroup(!selected); event.stopPropagation(); event.preventDefault(); }} @@ -328,6 +350,7 @@ const CommandGroupItem: React.FC = React.memo(({ key={command.id} command={command} onUpdateCommand={onUpdateCommand} + onLoadCommand={onLoadCommand} /> ))} {commandGroup.commandGroups !== undefined && Object.values(commandGroup.commandGroups).map((group) => ( @@ -335,6 +358,7 @@ const CommandGroupItem: React.FC = React.memo(({ key={group.id} commandGroup={group} onUpdateCommandGroup={onUpdateSubCommandGroup} + onLoadCommand={onLoadCommand} /> ))} @@ -350,22 +374,10 @@ interface CLIModGeneratorProfileCommandTreeProps { } const CLIModGeneratorProfileCommandTree: React.FC = ({ - profile, profileCommandTree, onChange, - onLoadCommandGroup, onLoadCommand, }) => { - const [expanded, setExpanded] = React.useState([]); - - React.useEffect(() => { - setExpanded(GetDefaultExpanded(profileCommandTree)); - }, [profile]); - - const handleToggle = (_event: React.ChangeEvent<{}>, nodeIds: string[]) => { - setExpanded(nodeIds); - }; - const onUpdateCommandGroup = React.useCallback((name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { onChange((profileCommandTree) => { return { @@ -376,41 +388,31 @@ const CLIModGeneratorProfileCommandTree: React.FC { - const names = commandGroup.names; - onChange((profileCommandTree) => { - return genericUpdateCommandGroup(profileCommandTree, names, (unloadedCommandGroup) => { - const newCommandGroup = decodeProfileCTCommandGroup(commandGroup, unloadedCommandGroup.selected) - if (newCommandGroup.selected) { - // TODO: - return loadAllNextLevel(newCommandGroup, onLoadCommand, onLoadCommandGroup, onLoadedCommand, onLoadedCommandGroup); - } - return newCommandGroup - })!; - }); }, [onChange]); const handleBatchedLoadedCommand = React.useCallback((commands: CLISpecsCommand[]) => { onChange((profileCommandTree) => { - return commands.reduce((tree, command) => { + const newTree = commands.reduce((tree, command) => { return genericUpdateCommand(tree, command.names, (unloadedCommand) => { return decodeProfileCTCommand(command, unloadedCommand.selected, unloadedCommand.modified); }) ?? tree; }, profileCommandTree); + return newTree; }) - }, []); + }, [onChange]); - const onLoadedCommand = useBatchedUpdate(handleBatchedLoadedCommand, 100); + const onLoadedCommand = useBatchedUpdate(handleBatchedLoadedCommand, 1000); + + const onLoadAndDecodeCommand = React.useCallback(async (names: string[]) => { + const command = await onLoadCommand(names); + onLoadedCommand(command); + }, [onLoadCommand]); return ( } defaultExpandIcon={} > @@ -419,6 +421,7 @@ const CLIModGeneratorProfileCommandTree: React.FC ))} @@ -454,10 +457,6 @@ interface ProfileCTCommandGroup { selected?: boolean; } -function isUnloadedCommandGroup(commandGroup: ProfileCTCommandGroup): boolean { - return commandGroup.commands === undefined && commandGroup.loading === false; -} - interface ProfileCTCommand { id: string; names: string[]; @@ -473,10 +472,6 @@ interface ProfileCTCommand { selected: boolean; } -function isUnloadedCommand(command: ProfileCTCommand): boolean { - return command.selectedVersion === undefined && command.loading === false; -} - interface ProfileCTCommandVersion { name: string; stage: string; @@ -555,49 +550,6 @@ function GetDefaultExpanded(tree: ProfileCommandTree): string[] { }); } -function findCommandGroup(tree: ProfileCommandTree, names: string[]): ProfileCTCommandGroup | undefined { - if (names.length === 0) { - return undefined; - } - let cg: ProfileCTCommandGroup | ProfileCommandTree = tree; - for (const name of names) { - if (cg.commandGroups === undefined) { - return undefined; - } - cg = cg.commandGroups[name]; - } - return cg as ProfileCTCommandGroup; -} - -function loadCommand(command: ProfileCTCommand, fetchCommand: (names: string[]) => Promise, onLoadedCommand: (command: CLISpecsCommand) => void): ProfileCTCommand | undefined { - if (isUnloadedCommand(command)) { - fetchCommand(command.names).then(onLoadedCommand); - return { ...command, loading: true }; - } else { - return undefined; - } -} - -function loadCommandGroup(commandGroup: ProfileCTCommandGroup, fetchCommandGroup: (names: string[]) => Promise, onLoadedCommandGroup: (commandGroup: CLISpecsCommandGroup) => void): ProfileCTCommandGroup | undefined { - if (isUnloadedCommandGroup(commandGroup)) { - fetchCommandGroup(commandGroup.names).then(onLoadedCommandGroup); - return { ...commandGroup, loading: true }; - } else { - return undefined; - } -} - -function loadAllNextLevel(commandGroup: ProfileCTCommandGroup, fetchCommand: (names: string[]) => Promise, fetchCommandGroup: (names: string[]) => Promise, onLoadedCommand: (command: CLISpecsCommand) => void, onLoadedCommandGroup: (commandGroup: CLISpecsCommandGroup) => void): ProfileCTCommandGroup { - if (isUnloadedCommandGroup(commandGroup)) { - fetchCommandGroup(commandGroup.names).then(onLoadedCommandGroup); - return { ...commandGroup, loading: true }; - } else { - const commandGroups = commandGroup.commandGroups ? Object.fromEntries(Object.entries(commandGroup.commandGroups).map(([name, group]) => [name, loadAllNextLevel(group, fetchCommand, fetchCommandGroup, onLoadedCommand, onLoadedCommandGroup)])) : undefined; - const commands = commandGroup.commands ? Object.fromEntries(Object.entries(commandGroup.commands).map(([name, command]) => [name, loadCommand(command, fetchCommand, onLoadedCommand) ?? command])) : undefined; - return { ...commandGroup, commandGroups: commandGroups, commands: commands }; - } -} - function genericUpdateCommand(tree: ProfileCommandTree, names: string[], updater: (command: ProfileCTCommand) => ProfileCTCommand | undefined): ProfileCommandTree | undefined { let nodes: ProfileCTCommandGroup[] = []; for (const name of names.slice(0, -1)) { @@ -643,42 +595,6 @@ function genericUpdateCommand(tree: ProfileCommandTree, names: string[], updater } } -function genericUpdateCommandGroup(tree: ProfileCommandTree, names: string[], updater: (commandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup | undefined): ProfileCommandTree | undefined { - let nodes: ProfileCTCommandGroup[] = []; - for (const name of names) { - const node = nodes.length === 0 ? tree : nodes[nodes.length - 1]; - if (node.commandGroups === undefined) { - throw new Error("Invalid names: " + names.join(' ')); - } - nodes.push(node.commandGroups[name]); - } - let currentCommandGroup = nodes[nodes.length - 1]; - const updatedCommandGroup = updater(currentCommandGroup); - if (updatedCommandGroup === undefined) { - return undefined; - } - currentCommandGroup = updatedCommandGroup; - for (const node of nodes.reverse().slice(1)) { - const commandGroups = { - ...node.commandGroups, - [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, - } - const selected = calculateSelected(node.commands ?? {}, commandGroups); - currentCommandGroup = { - ...node, - commandGroups: commandGroups, - selected: selected, - } - } - return { - ...tree, - commandGroups: { - ...tree.commandGroups, - [currentCommandGroup.names[currentCommandGroup.names.length - 1]]: currentCommandGroup, - } - } -} - function calculateSelected(commands: ProfileCTCommands, commandGroups: ProfileCTCommandGroups): boolean | undefined { const commandsAllSelected = Object.values(commands).reduce((pre, value) => { return pre && value.selected }, true); const commandsAllUnselected = Object.values(commands).reduce((pre, value) => { return pre && !value.selected }, true); @@ -693,90 +609,12 @@ function calculateSelected(commands: ProfileCTCommands, commandGroups: ProfileCT } } -function updateCommand(command: ProfileCTCommand, selected: boolean, version: string | undefined, registered: boolean | undefined): ProfileCTCommand { - if (selected) { - return { - ...command, - selectedVersion: version ?? command.selectedVersion ?? (command.versions !== undefined ? command.versions[0].name : undefined), - registered: registered ?? command.registered ?? true, - selected: true, - modified: true, - } - } else { - return { - ...command, - selectedVersion: undefined, - registered: undefined, - selected: false, - modified: true, - } - } -} - -function updateCommandGroup(commandGroup: ProfileCTCommandGroup, names: string[], selected: boolean, version: string | undefined, registered: boolean | undefined): ProfileCTCommandGroup { - if (names.length === 0) { - const commands = commandGroup.commands ? Object.fromEntries(Object.entries(commandGroup.commands).map(([key, command]) => [key, updateCommand(command, selected, version, registered)])) : undefined; - const commandGroups = commandGroup.commandGroups ? Object.fromEntries(Object.entries(commandGroup.commandGroups).map(([key, commandGroup]) => [key, updateCommandGroup(commandGroup, [], selected, version, registered)])) : undefined; - const groupSelected = commands !== undefined && commandGroups !== undefined ? calculateSelected(commands, commandGroups): selected; - return { - ...commandGroup, - commands: commands, - commandGroups: commandGroups, - selected: groupSelected, - } - } else { - let name = names[0]; - if (name in (commandGroup.commandGroups ?? {})) { - let subGroup = commandGroup.commandGroups![name]; - const commandGroups = { - ...commandGroup.commandGroups, - [name]: updateCommandGroup(subGroup, names.slice(1), selected, version, registered), - } - const commands = commandGroup.commands; - const groupSelected = calculateSelected(commands ?? {}, commandGroups); - return { - ...commandGroup, - commandGroups: commandGroups, - selected: groupSelected, - } - } else if (name in (commandGroup.commands ?? {}) && names.length === 1) { - let command = commandGroup.commands![name]; - const commands = { - ...commandGroup.commands, - [name]: updateCommand(command, selected, version, registered), - } - const commandGroups = commandGroup.commandGroups; - const groupSelected = calculateSelected(commands, commandGroups ?? {}); - return { - ...commandGroup, - commands: commands, - selected: groupSelected, - } - } else { - throw new Error("Invalid names: " + names.join(' ')); - } - } -} - -function updateProfileCommandTree(tree: ProfileCommandTree, names: string[], selected: boolean, version: string | undefined = undefined, registered: boolean | undefined = undefined): ProfileCommandTree { - const name = names[0]; - const commandGroup = tree.commandGroups[name]; - const commandGroups = { - ...tree.commandGroups, - [name]: updateCommandGroup(commandGroup, names.slice(1), selected, version, registered), - } - return { - ...tree, - commandGroups: commandGroups - } -} - function initializeCommandByModView(view: CLIModViewCommand | undefined, simpleCommand: CLISpecsSimpleCommand): ProfileCTCommand { return { id: simpleCommand.names.join('/'), names: simpleCommand.names, modified: false, - loading: true, + loading: false, selected: view !== undefined && view.version !== undefined, selectedVersion: view !== undefined ? view.version : undefined, registered: view !== undefined ? view.registered : undefined, @@ -784,9 +622,6 @@ function initializeCommandByModView(view: CLIModViewCommand | undefined, simpleC } function initializeCommandGroupByModView(view: CLIModViewCommandGroup | undefined, simpleCommandGroup: CLISpecsSimpleCommandGroup): ProfileCTCommandGroup { - if (simpleCommandGroup.names === undefined) { - console.log("simpleCommandGroup", simpleCommandGroup); - } const commands = simpleCommandGroup.commands !== undefined ? Object.fromEntries(Object.entries(simpleCommandGroup.commands).map(([key, value]) => [key, initializeCommandByModView(view?.commands?.[key], value)]) ) : undefined; const commandGroups = simpleCommandGroup.commandGroups !== undefined ? Object.fromEntries(Object.entries(simpleCommandGroup.commandGroups).map(([key, value]) => [key, initializeCommandGroupByModView(view?.commandGroups?.[key], value)]) ) : undefined; const selected = calculateSelected(commands ?? {}, commandGroups ?? {}); From a57ade2960eca5f46284aba6b6e914e5fdb647e2 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 30 Oct 2024 14:09:14 +0800 Subject: [PATCH 09/24] Batch Get --- src/aaz_dev/command/api/specs.py | 17 +++ .../cli/CLIModGeneratorProfileCommandTree.tsx | 108 +++++++++++++----- src/web/src/views/cli/CLIModuleGenerator.tsx | 64 ++++++----- 3 files changed, 133 insertions(+), 56 deletions(-) diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 8401158d..692f2833 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -56,6 +56,23 @@ def command_tree_leaf(node_names, leaf_name): return jsonify(result) +@bp.route("/CommandTree/Nodes/Leaves", methods=("POST",)) +def command_tree_leaves(): + data = request.get_json() + result = [] + manager = AAZSpecsManager() + + for command_names in data: + if command_names[0] != AAZSpecsManager.COMMAND_TREE_ROOT_NAME: + raise exceptions.ResourceNotFind(f"Command not exist: {' '.join(command_names)}") + command_names = command_names[1:] + leaf = manager.tree.find_command(*command_names) + if not leaf: + raise exceptions.ResourceNotFind(f"Command not exist: {' '.join(command_names)}") + result.append(leaf.to_primitive()) + return jsonify(result) + + @bp.route("/CommandTree/Nodes//Leaves//Versions/", methods=("GET",)) def aaz_command_in_version(node_names, leaf_name, version_name): if node_names[0] != AAZSpecsManager.COMMAND_TREE_ROOT_NAME: diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index a632c807..a3fb5783 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -84,18 +84,6 @@ const CommandItem: React.FC = React.memo(({ }) => { const leafName = command.names[command.names.length - 1]; - React.useEffect(() =>{ - if (command.selected === true && command.versions === undefined && command.loading === false) { - onUpdateCommand(leafName, (oldCommand) => { - return { - ...oldCommand, - loading: true, - } - }); - onLoadCommand(command.names); - } - }, [command]) - const selectCommand = React.useCallback((selected: boolean) => { onUpdateCommand(leafName, (oldCommand) => { if (oldCommand.versions === undefined && selected === true) { @@ -247,13 +235,13 @@ const CommandItem: React.FC = React.memo(({ interface CommandGroupItemProps { commandGroup: ProfileCTCommandGroup, onUpdateCommandGroup: (name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => void, - onLoadCommand: (names: string[]) => Promise, + onLoadCommands: (names: string[][]) => Promise, } const CommandGroupItem: React.FC = React.memo(({ commandGroup, onUpdateCommandGroup, - onLoadCommand, + onLoadCommands, }) => { const nodeName = commandGroup.names[commandGroup.names.length - 1]; const selected = commandGroup.selected ?? false; @@ -289,14 +277,16 @@ const CommandGroupItem: React.FC = React.memo(({ }); }, []); + const onLoadCommand = React.useCallback(async (names: string[]) => { + await onLoadCommands([names]); + }, [onLoadCommands]); + const updateCommandSelected = (command: ProfileCTCommand, selected: boolean): ProfileCTCommand => { if (selected === command.selected) { return command; } - onLoadCommand(command.names); return { ...command, - loading: (selected && command.versions === undefined), selected: selected, selectedVersion: selected ? (command.selectedVersion ? command.selectedVersion : (command.versions ? command.versions[0].name : undefined)) : command.selectedVersion, modified: true, @@ -319,7 +309,12 @@ const CommandGroupItem: React.FC = React.memo(({ const selectCommandGroup = React.useCallback((selected: boolean) => { onUpdateCommandGroup(nodeName, (oldCommandGroup) => { - return updateGroupSelected(oldCommandGroup, selected); + const selectedGroup = updateGroupSelected(oldCommandGroup, selected); + const [loadingNamesList, newGroup] = prepareLoadCommandsOfCommandGroup(selectedGroup); + if (loadingNamesList.length > 0) { + onLoadCommands(loadingNamesList); + } + return newGroup; }); }, []); @@ -358,7 +353,7 @@ const CommandGroupItem: React.FC = React.memo(({ key={group.id} commandGroup={group} onUpdateCommandGroup={onUpdateSubCommandGroup} - onLoadCommand={onLoadCommand} + onLoadCommands={onLoadCommands} /> ))} @@ -369,15 +364,16 @@ interface CLIModGeneratorProfileCommandTreeProps { profile?: string, profileCommandTree: ProfileCommandTree, onChange: (updater: ((oldProfileCommandTree: ProfileCommandTree) => ProfileCommandTree) | ProfileCommandTree) => void, - onLoadCommandGroup: (names: string[]) => Promise, - onLoadCommand: (names: string[]) => Promise, + onLoadCommands: (namesList: string[][]) => Promise, } const CLIModGeneratorProfileCommandTree: React.FC = ({ profileCommandTree, onChange, - onLoadCommand, + onLoadCommands, }) => { + const [defaultExpanded, _] = React.useState(GetDefaultExpanded(profileCommandTree)); + const onUpdateCommandGroup = React.useCallback((name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { onChange((profileCommandTree) => { return { @@ -390,7 +386,7 @@ const CLIModGeneratorProfileCommandTree: React.FC { + const handleBatchedLoadedCommands = React.useCallback((commands: CLISpecsCommand[]) => { onChange((profileCommandTree) => { const newTree = commands.reduce((tree, command) => { return genericUpdateCommand(tree, command.names, (unloadedCommand) => { @@ -401,18 +397,27 @@ const CLIModGeneratorProfileCommandTree: React.FC { + const commands = await onLoadCommands(names); + handleBatchedLoadedCommands(commands); + }, [onLoadCommands]); - const onLoadAndDecodeCommand = React.useCallback(async (names: string[]) => { - const command = await onLoadCommand(names); - onLoadedCommand(command); - }, [onLoadCommand]); + + React.useEffect(() => { + const [loadingNamesList, newTree] = PrepareLoadCommands(profileCommandTree); + if (loadingNamesList.length > 0) { + onChange(newTree); + onLoadCommands(loadingNamesList).then((commands) => { + handleBatchedLoadedCommands(commands); + }); + } + }, [profileCommandTree]); return ( } defaultExpandIcon={} > @@ -421,7 +426,7 @@ const CLIModGeneratorProfileCommandTree: React.FC ))} @@ -550,6 +555,51 @@ function GetDefaultExpanded(tree: ProfileCommandTree): string[] { }); } +function prepareLoadCommandsOfCommandGroup(commandGroup: ProfileCTCommandGroup): [string[][], ProfileCTCommandGroup] { + const namesList: string[][] = []; + const commands = commandGroup.commands ? Object.fromEntries(Object.entries(commandGroup.commands).map(([key, value]) => { + if (value.selected === true && value.versions === undefined && value.loading === false) { + namesList.push(value.names); + return [key, { + ...value, + loading: true, + }]; + } + return [key, value]; + })) : undefined; + const commandGroups = commandGroup.commandGroups ? Object.fromEntries(Object.entries(commandGroup.commandGroups).map(([key, value]) => { + const [namesListSub, updatedGroup] = prepareLoadCommandsOfCommandGroup(value); + namesList.push(...namesListSub); + return [key, updatedGroup]; + })) : undefined; + if (namesList.length > 0) { + return [namesList, { + ...commandGroup, + commands: commands, + commandGroups: commandGroups, + }]; + } else { + return [[], commandGroup]; + } +} + +function PrepareLoadCommands(tree: ProfileCommandTree): [string[][], ProfileCommandTree] { + const namesList: string[][] = []; + const commandGroups = Object.fromEntries(Object.entries(tree.commandGroups).map(([key, value]) => { + const [namesListSub, updatedGroup] = prepareLoadCommandsOfCommandGroup(value); + namesList.push(...namesListSub); + return [key, updatedGroup]; + })); + if (namesList.length > 0) { + return [namesList, { + ...tree, + commandGroups: commandGroups, + }]; + } else { + return [[], tree]; + } +} + function genericUpdateCommand(tree: ProfileCommandTree, names: string[], updater: (command: ProfileCTCommand) => ProfileCTCommand | undefined): ProfileCommandTree | undefined { let nodes: ProfileCTCommandGroup[] = []; for (const name of names.slice(0, -1)) { diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 76ac47fd..da0214cd 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -87,33 +87,44 @@ interface CLISpecsCommands { [name: string]: CLISpecsCommand, } -const useSpecsCommandTree: () => [(names: string[]) => Promise, (names: string[]) => Promise] = () => { +async function retrieveCommand(names: string[]): Promise { + return axios.get(`/AAZ/Specs/CommandTree/Nodes/aaz/${names.slice(0, -1).join('/')}/Leaves/${names[names.length - 1]}`).then(res => res.data); +} + +async function retrieveCommands(namesList: string[][]): Promise { + const namesListData = namesList.map(names => ['aaz', ...names]); + return axios.post(`/AAZ/Specs/CommandTree/Nodes/Leaves`, namesListData).then(res => res.data); +} + +const useSpecsCommandTree: () => (namesList: string[][]) => Promise = () => { const commandCache = React.useRef(new Map>()); - const cgCache = React.useRef(new Map>()); - - const fetchCommandGroup = React.useCallback(async (names: string[]) => { - const cachedPromise = cgCache.current.get(names.join('/')); - const fullNames = ['aaz', ...names]; - const cgPromise: Promise = cachedPromise ?? axios.get(`/AAZ/Specs/CommandTree/Nodes/${fullNames.join('/')}?limited=true`).then(res => res.data); - if (!cachedPromise) { - cgCache.current.set(names.join('/'), cgPromise); + + const fetchCommands = React.useCallback(async (namesList: string[][]) => { + const promiseResults = []; + const uncachedNamesList = []; + for (const names of namesList) { + const cachedPromise = commandCache.current.get(names.join('/')); + if (!cachedPromise) { + uncachedNamesList.push(names); + } else { + promiseResults.push(cachedPromise); + } } - return await cgPromise; - }, [commandCache, cgCache]); - - const fetchCommand = React.useCallback(async (names: string[]) => { - const cachedPromise = commandCache.current.get(names.join('/')); - const fullNames = ['aaz', ...names]; - const cgNames = fullNames.slice(0, -1); - const commandName = fullNames[fullNames.length - 1]; - const commandPromise: Promise = cachedPromise ?? axios.get(`/AAZ/Specs/CommandTree/Nodes/${cgNames.join('/')}/Leaves/${commandName}`).then(res => res.data); - if (!cachedPromise) { - commandCache.current.set(names.join('/'), commandPromise); + if (uncachedNamesList.length === 0) { + return await Promise.all(promiseResults); + } else if (uncachedNamesList.length === 1) { + const commandPromise = retrieveCommand(uncachedNamesList[0]); + commandCache.current.set(uncachedNamesList[0].join('/'), commandPromise); + return (await Promise.all(promiseResults.concat(commandPromise))); + } else { + const uncachedCommandsPromise = retrieveCommands(uncachedNamesList); + uncachedNamesList.forEach((names, idx) => { + commandCache.current.set(names.join('/'), uncachedCommandsPromise.then(commands => commands[idx])); + }); + return (await Promise.all(promiseResults)).concat(await uncachedCommandsPromise); } - return await commandPromise; - }, [commandCache]); - - return [fetchCommandGroup, fetchCommand]; + }, []); + return fetchCommands; } @@ -136,7 +147,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { const [selectedProfile, setSelectedProfile] = React.useState(undefined); const [showGenerateDialog, setShowGenerateDialog] = React.useState(false); - const [fetchCommandGroup, fetchCommand] = useSpecsCommandTree(); + const fetchCommands = useSpecsCommandTree(); React.useEffect(() => { loadModule(); @@ -243,8 +254,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { profile={selectedProfile} profileCommandTree={selectedCommandTree} onChange={onSelectedProfileTreeUpdate} - onLoadCommand={fetchCommand} - onLoadCommandGroup={fetchCommandGroup} + onLoadCommands={fetchCommands} /> )} From a18a8ff4bf1d2c28bd81d13765403e2bcde00058 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 30 Oct 2024 14:49:20 +0800 Subject: [PATCH 10/24] Fix dependencies --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 45 +++---------------- src/web/src/views/cli/CLIModuleGenerator.tsx | 2 +- 2 files changed, 7 insertions(+), 40 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index a3fb5783..8d6eab7e 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -38,39 +38,6 @@ const UnregisteredTypography = styled(SelectionTypography)(() = color: '#d9c136', })) - -function useBatchedUpdate(batchedUpdater: (states: T[]) => void, delay: number) { - const statesRef = React.useRef([]); - const timeoutRef = React.useRef(null); - - const handleTimeout = React.useCallback(() => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - batchedUpdater(statesRef.current); - timeoutRef.current = null; - statesRef.current = []; - }, [batchedUpdater]); - - const batchedCallback = React.useCallback((state: T) => { - statesRef.current = [...statesRef.current, state]; - if (timeoutRef.current === null) { - timeoutRef.current = setTimeout(handleTimeout, delay); - } - }, [batchedUpdater, delay]); - - React.useEffect(() => { - return () => { - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - return batchedCallback; -} - - interface CommandItemProps { command: ProfileCTCommand, onUpdateCommand: (name: string, updater: (oldCommand: ProfileCTCommand) => ProfileCTCommand) => void, @@ -97,7 +64,7 @@ const CommandItem: React.FC = React.memo(({ modified: true, } }); - }, []); + }, [onUpdateCommand, onLoadCommand]); const selectVersion = React.useCallback((version: string) => { onUpdateCommand(leafName, (oldCommand) => { @@ -107,7 +74,7 @@ const CommandItem: React.FC = React.memo(({ modified: true, } }); - }, []); + }, [onUpdateCommand]); const selectRegistered = React.useCallback((registered: boolean) => { onUpdateCommand(leafName, (oldCommand) => { @@ -117,7 +84,7 @@ const CommandItem: React.FC = React.memo(({ modified: true, } }); - }, []); + }, [onUpdateCommand]); return ( = React.memo(({ selected: selected, } }); - }, []); + }, [onUpdateCommandGroup]); const onUpdateSubCommandGroup = React.useCallback((name: string, updater: (oldCommandGroup: ProfileCTCommandGroup) => ProfileCTCommandGroup) => { onUpdateCommandGroup(nodeName, (oldCommandGroup) => { @@ -275,7 +242,7 @@ const CommandGroupItem: React.FC = React.memo(({ selected: selected, }; }); - }, []); + }, [onUpdateCommandGroup]); const onLoadCommand = React.useCallback(async (names: string[]) => { await onLoadCommands([names]); @@ -316,7 +283,7 @@ const CommandGroupItem: React.FC = React.memo(({ } return newGroup; }); - }, []); + }, [onUpdateCommandGroup, onLoadCommands]); return ( (namesList: string[][]) => Promise Date: Wed, 30 Oct 2024 15:50:48 +0800 Subject: [PATCH 11/24] remove cross layer call --- src/aaz_dev/cli/api/az.py | 4 +- src/aaz_dev/cli/api/portal.py | 2 +- .../controller/az_atomic_profile_builder.py | 4 +- .../cli/controller/portal_cli_generator.py | 2 +- src/aaz_dev/command/api/specs.py | 10 ++-- .../command/controller/specs_manager.py | 54 ++++++++++++++++--- .../command/controller/workspace_manager.py | 6 +-- .../tests/spec_tests/spec_portal_gen_test.py | 2 +- 8 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/aaz_dev/cli/api/az.py b/src/aaz_dev/cli/api/az.py index b0f254e7..0bed53ee 100644 --- a/src/aaz_dev/cli/api/az.py +++ b/src/aaz_dev/cli/api/az.py @@ -151,7 +151,7 @@ def portal_generate_main_module(module_name): return aaz_spec_manager = AAZSpecsManager() - root = aaz_spec_manager.tree.find_command_group() + root = aaz_spec_manager.find_command_group() if not root: raise exceptions.ResourceNotFind("Command group not exist") portal_cli_generator = PortalCliGenerator() @@ -172,7 +172,7 @@ def portal_generate_extension_module(module_name): return aaz_spec_manager = AAZSpecsManager() - root = aaz_spec_manager.tree.find_command_group() + root = aaz_spec_manager.find_command_group() if not root: raise exceptions.ResourceNotFind("Command group not exist") portal_cli_generator = PortalCliGenerator() diff --git a/src/aaz_dev/cli/api/portal.py b/src/aaz_dev/cli/api/portal.py index 441497b7..14d094c8 100644 --- a/src/aaz_dev/cli/api/portal.py +++ b/src/aaz_dev/cli/api/portal.py @@ -48,7 +48,7 @@ def generate_module_command_portal(module): az_main_manager = AzMainManager() az_ext_manager = AzExtensionManager() aaz_spec_manager = AAZSpecsManager() - root = aaz_spec_manager.tree.find_command_group() + root = aaz_spec_manager.find_command_group() if not root: return "Command group spec root not exist" portal_cli_generator = PortalCliGenerator() diff --git a/src/aaz_dev/cli/controller/az_atomic_profile_builder.py b/src/aaz_dev/cli/controller/az_atomic_profile_builder.py index 6e6f32be..1264b1de 100644 --- a/src/aaz_dev/cli/controller/az_atomic_profile_builder.py +++ b/src/aaz_dev/cli/controller/az_atomic_profile_builder.py @@ -105,7 +105,7 @@ def _build_command(self, view_command, load_cfg): return command, client def _build_command_group_from_aaz(self, *names): - aaz_cg = self._aaz_spec_manager.tree.find_command_group(*names) + aaz_cg = self._aaz_spec_manager.find_command_group(*names) if not aaz_cg: raise ResourceNotFind("Command group '{}' not exist in AAZ".format(' '.join(names))) command_group = CLIAtomicCommandGroup() @@ -120,7 +120,7 @@ def _build_command_group_from_aaz(self, *names): return command_group def _build_command_from_aaz(self, *names, version_name, load_cfg=True): - aaz_cmd = self._aaz_spec_manager.tree.find_command(*names) + aaz_cmd = self._aaz_spec_manager.find_command(*names) if not aaz_cmd: raise ResourceNotFind("Command '{}' not exist in AAZ".format(' '.join(names))) version = None diff --git a/src/aaz_dev/cli/controller/portal_cli_generator.py b/src/aaz_dev/cli/controller/portal_cli_generator.py index b87f2be1..e7fbf213 100644 --- a/src/aaz_dev/cli/controller/portal_cli_generator.py +++ b/src/aaz_dev/cli/controller/portal_cli_generator.py @@ -361,7 +361,7 @@ def generate_cmds_portal_info(self, aaz_spec_manager, registered_cmds): node_names = cmd_name_version[:-2] leaf_name = cmd_name_version[-2] registered_version = cmd_name_version[-1] - leaf = aaz_spec_manager.tree.find_command(*node_names, leaf_name) + leaf = aaz_spec_manager.find_command(*node_names, leaf_name) if not leaf or not leaf.versions: logging.warning("Command group: " + " ".join(node_names) + " not exist") continue diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 692f2833..154abafb 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -13,7 +13,7 @@ @bp.route("/CommandTree/Simple", methods=("GET",)) def simple_command_tree(): manager = AAZSpecsManager() - tree = manager.tree.simple_tree + tree = manager.simple_tree if not tree: raise exceptions.ResourceNotFind("Command group not exist") tree = tree.to_primitive() @@ -28,7 +28,7 @@ def command_tree_node(node_names): node_names = node_names[1:] manager = AAZSpecsManager() - node = manager.tree.find_command_group(*node_names) + node = manager.find_command_group(*node_names) if not node: raise exceptions.ResourceNotFind("Command group not exist") @@ -48,7 +48,7 @@ def command_tree_leaf(node_names, leaf_name): node_names = node_names[1:] manager = AAZSpecsManager() - leaf = manager.tree.find_command(*node_names, leaf_name) + leaf = manager.find_command(*node_names, leaf_name) if not leaf: raise exceptions.ResourceNotFind("Command not exist") @@ -66,7 +66,7 @@ def command_tree_leaves(): if command_names[0] != AAZSpecsManager.COMMAND_TREE_ROOT_NAME: raise exceptions.ResourceNotFind(f"Command not exist: {' '.join(command_names)}") command_names = command_names[1:] - leaf = manager.tree.find_command(*command_names) + leaf = manager.find_command(*command_names) if not leaf: raise exceptions.ResourceNotFind(f"Command not exist: {' '.join(command_names)}") result.append(leaf.to_primitive()) @@ -80,7 +80,7 @@ def aaz_command_in_version(node_names, leaf_name, version_name): node_names = node_names[1:] manager = AAZSpecsManager() - leaf = manager.tree.find_command(*node_names, leaf_name) + leaf = manager.find_command(*node_names, leaf_name) if not leaf: raise exceptions.ResourceNotFind("Command not exist") diff --git a/src/aaz_dev/command/controller/specs_manager.py b/src/aaz_dev/command/controller/specs_manager.py index 05946cac..5b5da778 100644 --- a/src/aaz_dev/command/controller/specs_manager.py +++ b/src/aaz_dev/command/controller/specs_manager.py @@ -114,6 +114,23 @@ def get_resource_versions(self, plane, resource_id): elif file_name.endswith('.md'): versions.add(file_name[:-3]) return sorted(versions, reverse=True) + + # Command Tree + @property + def simple_tree(self): + return self.tree.simple_tree + + def find_command_group(self, *cg_names): + return self.tree.find_command_group(*cg_names) + + def find_command(self, *cmd_names): + return self.tree.find_command(*cmd_names) + + def iter_command_groups(self, *root_cg_names): + yield from self.tree.iter_command_groups(*root_cg_names) + + def iter_commands(self, *root_node_names): + yield from self.tree.iter_commands(*root_node_names) def load_resource_cfg_reader(self, plane, resource_id, version): key = (plane, resource_id, version) @@ -178,6 +195,31 @@ def load_resource_cfg_reader_by_command_with_version(self, cmd, version): resource = version.resources[0] return self.load_resource_cfg_reader(resource.plane, resource.id, resource.version) + # command tree + def create_command_group(self, *cg_names): + return self.tree.create_command_group(*cg_names) + + def update_command_group_by_ws(self, ws_node): + return self.tree.update_command_group_by_ws(ws_node) + + def delete_command_group(self, *cg_names): + return self.tree.delete_command_group(*cg_names) + + def create_command(self, *cmd_names): + return self.tree.create_command(*cmd_names) + + def delete_command(self, *cmd_names): + return self.tree.delete_command(*cmd_names) + + def delete_command_version(self, *cmd_names, version): + return self.tree.delete_command_version(*cmd_names, version=version) + + def update_command_version(self, *cmd_names, plane, cfg_cmd): + return self.tree.update_command_version(*cmd_names, plane=plane, cfg_cmd=cfg_cmd) + + def verify_command_tree(self): + return self.tree.verify_command_tree() + def _remove_cfg(self, cfg): cfg_reader = CfgReader(cfg) @@ -188,7 +230,7 @@ def _remove_cfg(self, cfg): # update command tree for cmd_names, cmd in cfg_reader.iter_commands(): - self.tree.delete_command_version(*cmd_names, version=cmd.version) + self.delete_command_version(*cmd_names, version=cmd.version) def update_resource_cfg(self, cfg): cfg_reader = CfgReader(cfg=cfg) @@ -205,7 +247,7 @@ def update_resource_cfg(self, cfg): # add new command version for cmd_names, cmd in cfg_reader.iter_commands(): - self.tree.update_command_version(*cmd_names, plane=cfg.plane, cfg_cmd=cmd) + self.update_command_version(*cmd_names, plane=cfg.plane, cfg_cmd=cmd) for resource in cfg_reader.resources: key = (cfg.plane, resource.id, resource.version) @@ -260,7 +302,7 @@ def update_client_cfg(self, cfg): self._modified_resource_client_cfgs[key] = cfg def save(self): - self.tree.verify_command_tree() + self.verify_command_tree() remove_files = [] remove_folders = [] @@ -272,7 +314,7 @@ def save(self): # command for cmd_names in sorted(self._modified_commands): - cmd = self.tree.find_command(*cmd_names) + cmd = self.find_command(*cmd_names) file_path = self.get_command_readme_path(*cmd_names) if not cmd: # remove command file @@ -288,14 +330,14 @@ def save(self): # command groups for cg_names in sorted(command_groups): - cg = self.tree.find_command_group(*cg_names) + cg = self.find_command_group(*cg_names) if not cg: # remove command group folder remove_folders.append(self.get_command_group_folder(*cg_names)) else: # update command group readme file_path = self.get_command_group_readme_path(*cg_names) - if cg == self.tree.root: + if cg == self.root: update_files[file_path] = self.render_command_tree_readme(self.tree) else: update_files[file_path] = self.render_command_group_readme(cg) diff --git a/src/aaz_dev/command/controller/workspace_manager.py b/src/aaz_dev/command/controller/workspace_manager.py index 8183079b..fb8320fc 100644 --- a/src/aaz_dev/command/controller/workspace_manager.py +++ b/src/aaz_dev/command/controller/workspace_manager.py @@ -295,7 +295,7 @@ def create_command_tree_nodes(self, *node_names): if not node.command_groups or name not in node.command_groups: if not node.command_groups: node.command_groups = {} - aaz_node = self.aaz_specs.tree.find_command_group( + aaz_node = self.aaz_specs.find_command_group( *node_names[:idx + 1]) if aaz_node is not None: new_node = CMDCommandTreeNode({ @@ -1060,14 +1060,14 @@ def generate_to_aaz(self): # update commands for ws_leaf in self.iter_command_tree_leaves(): - self.aaz_specs.tree.update_command_by_ws(ws_leaf) + self.aaz_specs.update_command_by_ws(ws_leaf) # update command groups for ws_node in self.iter_command_tree_nodes(): if ws_node == self.ws.command_tree: # ignore root node continue - self.aaz_specs.tree.update_command_group_by_ws(ws_node) + self.aaz_specs.update_command_group_by_ws(ws_node) self.aaz_specs.save() def _merge_sub_resources_in_aaz(self): diff --git a/src/aaz_dev/command/tests/spec_tests/spec_portal_gen_test.py b/src/aaz_dev/command/tests/spec_tests/spec_portal_gen_test.py index 924822bf..06fbbc3d 100644 --- a/src/aaz_dev/command/tests/spec_tests/spec_portal_gen_test.py +++ b/src/aaz_dev/command/tests/spec_tests/spec_portal_gen_test.py @@ -16,7 +16,7 @@ def test_aaz_cmd_portal_generate(self): node_names = node_names[1:] manager = AAZSpecsManager() - leaf = manager.tree.find_command(*node_names, leaf_name) + leaf = manager.find_command(*node_names, leaf_name) if not leaf: raise exceptions.ResourceNotFind("Command group not exist") From 9c23be8cec93692fb376737ff709af45f74615fd Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 30 Oct 2024 16:11:56 +0800 Subject: [PATCH 12/24] Backend Fix --- src/aaz_dev/command/controller/specs_manager.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/aaz_dev/command/controller/specs_manager.py b/src/aaz_dev/command/controller/specs_manager.py index 5b5da778..f3a2434a 100644 --- a/src/aaz_dev/command/controller/specs_manager.py +++ b/src/aaz_dev/command/controller/specs_manager.py @@ -32,8 +32,6 @@ def __init__(self): self.resources_folder = os.path.join(self.folder, "Resources") self.commands_folder = os.path.join(self.folder, "Commands") self._tree = None - self._modified_command_groups = set() - self._modified_commands = set() self._modified_resource_cfgs = {} self._modified_resource_client_cfgs = {} @@ -217,6 +215,9 @@ def delete_command_version(self, *cmd_names, version): def update_command_version(self, *cmd_names, plane, cfg_cmd): return self.tree.update_command_version(*cmd_names, plane=plane, cfg_cmd=cfg_cmd) + def update_command_by_ws(self, ws_leaf): + return self.tree.update_command_by_ws(ws_leaf) + def verify_command_tree(self): return self.tree.verify_command_tree() @@ -313,7 +314,7 @@ def save(self): update_files[tree_path] = json.dumps(self.tree.to_model().to_primitive(), indent=2, sort_keys=True) # command - for cmd_names in sorted(self._modified_commands): + for cmd_names in sorted(self.tree._modified_commands): cmd = self.find_command(*cmd_names) file_path = self.get_command_readme_path(*cmd_names) if not cmd: @@ -324,7 +325,7 @@ def save(self): command_groups.add(tuple(cmd_names[:-1])) - for cg_names in sorted(self._modified_command_groups): + for cg_names in sorted(self.tree._modified_command_groups): command_groups.add(tuple(cg_names)) command_groups.add(tuple(cg_names[:-1])) @@ -337,7 +338,7 @@ def save(self): else: # update command group readme file_path = self.get_command_group_readme_path(*cg_names) - if cg == self.root: + if cg == self.tree.root: update_files[file_path] = self.render_command_tree_readme(self.tree) else: update_files[file_path] = self.render_command_group_readme(cg) @@ -378,8 +379,6 @@ def save(self): with open(file_path, 'w', encoding="utf-8") as f: f.write(data) - self._modified_command_groups = set() - self._modified_commands = set() self._modified_resource_cfgs = {} self._modified_resource_client_cfgs = {} @@ -397,7 +396,7 @@ def render_command_group_readme(command_group): @staticmethod def render_command_tree_readme(tree): - assert isinstance(tree, CMDSpecsCommandTree) + assert isinstance(tree, CMDSpecsCommandTree | CMDSpecsPartialCommandTree) tmpl = get_templates()['tree'] return tmpl.render(tree=tree) From 7aae85c278551a0423202ded253e17adf00a1163 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 30 Oct 2024 16:21:32 +0800 Subject: [PATCH 13/24] Revert dynamic in api --- src/aaz_dev/command/api/specs.py | 10 +---- .../command/controller/command_tree.py | 40 ------------------- .../command/model/specs/_command_tree.py | 12 ++---- .../tests/spec_tests/test_command_tree.py | 6 +-- src/web/src/views/cli/CLIModuleGenerator.tsx | 2 +- 5 files changed, 7 insertions(+), 63 deletions(-) diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 154abafb..0ebf813c 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -1,6 +1,5 @@ from flask import Blueprint, jsonify, request -from command.controller.command_tree import to_limited_primitive from utils import exceptions from utils.plane import PlaneEnum from command.controller.specs_manager import AAZSpecsManager @@ -31,13 +30,8 @@ def command_tree_node(node_names): node = manager.find_command_group(*node_names) if not node: raise exceptions.ResourceNotFind("Command group not exist") - - # Check for the 'limited' query parameter - limited = request.args.get('limited', 'false').lower() == 'true' - if limited: - result = to_limited_primitive(node) - else: - result = node.to_primitive() + + result = node.to_primitive() return jsonify(result) diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index cabd668e..0cf2f050 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -609,43 +609,3 @@ def to_model(self): tree = CMDSpecsCommandTree() tree.root = self.root return tree - - -def to_limited_primitive(command_group: CMDSpecsCommandGroup): - copied = CMDSpecsCommandGroup() - copied.names = command_group.names - copied.help = command_group.help - copied.commands = {} - copied.command_groups = {} - if isinstance(command_group.command_groups, CMDSpecsCommandGroupDict): - iterator = command_group.command_groups.raw_items() - else: - iterator = command_group.command_groups.items() - for k, v in iterator: - sub_cg = CMDSpecsCommandGroup() - sub_cg.names = v.names - sub_cg.commands = None - sub_cg.command_groups = None - if isinstance(v, CMDSpecsCommandGroup): - sub_cg.help = v.help - copied.command_groups[k] = sub_cg - elif isinstance(v, CMDSpecsPartialCommandGroup): - sub_cg.help = CMDHelp() - sub_cg.help.short = v.short_help - copied.command_groups[k] = sub_cg - if isinstance(command_group.commands, CMDSpecsCommandDict): - iterator = command_group.commands.raw_items() - else: - iterator = command_group.commands.items() - for k, v in iterator: - sub_command = CMDSpecsCommand() - sub_command.names = v.names - sub_command.versions = None - if isinstance(v, CMDSpecsCommand): - sub_command.help = v.help - copied.commands[k] = sub_command - elif isinstance(v, CMDSpecsPartialCommand): - sub_command.help = CMDHelp() - sub_command.help.short = v.short_help - copied.commands[k] = sub_command - return copied.to_primitive() diff --git a/src/aaz_dev/command/model/specs/_command_tree.py b/src/aaz_dev/command/model/specs/_command_tree.py index 14479806..a5315b2d 100644 --- a/src/aaz_dev/command/model/specs/_command_tree.py +++ b/src/aaz_dev/command/model/specs/_command_tree.py @@ -53,11 +53,7 @@ class Options: class CMDSpecsCommand(Model): names = ListType(field=CMDCommandNameField(), min_size=1, required=True) # full name of a command help = ModelType(CMDHelp, required=True) - versions = ListType( # None only when the command is a partial command - ModelType(CMDSpecsCommandVersion), - min_size=1, - export_level=NOT_NONE, - ) + versions = ListType(ModelType(CMDSpecsCommandVersion), required=True, min_size=1) class Options: serialize_when_none = False @@ -70,12 +66,10 @@ class CMDSpecsCommandGroup(Model): command_groups = DictType( field=ModelType("CMDSpecsCommandGroup"), serialized_name="commandGroups", - deserialize_from="commandGroups", - export_level=NOT_NONE, + deserialize_from="commandGroups" ) commands = DictType( - field=ModelType(CMDSpecsCommand), - export_level=NOT_NONE, + field=ModelType(CMDSpecsCommand) ) class Options: diff --git a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py index d93f9f03..3ebd2e2a 100644 --- a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py +++ b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py @@ -2,7 +2,7 @@ import unittest from command.controller.command_tree import CMDSpecsPartialCommand, CMDSpecsPartialCommandGroup, \ - CMDSpecsPartialCommandTree, to_limited_primitive, build_simple_command_tree + CMDSpecsPartialCommandTree, build_simple_command_tree from command.model.configuration import CMDHelp COMMAND_INFO = """# [Command] _vm deallocate_ @@ -311,10 +311,6 @@ def test_partial_command_group_to_primitive(self): command_tree = CMDSpecsPartialCommandTree(aaz_folder) cg = command_tree.find_command_group('acat') self.assertIsInstance(cg.command_groups.get_raw_item('report'), CMDSpecsPartialCommandGroup) - primitive = to_limited_primitive(cg) - self.assertListEqual(primitive['names'], cg.names) - self.assertEqual(primitive['help']['short'], cg.help.short) - self.assertIsInstance(cg.command_groups.get_raw_item('report'), CMDSpecsPartialCommandGroup) @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") def test_simple_command_tree(self): diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 834ffa12..c5e1a0f1 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -69,7 +69,7 @@ interface CLISpecsCommandVersion { interface CLISpecsCommand { names: string[], help: CLISpecsHelp, - versions?: CLISpecsCommandVersion[], + versions: CLISpecsCommandVersion[], } interface CLISpecsCommandGroup { From ae0ef927203490e4c6978a18a019bdf822262b20 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 30 Oct 2024 16:24:45 +0800 Subject: [PATCH 14/24] Fix style --- src/aaz_dev/command/api/specs.py | 2 +- src/aaz_dev/command/controller/specs_manager.py | 14 ++++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/aaz_dev/command/api/specs.py b/src/aaz_dev/command/api/specs.py index 0ebf813c..f2cedebe 100644 --- a/src/aaz_dev/command/api/specs.py +++ b/src/aaz_dev/command/api/specs.py @@ -30,7 +30,7 @@ def command_tree_node(node_names): node = manager.find_command_group(*node_names) if not node: raise exceptions.ResourceNotFind("Command group not exist") - + result = node.to_primitive() return jsonify(result) diff --git a/src/aaz_dev/command/controller/specs_manager.py b/src/aaz_dev/command/controller/specs_manager.py index f3a2434a..50063f86 100644 --- a/src/aaz_dev/command/controller/specs_manager.py +++ b/src/aaz_dev/command/controller/specs_manager.py @@ -40,11 +40,10 @@ def __init__(self): @property def tree(self): return self._tree - - @tree.setter - def tree(self, value): - logger.info("Set Command Tree") - self._tree = value + + @property + def simple_tree(self): + return self.tree.simple_tree # Commands folder def get_tree_file_path(self): @@ -112,11 +111,6 @@ def get_resource_versions(self, plane, resource_id): elif file_name.endswith('.md'): versions.add(file_name[:-3]) return sorted(versions, reverse=True) - - # Command Tree - @property - def simple_tree(self): - return self.tree.simple_tree def find_command_group(self, *cg_names): return self.tree.find_command_group(*cg_names) From d64c36aaa0d491acb68616121dabd61ebeaf8a6c Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 30 Oct 2024 17:16:29 +0800 Subject: [PATCH 15/24] Support aaz model missing detect --- .../cli/CLIModGeneratorProfileCommandTree.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 8d6eab7e..181d10b8 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -641,6 +641,18 @@ function initializeCommandByModView(view: CLIModViewCommand | undefined, simpleC function initializeCommandGroupByModView(view: CLIModViewCommandGroup | undefined, simpleCommandGroup: CLISpecsSimpleCommandGroup): ProfileCTCommandGroup { const commands = simpleCommandGroup.commands !== undefined ? Object.fromEntries(Object.entries(simpleCommandGroup.commands).map(([key, value]) => [key, initializeCommandByModView(view?.commands?.[key], value)]) ) : undefined; const commandGroups = simpleCommandGroup.commandGroups !== undefined ? Object.fromEntries(Object.entries(simpleCommandGroup.commandGroups).map(([key, value]) => [key, initializeCommandGroupByModView(view?.commandGroups?.[key], value)]) ) : undefined; + const leftCommands = Object.entries(view?.commands ?? {}).filter(([key, _]) => commands?.[key] === undefined).map(([_, value]) => value.names).map((names) => '`az ' + names.join(" ") + '`'); + const leftCommandGroups = Object.entries(view?.commandGroups ?? {}).filter(([key, _]) => commandGroups?.[key] === undefined).map(([_, value]) => value.names).map((names) => '`az ' + names.join(" ") + '`'); + const errors = []; + if (leftCommands.length > 0) { + errors.push(`Miss commands in aaz: ${leftCommands.join(', ')}`); + } + if (leftCommandGroups.length > 0) { + errors.push(`Miss command groups in aaz: ${leftCommandGroups.join(', ')}`); + } + if (errors.length > 0) { + throw new Error(errors.join('\n') + '\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.'); + } const selected = calculateSelected(commands ?? {}, commandGroups ?? {}); return { id: simpleCommandGroup.names.join('/'), @@ -655,6 +667,10 @@ function initializeCommandGroupByModView(view: CLIModViewCommandGroup | undefine function InitializeCommandTreeByModView(profileName: string, view: CLIModViewProfile | null, simpleTree: CLISpecsSimpleCommandTree): ProfileCommandTree { const commandGroups = Object.fromEntries(Object.entries(simpleTree.root.commandGroups).map(([key, value]) => [key, initializeCommandGroupByModView(view?.commandGroups?.[key], value)])); + const leftCommandGroups = Object.entries(view?.commandGroups ?? {}).filter(([key, _]) => commandGroups?.[key] === undefined).map(([_, value]) => value.names).map((names) => '`az ' + names.join(" ") + '`'); + if (leftCommandGroups.length > 0) { + throw new Error(`Miss command groups in aaz: ${leftCommandGroups.join(', ')}\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.`); + } return { name: profileName, commandGroups: commandGroups, From 5e84be2a884c85265c99f7320a6f9bc2487e6d13 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 30 Oct 2024 18:15:47 +0800 Subject: [PATCH 16/24] Fix error display --- src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx | 4 ++-- src/web/src/views/cli/CLIModuleGenerator.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 181d10b8..9119aab9 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -651,7 +651,7 @@ function initializeCommandGroupByModView(view: CLIModViewCommandGroup | undefine errors.push(`Miss command groups in aaz: ${leftCommandGroups.join(', ')}`); } if (errors.length > 0) { - throw new Error(errors.join('\n') + '\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.'); + throw new Error('\n' + errors.join('\n') + '\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.'); } const selected = calculateSelected(commands ?? {}, commandGroups ?? {}); return { @@ -669,7 +669,7 @@ function InitializeCommandTreeByModView(profileName: string, view: CLIModViewPro const commandGroups = Object.fromEntries(Object.entries(simpleTree.root.commandGroups).map(([key, value]) => [key, initializeCommandGroupByModView(view?.commandGroups?.[key], value)])); const leftCommandGroups = Object.entries(view?.commandGroups ?? {}).filter(([key, _]) => commandGroups?.[key] === undefined).map(([_, value]) => value.names).map((names) => '`az ' + names.join(" ") + '`'); if (leftCommandGroups.length > 0) { - throw new Error(`Miss command groups in aaz: ${leftCommandGroups.join(', ')}\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.`); + throw new Error(`\nMiss command groups in aaz: ${leftCommandGroups.join(', ')}\nSee: https://azure.github.io/aaz-dev-tools/pages/usage/cli-generator/#miss-command-models.`); } return { name: profileName, diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index c5e1a0f1..3d46b2ad 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -186,7 +186,6 @@ const CLIModuleGenerator: React.FC = ({ params }) => { } else { setInvalidText(`Error: ${err}`); } - setLoading(false); } }; @@ -280,6 +279,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { flexDirection: "column", alignItems: "stretch", justifyContent: "flex-start", + whiteSpace: "pre-line", }} variant="filled" severity='error' From bb656dbbe90b49d028366c13adc9b9d25425ed33 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Wed, 30 Oct 2024 18:30:46 +0800 Subject: [PATCH 17/24] Modify let to const --- src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx | 2 +- src/web/src/views/cli/CLIModuleGenerator.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index 9119aab9..f1c9bed4 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -568,7 +568,7 @@ function PrepareLoadCommands(tree: ProfileCommandTree): [string[][], ProfileComm } function genericUpdateCommand(tree: ProfileCommandTree, names: string[], updater: (command: ProfileCTCommand) => ProfileCTCommand | undefined): ProfileCommandTree | undefined { - let nodes: ProfileCTCommandGroup[] = []; + const nodes: ProfileCTCommandGroup[] = []; for (const name of names.slice(0, -1)) { const node = nodes.length === 0 ? tree : nodes[nodes.length - 1]; if (node.commandGroups === undefined) { diff --git a/src/web/src/views/cli/CLIModuleGenerator.tsx b/src/web/src/views/cli/CLIModuleGenerator.tsx index 3d46b2ad..c007bd55 100644 --- a/src/web/src/views/cli/CLIModuleGenerator.tsx +++ b/src/web/src/views/cli/CLIModuleGenerator.tsx @@ -163,7 +163,7 @@ const CLIModuleGenerator: React.FC = ({ params }) => { const simpleTree: CLISpecsSimpleCommandTree = await axios.get(`/AAZ/Specs/CommandTree/Simple`).then(res => res.data); Object.keys(modView!.profiles).forEach((profile) => { - let idx = profiles.findIndex(v => v === profile); + const idx = profiles.findIndex(v => v === profile); if (idx === -1) { throw new Error(`Invalid profile ${profile}`); } From c59d9c19932f40c5c08c08768720ee66b84dbd45 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Thu, 31 Oct 2024 15:49:07 +0800 Subject: [PATCH 18/24] Remove Tree.json Gen & Fix regiest state --- src/aaz_dev/command/controller/specs_manager.py | 4 ++-- src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/aaz_dev/command/controller/specs_manager.py b/src/aaz_dev/command/controller/specs_manager.py index 50063f86..e9681dc9 100644 --- a/src/aaz_dev/command/controller/specs_manager.py +++ b/src/aaz_dev/command/controller/specs_manager.py @@ -304,8 +304,8 @@ def save(self): update_files = {} command_groups = set() - tree_path = self.get_tree_file_path() - update_files[tree_path] = json.dumps(self.tree.to_model().to_primitive(), indent=2, sort_keys=True) + # tree_path = self.get_tree_file_path() + # update_files[tree_path] = json.dumps(self.tree.to_model().to_primitive(), indent=2, sort_keys=True) # command for cmd_names in sorted(self.tree._modified_commands): diff --git a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx index f1c9bed4..bbdfc269 100644 --- a/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx +++ b/src/web/src/views/cli/CLIModGeneratorProfileCommandTree.tsx @@ -357,7 +357,7 @@ const CLIModGeneratorProfileCommandTree: React.FC { const newTree = commands.reduce((tree, command) => { return genericUpdateCommand(tree, command.names, (unloadedCommand) => { - return decodeProfileCTCommand(command, unloadedCommand.selected, unloadedCommand.modified); + return decodeProfileCTCommand(command, unloadedCommand.selected, unloadedCommand.modified, unloadedCommand.registered); }) ?? tree; }, profileCommandTree); return newTree; @@ -457,7 +457,7 @@ function decodeProfileCTCommandVersion(response: any): ProfileCTCommandVersion { } -function decodeProfileCTCommand(response: CLISpecsCommand, selected: boolean = false, modified: boolean = false): ProfileCTCommand { +function decodeProfileCTCommand(response: CLISpecsCommand, selected: boolean = false, modified: boolean = false, registered: boolean | undefined = undefined): ProfileCTCommand { const versions = response.versions?.map((value: any) => decodeProfileCTCommandVersion(value)); const command = { id: response.names.join('/'), @@ -467,6 +467,7 @@ function decodeProfileCTCommand(response: CLISpecsCommand, selected: boolean = f modified: modified, loading: false, selected: selected, + registered: registered, } if (selected) { const selectedVersion = versions ? versions[0].name : undefined; From ccebb50d051fd4b462bb62b2d8ff6305928b4f77 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Thu, 31 Oct 2024 16:15:24 +0800 Subject: [PATCH 19/24] Fix slow export model --- .../command/controller/command_tree.py | 32 +++++++++++++++++++ .../command/controller/specs_manager.py | 8 ++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index 0cf2f050..69e297a0 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -605,6 +605,38 @@ def verify_command_tree(self): if details: raise exceptions.VerificationError(message="Invalid Command Tree", details=details) + def verify_updated_command_tree(self): + details = {} + for group_names in self._modified_command_groups: + group = self.find_command_group(*group_names) + if group == self.root: + continue + if not group: + details[' '.join(group_names)] = { + 'type': 'group', + 'help': "Miss short summary." + } + elif not group.help or not group.help.short: + details[' '.join(group.names)] = { + 'type': 'group', + 'help': "Miss short summary." + } + + for cmd_names in self._modified_commands: + cmd = self.find_command(*cmd_names) + if not cmd: + details[' '.join(cmd_names)] = { + 'type': 'command', + 'help': "Miss short summary." + } + elif not cmd.help or not cmd.help.short: + details[' '.join(cmd.names)] = { + 'type': 'command', + 'help': "Miss short summary." + } + if details: + raise exceptions.VerificationError(message="Invalid Command Tree", details=details) + def to_model(self): tree = CMDSpecsCommandTree() tree.root = self.root diff --git a/src/aaz_dev/command/controller/specs_manager.py b/src/aaz_dev/command/controller/specs_manager.py index e9681dc9..c5c66888 100644 --- a/src/aaz_dev/command/controller/specs_manager.py +++ b/src/aaz_dev/command/controller/specs_manager.py @@ -215,6 +215,9 @@ def update_command_by_ws(self, ws_leaf): def verify_command_tree(self): return self.tree.verify_command_tree() + def verify_updated_command_tree(self): + return self.tree.verify_updated_command_tree() + def _remove_cfg(self, cfg): cfg_reader = CfgReader(cfg) @@ -297,16 +300,13 @@ def update_client_cfg(self, cfg): self._modified_resource_client_cfgs[key] = cfg def save(self): - self.verify_command_tree() + self.verify_updated_command_tree() remove_files = [] remove_folders = [] update_files = {} command_groups = set() - # tree_path = self.get_tree_file_path() - # update_files[tree_path] = json.dumps(self.tree.to_model().to_primitive(), indent=2, sort_keys=True) - # command for cmd_names in sorted(self.tree._modified_commands): cmd = self.find_command(*cmd_names) From 1d4cf91f067675cf6eb726e8c8e05181ed24ad50 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Thu, 31 Oct 2024 16:47:20 +0800 Subject: [PATCH 20/24] Fix Empty Folder in Commands --- src/aaz_dev/command/controller/command_tree.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index 69e297a0..8c044673 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -29,6 +29,8 @@ def _build_simple_command_group(names, aaz_path): rel_names = [] # uri = '/Commands/' + '/'.join(rel_names) + f'/readme.md' full_path = os.path.join(aaz_path, 'Commands', *rel_names) + if rel_names and not os.path.exists(os.path.join(full_path, 'readme.md')): + return None commands = {} command_groups = {} for dir in os.listdir(full_path): @@ -39,7 +41,9 @@ def _build_simple_command_group(names, aaz_path): commands[command_name] = _build_simple_command(rel_names + [command_name]) else: cg_name = dir - command_groups[cg_name] = _build_simple_command_group(rel_names + [cg_name], aaz_path) + group = _build_simple_command_group(rel_names + [cg_name], aaz_path) + if group: + command_groups[cg_name] = group cg = CMDSpecsSimpleCommandGroup() cg.names = names cg.commands = commands From 8a6d06c084d4b6edffeefe7ce81094286cf419d9 Mon Sep 17 00:00:00 2001 From: Qinkai Wu <32201005+ReaNAiveD@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:15:14 +0800 Subject: [PATCH 21/24] Update src/aaz_dev/command/controller/command_tree.py Co-authored-by: kai ru <69238381+kairu-ms@users.noreply.github.com> --- src/aaz_dev/command/controller/command_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index 8c044673..9a78441f 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -35,7 +35,7 @@ def _build_simple_command_group(names, aaz_path): command_groups = {} for dir in os.listdir(full_path): if os.path.isfile(os.path.join(full_path, dir)): - if dir == 'readme.md' or dir == 'tree.json': + if not dir.endswith('.md') or dir == 'readme.md': continue command_name = dir[1:-3] commands[command_name] = _build_simple_command(rel_names + [command_name]) From c27ed08a67033b73c77a425f2f2b944c37842031 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Mon, 18 Nov 2024 17:16:05 +0800 Subject: [PATCH 22/24] Use a block by block way to parse the command groups --- .../command/controller/command_tree.py | 144 ++++++++++-------- 1 file changed, 80 insertions(+), 64 deletions(-) diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index 9a78441f..26ef8d12 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -68,74 +68,90 @@ def __init__(self, names, short_help, uri, aaz_path): @classmethod def parse_command_group_info(cls, info, cg_names, aaz_path): - prev_line = None - title = None - short_help = None - long_help = [] - cur_sub_block = None - block_items = None - command_groups = [] - commands = [] - in_code_block = False - - for line in info.splitlines(keepends=False): - line = line.strip() - - if line.startswith('"""'): - in_code_block = not in_code_block - continue - elif in_code_block: - continue + lines = info.splitlines(keepends=False) - if line.startswith("# ") and not title: - title = line[2:] - elif line.startswith("## "): - cur_sub_block = line[3:] - if cur_sub_block in ["Groups", "Subgroups"]: - block_items = command_groups - elif cur_sub_block in ["Commands"]: - block_items = commands - else: - block_items = None - elif line and not cur_sub_block and not short_help: - short_help = line - elif line and not cur_sub_block and not long_help and prev_line: - short_help = short_help + '\n' + line - elif line and not cur_sub_block: - long_help.append(line) - elif line.startswith('- ['): - name = line[3:].split(']')[0] - uri = line.split('(')[-1].split(')')[0] - if cur_sub_block in ["Groups", "Subgroups"]: - item = CMDSpecsPartialCommandGroup([*cg_names, name], None, uri, aaz_path) - elif cur_sub_block in ["Commands"]: - item = CMDSpecsPartialCommand([*cg_names, name], None, uri, aaz_path) - else: - continue - if block_items is not None: - block_items.append((name, item)) - elif line.startswith(': '): - if block_items: - block_items[-1][1].short_help = line[2:] - elif line and prev_line: - if block_items: - block_items[-1][1].short_help += '\n' + line - prev_line = line cg = CMDSpecsCommandGroup() - if not cg_names: - cg.names = ["aaz"] - else: - cg.names = list(cg_names) - if not short_help: - cg.help = None - else: - cg.help = CMDHelp() - cg.help.short = short_help - cg.help.lines = long_help if long_help else None - cg.command_groups = CMDSpecsCommandGroupDict(command_groups) - cg.commands = CMDSpecsCommandDict(commands) + _, _, remaining_lines = cls._parse_title(lines) + cg.names = list(cg_names) or ["aaz"] + cg.help, remaining_lines = cls._parse_help(remaining_lines) + cg.command_groups, remaining_lines = cls._parse_groups(remaining_lines, cg_names, aaz_path) + cg.commands, _ = cls._parse_commands(remaining_lines, cg_names, aaz_path) + return cg + @classmethod + def _parse_title(cls, lines): + assert len(lines) > 0 and lines[0].startswith('# ') + title_line = lines[0].strip() + if not title_line.endswith('_'): # root + return None, None, lines[2:] + _, category, names = title_line.split(maxsplit=2) + return category[1: -1], names[1: -1], lines[2:] + + @classmethod + def _parse_help(cls, lines): + assert len(lines) > 0 + if lines[0].startswith('## '): # root + return None, lines + short_help, remaining_lines = cls._read_until(lines, lambda line: not line) + short_help = '\n'.join(short_help) + remaining_lines = cls._del_empty(remaining_lines) + long_help, remaining_lines = cls._read_until(remaining_lines, lambda line: line.startswith('## ')) + long_help = long_help[:-1] if long_help and not long_help[-1] else long_help # Delete last line if empty + return CMDHelp(raw_data={'short': short_help, 'lines': long_help or None}), remaining_lines + + @classmethod + def _parse_groups(cls, lines: list[str], cg_names, aaz_path): + if lines and lines[0] in ['## Groups', '## Subgroups']: + groups = [] + remaining_lines = lines[2:] + while remaining_lines and not remaining_lines[0].startswith('## '): + group, remaining_lines = cls._parse_item( + remaining_lines, CMDSpecsPartialCommandGroup, cg_names, aaz_path) + groups.append((group.names[-1], group)) + return CMDSpecsCommandGroupDict(groups), remaining_lines + return CMDSpecsCommandGroupDict([]), lines + + @classmethod + def _parse_commands(cls, lines: list[str], cg_names, aaz_path): + if lines and lines[0] in ['## Commands']: + commands = [] + remaining_lines = lines[2:] + while remaining_lines and not remaining_lines[0].startswith('## '): + command, remaining_lines = cls._parse_item( + remaining_lines, CMDSpecsPartialCommand, cg_names, aaz_path) + commands.append((command.names[-1], command)) + return CMDSpecsCommandDict(commands), remaining_lines + return CMDSpecsCommandDict([]), lines + + @classmethod + def _parse_item(cls, lines, item_cls, cg_names, aaz_path): + assert len(lines) > 1 + name_line = lines[0] + assert name_line.startswith('- [') + name = name_line[3:].split(']')[0] + uri = name_line.split('(')[-1].split(')')[0] + short_help, remaining_lines = cls._read_until(lines[1:], lambda line: not line) + remaining_lines = cls._del_empty(remaining_lines) + short_help = '\n'.join(short_help) + return item_cls(names=[*cg_names, name], short_help=short_help, uri=uri, aaz_path=aaz_path), remaining_lines + + @classmethod + def _read_until(cls, lines, predicate): + result = [] + for idx in range(0, len(lines)): + if predicate(lines[idx]): + return result, lines[idx:] + result.append(lines[idx]) + return result, [] + + @classmethod + def _del_empty(cls, lines): + for idx in range(0, len(lines)): + if lines[idx]: + return lines[idx:] + return lines + def load(self): with open(self.aaz_path + self.uri, "r", encoding="utf-8") as f: content = f.read() From 4bdfec188b91ac8097004dc55648fb6a1e2b929d Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Fri, 6 Dec 2024 11:10:19 +0800 Subject: [PATCH 23/24] Drop Command Tree Patching Logic since we drop `tree.json` directly --- .../command/controller/command_tree.py | 36 ---------------- .../tests/spec_tests/test_command_tree.py | 42 +------------------ 2 files changed, 1 insertion(+), 77 deletions(-) diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index 26ef8d12..31db0cef 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -569,42 +569,6 @@ def update_command_by_ws(self, ws_leaf): self._modified_commands.add(tuple(command.names)) return command - def patch_partial_items(self, aaz_command_tree: CMDSpecsCommandTree): - aaz_command_tree = CMDSpecsPartialCommandTree(self.aaz_path, aaz_command_tree.root) - nodes = [self.root] - i = 0 - while i < len(nodes): - command_group = nodes[i] - if isinstance(command_group, CMDSpecsCommandGroup): - if isinstance(command_group.command_groups, CMDSpecsCommandGroupDict): - for key in command_group.command_groups.keys(): - raw_cg = command_group.command_groups.get_raw_item(key) - if isinstance(raw_cg, CMDSpecsCommandGroup): - nodes.append(raw_cg) - elif isinstance(raw_cg, CMDSpecsPartialCommandGroup): - command_group.command_groups[key] = aaz_command_tree.find_command_group(*raw_cg.names) - elif isinstance(command_group.command_groups, dict): - for cg in command_group.command_groups.values(): - nodes.append(cg) - if isinstance(command_group.commands, CMDSpecsCommandDict): - for key in command_group.commands.keys(): - raw_command = command_group.commands.get_raw_item(key) - if isinstance(raw_command, CMDSpecsPartialCommand): - command_group.commands[key] = aaz_command_tree.find_command(*raw_command.names) - i += 1 - - def patch(self): - tree_path = os.path.join(self.aaz_path, "Commands", "tree.json") - if not (os.path.exists(tree_path) and os.path.isfile(tree_path)): - return - try: - with open(tree_path, 'r', encoding="utf-8") as f: - data = json.load(f) - aaz_command_tree = CMDSpecsCommandTree(data) - self.patch_partial_items(aaz_command_tree) - except json.decoder.JSONDecodeError as e: - raise ValueError(f"Invalid Command Tree file: {tree_path}") from e - def verify_command_tree(self): details = {} for group in self.iter_command_groups(): diff --git a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py index 3ebd2e2a..a26d5b63 100644 --- a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py +++ b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py @@ -265,46 +265,6 @@ def test_load_command_group(self): self.assertEqual(group.commands["check-name-availability"].help.short, "Check whether the resource name is available in the given region.") group.validate() - @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") - def test_load_command_tree_from_disk(self): - aaz_folder = os.getenv("AAZ_FOLDER") - command_tree = CMDSpecsPartialCommandTree(aaz_folder) - self.assertIsNotNone(command_tree.root) - self.assertEqual(len(command_tree.root.commands), 0) - self.assertNotEqual(len(command_tree.root.command_groups), 0) - command_tree.iter_commands() - command_tree.to_model().validate() - command_tree_json = command_tree.to_model().to_primitive() - aaz_tree_path = os.path.join(aaz_folder, 'Commands', 'tree.json') - with open(aaz_tree_path, 'r', encoding='utf-8') as f: - import json - aaz_tree = json.load(f) - # command_tree_json_str = json.dumps(command_tree_json, sort_keys=True) - # aaz_tree_str = json.dumps(aaz_tree, sort_keys=True) - # with open(os.path.join(aaz_folder, 'Commands', 'tmp_tree.json'), 'w') as f: - # json.dump(command_tree_json, f, indent=2, sort_keys=True) - print("Dumped Command Tree String Len: " + str(len(json.dumps(command_tree_json, sort_keys=True)))) - print("Dumped AAZ Tree String Len: " + str(len(json.dumps(aaz_tree, sort_keys=True)))) - # self.assertEqual(command_tree_json_str, aaz_tree_str) - - @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") - def test_patch(self): - aaz_folder = os.getenv("AAZ_FOLDER") - command_tree = CMDSpecsPartialCommandTree(aaz_folder) - cg = command_tree.create_command_group('fake_cg') - cg.help = CMDHelp() - cg.help.short = 'HELP' - command = command_tree.create_command('fake_cg', 'fake_new_command') - command.help = CMDHelp() - command.help.short = 'HELP' - for version in command_tree.find_command('acat', 'report', 'snapshot', 'download').versions: - command_tree.delete_command_version('acat', 'report', 'snapshot', 'download', version=version.name) - command_tree.delete_command('acat', 'report', 'snapshot', 'download') - - command_tree.patch() - self.assertNotIn('download', command_tree.find_command_group('acat', 'report', 'snapshot').commands) - self.assertIn('fake_new_command', command_tree.find_command_group('fake_cg').commands) - @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") def test_partial_command_group_to_primitive(self): aaz_folder = os.getenv("AAZ_FOLDER") @@ -316,4 +276,4 @@ def test_partial_command_group_to_primitive(self): def test_simple_command_tree(self): aaz_folder = os.getenv("AAZ_FOLDER") simple_tree = build_simple_command_tree(aaz_folder) - print() + self.assertGreater(len(simple_tree.root.command_groups), 0) From 979a92e996eb94d49472215258cb6b13137b1b38 Mon Sep 17 00:00:00 2001 From: qinkaiwu Date: Fri, 6 Dec 2024 13:38:25 +0800 Subject: [PATCH 24/24] Fix multi-line help extraction error --- src/aaz_dev/command/controller/command_tree.py | 3 +-- .../command/tests/spec_tests/test_command_tree.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/aaz_dev/command/controller/command_tree.py b/src/aaz_dev/command/controller/command_tree.py index 31db0cef..e180737d 100644 --- a/src/aaz_dev/command/controller/command_tree.py +++ b/src/aaz_dev/command/controller/command_tree.py @@ -1,4 +1,3 @@ -import json import logging import os import re @@ -273,7 +272,7 @@ def parse_command_info(cls, info, cmd_names): logger.warning(f"Invalid command info markdown: \n{info}") return None if command_match.group("lines_help"): - lines_help = command_match.group("lines_help").strip().split("\n") + lines_help = command_match.group("lines_help").strip().split("\\\n") else: lines_help = None help = CMDHelp() diff --git a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py index a26d5b63..bc69b044 100644 --- a/src/aaz_dev/command/tests/spec_tests/test_command_tree.py +++ b/src/aaz_dev/command/tests/spec_tests/test_command_tree.py @@ -3,13 +3,14 @@ from command.controller.command_tree import CMDSpecsPartialCommand, CMDSpecsPartialCommandGroup, \ CMDSpecsPartialCommandTree, build_simple_command_tree -from command.model.configuration import CMDHelp +from command.templates import get_templates COMMAND_INFO = """# [Command] _vm deallocate_ Deallocate a VM so that computing resources are no longer allocated (charges no longer apply). The status will change from 'Stopped' to 'Stopped (Deallocated)'. -For an end-to-end tutorial, see https://docs.microsoft.com/azure/virtual-machines/linux/capture-image +For an end-to-end tutorial, see https://docs.microsoft.com/azure/virtual-machines/linux/capture-image \\ +Test Second Line ## Versions @@ -228,7 +229,8 @@ def test_load_command(self): self.assertEqual(command.names, ["vm", "deallocate"]) self.assertEqual(command.help.short, "Deallocate a VM so that computing resources are no longer allocated (charges no longer apply). The status will change from 'Stopped' to 'Stopped (Deallocated)'.") self.assertListEqual(command.help.lines, [ - "For an end-to-end tutorial, see https://docs.microsoft.com/azure/virtual-machines/linux/capture-image" + "For an end-to-end tutorial, see https://docs.microsoft.com/azure/virtual-machines/linux/capture-image ", + "Test Second Line" ]) self.assertEqual(len(command.versions), 4) self.assertEqual(command.versions[0].name, "2017-03-30") @@ -247,6 +249,12 @@ def test_load_command(self): ]) command.validate() + def test_render_command(self): + command = CMDSpecsPartialCommand.parse_command_info(COMMAND_INFO, ["vm", "deallocate"]) + tmpl = get_templates()["command"] + display = tmpl.render(command=command) + self.assertEqual(display, COMMAND_INFO) + @unittest.skipIf(os.getenv("AAZ_FOLDER") is None, "No AAZ_FOLDER environment variable set") def test_load_command_group(self): aaz_folder = os.getenv("AAZ_FOLDER")