Skip to content

Subst.py: fix bugs and improve substitution performance#4867

Open
bdbaddog wants to merge 4 commits into
SCons:masterfrom
bdbaddog:claude_subst_pref_bug_updates_1
Open

Subst.py: fix bugs and improve substitution performance#4867
bdbaddog wants to merge 4 commits into
SCons:masterfrom
bdbaddog:claude_subst_pref_bug_updates_1

Conversation

@bdbaddog

@bdbaddog bdbaddog commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

TODO:

  • - try godot build
  • - try wesnoth build

Bug fixes:

  • ListSubber.expanded() never detected an already-expanded string (re.findall() never returns None), so the early-exit optimization added in 2019 was dead code. Fixed with a check that is only true when a value needs neither further '$' expansion nor word-splitting, guarding against appending empty values as empty words.
  • scons_subst()/scons_subst_list() no longer leak a __builtins__ key into the live construction environment dictionary when substitution raises (deletion now in a try/finally).
  • inspect.signature() failures on some C/builtin callables no longer crash; such callables are treated as not matching the (target, source, env, for_signature) convention.
  • NameError raised during scons_subst_list() now includes the name of the unknown variable in the message.
  • The overrides argument no longer mutates a caller-supplied lvars dict; removed mutable default arguments.
  • Removed Literal.__neq__, a misspelled (never-invoked) __ne__; Python 3 derives != from Literal.__eq__.

Performance:

  • Cache the inspect.signature() check per callable (~100x faster on that check).
  • Return plain strings with no further '$' expansions directly in StringSubber.expand(), skipping a dict copy and recursive pass.
  • str.partition() instead of str.split() for the recursion-guard key.

Measured on a representative command line
($CC $CCFLAGS $CPPDEFINES $GEN -c -o $TARGET $SOURCES), identical output before/after:

function old new improvement
scons_subst 20.7 us 12.8 us ~38% faster
scons_subst_list 37.4 us 25.1 us ~33% faster

Adds 7 regression tests covering each fix. Full test suite passes (remaining failures are pre-existing environment-dependent tests, verified identical against unmodified HEAD).

Contributor Checklist:

  • I have created a new test or updated the unit tests to cover the new/changed functionality.
  • I have updated CHANGES.txt and RELEASE.txt (and read the README.rst).
  • I have updated the appropriate documentation

Bug fixes:
- ListSubber.expanded() never detected an already-expanded string
  (re.findall() never returns None), so the early-exit optimization
  added in 2019 was dead code. Fixed with a check that is only true
  when a value needs neither further '$' expansion nor word-splitting,
  guarding against appending empty values as empty words.
- scons_subst()/scons_subst_list() no longer leak a __builtins__ key
  into the live construction environment dictionary when substitution
  raises (deletion now in a try/finally).
- inspect.signature() failures on some C/builtin callables no longer
  crash; such callables are treated as not matching the
  (target, source, env, for_signature) convention.
- NameError raised during scons_subst_list() now includes the name of
  the unknown variable in the message.
- The overrides argument no longer mutates a caller-supplied lvars
  dict; removed mutable default arguments.
- Removed Literal.__neq__, a misspelled (never-invoked) __ne__;
  Python 3 derives != from Literal.__eq__.

Performance:
- Cache the inspect.signature() check per callable (~100x faster on
  that check).
- Return plain strings with no further '$' expansions directly in
  StringSubber.expand(), skipping a dict copy and recursive pass.
- str.partition() instead of str.split() for the recursion-guard key.

Measured on a representative command line
('$CC $CCFLAGS $CPPDEFINES $GEN -c -o $TARGET $SOURCES'),
identical output before/after:

                      old         new       improvement
  scons_subst         20.7 us     12.8 us   ~38% faster
  scons_subst_list    37.4 us     25.1 us   ~33% faster

Adds 7 regression tests covering each fix. Full test suite passes
(remaining failures are pre-existing environment-dependent tests,
verified identical against unmodified HEAD).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bdbaddog bdbaddog added subst Problems with quoting, substitution Performance labels Jun 10, 2026
Comment thread SCons/Subst.py
Comment thread SCons/Subst.py
if key[0] == '{' or '.' in key:
if key[0] == '{':
key = key[1:-1]
if key[0] == '{':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

so we just don't need the check for "attribute access"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The original logic was checking for { or . and then checking for { and then chopping the bookended {}'s off and doing nothing with the .

The . gets handled by not being in lvars or gvars and thus it gets eval'd

It's never used to shortcut checking lvars or gvars, so is kinda pointless here.

Comment thread SCons/Subst.py
if key.startswith('{') or '.' in key:
if key.startswith('{'):
key = key[1:-1]
if key[0] == '{':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why get rid of startswith? And we don't need the attribute-access check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

