From 43a7adb3795b8735e8909aca2151f5bf1f9b5381 Mon Sep 17 00:00:00 2001 From: filmfreedom-org Date: Tue, 8 Jun 2021 20:49:32 -0500 Subject: [PATCH] Refactoring to separate file_context elements. Updates to ranks.py --- abx/enum.py | 77 ++ abx/file_context.py | 1159 +---------------- abx/name_context.py | 337 +++++ abx/name_schema.py | 286 ++++ abx/parsers/__init__.py | 16 + .../__pycache__/__init__.cpython-35.pyc | Bin 0 -> 504 bytes .../__pycache__/abx_episode.cpython-35.pyc | Bin 0 -> 5410 bytes .../__pycache__/abx_fallback.cpython-35.pyc | Bin 0 -> 3191 bytes .../__pycache__/abx_schema.cpython-35.pyc | Bin 0 -> 4969 bytes abx/parsers/abx_episode.py | 204 +++ abx/parsers/abx_fallback.py | 105 ++ abx/parsers/abx_schema.py | 189 +++ abx/ranks.py | 22 +- pkg/abx/file_context.py | 12 +- .../test_file_context.cpython-35.pyc | Bin 20599 -> 19159 bytes tests/test_file_context.py | 60 +- tests/test_name_schema.py | 100 ++ tests/test_ranks.py | 36 +- 18 files changed, 1392 insertions(+), 1211 deletions(-) create mode 100644 abx/enum.py create mode 100644 abx/name_context.py create mode 100644 abx/name_schema.py create mode 100644 abx/parsers/__init__.py create mode 100644 abx/parsers/__pycache__/__init__.cpython-35.pyc create mode 100644 abx/parsers/__pycache__/abx_episode.cpython-35.pyc create mode 100644 abx/parsers/__pycache__/abx_fallback.cpython-35.pyc create mode 100644 abx/parsers/__pycache__/abx_schema.cpython-35.pyc create mode 100644 abx/parsers/abx_episode.py create mode 100644 abx/parsers/abx_fallback.py create mode 100644 abx/parsers/abx_schema.py create mode 100644 tests/test_name_schema.py 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 0000000000000000000000000000000000000000..44b54a7060247566b3137e1636b04db661f0130c GIT binary patch literal 504 zcmYjMu};G<5WS>nTGDpt#OettOCvFLs1Q;sSWt!Nmc_WS3n@t)*)D1;Mt*^h;1?M9 z5U))90u$#Hq@H~5?sRwWolggY=;Q74{>B6Nf+O=WzeLw#1P+h_<cJ<{d_KHl?gMWD z51?p489*99*@m<YWe8~q9&f`U4)>$zDlbIEN<nU!(n4u+M()Hzu8rD}l~ly_r?N6) zYerFqcQoc=3w?^NhX^kKropub6D$_7w;x9YZ6-3Qm_e%5rpin%D`F&3jfPN!k-8Bk zS;4d(H7l`idvm<$?<5<z17pvIhtYRX0ZRUyC$*Amk(tB@rFO|oJSW$=N!W7R_+eTn zl;%}#C>_;1JNy?GoF4jJ_XWoX!N5}?#&7q#xMh1tL`9uz$%XB^V#SJL$+9OKxk_h` wqGa>9V>^^`nNh0TX2W4YIbwC<AadX&j-ZXD?M_O`H-)%xP3^QpKlJ;ZUw~zN&Hw-a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..748bcfdff7629f09a3a4a2088f3533b1543fd087 GIT binary patch literal 5410 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 SatU7%AB;-&1$)vyWB(ud^pEQR literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ddb33a86416c8132a781f4eec696a43f91395eb0 GIT binary patch literal 3191 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 X`(ul)=U#F<?sm;{_MA8C+s^+0uwov7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a9550434cf48ed1ce174b6bb7eb94d5ae9af1fbc GIT binary patch literal 4969 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 C?0&%j literal 0 HcmV?d00001 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 d84587bcf4d60e479ed31967b4056e0801ad5dfb..1f7286a95b8dc6f224752314571393951d7eac55 100644 GIT binary patch delta 2584 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 ZBhDi(u*pY5Ro%FV6y0rIo~}qY>K_bFmFxfj delta 3905 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 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