Source code for tank.templatekey

# Copyright (c) 2013 Shotgun Software Inc.
#
# CONFIDENTIAL AND PROPRIETARY
#
# This work is provided "AS IS" and subject to the Shotgun 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 Shotgun Pipeline Toolkit Source Code License. All rights
# not expressly granted therein are reserved by Shotgun Software Inc.

"""
Classes for fields on TemplatePaths and TemplateStrings
"""

import sys
import datetime
from . import constants
from .errors import TankError
from .util import sgre as re
from tank_vendor import six
from tank_vendor.six.moves import zip


[docs]class TemplateKey(object): """ Base class for all template key types. Should not be used directly. TemplateKeys are used by Template object to move between key values and resolved strings. The template keys handle the manner in which this conversion should occur. Template keys come in four flavors: string, integer, sequence and timestamp:: >>> import sgtk >>> tk = sgtk.sgtk_from_path("/studio.08/demo_project") >>> template_path = tk.templates['nuke_asset_render'] # .../{name}/v{version}/{width}x{height}/{Asset}_{name}_{output}_{render_time}.{frame}.exr' >>> str_key = template_path.keys['Asset'] >>> str_key <Sgtk StringKey Asset> >>> int_key = template_path.keys['height'] >>> int_key <Sgtk IntegerKey height> >>> seq_key = template_path.keys['frame'] >>> seq_key <Sgtk SequenceKey frame> >>> timestamp_key = template_path.keys['render_time'] >>> timestamp_key <Sgtk TimestampKey render_time> """ def __init__( self, name, default=None, choices=None, shotgun_entity_type=None, shotgun_field_name=None, exclusions=None, abstract=False, length=None, ): """ :param str name: Name by which the key will be referred. :param default: Default value for this key. If the default is a callable, it will be invoked without any parameters whenever a default value is required. :param choices: List of possible values for this key. Can be either a list or a dictionary of choice:label pairs. :param str shotgun_entity_type: For keys directly linked to a shotgun field, the entity type. :param str shotgun_field_name: For keys directly linked to a shotgun field, the field name. :param list exclusions: List of forbidden values. :param bool abstract: Flagging that this should be treated as an abstract key. :param int length: If non-None, indicating that the value should be of a fixed length. """ self._name = name self._default = default # special handling for choices: if isinstance(choices, dict): # new style choices dictionary containing choice:label pairs: self._choices = choices elif isinstance(choices, list) or isinstance(choices, set): # old style choices - labels and choices are the same: self._choices = dict(list(zip(choices, choices))) else: self._choices = {} self._exclusions = exclusions or [] self._shotgun_entity_type = shotgun_entity_type self._shotgun_field_name = shotgun_field_name self._is_abstract = abstract self._length = length self._last_error = "" # check that the key name doesn't contain invalid characters if not re.match(r"^%s$" % constants.TEMPLATE_KEY_NAME_REGEX, name): raise TankError( "%s: Name contains invalid characters. " "Valid characters are %s." % (self, constants.VALID_TEMPLATE_KEY_NAME_DESC) ) # Validation if self.shotgun_field_name and not self.shotgun_entity_type: raise TankError("%s: SG field requires a SG entity be set." % self) if self.is_abstract and self.default is None: raise TankError( "%s: Fields marked as abstract needs to have a default value!" % self ) if not ((self.default is None) or self.validate(self.default)): raise TankError(self._last_error) if not all(self.validate(choice) for choice in self.choices): raise TankError(self._last_error) def _get_default(self): """ The default value for this key. If the default argument was specified as a callable in the constructor, it is invoked and assumed to take no parameters. :returns: The default value. """ if isinstance(self._default, six.moves.collections_abc.Callable): return self._default() else: return self._default def _set_default(self, value): """ Sets the default value for this key. :param value: New default value for the key. Can be None. """ self._default = value # Python 2.5 doesn't support @default.setter so use old style property. default = property(_get_default, _set_default) @property def name(self): """ The name that the template will use to refer to the key. """ return self._name @property def length(self): """ Fixed length that needs to be used for this item or None if any length is valid. """ return self._length @property def shotgun_entity_type(self): """ Shotgun entity type associated with this item """ return self._shotgun_entity_type @property def shotgun_field_name(self): """ Shotgun field name associated with this item """ return self._shotgun_field_name @property def exclusions(self): """ List of values which are not allowed for this item. """ return self._exclusions @property def is_abstract(self): """ A boolean value indicating if this key is abstract. Abstract keys are typically used in conjunction with path elements which represent clusters of files, for example when you want to represent a sequence of frames using a ``%04d`` syntax or a left and right eye using a ``%v`` syntax. """ return self._is_abstract @property def choices(self): """ List of choices available, e.g. ``['ma', 'mb']`` """ return list(self._choices.keys()) @property def labelled_choices(self): """ Dictionary of labelled choices, e.g. ``{'ma': 'Maya Ascii', 'mb': 'Maya Binary'}`` """ return self._choices
[docs] def str_from_value(self, value=None, ignore_type=False): """ Returns a string version of a value as appropriate for the key's setting. :param value: Value to process. If None, the key's default will be used. :param ignore_type: If true, no validation will be carried out prior to casting. :returns: String version of value as processed by the key. :raises: :class:`TankError` if value is not valid for the key. """ if value is None: if self.default is None: raise TankError( "No value provided and no default available for %s" % self ) else: value = self.default elif ignore_type: return value if isinstance(value, six.string_types) else str(value) if self.validate(value): return self._as_string(value) else: raise TankError(self._last_error)
[docs] def value_from_str(self, str_value): """ Validates and translates a string into an appropriate value for this key. :param str_value: The string to translate. :returns: The translated value. """ if self.validate(str_value): value = self._as_value(str_value) else: raise TankError(self._last_error) return value
[docs] def validate(self, value): """ Test if a value is valid for this key:: >>> str_key.validate('foo') True >>> int_key.validate(2) True >>> int_key.validate('foo') False >>> seq_key.validate(3) True >>> seq_key.validate('foo') False :param value: Value to test :returns: Bool """ str_value = value if isinstance(value, six.string_types) else str(value) # We are not case sensitive if str_value.lower() in [str(x).lower() for x in self.exclusions]: self._last_error = "%s Illegal value: %s is forbidden for this key." % ( self, value, ) return False if value is not None and self.choices: if str_value.lower() not in [str(x).lower() for x in self.choices]: self._last_error = "%s Illegal value: '%s' not in choices: %s" % ( self, value, str(self.choices), ) return False if self.length is not None and len(str_value) != self.length: self._last_error = ( "%s Illegal value: '%s' does not have a length of " "%d characters." % (self, value, self.length) ) return False return True
def _as_string(self, value): raise NotImplementedError def _as_value(self, str_value): return str_value def __repr__(self): return "<Sgtk %s %s>" % (self.__class__.__name__, self.name)
[docs]class StringKey(TemplateKey): """ :class:`TemplateKey` representing a string value. """ def __init__( self, name, default=None, choices=None, filter_by=None, shotgun_entity_type=None, shotgun_field_name=None, exclusions=None, abstract=False, length=None, subset=None, subset_format=None, ): """ :param str name: Name by which the key will be referred. :param str default: Default value for the key. :param choices: List of possible values for this key. Can be either a list or a dictionary of choice:label pairs. :param str filter_by: Name of filter type to limit values for string. Currently only accepted values are 'alphanumeric', 'alpha', None and a regex string. :param str shotgun_entity_type: For keys directly linked to a shotgun field, the entity type. :param str shotgun_field_name: For keys directly linked to a shotgun field, the field name. :param list exclusions: List of forbidden values. :param bool abstract: Flagging that this should be treated as an abstract key. :param int length: If non-None, indicating that the value should be of a fixed length. :param str subset: Regular expression defining a subset of the value to use. :param str subset_format: String to express the formatting of subset tokens. """ self._filter_by = filter_by # Build regexes for alpha and alphanumeric filter_by clauses # # Note that we cannot use a traditional [^a-zA-Z0-9] regex since we want # to support unicode and not just ascii. \W covers "Non-word characters", # which is basically the international equivalent of 7-bit ascii # self._filter_regex_u = None self._custom_regex_u = None if self._filter_by == "alphanumeric": self._filter_regex_u = re.compile(r"[\W_]", re.UNICODE) elif self._filter_by == "alpha": self._filter_regex_u = re.compile(r"[\W_0-9]", re.UNICODE) elif self._filter_by is not None: # filter_by is a regex self._custom_regex_u = re.compile(self._filter_by, re.UNICODE) self._subset_str = subset self._subset_format = subset_format if self._subset_format and sys.version_info < (2, 6): raise TankError("Subset formatting in template keys require python 2.6+!") self._subset_str = subset if subset: try: self._subset_regex = re.compile(subset, re.UNICODE) except Exception as e: raise TankError( "Template key %s: Invalid subset regex '%s': %s" % (name, subset, e) ) else: self._subset_regex = None super(StringKey, self).__init__( name, default=default, choices=choices, shotgun_entity_type=shotgun_entity_type, shotgun_field_name=shotgun_field_name, exclusions=exclusions, abstract=abstract, length=length, ) if self._subset_format and not self._subset_str: raise TankError( "%s: Cannot specify subset_format parameter without a subset parameter." % self ) @property def filter_by(self): """ Name of filter type to limit values for string. ``alphanumeric``, ``alpha``, ``None`` or a regex string. """ return self._filter_by @property def subset(self): """ Returns a regular expression describing how values should be transformed when they are being injected into template paths and strings. The format for a subset is a regular expression containing regex groups, for example:: # grabs capital letters of the two first words user_initials: type: str subset: '([A-Z])[a-z]* ([A-Z])[a-z]*' # extracts the first three characters first_three_characters: type: str subset: '(.{3}).*' # in code, the above expressions would compress the following input: some_template.apply_fields( {"user_initials": "John Smith", "first_three_characters": "John Smith"} ) # into "JS" for the {user_initials} key and "Joh" for the {first_three_characters} key If the subset expression contains more than one ``(regex group)`` to extract, the groups will be concatenated together in the order they are found. If you want greater control over this, see :meth:`subset_format`. """ return self._subset_str @property def subset_format(self): """ Returns the ``subset_format`` string for the given template key. This string is used in conjunction with the :meth:`subset` parameter and allows for the formatting of the values that are being extracted:: # grabs capital letters of the two first words user_initials_backwards: type: str subset: '([A-Z])[a-z]* ([A-Z])[a-z]*' subset_format: '{1}{0}' # in code, the above expression would compress the following input: some_template.apply_fields({"user_initials": "John Smith"}) # into "SJ" for the user_initials_backwards key. The formatting used for the string is standard python custom string formatting, where you can reference each regex group with an integer index. Read more about standard python string formatting here: https://docs.python.org/2/library/string.html#custom-string-formatting .. note:: Subset format is using python string formatting and is only compatible with with Python 2.6+. """ return self._subset_format
[docs] def validate(self, value): """ Test if a value is valid for this key. :param value: Value to test :returns: True if valid, false if not. """ # make sure that transforms such as a subset calculation # are valid. return self.__validate(value, validate_transforms=True)
[docs] def value_from_str(self, str_value): """ Validates and translates a string into an appropriate value for this key. :param str_value: The string to translate. :returns: The translated value. """ # this is used by the parser when transforming # a path or string into an actual value. # in this case, we don't want to validate transforms # such as the substring regext transform, since these # may not be valid in both directions. # # for example, a regex that extracts the initials from # a "Firstname Lastname" string will result in a value # which will not match the regex that is used to # extract it. # if self.__validate(str_value, validate_transforms=False): value = self._as_value(str_value) else: raise TankError(self._last_error) return value
def _as_string(self, value): """ Converts the given value to a string representation. :param value: value of any type to convert. Value is never None. :returns: string representation for this object. """ str_value = value if isinstance(value, six.string_types) else str(value) if self._subset_regex: # process substring computation. # we want to do this in unicode. if isinstance(str_value, six.binary_type): # convert to unicode input_is_utf8 = True value_to_convert = str_value.decode("utf-8") else: # already unicode input_is_utf8 = False value_to_convert = str_value # now perform extraction and concat match = self._subset_regex.match(value_to_convert) if match is None: # no match. return empty string # validate should prevent this from happening resolved_value = u"" elif self._subset_format: # we have an explicit format string we want to apply to the # match. Do the formatting as unicode. resolved_value = six.ensure_text(self._subset_format).format( *match.groups() ) else: # we have a match object. concatenate the groups resolved_value = "".join(match.groups()) # resolved value is now unicode. Convert it # so that it is consistent with input if isinstance(resolved_value, six.text_type) and input_is_utf8: # input was utf-8, regex resut is unicode, cast it back str_value = resolved_value.encode("utf-8") else: str_value = resolved_value return str_value def __validate(self, value, validate_transforms): """ Test if a value is valid for this key. :param value: Value to test :param validate_transforms: If true, then validate that transforms that mutate the value of a key are valid and can be applied. :returns: True if valid, false if not. """ u_value = value if not isinstance(u_value, six.text_type): # handle non-ascii characters correctly by # decoding to unicode assuming utf-8 encoding u_value = value.decode("utf-8") if self._filter_regex_u: # first check our std filters. These filters are negated # so here we are checking that there are occurances of # that pattern in the string if self._filter_regex_u.search(u_value): self._last_error = ( "%s Illegal value '%s' does not fit filter_by '%s'" % (self, value, self.filter_by) ) return False elif self._custom_regex_u: # check for any user specified regexes if self._custom_regex_u.match(u_value) is None: self._last_error = ( "%s Illegal value '%s' does not fit filter_by '%s'" % (self, value, self.filter_by) ) return False # check subset regex if self._subset_regex and validate_transforms: regex_match = self._subset_regex.match(u_value) if regex_match is None: self._last_error = ( "%s Illegal value '%s' does not fit " "subset expression '%s'" % (self, value, self.subset) ) return False # validate that the formatting can be applied to the input value if self._subset_format: try: # perform the formatting in unicode space to cover all cases six.ensure_text(self._subset_format).format(*regex_match.groups()) except Exception as e: self._last_error = ( "%s Illegal value '%s' does not fit subset '%s' with format '%s': %s" % (self, value, self.subset, self.subset_format, e) ) return False return super(StringKey, self).validate(value)
[docs]class TimestampKey(TemplateKey): """ :class:`TemplateKey` representing a time or date string formatted with strftime. """ def __init__(self, name, default=None, format_spec="%Y-%m-%d-%H-%M-%S"): """ :param str name: Name by which the key will be referred. :param default: Default value for this field. Acceptable values are: - ``None`` - a string formatted according to the format_spec, e.g. '2003-01-02 12:23' - ``utc_now``, which means the current time in the UTC timezone will be used as the default value. - ``now``, which means the current time in the local timezone will be used as the default value. :param str format_spec: Specification for formatting when casting to/from a string. The format follows the convention of :meth:`time.strftime`. The default value is ``%Y-%m-%d-%H-%M-%S``. Given June 24th, 2015 at 9:20:30 PM, this will yield ``2015-06-24-21-20-30``. """ # Can't use __repr__ because of a chicken and egg problem. The base class validates the # default value, so format_spec needs to be set first. But if I am testing format_spec # before calling the base class, then repr will crash since self.name won't have been set # yet. if isinstance(format_spec, six.string_types) is False: raise TankError( "format_spec for <Sgtk TimestampKey %s> is not of type string: %s" % (name, format_spec.__class__.__name__) ) self._format_spec = format_spec if isinstance(default, six.string_types): # if the user passes in now or utc, we'll generate the current time as the default time. if default.lower() == "now": default = self.__get_current_time elif default.lower() == "utc_now": default = self.__get_current_utc_time else: # Normally the base class is the one to validate, but in this case we need to # convert the string value into an actual value because the default is expected to # be a value and not a string, so we'll validate right away. if not self.validate(default): raise TankError(self._last_error) # If we are here everything went well, so convert the string to an actual value. default = datetime.datetime.strptime(default, self.format_spec) # Base class will validate other values using the format specifier. elif default is not None: raise TankError( "default for <Sgtk TimestampKey %s> is not of type string or None: %s" % (name, default.__class__.__name__) ) super(TimestampKey, self).__init__(name, default=default) @property def format_spec(self): """ Specification for formatting when casting to/from a string. The format follows the convention of :meth:`time.strftime`. """ return self._format_spec def __get_current_time(self): """ Returns the current time as a datetime.datetime instance. Do not streamline the code so the __init__ method simply passesd the datetime.datetime.now method, we can't mock datetime.now since it's builtin and will make unit tests more complicated to write. :returns: A datetime object representing the current time in the local timezone. """ return datetime.datetime.now() def __get_current_utc_time(self): """ Returns the current utc time as a datetime.datetime instance. Do not streamline the code so the __init__ method simply passesd the datetime.datetime.utcnow method, we can't mock datatime.datetime.utcnow since it's builtin and will make unit tests more complicated to write. :returns: A datetime object representing time current time in the UTC timezone. """ return datetime.datetime.utcnow()
[docs] def validate(self, value): """ Test if a value is valid for this key. :param value: Value to test. :returns: Bool """ if isinstance(value, six.string_types): # If we have a string we have to actually try to convert the string to see it if matches # the expected format. try: datetime.datetime.strptime(value, self.format_spec) return True except ValueError as e: # Bad value, report the error to the client code. self._last_error = "Invalid string: %s" % str(e) return False elif isinstance(value, datetime.datetime): return True else: self._last_error = ( "Invalid type: expecting string or datetime.datetime, not %s" % value.__class__.__name__ ) return False
def _as_string(self, value): """ Converts a given value as string. :param value: A datetime.datetime object that will be converted to a string according to the format specification. :returns: A string formatted according to the format_spec. """ return value.strftime(self.format_spec) def _as_value(self, str_value): """ Converts a string into a datetime.datetime. :param str_value: String to convert. :returns: A datetime representation of str_value parsed according to the format_spec. """ return datetime.datetime.strptime(str_value, self.format_spec)
[docs]class IntegerKey(TemplateKey): """ :class:`TemplateKey` representing an integer value. """ # Matches one non-zero digit follow by any number of digits. _NON_ZERO_POSITIVE_INTEGER_EXP = r"[1-9]\d*" # For the next two regular expressions, the ^ and $ are important to prevent partial matches. # Matches an optional 0 followed by a non zero positive integer. _FORMAT_SPEC_RE = re.compile("^(0?)(%s)$" % _NON_ZERO_POSITIVE_INTEGER_EXP) # Matches a non zero positive integer. _NON_ZERO_POSITIVE_INTEGER_RE = re.compile("^%s$" % _NON_ZERO_POSITIVE_INTEGER_EXP) def __init__( self, name, default=None, choices=None, format_spec=None, shotgun_entity_type=None, shotgun_field_name=None, exclusions=None, abstract=False, length=None, strict_matching=None, ): """ :param str name: Name by which the key will be referred. :param int default: Default value for this key. :param list choices: List of possible values for this key. :param str format_spec: Specification for formatting when casting to a string. The form is a zero followed the number of spaces to pad the value. :param str shotgun_entity_type: For keys directly linked to a shotgun field, the entity type. :param str shotgun_field_name: For keys directly linked to a shotgun field, the field name. :param list exclusions: List of forbidden values. :param bool abstract: Flagging that this should be treated as an abstract key. :param int length: If non-None, indicating that the value should be of a fixed length. :param bool strict_matching: Indicates if the padding should be matching exactly the format_spec when parsing a string. Default behavior is to match exactly the padding when a format_spec is provided. """ self._zero_padded = None self._minimum_width = None self._format_spec = None self._strict_matching = None # Validate and set up formatting details self._init_format_spec(name, format_spec) # Validate and set up strict matching defailts self._init_strict_matching(name, strict_matching) super(IntegerKey, self).__init__( name, default=default, choices=choices, shotgun_entity_type=shotgun_entity_type, shotgun_field_name=shotgun_field_name, exclusions=exclusions, abstract=abstract, length=length, ) @property def format_spec(self): """ Specification for formatting when casting to a string. The form is a zero followed the number of spaces to pad the value. """ return self._format_spec @property def strict_matching(self): """ Indicates if the padding should be matching exactly the format_spec when parsing a string. """ return self._strict_matching def _init_format_spec(self, name, format_spec): """ Asserts that the format_spec parameter is a valid value. :param name: Name of this template key. :param format_spec: Parameter to be validated. :raises TankError: Raised when the parameter is not a string that maching a %d format option. """ # No format spec means no formatting options. if format_spec is None: return if not isinstance(format_spec, six.string_types): msg = "format_spec for IntegerKey %s is not of type string: %s" raise TankError(msg % (name, format_spec)) if len(format_spec) == 0: raise TankError("format_spec can't be empty.") matches = self._FORMAT_SPEC_RE.match(format_spec) if not matches: raise TankError( "format_spec for <Sgtk IntegerKey %s> has to either be a number (e.g. '3') or " "a 0 followed by a number (e.g. '03'), not '%s'" % (name, format_spec) ) groups = matches.groups() # groups[0] is either '' or '0', in which case the padding is ' ' self._zero_padded = groups[0] == "0" # groups[1] is the minimum width of the number. self._minimum_width = int(groups[1]) self._format_spec = format_spec def _init_strict_matching(self, name, strict_matching): """ Asserts that the strict_matching parameter is a valid value. :param name: Name of this template key. :param strict_matching: Parameter to be validated. :raises TankError: Raised when the parameter is not a boolean. """ # make sure that strict_matching is not set or that it is a boolean if not (strict_matching is None or isinstance(strict_matching, bool)): msg = "strict_matching for <Sgtk IntegerKey %s> is not of type boolean: %s" raise TankError(msg % (name, str(strict_matching))) # If there is a format and strict_matching is set, there's an error, since there # is no format to enforce or not. if self._format_spec is None and strict_matching is not None: raise TankError("strict_matching can't be set if there is no format_spec") # By default, if strict_matching is not set but there is a format spec, we'll # strictly match. if strict_matching is None and self.format_spec is not None: strict_matching = True if strict_matching: # This regular expression is blind to the actual length of the string for performance # reasons. Code that uses it should test that the string's length is of # self._minimum_width first. It first matches up to n-1 padding characters. It then # matches either a single 0, or an actual multiple digit number that doesn't start with # 0. self._strict_validation_re = re.compile( "^%s{0,%d}((%s)|0)$" % ( "0" if self._zero_padded else " ", self._minimum_width - 1, self._NON_ZERO_POSITIVE_INTEGER_EXP, ) ) else: self._strict_validation_re = None self._strict_matching = strict_matching
[docs] def validate(self, value): if value is not None: if isinstance(value, six.string_types): # We have a string, make sure it loosely or strictly matches the format. if self.strict_matching and not self._strictly_matches(value): return False elif not self.strict_matching and not self._loosely_matches(value): return False elif not isinstance(value, int): self._last_error = "%s Illegal value '%s', expected an Integer" % ( self, value, ) return False return super(IntegerKey, self).validate(value) return True
def _loosely_matches(self, value): """ Checks if the value loosely matches. The value loosely matches if it can be turned into an int. For a given format_spec of "03", here are examples of loosely matching values: - '1' (missing padding) - '00000001' (too much padding) - ' 1' (too much padding) :param value: String to test :returns: True if it loosely matches, False otherwise. """ # This is the extent of what was tested before strict matching. We're actually a bit more permissive, # because the user could have specified a format specifier that uses spaces for padding, but isdigit will # fail if spaces are at the beginning of a string, so strip them out. if not self._zero_padded: value = value.lstrip() # Is digit is how we tested for a number before strict_matching was introduced, so don't change that behaviour if not value.isdigit(): self._last_error = "%s Illegal value '%s', expected an Integer" % ( self, value, ) return False return True def _strictly_matches(self, value): """ Checks if the value strictly matches the format_spec. A value strictly matches the format it is at least as wide as the minimum character length. If the string is wider, it must be a non zero positive number. If it is as wide, is must be a padded positive integer. :param value: Value to test :returns: True if the value strictly matches the format spec, False otherwise. """ error_msg = "%s Illegal value '%s', does not match format spec '%s'" % ( self, value, self.format_spec, ) # If there are more characters than the minimum size, we should have a non zero positive number if len(value) > self._minimum_width: if not self._NON_ZERO_POSITIVE_INTEGER_RE.match(value): self._last_error = error_msg return False return True # If there are less characters than the minimum size, then then there is no strict matching. if len(value) < self._minimum_width: self._last_error = error_msg return False # If there are many characters as the format_spec requires, we'll validate that things are # padded accordingly. Example of things that will fail are. # - '01a' # - '0 1' # - ' 01' matches = self._strict_validation_re.match(value) if not matches: self._last_error = error_msg return False return True def _as_string(self, value): """ Converts value into a string. :returns: String representation of the value according to the optional format_spec. """ if self.format_spec: # insert format spec into string return ("%%%sd" % self.format_spec) % value return "%d" % value def _as_value(self, str_value): """ Converts value into a string. :returns: String representation of the value according to the optional format_spec. """ return int(str_value)
[docs]class SequenceKey(IntegerKey): """ :class:`TemplateKey` representing an integer sequence, usually used to handle frame sequences. With image sequences, there are many ways of representing a set of images. Different applications use different representations, so it is often necessary to be able to extract image sequences on a particular format so that it works with a particular application environment. In Toolkit, this can be done using a special FORMAT directive. This format directive only works with abstract image sequence fields and supports a number of different formats. For example, an app may need to reconstruct a path, but the app doesn't know if the user has configured the input paths to use eight zero padded paths or four zero padded paths. However, the app runs in Nuke, so it needs path on the form %04d (for four zero padded paths). In order to get the correct padding, pass ``FORMAT: %d`` and Toolkit will format this with the correct padding. The following conversions are supported for sequence keys: - ``FORMAT: %d`` - Turns format_spec 04 into ``%04d`` and a non-zero padded format_spec into ``%d`` - ``FORMAT: @`` - Turns format_spec 04 into ``@@@@`` and a non-zero padded format_spec into ``@`` - ``FORMAT: #`` - Turns format_spec 04 into ``####`` and a non-zero padded format_spec into ``#`` - ``FORMAT: $F`` - Turns format_spec 04 into ``$F4`` and a non-zero padded format_spec into ``$F`` Example:: # An app in nuke generates a sequence path like this: >>> fields = {"Shot":"shot_2", "name":"render", "seq": "FORMAT: %d"} # the FORMAT field will correctly format the key regardless of # how it has been configured (e.g. for any type of padding) >>> template_path.apply_fields(fields) '/mnt/proj/shot_2/publish/render.%04d.exr' # in houdini, the code would look like this >>> fields = {"Shot":"shot_2", "name":"render", "seq": "FORMAT: $F"} # the FORMAT field will correctly format the key regardless of # how it has been configured (e.g. for any type of padding) >>> template_path.apply_fields(fields) '/mnt/proj/shot_2/publish/render.$F4.exr' """ # special keywork used when format is specified directly in value FRAMESPEC_FORMAT_INDICATOR = "FORMAT:" # valid format strings that can be used with this Key type VALID_FORMAT_STRINGS = ["%d", "#", "@", "$F", "<UDIM>", "$UDIM"] # flame sequence pattern regex ('[1234-5434]') FLAME_PATTERN_REGEX = r"^\[[0-9]+-[0-9]+\]$" def __init__( self, name, default=None, choices=None, format_spec="01", shotgun_entity_type=None, shotgun_field_name=None, exclusions=None, ): """ :param str name: Name by which the key will be referred. :param str default: Default value for this key. :param list choices: List of possible values for this key. :param str format_spec: Specification for formatting when casting to a string. The form is a zero followed the number of spaces to pad the value. :param str shotgun_entity_type: For keys directly linked to a shotgun field, the entity type. :param str shotgun_field_name: For keys directly linked to a shotgun field, the field name. :param str exclusions: List of forbidden values. """ # determine the actual frame specs given the padding (format_spec) # and the allowed formats self._frame_specs = [ self._resolve_frame_spec(x, format_spec) for x in self.VALID_FORMAT_STRINGS ] # all sequences are abstract by default and have a default value of %0Xd abstract = True if default is None: # default value is %d form default = self._resolve_frame_spec("%d", format_spec) super(SequenceKey, self).__init__( name, default=default, choices=choices, strict_matching=False, format_spec=format_spec, shotgun_entity_type=shotgun_entity_type, shotgun_field_name=shotgun_field_name, exclusions=exclusions, abstract=abstract, )
[docs] def validate(self, value): # use a std error message full_format_strings = [ "%s %s" % (self.FRAMESPEC_FORMAT_INDICATOR, x) for x in self.VALID_FORMAT_STRINGS ] error_msg = ( "%s Illegal value '%s', expected an Integer, a frame spec or format spec.\n" % (self, value) ) error_msg += "Valid frame specs: %s\n" % str(self._frame_specs) error_msg += "Valid format strings: %s\n" % full_format_strings if isinstance(value, six.string_types) and value.startswith( self.FRAMESPEC_FORMAT_INDICATOR ): # FORMAT: YXZ string - check that XYZ is in VALID_FORMAT_STRINGS pattern = self._extract_format_string(value) if pattern in self.VALID_FORMAT_STRINGS: return True else: self._last_error = error_msg return False elif isinstance(value, six.string_types) and re.match( self.FLAME_PATTERN_REGEX, value ): # value is matching the flame-style sequence pattern # [1234-5678] return True elif not (isinstance(value, int) or value.isdigit()): # not a digit - so it must be a frame spec! (like %05d) # make sure that it has the right length and formatting. if value in self._frame_specs: return True else: self._last_error = error_msg return False else: return super(SequenceKey, self).validate(value)
def _as_string(self, value): if isinstance(value, six.string_types) and value.startswith( self.FRAMESPEC_FORMAT_INDICATOR ): # this is a FORMAT: XYZ - convert it to the proper resolved frame spec pattern = self._extract_format_string(value) return self._resolve_frame_spec(pattern, self.format_spec) if isinstance(value, six.string_types) and re.match( self.FLAME_PATTERN_REGEX, value ): # this is a flame style sequence token [1234-56773] return value if value in self._frame_specs: # a frame spec like #### @@@@@ or %08d return value # resolve it via the integerKey base class return super(SequenceKey, self)._as_string(value) def _as_value(self, str_value): if str_value in self._frame_specs: return str_value if re.match(self.FLAME_PATTERN_REGEX, str_value): # this is a flame style sequence token [1234-56773] return str_value # resolve it via the integerKey base class return super(SequenceKey, self)._as_value(str_value) def _extract_format_string(self, value): """ Returns XYZ given the string "FORMAT: XYZ" """ if isinstance(value, six.string_types) and value.startswith( self.FRAMESPEC_FORMAT_INDICATOR ): pattern = value.replace(self.FRAMESPEC_FORMAT_INDICATOR, "").strip() else: # passthrough pattern = value return pattern def _resolve_frame_spec(self, format_string, format_spec): """ Turns a format_string %d and a format_spec "03" into a sequence identifier (%03d) """ error_msg = "Illegal format pattern for framespec: '%s'. " % format_string error_msg += "Legal patterns are: %s" % ", ".join(self.VALID_FORMAT_STRINGS) if format_string not in self.VALID_FORMAT_STRINGS: raise TankError(error_msg) if format_spec.startswith("0") and format_spec != "01": use_zero_padding = True else: use_zero_padding = False places = int(format_spec) if format_spec.isdigit() else 1 if use_zero_padding: if format_string == "%d": frame_spec = "%%0%dd" % places elif format_string == "#": frame_spec = "#" * places elif format_string == "@": frame_spec = "@" * places elif format_string == "$F": frame_spec = "$F%d" % places elif format_string in ("<UDIM>", "$UDIM"): # UDIM's aren't padded! frame_spec = format_string else: raise TankError(error_msg) else: # non zero padded rules if format_string == "%d": frame_spec = "%d" elif format_string == "#": frame_spec = "#" elif format_string == "@": frame_spec = "@" elif format_string == "$F": frame_spec = "$F" elif format_string in ("<UDIM>", "$UDIM"): # UDIM's aren't padded! frame_spec = format_string else: raise TankError(error_msg) return frame_spec
def make_keys(data): """ Factory method for instantiating template keys. :param data: Key data. :type data: Dictionary of the form: {<key name>: {'type': <key type>, <option>: <option value} :returns: Dictionary of the form: {<key name>: <TemplateKey object>} """ keys = {} names_classes = { "str": StringKey, "int": IntegerKey, "sequence": SequenceKey, "timestamp": TimestampKey, } for initial_key_name, key_data in data.items(): # We need to remove data before passing in as arguments, so copy it. prepped_data = key_data.copy() class_name = prepped_data.pop("type") KeyClass = names_classes.get(class_name) if not KeyClass: raise TankError( "Invalid type: '%s'. Valid types are: %s" % (class_name, list(names_classes.keys())) ) if "alias" in prepped_data: # The alias becomes the key's name and is used internally by Templates as the key's name key_name = prepped_data.pop("alias") else: key_name = initial_key_name key = KeyClass(key_name, **prepped_data) keys[initial_key_name] = key return keys