Repository: SCons/scons

Issue:
Users callables can have silent TypeError exceptions
Users are able to supply there own callable classes and put them into environment commands as expandable strings. If a user expereinces a TypeError exception in there __call__ method, it will silently be passed over, because scons is catching the TypeError to determine if the callable is not matching the function signature expected.

Example SConstruct:
```
import os
class TestPathIssue(object):
    def __init__(self, thing, makePathsRelative = True, debug = False):
        pass
    def __call__(self, target, source, env, for_signature):
        class FakeClass(object):
            pass
        # simulate the exception
        fake = FakeClass()
        os.path.splitdrive(fake)

env = Environment()
env["TESTCLASS"] = TestPathIssue
env["CCCOM"] = "$CC $_CCCOMCOM $CCFLAGS -o ${TESTCLASS('$TARGET')} -c ${TESTCLASS('$SOURCES')}"

env.Program(target='foo', source='foo.c')
```
results in this failure:
```
scons: Reading SConscript files ...
scons: done reading SConscript files.
scons: Building targets ...
gcc -o <SCons.Script.TestPathIssue object at 0x7fa562bfada0> -c <SCons.Script.TestPathIssue object at 0x7fa562bfaa90>
sh: 1: Syntax error: end of file unexpected
scons: *** [foo.o] Error 2
```

I would expect to see my exception from my callable.

**Required information**
* Link to SCons Users thread discussing your issue.
https://pairlist4.pair.net/pipermail/scons-users/2020-May/008149.html

* Version of SCons
3.1.2

* Version of Python
2.7 and 3.6

* Which python distribution if applicable (python.org, cygwin, anaconda, macports, brew,etc)\
python.org

* How you installed SCons
pip

* What Platform are you on? (Linux/Windows and which version)
Linux

* How to reproduce your issue?  Please include a small self contained reproducer. Likely a SConstruct should do for most issues.
See example at top

* How you invoke scons (The command line you're using "scons --flags some_arguments")
scons


Discussion:

Resolving patch:
diff --git a/SCons/Subst.py b/SCons/Subst.py
--- a/SCons/Subst.py
+++ b/SCons/Subst.py
@@ -30,7 +30,7 @@
 
 import collections
 import re
-
+from inspect import signature
 import SCons.Errors
 
 from SCons.Util import is_String, is_Sequence
@@ -420,12 +420,16 @@ def func(l, conv=self.conv, substitute=self.substitute, lvars=lvars):
                 return conv(substitute(l, lvars))
             return list(map(func, s))
         elif callable(s):
-            try:
+            # SCons has the unusual Null class where any __getattr__ call returns it's self, 
+            # which does not work the signature module, and the Null class returns an empty
+            # string if called on, so we make an exception in this condition for Null class
+            if (isinstance(s, SCons.Util.Null) or
+                set(signature(s).parameters.keys()) == set(['target', 'source', 'env', 'for_signature'])):
                 s = s(target=lvars['TARGETS'],
                      source=lvars['SOURCES'],
                      env=self.env,
                      for_signature=(self.mode != SUBST_CMD))
-            except TypeError:
+            else:
                 # This probably indicates that it's a callable
                 # object that doesn't match our calling arguments
                 # (like an Action).
@@ -590,12 +594,16 @@ def expand(self, s, lvars, within_list):
                 self.substitute(a, lvars, 1)
                 self.next_word()
         elif callable(s):
-            try:
+            # SCons has the unusual Null class where any __getattr__ call returns it's self, 
+            # which does not work the signature module, and the Null class returns an empty
+            # string if called on, so we make an exception in this condition for Null class
+            if (isinstance(s, SCons.Util.Null) or
+                set(signature(s).parameters.keys()) == set(['target', 'source', 'env', 'for_signature'])):
                 s = s(target=lvars['TARGETS'],
                      source=lvars['SOURCES'],
                      env=self.env,
                      for_signature=(self.mode != SUBST_CMD))
-            except TypeError:
+            else:
                 # This probably indicates that it's a callable
                 # object that doesn't match our calling arguments
                 # (like an Action).


Test patch:
diff --git a/test/Subst/TypeError.py b/test/Subst/TypeError.py
--- a/test/Subst/TypeError.py
+++ b/test/Subst/TypeError.py
@@ -85,7 +85,11 @@ def func(a, b, c):
 
 test.run(status=2, stderr=expect)
 
+# user callable exceptions (Github issue #3654):
+test.file_fixture('test_main.c')
+test.file_fixture('./fixture/SConstruct.callable_exception', 'SConstruct')
 
+test.run(status=2, stderr=r'.*TypeError\s:\sUser\scallable\sexception.*')
 
 test.pass_test()
 
diff --git a/test/Subst/fixture/SConstruct.callable_exception b/test/Subst/fixture/SConstruct.callable_exception
new file mode 100644
--- /dev/null
+++ b/test/Subst/fixture/SConstruct.callable_exception
@@ -0,0 +1,11 @@
+class TestCallable(object):
+    def __init__(self, thing, makePathsRelative = True, debug = False):
+        pass
+    def __call__(self, target, source, env, for_signature):
+       raise TypeError("User callable exception")
+
+env = Environment()
+env["TESTCLASS"] = TestCallable
+env["CCCOM"] = "$CC $_CCCOMCOM $CCFLAGS -o ${TESTCLASS('$TARGET')} -c ${TESTCLASS('$SOURCES')}"
+
+env.Program(target='test_main', source='test_main.c')
\ No newline at end of file