Skip to content
Open
76 changes: 67 additions & 9 deletions client/ayon_core/pipeline/anatomy/roots.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import os
import platform
import numbers
Expand Down Expand Up @@ -42,17 +43,24 @@ def __init__(self, parent, root_raw_data, name):
# as production safe. Some features may not work as expected, for
# example USD resolver or site sync.
try:
self.value = lowered_platform_keys[current_platform].format_map(
os.environ
)
except KeyError:
self.value = (
os.path.expandvars(

@iLLiCiTiT iLLiCiTiT Jun 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we gona do it, I'm against using expandvars, only {ENV} should be possible.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the reason? My argument is that using paths with $VAR inside is pretty standard.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is standard, but then you have to support it at every place which is using roots, including USD resolver, it is hard to find out if it is missing, and what exact variable it is (it does not have strictly defined end the the variable name). $MY_custom_var is valid env key, but how you find out if it should be $MY or $MY_custom...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the behavior is clearly defined. VAR = "foo":

lorem $VAR: lorem foo
lorem $VARipsum: lorem
lorem $VAR ipsum: lorem foo ipsum
lorem %VAR%: lorem foo
lorem %VAR%ipsum: lorem fooipsum

os.path.expandvars() is taking care of that in python and the resolver needs to implement {VAR} format anyway so adding $VAR and/or %VAR% is trivial at that point. But I don't really mind. @BigRoy ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't easily find out if it is missing (without validating it for each platform) and there are limitations, e.g. you can't use $VARipsum (which is missing in your example). I'm 100% against it. I know it is "known", but it is not easily detectable and easy to validate.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is there:

lorem $VARipsum: lorem

But it also supports curly bracket format that is standard too. So if you want this to work, you can:

lorem ${VAR}ipsum: lorem fooipsum

and all that is supported by os.path.expandvars()

@iLLiCiTiT iLLiCiTiT Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unresolved expanding and you have no idea it is unresolved

print(os.path.expandvars("$NOTEXISTING"))
>>> $NOTEXISTING

This does require to be more specific, something that should be filled is not

import os
os.environ["TEST"]="value"
print(os.path.expandvars("$TEST_something"))
>>> $TEST_something

Supporting this will only bring issues. At the end it does what the python formatting does, but with less control and more headaches for us.

os.path.expanduser(
lowered_platform_keys[current_platform])))
except AttributeError as e:
msg = f"Missing root definition for platform {current_platform}."
raise RootMissingEnv(msg) from e

try:
self.value = self.value.format_map(os.environ)
except KeyError as e:
result = StringTemplate(self.value).format(os.environ.copy())
is_are = "is" if len(result.missing_keys) == 1 else "are"
Comment thread
antirotor marked this conversation as resolved.
missing_keys = ", ".join(result.missing_keys)
raise RootMissingEnv(
f"Root \"{name}\" requires environment variable/s"
f" {missing_keys} which {is_are} not available."
)
) from e

self.clean_value = self._clean_root(self.value)

Expand Down Expand Up @@ -197,6 +205,13 @@ def find_root_template_from_path(self, path):

All platform values are checked for this replacement.

Both the input ``path`` and the stored root values are tried in their
original *and* expanded forms (via ``os.path.expandvars`` /
``os.path.expanduser``) so that the following mismatches are handled:

- ``path`` is already expanded but the stored root still contains
``~` / ``$VAR`` tokens (or vice-versa).

Args:
path (str): Path where root value should be found.

Expand Down Expand Up @@ -225,22 +240,65 @@ def find_root_template_from_path(self, path):
output = str(path)

mod_path = self._clean_path(path)
# Expanded version of the input path – used when the stored root value
# is already expanded while the caller passed an unexpanded path, or
# to normalise both sides consistently.
expanded_mod_path = self._clean_path(
os.path.expandvars(os.path.expanduser(path))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not allow to pass in not expanded path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? shouldn't one place take care of that intead reimplementing the logic in all places where you want to use this feature before calling find_root_template_from_path()?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you call find_root_template_from_path you should have full path, not ~/some/file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is what you get now:

ar = AnatomyRoot(
            parent=...,
            root_raw_data={"linux": "/home/user/projects"},
            name="work",
)

success, result = item.find_root_template_from_path("~/projects/shot/file.ma")

you'll get result == "{root[work]}/shot/file.ma"

it handles matching unexpanded paths to expanded roots in various forms (~, $VAR, etc.) and only if it cannot match, it will success == False.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And where you'd get the path "~/projects/shot/file.ma" from?

)

for root_os, root_path in self.cleaned_data.items():
# Skip empty paths
if not root_path:
continue

_mod_path = mod_path # reset to original cleaned value
# Expand variables in the stored root path so we can compare it
# against an already-expanded input path (and vice-versa).
expanded_root_path = self._clean_root(
os.path.expandvars(os.path.expanduser(root_path))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root paths should be already expanded in cleaned_data.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I get it - it is not expanded there, only for current platform. so are you suggesting that the expansion logic should be moved to ... probably _clean_path()?

)

_mod_path = mod_path
_expanded_mod_path = expanded_mod_path
_root_path = root_path
_expanded_root_path = expanded_root_path
if root_os == "windows":
root_path = root_path.lower()
_mod_path = _mod_path.lower()
_expanded_mod_path = _expanded_mod_path.lower()
_root_path = _root_path.lower()
_expanded_root_path = _expanded_root_path.lower()

replacement = "{" + self.full_key + "}"

if _mod_path.startswith(root_path):
# 1) original path vs original root (existing behaviour)
if _mod_path.startswith(_root_path):
result = True
replacement = "{" + self.full_key + "}"
output = replacement + mod_path[len(root_path):]
break

# 2) original path vs expanded root
# (root stored with vars, path already expanded)
if _mod_path.startswith(_expanded_root_path):
result = True
output = replacement + mod_path[len(expanded_root_path):]
break

# 3) expanded path vs original root
# (path stored with vars, root already expanded)
if _expanded_mod_path.startswith(_root_path):
result = True
output = replacement + expanded_mod_path[len(root_path):]
break

# 4) expanded path vs expanded root (both sides have vars)
if _expanded_mod_path.startswith(_expanded_root_path):
result = True
output = (
replacement
+ expanded_mod_path[len(expanded_root_path):]
)
break

return (result, output)


Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ log_cli = true
log_cli_level = "INFO"
addopts = "-ra -q"
testpaths = [
"client/ayon_core/tests"
"client/ayon_core/tests",
"tests"
]
markers = [
"unit: Unit tests",
Expand Down
Empty file.
Loading
Loading