From 07590abc3c58c5b2d7953a662db127dbb001d7b1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 13 Nov 2017 21:57:51 +0100 Subject: [PATCH 1/2] Implement automatic context switch on Maya scene open and scene save This requires: https://github.com/getavalon/core/pull/300 --- colorbleed/lib.py | 40 ++++++- colorbleed/maya/__init__.py | 13 ++- colorbleed/vendor/__init__.py | 0 colorbleed/vendor/pather/__init__.py | 5 + colorbleed/vendor/pather/core.py | 168 +++++++++++++++++++++++++++ colorbleed/vendor/pather/error.py | 5 + colorbleed/vendor/pather/version.py | 10 ++ 7 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 colorbleed/vendor/__init__.py create mode 100644 colorbleed/vendor/pather/__init__.py create mode 100644 colorbleed/vendor/pather/core.py create mode 100644 colorbleed/vendor/pather/error.py create mode 100644 colorbleed/vendor/pather/version.py diff --git a/colorbleed/lib.py b/colorbleed/lib.py index ea49fa54a4..04ea0cb092 100644 --- a/colorbleed/lib.py +++ b/colorbleed/lib.py @@ -1,6 +1,13 @@ +import logging + +from .vendor import pather +from .vendor.pather.error import ParseError + import avalon.io as io import avalon.api +log = logging.getLogger(__name__) + def is_latest(representation): """Return whether the representation is from latest version @@ -43,4 +50,35 @@ def any_outdated(): return True checked.add(representation) - return False \ No newline at end of file + return False + + +def update_context_from_path(path): + """Update the context using the current scene state. + + When no changes to the context it will not trigger an update. + When the context for a file could not be parsed an error is logged but not + raised. + + """ + if not path: + log.warning("Can't update the current context. Scene is not saved.") + return + + # Find the current context from the filename + project = io.find_one({"type": "project"}, + projection={"config.template.work": True}) + template = project['config']['template']['work'] + # Force to use the registered to root to avoid using wrong paths + template = pather.format(template, {"root": avalon.api.registered_root()}) + try: + context = pather.parse(template, path) + except ParseError: + log.error("Can't update the current context. Unable to parse the " + "context for: %s", path) + return + + if any([avalon.api.Session['AVALON_ASSET'] != context['asset'], + avalon.api.Session["AVALON_TASK"] != context['task']]): + log.info("Updating context to: %s", context) + avalon.api.update_current_context(context) diff --git a/colorbleed/maya/__init__.py b/colorbleed/maya/__init__.py index d482f6751a..f3f1f66432 100644 --- a/colorbleed/maya/__init__.py +++ b/colorbleed/maya/__init__.py @@ -8,6 +8,10 @@ from maya import cmds from avalon import api as avalon from pyblish import api as pyblish +from ..lib import ( + update_context_from_path, + any_outdated +) from . import menu from . import lib @@ -88,6 +92,9 @@ def on_save(_): avalon.logger.info("Running callback on save..") + # Update context for the current scene + update_context_from_path(cmds.file(query=True, sceneName=True)) + # Generate ids of the current context on nodes in the scene nodes = lib.get_id_required_nodes(referenced_nodes=False) for node, new_id in lib.generate_ids(nodes): @@ -97,10 +104,12 @@ def on_save(_): def on_open(_): """On scene open let's assume the containers have changed.""" - from ..lib import any_outdated from avalon.vendor.Qt import QtWidgets from ..widgets import popup + # Update context for the current scene + update_context_from_path(cmds.file(query=True, sceneName=True)) + if any_outdated(): log.warning("Scene has outdated content.") @@ -124,4 +133,4 @@ def on_open(_): dialog.setMessage("There are outdated containers in " "your Maya scene.") dialog.on_show.connect(_on_show_inventory) - dialog.show() + dialog.show() \ No newline at end of file diff --git a/colorbleed/vendor/__init__.py b/colorbleed/vendor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/colorbleed/vendor/pather/__init__.py b/colorbleed/vendor/pather/__init__.py new file mode 100644 index 0000000000..91094e45a8 --- /dev/null +++ b/colorbleed/vendor/pather/__init__.py @@ -0,0 +1,5 @@ +__author__ = 'Roy Nieterau' + + +from .core import * +from .version import * diff --git a/colorbleed/vendor/pather/core.py b/colorbleed/vendor/pather/core.py new file mode 100644 index 0000000000..f2a469dc8b --- /dev/null +++ b/colorbleed/vendor/pather/core.py @@ -0,0 +1,168 @@ + +__all__ = ['parse', 'ls', 'ls_iter', 'format'] + +import os +import re +import string +import glob + +from .error import ParseError + +# Regex pattern that matches valid file +# TODO: Implement complete pattern if required +RE_FILENAME = '[-\w.,; \[\]]' + + +def format(pattern, data, allow_partial=True): + """Format a pattern with a set of data + + Examples: + + Full formatting + >>> format("{a}/{b}/{c}", {"a": "foo", "b": "bar", "c": "nugget"}) + 'foo/bar/nugget' + + Partial formatting + >>> format("{asset}/{character}", {"asset": "hero"}) + 'hero/{character}' + + Disallow partial formatting + >>> format("{asset}/{character}", {"asset": "hero"}, + ... allow_partial=False) + Traceback (most recent call last): + ... + KeyError: 'character' + + Args: + pattern (str): The pattern to format. + data (dict): The key, value pairs used for formatting. + allow_partial (bool): Whether to raise error on partial format. + + Returns: + str: The formatted result + """ + + assert isinstance(data, dict) + + if not all(isinstance(value, basestring) for value in data.values()): + raise TypeError("The values in the data " + "dictionary must be strings") + + if allow_partial: + return _partial_format(pattern, data) + else: + return pattern.format(**data) + + +def parse(pattern, path): + """Parse data from a path based on a pattern + + Example: + >>> pattern = "root/{task}/{version}/data/" + >>> path = "root/modeling/v001/data/" + >>> parse(pattern, path) + {'task': 'modeling', 'version': 'v001'} + + Returns: + dict: The data retrieved from path using pattern. + """ + + pattern = os.path.normpath(pattern) + path = os.path.normpath(path) + + # Force forward slashes + path = path.replace('\\', '/') + pattern = pattern.replace('\\', '/') + + # Escape characters in path that are regex patterns so they are + # excluded by the regex searches. Exclude '{' and '}' in escaping. + pattern = re.escape(pattern) + pattern = pattern.replace('\{', '{').replace('\}', '}') + + keys = re.findall(r'{(%s+)}' % RE_FILENAME, + pattern) + if not keys: + return [] + + # Find the corresponding values + value_pattern = re.sub(r'{(%s+)}' % RE_FILENAME, + r'(%s+)' % RE_FILENAME, + pattern) + match_values = re.match(value_pattern, path) + + if not match_values: + raise ParseError("Path doesn't match with pattern. No values parsed") + + values = match_values.groups() + + return dict(zip(keys, values)) + + +def ls_iter(pattern, include=None, with_matches=False): + """Yield all matches for the given pattern. + + If the pattern starts with a relative path (or a dynamic key) the search + will start from the current working directory, defined by os.path.realpath. + + Arguments: + pattern (str): The pattern to match and search against. + include (dict): A dictionary used to target the search with the pattern + to include only those key-value pairs within the pattern. With this + you can reduce the filesystem query to a specified subset. + + Example: + >>> data = {"root": "foobar", "content": "nugget"} + >>> for path in ls_iter("{root}/{project}/data/{content}/", + ... include=data): + ... print path + + Returns: + (str, tuple): The matched paths (and data if `with_matches` is True) + + The returned value changes whether `with_matches` parameter is True or + False. If True a 2-tuple is yielded for each match as (path, data) else + only the path is returned + """ + + # format rule by data already provided to reduce query + if include is not None: + pattern = format(pattern, include, allow_partial=True) + + pattern = os.path.expandvars(pattern) + pattern = os.path.realpath(pattern) + + glob_pattern = re.sub(r'([/\\]{\w+}[/\\])', '/*/', pattern) # folder + glob_pattern = re.sub(r'({\w+})', '*', glob_pattern) # filename + + for path in glob.iglob(glob_pattern): + path = os.path.realpath(path) + if with_matches: + data = parse(pattern, path) + yield path, data + else: + yield path + + +def ls(pattern, include=None, with_matches=False): + return list(ls_iter(pattern, include, with_matches=with_matches)) + + +def _partial_format(s, data): + """Return string `s` formatted by `data` allowing a partial format + + Arguments: + s (str): The string that will be formatted + data (dict): The dictionary used to format with. + + Example: + >>> _partial_format("{d} {a} {b} {c} {d}", {'b': "and", 'd': "left"}) + 'left {a} and {c} left' + """ + + class FormatDict(dict): + def __missing__(self, key): + return "{" + key + "}" + + formatter = string.Formatter() + mapping = FormatDict(**data) + return formatter.vformat(s, (), mapping) diff --git a/colorbleed/vendor/pather/error.py b/colorbleed/vendor/pather/error.py new file mode 100644 index 0000000000..92006534d4 --- /dev/null +++ b/colorbleed/vendor/pather/error.py @@ -0,0 +1,5 @@ + + +class ParseError(ValueError): + """Error raised when parsing a path with a pattern fails""" + pass diff --git a/colorbleed/vendor/pather/version.py b/colorbleed/vendor/pather/version.py new file mode 100644 index 0000000000..85f96b1e3f --- /dev/null +++ b/colorbleed/vendor/pather/version.py @@ -0,0 +1,10 @@ + +VERSION_MAJOR = 0 +VERSION_MINOR = 1 +VERSION_PATCH = 0 + +version_info = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) +version = '%i.%i.%i' % version_info +__version__ = version + +__all__ = ['version', 'version_info', '__version__'] From b541694bf656fc809e63258f5e67ce68d0a54d9c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 14 Nov 2017 15:50:44 +0100 Subject: [PATCH 2/2] Refactor update context to update task --- colorbleed/lib.py | 24 ++++++++++++++++-------- colorbleed/maya/__init__.py | 10 +++++----- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/colorbleed/lib.py b/colorbleed/lib.py index 04ea0cb092..0586098489 100644 --- a/colorbleed/lib.py +++ b/colorbleed/lib.py @@ -53,7 +53,7 @@ def any_outdated(): return False -def update_context_from_path(path): +def update_task_from_path(path): """Update the context using the current scene state. When no changes to the context it will not trigger an update. @@ -62,7 +62,7 @@ def update_context_from_path(path): """ if not path: - log.warning("Can't update the current context. Scene is not saved.") + log.warning("Can't update the current task. Scene is not saved.") return # Find the current context from the filename @@ -74,11 +74,19 @@ def update_context_from_path(path): try: context = pather.parse(template, path) except ParseError: - log.error("Can't update the current context. Unable to parse the " - "context for: %s", path) + log.error("Can't update the current task. Unable to parse the " + "task for: %s", path) return - if any([avalon.api.Session['AVALON_ASSET'] != context['asset'], - avalon.api.Session["AVALON_TASK"] != context['task']]): - log.info("Updating context to: %s", context) - avalon.api.update_current_context(context) + # Find the changes between current Session and the path's context. + current = { + "asset": avalon.api.Session["AVALON_ASSET"], + "task": avalon.api.Session["AVALON_TASK"], + "app": avalon.api.Session["AVALON_APP"] + } + changes = {key: context[key] for key, current_value in current.items() + if context[key] != current_value} + + if changes: + log.info("Updating work task to: %s", context) + avalon.api.update_current_task(**changes) diff --git a/colorbleed/maya/__init__.py b/colorbleed/maya/__init__.py index f3f1f66432..7426aac905 100644 --- a/colorbleed/maya/__init__.py +++ b/colorbleed/maya/__init__.py @@ -9,7 +9,7 @@ from avalon import api as avalon from pyblish import api as pyblish from ..lib import ( - update_context_from_path, + update_task_from_path, any_outdated ) from . import menu @@ -92,8 +92,8 @@ def on_save(_): avalon.logger.info("Running callback on save..") - # Update context for the current scene - update_context_from_path(cmds.file(query=True, sceneName=True)) + # Update current task for the current scene + update_task_from_path(cmds.file(query=True, sceneName=True)) # Generate ids of the current context on nodes in the scene nodes = lib.get_id_required_nodes(referenced_nodes=False) @@ -107,8 +107,8 @@ def on_open(_): from avalon.vendor.Qt import QtWidgets from ..widgets import popup - # Update context for the current scene - update_context_from_path(cmds.file(query=True, sceneName=True)) + # Update current task for the current scene + update_task_from_path(cmds.file(query=True, sceneName=True)) if any_outdated(): log.warning("Scene has outdated content.")