From ecb446e9330a71a23e5b36f96e9d9fdf9c4e1de7 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 7 Feb 2026 06:52:44 -0800 Subject: [PATCH 1/6] initial path template support updates --- lib/envstack/path.py | 340 +++++++++++++++++++++++++------------------ 1 file changed, 195 insertions(+), 145 deletions(-) diff --git a/lib/envstack/path.py b/lib/envstack/path.py index 260944a..b5eb113 100644 --- a/lib/envstack/path.py +++ b/lib/envstack/path.py @@ -35,10 +35,12 @@ import os import re +from typing import Iterable, Optional, Tuple from envstack import config, logger from envstack.exceptions import * # noqa + # template path field regex: extracts bracketed {keys} keyword_re = re.compile(r"{(\w*)(?::\d*d)?(?::\d*\.\d*f)?}") @@ -51,29 +53,74 @@ # 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 _numdirs(p: str) -> int: + return str(p).replace("\\", "/").count("/") -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' +def _load_resolved_stack( + stack: str, + *, + platform: str = config.PLATFORM, + scope: Optional[str] = None, +): + """ + Load + resolve an envstack environment stack. + + NOTE: This 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_dollar_vars(template: str, env: dict) -> str: """ + Expand $VARS / ${VARS} in `template` using the provided `env` mapping. + + Uses envstack.env.EnvVar (string.Template-based) so behavior matches the rest + of envstack. + """ + 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; keep this 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 = ["/", "\\"] @@ -100,20 +147,47 @@ 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) + + 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 (e.g. backslashes) + return re.sub(r"^{}".format(re.escape(from_root)), to_root, self.path) def toString(self): """Returns this path as a string.""" @@ -125,25 +199,7 @@ 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" @@ -168,7 +224,7 @@ def apply_fields(self, **fields): Applies key/value pairs matching template format. :param fields: key/values to apply to template. - :returns: resolved path as string. + :returns: resolved path as Path. :raises: MissingFieldError. """ formats = self.get_formats() @@ -180,14 +236,12 @@ def cast(k, v): except ValueError: raise Exception("{0} must be {1}".format(k, fmt.__name__)) - # reclass values based on field format in template formatted = dict((k, cast(k, v)) for k, v in fields.items()) try: return Path(self.path_format.format(**formatted)) - except KeyError as err: - raise MissingFieldError(err) # noqa F405 + raise MissingFieldError(err) # noqa def get_keywords(self): """Returns a list of required keywords.""" @@ -209,12 +263,7 @@ def get_formats(self): return results def get_fields(self, path: str): - """Gets key/value pairs from path that map to template path. - - :param path: file system path as string. - :returns: dict of key/value pairs. - """ - # conform path and template slashes + """Gets key/value pairs from path that map to template path.""" path = path.replace("\\", "/") path_format = self.path_format.replace("\\", "/") @@ -232,23 +281,16 @@ def get_fields(self, path: str): back_ref = "(?P={name})".format(name=name) try: while True: - index = tokens[i + 1 :].index(tokens[i]) # noqa F405 + index = tokens[i + 1 :].index(tokens[i]) # noqa tokens[i + 1 + index] = back_ref except ValueError: pass pattern = "".join(tokens) matches = re.match(pattern, path) - # TODO: log/print info about what makes the path invalid - # for example, {show} appears twice in template, but has - # two different values in the path: - # template: /projects/{show}/{shot}/{show}_{desc}.ext - # filepath: /projects/bunny/tst001/bigbuck_cam.ext - # ^^^^^ ^^^^^^^ if not matches: - raise InvalidPath(path) # noqa F405 + raise InvalidPath(path) # noqa - # reclass values based on field format in template formats = self.get_formats() def cast(k, v): @@ -257,119 +299,127 @@ def cast(k, v): return {k: cast(k, matches.group(k)) for k in keywords} -def extract_fields(filepath: str, template: Template): - """Convenience function that extracts template fields from - a given filepath for a given template name. For example: :: - - >>> envstack.extract_fields('/projects/bunny/vsr/vsr0100/comp/test.nk', - 'TASKDIR') - {'task': 'comp', 'sequence': 'vsr', 'shot': 'vsr0100', 'show': 'bunny'} - - :param filepath: path to file. - :param template: Template instance, or name of template. - :returns: dictionary of template fields. +def extract_fields( + filepath: str, + template: Template, + *, + stack: str = config.DEFAULT_NAMESPACE, + platform: str = config.PLATFORM, +): + """ + Convenience function that extracts template fields from a given filepath for + a given template instance or template name. + + :param filepath: path to file + :param template: Template instance or name of template in `stack` + :param stack: stack namespace to load templates from (default: config.DEFAULT_NAMESPACE) + :param platform: optional platform name + :returns: dictionary of template fields """ try: - p = Path(filepath) + p = Path(filepath, platform=platform) if isinstance(template, str): - template = get_template(template, scope=p.scope()) + template = get_template( + template, stack=stack, platform=platform, scope=p.scope() + ) return template.get_fields(filepath) - # path does not match template format - except InvalidPath: # noqa F405 + except InvalidPath: # noqa logger.log.debug( "path does not match template: {0} {1}".format(template, filepath) ) return {} - # unhandled errors except Exception as err: logger.log.debug("error extracting fields: {}".format(err)) return {} def get_scope(filepath: str): - """Convenience function that returns the scope of a given filepath. - - :param filepath: filepath. - :returns: scope of the filepath. - """ + """Convenience function that returns the scope of a given filepath.""" return Path(filepath).scope() -def get_template_environ(platform: str = config.PLATFORM, scope: str = None): - """Returns default template Env instance defined by the value - config.TEMPLATES_ENV_NAME. - - :param platform: optional platform name. - :param scope: environment scope (default: cwd). - :returns: Env instance. +def get_template( + name: str, + *, + stack: str = config.DEFAULT_NAMESPACE, + platform: str = config.PLATFORM, + scope: str = None, + expand_envvars: bool = True, +): """ - from .env import load_environ - - return load_environ(TEMPLATES_ENV_NAME, platform=platform, scope=scope) + Returns a Template instance for a given template name located in `stack`. + + - Loads + resolves the envstack environment for `stack` + - Fetches template string from resolved env + - Expands $VARS / ${VARS} inside the template string using the resolved env + - Returns Template(expanded_string) + + :param name: template variable name + :param stack: envstack stack to load (e.g. 'fps') + :param platform: platform name + :param scope: scope (default: cwd via load_environ) + :param expand_envvars: whether to expand $VARS in the template string + """ + env = _load_resolved_stack(stack, platform=platform, scope=scope) + template = env.get(name) + if not template: + raise TemplateNotFound(name) # noqa -def get_template(name: str, platform: str = config.PLATFORM, scope: str = None): - """Returns a Template instance for a given name. Template paths are - defined by default in the env file set on config.TEMPLATES_ENV_NAME. + if expand_envvars: + template = _expand_dollar_vars(template, env) - For example, using 'NUKESCRIPT' as defined: :: + return Template(template) - >>> t = get_template('NUKESCRIPT') - >>> t.apply_fields(show='bunny', - sequence='abc', - shot='0100', - task='comp', - version=1) - - :param name: name of template. - :param platform: optional platform name. - :param scope: environment scope (default: cwd). - :returns: Template instance. +def match_template( + path: str, + *, + stack: str = config.DEFAULT_NAMESPACE, + platform: str = config.PLATFORM, + scope: str = None, + expand_envvars: bool = True, +): """ - env = get_template_environ(platform, scope=scope) - template = env.get(name) - if not template: - raise TemplateNotFound(name) # noqa F405 - return Template(template) - + Returns a Template that matches a given `path`. -def match_template(path: str, platform: str = config.PLATFORM, scope: str = None): - """Returns a Template that matches a given `path`. + - Loads + resolves `stack` + - Considers values that look like path templates + - Orders templates by directory depth (more specific first) + - Returns first matching template, or raises ValueError if the path matches + multiple templates of the same depth. - :path: path to match Template. - :param platform: optional platform name. - :param scope: environment scope (default: cwd). - :raises: ValueError if `path` matches multiple templates. - :returns: matching Template or None. + :raises: ValueError if `path` matches multiple templates at the same depth + :returns: matching Template or None """ - env = get_template_environ(platform, scope=scope) + env = _load_resolved_stack(stack, platform=platform, scope=scope) - # returns number of folders in a path - numdirs = lambda p: str(p).replace("\\", "/").count("/") # noqa E731 + items = list(_iter_template_items(env)) + items.sort(key=lambda kv: _numdirs(kv[1]), reverse=True) - # sort templates by number of folders - ordered = sorted(env, key=lambda k: numdirs(env[k]), reverse=True) + matched = None + matched_depth = None - # stores the return value - template = None - - # return first matching template or raise ValueError - for name in ordered: + for name, path_format in items: try: - path_format = env[name] - template_test = Template(path_format) + if expand_envvars: + path_format_expanded = _expand_dollar_vars(path_format, env) + else: + path_format_expanded = path_format - if template_test.get_fields(path): - if template is None: - template = template_test + template_test = Template(path_format_expanded) - elif numdirs(path_format) == numdirs(template.path_format): + if template_test.get_fields(path): + depth = _numdirs(template_test.path_format) + if matched is None: + matched = template_test + matched_depth = depth + elif depth == matched_depth: raise ValueError("path matches more than one template") - except InvalidPath: # noqa F405 + except InvalidPath: # noqa continue - return template + return matched From 7792726207c5ec3975bd2c3cb884f9a3cac6c604 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 7 Feb 2026 07:15:25 -0800 Subject: [PATCH 2/6] escape embedded env vars in path templates --- lib/envstack/path.py | 35 +++++++-- tests/test_path.py | 164 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 tests/test_path.py diff --git a/lib/envstack/path.py b/lib/envstack/path.py index b5eb113..59ec886 100644 --- a/lib/envstack/path.py +++ b/lib/envstack/path.py @@ -41,6 +41,9 @@ 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)?}") @@ -54,7 +57,22 @@ directory_re = re.compile(r"[^\\/]+|[\\/]") +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 + + 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("/") @@ -64,12 +82,8 @@ def _load_resolved_stack( platform: str = config.PLATFORM, scope: Optional[str] = None, ): - """ - Load + resolve an envstack environment stack. - - NOTE: This intentionally uses envstack's own resolution model rather than - os.environ as the primary source of truth. - """ + """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) @@ -128,6 +142,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) @@ -368,7 +387,9 @@ def get_template( if not template: raise TemplateNotFound(name) # noqa - if expand_envvars: + if not expand_envvars: + template = _escape_env_vars(template) + else: template = _expand_dollar_vars(template, env) return Template(template) diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 0000000..d5b5627 --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024-2025, Ryan Galloway (ryan@rsgalloway.com) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# - Neither the name of the software nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +__doc__ = """ +Contains unit tests for the path.py module. +""" + +import unittest +from unittest.mock import patch + +from envstack.exceptions import MissingFieldError, TemplateNotFound +from envstack.path import Path, Template, extract_fields, get_template, match_template + + +def _fake_env(**kvs): + """Helper to build a resolved env mapping for path tests.""" + return dict(kvs) + + +class TestTemplate(unittest.TestCase): + def test_get_keywords(self): + t = Template("/mnt/projects/{show}/{seq}/{shot}") + self.assertEqual(t.get_keywords(), ["show", "seq", "shot"]) + + def test_apply_fields_success(self): + t = Template("/mnt/projects/{show}/{seq}/{shot}") + p = t.apply_fields(show="demo", seq="aa", shot="010") + self.assertEqual(str(p), "/mnt/projects/demo/aa/010") + + def test_apply_fields_missing_raises(self): + t = Template("/mnt/projects/{show}/{seq}/{shot}") + with self.assertRaises(MissingFieldError): + t.apply_fields(show="demo", seq="aa") # missing shot + + def test_get_fields_success(self): + t = Template("/mnt/projects/{show}/{seq}/{shot}") + fields = t.get_fields("/mnt/projects/demo/aa/010") + self.assertEqual(fields, {"show": "demo", "seq": "aa", "shot": "010"}) + + +class TestGetTemplate(unittest.TestCase): + def test_get_template_expands_envvars_then_fields(self): + """ + Ensures ${ROOT} is expanded from the resolved env before applying {seq}/{shot}. + """ + env = _fake_env( + ROOT="/mnt/pipe", + NUKESCRIPT="${ROOT}/projects/{seq}/{shot}/comp/{seq}_{shot}.{version}.nk", + ) + + with patch("envstack.path._load_resolved_stack", return_value=env): + t = get_template("NUKESCRIPT", stack="fps", scope="/tmp") + p = t.apply_fields(seq="aa", shot="010", version="0001") + self.assertEqual( + str(p), + "/mnt/pipe/projects/aa/010/comp/aa_010.0001.nk", + ) + + def test_get_template_missing_raises(self): + env = _fake_env(ROOT="/mnt/pipe") + with patch("envstack.path._load_resolved_stack", return_value=env): + with self.assertRaises(TemplateNotFound): + get_template("DOES_NOT_EXIST", stack="fps", scope="/tmp") + + def test_get_template_can_disable_envvar_expansion(self): + env = _fake_env( + ROOT="/mnt/pipe", + SEQDIR="${ROOT}/projects/{seq}", + ) + with patch("envstack.path._load_resolved_stack", return_value=env): + t = get_template("SEQDIR", stack="fps", scope="/tmp", expand_envvars=False) + # should preserve ${ROOT} literally + p = t.apply_fields(seq="aa") + self.assertEqual(str(p), "${ROOT}/projects/aa") + + +class TestMatchTemplate(unittest.TestCase): + def test_match_template_picks_most_specific(self): + env = _fake_env( + ROOT="/mnt/pipe", + # less specific + SEQDIR="${ROOT}/projects/{seq}", + # more specific + SHOTDIR="${ROOT}/projects/{seq}/{shot}", + ) + with patch("envstack.path._load_resolved_stack", return_value=env): + t = match_template("/mnt/pipe/projects/aa/010", stack="fps", scope="/tmp") + self.assertIsNotNone(t) + self.assertEqual(str(t), "/mnt/pipe/projects/{seq}/{shot}") + + def test_match_template_none_when_no_match(self): + env = _fake_env( + ROOT="/mnt/pipe", + SEQDIR="${ROOT}/projects/{seq}", + ) + with patch("envstack.path._load_resolved_stack", return_value=env): + t = match_template("/some/other/path", stack="fps", scope="/tmp") + self.assertEqual(t, None) + + +class TestExtractFields(unittest.TestCase): + def test_extract_fields_with_template_name(self): + env = _fake_env( + ROOT="/mnt/pipe", + SHOTDIR="${ROOT}/projects/{seq}/{shot}", + ) + with patch("envstack.path._load_resolved_stack", return_value=env): + fields = extract_fields( + "/mnt/pipe/projects/aa/010", + "SHOTDIR", + stack="fps", + ) + self.assertEqual(fields, {"seq": "aa", "shot": "010"}) + + +class TestPathToPlatform(unittest.TestCase): + def test_to_platform_rewrites_root(self): + """ + Ensures Path.to_platform uses per-platform ROOT loaded from the same stack. + """ + env_linux = _fake_env(ROOT="/mnt/pipe") + env_windows = _fake_env(ROOT="//tools/pipe") + + def _fake_load(stack, platform, scope): + if platform in ("windows", "win32"): + return env_windows + return env_linux + + with patch("envstack.path._load_resolved_stack", side_effect=_fake_load): + p = Path("/mnt/pipe/projects/aa/010", platform="linux") + out = p.to_platform(platform="windows", stack="fps", scope="/tmp") + self.assertEqual(out, "//tools/pipe/projects/aa/010") + + +if __name__ == "__main__": + unittest.main() From 2851fb7865cfa6e70717fdbe983984fc80baa988 Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 7 Feb 2026 07:19:38 -0800 Subject: [PATCH 3/6] rename path expand var func, adds example path template --- examples/default/test.env | 1 + lib/envstack/path.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) 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 59ec886..43bce86 100644 --- a/lib/envstack/path.py +++ b/lib/envstack/path.py @@ -90,7 +90,7 @@ def _load_resolved_stack( return resolve_environ(raw) -def _expand_dollar_vars(template: str, env: dict) -> str: +def _expand_env_vars(template: str, env: dict) -> str: """ Expand $VARS / ${VARS} in `template` using the provided `env` mapping. @@ -390,7 +390,7 @@ def get_template( if not expand_envvars: template = _escape_env_vars(template) else: - template = _expand_dollar_vars(template, env) + template = _expand_env_vars(template, env) return Template(template) @@ -426,7 +426,7 @@ def match_template( for name, path_format in items: try: if expand_envvars: - path_format_expanded = _expand_dollar_vars(path_format, env) + path_format_expanded = _expand_env_vars(path_format, env) else: path_format_expanded = path_format From 80b499e4d8423174a89e62ce77f0d0dc6313ba6d Mon Sep 17 00:00:00 2001 From: Ryan Galloway Date: Sat, 7 Feb 2026 09:01:01 -0800 Subject: [PATCH 4/6] docstring and comment updates --- lib/envstack/path.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/envstack/path.py b/lib/envstack/path.py index 43bce86..0bb4d49 100644 --- a/lib/envstack/path.py +++ b/lib/envstack/path.py @@ -30,7 +30,7 @@ # __doc__ = """ -Contains pathing classes and functions. +Contains template path classes and functions. """ import os @@ -40,7 +40,6 @@ from envstack import config, logger from envstack.exceptions import * # noqa - # env var regex: matches $VAR or ${VAR} env_var_re = re.compile(r"\$\{[^}]+\}|\$\w+") @@ -93,13 +92,11 @@ def _load_resolved_stack( def _expand_env_vars(template: str, env: dict) -> str: """ Expand $VARS / ${VARS} in `template` using the provided `env` mapping. - - Uses envstack.env.EnvVar (string.Template-based) so behavior matches the rest - of envstack. + Uses EnvVar (string.Template-based). """ from .env import EnvVar - # EnvVar.expand() returns either EnvVar, list, or dict depending on input. + # 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): @@ -122,11 +119,11 @@ def _iter_template_items(env: dict) -> Iterable[Tuple[str, str]]: if not isinstance(v, str) or not v: continue - # Must contain at least one format field; otherwise it's not a template. + # 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; keep this loose. + # most path templates contain a separator; kept loose if "/" not in v and "\\" not in v: continue @@ -205,10 +202,10 @@ def to_platform( f"in stack '{stack}'" ) - # Use regex escape in case roots contain special chars (e.g. backslashes) + # use regex escape in case roots contain special chars return re.sub(r"^{}".format(re.escape(from_root)), to_root, self.path) - def toString(self): + def to_str(self): """Returns this path as a string.""" return str(self) @@ -225,7 +222,7 @@ def __init__(self, path: str): self.path_format = str(path) def __repr__(self): - return "