Skip to content

#5186 env var expansion #5434

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

hassineabd
Copy link
Contributor

@hassineabd hassineabd commented May 16, 2025

@hassineabd hassineabd changed the title Issue 5186 env var expansion #5186 env var expansion May 16, 2025
Copy link
Member

@pekkaklarck pekkaklarck left a comment

Choose a reason for hiding this comment

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

Looks very good in general, but there are some style issues to be fixed and some design decisions to be made. Most important design decisions are these:

  1. Should variable be expanded already before splitting lines or handling comments? I believe yes, because it would have small benefits and it would fix the current bug with -N$value.
  2. Should we support #expandvars: as well or only # expandvars:? I don't care too much, but I believe making the space optional would be good.
  3. Should we support ${var=default} style? I think it would be nice, but it can also be added later if there are concrete needs.
  4. Should we support %var% style on Windows? I don't consider it that important.

elif line and not line.startswith("#"):
args.append(line)
args.append(self._expand_vars(line) if expand_vars else line)
Copy link
Member

Choose a reason for hiding this comment

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

It would be simpler to expand variables from the whole file before even splitting it into lines than doing that for each line. It would make it impossible to expand variables only from some parts of the lines, but I actually don't think that's a good idea in general. I'll comment that separately later.

if not first_line.startswith('# expandvars:'):
return False

pragma_value = first_line.split(':', 1)[1].strip().lower()
Copy link
Member

Choose a reason for hiding this comment

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

Should we also support #expandvars:? What about # expandvars: where the space is actually (and most likely accidentally) a non-break space? An easy way to support them would be using regular expressions. They are often complicated, but in this case I consider the resulting code simpler:

match = re.fullmatch('#\s*expandvars:\s*(.*?)\s*', lines[0], flags=re.IGNORECASE)
if not match:
    return False
value = match.group(1).lower()
return value not in ("false", "no", "off", "")

Copy link
Contributor Author

Choose a reason for hiding this comment

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

i think that would be : .upper() if we are goind to use FALSE_STRINGS instead of ("false", "no", "off", "")
otherwise , resolved

separator = self._get_option_separator(line)
if not separator:
return [line]
option, value = line.split(separator, 1)
if separator == " ":
value = value.strip()
if expand_vars:
value = self._expand_vars(value)
Copy link
Member

Choose a reason for hiding this comment

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

This implementation expands variable only from option values, not from option names. I don't think that's a good idea for the following reasons:

  1. On the commend line you can use --$name value just fine so the implementation is inconsistent with that. I see no problems with supporting variables in option names either.
  2. The implementation doesn't work correctly when using short options without a separator like -N$value. Trying to fix this would be pretty complicated, because short options can be combined like -XN$value. Related to this, at least the -N$value usage should get a test!
  3. If variables are parsed everywhere, it can be done before splitting content to lines which simplifies the overall implementation.
  4. If variables are parsed as the first step, it allows conditional usage like
    $COND --name Example
    
    where $COND could be set to # to exclude the line. Not sure is this that useful, but could come handy in some cases.

def _expand_vars(self, value):
# Handle escaping with Template.safe_substitute
# (fail gracefully without raising errors for missing variable
return Template(value).safe_substitute(os.environ)
Copy link
Member

Choose a reason for hiding this comment

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

  1. Using Template.safe_substitute instead of os.path.expandvars is probably a good idea, but the latter would support %name% substitution on Windows. If that's important, we ought to be able to to extend Template to support it.
  2. One benefit of using Template is that we could extend it to support ${name=default} style somewhat easily. It could even be part of this enhancement, but it can also be added later.
  3. No need to explain how safe_substiture works here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thanks for the review

i see the trade-oof between using Template.safe_substitute and os.path.expandvars

1- i thought a bit about extending Template to support both ${var} and %var% formats, the solution i saw is :
creating like " EnvVarTemplate subclass " that extend functionality through inheritance to support %var% formats. I think that this would require regex pattern. however, I'm still wondering if adding windows style variable support critical for the current enhancement? I think that i can push this enhancement later since it will requires more tests

2- i' am considering adding this support ( ${name=default} ) but i don't see another solution other then this :

    def _expand_vars(self, value):
        result = Template(value).safe_substitute(os.environ)
        regexpatter, = r'\${([^}]+?)=([^}]*)}'
        def replace_with_default_value(match):
            var, default = match.groups()
            return os.environ.get(var, default)
        return re.sub(regexpatter, replace_with_default_value, result)

3- intended to make a comment for design reminder

@pekkaklarck
Copy link
Member

pekkaklarck commented May 19, 2025

I mentioned in my review that we could add support for defaults with syntax like ${name=default}. This is how it could be done in practice:

from collections.abc import Mapping
from string import Template


class TemplateWithDefaults(Template):
    braceidpattern = r'(?a:[_a-z][_a-z0-9]*(=[^}]*)?)'


class NamespaceWithDefaults(Mapping):

    def __init__(self, data=None, **extra):
        self.data = {**(data or {}), **extra}

    def __getitem__(self, key):
        if '=' in key:
            key, default = key.split('=', 1)
            return self.data.get(key, default)
        return self.data[key]

    def __iter__(self):
        return iter(self.data)

    def __len__(self):
        return len(self.data)


# Test the above:
tmpl = TemplateWithDefaults('${x}, ${y=bär} and ${z}')
ns = NamespaceWithDefaults(x='foo')
print(tmpl.safe_substitute(ns))  # => 'foo, bär and ${z}'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants