Source code for grill.names

from __future__ import annotations

import uuid
import typing
import itertools
import collections
from datetime import datetime

import naming
try:
    from pxr import Sdf
    _USD_SUFFIXES = tuple(ext for ext in Sdf.FileFormat.FindAllFileFormatExtensions() if ext.startswith('usd'))
except ImportError:  # Don't fail if Sdf is not importable to facilitate portability
    _USD_SUFFIXES = ("usd", "usda", "usdc", "usdz", "usdt")

from grill.tokens import ids


def _table_from_id(token_ids):
    headers = [
        'Token',
        'Pattern',
        'Default',
        'Description',
    ]
    table_sep = tuple([''] * len(headers))
    sorter = lambda value: (
        # cleanup backslashes formatting
        value.pattern.replace('\\', '\\\\'),
        value.default,
        # replace new lines with empty strings to avoid malformed tables.
        value.description.replace('\n', ' '),
    )
    rows = [table_sep, headers, table_sep]
    rows.extend([token.name, *sorter(token.value)] for token in token_ids)
    rows.append(table_sep)
    max_sizes = [(max(len(i) for i in r)) for r in zip(*rows)]

    format_rows = []
    for r in rows:
        filler = '=<' if r == table_sep else ''
        format_rows.append(' '.join(
            f"{{:{f'{filler}'}{f'{size}'}}}".format(i)
            for size, i in zip(max_sizes, r))
        )
    return '\n'.join(format_rows)


class DefaultName(naming.Name):
    """ Inherited by: :class:`grill.names.CGAsset`

    Base class for any Name object that wishes to provide `default` functionality via
    the `get_default` method.

    Subclass implementations can override the `_defaults` member to return a mapping
    appropriate to that class.
    """
    _defaults = {}

    @classmethod
    def get_default(cls, **kwargs) -> DefaultName:
        """Get a new Name object with default values and overrides from **kwargs."""
        name = cls()
        defaults = dict(name._defaults, **kwargs)
        name.name = name.get(**defaults)
        return name


class DefaultFile(DefaultName, naming.File):
    """ Inherited by: :class:`grill.names.DateTimeFile`

    Similar to :class:`grill.names.DefaultName`, provides File Name objects default
    creation via the `get_default` method.

    Adds an extra `DEFAULT_SUFFIX='ext'` member that will be used when creating objects.
    """

    DEFAULT_SUFFIX = 'ext'

    @property
    def _defaults(self):
        result = super()._defaults
        result['suffix'] = type(self).DEFAULT_SUFFIX
        return result


class DateTimeFile(DefaultFile):
    """Time based file names respecting iso standard.

    ============= ================
    **Config:**
    ------------------------------
    *year*        Between :py:data:`datetime.MINYEAR` and :py:data:`datetime.MAXYEAR` inclusive.
    *month*       Between 1 and 12 inclusive.
    *day*         Between 1 and the number of days in the given month of the given year.
    *hour*        In ``range(24)``.
    *minute*      In ``range(60)``.
    *second*      In ``range(60)``.
    *microsecond* In ``range(1000000)``.
    ============= ================

    ======  ============
    **Composed Fields:**
    --------------------
    *date*  `year` `month` `day`
    *time*  `hour` `minute` `second` `microsecond`
    ======  ============

    .. note::
        When getting a new default name, current ISO time at the moment of execution is used.

    Example:
        >>> tf = DateTimeFile.get_default(suffix='txt')
        >>> tf.day
        '28'
        >>> tf.date
        '2019-10-28'
        >>> tf.year = 1999
        >>> tf
        DateTimeFile("1999-10-28 22-29-31-926548.txt")
        >>> tf.month = 14  # ISO format validation
        Traceback (most recent call last):
            ...
        ValueError: month must be in 1..12
        >>> tf.datetime
        datetime.datetime(1999, 10, 28, 22, 29, 31, 926548)
    """

    config = dict.fromkeys(
        ('month', 'day', 'hour', 'minute', 'second'), r'\d{1,2}'
    )
    config.update(year=r'\d{1,4}', microsecond=r'\d{1,6}')
    join = dict(
        date=('year', 'month', 'day'),
        time=('hour', 'minute', 'second', 'microsecond'),
    )
    join_sep = '-'

    @property
    def _defaults(self):
        result = super()._defaults
        time_field = {'year', 'month', 'day', 'hour', 'minute', 'second', 'microsecond'}
        now = datetime.now()
        result.update({f: getattr(now, f) for f in time_field})
        return result

    def get_pattern_list(self) -> typing.List[str]:
        """Fields / properties names (sorted) to be used when building names.

        Defaults to [`date`, `time`] + keys of this name's config
        """
        return ["date", "time"] + super().get_pattern_list()

    @property
    def name(self) -> str:
        return super().name

    @name.setter
    def name(self, name: str):
        prev_name = self._name
        super(DateTimeFile, self.__class__).name.fset(self, name)
        if name:
            try:  # validate via datetime conversion
                self.datetime
            except ValueError:
                if prev_name:  # if we had a previous valid name, revert to it
                    self.name = prev_name
                raise

    @property
    def datetime(self) -> datetime:
        """ Return a :py:class:`datetime.datetime` object using this name values.

            >>> tf = DateTimeFile("1999-10-28 22-29-31-926548.txt")
            >>> tf.datetime
            datetime.datetime(1999, 10, 28, 22, 29, 31, 926548)
        """
        if not self.name:
            raise AttributeError("Can not retrieve datetime from an empty name")
        date = f"{int(self.year):04d}-{int(self.month):02d}-{int(self.day):02d}"
        time = (f"{int(self.hour):02d}:{int(self.minute):02d}:{int(self.second):02d}."
                f"{int(self.microsecond):06d}")
        return datetime.fromisoformat(f'{date}T{time}')


