diff --git a/examples/default/test.env b/examples/default/test.env index fc33d7e..7acc27c 100755 --- a/examples/default/test.env +++ b/examples/default/test.env @@ -3,6 +3,7 @@ include: [default] all: &all PYVERSION: $(python -c "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}')") PYTHONPATH: ${DEPLOY_ROOT}/lib/python${PYVERSION} + NUKESCRIPT: ${ROOT}/projects/{seq}/{shot}/comp/{show}_{seq}_{shot}.{version}.nk darwin: <<: *all linux: diff --git a/lib/envstack/path.py b/lib/envstack/path.py index 260944a..a552581 100644 --- a/lib/envstack/path.py +++ b/lib/envstack/path.py @@ -30,15 +30,19 @@ # __doc__ = """ -Contains pathing classes and functions. +Contains template path classes and functions. """ import os import re +from typing import Iterable, Optional, Tuple from envstack import config, logger from envstack.exceptions import * # noqa +# env var regex: matches $VAR or ${VAR} +env_var_re = re.compile(r"\$\{[^}]+\}|\$\w+") + # template path field regex: extracts bracketed {keys} keyword_re = re.compile(r"{(\w*)(?::\d*d)?(?::\d*\.\d*f)?}") @@ -51,29 +55,83 @@ # template path directory delimiter regex directory_re = re.compile(r"[^\\/]+|[\\/]") -# name of the path templates environment -TEMPLATES_ENV_NAME = "templates" -# environment variable that defines platform root -TEMPLATES_ENV_ROOT = "ROOT" +def _escape_env_vars(s: str) -> str: + """Convert ${VAR} -> ${{VAR}} so str.format ignores it, while leaving {token} + intact.""" + def repl(m: re.Match) -> str: + tok = m.group(0) + if tok.startswith("${"): + inner = tok[2:-1] + return "${{" + inner + "}}" + return tok -class Path(str): - """Subclass of `str` with some platform agnostic pathing support. - - For example, getting a named template, applying fields and converting to - a platform specific path, where 'NUKESCRIPT' and 'windows' are defined in - the TEMPLATES_ENV_NAME env: :: - - >>> t = get_template('NUKESCRIPT') - >>> p = t.apply_fields(show='bunny', - sequence='abc', - shot='0100', - ask='comp', - version=1) - >>> p.toPlatform('windows') - '//projects/bunny/abc/0100/comp/nuke/bunny_abc_0100_comp.1.nk' + return env_var_re.sub(repl, s) + + +def _numdirs(p: str) -> int: + """Returns the number of directory levels in a path string, used for template""" + return str(p).replace("\\", "/").count("/") + + +def _load_resolved_stack( + stack: str, + *, + platform: str = config.PLATFORM, + scope: Optional[str] = None, +): + """Load + resolve an envstack environment stack. Intentionally uses envstack's + own resolution model rather than os.environ as the primary source of truth.""" + from .env import load_environ, resolve_environ + + raw = load_environ(stack, platform=platform, scope=scope) + return resolve_environ(raw) + + +def _expand_env_vars(template: str, env: dict) -> str: + """ + Expand $VARS / ${VARS} in `template` using the provided `env` mapping. + Uses EnvVar (string.Template-based). """ + from .env import EnvVar + + # EnvVar.expand() returns either EnvVar, list, or dict depending on input + expanded = EnvVar(template).expand(env, recursive=True) + + if isinstance(expanded, list) or isinstance(expanded, dict): + raise InvalidSyntax( + f"Path template expansion must resolve to a string, got {type(expanded)}" + ) + + # expanded may already be a string-like EnvVar + return str(expanded) + + +def _iter_template_items(env: dict) -> Iterable[Tuple[str, str]]: + """ + Heuristic filter for "likely path templates" inside an environment. + + We avoid assuming a special namespace and instead scan the stack for values + that look like templates. + """ + for k, v in env.items(): + if not isinstance(v, str) or not v: + continue + + # must contain at least one format field; otherwise it's not a template + if "{" not in v or "}" not in v: + continue + + # most path templates contain a separator; kept loose + if "/" not in v and "\\" not in v: + continue + + yield k, v + + +class Path(str): + """Subclass of `str` with some platform agnostic pathing support.""" SEPARATORS = ["/", "\\"] @@ -81,6 +139,11 @@ def __init__(self, path, platform: str = config.PLATFORM): self.path = path self.platform = platform + def __new__(cls, path, platform: str = config.PLATFORM): + obj = super().__new__(cls, path) + obj.platform = platform + return obj + def __repr__(self): return "<{0} '{1}'>".format(self.__class__.__name__, self.path) @@ -100,22 +163,49 @@ def levels(self): tokens = directory_re.findall(self.path) return [t for t in tokens if t not in self.SEPARATORS] - def toPlatform(self, platform: str = config.PLATFORM): - """Converts path to platform. - - :param platform: name of platform to convert to. - :returns: converted path. + def to_platform( + self, + platform: str = config.PLATFORM, + *, + stack: str = config.DEFAULT_NAMESPACE, + scope: Optional[str] = None, + root_var: str = "ROOT", + ): + """ + Converts path root from this Path.platform to `platform` using ROOT values + from the resolved envstack environment for each platform. + + :param platform: target platform name + :param stack: envstack stack to load for ROOT values (e.g. 'fps') + :param scope: scope to resolve stack from (defaults to dirname of this path) + :param root_var: env var name to treat as platform root (default: ROOT) + :returns: converted path string """ if platform == self.platform: return str(self) - fromRoot = get_template_environ(self.platform).get(TEMPLATES_ENV_ROOT) - toRoot = get_template_environ(platform).get(TEMPLATES_ENV_ROOT) - if not fromRoot or not toRoot: - print("root value undefined for platform {}".format(platform)) - return - return re.sub(r"^{}".format(fromRoot), toRoot, self.path) - - def toString(self): + + scope = scope or self.scope() + try: + from_env = _load_resolved_stack(stack, platform=self.platform, scope=scope) + to_env = _load_resolved_stack(stack, platform=platform, scope=scope) + except Exception as err: + raise InvalidPath( + f"Failed to load stack '{stack}' for platform conversion: {err}" + ) + + from_root = from_env.get(root_var) + to_root = to_env.get(root_var) + + if not from_root or not to_root: + raise TemplateNotFound( + f"{root_var} undefined for platform conversion ({self.platform} -> {platform}) " + f"in stack '{stack}'" + ) + + # use regex escape in case roots contain special chars + return re.sub(r"^{}".format(re.escape(from_root)), to_root, self.path) + + def to_str(self): """Returns this path as a string.""" return str(self) @@ -125,32 +215,14 @@ def scope(self): class Template(object): - """Path Template class. :: - - >>> t = Template('/projects/{show}/{sequence}/{shot}/{task}') - >>> p = t.apply_fields(show='bunny', - sequence='abc', - shot='010', - task='comp') - >>> p.path - '/projects/bunny/abc/010/comp' - >>> t.get_fields('/projects/test/xyz/020/lighting') - {'task': 'lighting', 'sequence': 'xyz', 'shot': '020', 'show': 'test'} - - With padded version numbers: :: - - >>> t = Template('/show/{show}/pub/{asset}/v{version:03d}') - >>> p = t.apply_fields(show='foo', asset='bar', version=3) - >>> p.path - '/show/foo/pub/bar/v003' - """ + """Path Template class.""" def __init__(self, path: str): assert path, "Template path format cannot be empty" self.path_format = str(path) def __repr__(self): - return "