diff --git a/openpype/hosts/standalonepublisher/addon.py b/openpype/hosts/standalonepublisher/addon.py index 67204b581b2..607c4ecdae1 100644 --- a/openpype/hosts/standalonepublisher/addon.py +++ b/openpype/hosts/standalonepublisher/addon.py @@ -1,10 +1,13 @@ import os -import click - from openpype.lib import get_openpype_execute_args from openpype.lib.execute import run_detached_process -from openpype.modules import OpenPypeModule, ITrayAction, IHostAddon +from openpype.modules import ( + click_wrap, + OpenPypeModule, + ITrayAction, + IHostAddon, +) STANDALONEPUBLISH_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -37,10 +40,10 @@ def run_standalone_publisher(self): run_detached_process(args) def cli(self, click_group): - click_group.add_command(cli_main) + click_group.add_command(cli_main.to_click_obj()) -@click.group( +@click_wrap.group( StandAlonePublishAddon.name, help="StandalonePublisher related commands.") def cli_main(): diff --git a/openpype/hosts/traypublisher/addon.py b/openpype/hosts/traypublisher/addon.py index 3b34f9e6e8b..ca60760bab1 100644 --- a/openpype/hosts/traypublisher/addon.py +++ b/openpype/hosts/traypublisher/addon.py @@ -1,10 +1,13 @@ import os -import click - from openpype.lib import get_openpype_execute_args from openpype.lib.execute import run_detached_process -from openpype.modules import OpenPypeModule, ITrayAction, IHostAddon +from openpype.modules import ( + click_wrap, + OpenPypeModule, + ITrayAction, + IHostAddon, +) TRAYPUBLISH_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -38,10 +41,12 @@ def run_traypublisher(self): run_detached_process(args) def cli(self, click_group): - click_group.add_command(cli_main) + click_group.add_command(cli_main.to_click_obj()) -@click.group(TrayPublishAddon.name, help="TrayPublisher related commands.") +@click_wrap.group( + TrayPublishAddon.name, + help="TrayPublisher related commands.") def cli_main(): pass diff --git a/openpype/hosts/webpublisher/addon.py b/openpype/hosts/webpublisher/addon.py index 4438775b033..810d9aa6c30 100644 --- a/openpype/hosts/webpublisher/addon.py +++ b/openpype/hosts/webpublisher/addon.py @@ -1,8 +1,6 @@ import os -import click - -from openpype.modules import OpenPypeModule, IHostAddon +from openpype.modules import click_wrap, OpenPypeModule, IHostAddon WEBPUBLISHER_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -38,10 +36,10 @@ def headless_publish(self, log, close_plugin_name=None, is_test=False): ) def cli(self, click_group): - click_group.add_command(cli_main) + click_group.add_command(cli_main.to_click_obj()) -@click.group( +@click_wrap.group( WebpublisherAddon.name, help="Webpublisher related commands.") def cli_main(): @@ -49,10 +47,10 @@ def cli_main(): @cli_main.command() -@click.argument("path") -@click.option("-u", "--user", help="User email address") -@click.option("-p", "--project", help="Project") -@click.option("-t", "--targets", help="Targets", default=None, +@click_wrap.argument("path") +@click_wrap.option("-u", "--user", help="User email address") +@click_wrap.option("-p", "--project", help="Project") +@click_wrap.option("-t", "--targets", help="Targets", default=None, multiple=True) def publish(project, path, user=None, targets=None): """Start publishing (Inner command). @@ -67,11 +65,11 @@ def publish(project, path, user=None, targets=None): @cli_main.command() -@click.argument("path") -@click.option("-p", "--project", help="Project") -@click.option("-h", "--host", help="Host") -@click.option("-u", "--user", help="User email address") -@click.option("-t", "--targets", help="Targets", default=None, +@click_wrap.argument("path") +@click_wrap.option("-p", "--project", help="Project") +@click_wrap.option("-h", "--host", help="Host") +@click_wrap.option("-u", "--user", help="User email address") +@click_wrap.option("-t", "--targets", help="Targets", default=None, multiple=True) def publishfromapp(project, path, host, user=None, targets=None): """Start publishing through application (Inner command). @@ -86,10 +84,10 @@ def publishfromapp(project, path, host, user=None, targets=None): @cli_main.command() -@click.option("-e", "--executable", help="Executable") -@click.option("-u", "--upload_dir", help="Upload dir") -@click.option("-h", "--host", help="Host", default=None) -@click.option("-p", "--port", help="Port", default=None) +@click_wrap.option("-e", "--executable", help="Executable") +@click_wrap.option("-u", "--upload_dir", help="Upload dir") +@click_wrap.option("-h", "--host", help="Host", default=None) +@click_wrap.option("-p", "--port", help="Port", default=None) def webserver(executable, upload_dir, host=None, port=None): """Start service for communication with Webpublish Front end. diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index 30978053530..87f3233afce 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from . import click_wrap from .interfaces import ( ILaunchHookPaths, IPluginPaths, @@ -28,6 +29,8 @@ __all__ = ( + "click_wrap", + "ILaunchHookPaths", "IPluginPaths", "ITrayModule", diff --git a/openpype/modules/click_wrap.py b/openpype/modules/click_wrap.py new file mode 100644 index 00000000000..ed67035ec84 --- /dev/null +++ b/openpype/modules/click_wrap.py @@ -0,0 +1,365 @@ +"""Simplified wrapper for 'click' python module. + +Module 'click' is used as main cli handler in AYON/OpenPype. Addons can +register their own subcommands with options. This wrapper allows to define +commands and options as with 'click', but without any dependency. + +Why not to use 'click' directly? Version of 'click' used in AYON/OpenPype +is not compatible with 'click' version used in some DCCs (e.g. Houdini 20+). +And updating 'click' would break other DCCs. + +How to use it? If you already have cli commands defined in addon, just replace +'click' with 'click_wrap' and it should work and modify your addon's cli +method to convert 'click_wrap' object to 'click' object. + +Before +```python +import click +from openpype.modules import OpenPypeModule + + +class ExampleAddon(OpenPypeModule): + name = "example" + + def cli(self, click_group): + click_group.add_command(cli_main) + + +@click.group(ExampleAddon.name, help="Example addon") +def cli_main(): + pass + + +@cli_main.command(help="Example command") +@click.option("--arg1", help="Example argument 1", default="default1") +@click.option("--arg2", help="Example argument 2", is_flag=True) +def mycommand(arg1, arg2): + print(arg1, arg2) +``` + +Now +``` +from openpype import click_wrap +from openpype.modules import OpenPypeModule + + +class ExampleAddon(OpenPypeModule): + name = "example" + + def cli(self, click_group): + click_group.add_command(cli_main.to_click_obj()) + + +@click_wrap.group(ExampleAddon.name, help="Example addon") +def cli_main(): + pass + + +@cli_main.command(help="Example command") +@click_wrap.option("--arg1", help="Example argument 1", default="default1") +@click_wrap.option("--arg2", help="Example argument 2", is_flag=True) +def mycommand(arg1, arg2): + print(arg1, arg2) +``` + + +Added small enhancements: +- most of the methods can be used as chained calls +- functions/methods 'command' and 'group' can be used in a way that + first argument is callback function and the rest are arguments + for click + +Example: + ```python + from openpype import click_wrap + from openpype.modules import OpenPypeModule + + + class ExampleAddon(OpenPypeModule): + name = "example" + + def cli(self, click_group): + # Define main command (name 'example') + main = click_wrap.group( + self._cli_main, name=self.name, help="Example addon" + ) + # Add subcommand (name 'mycommand') + ( + main.command( + self._cli_command, name="mycommand", help="Example command" + ) + .option( + "--arg1", help="Example argument 1", default="default1" + ) + .option( + "--arg2", help="Example argument 2", is_flag=True, + ) + ) + # Convert main command to click object and add it to parent group + click_group.add_command(main.to_click_obj()) + + def _cli_main(self): + pass + + def _cli_command(self, arg1, arg2): + print(arg1, arg2) + ``` + + ```shell + openpype_console addon example mycommand --arg1 value1 --arg2 + ``` +""" + +import collections + +FUNC_ATTR_NAME = "__ayon_cli_options__" + + +class Command(object): + def __init__(self, func, *args, **kwargs): + # Command function + self._func = func + # Command definition arguments + self._args = args + # Command definition kwargs + self._kwargs = kwargs + # Both 'options' and 'arguments' are stored to the same variable + # - keep order of options and arguments + self._options = getattr(func, FUNC_ATTR_NAME, []) + + def to_click_obj(self): + """Converts this object to click object. + + Returns: + click.Command: Click command object. + """ + return convert_to_click(self) + + # --- Methods for 'convert_to_click' function --- + def get_args(self): + """ + Returns: + tuple: Command definition arguments. + """ + return self._args + + def get_kwargs(self): + """ + Returns: + dict[str, Any]: Command definition kwargs. + """ + return self._kwargs + + def get_func(self): + """ + Returns: + Function: Function to invoke on command trigger. + """ + return self._func + + def iter_options(self): + """ + Yields: + tuple[str, tuple, dict]: Option type name with args and kwargs. + """ + for item in self._options: + yield item + # ----------------------------------------------- + + def add_option(self, *args, **kwargs): + return self.add_option_by_type("option", *args, **kwargs) + + def add_argument(self, *args, **kwargs): + return self.add_option_by_type("argument", *args, **kwargs) + + option = add_option + argument = add_argument + + def add_option_by_type(self, option_name, *args, **kwargs): + self._options.append((option_name, args, kwargs)) + return self + + +class Group(Command): + def __init__(self, func, *args, **kwargs): + super(Group, self).__init__(func, *args, **kwargs) + # Store sub-groupd and sub-commands to the same variable + self._commands = [] + + # --- Methods for 'convert_to_click' function --- + def iter_commands(self): + for command in self._commands: + yield command + # ----------------------------------------------- + + def add_command(self, command): + """Add prepared command object as child. + + Args: + command (Command): Prepared command object. + """ + if command not in self._commands: + self._commands.append(command) + + def add_group(self, group): + """Add prepared group object as child. + + Args: + group (Group): Prepared group object. + """ + if group not in self._commands: + self._commands.append(group) + + def command(self, *args, **kwargs): + """Add child command. + + Returns: + Union[Command, Function]: New command object, or wrapper function. + """ + return self._add_new(Command, *args, **kwargs) + + def group(self, *args, **kwargs): + """Add child group. + + Returns: + Union[Group, Function]: New group object, or wrapper function. + """ + return self._add_new(Group, *args, **kwargs) + + def _add_new(self, target_cls, *args, **kwargs): + func = None + if args and callable(args[0]): + args = list(args) + func = args.pop(0) + args = tuple(args) + + def decorator(_func): + out = target_cls(_func, *args, **kwargs) + self._commands.append(out) + return out + + if func is not None: + return decorator(func) + return decorator + + +def convert_to_click(obj_to_convert): + """Convert wrapped object to click object. + + Args: + obj_to_convert (Command): Object to convert to click object. + + Returns: + click.Command: Click command object. + """ + import click + + commands_queue = collections.deque() + commands_queue.append((obj_to_convert, None)) + top_obj = None + while commands_queue: + item = commands_queue.popleft() + command_obj, parent_obj = item + if not isinstance(command_obj, Command): + raise TypeError( + "Invalid type '{}' expected 'Command'".format( + type(command_obj) + ) + ) + + if isinstance(command_obj, Group): + click_obj = ( + click.group( + *command_obj.get_args(), + **command_obj.get_kwargs() + )(command_obj.get_func()) + ) + + else: + click_obj = ( + click.command( + *command_obj.get_args(), + **command_obj.get_kwargs() + )(command_obj.get_func()) + ) + + for item in command_obj.iter_options(): + option_name, args, kwargs = item + if option_name == "option": + click.option(*args, **kwargs)(click_obj) + elif option_name == "argument": + click.argument(*args, **kwargs)(click_obj) + else: + raise ValueError( + "Invalid option name '{}'".format(option_name) + ) + + if top_obj is None: + top_obj = click_obj + + if parent_obj is not None: + parent_obj.add_command(click_obj) + + if isinstance(command_obj, Group): + for command in command_obj.iter_commands(): + commands_queue.append((command, click_obj)) + + return top_obj + + +def group(*args, **kwargs): + func = None + if args and callable(args[0]): + args = list(args) + func = args.pop(0) + args = tuple(args) + + def decorator(_func): + return Group(_func, *args, **kwargs) + + if func is not None: + return decorator(func) + return decorator + + +def command(*args, **kwargs): + func = None + if args and callable(args[0]): + args = list(args) + func = args.pop(0) + args = tuple(args) + + def decorator(_func): + return Command(_func, *args, **kwargs) + + if func is not None: + return decorator(func) + return decorator + + +def argument(*args, **kwargs): + def decorator(func): + return _add_option_to_func( + func, "argument", *args, **kwargs + ) + return decorator + + +def option(*args, **kwargs): + def decorator(func): + return _add_option_to_func( + func, "option", *args, **kwargs + ) + return decorator + + +def _add_option_to_func(func, option_name, *args, **kwargs): + if isinstance(func, Command): + func.add_option_by_type(option_name, *args, **kwargs) + return func + + if not hasattr(func, FUNC_ATTR_NAME): + setattr(func, FUNC_ATTR_NAME, []) + cli_options = getattr(func, FUNC_ATTR_NAME) + cli_options.append((option_name, args, kwargs)) + return func diff --git a/openpype/modules/example_addons/example_addon/addon.py b/openpype/modules/example_addons/example_addon/addon.py index be1d3ff9205..e9de0c1bf52 100644 --- a/openpype/modules/example_addons/example_addon/addon.py +++ b/openpype/modules/example_addons/example_addon/addon.py @@ -8,9 +8,9 @@ """ import os -import click from openpype.modules import ( + click_wrap, JsonFilesSettingsDef, OpenPypeAddOn, ModulesManager, @@ -115,10 +115,12 @@ def get_plugin_paths(self): } def cli(self, click_group): - click_group.add_command(cli_main) + click_group.add_command(cli_main.to_click_obj()) -@click.group(ExampleAddon.name, help="Example addon dynamic cli commands.") +@click_wrap.group( + ExampleAddon.name, + help="Example addon dynamic cli commands.") def cli_main(): pass diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index b5152ff9c4f..2042367a7e0 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -3,9 +3,8 @@ import collections import platform -import click - from openpype.modules import ( + click_wrap, OpenPypeModule, ITrayModule, IPluginPaths, @@ -489,7 +488,7 @@ def get_credentials(self): return cred.get("username"), cred.get("api_key") def cli(self, click_group): - click_group.add_command(cli_main) + click_group.add_command(cli_main.to_click_obj()) def _check_ftrack_url(url): @@ -540,24 +539,24 @@ def resolve_ftrack_url(url, logger=None): return ftrack_url -@click.group(FtrackModule.name, help="Ftrack module related commands.") +@click_wrap.group(FtrackModule.name, help="Ftrack module related commands.") def cli_main(): pass @cli_main.command() -@click.option("-d", "--debug", is_flag=True, help="Print debug messages") -@click.option("--ftrack-url", envvar="FTRACK_SERVER", +@click_wrap.option("-d", "--debug", is_flag=True, help="Print debug messages") +@click_wrap.option("--ftrack-url", envvar="FTRACK_SERVER", help="Ftrack server url") -@click.option("--ftrack-user", envvar="FTRACK_API_USER", +@click_wrap.option("--ftrack-user", envvar="FTRACK_API_USER", help="Ftrack api user") -@click.option("--ftrack-api-key", envvar="FTRACK_API_KEY", +@click_wrap.option("--ftrack-api-key", envvar="FTRACK_API_KEY", help="Ftrack api key") -@click.option("--legacy", is_flag=True, +@click_wrap.option("--legacy", is_flag=True, help="run event server without mongo storing") -@click.option("--clockify-api-key", envvar="CLOCKIFY_API_KEY", +@click_wrap.option("--clockify-api-key", envvar="CLOCKIFY_API_KEY", help="Clockify API key.") -@click.option("--clockify-workspace", envvar="CLOCKIFY_WORKSPACE", +@click_wrap.option("--clockify-workspace", envvar="CLOCKIFY_WORKSPACE", help="Clockify workspace") def eventserver( debug, diff --git a/openpype/modules/job_queue/module.py b/openpype/modules/job_queue/module.py index 7075fcea14d..6792cd2aca0 100644 --- a/openpype/modules/job_queue/module.py +++ b/openpype/modules/job_queue/module.py @@ -41,8 +41,7 @@ import copy import platform -import click -from openpype.modules import OpenPypeModule +from openpype.modules import OpenPypeModule, click_wrap from openpype.settings import get_system_settings @@ -153,7 +152,7 @@ def get_job_status(self, job_id): return requests.get(api_path).json() def cli(self, click_group): - click_group.add_command(cli_main) + click_group.add_command(cli_main.to_click_obj()) @classmethod def get_server_url_from_settings(cls): @@ -213,7 +212,7 @@ def _start_tvpaint_worker(cls, app, server_url): return main(str(executable), server_url) -@click.group( +@click_wrap.group( JobQueueModule.name, help="Application job server. Can be used as render farm." ) @@ -225,8 +224,8 @@ def cli_main(): "start_server", help="Start server handling workers and their jobs." ) -@click.option("--port", help="Server port") -@click.option("--host", help="Server host (ip address)") +@click_wrap.option("--port", help="Server port") +@click_wrap.option("--host", help="Server host (ip address)") def cli_start_server(port, host): JobQueueModule.start_server(port, host) @@ -236,7 +235,9 @@ def cli_start_server(port, host): "Start a worker for a specific application. (e.g. \"tvpaint/11.5\")" ) ) -@click.argument("app_name") -@click.option("--server_url", help="Server url which handle workers and jobs.") +@click_wrap.argument("app_name") +@click_wrap.option( + "--server_url", + help="Server url which handle workers and jobs.") def cli_start_worker(app_name, server_url): JobQueueModule.start_worker(app_name, server_url) diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index 8d2d5ccd609..0ab627ba757 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -1,9 +1,9 @@ """Kitsu module.""" -import click import os from openpype.modules import ( + click_wrap, OpenPypeModule, IPluginPaths, ITrayAction, @@ -98,17 +98,17 @@ def get_plugin_paths(self): } def cli(self, click_group): - click_group.add_command(cli_main) + click_group.add_command(cli_main.to_click_obj()) -@click.group(KitsuModule.name, help="Kitsu dynamic cli commands.") +@click_wrap.group(KitsuModule.name, help="Kitsu dynamic cli commands.") def cli_main(): pass @cli_main.command() -@click.option("--login", envvar="KITSU_LOGIN", help="Kitsu login") -@click.option( +@click_wrap.option("--login", envvar="KITSU_LOGIN", help="Kitsu login") +@click_wrap.option( "--password", envvar="KITSU_PWD", help="Password for kitsu username" ) def push_to_zou(login, password): @@ -124,11 +124,11 @@ def push_to_zou(login, password): @cli_main.command() -@click.option("-l", "--login", envvar="KITSU_LOGIN", help="Kitsu login") -@click.option( +@click_wrap.option("-l", "--login", envvar="KITSU_LOGIN", help="Kitsu login") +@click_wrap.option( "-p", "--password", envvar="KITSU_PWD", help="Password for kitsu username" ) -@click.option( +@click_wrap.option( "-prj", "--project", "projects", @@ -136,7 +136,7 @@ def push_to_zou(login, password): default=[], help="Sync specific kitsu projects", ) -@click.option( +@click_wrap.option( "-lo", "--listen-only", "listen_only", diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 8a926979205..3d6f76ad55f 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -7,7 +7,6 @@ import signal from collections import deque, defaultdict -import click from bson.objectid import ObjectId from openpype.client import ( @@ -15,7 +14,12 @@ get_representations, get_representation_by_id, ) -from openpype.modules import OpenPypeModule, ITrayModule, IPluginPaths +from openpype.modules import ( + OpenPypeModule, + ITrayModule, + IPluginPaths, + click_wrap, +) from openpype.settings import ( get_project_settings, get_system_settings, @@ -2405,7 +2409,7 @@ def _get_roots_config(self, presets, project_name, site_name): return presets[project_name]['sites'][site_name]['root'] def cli(self, click_group): - click_group.add_command(cli_main) + click_group.add_command(cli_main.to_click_obj()) # Webserver module implementation def webserver_initialization(self, server_manager): @@ -2417,13 +2421,15 @@ def webserver_initialization(self, server_manager): ) -@click.group(SyncServerModule.name, help="SyncServer module related commands.") +@click_wrap.group( + SyncServerModule.name, + help="SyncServer module related commands.") def cli_main(): pass @cli_main.command() -@click.option( +@click_wrap.option( "-a", "--active_site", required=True,