diff --git a/dev/update_python_packages.py b/dev/update_python_packages.py index 63514a8d..25979610 100644 --- a/dev/update_python_packages.py +++ b/dev/update_python_packages.py @@ -112,7 +112,9 @@ def install_common_python_packages(python_dist_dir): """ if not os.path.exists(python_dist_dir): - print(f"Cannot find Python distribution folder {python_dist_dir}") + print( + f"Missing Python distribution folder {python_dist_dir}. \nCreate folder with requirements.txt to support new Python version." + ) return with TemporaryDirectory() as temp_dir: @@ -304,5 +306,7 @@ def install_qt_packages(python_dist_dir): f"Python{sys.version_info.major}{sys.version_info.minor}", ) ) -install_common_python_packages(python_dist_dir) +success = install_common_python_packages(python_dist_dir) +if not success: + sys.exit(1) install_qt_packages(python_dist_dir) diff --git a/dist/Alias/python3.13/2027.1/alias_api_om.pyd b/dist/Alias/python3.13/2027.1/alias_api_om.pyd new file mode 100644 index 00000000..c24036a5 Binary files /dev/null and b/dist/Alias/python3.13/2027.1/alias_api_om.pyd differ diff --git a/dist/Python/Python312/packages/frozen_requirements.txt b/dist/Python/Python312/packages/frozen_requirements.txt new file mode 100644 index 00000000..199388f3 --- /dev/null +++ b/dist/Python/Python312/packages/frozen_requirements.txt @@ -0,0 +1,18 @@ +bidict==0.23.1 +certifi==2026.4.22 +cffi==2.0.0 +charset-normalizer==3.4.7 +cryptography==48.0.0 +dnspython==2.8.0 +eventlet==0.41.0 +greenlet==3.5.0 +h11==0.16.0 +idna==3.13 +pycparser==3.0 +python-engineio==4.13.1 +python-socketio==5.16.1 +requests==2.33.1 +simple-websocket==1.1.0 +urllib3==2.7.0 +websocket-client==1.9.0 +wsproto==1.3.2 diff --git a/dist/Python/Python312/packages/pkgs.zip b/dist/Python/Python312/packages/pkgs.zip new file mode 100644 index 00000000..cab13397 Binary files /dev/null and b/dist/Python/Python312/packages/pkgs.zip differ diff --git a/dist/Python/Python312/requirements.txt b/dist/Python/Python312/requirements.txt new file mode 100644 index 00000000..8aaf975a --- /dev/null +++ b/dist/Python/Python312/requirements.txt @@ -0,0 +1,15 @@ +# Copyright (c) 2024 Autodesk Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the ShotGrid Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the ShotGrid Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Autodesk Inc. + +cryptography +eventlet +python-socketio>=5.14.0 +requests +websocket-client \ No newline at end of file diff --git a/python/tk_framework_alias/client/socketio/proxy_wrapper.py b/python/tk_framework_alias/client/socketio/proxy_wrapper.py index cf9453b9..d21d2ecc 100644 --- a/python/tk_framework_alias/client/socketio/proxy_wrapper.py +++ b/python/tk_framework_alias/client/socketio/proxy_wrapper.py @@ -609,6 +609,11 @@ def _create_object(self): """ class_attrs = self._get_attributes() + + # Remove __slots__ — proxy classes don't need them, and their presence + # causes ValueError when a class variable shares a name with a slot. + class_attrs.pop("__slots__", None) + return type(self.__class_name, (self.__class__,), class_attrs) @@ -773,11 +778,16 @@ def _create_proxy(cls, data): proxy_module_name = data["__module_name__"] module = AliasClientObjectProxyWrapper.get_module(proxy_module_name) if not module: - raise Exception("Module not found") + return None proxy_type_name = data["__class_name__"] proxy_type = cls.get_proxy_type(proxy_module_name, proxy_type_name) if not proxy_type: - lookup_type = getattr(module, proxy_type_name) + lookup_type = getattr(module, proxy_type_name, None) + if lookup_type is None: + # Unknown type (e.g. PyCapsule) — create a bare proxy + proxy_type = type(proxy_type_name, (cls,), {}) + cls.store_type(proxy_module_name, proxy_type_name, proxy_type) + return proxy_type(data) proxy_attributes = lookup_type.__dict__ # Skip any private members, and modify any attributes that conflict diff --git a/python/tk_framework_alias/server/api/__init__.py b/python/tk_framework_alias/server/api/__init__.py index 68217e11..7295d0f5 100644 --- a/python/tk_framework_alias/server/api/__init__.py +++ b/python/tk_framework_alias/server/api/__init__.py @@ -54,7 +54,7 @@ def get_module_path(module_name, alias_version): return module_path -def get_alias_api_module(): +def get_alias_api_module(alias_bin_path=None): """ Import the right Alias Python API module according to the criteria: - the version of Alias @@ -68,7 +68,19 @@ def get_alias_api_module(): is_open_model = os.path.basename(sys.executable) != "Alias.exe" module_name = OPEN_MODEL_API_NAME if is_open_model else OPEN_ALIAS_API_NAME alias_version = get_alias_version() - module_path = get_module_path(module_name, alias_version) + + # First check if Alias ships the Python API module + module_path = None + if alias_bin_path: + alias_api_pyd = f"{module_name}.pyd" + alias_python_module_path = os.path.join(alias_bin_path, alias_api_pyd) + if os.path.exists(alias_python_module_path): + module_path = alias_python_module_path + + if not module_path: + # Fallback to the framework's Python API module + module_path = get_module_path(module_name, alias_version) + if not module_path: return None @@ -122,6 +134,6 @@ def get_alias_api_module(): ) alias_dll_path = os.path.dirname(alias_bin_path) with os.add_dll_directory(alias_dll_path): - alias_api = get_alias_api_module() + alias_api = get_alias_api_module(alias_dll_path) else: alias_api = get_alias_api_module() diff --git a/python/tk_framework_alias/server/socketio/namespaces/events_namespace.py b/python/tk_framework_alias/server/socketio/namespaces/events_namespace.py index 9850f646..d6dc5d2f 100644 --- a/python/tk_framework_alias/server/socketio/namespaces/events_namespace.py +++ b/python/tk_framework_alias/server/socketio/namespaces/events_namespace.py @@ -109,12 +109,14 @@ def on_connect_error(self, data): self._log_message(None, f"Client connection failed\n{data}") - def on_disconnect(self, sid): + def on_disconnect(self, sid, reason=None): """ A disconnect error event triggered. :param sid: The session id of the client that triggered the event. :type sid: str + :param reason: The reason for the disconnect (provided by newer socketio versions). + :type reason: str | None """ if self.client_sid is None or sid != self.client_sid: diff --git a/python/tk_framework_alias/server/socketio/namespaces/server_namespace.py b/python/tk_framework_alias/server/socketio/namespaces/server_namespace.py index d97b2ae1..0bebe6d1 100644 --- a/python/tk_framework_alias/server/socketio/namespaces/server_namespace.py +++ b/python/tk_framework_alias/server/socketio/namespaces/server_namespace.py @@ -140,12 +140,14 @@ def on_connect_error(self, data): self._log_message(None, f"Client connection failed\n{data}") - def on_disconnect(self, sid): + def on_disconnect(self, sid, reason=None): """ A disconnect error event triggered. :param sid: The session id of the client that triggered the event. :type sid: str + :param reason: The reason for the disconnect (provided by newer socketio versions). + :type reason: str | None """ if self.client_sid is None or sid != self.client_sid: @@ -308,8 +310,13 @@ def on_load_alias_api( # Check if the cache is up-to-date. If not, create a new cache. Creating # a new cache is expensive and should only be done if the api or # extensions have changed + force_rebuild = os.environ.get("TK_ALIAS_REBUILD_API_CACHE", "0") in ( + "1", + "true", + ) if ( - not os.path.exists(cache_filepath) + force_rebuild + or not os.path.exists(cache_filepath) or not os.path.exists(cache_module_filepath) or not filecmp.cmp(api_info["file_path"], cache_module_filepath) or extensions_updated diff --git a/python/tk_framework_alias/server/socketio/server_json.py b/python/tk_framework_alias/server/socketio/server_json.py index 905a2ae4..eeb10916 100644 --- a/python/tk_framework_alias/server/socketio/server_json.py +++ b/python/tk_framework_alias/server/socketio/server_json.py @@ -50,7 +50,12 @@ class AliasServerJSONEncoder(json.JSONEncoder): def __init__(self, *args, **kwargs): """Initialize the encoder.""" + # Disable the built-in circular reference check; we handle cycle prevention + # ourselves via _seen_ids in encode_class_type/encode_module. + kwargs["check_circular"] = False super().__init__(*args, **kwargs) + self._seen_ids = set() + self._module_cache_mode = False @staticmethod def is_al_object(obj): @@ -63,12 +68,34 @@ def is_al_object(obj): def is_al_enum(obj): """Return True if the object is an Alias Python API enum.""" - return ( - AliasServerJSONEncoder.is_al_object(obj) - and hasattr(obj, "name") - and hasattr(obj, "value") - and hasattr(obj, "__entries") - ) + if not AliasServerJSONEncoder.is_al_object(obj): + return False + if inspect.isclass(obj): + return False + # pybind11 enum classes expose a __members__ mapping (name -> value) + # or __entries dict depending on version. Check the class for either. + obj_type = type(obj) + if hasattr(obj_type, "__entries"): + return True + # Check multiple ways — pybind11 types may use custom descriptors + if "__members__" in dir(obj_type): + try: + pb11_members = getattr(obj_type, "__members__", None) + if pb11_members is not None and hasattr(pb11_members, "items"): + return True + except Exception: + pass + # Last resort: pybind11 arithmetic enums support int conversion + try: + int(obj) + # Verify it's not just a regular numeric Alias object — check if + # repr looks like an enum: + r = repr(obj) + if r.startswith("<") and "." in r and ":" in r: + return True + except (TypeError, ValueError): + pass + return False @staticmethod def encode_exception(obj): @@ -141,29 +168,116 @@ def encode_function(obj, is_method=False): "__is_method__": is_method, } + @staticmethod + def _sanitize_dict_keys(obj, _seen=None): + """Convert dict keys that are not JSON-serializable to their string representation. + + The json encoder's ``default`` method only handles values; dict keys that are not + str/int/float/bool/None cause a TypeError before ``default`` is ever called. This + handles dicts found in pybind11 modules (e.g. ``__entries__``) that use type objects + as keys. + """ + + if not isinstance(obj, (dict, list, tuple)): + return obj + + if _seen is None: + _seen = set() + + obj_id = id(obj) + if obj_id in _seen: + return None + _seen.add(obj_id) + + if isinstance(obj, dict): + sanitized = {} + for k, v in obj.items(): + if not isinstance(k, (str, int, float, bool, type(None))): + k = str(k) + sanitized[k] = AliasServerJSONEncoder._sanitize_dict_keys(v, _seen) + _seen.discard(obj_id) + return sanitized + + result = type(obj)( + AliasServerJSONEncoder._sanitize_dict_keys(item, _seen) for item in obj + ) + _seen.discard(obj_id) + return result + + def _encode_member_value(self, member_value): + """Encode a member value for use in module/class member lists. + + Handles Alias API instances and enums as lightweight references so they + don't trigger client-side proxy creation during cache deserialization + (which fails because the module hasn't been registered yet at that point). + + Order matters: callables must be checked before is_al_object because + pybind11 methods have __module__ set to the API module name. + """ + + if inspect.isclass(member_value): + return { + "__module_name__": member_value.__module__, + "__class_name__": member_value.__name__, + "__members__": None, + } + if inspect.ismodule(member_value): + return {"__module_name__": member_value.__name__} + if self.is_al_enum(member_value): + return self.encode_al_enum(member_value) + if callable(member_value): + return self.encode_callable(member_value) + if self.is_al_object(member_value): + return { + "__module_name__": member_value.__module__, + "__class_name__": member_value.__class__.__name__, + "__al_instance_repr__": repr(member_value), + } + return self._sanitize_dict_keys(member_value) + def encode_class_type(self, obj): """Encode a class type object such that is JSON serializable.""" + obj_id = id(obj) + if obj_id in self._seen_ids: + return { + "__module_name__": obj.__module__, + "__class_name__": obj.__name__, + "__members__": None, + } + self._seen_ids.add(obj_id) + class_type_name = obj.__name__ members = inspect.getmembers(obj) + # pybind11 enum classes may not expose all enum values in dir(), so + # inspect.getmembers misses them. Look for a __members__ mapping in the + # already-retrieved members (maps name -> enum value/int). + existing_names = {m[0] for m in members} + pb11_dict = None + for member_name, member_value in list(members): + if member_name == "__members__": + pb11_dict = member_value + break + if pb11_dict is None: + # Try direct attribute access as fallback + try: + pb11_dict = getattr(obj, "__members__", None) + except Exception: + pass + if pb11_dict is not None: + try: + items = pb11_dict.items() if hasattr(pb11_dict, "items") else [] + for name, value in items: + if name not in existing_names: + members.append((name, value)) + existing_names.add(name) + except Exception: + pass + class_members = [] for member_name, member_value in members: - if inspect.isclass(member_value): - # Avoid circular references by not nesting class type objects. - # Specify that this value is a class type but do not include its members, the - # receiving end will need to look up the class type members from the root - # module - class_name = member_value.__name__ - value = { - "__module_name__": member_value.__module__, - "__class_name__": class_name, - "__members__": None, - } - else: - value = member_value - - class_members.append((member_name, value)) + class_members.append((member_name, self._encode_member_value(member_value))) return { "__module_name__": obj.__module__, @@ -174,20 +288,93 @@ def encode_class_type(self, obj): def encode_module(self, obj): """Encode a module object such that is JSON serializable.""" + obj_id = id(obj) + if obj_id in self._seen_ids: + return {"__module_name__": obj.__name__} + self._seen_ids.add(obj_id) + self._module_cache_mode = True + + members = [] + for name, value in inspect.getmembers(obj): + if inspect.isclass(value): + # Let classes pass through so the encoder calls encode_class_type + # with full member data (unlike _encode_member_value which stubs them) + members.append((name, value)) + elif inspect.ismodule(value): + members.append((name, {"__module_name__": value.__name__})) + elif self.is_al_enum(value): + members.append((name, self.encode_al_enum(value))) + elif callable(value): + members.append((name, self.encode_callable(value))) + elif self.is_al_object(value): + members.append( + ( + name, + { + "__module_name__": value.__module__, + "__class_name__": value.__class__.__name__, + "__al_instance_repr__": repr(value), + }, + ) + ) + else: + members.append((name, self._sanitize_dict_keys(value))) + return { "__module_name__": obj.__name__, - "__members__": inspect.getmembers(obj), + "__members__": members, } @staticmethod def encode_al_enum(obj): """Encode an Alias Python API enum such that is JSON serializable.""" + obj_type = type(obj) + # Try direct .name/.value first; fall back to __members__ reverse + # lookup and int() for pybind11 arithmetic enums where the properties + # may not be accessible on instances. + enum_name = None + enum_value = None + try: + enum_name = obj.name + except (AttributeError, TypeError): + pass + try: + enum_value = obj.value + except (AttributeError, TypeError): + pass + + if enum_name is None: + try: + pb11_members = getattr(obj_type, "__members__", None) + if pb11_members and hasattr(pb11_members, "items"): + for member_name, member_value in pb11_members.items(): + if member_value == obj: + enum_name = member_name + break + except Exception: + pass + + if enum_name is None: + # Parse from repr: "" + try: + r = repr(obj) + if "." in r and ":" in r: + enum_name = r.split(".")[1].split(":")[0].strip() + except Exception: + pass + + if enum_value is None: + try: + enum_value = int(obj) + except (TypeError, ValueError): + enum_value = 0 + return { - "__module_name__": obj.__module__, - "__class_name__": obj.__class__.__name__, - "__enum_name__": obj.name, - "__enum_value__": obj.value, + "__module_name__": getattr(obj, "__module__", obj_type.__module__), + "__class_name__": obj_type.__name__, + "__enum_name__": enum_name, + "__enum_value__": enum_value, } @staticmethod @@ -227,7 +414,7 @@ def default(self, obj): return self.encode_set(obj) if isinstance(obj, types.MappingProxyType): - return dict(obj) + return self._sanitize_dict_keys(dict(obj)) if isinstance(obj, importlib.machinery.ModuleSpec): return None @@ -261,15 +448,33 @@ def default(self, obj): if inspect.ismodule(obj): return self.encode_module(obj) - if callable(obj): - return self.encode_callable(obj) - if self.is_al_enum(obj): return self.encode_al_enum(obj) + if callable(obj): + return self.encode_callable(obj) + if self.is_al_object(obj): + if self._module_cache_mode: + return { + "__module_name__": obj.__module__, + "__class_name__": obj.__class__.__name__, + "__al_instance_repr__": repr(obj), + } return self.encode_al_object(obj) + # Handle opaque C objects (PyCapsule, etc.) returned by Alias API + # by registering them in the data model so the client gets a ref ID. + if type(obj).__name__ == "PyCapsule": + data_model = alias_bridge.AliasBridge().alias_data_model + instance_id = data_model.register_instance(obj) + return { + "__module_name__": alias_api.__name__, + "__class_name__": "PyCapsule", + "__instance_id__": instance_id, + "__dict__": {"name": None, "type": None}, + } + # Fall back to the default encode method. return super().default(obj) diff --git a/python/tk_framework_alias_utils/environment_utils.py b/python/tk_framework_alias_utils/environment_utils.py index 79015f7b..14f17eee 100644 --- a/python/tk_framework_alias_utils/environment_utils.py +++ b/python/tk_framework_alias_utils/environment_utils.py @@ -524,6 +524,7 @@ def get_framework_supported_python_versions(): (3, 9), (3, 10), (3, 11), + (3, 12), (3, 13), ] diff --git a/python/tk_framework_alias_utils/startup.py b/python/tk_framework_alias_utils/startup.py index 384ed61f..c144e0ea 100644 --- a/python/tk_framework_alias_utils/startup.py +++ b/python/tk_framework_alias_utils/startup.py @@ -16,6 +16,7 @@ import shutil import zipfile import pprint +import re import subprocess import zipfile @@ -830,6 +831,9 @@ def ensure_plugin_ready( logger = logging.getLogger(__file__) logger.setLevel(logging.DEBUG) + alias_python_supported = False + server_python_exe = None + if version_cmp(alias_version, "2024.0") >= 0: # Alias >= 2024.0 # Client will run in a new process, separate from Alias. @@ -839,7 +843,26 @@ def ensure_plugin_ready( # the Alias Plugin to bootstrap the Flow Production Tracking Alias Engine. ensure_toolkit_plugin_up_to_date(logger) - if version_cmp(alias_version, "2026.0") >= 0: + if version_cmp(alias_version, "2027.1") >= 0: + # Alias >= 2027.1 now supports Python and ships python interpreter. + alias_python_supported = True + alias_bin_path = os.path.dirname(alias_exec_path) + alias_python_dir = os.path.join(alias_bin_path, "Python") + if not os.path.exists(alias_python_dir): + raise Exception( + f"Could not find Alias Python directory at {alias_python_dir}" + ) + for filename in os.listdir(alias_python_dir): + match = re.match(r"python(\d)(\d+)\.dll", filename) + if match: + py_major_version = int(match.group(1)) + py_minor_version = int(match.group(2)) + break + else: + raise Exception( + f"Could not determine Alias Python version from {alias_python_dir}" + ) + elif version_cmp(alias_version, "2026.0") >= 0: # Alias >= 2026.0 has removed dependency on Qt/PySide for the FPT plugin py_major_version = 3 py_minor_version = 11 @@ -851,15 +874,18 @@ def ensure_plugin_ready( py_major_version = 3 py_minor_version = 7 - install_python_packages = os.environ.get( - "SHOTGRID_ALIAS_INSTALL_PYTHON_PACKAGES" - ) in ("1", "true", "True") - server_python_exe = ensure_python_installed( - py_major_version, - py_minor_version, - logger, - install_python_packages=install_python_packages, - ) + if not alias_python_supported: + install_python_packages = os.environ.get( + "SHOTGRID_ALIAS_INSTALL_PYTHON_PACKAGES" + ) in ("1", "true", "True") + + # Use the framework's Python interpreter (installed for user) + server_python_exe = ensure_python_installed( + py_major_version, + py_minor_version, + logger, + install_python_packages=install_python_packages, + ) else: raise Exception( f"Alias {alias_version} is not supported in this tk-framework-alias version. Update to Alias 2024.0 or later or downgrade to tk-framework-alias v2.5.0 to use Alias < 2024.0." @@ -873,17 +899,21 @@ def ensure_plugin_ready( # version. ensure_python_packages_installed(logger=logger) - # Get the file path to the .lst file that contains the file path to the Alias Plugin to - # load at startup with Alias. - plugin_lst_path = get_plugin_lst( - alias_version, - py_major_version, - py_minor_version, - logger, - ) - if not plugin_lst_path: - raise Exception("The plugin .lst file not found for Alias {alias_version}.") - logger.debug(f"Alias Plugin List file path {plugin_lst_path}") + if alias_python_supported: + plugin_lst_path = None + else: + # For Alias < 2027.1, pre-python support we use C++ compiled plugin + # Get the file path to the .lst file that contains the file path to the Alias Plugin to + # load at startup with Alias. + plugin_lst_path = get_plugin_lst( + alias_version, + py_major_version, + py_minor_version, + logger, + ) + if not plugin_lst_path: + raise Exception("The plugin .lst file not found for Alias {alias_version}.") + logger.debug(f"Alias Plugin List file path {plugin_lst_path}") # Get the dictionary of environment variables that are needed by the Alias Plugin plugin_env = get_plugin_environment(