class CGAsset(DefaultName):
    """Inherited by: :class:`grill.names.CGAssetFile`

    Elemental resources that, when composed, generate the entities that bring an idea to a tangible product
    through their life cycles (e.g. a character, a film, a videogame).

    """
    config = {token.name: token.value.pattern for token in ids.CGAsset}
    __doc__ += '\n' + _table_from_id(ids.CGAsset) + '\n'

    def __init__(self, *args, sep='-', **kwargs):
        super().__init__(*args, sep=sep, **kwargs)

    @property
    def _defaults(self):
        result = super()._defaults
        result.update({token.name: token.value.default for token in ids.CGAsset})
        return result


class CGAssetFile(CGAsset, DefaultFile, naming.PipeFile):
    """Inherited by: :class:`grill.names.UsdAsset`

    Versioned files in the pipeline for a CGAsset.

    Example:
        >>> name = CGAssetFile.get_default(version=7)
        >>> name.suffix
        'ext'
        >>> name.suffix = 'abc'
        >>> name.path
        WindowsPath('demo/3d/abc/entity/rnd/lead/atom/main/all/whole/7/demo-3d-abc-entity-rnd-lead-atom-main-all-whole.7.abc')
    """

    @property
    def _defaults(self):
        result = super()._defaults
        result.update(version=1)
        return result

    def get_path_pattern_list(self) -> typing.List[str]:
        pattern = super().get_pattern_list()
        pattern.append('version')
        return pattern


[docs] class UsdAsset(CGAssetFile): """Specialized :class:`grill.names.CGAssetFile` name object for USD asset resources. .. admonition:: Inheritance Diagram for UsdAsset :class: dropdown, note .. inheritance-diagram:: grill.names.UsdAsset This is the currency for USD asset identifiers in the pipeline. Examples: >>> asset_id = UsdAsset.get_default() >>> asset_id UsdAsset("demo-3d-abc-entity-rnd-main-atom-lead-base-whole.1.usda") >>> asset_id.suffix = 'usdc' >>> asset_id.version = 42 >>> asset_id UsdAsset("demo-3d-abc-entity-rnd-main-atom-lead-base-whole.42.usdc") >>> asset_id.suffix = 'abc' Traceback (most recent call last): ... ValueError: Can't set invalid name 'demo-3d-abc-entity-rnd-main-atom-lead-base-whole.42.abc' on UsdAsset("demo-3d-abc-entity-rnd-main-atom-lead-base-whole.42.usdc"). Valid convention is: '{code}-{media}-{kingdom}-{cluster}-{area}-{stream}-{item}-{step}-{variant}-{part}.{pipe}.{suffix}' with pattern: '^(?P<code>\w+)\-(?P<media>\w+)\-(?P<kingdom>\w+)\-(?P<cluster>\w+)\-(?P<area>\w+)\-(?P<stream>\w+)\-(?P<item>\w+)\-(?P<step>\w+)\-(?P<variant>\w+)\-(?P<part>\w+)(?P<pipe>(\.(?P<output>\w+))?\.(?P<version>\d+)(\.(?P<index>\d+))?)(\.(?P<suffix>sdf|usd|usda|usdc|usdz))$' .. seealso:: :class:`grill.names.CGAsset` for a description of available fields, :class:`naming.Name` for an overview of the core API. """ DEFAULT_SUFFIX = 'usd' file_config = naming.NameConfig( # NOTE: limit to only extensions starting with USD (some environments register other extensions untested by the grill) {'suffix': "|".join(_USD_SUFFIXES)} ) @classmethod def get_anonymous(cls, **values) -> UsdAsset: """Get an anonymous :class:`UsdAsset` name with optional field overrides. Useful for situations where a temporary but valid identifier is needed. :param values: Variable keyword arguments with the keys referring to the name's fields which will use the given values. Example: >>> UsdAsset.get_anonymous(stream='test') UsdAsset("4209091047-34604-19646-169-123-test-4209091047-34604-19646-169.1.usda") """ keys = cls.get_default().get_pattern_list() anon = itertools.cycle(uuid.uuid4().fields) return cls.get_default(**collections.ChainMap(values, dict(zip(keys, anon))))
class LifeTR(naming.Name): """Taxonomic Rank used for biological classification. """ config = {token.name: token.value.pattern for token in ids.LifeTR} __doc__ += '\n' + _table_from_id(ids.LifeTR) + '\n' def __init__(self, *args, sep=':', **kwargs): super().__init__(*args, sep=sep, **kwargs)