diff --git a/ayon_api/__init__.py b/ayon_api/__init__.py index fc89a1dfb..c964c24ef 100644 --- a/ayon_api/__init__.py +++ b/ayon_api/__init__.py @@ -148,6 +148,8 @@ enroll_event_job, get_attributes_schema, reset_attributes_schema, + reset_attributes_cache, + set_attributes_cache_timeout, set_attribute_config, remove_attribute_config, get_attributes_for_type, @@ -433,6 +435,8 @@ "enroll_event_job", "get_attributes_schema", "reset_attributes_schema", + "reset_attributes_cache", + "set_attributes_cache_timeout", "set_attribute_config", "remove_attribute_config", "get_attributes_for_type", diff --git a/ayon_api/_api.py b/ayon_api/_api.py index d18d00e74..87437368f 100644 --- a/ayon_api/_api.py +++ b/ayon_api/_api.py @@ -55,7 +55,6 @@ EnrollEventData, AttributeScope, AttributeSchemaDataDict, - AttributeSchemaDict, AttributesSchemaDict, AddonsInfoDict, InstallersInfoDict, @@ -3582,10 +3581,30 @@ def get_attributes_schema( def reset_attributes_schema() -> None: + """Reset attributes schema cache. + + DEPRECATED: + Use 'reset_attributes_cache' instead. + + """ con = get_server_api_connection() return con.reset_attributes_schema() +def reset_attributes_cache() -> None: + con = get_server_api_connection() + return con.reset_attributes_cache() + + +def set_attributes_cache_timeout( + timeout: int, +) -> None: + con = get_server_api_connection() + return con.set_attributes_cache_timeout( + timeout=timeout, + ) + + def set_attribute_config( attribute_name: str, data: AttributeSchemaDataDict, @@ -3622,7 +3641,7 @@ def remove_attribute_config( def get_attributes_for_type( entity_type: AttributeScope, -) -> dict[str, AttributeSchemaDict]: +) -> dict[str, AttributeSchemaDataDict]: """Get attribute schemas available for an entity type. Example:: @@ -3670,6 +3689,9 @@ def get_attributes_fields_for_type( ) -> set[str]: """Prepare attribute fields for entity type. + DEPRECATED: Field 'attrib' is marked as deprecated and should not be + used for GraphQL queries. + Returns: set[str]: Attributes fields for entity type. diff --git a/ayon_api/_api_helpers/attributes.py b/ayon_api/_api_helpers/attributes.py index 26db47484..c9deb6356 100644 --- a/ayon_api/_api_helpers/attributes.py +++ b/ayon_api/_api_helpers/attributes.py @@ -1,39 +1,107 @@ from __future__ import annotations +import copy +import time import typing from typing import Optional -import copy from .base import BaseServerAPI if typing.TYPE_CHECKING: from ayon_api.typing import ( - AttributeSchemaDataDict, AttributeSchemaDict, + AttributeSchemaDataDict, AttributesSchemaDict, AttributeScope, ) +class _AttributesCache: + _schema = None + _last_fetch = 0 + _timeout = 60 + _attributes_by_type = {} + + def reset_schema(self) -> None: + self._schema = None + self._last_fetch = 0 + self._attributes_by_type = {} + + def set_timeout(self, timeout: int) -> None: + self._timeout = timeout + + def get_schema(self) -> AttributesSchemaDict: + return copy.deepcopy(self._schema) + + def set_schema(self, schema: AttributesSchemaDict) -> None: + self._schema = schema + self._last_fetch = time.time() + + def is_valid(self) -> bool: + if self._schema is None: + return False + return time.time() - self._last_fetch < self._timeout + + def invalidate(self) -> None: + if not self.is_valid(): + self.reset_schema() + + def get_attributes_for_type( + self, entity_type: AttributeScope + ) -> list[AttributeSchemaDict]: + attributes = self._attributes_by_type.get(entity_type) + if attributes is not None: + return attributes + + attributes_schema = self.get_schema() + if attributes_schema is None: + raise ValueError("Attributes schema is not cached.") + + attributes = [] + for attr in attributes_schema["attributes"]: + if entity_type not in attr["scope"]: + continue + attributes.append(attr) + + self._attributes_by_type[entity_type] = attributes + return attributes + + class AttributesAPI(BaseServerAPI): - _attributes_schema = None - _entity_type_attributes_cache = {} + _attributes_cache = _AttributesCache() def get_attributes_schema( self, use_cache: bool = True ) -> AttributesSchemaDict: if not use_cache: - self.reset_attributes_schema() + self._attributes_cache.reset_schema() + else: + self._attributes_cache.invalidate() - if self._attributes_schema is None: + if not self._attributes_cache.is_valid(): result = self.get("attributes") result.raise_for_status() - self._attributes_schema = result.data - return copy.deepcopy(self._attributes_schema) + self._attributes_cache.set_schema(result.data) + return self._attributes_cache.get_schema() def reset_attributes_schema(self) -> None: - self._attributes_schema = None - self._entity_type_attributes_cache = {} + """Reset attributes schema cache. + + DEPRECATED: + Use 'reset_attributes_cache' instead. + + """ + self.log.warning( + "Used deprecated function 'reset_attributes_schema'." + " Please use 'reset_attributes_cache' instead." + ) + self.reset_attributes_cache() + + def reset_attributes_cache(self) -> None: + self._attributes_cache.reset_schema() + + def set_attributes_cache_timeout(self, timeout: int) -> None: + self._attributes_cache.set_timeout(timeout) def set_attribute_config( self, @@ -64,12 +132,10 @@ def set_attribute_config( position=position, builtin=builtin ) - if response.status_code != 204: - # TODO raise different exception - raise ValueError( - f"Attribute \"{attribute_name}\" was not created/updated." - f" {response.detail}" - ) + response.raise_for_status( + f"Attribute \"{attribute_name}\" was not created/updated." + f" {response.detail}" + ) self.reset_attributes_schema() @@ -92,7 +158,7 @@ def remove_attribute_config(self, attribute_name: str) -> None: def get_attributes_for_type( self, entity_type: AttributeScope - ) -> dict[str, AttributeSchemaDict]: + ) -> dict[str, AttributeSchemaDataDict]: """Get attribute schemas available for an entity type. Example:: @@ -129,29 +195,32 @@ def get_attributes_for_type( for entered entity type. """ - attributes = self._entity_type_attributes_cache.get(entity_type) - if attributes is None: - attributes_schema = self.get_attributes_schema() - attributes = {} - for attr in attributes_schema["attributes"]: - if entity_type not in attr["scope"]: - continue - attr_name = attr["name"] - attributes[attr_name] = attr["data"] - - self._entity_type_attributes_cache[entity_type] = attributes - - return copy.deepcopy(attributes) + # Make sure attributes are cached + self.get_attributes_schema() + return { + attr["name"]: attr["data"] + for attr in self._attributes_cache.get_attributes_for_type( + entity_type + ) + } def get_attributes_fields_for_type( self, entity_type: AttributeScope ) -> set[str]: """Prepare attribute fields for entity type. + DEPRECATED: Field 'attrib' is marked as deprecated and should not be + used for GraphQL queries. + Returns: set[str]: Attributes fields for entity type. """ + self.log.warning( + "Method 'get_attributes_fields_for_type' is deprecated and should" + " not be used for GraphQL queries. Use 'allAttrib' field instead" + " of 'attrib'." + ) attributes = self.get_attributes_for_type(entity_type) return { f"attrib.{attr}" diff --git a/ayon_api/_api_helpers/base.py b/ayon_api/_api_helpers/base.py index cca8aa8e3..d9284207b 100644 --- a/ayon_api/_api_helpers/base.py +++ b/ayon_api/_api_helpers/base.py @@ -15,6 +15,7 @@ ProjectDict, StreamType, AttributeScope, + AttributeSchemaDataDict, ) _PLACEHOLDER = object() @@ -134,7 +135,7 @@ def get_user( def get_attributes_for_type( self, entity_type: AttributeScope - ) -> set[str]: + ) -> dict[str, AttributeSchemaDataDict]: raise NotImplementedError() def get_attributes_fields_for_type( diff --git a/ayon_api/_api_helpers/lists.py b/ayon_api/_api_helpers/lists.py index df22e3844..6bab56f91 100644 --- a/ayon_api/_api_helpers/lists.py +++ b/ayon_api/_api_helpers/lists.py @@ -51,16 +51,17 @@ def get_entity_lists( if fields is None: fields = self.get_default_fields_for_type("entityList") - # List does not have 'attrib' field but has 'allAttrib' field - # which is json string and contains only values that are set o_fields = tuple(fields) fields = set() - requires_attrib = False + add_all_attrib = False for field in o_fields: if field == "attrib" or field.startswith("attrib."): - requires_attrib = True - field = "allAttrib" - fields.add(field) + add_all_attrib = True + else: + fields.add(field) + + if add_all_attrib: + fields.add("allAttrib") if "items" in fields: fields.discard("items") @@ -71,8 +72,8 @@ def get_entity_lists( "items.position", } - available_attribs = [] - if requires_attrib: + available_attribs = {} + if "allAttrib" in fields: available_attribs = self.get_attributes_for_type("list") if active is not None: @@ -97,17 +98,13 @@ def get_entity_lists( if isinstance(attributes, str): entity_list["attributes"] = json.loads(attributes) - if requires_attrib: - all_attrib = json.loads( - entity_list.get("allAttrib") or "{}" - ) - entity_list["attrib"] = { - attrib_name: all_attrib.get(attrib_name) - for attrib_name in available_attribs - } - self._convert_entity_data(entity_list) + attrib = entity_list.get("attrib") + if attrib is not None: + for attrib_name, attrib_data in available_attribs.items(): + attrib.setdefault(attrib_name, attrib_data["default"]) + yield entity_list def get_entity_list_rest( diff --git a/ayon_api/_api_helpers/projects.py b/ayon_api/_api_helpers/projects.py index 70099d29b..58857248e 100644 --- a/ayon_api/_api_helpers/projects.py +++ b/ayon_api/_api_helpers/projects.py @@ -1,6 +1,5 @@ from __future__ import annotations -import copy import json import platform import warnings @@ -164,8 +163,10 @@ def get_rest_project( return None project = response.data attrib = project["attrib"] - for attr_name in self.get_attributes_for_type("project"): - attrib.setdefault(attr_name, None) + for attr_name, attr_data in ( + self.get_attributes_for_type("project").items() + ): + attrib.setdefault(attr_name, attr_data["default"]) self._fill_project_entity_data(project) return project @@ -663,7 +664,7 @@ def _get_project_graphql_fields( if fields is None: return set(), ProjectFetchType.REST - rest_list_fields = { + rest_fields = { "name", "code", "active", @@ -671,10 +672,11 @@ def _get_project_graphql_fields( "updatedAt", } graphql_fields = set() - if len(fields - rest_list_fields) == 0: + if len(fields - rest_fields) == 0: return graphql_fields, ProjectFetchType.RESTList must_use_graphql = False + add_all_attrib = False for field in tuple(fields): # Product types are available only in GraphQl if field == "usedTags": @@ -707,11 +709,12 @@ def _get_project_graphql_fields( elif field.startswith("bundle"): graphql_fields.add(field) - elif field == "attrib": - fields.discard("attrib") - graphql_fields |= self.get_attributes_fields_for_type( - "project" - ) + elif field == "attrib" or field.startswith("attrib."): + fields.discard(field) + add_all_attrib = True + + if add_all_attrib: + graphql_fields.add("allAttrib") # NOTE 'config' in GraphQl is NOT the same as from REST api. # - At the moment of this comment there is missing 'productBaseTypes'. @@ -807,6 +810,10 @@ def _get_graphql_projects( if project_name is not None: query.set_variable_value("projectName", project_name) + attributes = {} + if "allAttrib" in fields: + attributes = self.get_attributes_for_type("project") + for parsed_data in query.continuous_query(self): for project in parsed_data["projects"]: if active is not None and active is not project["active"]: @@ -815,19 +822,22 @@ def _get_graphql_projects( if library is not None and library is not project["library"]: continue + attrib = None all_attrib = project.get("allAttrib") if isinstance(all_attrib, str): - all_attrib = json.loads(all_attrib) - project["allAttrib"] = all_attrib - - if own_attributes and all_attrib: - own_attrib = {} - if all_attrib: - own_attrib = copy.deepcopy(all_attrib) - attrib = project.get("attrib", {}) - for key in attrib.keys(): - own_attrib.setdefault(key, None) - project["ownAttrib"] = own_attrib + attrib = json.loads(all_attrib) + + if attrib is not None: + # NOTE 'ownAttrib' logic might change in the future if + # allAttrib would return all attribute values. + project["ownAttrib"] = list(attrib) + project["attrib"] = attrib + for name, attr_data in attributes.items(): + # NOTE 'default' can be 'None' + attrib.setdefault(name, attr_data["default"]) + + if own_attributes: + fill_own_attribs(project) self._fill_project_entity_data(project) yield project diff --git a/ayon_api/constants.py b/ayon_api/constants.py index e39fc175f..c122e4eef 100644 --- a/ayon_api/constants.py +++ b/ayon_api/constants.py @@ -33,9 +33,6 @@ "hasPassword", "updatedAt", "apiKeyPreview", - "attrib.avatarUrl", - "attrib.email", - "attrib.fullName", } # --- Project folder types --- @@ -104,7 +101,6 @@ "linkTypes", "statuses", "tags", - "attrib", } # --- Folders --- diff --git a/ayon_api/server_api.py b/ayon_api/server_api.py index 1b4668218..7ad2f5ba0 100644 --- a/ayon_api/server_api.py +++ b/ayon_api/server_api.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import copy import os import re import io @@ -1007,29 +1008,37 @@ def get_users( if not fields: fields = self.get_default_fields_for_type("user") + else: + fields = set(fields) + self._prepare_fields("user", fields) - query = users_graphql_query(set(fields)) + query = users_graphql_query(fields) for attr, filter_value in filters.items(): query.set_variable_value(attr, filter_value) - attributes = self.get_attributes_for_type("user") + attributes = {} + if "allAttrib" in fields: + attributes = self.get_attributes_for_type("user") + for parsed_data in query.continuous_query(self): for user in parsed_data["users"]: access_groups = user.get("accessGroups") if isinstance(access_groups, str): user["accessGroups"] = json.loads(access_groups) - all_attrib = user.get("allAttrib") - if isinstance(all_attrib, str): - user["allAttrib"] = json.loads(all_attrib) - if "attrib" in user: - user["ownAttrib"] = user["attrib"].copy() - attrib = user["attrib"] - for key, value in tuple(attrib.items()): - if value is not None: - continue - attr_def = attributes.get(key) - if attr_def is not None: - attrib[key] = attr_def["default"] + + attrib = user.get("allAttrib") + if isinstance(attrib, str): + attrib = json.loads(attrib) + + if attrib is not None: + own_attrib = copy.deepcopy(attrib) + user["ownAttrib"] = own_attrib + for name, attr_data in attributes.items(): + attrib.setdefault(name, attr_data["default"]) + own_attrib.setdefault(name, None) + + user["attrib"] = attrib + yield user def get_user_by_name( @@ -1089,10 +1098,9 @@ def get_user( response.raise_for_status() user = response.data - # NOTE Server does return only filled attributes right now. - # This would fill all missing attributes with 'None'. - # for attr_name in self.get_attributes_for_type("user"): - # user["attrib"].setdefault(attr_name, None) + attributes = self.get_attributes_for_type("user") + for attr_name, attr_data in attributes.items(): + user["attrib"].setdefault(attr_name, attr_data["default"]) fill_own_attribs(user) return user @@ -2124,6 +2132,9 @@ def get_default_fields_for_type(self, entity_type: str) -> set[str]: if entity_type == "activity": return set(DEFAULT_ACTIVITY_FIELDS) + if entity_type == "productType": + return set(DEFAULT_PRODUCT_TYPE_FIELDS) + if entity_type == "project": entity_type_defaults = set(DEFAULT_PROJECT_FIELDS) maj_v, min_v, patch_v, _, _ = self.server_version_tuple @@ -2154,9 +2165,6 @@ def get_default_fields_for_type(self, entity_type: str) -> set[str]: if not self.graphql_allows_traits_in_representations: entity_type_defaults.discard("traits") - elif entity_type == "productType": - entity_type_defaults = set(DEFAULT_PRODUCT_TYPE_FIELDS) - elif entity_type == "workfile": entity_type_defaults = set(DEFAULT_WORKFILE_INFO_FIELDS) @@ -2165,15 +2173,13 @@ def get_default_fields_for_type(self, entity_type: str) -> set[str]: elif entity_type == "entityList": entity_type_defaults = set(DEFAULT_ENTITY_LIST_FIELDS) - # Attributes scope is 'list' - entity_type = "list" else: raise ValueError(f"Unknown entity type \"{entity_type}\"") - return ( - entity_type_defaults - | self.get_attributes_fields_for_type(entity_type) - ) + + entity_type_defaults.add("allAttrib") + + return entity_type_defaults def get_rest_entity_by_id( self, @@ -2424,16 +2430,21 @@ def _prepare_fields( if not fields: return - if "attrib" in fields: - fields.remove("attrib") - fields |= self.get_attributes_fields_for_type(entity_type) + add_all_attrib = False + for field in tuple(fields): + if field == "attrib" or field.startswith("attrib."): + fields.discard(field) + add_all_attrib = True if own_attributes: if entity_type == "project": - fields.add("allAttrib") + add_all_attrib = True elif entity_type in {"folder", "task"}: fields.add("ownAttrib") + if add_all_attrib: + fields.add("allAttrib") + if entity_type != "project": return @@ -2499,11 +2510,18 @@ def _prepare_advanced_filters( return filters def _convert_entity_data(self, entity: AnyEntityDict): - if not entity or "data" not in entity: + if not entity: return - entity_data = entity["data"] or {} - if isinstance(entity_data, str): - entity_data = json.loads(entity_data) + if "data" in entity: + entity_data = entity["data"] or {} + if isinstance(entity_data, str): + entity_data = json.loads(entity_data) + + entity["data"] = entity_data - entity["data"] = entity_data + all_attrib = entity.get("allAttrib") + if isinstance(all_attrib, str): + # NOTE: This expects server returns all attributes available for + # the entity type. + entity["attrib"] = json.loads(all_attrib)