diff --git a/abx/enum.py b/abx/enum.py new file mode 100644 index 0000000..ef6f595 --- /dev/null +++ b/abx/enum.py @@ -0,0 +1,77 @@ +# enum.py +""" +A custom enumeration type that supports ordering and Blender enum UI requirements. +""" + + +class Enum(dict): + """ + List of options defined in a two-way dictionary. + """ + def __init__(self, *options): + """ + Args: + *options (list): a list of strings to be used as enumerated values. + """ + for i, option in enumerate(options): + if isinstance(option, list) or isinstance(option, tuple): + name = option[0] + self[i] = tuple(option) + else: + name = str(option) + self[i] = (option, option, option) + self[name] = i + if name not in ('name', 'number', 'options'): + setattr(self, name, i) + + @property + def options(self): + """ + Gives the options in a Blender-friendly format. + + Returns: + A list of triples containing the three required fields for + Blender's bpy.props.EnumProperty. + + If the Enum was initialized with strings, the options will + contain the same string three times. If initialized with + tuples of strings, they will be used unaltered. + """ + options = [] + number_keys = sorted([k for k in self.keys() if type(k) is int]) + return [self[i] for i in number_keys] + + def name(self, n): + """ + Return the name (str) value of enum, regardless of which is provided. + + Args: + n (str, int): An enum value (either number or string). + + Returns: + Returns a string if n is recognized. Returns None if not. + """ + if type(n) is int: + return self[n][0] + elif type(n) is str: + return n + else: + return None + + def number(self, n): + """ + Return the number (int) value of enum, regardless of which is provided. + + Args: + n (str, int): An enum value (either number or string). + + Returns: + Returns a number if n is recognized. Returns None if not. + """ + if type(n) is str: + return self[n] + elif type(n) is int: + return n + else: + return None + diff --git a/abx/file_context.py b/abx/file_context.py index 3edd646..af5caa7 100644 --- a/abx/file_context.py +++ b/abx/file_context.py @@ -17,42 +17,6 @@ overriding the top-level ones. @contact: digitante@gmail.com -Demo: ->>> ->>> fc = FileContext(TESTPATH) - ->>> fc.notes -['Data from implicit + explicit sources'] - ->>> fc['project']['name'] -'My Project' - ->>> fc['episode']['code'] -1 - ->>> fc['rank'] -'block' - ->>> fc['block']['title'] -'Beginning Of End' - ->>> fc['seq']['title'] -'LastPoint' - ->>> fc['episode']['title'] -'Pilot' ->>> fc['hierarchy'] -'episode' - ->>> fc['filename'] -'A.001-LP-1-BeginningOfEnd-anim.txt' - ->>> fc['path'] -'/project/terry/Dev/eclipse-workspace/ABX/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.txt' - ->>> fc.root -'/project/terry/Dev/eclipse-workspace/ABX/testdata/myproject' - """ import os, re, copy, string, collections @@ -68,1097 +32,17 @@ TESTPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'testda from . import accumulate from .accumulate import RecursiveDict +from .enum import Enum +from .ranks import RankNotFound -wordre = re.compile(r'([A-Z][a-z]+|[a-z]+|[0-9]+|[A-Z][A-Z]+)') +from .parsers import NameParsers -class Enum(dict): - """ - List of options defined in a two-way dictionary. - """ - def __init__(self, *options): - """ - Args: - *options (list): a list of strings to be used as enumerated values. - """ - for i, option in enumerate(options): - if isinstance(option, list) or isinstance(option, tuple): - name = option[0] - self[i] = tuple(option) - else: - name = str(option) - self[i] = (option, option, option) - self[name] = i - if name not in ('name', 'number', 'options'): - setattr(self, name, i) - - @property - def options(self): - """ - Gives the options in a Blender-friendly format. - - Returns: - A list of triples containing the three required fields for - Blender's bpy.props.EnumProperty. - - If the Enum was initialized with strings, the options will - contain the same string three times. If initialized with - tuples of strings, they will be used unaltered. - """ - options = [] - number_keys = sorted([k for k in self.keys() if type(k) is int]) - return [self[i] for i in number_keys] - - def name(self, n): - """ - Return the name (str) value of enum, regardless of which is provided. - - Args: - n (str, int): An enum value (either number or string). - - Returns: - Returns a string if n is recognized. Returns None if not. - """ - if type(n) is int: - return self[n][0] - elif type(n) is str: - return n - else: - return None - - def number(self, n): - """ - Return the number (int) value of enum, regardless of which is provided. - - Args: - n (str, int): An enum value (either number or string). - - Returns: - Returns a number if n is recognized. Returns None if not. - """ - if type(n) is str: - return self[n] - elif type(n) is int: - return n - else: - return None - log_level = Enum('DEBUG', 'INFO', 'WARNING', 'ERROR') - + +from .name_schema import FieldSchema -NameParsers = {} # Parser registry - -def registered_parser(parser): - """ - Decorator function to register a parser class. - """ - NameParsers[parser.name] = parser - return parser - -@registered_parser -class Parser_ABX_Episode: - """ - Default filename parsing algorithm. - - Assumes field-based filenames of the form: - - E[-[-[-Cam][-]]][-]-<role>.<filetype> - - Where the <field> indicates fields with fieldnames, and there are three expected separators: - - - is the 'field_separator' - E is the 'episode_separator' - . is the 'filetype_separator' - - (These can be overridden in the initialization). - The class is callable, taking a string as input and returning a dictionary of fields. - """ - name = 'abx_episode' - - max_score = 10 # Maximum number of fields parsed - - # supported values for filetype - filetypes = { - 'blend': "Blender File", - 'kdenlive': "Kdenlive Video Editor File", - 'mlt': "Kdenlive Video Mix Script", - 'svg': "Scalable Vector Graphics (Inkscape)", - 'kra': "Krita Graphic File", - 'xcf': "Gimp Graphic File", - 'png': "Portable Network Graphics (PNG) Image", - 'jpg': "Joint Photographic Experts Group (JPEG) Image", - 'aup': "Audacity Project", - 'ardour': "Ardour Project", - 'flac': "Free Lossless Audio Codec (FLAC)", - 'mp3': "MPEG Audio Layer III (MP3) Audio File", - 'ogg': "Ogg Vorbis Audio File", - 'avi': "Audio Video Interleave (AVI) Video Container", - 'mkv': "Matroska Video Container", - 'mp4': "Moving Picture Experts Group (MPEG) 4 Format}", - 'txt': "Plain Text File" - } - - # Roles that make sense in an episode context - roles = { - 'extras': "Extras, crowds, auxillary animated movement", - 'mech': "Mechanical animation", - 'anim': "Character animation", - 'cam': "Camera direction", - 'vfx': "Visual special effects", - 'compos': "Compositing", - 'bkg': "Background 2D image", - 'bb': "Billboard 2D image", - 'tex': "Texture 2D image", - 'foley': "Foley sound", - 'voice': "Voice recording", - 'fx': "Sound effects", - 'music': "Music track", - 'cue': "Musical cue", - 'amb': "Ambient sound", - 'loop': "Ambient sound loop", - 'edit': "Video edit" - } - - # A few filetypes imply their roles: - roles_by_filetype = { - 'kdenlive': 'edit', - 'mlt': 'edit' - } - - - def __init__(self, field_separator='-', episode_separator='E', filetype_separator='.', - fields=None, filetypes=None, roles=None, **kwargs): - if not fields: - fields = {} - if filetypes: - self.filetypes = copy.deepcopy(self.filetypes) # Copy class attribute to instance - self.filetypes.update(filetypes) # Update with new values - if roles: - self.roles = copy.deepcopy(self.roles) # Copy class attribute to instance - self.roles.update(roles) # Update with new values - self.field_separator = field_separator - self.episode_separator = episode_separator - self.filetype_separator = filetype_separator - - def __call__(self, filename, namepath): - score = 0.0 - fielddata = {} - - # Check for filetype ending - i_filetype = filename.rfind(self.filetype_separator) - if i_filetype < 0: - fielddata['filetype'] = None - else: - fielddata['filetype'] = filename[i_filetype+1:] - filename = filename[:i_filetype] - score = score + 1.0 - - components = filename.split(self.field_separator) - - # Check for role marker in last component - if components[-1] in self.roles: - fielddata['role'] = components[-1] - del components[-1] - fielddata['hierarchy'] = 'episode' - score = score + 2.0 - elif fielddata['filetype'] in self.roles_by_filetype: - fielddata['role'] = self.roles_by_filetype[fielddata['filetype']] - fielddata['hierarchy'] = 'episode' - else: - fielddata['role'] = None - fielddata['hierarchy'] = None - - # Check for a descriptive title (must be 3+ characters in length) - if components and len(components[-1])>2: - # Normalize the title as words with spaces - title = ' '.join(w for w in wordre.split(components[-1]) if wordre.fullmatch(w)) - del components[-1] - score = score + 1.0 - else: - title = None - - # Check if first field contains series/episode number - if components: - prefix = components[0] - try: - fielddata['series'] = {} - fielddata['episode'] = {} - fielddata['series']['code'], episode_id = prefix.split(self.episode_separator) - fielddata['episode']['code'] = int(episode_id) - fielddata['rank'] = 'episode' - del components[0] - score = score + 2.0 - except: - pass - - # Check for sequence/block/shot/camera designations - if components: - fielddata['seq'] = {} - fielddata['seq']['code'] = components[0] - fielddata['rank'] = 'seq' - del components[0] - score = score + 1.0 - - if components: - try: - fielddata['block'] = {} - fielddata['block']['code'] = int(components[0]) - del components[0] - fielddata['rank'] = 'block' - score = score + 1.0 - except: - pass - - if components and components[0].startswith('Cam'): - fielddata['camera'] = {} - fielddata['camera']['code'] = components[0][len('Cam'):] - fielddata['rank'] = 'camera' - del components[0] - score = score + 1.0 - - if components: - # Any remaining structure is joined back to make the shot ID - fielddata['shot'] = {} - fielddata['shot']['code'] = ''.join(components) - fielddata['rank'] = 'shot' - components = None - score = score + 1.0 - - if title and fielddata['rank'] in fielddata: - fielddata[fielddata['rank']]['title'] = title - - return score/self.max_score, fielddata - -@registered_parser -class Parser_ABX_Schema(object): - """ - Parser based on using the list of schemas. - - The schemas are normally defined in the project root directory YAML. - """ - name = 'abx_schema' - - def __init__(self, schemas=None, definitions=None, - filetype_separator = '.', - comment_separator = '--', - role_separator = '-', - title_separator = '-', - **kwargs): - - self.filetype_separator = filetype_separator - self.comment_separator = comment_separator - self.role_separator = role_separator - self.title_separator = title_separator - - self.schemas = schemas - - if 'roles' in definitions: - self.roles = definitions['roles'] - else: - self.roles = [] - - if 'filetypes' in definitions: - self.filetypes = definitions['filetypes'] - else: - self.filetypes = [] - - if 'roles_by_filetype' in definitions: - self.roles_by_filetype = definitions['roles_by_filetype'] - else: - self.roles_by_filetype = [] - - def _parse_ending(self, filename, separator): - try: - remainder, suffix = filename.rsplit(separator, 1) - score = 1.0 - except ValueError: - remainder = filename - suffix = None - score = 0.0 - return (suffix, remainder, score) - - def _parse_beginning(self, filename, separator): - try: - prefix, remainder = filename.split(separator, 1) - score = 1.0 - except ValueError: - prefix = filename - remainder = '' - score = 0.0 - return (prefix, remainder, score) - - def __call__ (self, filename, namepath, debug=False): - fields = {} - score = 0.0 - possible = 0.0 - - # First get specially-handled extensions - remainder = filename - field, newremainder, s = self._parse_ending(remainder, self.filetype_separator) - if field and field in self.filetypes: - remainder = newremainder - fields['filetype'] = field - score += s*1.0 - else: - fields['filetype'] = None - - field, remainder, s = self._parse_ending(remainder, self.comment_separator) - fields['comment'] = field - score += s*0.5 - - field, newremainder, s = self._parse_ending(remainder, self.role_separator) - if field and field in self.roles: - remainder = newremainder - fields['role'] = field - score += s*0.5 - else: - fields['role'] = None - - field, remainder, s = self._parse_ending(remainder, self.title_separator) - fields['title'] = field - score += s*0.5 - - possible += 3.0 - - # Implicit roles - if ( not fields['role'] and - fields['filetype'] and - fields['role'] in self.roles_by_filetype): - self.role = self.roles_by_filetype[fields['filetype']] - score += 0.2 - - #possible += 0.2 - - # Figure the rest out from the schema - # Find the matching rank start position for the filename - start = 0 - for start, (schema, name) in enumerate(zip(self.schemas, namepath)): - field, r, s = self._parse_beginning(remainder, schema.delimiter) - try: - if field.lower() == schema.format.format(name).lower(): - score += 1.0 - break - except ValueError: - print(' (365) field, format', field, schema.format) - - possible += 1.0 - - # Starting from that position, try to match fields - # up to the end of the namepath (checking against it) - irank = 0 - for irank, (schema, name) in enumerate( - zip(self.schemas[start:], namepath[start:])): - if not remainder: break - field, remainder, s = self._parse_beginning(remainder, schema.delimiter) - score += s - try: - if ( type(field) == str and - field.lower() == schema.format.format(name).lower()): - fields[schema.rank]={'code':field} - fields['rank'] = schema.rank - score += 1.0 - except ValueError: - print(' (384) field, format', field, schema.format) - possible += 2.0 - - # Remaining fields are authoritative (doesn't affect score) - for schema in self.schemas[irank:]: - if not remainder: break - field, remainder, s = self._parse_beginning(remainder, schema.delimiter) - fields[schema.rank]={'code':field} - fields['rank'] = schema.rank - - if 'rank' in fields: - fields[fields['rank']]['title'] = fields['title'] - - if not fields['role'] and fields['filetype'] in self.roles_by_filetype: - fields['role'] = self.roles_by_filetype[fields['filetype']] - - return score/possible, fields - -@registered_parser -class Parser_ABX_Fallback(object): - """ - Highly-tolerant parser to fall back to if others fail. - - Makes very minimal assumptions about filename structure. - """ - name = 'abx_fallback' - - filetypes = DEFAULT_YAML['definitions']['filetypes'] - roles = DEFAULT_YAML['definitions']['roles'] - roles_by_filetype = ( - DEFAULT_YAML['definitions']['roles_by_filetype']) - - main_sep_re = re.compile(r'\W+') # Any single non-word char - comment_sep_re = re.compile(r'[\W_][\W_]+|[~#$!=+&]+') - - - def __init__(self, **kwargs): - pass - - def _parse_ending(self, filename, separator): - try: - remainder, suffix = filename.rsplit(separator, 1) - score = 1.0 - except ValueError: - remainder = filename - suffix = None - score = 0.0 - return (suffix, remainder, score) - - def __call__(self, filename, namepath): - fields = {} - score = 1.0 - possible = 4.5 - - split = filename.rsplit('.', 1) - if len(split)<2 or split[1] not in self.filetypes: - fields['filetype'] = None - remainder = filename - score += 1.0 - else: - fields['filetype'] = split[1] - remainder = split[0] - - comment_match = self.comment_sep_re.search(remainder) - if comment_match: - fields['comment'] = remainder[comment_match.end():] - remainder = remainder[:comment_match.start()] - else: - fields['comment'] = None - - role = self.main_sep_re.split(remainder)[-1] - if role in self.roles: - fields['role'] = role - remainder = remainder[:-1-len(role)] - score += 1.0 - else: - fields['role'] = None - - # Implied role - if fields['filetype'] in self.roles_by_filetype: - fields['role'] = self.roles_by_filetype[fields['filetype']] - score += 1.0 - - words = self.main_sep_re.split(remainder) - fields['code'] = ''.join([w.capitalize() for w in words]) - fields['title'] = remainder - - return score/possible, fields - - - -class RankNotFound(LookupError): - """ - Error returned if an unexpected 'rank' is encountered. - """ - pass - -class NameSchema(object): - """ - Represents a schema used for parsing and constructing names. - - We need naming information in various formats, based on knowledge about - the role of the Blender file and scene in the project. This object tracks - this information and returns correct names based on it via properties. - - Note that NameSchema is NOT an individual project unit name, but a defined - pattern for how names are treat at that level in the project. It is a class - of names, not a name itself. Thus "shot" has a schema, but is distinct from - "shot A" which is a particular "project unit". The job of the schema is - to tell us things like "shots in this project will be represented by - single capital letters". - - See NameContext for the characteristics of a particular unit. - - Attributes: - codetype (type): Type of code name used for this rank. - Usually it will be int, str, or Enum. - Pre-defined enumerations are available - for uppercase letters (_letters) and - lowercase letters (_lowercase) (Roman -- - in principle, other alphabets could be added). - - rank (int): Rank of hierarchy under project (which is 0). The - rank value increases as you go "down" the tree. - Sorry about that confusion. - - ranks (list(Enum)): List of named ranks known to schema (may include - both higher and lower ranks). - - parent (NameSchema|None): - Earlier rank to which this schema is attached. - - format (str): Code for formatting with Python str.format() method. - Optional: format can also be specified with the - following settings, or left to default formatting. - - pad (str): Padding character. - minlength (int): Minimum character length (0 means it may be empty). - maxlength (int): Maximum character length (0 means no limit). - - words (bool): Treat name/title fields like a collection of words, - which can then be represented using "TitleCaps" or - "underscore_spacing", etc for identifier use. - - delimiter (str): Field delimiter marking the end of this ranks' - code in designations. Note this is the delimiter - after this rank - the higher (lower value) rank - controls the delimiter used before it. - - default: The default value for this rank. May be None, - in which case, the rank will be treated as unset - until a setting is made. The UI must provide a - means to restore the unset value. Having no values - set below a certain rank is how a NameContext's - rank is determined. - - Note that the rank may go back to a lower value than the schema's - parent object in order to overwrite earlier schemas (for overriding a - particular branch in the project) or (compare this to the use of '..' - in operating system paths). Or it may skip a rank, indicating an - implied intermediate value, which will be treated as having a fixed - value. (I'm not certain I want that, but it would allow us to keep - rank numbers synchronized better in parallel hierarchies in a project). - - Note that schemas can be overridden at any level in a project by - 'project_schema' directives in unit YAML files, so it is possible to - change the schema behavior locally. By design, only lower levels in - the hierarchy (higher values of rank) can be affected by overrides. - - This kind of use isn't fully developed yet, but the plan is to include - things like managing 'Library' assets with a very different structure - from shot files. This way, the project can split into 'Library' and - 'Episode' forks with completely different schemas for each. - """ - # Defaults - _default_schema = { - 'delimiter':'-', - - 'type': 'string', - 'format':'{:s}', - 'minlength':1, # Must be at least one character - 'maxlength':0, # 0 means unlimited - 'words': False, # If true, treat value as words and spaces - 'pad': '0', # Left-padding character for fixed length - 'default': None, - - 'rank': 'project', - 'irank': 0, - 'ranks': ('series', 'episode', 'sequence', - 'block', 'camera', 'shot', 'element') - } - - # Really this is more like a set than a dictionary right now, but I - # thought I might refactor to move the definitions into the dictionary: - _codetypes = { - 'number':{}, - 'string':{}, - 'letter':{}, - 'lowercase':{}, - } - - _letter = tuple((A,A,A) for A in string.ascii_uppercase) - _lowercase = tuple((a,a,a) for a in string.ascii_lowercase) - - rank = 'project' - irank = 0 - default = None - - ranks = ('project',) - - def __init__(self, parent=None, rank=None, schema=None, debug=False): - """ - Create a NameSchema from schema data source. - - NameSchema is typically initialized based on data from YAML files - within the project. This allows us to avoid encoding project structure - into ABX, leaving how units are named up to the production designer. - - If you want our suggestions, you can look at the "Lunatics!" project's - 'lunatics.yaml' file, or the 'myproject.yaml' file in the ABX source - distribution. - - Arguments: - parent (NameSchema): - The level in the schema hierarchy above this one. - Should be None if this is the top. - - rank (int): The rank of this schema to be created. - - schema (dict): Data defining the schema, typically loaded from a - YAML file in the project. - - debug (bool): Used only for testing. Turns on some verbose output - about internal implementation. - - Note that the 'rank' is specified because it may NOT be sequential from - the parent schema. - """ - # Three types of schema data: - - # Make sure schema is a copy -- no side effects! - if not schema: - schema = {} - else: - s = {} - s.update(schema) - schema = s - - if not rank and 'rank' in schema: - rank = schema['rank'] - - # Stepped down in rank from parent: - self.parent = parent - - if parent and rank: - # Check rank is defined in parent ranks and use that - # We can skip optional ranks - if rank in parent.ranks: - j = parent.ranks.index(rank) - self.ranks = parent.ranks[j+1:] - self.rank = rank - else: - # It's an error to ask for a rank that isn't defined - raise RankNotFound( - '"%s" not in defined ranks for "%s"' % (rank, parent)) - - elif parent and not rank: - # By default, get the first rank below parent - self.rank = parent.ranks[0] - self.ranks = parent.ranks[1:] - - elif rank and not parent: - # With no parent, we're starting a new tree and renaming the root - self.rank = rank - self.ranks = self._default_schema['ranks'] - - else: # not rank and not parent: - # New tree with default rank - self.rank = self._default_schema['rank'] - self.ranks = self._default_schema['ranks'] - - # Directly inherited/acquired from parent - # So far, only need a delimiter specified, but might be other stuff - self.delimiter = self._default_schema['delimiter'] - if parent and parent.delimiter: self.delimiter = parent.delimiter - - # Explicit override by the new schema: - if 'ranks' in schema: self.ranks = schema['ranks'] - if 'delimiter' in schema: self.delimiter = schema['delimiter'] - if 'default' in schema: - if schema['default'] == 'None': - self.default = None - else: - self.default = schema['default'] - - # Default unless specified (i.e. not inherited from parent) - newschema = {} - newschema.update(self._default_schema) - newschema.update(schema) - - self.format = str(newschema['format']) - - self.minlength = int(newschema['minlength']) - self.maxlength = int(newschema['maxlength']) - self.pad = str(newschema['pad']) - self.words = bool(newschema['words']) - - if newschema['type'] == 'letter': - self.codetype = self._letter - - elif newschema['type'] == 'lowercase': - self.codetype = self._lowercase - - elif newschema['type'] == 'number': - # Recognized Python types - self.codetype = int - if 'minlength' or 'maxlength' in schema: - self.format = '{:0>%dd}' % self.minlength - - elif newschema['type'] == 'string': - self.codetype = str - - if ('minlength' in schema) or ('maxlength' in schema): - if self.maxlength == 0: - # Special case for unlimited length - self.format = '{:%1.1s>%ds}' % (self.pad, self.minlength) - self.format = '{:%1.1s>%d.%ds}' % ( - self. pad, self.minlength, self.maxlength) - - elif newschema['type'] == 'bool': - self.codetype = bool - - elif isinstance(newschema['type'], collections.Sequence): - # Enumerated types - # This is somewhat specific to Blender -- setting the - # enumeration values requires a sequence in a particular format - self.codetype = [] - for option in newschema['type']: - if type(option) is not str and isinstance(option, collections.Sequence): - option = tuple([str(e) for e in option][:3]) - else: - option = (str(option), str(option), str(option)) - self.codetype.append(option) - - elif isinstance(newschema['type'], collections.Mapping): - self.codetype = [] - for key, val in newschema['type'].items(): - if type(val) is not str and isinstance(val, collections.Sequence): - if len(val) == 0: - option = (str(key), str(key), str(key)) - elif len(val) == 1: - option = (str(key), str(val[0]), str(val[0])) - else: - option = (str(key), str(val[0]), str(val[1])) - else: - option = (str(key), str(val), str(val)) - self.codetype.append(option) - else: - # If all else fails - self.codetype = None - - def __repr__(self): - return('<(%s).NameSchema: %s (%s, %s, %s, (%s))>' % ( - repr(self.parent), - #self.irank, - self.rank, - self.delimiter, - self.default, - self.format, - self.codetype - )) - - -class NameContext(object): - """ - Single naming context within the file (e.g. a Blender scene). - - NameContext defines the characteristics of any particular project - unit (taxon) within the project. So, for example, it may represent - the context of an "Episode" or a "Sequence" or a "Shot". - - Used in Blender, it will typically be used in two ways: one to represent - the entire file (as the base class for FileContext) and one to represent - a particular Blender scene within the file, which may represent a single - shot, multiple shots with the same camera, or perhaps just an element - of a compositing shot created from multiple Blender scenes. - - Examples of all three uses occur in "Lunatics!" episode 1: the - "Press Conference" uses multicam workflow, with scenes for each camera - with multiple shots selected from the timeline, using the VSE; most scenes - are done 'single camera' on a shot-per-scene basis; but some shots use a - 'Freestyle camera clipping' technique which puts the line render in a - separate (but linked) scene, while the final shot in the episode combines - three different Blender scenes in a 2D composite, to effect a smooth - transition to the titles. - - Attributes: - container (NameContext): - The project unit that contains this one. One step - up the tree, but not necessarily the next step up - in rank (because there can be skipped ranks). - - schemas (list(NameSchema)): - The schema list as seen by this unit, taking into - account any schema overrides. - - namepath_segment (list): - List of namepath codes defined in this object, not - including the container's namepath. (Implementation) - - omit_ranks dict(str:int): - How many ranks to omit from the beginning in shortened - names for specific uses. (Implementation). - Probably a mistake this isn't in the NameSchema instead. - - fields (dict): The field values used to initialize the NameContext, - May include some details not defined in this attribution - API, and it includes the raw state of the 'name', 'code', - and 'title' fields, determining which are - authoritative -- i.e. fields which aren't specified are - left to 'float', being generated by the related ones. - Thus, name implies title, or title implies name. You - can have one or the other or both, but the other will be - generated from the provided one if it isn't specified. - (Implementation). - - code (str|int|Enum): - Identification code for the unit (I replaced 'id' in earlier - versions, because 'id' is a reserved word in Python for Python - memory reference / pointer identity. - (R/W Property). - - namepath (list): - List of codes for project units above this one. - (R/O Property, generated from namepath_segment and - container). - - rank (int): Rank of this unit (same as in Schema). - (R/W Property, may affect other attributes). - - name (str): Short name for the unit. - (R/W Property, may affect title). - - title (str): Full title for the unit. - (R/W Property, may affect name). - - designation (str): - Full designation for the unit, including all - the namepath elements, but no title. - - fullname (str): The full designation, plus the current unit name. - - shortname (str):Abbreviated designation, according to omit_ranks, - with name. - - - - - - - """ - - def __init__(self, container, fields=None, namepath_segment=(), ): - self.clear() - if container or fields or namepath_segment: - self.update(container, fields, namepath_segment) - - def clear(self): - self.fields = {} - self.schemas = ['project'] - self.rank = 0 - self.code = 'untitled' - self.container = None - self.namepath_segment = [] - - def update(self, container=None, fields=None, namepath_segment=()): - self.container = container - - if namepath_segment: - self.namepath_segment = namepath_segment - else: - self.namepath_segment = [] - - try: - self.schemas = self.container.schemas - except AttributeError: - self.schemas = [] - - try: - self.omit_ranks = self.container.omit_ranks - except AttributeError: - self.omit_ranks = {} - self.omit_ranks.update({ - 'edit': 0, - 'render': 1, - 'filename': 1, - 'scene': 3}) - - if fields: - if isinstance(fields, dict): - self.fields.update(fields) - elif isinstance(fields, str): - self.fields.update(yaml.safe_load(fields)) - - def update_fields(self, data): - self.fields.update(data) - - def _load_schemas(self, schemas, start=0): - """ - Load schemas from a list of schema dictionaries. - - @schemas: list of dictionaries containing schema field data (see NameSchema). - The data will typically be extracted from YAML, and is - expected to be a list of dictionaries, each of which defines - fields understood by the NameSchema class, to instantiate - NameSchema objects. The result is a linked chain of schemas from - the top of the project tree down. - - @start: if a start value is given, the top of the existing schema - chain is kept, and the provided schemas starts under the rank of - the start level in the existing schema. This is what happens when - the schema is locally overridden at some point in the hierarchy. - """ - self.schemas = self.schemas[:start] - if self.schemas: - last = self.schemas[-1] - else: - last = None - for schema in schemas: - self.schemas.append(NameSchema(last, schema['rank'], schema=schema)) - #last = self.schemas[-1] - - def _parse_words(self, wordtext): - words = [] - groups = re.split(r'[\W_]', wordtext) - for group in groups: - if len(group)>1: - group = group[0].upper() + group[1:] - words.extend(re.findall(r'[A-Z][a-z]*', group)) - elif len(group)==1: - words.append(group[0].upper()) - else: - continue - return words - - def _cap_words(self, words): - return ''.join(w.capitalize() for w in words) - - def _underlower_words(self, words): - return '_'.join(w.lower() for w in words) - - def _undercap_words(self, words): - return '_'.join(w.capitalize() for w in words) - - def _spacecap_words(self, words): - return ' '.join(w.capitalize() for w in words) - - def _compress_name(self, name): - return self._cap_words(self._parse_words(name)) - - @property - def namepath(self): - if self.container: - return self.container.namepath + self.namepath_segment - else: - return self.namepath_segment - - @property - def rank(self): - if 'rank' in self.fields: - return self.fields['rank'] - else: - return None - - @rank.setter - def rank(self, rank): - self.fields['rank'] = rank - - @property - def name(self): - if 'name' in self.fields: - return self.fields['name'] - elif 'title' in self.fields: - return self._compress_name(self.fields['title']) -# elif 'code' in self.fields: -# return self.fields['code'] - else: - return '' - - @name.setter - def name(self, name): - self.fields['name'] = name - - @property - def code(self): - if self.rank: - return self.fields[self.rank]['code'] - else: - return self.fields['code'] - - @code.setter - def code(self, code): - if self.rank: - self.fields[self.rank] = {'code': code} - else: - self.fields['code'] = code - - @property - def description(self): - if 'description' in self.fields: - return self.fields['description'] - else: - return '' - - @description.setter - def description(self, description): - self.fields['description'] = str(description) - - def _get_name_components(self): - components = [] - for code, schema in zip(self.namepath, self.schemas): - if code is None: continue - components.append(schema.format.format(code)) - components.append(schema.delimiter) - return components[:-1] - - @property - def fullname(self): - if self.name: - return (self.designation + - self.schemas[-1].delimiter + - self._compress_name(self.name) ) - else: - return self.designation - - @property - def designation(self): - return ''.join(self._get_name_components()) - - @property - def shortname(self): - namebase = self.omit_ranks['filename']*2 - return (''.join(self._get_name_components()[namebase:]) + - self.schemas[-1].delimiter + - self._compress_name(self.name)) - - def get_scene_name(self, suffix=''): - """ - Create a name for the current scene, based on namepath. - - Arguments: - suffix (str): Optional suffix code used to improve - identifications of scenes. - """ - namebase = self.omit_ranks['scene']*2 - desig = ''.join(self._get_name_components()[namebase:]) - - if suffix: - return desig + ' ' + suffix - else: - return desig - - def get_render_path(self, suffix='', framedigits=5, ext='png'): - """ - Create a render filepath, based on namepath and parameters. - - Arguments: - suffix (str): - Optional unique code (usually for render profile). - - framedigits (int): - How many digits to reserve for frame number. - - ext (str): - Filetype extension for the render. - - This is meant to be called by render_profile to combine namepath - based name with parameters for the specific render, to uniquely - idenfify movie or image-stream output. - """ - desig = ''.join(self._get_name_components()[self.omit_ranks['render']+1:]) - - if ext in ('avi', 'mov', 'mp4', 'mkv'): - if suffix: - path = os.path.join(self.render_root, suffix, - desig + '-' + suffix + '.' + ext) - else: - path = os.path.join(self.render_root, ext.upper(), - desig + '.' + ext) - else: - if suffix: - path = os.path.join(self.render_root, - suffix, desig, - desig + '-' + suffix + '-f' + '#'*framedigits + '.' + ext) - else: - path = os.path.join(self.render_root, - ext.upper(), desig, - desig + '-f' + '#'*framedigits + '.' + ext) - return path - - +from .name_context import NameContext class FileContext(NameContext): """ @@ -1172,7 +56,7 @@ class FileContext(NameContext): For this, you will need to have a <project>.yaml file which defines the 'project_schema' (a list of dictionaries used to initialize a list - of NameSchema objects). Examples of <project>.yaml are provided in the + of FieldSchema objects). Examples of <project>.yaml are provided in the 'myproject.yaml' file in the test data in the source distribution of ABX, and you can also see a "live" example in the "Lunatics!" project. @@ -1459,12 +343,29 @@ class FileContext(NameContext): 'scene': 3}) # Data from Parsing the File Name - try: - self.parsers = [NameParsers[self.provided_data['definitions']['parser']](**self.schema['filenames'])] - except (TypeError, KeyError, IndexError): - self.parsers = [ - #Parser_ABX_Episode(), - Parser_ABX_Schema(self.schemas, self.provided_data['definitions'])] + if ( 'parser' in self.provided_data['definitions'] and + self.provided_data['definitions']['parser'] in NameParsers): + # If project has defined what parser it wants (and it is registered), + # then restrict to that parser: + parser_selection = [self.provided_data['definitions']['parser']] + else: + parser_selection = NameParsers.keys() + + if 'parser_options' in self.provided_data['definitions']: + parser_options = self.provided_data['definitions']['parser_options'] + else: + parser_options = {} + + # TESTING: + # Previous code locked-in the schema parser, so I'm starting with that: + parser_selection = ['abx_schema'] + + self.parsers = [NameParsers[p]( + schemas = self.schemas, + definitions = self.provided_data['definitions'], + **parser_options) + for p in parser_selection] + parser_chosen, parser_score = self._parse_filename() self.log(log_level.INFO, "Parsed with %s, score: %d" % diff --git a/abx/name_context.py b/abx/name_context.py new file mode 100644 index 0000000..a945bdc --- /dev/null +++ b/abx/name_context.py @@ -0,0 +1,337 @@ +# name_context.py +""" +NameContext defines the context of a particular named asset. + +Examples include scenes in a Blender file, but could be applied to other things. +Also a base class for FileContext which applies to the whole file. + +NameContext handles the unified needs of these types, and in particular, +hosts the methods used to generate names. +""" + +import os, re + +import yaml + +from .name_schema import FieldSchema + +class NameContext(object): + """ + Single naming context within the file (e.g. a Blender scene). + + NameContext defines the characteristics of any particular project + unit (taxon) within the project. So, for example, it may represent + the context of an "Episode" or a "Sequence" or a "Shot". + + Used in Blender, it will typically be used in two ways: one to represent + the entire file (as the base class for FileContext) and one to represent + a particular Blender scene within the file, which may represent a single + shot, multiple shots with the same camera, or perhaps just an element + of a compositing shot created from multiple Blender scenes. + + Examples of all three uses occur in "Lunatics!" episode 1: the + "Press Conference" uses multicam workflow, with scenes for each camera + with multiple shots selected from the timeline, using the VSE; most scenes + are done 'single camera' on a shot-per-scene basis; but some shots use a + 'Freestyle camera clipping' technique which puts the line render in a + separate (but linked) scene, while the final shot in the episode combines + three different Blender scenes in a 2D composite, to effect a smooth + transition to the titles. + + Attributes: + container (NameContext): + The project unit that contains this one. One step + up the tree, but not necessarily the next step up + in rank (because there can be skipped ranks). + + schemas (list(FieldSchema)): + The schema list as seen by this unit, taking into + account any schema overrides. + + namepath_segment (list): + List of namepath codes defined in this object, not + including the container's namepath. (Implementation) + + omit_ranks dict(str:int): + How many ranks to omit from the beginning in shortened + names for specific uses. (Implementation). + Probably a mistake this isn't in the FieldSchema instead. + + fields (dict): The field values used to initialize the NameContext, + May include some details not defined in this attribution + API, and it includes the raw state of the 'name', 'code', + and 'title' fields, determining which are + authoritative -- i.e. fields which aren't specified are + left to 'float', being generated by the related ones. + Thus, name implies title, or title implies name. You + can have one or the other or both, but the other will be + generated from the provided one if it isn't specified. + (Implementation). + + code (str|int|Enum): + Identification code for the unit (I replaced 'id' in earlier + versions, because 'id' is a reserved word in Python for Python + memory reference / pointer identity. + (R/W Property). + + namepath (list): + List of codes for project units above this one. + (R/O Property, generated from namepath_segment and + container). + + rank (int): Rank of this unit (same as in Schema). + (R/W Property, may affect other attributes). + + name (str): Short name for the unit. + (R/W Property, may affect title). + + title (str): Full title for the unit. + (R/W Property, may affect name). + + designation (str): + Full designation for the unit, including all + the namepath elements, but no title. + + fullname (str): The full designation, plus the current unit name. + + shortname (str):Abbreviated designation, according to omit_ranks, + with name. + """ + + def __init__(self, container, fields=None, namepath_segment=(), ): + self.clear() + if container or fields or namepath_segment: + self.update(container, fields, namepath_segment) + + def clear(self): + self.fields = {} + self.schemas = ['project'] + self.rank = 0 + self.code = 'untitled' + self.container = None + self.namepath_segment = [] + + def update(self, container=None, fields=None, namepath_segment=()): + self.container = container + + if namepath_segment: + self.namepath_segment = namepath_segment + else: + self.namepath_segment = [] + + try: + self.schemas = self.container.schemas + except AttributeError: + self.schemas = [] + + try: + self.omit_ranks = self.container.omit_ranks + except AttributeError: + self.omit_ranks = {} + self.omit_ranks.update({ + 'edit': 0, + 'render': 1, + 'filename': 1, + 'scene': 3}) + + if fields: + if isinstance(fields, dict): + self.fields.update(fields) + elif isinstance(fields, str): + self.fields.update(yaml.safe_load(fields)) + + def update_fields(self, data): + self.fields.update(data) + + def _load_schemas(self, schemas, start=0): + """ + Load schemas from a list of schema dictionaries. + + @schemas: list of dictionaries containing schema field data (see FieldSchema). + The data will typically be extracted from YAML, and is + expected to be a list of dictionaries, each of which defines + fields understood by the FieldSchema class, to instantiate + FieldSchema objects. The result is a linked chain of schemas from + the top of the project tree down. + + @start: if a start value is given, the top of the existing schema + chain is kept, and the provided schemas starts under the rank of + the start level in the existing schema. This is what happens when + the schema is locally overridden at some point in the hierarchy. + """ + self.schemas = self.schemas[:start] + if self.schemas: + last = self.schemas[-1] + else: + last = None + for schema in schemas: + self.schemas.append(FieldSchema(last, schema['rank'], schema=schema)) + #last = self.schemas[-1] + + def _parse_words(self, wordtext): + words = [] + groups = re.split(r'[\W_]', wordtext) + for group in groups: + if len(group)>1: + group = group[0].upper() + group[1:] + words.extend(re.findall(r'[A-Z][a-z]*', group)) + elif len(group)==1: + words.append(group[0].upper()) + else: + continue + return words + + def _cap_words(self, words): + return ''.join(w.capitalize() for w in words) + + def _underlower_words(self, words): + return '_'.join(w.lower() for w in words) + + def _undercap_words(self, words): + return '_'.join(w.capitalize() for w in words) + + def _spacecap_words(self, words): + return ' '.join(w.capitalize() for w in words) + + def _compress_name(self, name): + return self._cap_words(self._parse_words(name)) + + @property + def namepath(self): + if self.container: + return self.container.namepath + self.namepath_segment + else: + return self.namepath_segment + + @property + def rank(self): + if 'rank' in self.fields: + return self.fields['rank'] + else: + return None + + @rank.setter + def rank(self, rank): + self.fields['rank'] = rank + + @property + def name(self): + if 'name' in self.fields: + return self.fields['name'] + elif 'title' in self.fields: + return self._compress_name(self.fields['title']) +# elif 'code' in self.fields: +# return self.fields['code'] + else: + return '' + + @name.setter + def name(self, name): + self.fields['name'] = name + + @property + def code(self): + if self.rank: + return self.fields[self.rank]['code'] + else: + return self.fields['code'] + + @code.setter + def code(self, code): + if self.rank: + self.fields[self.rank] = {'code': code} + else: + self.fields['code'] = code + + @property + def description(self): + if 'description' in self.fields: + return self.fields['description'] + else: + return '' + + @description.setter + def description(self, description): + self.fields['description'] = str(description) + + def _get_name_components(self): + components = [] + for code, schema in zip(self.namepath, self.schemas): + if code is None: continue + components.append(schema.format.format(code)) + components.append(schema.delimiter) + return components[:-1] + + @property + def fullname(self): + if self.name: + return (self.designation + + self.schemas[-1].delimiter + + self._compress_name(self.name) ) + else: + return self.designation + + @property + def designation(self): + return ''.join(self._get_name_components()) + + @property + def shortname(self): + namebase = self.omit_ranks['filename']*2 + return (''.join(self._get_name_components()[namebase:]) + + self.schemas[-1].delimiter + + self._compress_name(self.name)) + + def get_scene_name(self, suffix=''): + """ + Create a name for the current scene, based on namepath. + + Arguments: + suffix (str): Optional suffix code used to improve + identifications of scenes. + """ + namebase = self.omit_ranks['scene']*2 + desig = ''.join(self._get_name_components()[namebase:]) + + if suffix: + return desig + ' ' + suffix + else: + return desig + + def get_render_path(self, suffix='', framedigits=5, ext='png'): + """ + Create a render filepath, based on namepath and parameters. + + Arguments: + suffix (str): + Optional unique code (usually for render profile). + + framedigits (int): + How many digits to reserve for frame number. + + ext (str): + Filetype extension for the render. + + This is meant to be called by render_profile to combine namepath + based name with parameters for the specific render, to uniquely + idenfify movie or image-stream output. + """ + desig = ''.join(self._get_name_components()[self.omit_ranks['render']+1:]) + + if ext in ('avi', 'mov', 'mp4', 'mkv'): + if suffix: + path = os.path.join(self.render_root, suffix, + desig + '-' + suffix + '.' + ext) + else: + path = os.path.join(self.render_root, ext.upper(), + desig + '.' + ext) + else: + if suffix: + path = os.path.join(self.render_root, + suffix, desig, + desig + '-' + suffix + '-f' + '#'*framedigits + '.' + ext) + else: + path = os.path.join(self.render_root, + ext.upper(), desig, + desig + '-f' + '#'*framedigits + '.' + ext) + return path diff --git a/abx/name_schema.py b/abx/name_schema.py new file mode 100644 index 0000000..0dbb1e6 --- /dev/null +++ b/abx/name_schema.py @@ -0,0 +1,286 @@ +# name_schema.py +""" +Object for managing schema directives from project YAML and applying them to parsing and mapping name fields. +""" + +import string, collections + +from .ranks import RankNotFound, Rank, Branch, Trunk + +class FieldSchema(object): + """ + Represents a schema used for parsing and constructing a field in names. + + We need naming information in various formats, based on knowledge about + the role of the Blender file and scene in the project. This object tracks + this information and returns correct names based on it via properties. + + Note that FieldSchema is NOT an individual project unit name, but a defined + pattern for how names are treat at that level in the project. It is a class + of names, not a name itself. Thus "shot" has a schema, but is distinct from + "shot A" which is a particular "project unit". The job of the schema is + to tell us things like "shots in this project will be represented by + single capital letters". + + See NameContext for the characteristics of a particular unit. + + Attributes: + codetype (type): Type of code name used for this rank. + Usually it will be int, str, or Enum. + Pre-defined enumerations are available + for uppercase letters (_letters) and + lowercase letters (_lowercase) (Roman -- + in principle, other alphabets could be added). + + rank (Rank): Rank of hierarchy under project (which is 0). The + rank value increases as you go "down" the tree. + Sorry about that confusion. + + + ranks (Branch): List of named ranks known to schema (may include + both higher and lower ranks). + + parent (|None): + Earlier rank to which this schema is attached. + + format (str): Code for formatting with Python str.format() method. + Optional: format can also be specified with the + following settings, or left to default formatting. + + pad (str): Padding character. + minlength (int): Minimum character length (0 means it may be empty). + maxlength (int): Maximum character length (0 means no limit). + + words (bool): Treat name/title fields like a collection of words, + which can then be represented using "TitleCaps" or + "underscore_spacing", etc for identifier use. + + delimiter (str): Field delimiter marking the end of this ranks' + code in designations. Note this is the delimiter + after this rank - the higher (lower value) rank + controls the delimiter used before it. + + default: The default value for this rank. May be None, + in which case, the rank will be treated as unset + until a setting is made. The UI must provide a + means to restore the unset value. Having no values + set below a certain rank is how a NameContext's + rank is determined. + + Note that the rank may go back to a lower value than the schema's + parent object in order to overwrite earlier schemas (for overriding a + particular branch in the project) or (compare this to the use of '..' + in operating system paths). Or it may skip a rank, indicating an + implied intermediate value, which will be treated as having a fixed + value. (I'm not certain I want that, but it would allow us to keep + rank numbers synchronized better in parallel hierarchies in a project). + + Note that schemas can be overridden at any level in a project by + 'project_schema' directives in unit YAML files, so it is possible to + change the schema behavior locally. By design, only lower levels in + the hierarchy (higher values of rank) can be affected by overrides. + + This kind of use isn't fully developed yet, but the plan is to include + things like managing 'Library' assets with a very different structure + from shot files. This way, the project can split into 'Library' and + 'Episode' forks with completely different schemas for each. + """ + # Defaults + _default_schema = { + 'delimiter':'-', + + 'type': 'string', + 'format':'{:s}', + 'minlength':1, # Must be at least one character + 'maxlength':0, # 0 means unlimited + 'words': False, # If true, treat value as words and spaces + 'pad': '0', # Left-padding character for fixed length + 'default': None, + + 'rank': 'project', + 'irank': 0, + 'ranks': ('series', 'episode', 'sequence', + 'block', 'camera', 'shot', 'element') + } + + # Really this is more like a set than a dictionary right now, but I + # thought I might refactor to move the definitions into the dictionary: + _codetypes = { + 'number':{}, + 'string':{}, + 'letter':{}, + 'lowercase':{}, + } + + _letter = tuple((A,A,A) for A in string.ascii_uppercase) + _lowercase = tuple((a,a,a) for a in string.ascii_lowercase) + + rank = 'project' + irank = 0 + default = None + + #ranks = ('project',) + branch = Trunk + + def __init__(self, parent=None, rank=None, schema=None, debug=False): + """ + Create a FieldSchema from schema data source. + + FieldSchema is typically initialized based on data from YAML files + within the project. This allows us to avoid encoding project structure + into ABX, leaving how units are named up to the production designer. + + If you want our suggestions, you can look at the "Lunatics!" project's + 'lunatics.yaml' file, or the 'myproject.yaml' file in the ABX source + distribution. + + Arguments: + parent (FieldSchema): + The level in the schema hierarchy above this one. + Should be None if this is the top. + + rank (int): The rank of this schema to be created. + + schema (dict): Data defining the schema, typically loaded from a + YAML file in the project. + + debug (bool): Used only for testing. Turns on some verbose output + about internal implementation. + + Note that the 'rank' is specified because it may NOT be sequential from + the parent schema. + """ + # Three types of schema data: + + # Make sure schema is a copy -- no side effects! + if not schema: + schema = {} + else: + s = {} + s.update(schema) + schema = s + + if not rank and 'rank' in schema: + rank = schema['rank'] + + # Stepped down in rank from parent: + self.parent = parent + + if parent and rank: + # Check rank is defined in parent ranks and use that + # We can skip optional ranks + if rank in parent.ranks: + j = parent.ranks.index(rank) + self.ranks = parent.ranks[j+1:] + self.rank = rank + else: + # It's an error to ask for a rank that isn't defined + raise RankNotFound( + '"%s" not in defined ranks for "%s"' % (rank, parent)) + + elif parent and not rank: + # By default, get the first rank below parent + self.rank = parent.ranks[0] + self.ranks = parent.ranks[1:] + + elif rank and not parent: + # With no parent, we're starting a new tree and renaming the root + self.rank = rank + self.ranks = self._default_schema['ranks'] + + else: # not rank and not parent: + # New tree with default rank + self.rank = self._default_schema['rank'] + self.ranks = self._default_schema['ranks'] + + # Directly inherited/acquired from parent + # So far, only need a delimiter specified, but might be other stuff + self.delimiter = self._default_schema['delimiter'] + if parent and parent.delimiter: self.delimiter = parent.delimiter + + # Explicit override by the new schema: + if 'ranks' in schema: self.ranks = schema['ranks'] + if 'delimiter' in schema: self.delimiter = schema['delimiter'] + if 'default' in schema: + if schema['default'] == 'None': + self.default = None + else: + self.default = schema['default'] + + # Default unless specified (i.e. not inherited from parent) + newschema = {} + newschema.update(self._default_schema) + newschema.update(schema) + + self.format = str(newschema['format']) + + self.minlength = int(newschema['minlength']) + self.maxlength = int(newschema['maxlength']) + self.pad = str(newschema['pad']) + self.words = bool(newschema['words']) + + if newschema['type'] == 'letter': + self.codetype = self._letter + + elif newschema['type'] == 'lowercase': + self.codetype = self._lowercase + + elif newschema['type'] == 'number': + # Recognized Python types + self.codetype = int + if 'minlength' or 'maxlength' in schema: + self.format = '{:0>%dd}' % self.minlength + + elif newschema['type'] == 'string': + self.codetype = str + + if ('minlength' in schema) or ('maxlength' in schema): + if self.maxlength == 0: + # Special case for unlimited length + self.format = '{:%1.1s>%ds}' % (self.pad, self.minlength) + self.format = '{:%1.1s>%d.%ds}' % ( + self. pad, self.minlength, self.maxlength) + + elif newschema['type'] == 'bool': + self.codetype = bool + + elif isinstance(newschema['type'], collections.Sequence): + # Enumerated types + # This is somewhat specific to Blender -- setting the + # enumeration values requires a sequence in a particular format + self.codetype = [] + for option in newschema['type']: + if type(option) is not str and isinstance(option, collections.Sequence): + option = tuple([str(e) for e in option][:3]) + else: + option = (str(option), str(option), str(option)) + self.codetype.append(option) + + elif isinstance(newschema['type'], collections.Mapping): + self.codetype = [] + for key, val in newschema['type'].items(): + if type(val) is not str and isinstance(val, collections.Sequence): + if len(val) == 0: + option = (str(key), str(key), str(key)) + elif len(val) == 1: + option = (str(key), str(val[0]), str(val[0])) + else: + option = (str(key), str(val[0]), str(val[1])) + else: + option = (str(key), str(val), str(val)) + self.codetype.append(option) + else: + # If all else fails + self.codetype = None + + def __repr__(self): + return('<(%s).FieldSchema: %s (%s, %s, %s, (%s))>' % ( + repr(self.parent), + #self.irank, + self.rank, + self.delimiter, + self.default, + self.format, + self.codetype + )) + diff --git a/abx/parsers/__init__.py b/abx/parsers/__init__.py new file mode 100644 index 0000000..852e555 --- /dev/null +++ b/abx/parsers/__init__.py @@ -0,0 +1,16 @@ +# parsers (sub-package) +""" +Filename Parsers & Registry for FileContext. +""" + +NameParsers = {} # Parser registry + +def registered_parser(parser): + """ + Decorator function to register a parser class. + """ + NameParsers[parser.name] = parser + return parser + +from . import abx_episode, abx_fallback, abx_schema + diff --git a/abx/parsers/__pycache__/__init__.cpython-35.pyc b/abx/parsers/__pycache__/__init__.cpython-35.pyc new file mode 100644 index 0000000..44b54a7 Binary files /dev/null and b/abx/parsers/__pycache__/__init__.cpython-35.pyc differ diff --git a/abx/parsers/__pycache__/abx_episode.cpython-35.pyc b/abx/parsers/__pycache__/abx_episode.cpython-35.pyc new file mode 100644 index 0000000..748bcfd Binary files /dev/null and b/abx/parsers/__pycache__/abx_episode.cpython-35.pyc differ diff --git a/abx/parsers/__pycache__/abx_fallback.cpython-35.pyc b/abx/parsers/__pycache__/abx_fallback.cpython-35.pyc new file mode 100644 index 0000000..ddb33a8 Binary files /dev/null and b/abx/parsers/__pycache__/abx_fallback.cpython-35.pyc differ diff --git a/abx/parsers/__pycache__/abx_schema.cpython-35.pyc b/abx/parsers/__pycache__/abx_schema.cpython-35.pyc new file mode 100644 index 0000000..a955043 Binary files /dev/null and b/abx/parsers/__pycache__/abx_schema.cpython-35.pyc differ diff --git a/abx/parsers/abx_episode.py b/abx/parsers/abx_episode.py new file mode 100644 index 0000000..ca3fb32 --- /dev/null +++ b/abx/parsers/abx_episode.py @@ -0,0 +1,204 @@ +# abx_episode.py +""" +Custom parser written for "Lunatics!" Project Episode files. + +Superseded by 'abx_schema' parser (probably). +""" + +import re, copy + +from . import registered_parser + +wordre = re.compile(r'([A-Z][a-z]+|[a-z]+|[0-9]+|[A-Z][A-Z]+)') + +@registered_parser +class Parser_ABX_Episode: + """ + Original "Lunatics!" filename parsing algorithm. (DEPRECATED) + + This parser was written before the Schema parser. It hard-codes the schema used + in the "Lunatics!" Project, and can probably be safely replaced by using the Schema + parser with appropriate YAML settings in the <project>.yaml file, which also allows + much more flexibility in naming schemes. + + YAML parameter settings available for this parser: + + --- + definitions: + parser: abx_episode # Force use of this parser + + parser_options: # Available settings (w/ defaults) + field_separator: '-' + episode_separator: 'E' + filetype_separator: '.' + + Filetypes and roles are hard-code, and can't be changed from the YAML. + + Assumes field-based filenames of the form: + + <series>E<episode>[-<seq>[-<block>[-Cam<camera>][-<shot>]]][-<title>]-<role>.<filetype> + + Where the <field> indicates fields with fieldnames, and there are three expected separators: + + - is the 'field_separator' + E is the 'episode_separator' + . is the 'filetype_separator' + + (These can be overridden in the initialization). + The class is callable, taking a string as input and returning a dictionary of fields. + """ + name = 'abx_episode' + + max_score = 10 # Maximum number of fields parsed + + # supported values for filetype + filetypes = { + 'blend': "Blender File", + 'kdenlive': "Kdenlive Video Editor File", + 'mlt': "Kdenlive Video Mix Script", + 'svg': "Scalable Vector Graphics (Inkscape)", + 'kra': "Krita Graphic File", + 'xcf': "Gimp Graphic File", + 'png': "Portable Network Graphics (PNG) Image", + 'jpg': "Joint Photographic Experts Group (JPEG) Image", + 'aup': "Audacity Project", + 'ardour': "Ardour Project", + 'flac': "Free Lossless Audio Codec (FLAC)", + 'mp3': "MPEG Audio Layer III (MP3) Audio File", + 'ogg': "Ogg Vorbis Audio File", + 'avi': "Audio Video Interleave (AVI) Video Container", + 'mkv': "Matroska Video Container", + 'mp4': "Moving Picture Experts Group (MPEG) 4 Format}", + 'txt': "Plain Text File" + } + + # Roles that make sense in an episode context + roles = { + 'extras': "Extras, crowds, auxillary animated movement", + 'mech': "Mechanical animation", + 'anim': "Character animation", + 'cam': "Camera direction", + 'vfx': "Visual special effects", + 'compos': "Compositing", + 'bkg': "Background 2D image", + 'bb': "Billboard 2D image", + 'tex': "Texture 2D image", + 'foley': "Foley sound", + 'voice': "Voice recording", + 'fx': "Sound effects", + 'music': "Music track", + 'cue': "Musical cue", + 'amb': "Ambient sound", + 'loop': "Ambient sound loop", + 'edit': "Video edit" + } + + # A few filetypes imply their roles: + roles_by_filetype = { + 'kdenlive': 'edit', + 'mlt': 'edit' + } + + + def __init__(self, field_separator='-', episode_separator='E', filetype_separator='.', + fields=None, filetypes=None, roles=None, **kwargs): + if not fields: + fields = {} + if filetypes: + self.filetypes = copy.deepcopy(self.filetypes) # Copy class attribute to instance + self.filetypes.update(filetypes) # Update with new values + if roles: + self.roles = copy.deepcopy(self.roles) # Copy class attribute to instance + self.roles.update(roles) # Update with new values + self.field_separator = field_separator + self.episode_separator = episode_separator + self.filetype_separator = filetype_separator + + def __call__(self, filename, namepath): + score = 0.0 + fielddata = {} + + # Check for filetype ending + i_filetype = filename.rfind(self.filetype_separator) + if i_filetype < 0: + fielddata['filetype'] = None + else: + fielddata['filetype'] = filename[i_filetype+1:] + filename = filename[:i_filetype] + score = score + 1.0 + + components = filename.split(self.field_separator) + + # Check for role marker in last component + if components[-1] in self.roles: + fielddata['role'] = components[-1] + del components[-1] + fielddata['hierarchy'] = 'episode' + score = score + 2.0 + elif fielddata['filetype'] in self.roles_by_filetype: + fielddata['role'] = self.roles_by_filetype[fielddata['filetype']] + fielddata['hierarchy'] = 'episode' + else: + fielddata['role'] = None + fielddata['hierarchy'] = None + + # Check for a descriptive title (must be 3+ characters in length) + if components and len(components[-1])>2: + # Normalize the title as words with spaces + title = ' '.join(w for w in wordre.split(components[-1]) if wordre.fullmatch(w)) + del components[-1] + score = score + 1.0 + else: + title = None + + # Check if first field contains series/episode number + if components: + prefix = components[0] + try: + fielddata['series'] = {} + fielddata['episode'] = {} + fielddata['series']['code'], episode_id = prefix.split(self.episode_separator) + fielddata['episode']['code'] = int(episode_id) + fielddata['rank'] = 'episode' + del components[0] + score = score + 2.0 + except: + pass + + # Check for sequence/block/shot/camera designations + if components: + fielddata['seq'] = {} + fielddata['seq']['code'] = components[0] + fielddata['rank'] = 'seq' + del components[0] + score = score + 1.0 + + if components: + try: + fielddata['block'] = {} + fielddata['block']['code'] = int(components[0]) + del components[0] + fielddata['rank'] = 'block' + score = score + 1.0 + except: + pass + + if components and components[0].startswith('Cam'): + fielddata['camera'] = {} + fielddata['camera']['code'] = components[0][len('Cam'):] + fielddata['rank'] = 'camera' + del components[0] + score = score + 1.0 + + if components: + # Any remaining structure is joined back to make the shot ID + fielddata['shot'] = {} + fielddata['shot']['code'] = ''.join(components) + fielddata['rank'] = 'shot' + components = None + score = score + 1.0 + + if title and fielddata['rank'] in fielddata: + fielddata[fielddata['rank']]['title'] = title + + return score/self.max_score, fielddata diff --git a/abx/parsers/abx_fallback.py b/abx/parsers/abx_fallback.py new file mode 100644 index 0000000..dedc9ee --- /dev/null +++ b/abx/parsers/abx_fallback.py @@ -0,0 +1,105 @@ +# abx_fallback.py +""" +Fallback parser used in case others fail. + +The fallback parser makes only a very minimal and robust set of assumptions. + +Any legal filename will successfully return a simple parse, though much +interpretation may be lost. It still allows for common field-based practices, +but falls back on using the unaltered filename if necessary. +""" + +import re, os + +import yaml + +from . import registered_parser + + +DEFAULT_YAML = {} +with open(os.path.join(os.path.dirname(__file__), '..', 'abx.yaml')) as def_yaml_file: + DEFAULT_YAML.update(yaml.safe_load(def_yaml_file)) + + + +@registered_parser +class Parser_ABX_Fallback(object): + """ + Highly-tolerant parser to fall back on if others fail. + + Makes very minimal assumptions about filename structure. + + YAML options available: + + --- + definitions: + parser: abx_fallback # Force use of this parser. + + There are no other options. Field separators are defined very broadly, + and include most non-word characters (~#$!=+&_-). This was mostly designed + to work without a project schema available. + """ + name = 'abx_fallback' + + filetypes = DEFAULT_YAML['definitions']['filetypes'] + roles = DEFAULT_YAML['definitions']['roles'] + roles_by_filetype = ( + DEFAULT_YAML['definitions']['roles_by_filetype']) + + main_sep_re = re.compile(r'\W+') # Any single non-word char + comment_sep_re = re.compile(r'[\W_][\W_]+|[~#$!=+&]+') + + + def __init__(self, **kwargs): + pass + + def _parse_ending(self, filename, separator): + try: + remainder, suffix = filename.rsplit(separator, 1) + score = 1.0 + except ValueError: + remainder = filename + suffix = None + score = 0.0 + return (suffix, remainder, score) + + def __call__(self, filename, namepath): + fields = {} + score = 1.0 + possible = 4.5 + + split = filename.rsplit('.', 1) + if len(split)<2 or split[1] not in self.filetypes: + fields['filetype'] = None + remainder = filename + score += 1.0 + else: + fields['filetype'] = split[1] + remainder = split[0] + + comment_match = self.comment_sep_re.search(remainder) + if comment_match: + fields['comment'] = remainder[comment_match.end():] + remainder = remainder[:comment_match.start()] + else: + fields['comment'] = None + + role = self.main_sep_re.split(remainder)[-1] + if role in self.roles: + fields['role'] = role + remainder = remainder[:-1-len(role)] + score += 1.0 + else: + fields['role'] = None + + # Implied role + if fields['filetype'] in self.roles_by_filetype: + fields['role'] = self.roles_by_filetype[fields['filetype']] + score += 1.0 + + words = self.main_sep_re.split(remainder) + fields['code'] = ''.join([w.capitalize() for w in words]) + fields['title'] = remainder + + return score/possible, fields + diff --git a/abx/parsers/abx_schema.py b/abx/parsers/abx_schema.py new file mode 100644 index 0000000..0d78e0b --- /dev/null +++ b/abx/parsers/abx_schema.py @@ -0,0 +1,189 @@ +# abx_schema.py +""" +Generalized fields-based parser based on provided schema. + +Expands on the 'abx_episode' parser by allowing all the schema to +be defined by outside configuration data (generally provided in a +project YAML file, but this module does not depend on the data +source used). +""" + +from . import registered_parser + +@registered_parser +class Parser_ABX_Schema(object): + """ + Parser based on using the list of schemas. + The schemas are normally defined in the project root directory YAML. + + The project YAML can additionally control parsing with this parser: + + --- + definitions: + parser: abx_schema # Force use of this parser + + parser_options: # Set parameters + filetype_separator: '.' + comment_separator: '--' + role_separator: '-' + title_separator: '-' + + filetypes: # Recognized filetypes. + blend: Blender File # <filetype>: documentation + ... + + roles: # Recognized role fields. + anim: Character Animation # <role>: documentation + ... + + roles_by_filetype: # Roles implied by filetype. + kdenlive: edit # <filetype>:<role> + ... + + (For the full default lists see abx/abx.yaml). + + schemas (list): The current schema-list defining how filenames should be parsed. + This "Schema" parser uses this to determine both parsing and + mapping of text fields in the filename. + + definitions(dict): The project definitions currently visible to the parser. + """ + name = 'abx_schema' + + def __init__(self, schemas=None, definitions=None, + filetype_separator = '.', + comment_separator = '--', + role_separator = '-', + title_separator = '-', + **kwargs): + + self.filetype_separator = filetype_separator + self.comment_separator = comment_separator + self.role_separator = role_separator + self.title_separator = title_separator + + self.schemas = schemas + + if 'roles' in definitions: + self.roles = definitions['roles'] + else: + self.roles = [] + + if 'filetypes' in definitions: + self.filetypes = definitions['filetypes'] + else: + self.filetypes = [] + + if 'roles_by_filetype' in definitions: + self.roles_by_filetype = definitions['roles_by_filetype'] + else: + self.roles_by_filetype = [] + + def _parse_ending(self, filename, separator): + try: + remainder, suffix = filename.rsplit(separator, 1) + score = 1.0 + except ValueError: + remainder = filename + suffix = None + score = 0.0 + return (suffix, remainder, score) + + def _parse_beginning(self, filename, separator): + try: + prefix, remainder = filename.split(separator, 1) + score = 1.0 + except ValueError: + prefix = filename + remainder = '' + score = 0.0 + return (prefix, remainder, score) + + def __call__ (self, filename, namepath, debug=False): + fields = {} + score = 0.0 + possible = 0.0 + + # First get specially-handled extensions + remainder = filename + field, newremainder, s = self._parse_ending(remainder, self.filetype_separator) + if field and field in self.filetypes: + remainder = newremainder + fields['filetype'] = field + score += s*1.0 + else: + fields['filetype'] = None + + field, remainder, s = self._parse_ending(remainder, self.comment_separator) + fields['comment'] = field + score += s*0.5 + + field, newremainder, s = self._parse_ending(remainder, self.role_separator) + if field and field in self.roles: + remainder = newremainder + fields['role'] = field + score += s*0.5 + else: + fields['role'] = None + + field, remainder, s = self._parse_ending(remainder, self.title_separator) + fields['title'] = field + score += s*0.5 + + possible += 3.0 + + # Implicit roles + if ( not fields['role'] and + fields['filetype'] and + fields['role'] in self.roles_by_filetype): + self.role = self.roles_by_filetype[fields['filetype']] + score += 0.2 + + #possible += 0.2 + + # Figure the rest out from the schema + # Find the matching rank start position for the filename + start = 0 + for start, (schema, name) in enumerate(zip(self.schemas, namepath)): + field, r, s = self._parse_beginning(remainder, schema.delimiter) + try: + if field.lower() == schema.format.format(name).lower(): + score += 1.0 + break + except ValueError: + print(' (365) field, format', field, schema.format) + + possible += 1.0 + + # Starting from that position, try to match fields + # up to the end of the namepath (checking against it) + irank = 0 + for irank, (schema, name) in enumerate( + zip(self.schemas[start:], namepath[start:])): + if not remainder: break + field, remainder, s = self._parse_beginning(remainder, schema.delimiter) + score += s + try: + if ( type(field) == str and + field.lower() == schema.format.format(name).lower()): + fields[schema.rank]={'code':field} + fields['rank'] = schema.rank + score += 1.0 + except ValueError: + print(' (384) field, format', field, schema.format) + possible += 2.0 + + # Remaining fields are authoritative (doesn't affect score) + for schema in self.schemas[irank:]: + if not remainder: break + field, remainder, s = self._parse_beginning(remainder, schema.delimiter) + fields[schema.rank]={'code':field} + fields['rank'] = schema.rank + + if 'rank' in fields: + fields[fields['rank']]['title'] = fields['title'] + + if not fields['role'] and fields['filetype'] in self.roles_by_filetype: + fields['role'] = self.roles_by_filetype[fields['filetype']] + + return score/possible, fields diff --git a/abx/ranks.py b/abx/ranks.py index cf05003..9405990 100644 --- a/abx/ranks.py +++ b/abx/ranks.py @@ -6,8 +6,12 @@ Objects for representing the ranks of a hierarchy, with the possibility of branching at nodes with redefined ranks via the 'project_schema' directives in project YAML files. """ - -import numbers + +class RankNotFound(LookupError): + """ + Error returned if an unexpected 'rank' is encountered. + """ + pass class Branch(object): """ @@ -37,7 +41,7 @@ class Branch(object): if self.code: code = self.code else: - code = 'trunk' + code = 'Trunk' return "<branch '%s': %s>" % (code, ranklist) def __contains__(self, other): @@ -53,9 +57,13 @@ class Branch(object): if isinstance(n, int) and 0 < n < len(self._ranks): return self._ranks[n] elif isinstance(n, str): + if n.lower()=='trunk': + return self._ranks[0] for rank in self._ranks: if str(rank) == n: return rank + elif n==0: + self._ranks[0] else: raise TypeError @@ -168,7 +176,7 @@ class Rank(object): if (self.num + other) < len(self.branch.ranks): return self.branch.ranks[self.num+other] elif (self.num + other) < 1: - return trunk + return Trunk else: return None else: @@ -184,7 +192,7 @@ class Rank(object): if 0 < (self.num - other) < len(self.branch.ranks): return self.branch.ranks[self.num-other] elif (self.num - other) < 1: - return trunk + return Trunk elif (self.num - other) > len(self.branch.ranks): return None else: @@ -241,7 +249,7 @@ class RankList(list): return super().__getitem__(i) -# Define the trunk branch object +# Define the Trunk branch object # This schema will make sense for any unaffiliated Blender document, # even if it hasn't been saved as a file yet: -trunk = Branch(None, '', 0, ('', 'file', 'scene')) +Trunk = Branch(None, '', 0, ('', 'file', 'scene')) diff --git a/pkg/abx/file_context.py b/pkg/abx/file_context.py index 4dec9b9..670549d 100644 --- a/pkg/abx/file_context.py +++ b/pkg/abx/file_context.py @@ -521,7 +521,7 @@ class Parser_ABX_Fallback(object): class RankNotFound(LookupError): pass -class NameSchema(object): +class FieldSchema(object): """ Represents a schema used for parsing and constructing designations, names, etc. """ @@ -686,7 +686,7 @@ class NameSchema(object): def __repr__(self): - return('<(%s).NameSchema: %s (%s, %s, %s, (%s))>' % ( + return('<(%s).FieldSchema: %s (%s, %s, %s, (%s))>' % ( repr(self.parent), #self.irank, self.rank, @@ -751,11 +751,11 @@ class NameContext(object): """ Load schemas from a list of schema dictionaries. - @schemas: list of dictionaries containing schema field data (see NameSchema). + @schemas: list of dictionaries containing schema field data (see FieldSchema). The data will typically be extracted from YAML, and is expected to be a list of dictionaries, each of which defines - fields understood by the NameSchema class, to instantiate - NameSchema objects. The result is a linked chain of schemas from + fields understood by the FieldSchema class, to instantiate + FieldSchema objects. The result is a linked chain of schemas from the top of the project tree down. @start: if a start value is given, the top of the existing schema @@ -769,7 +769,7 @@ class NameContext(object): else: last = None for schema in schemas: - self.schemas.append(NameSchema(last, schema['rank'], schema=schema)) + self.schemas.append(FieldSchema(last, schema['rank'], schema=schema)) #last = self.schemas[-1] def _parse_words(self, wordtext): diff --git a/tests/__pycache__/test_file_context.cpython-35.pyc b/tests/__pycache__/test_file_context.cpython-35.pyc index d84587b..1f7286a 100644 Binary files a/tests/__pycache__/test_file_context.cpython-35.pyc and b/tests/__pycache__/test_file_context.cpython-35.pyc differ diff --git a/tests/test_file_context.py b/tests/test_file_context.py index 7231faf..74dfe34 100644 --- a/tests/test_file_context.py +++ b/tests/test_file_context.py @@ -63,9 +63,9 @@ class FileContext_Utilities_Tests(unittest.TestCase): class FileContext_NameSchema_Interface_Tests(unittest.TestCase): """ - Test the interfaces presented by NameSchema. + Test the interfaces presented by FieldSchema. - NameSchema is not really intended to be used from outside the + FieldSchema is not really intended to be used from outside the file_context module, but it is critical to the behavior of the module, so I want to make sure it's working as expected. """ @@ -88,59 +88,7 @@ class FileContext_NameSchema_Interface_Tests(unittest.TestCase): {'rank': 'block', 'delimiter':'-', 'format':'{!s:1s}'}, {'rank': 'shot', 'delimiter':'-', 'format':'{!s:s}'}, {'rank': 'element', 'delimiter':'-', 'format':'{!s:s}'}] - - def test_NameSchema_create_single(self): - ns = file_context.NameSchema(schema = self.TESTSCHEMA_LIST[0]) - - # Test for ALL the expected properties: - - # Set by the test schema - self.assertEqual(ns.rank, 'project') - self.assertEqual(ns.delimiter, '-') - self.assertEqual(ns.format, '{:s}') - self.assertEqual(ns.words, True) - self.assertEqual(ns.codetype, str) - - # Default values - self.assertEqual(ns.pad, '0') - self.assertEqual(ns.minlength, 1) - self.assertEqual(ns.maxlength, 0) - self.assertEqual(ns.default, None) - - # Candidates for removal: - self.assertEqual(ns.irank, 0) # Is this used at all? - self.assertEqual(ns.parent, None) - self.assertListEqual(list(ns.ranks), - ['series', 'episode', 'sequence', - 'block', 'camera', 'shot', 'element']) - - def test_NameSchema_load_chain_from_project_yaml(self): - with open(self.TESTPROJECTYAML, 'rt') as yaml_file: - data = yaml.safe_load(yaml_file) - schema_dicts = data['project_schema'] - - schema_chain = [] - last = None - for schema_dict in schema_dicts: - rank = schema_dict['rank'] - parent = last - schema_chain.append(file_context.NameSchema( - parent = parent, - rank = rank, - schema = schema_dict)) - last = schema_chain[-1] - - #print( schema_chain ) - - self.assertEqual(len(schema_chain), 8) - - self.assertEqual( - schema_chain[-1].parent.parent.parent.parent.parent.parent.parent.rank, - 'project') - - self.assertEqual(schema_chain[5].rank, 'camera') - self.assertEqual(schema_chain[5].codetype[1], ('c2', 'c2', 'c2')) - + @@ -218,7 +166,7 @@ class FileContext_Parser_UnitTests(unittest.TestCase): 'A.001-LP-1-BeginningOfEnd-anim.txt') def setUp(self): - self.TESTSCHEMAS = [file_context.NameSchema( #rank=s['rank'], + self.TESTSCHEMAS = [file_context.FieldSchema( #rank=s['rank'], schema=s) for s in self.TESTSCHEMA_LIST] diff --git a/tests/test_name_schema.py b/tests/test_name_schema.py new file mode 100644 index 0000000..a67b5b7 --- /dev/null +++ b/tests/test_name_schema.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Test the file_context module. + +This was written well after I wrote the module, and starts out as a conversion +from the doctests I had in the module already. +""" + + +import unittest, os +import yaml + +import sys +print("__file__ = ", __file__) +sys.path.append(os.path.normpath(os.path.join(__file__, '..', '..'))) + +from abx import name_schema + +class FileContext_NameSchema_Interface_Tests(unittest.TestCase): + """ + Test the interfaces presented by FieldSchema. + + FieldSchema is not really intended to be used from outside the + file_context module, but it is critical to the behavior of the + module, so I want to make sure it's working as expected. + """ + TESTDATA = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'testdata')) + + TESTPROJECTYAML = os.path.join(TESTDATA, 'myproject', 'myproject.yaml') + + TESTPATH = os.path.join(TESTDATA, 'myproject/Episodes/' + + 'A.001-Pilot/Seq/LP-LastPoint/' + + 'A.001-LP-1-BeginningOfEnd-anim.txt') + + + # Normally from 'project_schema' in YAML + TESTSCHEMA_LIST =[ + {'rank': 'project', 'delimiter':'-', 'format':'{:s}', 'words':True}, + {'rank': 'series', 'delimiter':'E', 'format':'{:2s}'}, + {'rank': 'episode', 'delimiter':'-', 'format':'{!s:>02s}'}, + {'rank': 'sequence','delimiter':'-', 'format':'{:2s}'}, + {'rank': 'block', 'delimiter':'-', 'format':'{!s:1s}'}, + {'rank': 'shot', 'delimiter':'-', 'format':'{!s:s}'}, + {'rank': 'element', 'delimiter':'-', 'format':'{!s:s}'}] + + def test_NameSchema_create_single(self): + ns = name_schema.FieldSchema(schema = self.TESTSCHEMA_LIST[0]) + + # Test for ALL the expected properties: + + # Set by the test schema + self.assertEqual(ns.rank, 'project') + self.assertEqual(ns.delimiter, '-') + self.assertEqual(ns.format, '{:s}') + self.assertEqual(ns.words, True) + self.assertEqual(ns.codetype, str) + + # Default values + self.assertEqual(ns.pad, '0') + self.assertEqual(ns.minlength, 1) + self.assertEqual(ns.maxlength, 0) + self.assertEqual(ns.default, None) + + # Candidates for removal: + self.assertEqual(ns.irank, 0) # Is this used at all? + self.assertEqual(ns.parent, None) + self.assertListEqual(list(ns.ranks), + ['series', 'episode', 'sequence', + 'block', 'camera', 'shot', 'element']) + + def test_NameSchema_load_chain_from_project_yaml(self): + with open(self.TESTPROJECTYAML, 'rt') as yaml_file: + data = yaml.safe_load(yaml_file) + schema_dicts = data['project_schema'] + + schema_chain = [] + last = None + for schema_dict in schema_dicts: + rank = schema_dict['rank'] + parent = last + schema_chain.append(name_schema.FieldSchema( + parent = parent, + rank = rank, + schema = schema_dict)) + last = schema_chain[-1] + + #print( schema_chain ) + + self.assertEqual(len(schema_chain), 8) + + self.assertEqual( + schema_chain[-1].parent.parent.parent.parent.parent.parent.parent.rank, + 'project') + + self.assertEqual(schema_chain[5].rank, 'camera') + self.assertEqual(schema_chain[5].codetype[1], ('c2', 'c2', 'c2')) + + + \ No newline at end of file diff --git a/tests/test_ranks.py b/tests/test_ranks.py index cad342c..21c1b51 100644 --- a/tests/test_ranks.py +++ b/tests/test_ranks.py @@ -12,17 +12,17 @@ from abx import ranks class BranchTests(unittest.TestCase): def test_trunk_branch(self): - t = ranks.trunk.rank('') - f = ranks.trunk.rank('file') - s = ranks.trunk.rank('scene') - self.assertEqual(repr(ranks.trunk), "<branch 'trunk': file, scene>") - self.assertIn(t, ranks.trunk) - self.assertIn(f, ranks.trunk) - self.assertIn(s, ranks.trunk) + t = ranks.Trunk.rank('') + f = ranks.Trunk.rank('file') + s = ranks.Trunk.rank('scene') + self.assertEqual(repr(ranks.Trunk), "<branch 'Trunk': file, scene>") + self.assertIn(t, ranks.Trunk) + self.assertIn(f, ranks.Trunk) + self.assertIn(s, ranks.Trunk) def test_defining_branch(self): - b = ranks.Branch(ranks.trunk, 'myproject', 1, + b = ranks.Branch(ranks.Trunk, 'myproject', 1, ('project', 'series', 'episode', 'sequence', 'block', 'shot', 'element')) @@ -30,7 +30,7 @@ class BranchTests(unittest.TestCase): class RanksTests(unittest.TestCase): def setUp(self): - self.b = ranks.Branch(ranks.trunk, 'myproject', 1, + self.b = ranks.Branch(ranks.Trunk, 'myproject', 1, ('project', 'series', 'episode', 'sequence', 'block', 'shot', 'element')) @@ -115,12 +115,12 @@ class RanksTests(unittest.TestCase): pr = self.b.rank('project') r = se - 1 # Normal - 'project' is one below 'series' - s = se - 2 # ? Should this be 'project' or 'trunk'/None? + s = se - 2 # ? Should this be 'project' or 'Trunk'/None? t = se - 3 # "` " self.assertEqual(r, pr) - self.assertEqual(s, ranks.trunk) - self.assertEqual(t, ranks.trunk) + self.assertEqual(s, ranks.Trunk) + self.assertEqual(t, ranks.Trunk) def test_rank_slices_from_branch(self): @@ -155,5 +155,15 @@ class RanksTests(unittest.TestCase): self.assertEqual( ranks.branch, self.b) - + + def test_using_rank_as_key(self): + d = dict(zip(self.b.ranks, range(len(self.b.ranks)))) + R = self.b.rank + + self.assertDictEqual(d, { + R('trunk'):0, R('project'):1, R('series'):2, R('episode'):3, + R('sequence'):4, R('block'):5, R('shot'):6, R('element'):7 + }) + + \ No newline at end of file