Skip to content

Commit

Permalink
Merge pull request #12 from BigRoy/enhancement/AY-1261-debug_shell_la…
Browse files Browse the repository at this point in the history
…uncher_action

Add a Debug Shell launcher which allows to launch a terminal into environment of an application
  • Loading branch information
BigRoy authored Sep 3, 2024
2 parents 239e8fa + 9215300 commit fd0fdf3
Show file tree
Hide file tree
Showing 10 changed files with 290 additions and 11 deletions.
20 changes: 10 additions & 10 deletions client/ayon_applications/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .constants import APPLICATIONS_ADDON_ROOT
from .defs import LaunchTypes
from .manager import ApplicationManager
from .utils import get_app_icon_path


class ApplicationsAddon(AYONAddon, IPluginPaths):
Expand Down Expand Up @@ -108,12 +109,17 @@ def get_applications_manager(self, settings=None):
return ApplicationManager(settings)

def get_plugin_paths(self):
plugins_dir = os.path.join(APPLICATIONS_ADDON_ROOT, "plugins")
return {
"publish": [
os.path.join(APPLICATIONS_ADDON_ROOT, "plugins", "publish")
]
"actions": [os.path.join(plugins_dir, "launcher_actions")],
"publish": [os.path.join(plugins_dir, "publish")]
}

def get_launch_hook_paths(self, app):
return [
os.path.join(APPLICATIONS_ADDON_ROOT, "hooks")
]

def get_app_icon_path(self, icon_filename):
"""Get icon path.
Expand All @@ -124,13 +130,7 @@ def get_app_icon_path(self, icon_filename):
Union[str, None]: Icon path or None if not found.
"""
if not icon_filename:
return None
icon_name = os.path.basename(icon_filename)
path = os.path.join(APPLICATIONS_ADDON_ROOT, "icons", icon_name)
if os.path.exists(path):
return path
return None
return get_app_icon_path(icon_filename)

def get_app_icon_url(self, icon_filename, server=False):
"""Get icon path.
Expand Down
2 changes: 2 additions & 0 deletions client/ayon_applications/defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ class ApplicationGroup:
"photoshop": "photoshop.png",
"resolve": "resolve.png",
"substancepainter": "substancepainter.png",
"terminal": "terminal.png",
"tvpaint": "tvpaint.png",
"unreal": "ue4.png",
"wrap": "wrap.png",
Expand All @@ -199,6 +200,7 @@ class ApplicationGroup:
"photoshop": "Photoshop",
"resolve": "Resolve",
"substancepainter": "Substance Painter",
"terminal": "Terminal",
"tvpaint": "TVPaint",
"unreal": "Unreal Editor",
"wrap": "Wrap",
Expand Down
23 changes: 23 additions & 0 deletions client/ayon_applications/hooks/prelaunch_shell_windows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import subprocess
from ayon_applications import PreLaunchHook, LaunchTypes


class LaunchTerminalWindowsCreationflags(PreLaunchHook):
"""Avoid running the terminal without new console"""

# Should be as last hook because must change launch arguments to string
order = 1000
app_groups = {"terminal"}
platforms = {"windows"}
launch_types = {LaunchTypes.local}

def execute(self):
# Change `creationflags` to CREATE_NEW_CONSOLE
# - on Windows some apps will create new window using its console
# Set `stdout` and `stderr` to None so new created console does not
# have redirected output to DEVNULL in build
self.launch_context.kwargs.update({
"creationflags": subprocess.CREATE_NEW_CONSOLE,
"stdout": None,
"stderr": None
})
Binary file added client/ayon_applications/icons/terminal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
189 changes: 189 additions & 0 deletions client/ayon_applications/plugins/launcher_actions/debug_terminal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import os
from typing import Optional

from qtpy import QtWidgets, QtGui, QtCore

from ayon_applications import (
Application,
ApplicationManager
)

from ayon_applications.utils import (
get_app_environments_for_context,
get_applications_for_context,
get_app_icon_path
)
from ayon_core.pipeline.actions import LauncherActionSelection
from ayon_core.pipeline import LauncherAction
from ayon_core.style import load_stylesheet
from ayon_core.tools.utils.lib import get_qt_icon


def get_application_qt_icon(application: Application) -> Optional[QtGui.QIcon]:
"""Return QtGui.QIcon for an Application"""
icon = application.icon
if not icon:
return QtGui.QIcon()
icon_filepath = get_app_icon_path(icon)
if os.path.exists(icon_filepath):
return get_qt_icon({"type": "path", "path": icon_filepath})
return QtGui.QIcon()


class DebugTerminal(LauncherAction):
"""Run any host environment in command line terminal."""
name = "debugterminal"
label = "Terminal"
icon = {
"type": "awesome-font",
"name": "fa.terminal",
"color": "#e8770e"
}
order = 10

def is_compatible(self, selection) -> bool:
return selection.is_task_selected

def process(self, selection, **kwargs):
# Get cursor position directly so the menu shows closer to where user
# clicked because the get applications logic might take a brief moment
pos = QtGui.QCursor.pos()
application_manager = ApplicationManager()

# Choose terminal
terminal_applications = self.get_terminal_applications(
application_manager)
if len(terminal_applications) == 0:
raise ValueError(
"Missing application variants for terminal application. "
"Please configure "
"'ayon+settings://applications/applications/terminal'"
)
elif len(terminal_applications) == 1:
# If only one configured shell application, always use that one
terminal_app = terminal_applications[0]
print("Only one terminal application variant is configured. "
f"Defaulting to {terminal_app.full_label}")
else:
terminal_app = self.choose_app(
terminal_applications, pos, show_variant_name_only=True)
if not terminal_app:
return

# Get applications
applications = self.get_project_applications(
application_manager, selection)
app = self.choose_app(applications, pos)
if not app:
return

print(f"Retrieving environment for: {app.full_label}..")
env = get_app_environments_for_context(selection.project_name,
selection.folder_path,
selection.task_name,
app.full_name)

# If an executable is found. Then add the parent folder to PATH
# just so we can run the application easily from the command line.
exe = app.find_executable()
if exe:
exe_path = exe._realpath()
folder = os.path.dirname(exe_path)
print(f"Appending to PATH: {folder}")
env["PATH"] += os.pathsep + folder

cwd = env.get("AYON_WORKDIR")
if cwd:
print(f"Setting Work Directory: {cwd}")

print(f"Launching terminal in environment of {app.full_label}..")
self.launch_terminal_with_app_context(
application_manager,
terminal_app,
project_name=selection.project_name,
folder_path=selection.folder_path,
task_name=selection.task_name,
env=env,
cwd=cwd)

@staticmethod
def choose_app(
applications: list[Application],
pos: QtCore.QPoint,
show_variant_name_only: bool = False
) -> Optional[Application]:
"""Show menu to choose from list of applications"""
menu = QtWidgets.QMenu()
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose) # force garbage collect
menu.setStyleSheet(load_stylesheet())

# Sort applications
applications.sort(key=lambda item: item.full_label)

for app in applications:
label = app.label if show_variant_name_only else app.full_label
menu_action = QtWidgets.QAction(label, parent=menu)
icon = get_application_qt_icon(app)
if icon:
menu_action.setIcon(icon)
menu_action.setData(app)
menu.addAction(menu_action)

result = menu.exec_(pos)
if result:
return result.data()

@staticmethod
def get_project_applications(
application_manager: ApplicationManager,
selection: LauncherActionSelection) -> list[Application]:
"""Return the enabled applications for the project"""

application_names = get_applications_for_context(
project_name=selection.project_name,
folder_entity=selection.folder_entity,
task_entity=selection.task_entity,
project_settings=selection.get_project_settings(),
project_entity=selection.project_entity
)

# Filter to apps valid for this current project, with logic from:
# `ayon_core.tools.launcher.models.actions.ApplicationAction.is_compatible` # noqa
applications = []
for app_name in application_names:
app = application_manager.applications.get(app_name)
if not app or not app.enabled:
continue
applications.append(app)

return applications

@staticmethod
def get_terminal_applications(application_manager) -> list[Application]:
"""Return all configured terminal applications"""
# TODO: Maybe filter out terminal applications not configured for your
# current platform
return list(
application_manager.app_groups["terminal"].variants.values())

def launch_terminal_with_app_context(
self,
application_manager: ApplicationManager,
application: Application,
project_name: str,
folder_path: str,
task_name: str,
cwd: str,
env: dict[str, str]
) -> list[str]:
"""Return the terminal executable to launch."""
# TODO: Allow customization per user for this via AYON settings
launch_context = application_manager.create_launch_context(
application.full_name,
project_name=project_name,
folder_path=folder_path,
task_name=task_name,
env=env
)
launch_context.kwargs["cwd"] = cwd
return application_manager.launch_with_context(launch_context)
25 changes: 24 additions & 1 deletion client/ayon_applications/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
should_open_workfiles_tool_on_launch,
)

from .constants import PLATFORM_NAMES, DEFAULT_ENV_SUBGROUP
from .constants import (
APPLICATIONS_ADDON_ROOT,
DEFAULT_ENV_SUBGROUP,
PLATFORM_NAMES,
)
from .exceptions import MissingRequiredKey, ApplicationLaunchFailed
from .manager import ApplicationManager

Expand Down Expand Up @@ -742,3 +746,22 @@ def _prepare_last_workfile(data, workdir, addons_manager):

data["env"]["AYON_LAST_WORKFILE"] = last_workfile_path
data["last_workfile_path"] = last_workfile_path


def get_app_icon_path(icon_filename):
"""Get icon path.
Args:
icon_filename (str): Icon filename.
Returns:
Union[str, None]: Icon path or None if not found.
"""
if not icon_filename:
return None
icon_name = os.path.basename(icon_filename)
path = os.path.join(APPLICATIONS_ADDON_ROOT, "icons", icon_name)
if os.path.exists(path):
return path
return None
Binary file added public/icons/terminal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions server/applications.json
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,42 @@
}
]
},
"terminal": {
"enabled": true,
"host_name": "",
"environment": "{}",
"variants": [
{
"name": "main",
"label": "Main",
"executables": {
"windows": ["cmd.exe"],
"darwin": ["Terminal.app"],
"linux": [
"gnome-terminal",
"x-terminal-emulator",
"konsole",
"xfce4-terminal",
"lxterminal",
"mate-terminal",
"tilix",
"deepin-terminal",
"terminator",
"alacritty",
"st",
"urxvt",
"xterm"
]
},
"arguments": {
"windows": [],
"darwin": [],
"linux": []
},
"environment": "{}"
}
]
},
"additional_apps": []
}
}
1 change: 1 addition & 0 deletions server/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"photoshop": "Photoshop",
"resolve": "Resolve",
"substancepainter": "Substance Painter",
"terminal": "Terminal",
"tvpaint": "TVPaint",
"unreal": "Unreal Editor",
"wrap": "Wrap",
Expand Down
5 changes: 5 additions & 0 deletions server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"zbrush",
"equalizer",
"motionbuilder",
"terminal",
}


Expand Down Expand Up @@ -319,6 +320,10 @@ class ApplicationsSettings(BaseSettingsModel):
default_factory=AppGroup, title="3DEqualizer")
motionbuilder: AppGroup = SettingsField(
default_factory=AppGroup, title="Motion Builder")
terminal: AppGroup = SettingsField(
default_factory=AppGroup,
title="Terminal",
description="Terminal application")
additional_apps: list[AdditionalAppGroup] = SettingsField(
default_factory=list, title="Additional Applications")

Expand Down

0 comments on commit fd0fdf3

Please sign in to comment.