diff --git a/Makefile b/Makefile index dd80957cae..027b9adab1 100644 --- a/Makefile +++ b/Makefile @@ -34,18 +34,18 @@ smoke-test: lint: # Linter performs static analysis to catch latent bugs - ruff samcli + ruff samcli schema # mypy performs type check - mypy --exclude /testdata/ --exclude /init/templates/ --no-incremental setup.py samcli tests + mypy --exclude /testdata/ --exclude /init/templates/ --no-incremental setup.py samcli tests schema # Command to run everytime you make changes to verify everything works dev: lint test black: - black setup.py samcli tests + black setup.py samcli tests schema black-check: - black --check setup.py samcli tests + black --check setup.py samcli tests schema format: black ruff samcli --fix diff --git a/samcli/lib/config/samconfig.py b/samcli/lib/config/samconfig.py index e48e53d625..2c3ba303a6 100644 --- a/samcli/lib/config/samconfig.py +++ b/samcli/lib/config/samconfig.py @@ -79,7 +79,7 @@ def get_all(self, cmd_names, section, env=DEFAULT_ENV): self._read() if isinstance(self.document, dict): toml_content = self.document.get(env, {}) - params = toml_content.get(self._to_key(cmd_names), {}).get(section, {}) + params = toml_content.get(self.to_key(cmd_names), {}).get(section, {}) if DEFAULT_GLOBAL_CMDNAME in toml_content: global_params = toml_content.get(DEFAULT_GLOBAL_CMDNAME, {}).get(section, {}) global_params.update(params.copy()) @@ -119,7 +119,7 @@ def put(self, cmd_names, section, key, value, env=DEFAULT_ENV): # Empty document prepare the initial structure. # self.document is a nested dict, we need to check each layer and add new tables, otherwise duplicated key # in parent layer will override the whole child layer - cmd_name_key = self._to_key(cmd_names) + cmd_name_key = self.to_key(cmd_names) env_content = self.document.get(env, {}) cmd_content = env_content.get(cmd_name_key, {}) param_content = cmd_content.get(section, {}) @@ -267,6 +267,6 @@ def _version_sanity_check(version: Any) -> None: raise SamConfigVersionException(f"'{VERSION_KEY}' key is not present or is in unrecognized format. ") @staticmethod - def _to_key(cmd_names: Iterable[str]) -> str: + def to_key(cmd_names: Iterable[str]) -> str: # construct a parsed name that is of the format: a_b_c_d return "_".join([cmd.replace("-", "_").replace(" ", "_") for cmd in cmd_names]) diff --git a/schema/schema.py b/schema/schema.py new file mode 100644 index 0000000000..a2925dc2f7 --- /dev/null +++ b/schema/schema.py @@ -0,0 +1,105 @@ +"""Handles JSON schema generation logic""" + + +import importlib +from typing import Dict, List, Optional, Union + +import click + +from samcli.cli.command import _SAM_CLI_COMMAND_PACKAGES +from samcli.lib.config.samconfig import SamConfig + + +def format_param(param: click.core.Option) -> Dict[str, Union[Optional[str], List[str]]]: + """Format a click Option parameter to a dictionary object. + + A parameter object should contain the following information that will be + necessary for including in the JSON schema: + * name - The name of the parameter + * help - The parameter's description (may vary between commands) + * type - The data type accepted by the parameter + * type.choices - If there are only a certain number of options allowed, + a list of those allowed options + * default - The default option for that parameter + """ + formatted_param: Dict[str, Union[Optional[str], List[str]]] = {"name": param.name, "help": param.help} + + # NOTE: Params do not have explicit "string" type; either "text" or "path". + # All choice options are from a set of strings. + if param.type.name in ["text", "path", "choice"]: + formatted_param["type"] = "string" + else: + formatted_param["type"] = param.type.name + + if param.default: + formatted_param["default"] = str(param.default) + + if param.type.name == "choice" and isinstance(param.type, click.Choice): + formatted_param["choices"] = list(param.type.choices) + + return formatted_param + + +def get_params_from_command(cli, main_command_name: str = "") -> Dict[str, dict]: + """Given a command CLI, return it in a dictionary, pointing to its parameters as dictionary objects.""" + params = [ + param + for param in cli.params + if param.name and isinstance(param, click.core.Option) # exclude None and non-Options + ] + cmd_name = SamConfig.to_key([main_command_name, cli.name]) if main_command_name else cli.name + return {cmd_name: {param.name: format_param(param) for param in params}} + + +def retrieve_command_structure(package_name: str) -> Dict[str, dict]: + """Given a SAM CLI package name, retrieve its structure. + + Parameters + ---------- + package_name: str + The name of the command package to retrieve. + + Returns + ------- + dict + A dictionary that maps the name of the command to its relevant click options. + """ + module = importlib.import_module(package_name) + command = {} + + if isinstance(module.cli, click.core.Group): # command has subcommands (e.g. local invoke) + for subcommand in module.cli.commands.values(): + command.update( + get_params_from_command( + subcommand, + module.__name__.split(".")[-1], # if Group CLI, get last section of module name for cmd name + ) + ) + else: + command.update(get_params_from_command(module.cli)) + return command + + +def generate_schema() -> dict: + """Generate a JSON schema for all SAM CLI commands. + + Returns + ------- + dict + A dictionary representation of the JSON schema. + """ + schema: dict = {} + commands = {} + params = set() # NOTE(leogama): Currently unused due to some params having different help values + # TODO: Populate schema with relevant attributes + for package_name in _SAM_CLI_COMMAND_PACKAGES: + new_command = retrieve_command_structure(package_name) + commands.update(new_command) + for param_list in new_command.values(): + command_params = [param for param in param_list] + params.update(command_params) + return schema + + +if __name__ == "__main__": + generate_schema()