same comment as above about .

it's more typing? Not exactly sure, but is there any reason to use startswith instead of just checking the 1 character for a single character?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

functionally no; readability-wise I think yes. I hate using indexing-slicing with just "magic numbers"; we can't avoid them with node lists where stuff[0] is magical and doesn't have an alternative, but I do like starts/endswith on strings. Not a big deal, though.

Comment thread SCons/Subst.py


def scons_subst(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars={}, lvars={}, conv=None, overrides: dict | None = None):
def scons_subst(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars=None, lvars=None, conv=None, overrides: dict | None = None):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a long-standing checker flag - mutable default arg. Using None as a sentinel and then adding checks is the usual solution - we've changed this in a few other places. Is there an actual benefit to the change? We don't change gvars (well, we change it temporarily).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There was an error condition where gvars or lvars could get set and not cleared and then cause issues on next call. (see the notes I posted in discord?)

Comment thread SCons/Subst.py Outdated
# caller's dictionary doing so.
if overrides:
lvars.update(overrides)
lvars = {**lvars, **overrides}

@mwichmann mwichmann Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe there's a way to restruture this plus the above - because in at least one flow we could now remake lvars twice - once from the copy, then here from the repacking to account for the override.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Here's a proposed rejig for this:

    # Build any special TARGET/SOURCE vars and apply overrides.
    # Only copy the caller's lvars once if we need to modify it.
    d = {}
    if 'TARGET' not in lvars:
        d = subst_dict(target, source)
    if d or overrides:
        lvars = {**lvars, **d, **overrides}

If this approach seems okay, I can make a new PR once this one is resolved - since this is essentially a new request let's not muddy this one. There's a small tweak needed in ActionTests in any case, the inconsistency there doesn't break until this proposed change makes it stricter that overrides needs to be a dict.

@bdbaddog bdbaddog Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

AI says

Good idea — it's cleaner and saves a copy, but the snippet as written has one bug: overrides defaults to None, and {**None} raises TypeError. It needs **(overrides or {}):

      d = {}
      if 'TARGET' not in lvars:
          d = subst_dict(target, source)
      if d or overrides:
          lvars = {**lvars, **d, **(overrides or {})}

  With that fix it's strictly equal-or-better than what's there now:

  - Both apply (TARGET missing and overrides given): current code copies twice (lvars.copy() + update, then {**lvars, **overrides}); yours builds one merged dict.
  - Only one applies: same single copy either way.
  - Neither applies: no copy either way.
  - Semantics are identical, including overrides winning over subst_dict keys (same precedence order).

  One side note: subst_dict() always returns a non-empty dict (it always sets TARGET/TARGETS/SOURCE/SOURCES, even to NullNodesList), so the if d: truthiness check — both in the current code and in your version — is effectively always true when the 'TARGET' not in
  lvars branch runs. Your if d or overrides: keeps it as a harmless guard.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yeah, I know it needs more scaffolding at the top. I didn't say it was a complete PR, though it works in my copy here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Oh, didn't read this all. I'd rather put the overrides stuff at the top of the function, though this works as well. With the other entry-point checks:

if overrides is None:
    overrides = {}

There's a small hange needed to correct an error in ActionTests.py if we do it my way though, as its local subst functions default overrides to False (i.e., bool instead of dict) before calling on to scons_subst.

Comment thread SCons/Subst.py
return result

def scons_subst_list(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars={}, lvars={}, conv=None, overrides: dict | None = None):
def scons_subst_list(strSubst, env, mode=SUBST_RAW, target=None, source=None, gvars=None, lvars=None, conv=None, overrides: dict | None = None):

@mwichmann mwichmann Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

same comments as for scons_subst

bdbaddog and others added 2 commits June 13, 2026 15:03
…tionary operations

Suggested by Mats Wichmann: merge TARGET/SOURCE variable detection and overrides
into a single dictionary operation. This avoids unnecessary copy operations when
neither special variables nor overrides need to be applied, improving performance
for common substitution cases.

Co-Authored-By: Mats Wichmann <mats@wichmann.us>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Fix TypeError when overrides is None by using (overrides or {}) to provide
an empty dict for unpacking.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Comment thread SCons/Subst.py
# Executor setting the variables.
# Build any special TARGET/SOURCE vars and apply overrides.
# Only copy the caller's lvars once if we need to modify it.
d = {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This new version would apply equally to scons_subst_list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done..

Apply the same dictionary operation consolidation and None-safety fix
to scons_subst_list() that was applied to scons_subst(). Both functions
now merge TARGET/SOURCE variables and overrides in a single operation,
improving performance and avoiding unnecessary copy operations.

Co-Authored-By: Mats Wichmann <mats@wichmann.us>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Performance subst Problems with quoting, substitution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants