Merge pull request #5783 from ynput/feature/OP-6631_Launcher-in-dev-mode

AYON: Support dev bundles
This commit is contained in:
Jakub Trllo 2023-10-19 11:30:13 +02:00 committed by GitHub
commit d8dcd91a93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 299 additions and 117 deletions

View file

@ -31,13 +31,13 @@ from openpype.settings.lib import (
get_studio_system_settings_overrides,
load_json_file
)
from openpype.settings.ayon_settings import is_dev_mode_enabled
from openpype.lib import (
Logger,
import_filepath,
import_module_from_dirpath,
)
from openpype.lib.openpype_version import is_staging_enabled
from .interfaces import (
OpenPypeInterface,
@ -317,21 +317,10 @@ def load_modules(force=False):
time.sleep(0.1)
def _get_ayon_addons_information():
"""Receive information about addons to use from server.
Todos:
Actually ask server for the information.
Allow project name as optional argument to be able to query information
about used addons for specific project.
Returns:
List[Dict[str, Any]]: List of addon information to use.
"""
output = []
def _get_ayon_bundle_data():
bundle_name = os.getenv("AYON_BUNDLE_NAME")
bundles = ayon_api.get_bundles()["bundles"]
final_bundle = next(
return next(
(
bundle
for bundle in bundles
@ -339,10 +328,22 @@ def _get_ayon_addons_information():
),
None
)
if final_bundle is None:
return output
bundle_addons = final_bundle["addons"]
def _get_ayon_addons_information(bundle_info):
"""Receive information about addons to use from server.
Todos:
Actually ask server for the information.
Allow project name as optional argument to be able to query information
about used addons for specific project.
Returns:
List[Dict[str, Any]]: List of addon information to use.
"""
output = []
bundle_addons = bundle_info["addons"]
addons = ayon_api.get_addons_info()["addons"]
for addon in addons:
name = addon["name"]
@ -378,38 +379,73 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
v3_addons_to_skip = []
addons_info = _get_ayon_addons_information()
bundle_info = _get_ayon_bundle_data()
addons_info = _get_ayon_addons_information(bundle_info)
if not addons_info:
return v3_addons_to_skip
addons_dir = os.environ.get("AYON_ADDONS_DIR")
if not addons_dir:
addons_dir = os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
"addons"
)
if not os.path.exists(addons_dir):
dev_mode_enabled = is_dev_mode_enabled()
dev_addons_info = {}
if dev_mode_enabled:
# Get dev addons info only when dev mode is enabled
dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info)
addons_dir_exists = os.path.exists(addons_dir)
if not addons_dir_exists:
log.warning("Addons directory does not exists. Path \"{}\"".format(
addons_dir
))
return v3_addons_to_skip
for addon_info in addons_info:
addon_name = addon_info["name"]
addon_version = addon_info["version"]
folder_name = "{}_{}".format(addon_name, addon_version)
addon_dir = os.path.join(addons_dir, folder_name)
if not os.path.exists(addon_dir):
log.debug((
"No localized client code found for addon {} {}."
).format(addon_name, addon_version))
dev_addon_info = dev_addons_info.get(addon_name, {})
use_dev_path = dev_addon_info.get("enabled", False)
addon_dir = None
if use_dev_path:
addon_dir = dev_addon_info["path"]
if not addon_dir or not os.path.exists(addon_dir):
log.warning((
"Dev addon {} {} path does not exists. Path \"{}\""
).format(addon_name, addon_version, addon_dir))
continue
elif addons_dir_exists:
folder_name = "{}_{}".format(addon_name, addon_version)
addon_dir = os.path.join(addons_dir, folder_name)
if not os.path.exists(addon_dir):
log.debug((
"No localized client code found for addon {} {}."
).format(addon_name, addon_version))
continue
if not addon_dir:
continue
sys.path.insert(0, addon_dir)
imported_modules = []
for name in os.listdir(addon_dir):
# Ignore of files is implemented to be able to run code from code
# where usually is more files than just the addon
# Ignore start and setup scripts
if name in ("setup.py", "start.py"):
continue
path = os.path.join(addon_dir, name)
basename, ext = os.path.splitext(name)
# Ignore folders/files with dot in name
# - dot names cannot be imported in Python
if "." in basename:
continue
is_dir = os.path.isdir(path)
is_py_file = ext.lower() == ".py"
if not is_py_file and not is_dir:

View file

@ -55,6 +55,9 @@ def get_openpype_staging_icon_filepath():
def get_openpype_icon_filepath(staging=None):
if AYON_SERVER_ENABLED and os.getenv("AYON_USE_DEV") == "1":
return get_resource("icons", "AYON_icon_dev.png")
if staging is None:
staging = is_running_staging()
@ -68,7 +71,9 @@ def get_openpype_splash_filepath(staging=None):
staging = is_running_staging()
if AYON_SERVER_ENABLED:
if staging:
if os.getenv("AYON_USE_DEV") == "1":
splash_file_name = "AYON_splash_dev.png"
elif staging:
splash_file_name = "AYON_splash_staging.png"
else:
splash_file_name = "AYON_splash.png"

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -290,6 +290,16 @@ def _convert_modules_system(
modules_settings[key] = value
def is_dev_mode_enabled():
"""Dev mode is enabled in AYON.
Returns:
bool: True if dev mode is enabled.
"""
return os.getenv("AYON_USE_DEV") == "1"
def convert_system_settings(ayon_settings, default_settings, addon_versions):
default_settings = copy.deepcopy(default_settings)
output = {
@ -1400,15 +1410,39 @@ class _AyonSettingsCache:
if _AyonSettingsCache.variant is None:
from openpype.lib.openpype_version import is_staging_enabled
_AyonSettingsCache.variant = (
"staging" if is_staging_enabled() else "production"
)
variant = "production"
if is_dev_mode_enabled():
variant = cls._get_dev_mode_settings_variant()
elif is_staging_enabled():
variant = "staging"
_AyonSettingsCache.variant = variant
return _AyonSettingsCache.variant
@classmethod
def _get_bundle_name(cls):
return os.environ["AYON_BUNDLE_NAME"]
@classmethod
def _get_dev_mode_settings_variant(cls):
"""Develop mode settings variant.
Returns:
str: Name of settings variant.
"""
bundles = ayon_api.get_bundles()
user = ayon_api.get_user()
username = user["name"]
for bundle in bundles:
if (
bundle.get("isDev")
and bundle.get("activeUser") == username
):
return bundle["name"]
# Return fake variant - distribution logic will tell user that he
# does not have set any dev bundle
return "dev"
@classmethod
def get_value_by_project(cls, project_name):
cache_item = _AyonSettingsCache.cache_by_project_name[project_name]

View file

@ -602,12 +602,12 @@ def delete_installer(*args, **kwargs):
def download_installer(*args, **kwargs):
con = get_server_api_connection()
con.download_installer(*args, **kwargs)
return con.download_installer(*args, **kwargs)
def upload_installer(*args, **kwargs):
con = get_server_api_connection()
con.upload_installer(*args, **kwargs)
return con.upload_installer(*args, **kwargs)
# Dependency packages
@ -753,12 +753,12 @@ def get_secrets(*args, **kwargs):
def get_secret(*args, **kwargs):
con = get_server_api_connection()
return con.delete_secret(*args, **kwargs)
return con.get_secret(*args, **kwargs)
def save_secret(*args, **kwargs):
con = get_server_api_connection()
return con.delete_secret(*args, **kwargs)
return con.save_secret(*args, **kwargs)
def delete_secret(*args, **kwargs):
@ -978,12 +978,14 @@ def delete_project(project_name):
def get_thumbnail_by_id(project_name, thumbnail_id):
con = get_server_api_connection()
con.get_thumbnail_by_id(project_name, thumbnail_id)
return con.get_thumbnail_by_id(project_name, thumbnail_id)
def get_thumbnail(project_name, entity_type, entity_id, thumbnail_id=None):
con = get_server_api_connection()
con.get_thumbnail(project_name, entity_type, entity_id, thumbnail_id)
return con.get_thumbnail(
project_name, entity_type, entity_id, thumbnail_id
)
def get_folder_thumbnail(project_name, folder_id, thumbnail_id=None):

View file

@ -144,6 +144,7 @@ def product_types_query(fields):
query_queue.append((k, v, field))
return query
def project_product_types_query(fields):
query = GraphQlQuery("ProjectProductTypes")
project_query = query.add_field("project")
@ -175,6 +176,8 @@ def folders_graphql_query(fields):
parent_folder_ids_var = query.add_variable("parentFolderIds", "[String!]")
folder_paths_var = query.add_variable("folderPaths", "[String!]")
folder_names_var = query.add_variable("folderNames", "[String!]")
folder_types_var = query.add_variable("folderTypes", "[String!]")
statuses_var = query.add_variable("folderStatuses", "[String!]")
has_products_var = query.add_variable("folderHasProducts", "Boolean!")
project_field = query.add_field("project")
@ -185,6 +188,8 @@ def folders_graphql_query(fields):
folders_field.set_filter("parentIds", parent_folder_ids_var)
folders_field.set_filter("names", folder_names_var)
folders_field.set_filter("paths", folder_paths_var)
folders_field.set_filter("folderTypes", folder_types_var)
folders_field.set_filter("statuses", statuses_var)
folders_field.set_filter("hasProducts", has_products_var)
nested_fields = fields_to_dict(fields)

View file

@ -75,6 +75,7 @@ from .utils import (
TransferProgress,
create_dependency_package_basename,
ThumbnailContent,
get_default_timeout,
)
PatternType = type(re.compile(""))
@ -351,7 +352,6 @@ class ServerAPI(object):
timeout (Optional[float]): Timeout for requests.
max_retries (Optional[int]): Number of retries for requests.
"""
_default_timeout = 10.0
_default_max_retries = 3
def __init__(
@ -500,20 +500,13 @@ class ServerAPI(object):
def get_default_timeout(cls):
"""Default value for requests timeout.
First looks for environment variable SERVER_TIMEOUT_ENV_KEY which
can affect timeout value. If not available then use class
attribute '_default_timeout'.
Utils function 'get_default_timeout' is used by default.
Returns:
float: Timeout value in seconds.
"""
try:
return float(os.environ.get(SERVER_TIMEOUT_ENV_KEY))
except (ValueError, TypeError):
pass
return cls._default_timeout
return get_default_timeout()
@classmethod
def get_default_max_retries(cls):
@ -662,13 +655,10 @@ class ServerAPI(object):
as default variant.
Args:
variant (Literal['production', 'staging']): Settings variant name.
variant (str): Settings variant name. It is possible to use
'production', 'staging' or name of dev bundle.
"""
if variant not in ("production", "staging"):
raise ValueError((
"Invalid variant name {}. Expected 'production' or 'staging'"
).format(variant))
self._default_settings_variant = variant
default_settings_variant = property(
@ -938,8 +928,8 @@ class ServerAPI(object):
int(re_match.group("major")),
int(re_match.group("minor")),
int(re_match.group("patch")),
re_match.group("prerelease"),
re_match.group("buildmetadata")
re_match.group("prerelease") or "",
re_match.group("buildmetadata") or "",
)
return self._server_version_tuple
@ -1140,31 +1130,41 @@ class ServerAPI(object):
response = None
new_response = None
for _ in range(max_retries):
for retry_idx in reversed(range(max_retries)):
try:
response = function(url, **kwargs)
break
except ConnectionRefusedError:
if retry_idx == 0:
self.log.warning(
"Connection error happened.", exc_info=True
)
# Server may be restarting
new_response = RestApiResponse(
None,
{"detail": "Unable to connect the server. Connection refused"}
)
except requests.exceptions.Timeout:
# Connection timed out
new_response = RestApiResponse(
None,
{"detail": "Connection timed out."}
)
except requests.exceptions.ConnectionError:
# Other connection error (ssl, etc) - does not make sense to
# try call server again
# Log warning only on last attempt
if retry_idx == 0:
self.log.warning(
"Connection error happened.", exc_info=True
)
new_response = RestApiResponse(
None,
{"detail": "Unable to connect the server. Connection error"}
)
break
time.sleep(0.1)
@ -1349,7 +1349,9 @@ class ServerAPI(object):
status=None,
description=None,
summary=None,
payload=None
payload=None,
progress=None,
retries=None
):
kwargs = {
key: value
@ -1360,9 +1362,27 @@ class ServerAPI(object):
("description", description),
("summary", summary),
("payload", payload),
("progress", progress),
("retries", retries),
)
if value is not None
}
# 'progress' and 'retries' are available since 0.5.x server version
major, minor, _, _, _ = self.server_version_tuple
if (major, minor) < (0, 5):
args = []
if progress is not None:
args.append("progress")
if retries is not None:
args.append("retries")
fields = ", ".join("'{}'".format(f) for f in args)
ending = "s" if len(args) > 1 else ""
raise ValueError((
"Your server version '{}' does not support update"
" of {} field{} on event. The fields are supported since"
" server version '0.5'."
).format(self.get_server_version(), fields, ending))
response = self.patch(
"events/{}".format(event_id),
**kwargs
@ -1434,6 +1454,7 @@ class ServerAPI(object):
description=None,
sequential=None,
events_filter=None,
max_retries=None,
):
"""Enroll job based on events.
@ -1475,8 +1496,12 @@ class ServerAPI(object):
in target event.
sequential (Optional[bool]): The source topic must be processed
in sequence.
events_filter (Optional[ayon_server.sqlfilter.Filter]): A dict-like
with conditions to filter the source event.
events_filter (Optional[dict[str, Any]]): Filtering conditions
to filter the source event. For more technical specifications
look to server backed 'ayon_server.sqlfilter.Filter'.
TODO: Add example of filters.
max_retries (Optional[int]): How many times can be event retried.
Default value is based on server (3 at the time of this PR).
Returns:
Union[None, dict[str, Any]]: None if there is no event matching
@ -1487,6 +1512,7 @@ class ServerAPI(object):
"sourceTopic": source_topic,
"targetTopic": target_topic,
"sender": sender,
"maxRetries": max_retries,
}
if sequential is not None:
kwargs["sequential"] = sequential
@ -2236,6 +2262,34 @@ class ServerAPI(object):
response.raise_for_status("Failed to create/update dependency")
return response.data
def _get_dependency_package_route(
self, filename=None, platform_name=None
):
major, minor, patch, _, _ = self.server_version_tuple
if (major, minor, patch) <= (0, 2, 0):
# Backwards compatibility for AYON server 0.2.0 and lower
self.log.warning((
"Using deprecated dependency package route."
" Please update your AYON server to version 0.2.1 or higher."
" Backwards compatibility for this route will be removed"
" in future releases of ayon-python-api."
))
if platform_name is None:
platform_name = platform.system().lower()
base = "dependencies"
if not filename:
return base
return "{}/{}/{}".format(base, filename, platform_name)
if (major, minor) <= (0, 3):
endpoint = "desktop/dependency_packages"
else:
endpoint = "desktop/dependencyPackages"
if filename:
return "{}/{}".format(endpoint, filename)
return endpoint
def get_dependency_packages(self):
"""Information about dependency packages on server.
@ -2263,33 +2317,11 @@ class ServerAPI(object):
server.
"""
endpoint = "desktop/dependencyPackages"
major, minor, _, _, _ = self.server_version_tuple
if major == 0 and minor <= 3:
endpoint = "desktop/dependency_packages"
endpoint = self._get_dependency_package_route()
result = self.get(endpoint)
result.raise_for_status()
return result.data
def _get_dependency_package_route(
self, filename=None, platform_name=None
):
major, minor, patch, _, _ = self.server_version_tuple
if major == 0 and (minor > 2 or (minor == 2 and patch >= 1)):
base = "desktop/dependency_packages"
if not filename:
return base
return "{}/{}".format(base, filename)
# Backwards compatibility for AYON server 0.2.0 and lower
if platform_name is None:
platform_name = platform.system().lower()
base = "dependencies"
if not filename:
return base
return "{}/{}/{}".format(base, filename, platform_name)
def create_dependency_package(
self,
filename,
@ -3515,7 +3547,9 @@ class ServerAPI(object):
folder_ids=None,
folder_paths=None,
folder_names=None,
folder_types=None,
parent_ids=None,
statuses=None,
active=True,
fields=None,
own_attributes=False
@ -3536,8 +3570,12 @@ class ServerAPI(object):
for filtering.
folder_names (Optional[Iterable[str]]): Folder names used
for filtering.
folder_types (Optional[Iterable[str]]): Folder types used
for filtering.
parent_ids (Optional[Iterable[str]]): Ids of folder parents.
Use 'None' if folder is direct child of project.
statuses (Optional[Iterable[str]]): Folder statuses used
for filtering.
active (Optional[bool]): Filter active/inactive folders.
Both are returned if is set to None.
fields (Optional[Iterable[str]]): Fields to be queried for
@ -3574,6 +3612,18 @@ class ServerAPI(object):
return
filters["folderNames"] = list(folder_names)
if folder_types is not None:
folder_types = set(folder_types)
if not folder_types:
return
filters["folderTypes"] = list(folder_types)
if statuses is not None:
statuses = set(statuses)
if not statuses:
return
filters["folderStatuses"] = list(statuses)
if parent_ids is not None:
parent_ids = set(parent_ids)
if not parent_ids:
@ -4312,9 +4362,6 @@ class ServerAPI(object):
fields.remove("attrib")
fields |= self.get_attributes_fields_for_type("version")
if active is not None:
fields.add("active")
# Make sure fields have minimum required fields
fields |= {"id", "version"}
@ -4323,6 +4370,9 @@ class ServerAPI(object):
use_rest = True
fields = {"id"}
if active is not None:
fields.add("active")
if own_attributes:
fields.add("ownAttrib")
@ -5845,19 +5895,22 @@ class ServerAPI(object):
"""Helper method to get links from server for entity types.
Example output:
[
{
"id": "59a212c0d2e211eda0e20242ac120002",
"linkType": "reference",
"description": "reference link between folders",
"projectName": "my_project",
"author": "frantadmin",
"entityId": "b1df109676db11ed8e8c6c9466b19aa8",
"entityType": "folder",
"direction": "out"
},
{
"59a212c0d2e211eda0e20242ac120001": [
{
"id": "59a212c0d2e211eda0e20242ac120002",
"linkType": "reference",
"description": "reference link between folders",
"projectName": "my_project",
"author": "frantadmin",
"entityId": "b1df109676db11ed8e8c6c9466b19aa8",
"entityType": "folder",
"direction": "out"
},
...
],
...
]
}
Args:
project_name (str): Project where links are.

View file

@ -1,3 +1,4 @@
import os
import re
import datetime
import uuid
@ -15,6 +16,7 @@ except ImportError:
import requests
import unidecode
from .constants import SERVER_TIMEOUT_ENV_KEY
from .exceptions import UrlError
REMOVED_VALUE = object()
@ -27,6 +29,23 @@ RepresentationParents = collections.namedtuple(
)
def get_default_timeout():
"""Default value for requests timeout.
First looks for environment variable SERVER_TIMEOUT_ENV_KEY which
can affect timeout value. If not available then use 10.0 s.
Returns:
float: Timeout value in seconds.
"""
try:
return float(os.environ.get(SERVER_TIMEOUT_ENV_KEY))
except (ValueError, TypeError):
pass
return 10.0
class ThumbnailContent:
"""Wrapper for thumbnail content.
@ -231,30 +250,36 @@ def _try_parse_url(url):
return None
def _try_connect_to_server(url):
def _try_connect_to_server(url, timeout=None):
if timeout is None:
timeout = get_default_timeout()
try:
# TODO add validation if the url lead to Ayon server
# - thiw won't validate if the url lead to 'google.com'
requests.get(url)
# - this won't validate if the url lead to 'google.com'
requests.get(url, timeout=timeout)
except BaseException:
return False
return True
def login_to_server(url, username, password):
def login_to_server(url, username, password, timeout=None):
"""Use login to the server to receive token.
Args:
url (str): Server url.
username (str): User's username.
password (str): User's password.
timeout (Optional[float]): Timeout for request. Value from
'get_default_timeout' is used if not specified.
Returns:
Union[str, None]: User's token if login was successfull.
Otherwise 'None'.
"""
if timeout is None:
timeout = get_default_timeout()
headers = {"Content-Type": "application/json"}
response = requests.post(
"{}/api/auth/login".format(url),
@ -262,7 +287,8 @@ def login_to_server(url, username, password):
json={
"name": username,
"password": password
}
},
timeout=timeout,
)
token = None
# 200 - success
@ -273,47 +299,67 @@ def login_to_server(url, username, password):
return token
def logout_from_server(url, token):
def logout_from_server(url, token, timeout=None):
"""Logout from server and throw token away.
Args:
url (str): Url from which should be logged out.
token (str): Token which should be used to log out.
timeout (Optional[float]): Timeout for request. Value from
'get_default_timeout' is used if not specified.
"""
if timeout is None:
timeout = get_default_timeout()
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer {}".format(token)
}
requests.post(
url + "/api/auth/logout",
headers=headers
headers=headers,
timeout=timeout,
)
def is_token_valid(url, token):
def is_token_valid(url, token, timeout=None):
"""Check if token is valid.
Token can be a user token or service api key.
Args:
url (str): Server url.
token (str): User's token.
timeout (Optional[float]): Timeout for request. Value from
'get_default_timeout' is used if not specified.
Returns:
bool: True if token is valid.
"""
headers = {
if timeout is None:
timeout = get_default_timeout()
base_headers = {
"Content-Type": "application/json",
"Authorization": "Bearer {}".format(token)
}
response = requests.get(
"{}/api/users/me".format(url),
headers=headers
)
return response.status_code == 200
for header_value in (
{"Authorization": "Bearer {}".format(token)},
{"X-Api-Key": token},
):
headers = base_headers.copy()
headers.update(header_value)
response = requests.get(
"{}/api/users/me".format(url),
headers=headers,
timeout=timeout,
)
if response.status_code == 200:
return True
return False
def validate_url(url):
def validate_url(url, timeout=None):
"""Validate url if is valid and server is available.
Validation checks if can be parsed as url and contains scheme.
@ -334,6 +380,7 @@ def validate_url(url):
Args:
url (str): Server url.
timeout (Optional[int]): Timeout in seconds for connection to server.
Returns:
Url which was used to connect to server.
@ -369,10 +416,10 @@ def validate_url(url):
# - this will trigger UrlError if both will crash
if not parsed_url.scheme:
new_url = "https://" + modified_url
if _try_connect_to_server(new_url):
if _try_connect_to_server(new_url, timeout=timeout):
return new_url
if _try_connect_to_server(modified_url):
if _try_connect_to_server(modified_url, timeout=timeout):
return modified_url
hints = []

View file

@ -1,2 +1,2 @@
"""Package declaring Python API for Ayon server."""
__version__ = "0.4.1"
__version__ = "0.5.1"