Source code for tank.template

# 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.

"""
Management of file and directory templates.

"""

import os

from . import templatekey
from .errors import TankError
from . import constants
from .template_path_parser import TemplatePathParser
from tank_vendor import six
from tank_vendor.shotgun_api3.lib import sgsix
from tank_vendor.six.moves import zip
from tank.util import is_linux, is_macos, is_windows, sgre as re


[docs]class Template(object): """ Represents an expression containing several dynamic tokens in the form of :class:`TemplateKey` objects. """ @classmethod def _keys_from_definition(cls, definition, template_name, keys): """Extracts Template Keys from a definition. :param definition: Template definition as string :param template_name: Name of template. :param keys: Mapping of key names to keys as dict :returns: Mapping of key names to keys and collection of keys ordered as they appear in the definition. :rtype: List of Dictionaries, List of lists """ names_keys = {} ordered_keys = [] # regular expression to find key names regex = r"(?<={)%s(?=})" % constants.TEMPLATE_KEY_NAME_REGEX key_names = re.findall(regex, definition) for key_name in key_names: key = keys.get(key_name) if key is None: msg = "Template definition for template %s refers to key {%s}, which does not appear in supplied keys." raise TankError(msg % (template_name, key_name)) else: if names_keys.get(key.name, key) != key: # Different keys using same name msg = ( "Template definition for template %s uses two keys" + " which use the name '%s'." ) raise TankError(msg % (template_name, key.name)) names_keys[key.name] = key ordered_keys.append(key) return names_keys, ordered_keys def __init__(self, definition, keys, name=None): """ This class is not designed to be used directly but should be subclassed by any Template implementations. Current implementations can be found in the :class:`TemplatePath` and :class:`TemplateString` classes. :param definition: Template definition. :type definition: String :param keys: Mapping of key names to keys :type keys: Dictionary :param name: (Optional) name for this template. :type name: String """ self.name = name # version for __repr__ self._repr_def = self._fix_key_names(definition, keys) variations = self._definition_variations(definition) # We want them most inclusive(longest) version first variations.sort(key=lambda x: len(x), reverse=True) # get format keys and types self._keys = [] self._ordered_keys = [] for variation in variations: var_keys, ordered_keys = self._keys_from_definition(variation, name, keys) self._keys.append(var_keys) self._ordered_keys.append(ordered_keys) # substitute aliased key names self._definitions = [] for variation in variations: self._definitions.append(self._fix_key_names(variation, keys)) # get defintion ready for string substitution self._cleaned_definitions = [] for definition in self._definitions: self._cleaned_definitions.append(self._clean_definition(definition)) # string which will be prefixed to definition self._prefix = "" self._static_tokens = [] def __repr__(self): class_name = self.__class__.__name__ if self.name: return "<Sgtk %s %s: %s>" % (class_name, self.name, self._repr_def) else: return "<Sgtk %s %s>" % (class_name, self._repr_def) @property def definition(self): """ The template as a string, e.g ``shots/{Shot}/{Step}/pub/{name}.v{version}.ma`` """ # Use first definition as it should be most inclusive in case of variations return self._definitions[0] @property def keys(self): """ The keys that this template is using. For a template ``shots/{Shot}/{Step}/pub/{name}.v{version}.ma``, the keys are ``{Shot}``, ``{Step}`` and ``{name}``. :returns: a dictionary of class:`TemplateKey` objects, keyed by token name. """ # First keys should be most inclusive return self._keys[0].copy() @property def ordered_keys(self): """ The keys that this template is using in the order they appear. For a template ``shots/{Shot}/{Step}/pub/{name}.v{version}.ma``, the keys are ``{Shot}``, ``{Step}`` and ``{name}``. :returns: a list of class:`TemplateKey` objects. """ # First keys should be most inclusive. Return a copy of the list by # instantiating a new list from it. return list(self._ordered_keys[0])
[docs] def is_optional(self, key_name): """ Returns true if the given key name is optional for this template. For the template ``{Shot}[_{name}]``, ``is_optional("Shot")`` would return ``False`` and ``is_optional("name")`` would return ``True`` :param key_name: Name of template key for which the check should be carried out :returns: True if key is optional, False if not. """ # the key is required if it's in the # minimum set of keys for this template if key_name in min(self._keys, key=lambda i: len(i.keys())): # this key is required return False else: return True
[docs] def missing_keys(self, fields, skip_defaults=False): """ Determines keys required for use of template which do not exist in a given fields. Example:: >>> tk.templates["max_asset_work"].missing_keys({}) ['Step', 'sg_asset_type', 'Asset', 'version', 'name'] >>> tk.templates["max_asset_work"].missing_keys({"name": "foo"}) ['Step', 'sg_asset_type', 'Asset', 'version'] :param fields: fields to test :type fields: mapping (dictionary or other) :param skip_defaults: If true, do not treat keys with default values as missing. :type skip_defaults: Bool :returns: Fields needed by template which are not in inputs keys or which have values of None. :rtype: list """ # find shortest keys dictionary keys = min(self._keys, key=lambda i: len(i.keys())) return self._missing_keys(fields, keys, skip_defaults)
def _missing_keys(self, fields, keys, skip_defaults): """ Compares two dictionaries to determine keys in second missing in first. :param fields: fields to test :param keys: Dictionary of template keys to test :param skip_defaults: If true, do not treat keys with default values as missing. :returns: Fields needed by template which are not in inputs keys or which have values of None. """ if skip_defaults: required_keys = [key.name for key in keys.values() if key.default is None] else: required_keys = keys return [x for x in required_keys if (x not in fields) or (fields[x] is None)]
[docs] def apply_fields(self, fields, platform=None): r""" Creates path using fields. Certain fields may be processed in special ways, for example :class:`SequenceKey` fields, which can take a `FORMAT` string which will intelligently format a image sequence specifier based on the type of data is being handled. Example:: # get a template object from the API >>> template_obj = sgtk.templates["maya_shot_publish"] <Sgtk Template maya_asset_project: shots/{Shot}/{Step}/pub/{name}.v{version}.ma> >>> fields = {'Shot': '001_002', 'Step': 'comp', 'name': 'main_scene', 'version': 3 } >>> template_obj.apply_fields(fields) '/projects/bbb/shots/001_002/comp/pub/main_scene.v003.ma' .. note:: For formatting of special values, see :class:`SequenceKey` and :class:`TimestampKey`. Example:: >>> fields = {"Sequence":"seq_1", "Shot":"shot_2", "Step":"comp", "name":"henry", "version":3} >>> template_path.apply_fields(fields) '/studio_root/sgtk/demo_project_1/sequences/seq_1/shot_2/comp/publish/henry.v003.ma' >>> template_path.apply_fields(fields, platform='win32') 'z:\studio_root\sgtk\demo_project_1\sequences\seq_1\shot_2\comp\publish\henry.v003.ma' >>> template_str.apply_fields(fields) 'Maya Scene henry, v003' :param fields: Mapping of keys to fields. Keys must match those in template definition. :param platform: Optional operating system platform. If you leave it at the default value of None, paths will be created to match the current operating system. If you pass in a sys.platform-style string (e.g. ``win32``, ``linux2`` or ``darwin``), paths will be generated to match that platform. :returns: Full path, matching the template with the given fields inserted. """ return self._apply_fields(fields, platform=platform)
def _apply_fields( self, fields, ignore_types=None, platform=None, skip_defaults=False ): """ Creates path using fields. :param fields: Mapping of keys to fields. Keys must match those in template definition. :param ignore_types: Keys for whom the defined type is ignored as list of strings. This allows setting a Key whose type is int with a string value. :param platform: Optional operating system platform. If you leave it at the default value of None, paths will be created to match the current operating system. If you pass in a sys.platform-style string (e.g. 'win32', 'linux2' or 'darwin'), paths will be generated to match that platform. :param skip_defaults: Optional. If set to True, if a key has a default value and no corresponding value in the fields argument, its default value will be used. If set to False, keys that are not specified in the fields argument are skipped whether they have a default value or not. Defaults to False :returns: Full path, matching the template with the given fields inserted. """ ignore_types = ignore_types or [] # find largest key mapping without missing values keys = None # index of matching keys will be used to find cleaned_definition index = -1 for index, cur_keys in enumerate(self._keys): # We are iterating through all possible key combinations from the longest to shortest # and using the first one that doesn't have any missing keys. skip_defaults=False on # _apply_fields means we don't want to use a key that is not specified in the fields # parameter. So we want the missing_keys function to flag even the default keys that are # missing. Therefore we need to negate the skip_defaults parameter for the _missing_keys argument missing_keys = self._missing_keys( fields, cur_keys, skip_defaults=not skip_defaults ) if not missing_keys: keys = cur_keys break if keys is None: raise TankError( "Tried to resolve a path from the template %s and a set " "of input fields '%s' but the following required fields were missing " "from the input: %s" % (self, fields, missing_keys) ) # Process all field values through template keys processed_fields = {} for key_name, key in keys.items(): value = fields.get(key_name) ignore_type = key_name in ignore_types processed_fields[key_name] = key.str_from_value( value, ignore_type=ignore_type ) return self._cleaned_definitions[index] % processed_fields def _definition_variations(self, definition): """ Determines all possible definition based on combinations of optional sectionals. "{foo}" ==> ['{foo}'] "{foo}_{bar}" ==> ['{foo}_{bar}'] "{foo}[_{bar}]" ==> ['{foo}', '{foo}_{bar}'] "{foo}_[{bar}_{baz}]" ==> ['{foo}_', '{foo}_{bar}_{baz}'] """ # split definition by optional sections tokens = re.split(r"(\[[^]]*\])", definition) # seed with empty string definitions = [""] for token in tokens: temp_definitions = [] # regex return some blank strings, skip them if token == "": continue if token.startswith("["): # check that optional contains a key if not re.search("{*%s}" % constants.TEMPLATE_KEY_NAME_REGEX, token): raise TankError( 'Optional sections must include a key definition. Token: "%s" Template: %s' % (token, self) ) # Add definitions skipping this optional value temp_definitions = definitions[:] # strip brackets from token token = re.sub(r"[\[\]]", "", token) # check non-optional contains no dangleing brackets if re.search(r"[\[\]]", token): raise TankError( "Square brackets are not allowed outside of optional section definitions." ) # make defintions with token appended for definition in definitions: temp_definitions.append(definition + token) definitions = temp_definitions return definitions def _fix_key_names(self, definition, keys): """ Substitutes key name for name used in definition """ # Substitute key names for original key input names(key aliasing) substitutions = [ (key_name, key.name) for key_name, key in keys.items() if key_name != key.name ] for old_name, new_name in substitutions: old_def = r"{%s}" % old_name new_def = r"{%s}" % new_name definition = re.sub(old_def, new_def, definition) return definition def _clean_definition(self, definition): # Create definition with key names as strings with no format, enum or default values regex = r"{(%s)}" % constants.TEMPLATE_KEY_NAME_REGEX cleaned_definition = re.sub(regex, r"%(\g<1>)s", definition) return cleaned_definition def _calc_static_tokens(self, definition): """ Finds the tokens from a definition which are not involved in defining keys. """ # expand the definition to include the prefix unless the definition is empty in which # case we just want to parse the prefix. For example, in the case of a path template, # having an empty definition would result in expanding to the project/storage root expanded_definition = ( os.path.join(self._prefix, definition) if definition else self._prefix ) regex = r"{%s}" % constants.TEMPLATE_KEY_NAME_REGEX tokens = re.split(regex, expanded_definition.lower()) # Remove empty strings return [x for x in tokens if x] @property def parent(self): """ Returns Template representing the parent of this object. :returns: :class:`Template` """ raise NotImplementedError
[docs] def validate_and_get_fields(self, path, required_fields=None, skip_keys=None): """ Takes an input string and determines whether it can be mapped to the template pattern. If it can then the list of matching fields is returned. Example:: >>> good_path = '/studio_root/sgtk/demo_project_1/sequences/seq_1/shot_2/comp/publish/henry.v003.ma' >>> template_path.validate_and_get_fields(good_path) {'Sequence': 'seq_1', 'Shot': 'shot_2', 'Step': 'comp', 'name': 'henry', 'version': 3} >>> bad_path = '/studio_root/sgtk/demo_project_1/shot_2/comp/publish/henry.v003.ma' >>> template_path.validate_and_get_fields(bad_path) None :param path: Path to validate :param required_fields: An optional dictionary of key names to key values. If supplied these values must be present in the input path and found by the template. :param skip_keys: List of field names whose values should be ignored :returns: Dictionary of fields found from the path or None if path fails to validate """ required_fields = required_fields or {} skip_keys = skip_keys or [] # Path should split into keys as per template path_fields = {} try: path_fields = self.get_fields(path, skip_keys=skip_keys) except TankError: return None # Check that all required fields were found in the path: for key, value in required_fields.items(): if (key not in skip_keys) and (path_fields.get(key) != value): return None return path_fields
[docs] def validate(self, path, fields=None, skip_keys=None): """ Validates that a path can be mapped to the pattern given by the template. Example:: >>> good_path = '/studio_root/sgtk/demo_project_1/sequences/seq_1/shot_2/comp/publish/henry.v003.ma' >>> template_path.validate(good_path) True >>> bad_path = '/studio_root/sgtk/demo_project_1/shot_2/comp/publish/henry.v003.ma' >>> template_path.validate(bad_path) False :param path: Path to validate :type path: String :param fields: An optional dictionary of key names to key values. If supplied these values must be present in the input path and found by the template. :type fields: Dictionary :param skip_keys: Field names whose values should be ignored :type skip_keys: List :returns: True if the path is valid for this template :rtype: Bool """ return self.validate_and_get_fields(path, fields, skip_keys) != None
[docs] def get_fields(self, input_path, skip_keys=None): """ Extracts key name, value pairs from a string. Example:: >>> input_path = '/studio_root/sgtk/demo_project_1/sequences/seq_1/shot_2/comp/publish/henry.v003.ma' >>> template_path.get_fields(input_path) {'Sequence': 'seq_1', 'Shot': 'shot_2', 'Step': 'comp', 'name': 'henry', 'version': 3} :param input_path: Source path for values :type input_path: String :param skip_keys: Optional keys to skip :type skip_keys: List :returns: Values found in the path based on keys in template :rtype: Dictionary """ path_parser = None fields = None for ordered_keys, static_tokens in zip(self._ordered_keys, self._static_tokens): path_parser = TemplatePathParser(ordered_keys, static_tokens) fields = path_parser.parse_path(input_path, skip_keys) if fields != None: break if fields is None: raise TankError("Template %s: %s" % (str(self), path_parser.last_error)) return fields
[docs]class TemplatePath(Template): """ :class:`Template` representing a complete path on disk. The template definition is multi-platform and you can pass it per-os roots given by a separate :meth:`root_path`. """ def __init__(self, definition, keys, root_path, name=None, per_platform_roots=None): """ TemplatePath objects are typically created automatically by toolkit reading the template configuration. :param definition: Template definition string. :param keys: Mapping of key names to keys (dict) :param root_path: Path to project root for this template. :param name: Optional name for this template. :param per_platform_roots: Root paths for all supported operating systems. This is a dictionary with sys.platform-style keys """ super(TemplatePath, self).__init__(definition, keys, name=name) self._prefix = root_path self._per_platform_roots = per_platform_roots # Make definition use platform separator for index, rel_definition in enumerate(self._definitions): self._definitions[index] = os.path.join(*split_path(rel_definition)) # get definition ready for string substitution self._cleaned_definitions = [] for definition in self._definitions: self._cleaned_definitions.append(self._clean_definition(definition)) # split by format strings the definition string into tokens self._static_tokens = [] for definition in self._definitions: self._static_tokens.append(self._calc_static_tokens(definition)) @property def root_path(self): """ Returns the root path associated with this template. """ return self._prefix @property def parent(self): """ Returns Template representing the parent of this object. For paths, this means the parent folder. :returns: :class:`Template` """ parent_definition = os.path.dirname(self.definition) if parent_definition: return TemplatePath( parent_definition, self.keys, self.root_path, None, self._per_platform_roots, ) return None def _apply_fields( self, fields, ignore_types=None, platform=None, skip_defaults=False ): """ Creates path using fields. :param fields: Mapping of keys to fields. Keys must match those in template definition. :param ignore_types: Keys for whom the defined type is ignored as list of strings. This allows setting a Key whose type is int with a string value. :param platform: Optional operating system platform. If you leave it at the default value of None, paths will be created to match the current operating system. If you pass in a sys.platform-style string (e.g. 'win32', 'linux2' or 'darwin'), paths will be generated to match that platform. :param skip_defaults: Optional. If set to True, if a key has a default value and no corresponding value in the fields argument, its default value will be used. If set to False, keys that are not specified in the fields argument are skipped whether they have a default value or not. Defaults to False :returns: Full path, matching the template with the given fields inserted. """ relative_path = super(TemplatePath, self)._apply_fields( fields, ignore_types, platform, skip_defaults=skip_defaults ) if platform is None: # return the current OS platform's path return ( os.path.join(self.root_path, relative_path) if relative_path else self.root_path ) else: platform = sgsix.normalize_platform(platform) # caller has requested a path for another OS if self._per_platform_roots is None: # it's possible that the additional os paths are not set for a template # object (mainly because of backwards compatibility reasons) and in this case # we cannot compute the path. raise TankError( "Template %s cannot resolve path for operating system '%s' - " "it was instantiated in a mode which only supports the resolving " "of current operating system paths." % (self, platform) ) platform_root_path = self._per_platform_roots.get(platform) if platform_root_path is None: # either the platform is undefined or unknown raise TankError( "Cannot resolve path for operating system '%s'! Please ensure " "that you have a valid storage set up for this platform." % platform ) elif is_windows(platform): # use backslashes for windows if relative_path: return "%s\\%s" % ( platform_root_path, relative_path.replace(os.sep, "\\"), ) else: # not path generated - just return the root path return platform_root_path elif is_macos(platform) or is_linux(platform): # unix-like plaforms - use slashes if relative_path: return "%s/%s" % ( platform_root_path, relative_path.replace(os.sep, "/"), ) else: # not path generated - just return the root path return platform_root_path else: raise TankError( "Cannot evaluate path. Unsupported platform '%s'." % platform )
[docs]class TemplateString(Template): """ :class:`Template` class for templates representing strings. Templated strings are useful if you want to write code where you can configure the formatting of strings, for example how a name or other string field should be configured in Shotgun, given a series of key values. """ def __init__(self, definition, keys, name=None, validate_with=None): """ TemplatePath objects are typically created automatically by toolkit reading the template configuration. :param definition: Template definition string. :param keys: Mapping of key names to keys (dict) :param name: Optional name for this template. :param validate_with: Optional :class:`Template` to use for validation """ super(TemplateString, self).__init__(definition, keys, name=name) self.validate_with = validate_with self._prefix = "@" # split by format strings the definition string into tokens self._static_tokens = [] for definition in self._definitions: self._static_tokens.append(self._calc_static_tokens(definition)) @property def parent(self): """ Strings don't have a concept of parent so this always returns ``None``. """ return None def get_fields(self, input_path, skip_keys=None): """ Extracts key name, value pairs from a string. Example:: >>> input = 'filename.v003.ma' >>> template_string.get_fields(input) {'name': 'henry', 'version': 3} :param input_path: Source path for values :type input_path: String :param skip_keys: Optional keys to skip :type skip_keys: List :returns: Values found in the path based on keys in template :rtype: Dictionary """ # add path prefix as original design was to require project root adj_path = os.path.join(self._prefix, input_path) return super(TemplateString, self).get_fields(adj_path, skip_keys=skip_keys)
def split_path(input_path): """ Split a path into tokens. :param input_path: path to split :type input_path: string :returns: tokenized path :rtype: list of tokens """ cur_path = os.path.normpath(input_path) cur_path = cur_path.replace("\\", "/") return cur_path.split("/") def read_templates(pipeline_configuration): """ Creates templates and keys based on contents of templates file. :param pipeline_configuration: pipeline config object :returns: Dictionary of form {template name: template object} """ per_platform_roots = pipeline_configuration.get_all_platform_data_roots() data = pipeline_configuration.get_templates_config() # get dictionaries from the templates config file: def get_data_section(section_name): # support both the case where the section # name exists and is set to None and the case where it doesn't exist d = data.get(section_name) if d is None: d = {} return d keys = templatekey.make_keys(get_data_section("keys")) template_paths = make_template_paths( get_data_section("paths"), keys, per_platform_roots, default_root=pipeline_configuration.get_primary_data_root_name(), ) template_strings = make_template_strings( get_data_section("strings"), keys, template_paths ) # Detect duplicate names across paths and strings dup_names = set(template_paths).intersection(set(template_strings)) if dup_names: raise TankError( "Detected paths and strings with the same name: %s" % str(list(dup_names)) ) # Put path and strings together templates = template_paths templates.update(template_strings) return templates def make_template_paths(data, keys, all_per_platform_roots, default_root=None): """ Factory function which creates TemplatePaths. :param data: Data from which to construct the template paths. Dictionary of form: {<template name>: {<option>: <option value>}} :param keys: Available keys. Dictionary of form: {<key name> : <TemplateKey object>} :param all_per_platform_roots: Root paths for all platforms. nested dictionary first keyed by storage root name and then by sys.platform-style os name. :returns: Dictionary of form {<template name> : <TemplatePath object>} """ if data and not all_per_platform_roots: raise TankError( "At least one root must be defined when using 'path' templates." ) template_paths = {} templates_data = _process_templates_data(data, "path") for template_name, template_data in templates_data.items(): definition = template_data["definition"] root_name = template_data.get("root_name") if not root_name: # If the root name is not explicitly set we use the default arg # provided if default_root: root_name = default_root else: raise TankError( "The template %s (%s) can not be evaluated. No root_name " "is specified, and no root name can be determined from " "the configuration. Update the template definition to " "include a root_name or update your configuration's " "roots.yml file to mark one of the storage roots as the " "default: `default: true`." % (template_name, definition) ) # to avoid confusion between strings and paths, validate to check # that each item contains at least a "/" (#19098) if "/" not in definition: raise TankError( "The template %s (%s) does not seem to be a valid path. A valid " "path needs to contain at least one '/' character. Perhaps this " "template should be in the strings section " "instead?" % (template_name, definition) ) root_path = all_per_platform_roots.get(root_name, {}).get(sgsix.platform) if root_path is None: raise TankError( "Undefined PTR storage! The local file storage '%s' is not defined for this " "operating system." % root_name ) template_path = TemplatePath( definition, keys, root_path, template_name, all_per_platform_roots[root_name], ) template_paths[template_name] = template_path return template_paths def make_template_strings(data, keys, template_paths): """ Factory function which creates TemplateStrings. :param data: Data from which to construct the template strings. :type data: Dictionary of form: {<template name>: {<option>: <option value>}} :param keys: Available keys. :type keys: Dictionary of form: {<key name> : <TemplateKey object>} :param template_paths: TemplatePaths available for optional validation. :type template_paths: Dictionary of form: {<template name>: <TemplatePath object>} :returns: Dictionary of form {<template name> : <TemplateString object>} """ template_strings = {} templates_data = _process_templates_data(data, "path") for template_name, template_data in templates_data.items(): definition = template_data["definition"] validator_name = template_data.get("validate_with") validator = template_paths.get(validator_name) if validator_name and not validator: msg = "Template %s validate_with is set to undefined template %s." raise TankError(msg % (template_name, validator_name)) template_string = TemplateString( definition, keys, template_name, validate_with=validator ) template_strings[template_name] = template_string return template_strings def _conform_template_data(template_data, template_name): """ Takes data for single template and conforms it expected data structure. """ if isinstance(template_data, six.string_types): template_data = {"definition": template_data} elif not isinstance(template_data, dict): raise TankError( "template %s has data which is not a string or dictionary." % template_name ) if "definition" not in template_data: raise TankError("Template %s missing definition." % template_name) return template_data def _process_templates_data(data, template_type): """ Conforms templates data and checks for duplicate definitions. :param data: Dictionary in form { <template name> : <data> } :param template_type: path or string :returns: Processed data. """ templates_data = {} # Track definition to detect duplicates definitions = {} for template_name, template_data in data.items(): cur_data = _conform_template_data(template_data, template_name) definition = cur_data["definition"] if template_type == "path": root_name = cur_data.get("root_name") else: root_name = None # Record this templates definition cur_key = (root_name, definition) definitions[cur_key] = definitions.get(cur_key, []) + [template_name] templates_data[template_name] = cur_data dups_msg = "" for (root_name, definition), template_names in definitions.items(): if len(template_names) > 1: # We have a duplicate dups_msg += "%s: %s\n" % (", ".join(template_names), definition) if dups_msg: raise TankError( "It looks like you have one or more " "duplicate entries in your templates.yml file. Each template path that you " "define in the templates.yml file needs to be unique, otherwise toolkit " "will not be able to resolve which template a particular path on disk " "corresponds to. The following duplicate " "templates were detected:\n %s" % dups_msg ) return templates_data