diff --git a/pype/lib/applications.py b/pype/lib/applications.py index f3a650be4f..d20b01c3d2 100644 --- a/pype/lib/applications.py +++ b/pype/lib/applications.py @@ -101,6 +101,9 @@ class ApplicationManager: def refresh(self): """Refresh applications from settings.""" + self.applications.clear() + self.tools.clear() + settings = get_system_settings() hosts_definitions = settings["applications"] diff --git a/pype/modules/__init__.py b/pype/modules/__init__.py index e0481d0c92..787b9128e8 100644 --- a/pype/modules/__init__.py +++ b/pype/modules/__init__.py @@ -40,7 +40,7 @@ from .log_viewer import LogViewModule from .muster import MusterModule from .deadline import DeadlineModule from .standalonepublish_action import StandAlonePublishAction -from .websocket_server import WebsocketModule +from .webserver import WebServerModule from .sync_server import SyncServer @@ -82,6 +82,6 @@ __all__ = ( "DeadlineModule", "StandAlonePublishAction", - "WebsocketModule", + "WebServerModule", "SyncServer" ) diff --git a/pype/modules/rest_api/rest_api.py b/pype/modules/rest_api/rest_api.py index a30402b5fe..b37f5e13eb 100644 --- a/pype/modules/rest_api/rest_api.py +++ b/pype/modules/rest_api/rest_api.py @@ -107,7 +107,6 @@ class RestApiModule(PypeModule, ITrayService): self.rest_api_url = None self.rest_api_thread = None - self.resources_url = None def register_callback( self, path, callback, url_prefix="", methods=[], strict_match=False @@ -189,11 +188,9 @@ class RestApiModule(PypeModule, ITrayService): self.rest_api_url = "http://localhost:{}".format(port) self.rest_api_thread = RestApiThread(self, port) self.register_statics("/res", resources.RESOURCES_DIR) - self.resources_url = "{}/res".format(self.rest_api_url) # Set rest api environments os.environ["PYPE_REST_API_URL"] = self.rest_api_url - os.environ["PYPE_STATICS_SERVER"] = self.resources_url def tray_start(self): RestApiFactory.prepare_registered() diff --git a/pype/modules/webserver/__init__.py b/pype/modules/webserver/__init__.py new file mode 100644 index 0000000000..ace27d94ab --- /dev/null +++ b/pype/modules/webserver/__init__.py @@ -0,0 +1,6 @@ +from .webserver_module import WebServerModule + + +__all__ = ( + "WebServerModule", +) diff --git a/pype/modules/webserver/server.py b/pype/modules/webserver/server.py new file mode 100644 index 0000000000..41f8f86a1b --- /dev/null +++ b/pype/modules/webserver/server.py @@ -0,0 +1,142 @@ +import threading +import asyncio + +from aiohttp import web + +from pype.lib import PypeLogger + +log = PypeLogger.get_logger("WebServer") + + +class WebServerManager: + """Manger that care about web server thread.""" + def __init__(self, module): + self.module = module + + self.client = None + self.handlers = {} + self.on_stop_callbacks = [] + + self.app = web.Application() + + # add route with multiple methods for single "external app" + + self.webserver_thread = WebServerThread(self, self.module.port) + + def add_route(self, *args, **kwargs): + self.app.router.add_route(*args, **kwargs) + + def add_static(self, *args, **kwargs): + self.app.router.add_static(*args, **kwargs) + + def start_server(self): + if self.webserver_thread and not self.webserver_thread.is_alive(): + self.webserver_thread.start() + + def stop_server(self): + if not self.is_running: + return + try: + log.debug("Stopping Web server") + self.webserver_thread.is_running = False + self.webserver_thread.stop() + + except Exception: + log.warning( + "Error has happened during Killing Web server", + exc_info=True + ) + + @property + def is_running(self): + if not self.webserver_thread: + return False + return self.webserver_thread.is_running + + def thread_stopped(self): + for callback in self.on_stop_callbacks: + callback() + + +class WebServerThread(threading.Thread): + """ Listener for requests in thread.""" + def __init__(self, manager, port): + super(WebServerThread, self).__init__() + + self.is_running = False + self.port = port + self.manager = manager + self.loop = None + self.runner = None + self.site = None + self.tasks = [] + + def run(self): + self.is_running = True + + try: + log.info("Starting WebServer server") + self.loop = asyncio.new_event_loop() # create new loop for thread + asyncio.set_event_loop(self.loop) + + self.loop.run_until_complete(self.start_server()) + + log.debug( + "Running Web server on URL: \"localhost:{}\"".format(self.port) + ) + + asyncio.ensure_future(self.check_shutdown(), loop=self.loop) + self.loop.run_forever() + + except Exception: + log.warning( + "Web Server service has failed", exc_info=True + ) + finally: + self.loop.close() # optional + + self.is_running = False + self.manager.thread_stopped() + log.info("Web server stopped") + + async def start_server(self): + """ Starts runner and TCPsite """ + self.runner = web.AppRunner(self.manager.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, 'localhost', self.port) + await self.site.start() + + def stop(self): + """Sets is_running flag to false, 'check_shutdown' shuts server down""" + self.is_running = False + + async def check_shutdown(self): + """ Future that is running and checks if server should be running + periodically. + """ + while self.is_running: + while self.tasks: + task = self.tasks.pop(0) + log.debug("waiting for task {}".format(task)) + await task + log.debug("returned value {}".format(task.result)) + + await asyncio.sleep(0.5) + + log.debug("Starting shutdown") + await self.site.stop() + log.debug("Site stopped") + await self.runner.cleanup() + log.debug("Runner stopped") + tasks = [ + task + for task in asyncio.all_tasks() + if task is not asyncio.current_task() + ] + list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks + results = await asyncio.gather(*tasks, return_exceptions=True) + log.debug(f'Finished awaiting cancelled tasks, results: {results}...') + await self.loop.shutdown_asyncgens() + # to really make sure everything else has time to stop + await asyncio.sleep(0.07) + self.loop.stop() diff --git a/pype/modules/webserver/webserver_module.py b/pype/modules/webserver/webserver_module.py new file mode 100644 index 0000000000..0cdf478d8c --- /dev/null +++ b/pype/modules/webserver/webserver_module.py @@ -0,0 +1,55 @@ +import os +from pype import resources +from .. import PypeModule, ITrayService + + +class WebServerModule(PypeModule, ITrayService): + name = "webserver" + label = "WebServer" + + def initialize(self, module_settings): + self.enabled = True + self.server_manager = None + + # TODO find free port + self.port = 8098 + + def connect_with_modules(self, *_a, **_kw): + return + + def tray_init(self): + self.create_server_manager() + self._add_resources_statics() + + def tray_start(self): + self.start_server() + + def tray_exit(self): + self.stop_server() + + def _add_resources_statics(self): + static_prefix = "/res" + self.server_manager.add_static(static_prefix, resources.RESOURCES_DIR) + + os.environ["PYPE_STATICS_SERVER"] = "http://localhost:{}{}".format( + self.port, static_prefix + ) + + def start_server(self): + if self.server_manager: + self.server_manager.start_server() + + def stop_server(self): + if self.server_manager: + self.server_manager.stop_server() + + def create_server_manager(self): + if self.server_manager: + return + + from .server import WebServerManager + + self.server_manager = WebServerManager(self) + self.server_manager.on_stop_callbacks.append( + self.set_service_failed_icon + ) diff --git a/pype/modules/websocket_server/__init__.py b/pype/modules/websocket_server/__init__.py deleted file mode 100644 index 0f6888585f..0000000000 --- a/pype/modules/websocket_server/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .websocket_server import ( - WebsocketModule, - WebSocketServer -) - - -__all__ = ( - "WebsocketModule", - "WebSocketServer" -) diff --git a/pype/modules/websocket_server/hosts/__init__.py b/pype/modules/websocket_server/hosts/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/pype/modules/websocket_server/hosts/aftereffects.py b/pype/modules/websocket_server/hosts/aftereffects.py deleted file mode 100644 index 14d2c04338..0000000000 --- a/pype/modules/websocket_server/hosts/aftereffects.py +++ /dev/null @@ -1,64 +0,0 @@ -from pype.api import Logger -from wsrpc_aiohttp import WebSocketRoute -import functools - -import avalon.aftereffects as aftereffects - -log = Logger().get_logger("WebsocketServer") - - -class AfterEffects(WebSocketRoute): - """ - One route, mimicking external application (like Harmony, etc). - All functions could be called from client. - 'do_notify' function calls function on the client - mimicking - notification after long running job on the server or similar - """ - instance = None - - def init(self, **kwargs): - # Python __init__ must be return "self". - # This method might return anything. - log.debug("someone called AfterEffects route") - self.instance = self - return kwargs - - # server functions - async def ping(self): - log.debug("someone called AfterEffects route ping") - - # This method calls function on the client side - # client functions - - async def read(self): - log.debug("aftereffects.read client calls server server calls " - "aftereffects client") - return await self.socket.call('aftereffects.read') - - # panel routes for tools - async def creator_route(self): - self._tool_route("creator") - - async def workfiles_route(self): - self._tool_route("workfiles") - - async def loader_route(self): - self._tool_route("loader") - - async def publish_route(self): - self._tool_route("publish") - - async def sceneinventory_route(self): - self._tool_route("sceneinventory") - - async def projectmanager_route(self): - self._tool_route("projectmanager") - - def _tool_route(self, tool_name): - """The address accessed when clicking on the buttons.""" - partial_method = functools.partial(aftereffects.show, tool_name) - - aftereffects.execute_in_main_thread(partial_method) - - # Required return statement. - return "nothing" diff --git a/pype/modules/websocket_server/hosts/external_app_1.py b/pype/modules/websocket_server/hosts/external_app_1.py deleted file mode 100644 index 9352787175..0000000000 --- a/pype/modules/websocket_server/hosts/external_app_1.py +++ /dev/null @@ -1,47 +0,0 @@ -import asyncio - -from pype.api import Logger -from wsrpc_aiohttp import WebSocketRoute - -log = Logger().get_logger("WebsocketServer") - - -class ExternalApp1(WebSocketRoute): - """ - One route, mimicking external application (like Harmony, etc). - All functions could be called from client. - 'do_notify' function calls function on the client - mimicking - notification after long running job on the server or similar - """ - - def init(self, **kwargs): - # Python __init__ must be return "self". - # This method might return anything. - log.debug("someone called ExternalApp1 route") - return kwargs - - async def server_function_one(self): - log.info('In function one') - - async def server_function_two(self): - log.info('In function two') - return 'function two' - - async def server_function_three(self): - log.info('In function three') - asyncio.ensure_future(self.do_notify()) - return '{"message":"function tree"}' - - async def server_function_four(self, *args, **kwargs): - log.info('In function four args {} kwargs {}'.format(args, kwargs)) - ret = dict(**kwargs) - ret["message"] = "function four received arguments" - return str(ret) - - # This method calls function on the client side - async def do_notify(self): - import time - time.sleep(5) - log.info('Calling function on server after delay') - awesome = 'Somebody server_function_three method!' - await self.socket.call('notify', result=awesome) diff --git a/pype/modules/websocket_server/hosts/photoshop.py b/pype/modules/websocket_server/hosts/photoshop.py deleted file mode 100644 index b75874aa6c..0000000000 --- a/pype/modules/websocket_server/hosts/photoshop.py +++ /dev/null @@ -1,67 +0,0 @@ -from pype.api import Logger -from wsrpc_aiohttp import WebSocketRoute -import functools - -import avalon.photoshop as photoshop - -log = Logger().get_logger("WebsocketServer") - - -class Photoshop(WebSocketRoute): - """ - One route, mimicking external application (like Harmony, etc). - All functions could be called from client. - 'do_notify' function calls function on the client - mimicking - notification after long running job on the server or similar - """ - instance = None - - def init(self, **kwargs): - # Python __init__ must be return "self". - # This method might return anything. - log.debug("someone called Photoshop route") - self.instance = self - return kwargs - - # server functions - async def ping(self): - log.debug("someone called Photoshop route ping") - - # This method calls function on the client side - # client functions - - async def read(self): - log.debug("photoshop.read client calls server server calls " - "Photo client") - return await self.socket.call('Photoshop.read') - - # panel routes for tools - async def creator_route(self): - self._tool_route("creator") - - async def workfiles_route(self): - self._tool_route("workfiles") - - async def loader_route(self): - self._tool_route("loader") - - async def publish_route(self): - self._tool_route("publish") - - async def sceneinventory_route(self): - self._tool_route("sceneinventory") - - async def projectmanager_route(self): - self._tool_route("projectmanager") - - async def subsetmanager_route(self): - self._tool_route("subsetmanager") - - def _tool_route(self, tool_name): - """The address accessed when clicking on the buttons.""" - partial_method = functools.partial(photoshop.show, tool_name) - - photoshop.execute_in_main_thread(partial_method) - - # Required return statement. - return "nothing" diff --git a/pype/modules/websocket_server/stubs/aftereffects_server_stub.py b/pype/modules/websocket_server/stubs/aftereffects_server_stub.py deleted file mode 100644 index 9449d0b378..0000000000 --- a/pype/modules/websocket_server/stubs/aftereffects_server_stub.py +++ /dev/null @@ -1,502 +0,0 @@ -from pype.modules.websocket_server import WebSocketServer -""" - Stub handling connection from server to client. - Used anywhere solution is calling client methods. -""" -import json -import attr - -import logging -log = logging.getLogger(__name__) - - -@attr.s -class AEItem(object): - """ - Object denoting Item in AE. Each item is created in AE by any Loader, - but contains same fields, which are being used in later processing. - """ - # metadata - id = attr.ib() # id created by AE, could be used for querying - name = attr.ib() # name of item - item_type = attr.ib(default=None) # item type (footage, folder, comp) - # all imported elements, single for - # regular image, array for Backgrounds - members = attr.ib(factory=list) - workAreaStart = attr.ib(default=None) - workAreaDuration = attr.ib(default=None) - frameRate = attr.ib(default=None) - file_name = attr.ib(default=None) - - -class AfterEffectsServerStub(): - """ - Stub for calling function on client (Photoshop js) side. - Expects that client is already connected (started when avalon menu - is opened). - 'self.websocketserver.call' is used as async wrapper - """ - - def __init__(self): - self.websocketserver = WebSocketServer.get_instance() - self.client = self.websocketserver.get_client() - - def open(self, path): - """ - Open file located at 'path' (local). - Args: - path(string): file path locally - Returns: None - """ - self.websocketserver.call(self.client.call - ('AfterEffects.open', path=path) - ) - - def get_metadata(self): - """ - Get complete stored JSON with metadata from AE.Metadata.Label - field. - - It contains containers loaded by any Loader OR instances creted - by Creator. - - Returns: - (dict) - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.get_metadata') - ) - try: - metadata = json.loads(res) - except json.decoder.JSONDecodeError: - raise ValueError("Unparsable metadata {}".format(res)) - return metadata or [] - - def read(self, item, layers_meta=None): - """ - Parses item metadata from Label field of active document. - Used as filter to pick metadata for specific 'item' only. - - Args: - item (AEItem): pulled info from AE - layers_meta (dict): full list from Headline - (load and inject for better performance in loops) - Returns: - (dict): - """ - if layers_meta is None: - layers_meta = self.get_metadata() - - for item_meta in layers_meta: - if 'container' in item_meta.get('id') and \ - str(item.id) == str(item_meta.get('members')[0]): - return item_meta - - log.debug("Couldn't find layer metadata") - - def imprint(self, item, data, all_items=None, items_meta=None): - """ - Save item metadata to Label field of metadata of active document - Args: - item (AEItem): - data(string): json representation for single layer - all_items (list of item): for performance, could be - injected for usage in loop, if not, single call will be - triggered - items_meta(string): json representation from Headline - (for performance - provide only if imprint is in - loop - value should be same) - Returns: None - """ - if not items_meta: - items_meta = self.get_metadata() - - result_meta = [] - # fix existing - is_new = True - - for item_meta in items_meta: - if item_meta.get('members') \ - and str(item.id) == str(item_meta.get('members')[0]): - is_new = False - if data: - item_meta.update(data) - result_meta.append(item_meta) - else: - result_meta.append(item_meta) - - if is_new: - result_meta.append(data) - - # Ensure only valid ids are stored. - if not all_items: - # loaders create FootageItem now - all_items = self.get_items(comps=True, - folders=True, - footages=True) - item_ids = [int(item.id) for item in all_items] - cleaned_data = [] - for meta in result_meta: - # for creation of instance OR loaded container - if 'instance' in meta.get('id') or \ - int(meta.get('members')[0]) in item_ids: - cleaned_data.append(meta) - - payload = json.dumps(cleaned_data, indent=4) - - self.websocketserver.call(self.client.call - ('AfterEffects.imprint', payload=payload)) - - def get_active_document_full_name(self): - """ - Returns just a name of active document via ws call - Returns(string): file name - """ - res = self.websocketserver.call(self.client.call( - 'AfterEffects.get_active_document_full_name')) - - return res - - def get_active_document_name(self): - """ - Returns just a name of active document via ws call - Returns(string): file name - """ - res = self.websocketserver.call(self.client.call( - 'AfterEffects.get_active_document_name')) - - return res - - def get_items(self, comps, folders=False, footages=False): - """ - Get all items from Project panel according to arguments. - There are multiple different types: - CompItem (could have multiple layers - source for Creator, - will be rendered) - FolderItem (collection type, currently used for Background - loading) - FootageItem (imported file - created by Loader) - Args: - comps (bool): return CompItems - folders (bool): return FolderItem - footages (bool: return FootageItem - - Returns: - (list) of namedtuples - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.get_items', - comps=comps, - folders=folders, - footages=footages) - ) - return self._to_records(res) - - def get_selected_items(self, comps, folders=False, footages=False): - """ - Same as get_items but using selected items only - Args: - comps (bool): return CompItems - folders (bool): return FolderItem - footages (bool: return FootageItem - - Returns: - (list) of namedtuples - - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.get_selected_items', - comps=comps, - folders=folders, - footages=footages) - ) - return self._to_records(res) - - def import_file(self, path, item_name, import_options=None): - """ - Imports file as a FootageItem. Used in Loader - Args: - path (string): absolute path for asset file - item_name (string): label for created FootageItem - import_options (dict): different files (img vs psd) need different - config - - """ - res = self.websocketserver.call(self.client.call( - 'AfterEffects.import_file', - path=path, - item_name=item_name, - import_options=import_options) - ) - records = self._to_records(res) - if records: - return records.pop() - - log.debug("Couldn't import {} file".format(path)) - - def replace_item(self, item, path, item_name): - """ Replace FootageItem with new file - - Args: - item (dict): - path (string):absolute path - item_name (string): label on item in Project list - - """ - self.websocketserver.call(self.client.call - ('AfterEffects.replace_item', - item_id=item.id, - path=path, item_name=item_name)) - - def rename_item(self, item, item_name): - """ Replace item with item_name - - Args: - item (dict): - item_name (string): label on item in Project list - - """ - self.websocketserver.call(self.client.call - ('AfterEffects.rename_item', - item_id=item.id, - item_name=item_name)) - - def delete_item(self, item_id): - """ Deletes *Item in a file - Args: - item_id (int): - - """ - self.websocketserver.call(self.client.call - ('AfterEffects.delete_item', - item_id=item_id - )) - - def is_saved(self): - # TODO - return True - - def set_label_color(self, item_id, color_idx): - """ - Used for highlight additional information in Project panel. - Green color is loaded asset, blue is created asset - Args: - item_id (int): - color_idx (int): 0-16 Label colors from AE Project view - """ - self.websocketserver.call(self.client.call - ('AfterEffects.set_label_color', - item_id=item_id, - color_idx=color_idx - )) - - def get_work_area(self, item_id): - """ Get work are information for render purposes - Args: - item_id (int): - - Returns: - (namedtuple) - - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.get_work_area', - item_id=item_id - )) - - records = self._to_records(res) - if records: - return records.pop() - - log.debug("Couldn't get work area") - - def set_work_area(self, item, start, duration, frame_rate): - """ - Set work area to predefined values (from Ftrack). - Work area directs what gets rendered. - Beware of rounding, AE expects seconds, not frames directly. - - Args: - item (dict): - start (float): workAreaStart in seconds - duration (float): in seconds - frame_rate (float): frames in seconds - """ - self.websocketserver.call(self.client.call - ('AfterEffects.set_work_area', - item_id=item.id, - start=start, - duration=duration, - frame_rate=frame_rate - )) - - def save(self): - """ - Saves active document - Returns: None - """ - self.websocketserver.call(self.client.call - ('AfterEffects.save')) - - def saveAs(self, project_path, as_copy): - """ - Saves active project to aep (copy) or png or jpg - Args: - project_path(string): full local path - as_copy: - Returns: None - """ - self.websocketserver.call(self.client.call - ('AfterEffects.saveAs', - image_path=project_path, - as_copy=as_copy)) - - def get_render_info(self): - """ Get render queue info for render purposes - - Returns: - (namedtuple): with 'file_name' field - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.get_render_info')) - - records = self._to_records(res) - if records: - return records.pop() - - log.debug("Render queue needs to have file extension in 'Output to'") - - def get_audio_url(self, item_id): - """ Get audio layer absolute url for comp - - Args: - item_id (int): composition id - Returns: - (str): absolute path url - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.get_audio_url', - item_id=item_id)) - - return res - - def close(self): - self.client.close() - - def import_background(self, comp_id, comp_name, files): - """ - Imports backgrounds images to existing or new composition. - - If comp_id is not provided, new composition is created, basic - values (width, heights, frameRatio) takes from first imported - image. - - All images from background json are imported as a FootageItem and - separate layer is created for each of them under composition. - - Order of imported 'files' is important. - - Args: - comp_id (int): id of existing composition (null if new) - comp_name (str): used when new composition - files (list): list of absolute paths to import and - add as layers - - Returns: - (AEItem): object with id of created folder, all imported images - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.import_background', - comp_id=comp_id, - comp_name=comp_name, - files=files)) - - records = self._to_records(res) - if records: - return records.pop() - - log.debug("Import background failed.") - - def reload_background(self, comp_id, comp_name, files): - """ - Reloads backgrounds images to existing composition. - - It actually deletes complete folder with imported images and - created composition for safety. - - Args: - comp_id (int): id of existing composition to be overwritten - comp_name (str): new name of composition (could be same as old - if version up only) - files (list): list of absolute paths to import and - add as layers - Returns: - (AEItem): object with id of created folder, all imported images - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.reload_background', - comp_id=comp_id, - comp_name=comp_name, - files=files)) - - records = self._to_records(res) - if records: - return records.pop() - - log.debug("Reload of background failed.") - - def add_item_as_layer(self, comp_id, item_id): - """ - Adds already imported FootageItem ('item_id') as a new - layer to composition ('comp_id'). - - Args: - comp_id (int): id of target composition - item_id (int): FootageItem.id - comp already found previously - """ - res = self.websocketserver.call(self.client.call - ('AfterEffects.add_item_as_layer', - comp_id=comp_id, - item_id=item_id)) - - records = self._to_records(res) - if records: - return records.pop() - - log.debug("Adding new layer failed.") - - def _to_records(self, res): - """ - Converts string json representation into list of AEItem - dot notation access to work. - Returns: - res(string): - json representation - """ - if not res: - return [] - - try: - layers_data = json.loads(res) - except json.decoder.JSONDecodeError: - raise ValueError("Received broken JSON {}".format(res)) - if not layers_data: - return [] - - ret = [] - # convert to AEItem to use dot donation - if isinstance(layers_data, dict): - layers_data = [layers_data] - for d in layers_data: - # currently implemented and expected fields - item = AEItem(d.get('id'), - d.get('name'), - d.get('type'), - d.get('members'), - d.get('workAreaStart'), - d.get('workAreaDuration'), - d.get('frameRate'), - d.get('file_name')) - - ret.append(item) - return ret diff --git a/pype/modules/websocket_server/stubs/photoshop_server_stub.py b/pype/modules/websocket_server/stubs/photoshop_server_stub.py deleted file mode 100644 index 5409120a65..0000000000 --- a/pype/modules/websocket_server/stubs/photoshop_server_stub.py +++ /dev/null @@ -1,437 +0,0 @@ -from pype.modules.websocket_server import WebSocketServer -""" - Stub handling connection from server to client. - Used anywhere solution is calling client methods. -""" -import json -import attr - - -@attr.s -class PSItem(object): - """ - Object denoting layer or group item in PS. Each item is created in - PS by any Loader, but contains same fields, which are being used - in later processing. - """ - # metadata - id = attr.ib() # id created by AE, could be used for querying - name = attr.ib() # name of item - group = attr.ib(default=None) # item type (footage, folder, comp) - parents = attr.ib(factory=list) - visible = attr.ib(default=True) - type = attr.ib(default=None) - # all imported elements, single for - members = attr.ib(factory=list) - long_name = attr.ib(default=None) - - -class PhotoshopServerStub: - """ - Stub for calling function on client (Photoshop js) side. - Expects that client is already connected (started when avalon menu - is opened). - 'self.websocketserver.call' is used as async wrapper - """ - PUBLISH_ICON = '\u2117 ' - LOADED_ICON = '\u25bc' - - def __init__(self): - self.websocketserver = WebSocketServer.get_instance() - self.client = self.websocketserver.get_client() - - def open(self, path): - """ - Open file located at 'path' (local). - Args: - path(string): file path locally - Returns: None - """ - self.websocketserver.call(self.client.call - ('Photoshop.open', path=path) - ) - - def read(self, layer, layers_meta=None): - """ - Parses layer metadata from Headline field of active document - Args: - layer: (PSItem) - layers_meta: full list from Headline (for performance in loops) - Returns: - """ - if layers_meta is None: - layers_meta = self.get_layers_metadata() - - return layers_meta.get(str(layer.id)) - - def imprint(self, layer, data, all_layers=None, layers_meta=None): - """ - Save layer metadata to Headline field of active document - - Stores metadata in format: - [{ - "active":true, - "subset":"imageBG", - "family":"image", - "id":"pyblish.avalon.instance", - "asset":"Town", - "uuid": "8" - }] - for created instances - OR - [{ - "schema": "avalon-core:container-2.0", - "id": "pyblish.avalon.instance", - "name": "imageMG", - "namespace": "Jungle_imageMG_001", - "loader": "ImageLoader", - "representation": "5fbfc0ee30a946093c6ff18a", - "members": [ - "40" - ] - }] - for loaded instances - - Args: - layer (PSItem): - data(string): json representation for single layer - all_layers (list of PSItem): for performance, could be - injected for usage in loop, if not, single call will be - triggered - layers_meta(string): json representation from Headline - (for performance - provide only if imprint is in - loop - value should be same) - Returns: None - """ - if not layers_meta: - layers_meta = self.get_layers_metadata() - - # json.dumps writes integer values in a dictionary to string, so - # anticipating it here. - if str(layer.id) in layers_meta and layers_meta[str(layer.id)]: - if data: - layers_meta[str(layer.id)].update(data) - else: - layers_meta.pop(str(layer.id)) - else: - layers_meta[str(layer.id)] = data - - # Ensure only valid ids are stored. - if not all_layers: - all_layers = self.get_layers() - layer_ids = [layer.id for layer in all_layers] - cleaned_data = [] - - for id in layers_meta: - if int(id) in layer_ids: - cleaned_data.append(layers_meta[id]) - - payload = json.dumps(cleaned_data, indent=4) - - self.websocketserver.call(self.client.call - ('Photoshop.imprint', payload=payload) - ) - - def get_layers(self): - """ - Returns JSON document with all(?) layers in active document. - - Returns: - Format of tuple: { 'id':'123', - 'name': 'My Layer 1', - 'type': 'GUIDE'|'FG'|'BG'|'OBJ' - 'visible': 'true'|'false' - """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_layers')) - - return self._to_records(res) - - def get_layer(self, layer_id): - """ - Returns PSItem for specific 'layer_id' or None if not found - Args: - layer_id (string): unique layer id, stored in 'uuid' field - - Returns: - (PSItem) or None - """ - layers = self.get_layers() - for layer in layers: - if str(layer.id) == str(layer_id): - return layer - - def get_layers_in_layers(self, layers): - """ - Return all layers that belong to layers (might be groups). - Args: - layers : - Returns: - """ - all_layers = self.get_layers() - ret = [] - parent_ids = set([lay.id for lay in layers]) - - for layer in all_layers: - parents = set(layer.parents) - if len(parent_ids & parents) > 0: - ret.append(layer) - if layer.id in parent_ids: - ret.append(layer) - - return ret - - def create_group(self, name): - """ - Create new group (eg. LayerSet) - Returns: - """ - enhanced_name = self.PUBLISH_ICON + name - ret = self.websocketserver.call(self.client.call - ('Photoshop.create_group', - name=enhanced_name)) - # create group on PS is asynchronous, returns only id - return PSItem(id=ret, name=name, group=True) - - def group_selected_layers(self, name): - """ - Group selected layers into new LayerSet (eg. group) - Returns: (Layer) - """ - enhanced_name = self.PUBLISH_ICON + name - res = self.websocketserver.call(self.client.call - ('Photoshop.group_selected_layers', - name=enhanced_name) - ) - res = self._to_records(res) - if res: - rec = res.pop() - rec.name = rec.name.replace(self.PUBLISH_ICON, '') - return rec - raise ValueError("No group record returned") - - def get_selected_layers(self): - """ - Get a list of actually selected layers - Returns: - """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_selected_layers')) - return self._to_records(res) - - def select_layers(self, layers): - """ - Selects specified layers in Photoshop by its ids - Args: - layers: - Returns: None - """ - layers_id = [str(lay.id) for lay in layers] - self.websocketserver.call(self.client.call - ('Photoshop.select_layers', - layers=json.dumps(layers_id)) - ) - - def get_active_document_full_name(self): - """ - Returns full name with path of active document via ws call - Returns(string): full path with name - """ - res = self.websocketserver.call( - self.client.call('Photoshop.get_active_document_full_name')) - - return res - - def get_active_document_name(self): - """ - Returns just a name of active document via ws call - Returns(string): file name - """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_active_document_name')) - - return res - - def is_saved(self): - """ - Returns true if no changes in active document - Returns: - """ - return self.websocketserver.call(self.client.call - ('Photoshop.is_saved')) - - def save(self): - """ - Saves active document - Returns: None - """ - self.websocketserver.call(self.client.call - ('Photoshop.save')) - - def saveAs(self, image_path, ext, as_copy): - """ - Saves active document to psd (copy) or png or jpg - Args: - image_path(string): full local path - ext: - as_copy: - Returns: None - """ - self.websocketserver.call(self.client.call - ('Photoshop.saveAs', - image_path=image_path, - ext=ext, - as_copy=as_copy)) - - def set_visible(self, layer_id, visibility): - """ - Set layer with 'layer_id' to 'visibility' - Args: - layer_id: - visibility: - Returns: None - """ - self.websocketserver.call(self.client.call - ('Photoshop.set_visible', - layer_id=layer_id, - visibility=visibility)) - - def get_layers_metadata(self): - """ - Reads layers metadata from Headline from active document in PS. - (Headline accessible by File > File Info) - - Returns: - (string): - json documents - example: - {"8":{"active":true,"subset":"imageBG", - "family":"image","id":"pyblish.avalon.instance", - "asset":"Town"}} - 8 is layer(group) id - used for deletion, update etc. - """ - layers_data = {} - res = self.websocketserver.call(self.client.call('Photoshop.read')) - try: - layers_data = json.loads(res) - except json.decoder.JSONDecodeError: - pass - # format of metadata changed from {} to [] because of standardization - # keep current implementation logic as its working - if not isinstance(layers_data, dict): - temp_layers_meta = {} - for layer_meta in layers_data: - layer_id = layer_meta.get("uuid") or \ - (layer_meta.get("members")[0]) - temp_layers_meta[layer_id] = layer_meta - layers_data = temp_layers_meta - else: - # legacy version of metadata - for layer_id, layer_meta in layers_data.items(): - if layer_meta.get("schema") != "avalon-core:container-2.0": - layer_meta["uuid"] = str(layer_id) - else: - layer_meta["members"] = [str(layer_id)] - - return layers_data - - def import_smart_object(self, path, layer_name): - """ - Import the file at `path` as a smart object to active document. - - Args: - path (str): File path to import. - layer_name (str): Unique layer name to differentiate how many times - same smart object was loaded - """ - enhanced_name = self.LOADED_ICON + layer_name - res = self.websocketserver.call(self.client.call - ('Photoshop.import_smart_object', - path=path, name=enhanced_name)) - rec = self._to_records(res).pop() - if rec: - rec.name = rec.name.replace(self.LOADED_ICON, '') - return rec - - def replace_smart_object(self, layer, path, layer_name): - """ - Replace the smart object `layer` with file at `path` - layer_name (str): Unique layer name to differentiate how many times - same smart object was loaded - - Args: - layer (PSItem): - path (str): File to import. - """ - enhanced_name = self.LOADED_ICON + layer_name - self.websocketserver.call(self.client.call - ('Photoshop.replace_smart_object', - layer_id=layer.id, - path=path, name=enhanced_name)) - - def delete_layer(self, layer_id): - """ - Deletes specific layer by it's id. - Args: - layer_id (int): id of layer to delete - """ - self.websocketserver.call(self.client.call - ('Photoshop.delete_layer', - layer_id=layer_id)) - - def rename_layer(self, layer_id, name): - """ - Renames specific layer by it's id. - Args: - layer_id (int): id of layer to delete - name (str): new name - """ - self.websocketserver.call(self.client.call - ('Photoshop.rename_layer', - layer_id=layer_id, - name=name)) - - def remove_instance(self, instance_id): - cleaned_data = {} - - for key, instance in self.get_layers_metadata().items(): - if key != instance_id: - cleaned_data[key] = instance - - payload = json.dumps(cleaned_data, indent=4) - - self.websocketserver.call(self.client.call - ('Photoshop.imprint', payload=payload) - ) - - def close(self): - self.client.close() - - def _to_records(self, res): - """ - Converts string json representation into list of PSItem for - dot notation access to work. - Args: - res (string): valid json - Returns: - - """ - try: - layers_data = json.loads(res) - except json.decoder.JSONDecodeError: - raise ValueError("Received broken JSON {}".format(res)) - ret = [] - - # convert to AEItem to use dot donation - if isinstance(layers_data, dict): - layers_data = [layers_data] - for d in layers_data: - # currently implemented and expected fields - item = PSItem(d.get('id'), - d.get('name'), - d.get('group'), - d.get('parents'), - d.get('visible'), - d.get('type'), - d.get('members'), - d.get('long_name')) - - ret.append(item) - return ret diff --git a/pype/modules/websocket_server/test_client/wsrpc_client.html b/pype/modules/websocket_server/test_client/wsrpc_client.html deleted file mode 100644 index 9c3f469aca..0000000000 --- a/pype/modules/websocket_server/test_client/wsrpc_client.html +++ /dev/null @@ -1,179 +0,0 @@ - - - - - Title - - - - - - - - - - - - - -
-
Test of wsrpc javascript client
- -
- -
-
-
-
-

No return value

-
-
-
    -
  • Calls server_function_one
  • -
  • Function only logs on server
  • -
  • No return value
  • -
  •  
  • -
  •  
  • -
  •  
  • -
- -
-
-
-
-

Return value

-
-
-
    -
  • Calls server_function_two
  • -
  • Function logs on server
  • -
  • Returns simple text value
  • -
  •  
  • -
  •  
  • -
  •  
  • -
- -
-
-
-
-

Notify

-
-
-
    -
  • Calls server_function_three
  • -
  • Function logs on server
  • -
  • Returns json payload
  • -
  • Server then calls function ON the client after delay
  • -
  •  
  • -
- -
-
-
-
-

Send value

-
-
-
    -
  • Calls server_function_four
  • -
  • Function logs on server
  • -
  • Returns modified sent values
  • -
  •  
  • -
  •  
  • -
  •  
  • -
- -
-
-
-
- - - \ No newline at end of file diff --git a/pype/modules/websocket_server/test_client/wsrpc_client.py b/pype/modules/websocket_server/test_client/wsrpc_client.py deleted file mode 100644 index ef861513ae..0000000000 --- a/pype/modules/websocket_server/test_client/wsrpc_client.py +++ /dev/null @@ -1,34 +0,0 @@ -import asyncio - -from wsrpc_aiohttp import WSRPCClient - -""" - Simple testing Python client for wsrpc_aiohttp - Calls sequentially multiple methods on server -""" - -loop = asyncio.get_event_loop() - - -async def main(): - print("main") - client = WSRPCClient("ws://127.0.0.1:8099/ws/", - loop=asyncio.get_event_loop()) - - client.add_route('notify', notify) - await client.connect() - print("connected") - print(await client.proxy.ExternalApp1.server_function_one()) - print(await client.proxy.ExternalApp1.server_function_two()) - print(await client.proxy.ExternalApp1.server_function_three()) - print(await client.proxy.ExternalApp1.server_function_four(foo="one")) - await client.close() - - -def notify(socket, *args, **kwargs): - print("called from server") - - -if __name__ == "__main__": - # loop.run_until_complete(main()) - asyncio.run(main()) diff --git a/pype/modules/websocket_server/websocket_server.py b/pype/modules/websocket_server/websocket_server.py deleted file mode 100644 index 7a6710349b..0000000000 --- a/pype/modules/websocket_server/websocket_server.py +++ /dev/null @@ -1,265 +0,0 @@ -import os -import sys -import pyclbr -import importlib -import urllib -import threading - -import six -from pype.lib import PypeLogger -from .. import PypeModule, ITrayService - -if six.PY2: - web = asyncio = STATIC_DIR = WebSocketAsync = None -else: - from aiohttp import web - import asyncio - from wsrpc_aiohttp import STATIC_DIR, WebSocketAsync - -log = PypeLogger().get_logger("WebsocketServer") - - -class WebsocketModule(PypeModule, ITrayService): - name = "Websocket server" - label = "Websocket server" - - def initialize(self, module_settings): - if asyncio is None: - raise AssertionError( - "WebSocketServer module requires Python 3.5 or higher." - ) - - self.enabled = True - self.websocket_server = None - - def connect_with_modules(self, *_a, **kw): - return - - def tray_init(self): - self.websocket_server = WebSocketServer() - self.websocket_server.on_stop_callbacks.append( - self.set_service_failed_icon - ) - - def tray_start(self): - if self.websocket_server: - self.websocket_server.module_start() - - def tray_exit(self): - if self.websocket_server: - self.websocket_server.module_stop() - - -class WebSocketServer(): - """ - Basic POC implementation of asychronic websocket RPC server. - Uses class in external_app_1.py to mimic implementation for single - external application. - 'test_client' folder contains two test implementations of client - """ - _instance = None - - def __init__(self): - WebSocketServer._instance = self - - self.client = None - self.handlers = {} - self.on_stop_callbacks = [] - - port = None - websocket_url = os.getenv("WEBSOCKET_URL") - if websocket_url: - parsed = urllib.parse.urlparse(websocket_url) - port = parsed.port - if not port: - port = 8098 # fallback - - self.app = web.Application() - - self.app.router.add_route("*", "/ws/", WebSocketAsync) - self.app.router.add_static("/js", STATIC_DIR) - self.app.router.add_static("/", ".") - - # add route with multiple methods for single "external app" - directories_with_routes = ['hosts'] - self.add_routes_for_directories(directories_with_routes) - - self.websocket_thread = WebsocketServerThread(self, port) - - def module_start(self): - if self.websocket_thread: - self.websocket_thread.start() - - def module_stop(self): - if self.websocket_thread: - self.websocket_thread.stop() - - def add_routes_for_directories(self, directories_with_routes): - """ Loops through selected directories to find all modules and - in them all classes implementing 'WebSocketRoute' that could be - used as route. - All methods in these classes are registered automatically. - """ - for dir_name in directories_with_routes: - dir_name = os.path.join(os.path.dirname(__file__), dir_name) - for file_name in os.listdir(dir_name): - if '.py' in file_name and '__' not in file_name: - self.add_routes_for_module(file_name, dir_name) - - def add_routes_for_module(self, file_name, dir_name): - """ Auto routes for all classes implementing 'WebSocketRoute' - in 'file_name' in 'dir_name' - """ - module_name = file_name.replace('.py', '') - module_info = pyclbr.readmodule(module_name, [dir_name]) - - for class_name, cls_object in module_info.items(): - sys.path.append(dir_name) - if 'WebSocketRoute' in cls_object.super: - log.debug('Adding route for {}'.format(class_name)) - module = importlib.import_module(module_name) - cls = getattr(module, class_name) - WebSocketAsync.add_route(class_name, cls) - sys.path.pop() - - def call(self, func): - log.debug("websocket.call {}".format(func)) - future = asyncio.run_coroutine_threadsafe(func, - self.websocket_thread.loop) - result = future.result() - return result - - def get_client(self): - """ - Return first connected client to WebSocket - TODO implement selection by Route - :return: client - """ - clients = WebSocketAsync.get_clients() - client = None - if len(clients) > 0: - key = list(clients.keys())[0] - client = clients.get(key) - - return client - - @staticmethod - def get_instance(): - if WebSocketServer._instance is None: - WebSocketServer() - return WebSocketServer._instance - - def stop_websocket_server(self): - self.stop() - - @property - def is_running(self): - return self.websocket_thread.is_running - - def stop(self): - if not self.is_running: - return - try: - log.debug("Stopping websocket server") - self.websocket_thread.is_running = False - self.websocket_thread.stop() - except Exception: - log.warning( - "Error has happened during Killing websocket server", - exc_info=True - ) - - def thread_stopped(self): - for callback in self.on_stop_callbacks: - callback() - - -class WebsocketServerThread(threading.Thread): - """ Listener for websocket rpc requests. - - It would be probably better to "attach" this to main thread (as for - example Harmony needs to run something on main thread), but currently - it creates separate thread and separate asyncio event loop - """ - def __init__(self, module, port): - if asyncio is None: - raise AssertionError( - "WebSocketServer module requires Python 3.5 or higher." - ) - - super(WebsocketServerThread, self).__init__() - - self.is_running = False - self.port = port - self.module = module - self.loop = None - self.runner = None - self.site = None - self.tasks = [] - - def run(self): - self.is_running = True - - try: - log.info("Starting websocket server") - self.loop = asyncio.new_event_loop() # create new loop for thread - asyncio.set_event_loop(self.loop) - - self.loop.run_until_complete(self.start_server()) - - log.debug( - "Running Websocket server on URL:" - " \"ws://localhost:{}\"".format(self.port) - ) - - asyncio.ensure_future(self.check_shutdown(), loop=self.loop) - self.loop.run_forever() - except Exception: - log.warning( - "Websocket Server service has failed", exc_info=True - ) - finally: - self.loop.close() # optional - - self.is_running = False - self.module.thread_stopped() - log.info("Websocket server stopped") - - async def start_server(self): - """ Starts runner and TCPsite """ - self.runner = web.AppRunner(self.module.app) - await self.runner.setup() - self.site = web.TCPSite(self.runner, 'localhost', self.port) - await self.site.start() - - def stop(self): - """Sets is_running flag to false, 'check_shutdown' shuts server down""" - self.is_running = False - - async def check_shutdown(self): - """ Future that is running and checks if server should be running - periodically. - """ - while self.is_running: - while self.tasks: - task = self.tasks.pop(0) - log.debug("waiting for task {}".format(task)) - await task - log.debug("returned value {}".format(task.result)) - - await asyncio.sleep(0.5) - - log.debug("Starting shutdown") - await self.site.stop() - log.debug("Site stopped") - await self.runner.cleanup() - log.debug("Runner stopped") - tasks = [task for task in asyncio.all_tasks() if - task is not asyncio.current_task()] - list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks - results = await asyncio.gather(*tasks, return_exceptions=True) - log.debug(f'Finished awaiting cancelled tasks, results: {results}...') - await self.loop.shutdown_asyncgens() - # to really make sure everything else has time to stop - await asyncio.sleep(0.07) - self.loop.stop() diff --git a/pype/tools/launcher/models.py b/pype/tools/launcher/models.py index 58f0076871..631f6ddc98 100644 --- a/pype/tools/launcher/models.py +++ b/pype/tools/launcher/models.py @@ -144,6 +144,7 @@ class ActionModel(QtGui.QStandardItemModel): if not project_doc: return actions + self.application_manager.refresh() for app_def in project_doc["config"]["apps"]: app_name = app_def["name"] app = self.application_manager.applications.get(app_name) diff --git a/repos/avalon-core b/repos/avalon-core index 8d3364dc8a..b6b56c4b3b 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 8d3364dc8ae73a33726ba3279ff75adff73c6239 +Subproject commit b6b56c4b3be612138fb2df6f11c8e27f11c62ffd