Repository: SCons/scons

Issue:
Make the VS tool less intrusive of the SCONS_HOME variable
This issue was originally created at: 2011-04-18 19:17:10.
This issue was reported by: `f94sbu`.
f94sbu said at 2011-04-18 19:17:10
>The `SCONS_HOME` variable is currently set up by the msvs tool and it is pulled directly from the `os.environ`. This forces the `SCONS_HOME` variable to set after the msvs tool is being run. It would be better if `os.environ` is searched when `SCONS_HOME` is needed instead of overwriting a value that might have been set up prior.




Discussion:
Not sure what this is actually exposing as a problem. "It would be better"...  @bdbaddog @jcbrill any reason not to just toss this one?
The msvs tool code usage of `SCONS_HOME` does not appear to have changed since `2.0.0.final.0`.

`SCONS_HOME` is retrieved from the SCons environment in the `getExecScriptMain` function and set in the SCons environment in the `generate` function in the msvs tool (`msvs.py`):
```
def getExecScriptMain(env, xml=None):
    scons_home = env.get('SCONS_HOME')
    if not scons_home and 'SCONS_LIB_DIR' in os.environ:
        scons_home = os.environ['SCONS_LIB_DIR']
    ...
    
...

def generate(env):
    ...
    env['SCONS_HOME'] = os.environ.get('SCONS_HOME')

```

Currently, the `generate` function overwrites any existing SCons environment `SCONS_HOME` value with the OS environment `SCONS_HOME` value.

If a valid `env['SCONS_HOME']` value existed before the msvc tool initialized, it would be overwritten with the OS environment value which could be the same value, a different value, or `None`.

It appears that the issue report is advocating for assigning the SCons environment `SCONS_HOME` value only if it does not already exist in the SCons environment (i.e., the user has not defined `SCONS_HOME` in the SCons environment).

The simplest (but perhaps not the best) change would be to update the `generate` function in the msvs tool (`msvs.py`) to only use the OS environment value if the SCons environment value evaluates as False:
```
def generate(env):
    ...
    env['SCONS_HOME'] = env.get('SCONS_HOME') or os.environ.get('SCONS_HOME')
```

From a user standpoint, I could see where the current behavior might be construed as "inconvenient".

Whether or not this is important is above my pay grade :)

EDIT:
Explicit alternate method (only assign if not already defined as opposed to retrieving and assigning an existing value):
```
def generate(env):
    ...
    if not env.get('SCONS_HOME'):
        env['SCONS_HOME'] = os.environ.get('SCONS_HOME')

```

we're not usually big env var fans, but this one already exists. l have no opinion, just trolling through backlog 
I think this shell env var is long obsoleted.
Hmm looks like it may be used to populate the generated msvs project file so it knows where to execute scons from.

interesting.. might be a better way to do this?
as I recall from the look the other day, that's what it's for. We do use consvars to pass information around...
Full excerpt from a reasonably recent SCons:
```
# This is how we re-invoke SCons from inside MSVS Project files.
# The problem is that we might have been invoked as either scons.bat
# or scons.py.  If we were invoked directly as scons.py, then we could
# use sys.argv[0] to find the SCons "executable," but that doesn't work
# if we were invoked as scons.bat, which uses "python -c" to execute
# things and ends up with "-c" as sys.argv[0].  Consequently, we have
# the MSVS Project file invoke SCons the same way that scons.bat does,
# which works regardless of how we were invoked.
def getExecScriptMain(env, xml=None):
    scons_home = env.get('SCONS_HOME')
    if not scons_home and 'SCONS_LIB_DIR' in os.environ:
        scons_home = os.environ['SCONS_LIB_DIR']
    if scons_home:
        exec_script_main = "from os.path import join; import sys; sys.path = [ r'%s' ] + sys.path; import SCons.Script; SCons.Script.main()" % scons_home
    else:
        version = SCons.__version__
        exec_script_main = "from os.path import join; import sys; sys.path = [ join(sys.prefix, 'Lib', 'site-packages', 'scons-%(version)s'), join(sys.prefix, 'scons-%(version)s'), join(sys.prefix, 'Lib', 'site-packages', 'scons'), join(sys.prefix, 'scons') ] + sys.path; import SCons.Script; SCons.Script.main()" % locals()
    if xml:
        exec_script_main = xmlify(exec_script_main)
    return exec_script_main
```

If I understand the code above correctly, the `SCONS_HOME` variable is likely only used when the SCons may not be found on the PYTHONPATH or to force a specific location.  It seems to be written to handle the case where `SCONS_HOME` is not defined.

I'm guessing that the original issue was raised because a user-defined `SCONS_HOME` in SCons was being over-written when the tool initialized. One might be setting the SCons environment value from a command-line option, etc.  The OS environment variable does not have to be defined.  Unfortunately, the way it is written it would overwrite an existing value with None when  the OS value is undefined.
Couldn't we just change all that to `python <location of scons script itself>`. where python is sys.executable?
Observations:
* The `SCONS_HOME` system environment variable is *required* when running the test suite and using an SCons folder that is not installed in python (i.e., standalone SCons folder).
* When running the test suite and using an SCons folder that is not installed in python and the `SCONS_HOME` system environment variable is not defined, the MSVS exec tests likely produce a *false positive* result due to the way the tests are written.  This has been verified with 14.3.
* The current `Tool\msvs.py` code *should* be modified.  The order matters:
  * The `getExecScriptMain` function may be called before the `generate` function in which case the `SCONS_HOME` system environment variable has not been retrieved and assigned which produces an invalid msvs project file.
  * If `SCONS_HOME` is defined in the SCons environment and not in the system environment, the `getExecScriptMain` function will use the desired `SCONS_HOME` value which correctly populates the msvs project file but the `generate` function call may overwrite the desired value with None (this issue report).

### Code Configurations (`Tool\msvs.py`)

Current `Tool\msvs.py` code:
```
def getExecScriptMain(env, xml=None):
    scons_home = env.get('SCONS_HOME')
    ...
    
def generate(env) -> None:
    ...
    env['SCONS_HOME'] = os.environ.get('SCONS_HOME')
```

Proposed `Tool\msvs.py` code:
```
def getExecScriptMain(env, xml=None):
    if 'SCONS_HOME' not in env:
        env['SCONS_HOME'] = os.environ.get('SCONS_HOME')
    scons_home = env.get('SCONS_HOME')
    ...
    
def generate(env) -> None:
    ...
    if 'SCONS_HOME' not in env:
        env['SCONS_HOME'] = os.environ.get('SCONS_HOME')
```

### Test Configurations

Test 1:
* OS environment variable `SCONS_HOME` undefined (set "SCONS_HOME=")
* SCons environment variable `SCONS_HOME` undefined
* sconstruct (`test\MSVS\vs-14.3-exec.py` with minor change):
  ```
  env=Environment(MSVC_VERSION = '14.3')

  env.MSVSProject(target = 'foo.vcxproj',
                  srcs = ['foo.c'],
                  buildtarget = 'foo.exe',
                  variant = 'Release',
                  DebugSettings = {'LocalDebuggerCommandArguments':'echo "<foo.c>" > output.txt'})
                
  env.Program('foo.c')
  ```

Test 2:
* OS environment variable `SCONS_HOME` defined (set "SCONS_HOME=S:\SCons\jbrill-msvc-pchnode")
* SCons environment variable `SCONS_HOME` undefined
* sconstruct (`test\MSVS\vs-14.3-exec.py` with minor change):
  ```
  env=Environment(MSVC_VERSION = '14.3')

  env.MSVSProject(target = 'foo.vcxproj',
                  srcs = ['foo.c'],
                  buildtarget = 'foo.exe',
                  variant = 'Release',
                  DebugSettings = {'LocalDebuggerCommandArguments':'echo "<foo.c>" > output.txt'})
                
  env.Program('foo.c')
  ```


Test 3:
* OS environment variable `SCONS_HOME` undefined (set "SCONS_HOME=")
* SCons environment variable `SCONS_HOME` defined (SCONS_HOME=r'S:\SCons\jbrill-msvc-pchnode')
* sconstruct (`test\MSVS\vs-14.3-exec.py` with minor change):
  ```
  env=Environment(MSVC_VERSION = '14.3', SCONS_HOME=r'S:\SCons\jbrill-msvc-pchnode')

  env.MSVSProject(target = 'foo.vcxproj',
                  srcs = ['foo.c'],
                  buildtarget = 'foo.exe',
                  variant = 'Release',
                  DebugSettings = {'LocalDebuggerCommandArguments':'echo "<foo.c>" > output.txt'})
                
  env.Program('foo.c')
  ```
  
Skeleton batch file:
```
@setlocal

@set PYEXE=E:\Python\python-3.6.8-embed-amd64\python.exe
@set DEVENVCOM=C:\Software\MSVS-2022-143-Com\Common7\IDE\devenv.com
@set SCONS_BRANCH=S:\SCons\jbrill-msvc-pchnode

@set "SCONS_HOME=%SCONS_BRANCH%"
@rem set "SCONS_HOME="

@echo prepare
@call :clean-all

@echo +++batch SCONS_HOME=%SCONS_HOME%

%PYEXE% %SCONS_BRANCH%\scripts\scons.py

@echo Run foo.exe
@.\foo.exe

@echo remove foo.exe foo.obj .sconsign.dblite
@call :clean-prog

@echo Build MSVS solution
%DEVENVCOM% foo.sln /build Release

@echo Run foo.exe
@.\foo.exe

@endlocal
@goto :eof

:clean-prog
@if exist foo.obj @del foo.obj
@if exist foo.exe @del foo.exe
@if exist .sconsign.dblite @del .sconsign.dblite
@goto :eof

:clean-proj
@if exist foo.sln @del foo.sln
@if exist foo.vcxproj @del foo.vcxproj
@if exist foo.vcxproj.filters @del foo.vcxproj.filters
@if exist foo.vcxproj.user @del foo.vcxproj.user
@if exist .vs @rmdir /s /q .vs
@if exist Release @rmdir /s /q Release
@goto :eof

:clean-all
@call :clean-prog
@call :clean-proj
@goto :eof
```
  
### Results

| Run | Code | Test | Result | Expected | Comment |
| --- | ---- | ---- | ------ | -------- | ------- |
| 1   | Current | 1 | Fail | Yes | Generated project assumes installed in python |
| 2   | Current | 2 | Fail | No  | Generated project erroneously assumes installed in python |
| 3   | Current | 3 | Pass | Yes | Generated project known to use standalone folder |
| 4   | Proposed | 1 | Fail | Yes | Generated project assumes installed in python |
| 5   | Proposed | 2 | Pass | Yes | Generated project known to use standalone folder |
| 6   | Proposed | 3 | Pass | Yes | Generated project known to use standalone folder |

Log file using the current code, setting the OS `SCONS_HOME`, and hard-code print statements in the batch file, code, and sconstruct (Run 2 above):
```
prepare
+++batch SCONS_HOME=S:\SCons\jbrill-msvc-pchnode

S:\SCons\py-sandbox\msvs-proj>E:\Python\python-3.6.8-embed-amd64\python.exe S:\SCons\jbrill-msvc-pchnode\scripts\scons.py 
scons: Reading SConscript files ...
scons: +++msvs.getExecScriptMain None
scons: +++msvs.generate 1 None
scons: +++msvs.generate 2 S:\SCons\jbrill-msvc-pchnode
scons: +++sconstruct S:\SCons\jbrill-msvc-pchnode
scons: done reading SConscript files.
scons: Building targets ...
scons: +++msvs.getExecScriptMain None
scons: +++msvs.generate 1 None
scons: +++msvs.generate 2 S:\SCons\jbrill-msvc-pchnode
cl /Fofoo.obj /c foo.c /nologo
foo.c
link /nologo /OUT:foo.exe foo.obj
Adding 'foo - Release|Win32' to 'foo.vcxproj'
scons: done building targets.
Run foo.exe
foo.c
remove foo.exe foo.obj .sconsign.dblite
Build MSVS solution

S:\SCons\py-sandbox\msvs-proj>C:\Software\MSVS-2022-143-Com\Common7\IDE\devenv.com foo.sln /build Release 

Microsoft Visual Studio 2022 Version 17.8.3.
Copyright (C) Microsoft Corp. All rights reserved.
Build started at 9:18 AM...
1>------ Build started: Project: foo, Configuration: Release Win32 ------
1>Starting SCons
1>Traceback (most recent call last):
1>  File "<string>", line 1, in <module>
1>ModuleNotFoundError: No module named 'SCons'
1>C:\Software\MSVS-2022-143-Com\MSBuild\Microsoft\VC\v170\Microsoft.MakeFile.Targets(45,5): error MSB3073: The command "echo Starting SCons && "E:\Python\python-3.6.8-embed-amd64\python.exe" -c "from os.path import join; import sys; sys.path = [ join(sys.prefix, 'Lib', 'site-packages', 'scons-4.6.1'), join(sys.prefix, 'scons-4.6.1'), join(sys.prefix, 'Lib', 'site-packages', 'scons'), join(sys.prefix, 'scons') ] + sys.path; import SCons.Script; SCons.Script.main()" -C "S:\SCons\py-sandbox\msvs-proj" -f SConstruct "foo.exe"" exited with code 1.
1>Done building project "foo.vcxproj" -- FAILED.
========== Build: 0 succeeded, 1 failed, 0 up-to-date, 0 skipped ==========
========== Build completed at 9:18 AM and took 00.712 seconds ==========
Run foo.exe
'.\foo.exe' is not recognized as an internal or external command,
operable program or batch file.
```

Log file using the proposed code, setting the OS `SCONS_HOME`, and hard-code print statements in the batch file, code, and sconstruct (Run 5 above):
```
prepare
+++batch SCONS_HOME=S:\SCons\jbrill-msvc-pchnode

S:\SCons\py-sandbox\msvs-proj>E:\Python\python-3.6.8-embed-amd64\python.exe S:\SCons\jbrill-msvc-pchnode\scripts\scons.py 
scons: Reading SConscript files ...
scons: +++msvs.getExecScriptMain S:\SCons\jbrill-msvc-pchnode
scons: +++msvs.generate 1 S:\SCons\jbrill-msvc-pchnode
scons: +++msvs.generate 2 S:\SCons\jbrill-msvc-pchnode
scons: +++sconstruct S:\SCons\jbrill-msvc-pchnode
scons: done reading SConscript files.
scons: Building targets ...
scons: +++msvs.getExecScriptMain S:\SCons\jbrill-msvc-pchnode
scons: +++msvs.generate 1 S:\SCons\jbrill-msvc-pchnode
scons: +++msvs.generate 2 S:\SCons\jbrill-msvc-pchnode
cl /Fofoo.obj /c foo.c /nologo
foo.c
link /nologo /OUT:foo.exe foo.obj
Adding 'foo - Release|Win32' to 'foo.vcxproj'
scons: done building targets.
Run foo.exe
foo.c
remove foo.exe foo.obj .sconsign.dblite
Build MSVS solution

S:\SCons\py-sandbox\msvs-proj>C:\Software\MSVS-2022-143-Com\Common7\IDE\devenv.com foo.sln /build Release 

Microsoft Visual Studio 2022 Version 17.8.3.
Copyright (C) Microsoft Corp. All rights reserved.
Build started at 9:34 AM...
1>------ Build started: Project: foo, Configuration: Release Win32 ------
1>Starting SCons
1>scons: Reading SConscript files ...
1>scons: +++msvs.getExecScriptMain S:\SCons\jbrill-msvc-pchnode
1>scons: +++msvs.generate 1 S:\SCons\jbrill-msvc-pchnode
1>scons: +++msvs.generate 2 S:\SCons\jbrill-msvc-pchnode
1>scons: +++sconstruct S:\SCons\jbrill-msvc-pchnode
1>scons: done reading SConscript files.
1>scons: Building targets ...
1>scons: +++msvs.getExecScriptMain S:\SCons\jbrill-msvc-pchnode
1>scons: +++msvs.generate 1 S:\SCons\jbrill-msvc-pchnode
1>scons: +++msvs.generate 2 S:\SCons\jbrill-msvc-pchnode
1>cl /Fofoo.obj /c foo.c /nologo
1>foo.c
1>link /nologo /OUT:foo.exe foo.obj
1>scons: done building targets.
========== Build: 1 succeeded, 0 failed, 0 up-to-date, 0 skipped ==========
========== Build completed at 9:34 AM and took 02.650 seconds ==========
Run foo.exe
foo.c
```

### Test suite

Example where the program build passes, the msvs project build fails, and the test passes when the OS `SCONS_HOME` environment variable is not defined and the SCons tests are run with a standalone root:
```
...
test.subdir('sub dir')

test.write(['sub dir', 'SConstruct'], """\
env=Environment(MSVS_VERSION = '%(msvs_version)s')

env.MSVSProject(target = 'foo.vcxproj',
                srcs = ['foo.c'],
                buildtarget = 'foo.exe',
                variant = 'Release',
                DebugSettings = {'LocalDebuggerCommandArguments':'echo "<foo.c>" > output.txt'})
env.Program('foo.c')
""" % locals())

test.write(['sub dir', 'foo.c'], r"""
#include <stdio.h>
int
main(int argc, char *argv)
{
    printf("foo.c\n");
    exit (0);
}
""")

!!! Generates: foo.obj, foo.exe, and .sconsign.dblite
test.run(chdir='sub dir', arguments='.') 

test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))

import SCons.Platform.win32
system_dll_path = os.path.join(SCons.Platform.win32.get_system_root(), 'System32')
os.environ['PATH'] = os.environ['PATH'] + os.pathsep + system_dll_path

# !!! The following fails !!!
test.run(chdir='sub dir',
         program=[test.get_msvs_executable(msvs_version)], 
         arguments=['foo.sln', '/build', 'Release'])

# !!! foo.exe STILL EXISTS from env.Program() build !!!
test.run(program=test.workpath('sub dir', 'foo'), stdout="foo.c\n")
test.validate_msvs_file(test.workpath('sub dir', 'foo.vcxproj.user'))

# !!! Program build passes, project build fails: FALSE POSITIVE !!!
test.pass_test()
```

There does not appear to be an issue with scons-local installations and the test suite and the MSVS executables.  If looks like when running the test suite that `SCONS_LIB_DIR` is populated with the scons-local path when `SCONS_HOME` is not defined.

Most of the tests pass when the object files (i.e., `foo.exe` and `foo.obj`) are deleted before running the msvs executable against the generated project file.

At present, the the vc 6.0 test fails but appears to work outside of the test environment.  Not sure what's going on yet.

Resolving patch:
diff --git a/SCons/Tool/MSCommon/vc.py b/SCons/Tool/MSCommon/vc.py
--- a/SCons/Tool/MSCommon/vc.py
+++ b/SCons/Tool/MSCommon/vc.py
@@ -718,6 +718,7 @@ def _skip_sendtelemetry(env):
     '14.0': [
         (SCons.Util.HKEY_LOCAL_MACHINE, r'Microsoft\VisualStudio\14.0\Setup\VC\ProductDir')],
     '14.0Exp': [
+        (SCons.Util.HKEY_LOCAL_MACHINE, r'Microsoft\WDExpress\14.0\Setup\VS\ProductDir'),
         (SCons.Util.HKEY_LOCAL_MACHINE, r'Microsoft\VCExpress\14.0\Setup\VC\ProductDir')],
     '12.0': [
         (SCons.Util.HKEY_LOCAL_MACHINE, r'Microsoft\VisualStudio\12.0\Setup\VC\ProductDir'),
@@ -905,9 +906,14 @@ def find_vc_pdir(env, msvc_version):
         except OSError:
             debug('no VC registry key %s', repr(key))
         else:
-            if msvc_version == '9.0' and key.lower().endswith('\\vcforpython\\9.0\\installdir'):
-                # Visual C++ for Python registry key is installdir (root) not productdir (vc)
-                comps = os.path.join(comps, 'VC')
+            if msvc_version == '9.0':
+                if key.lower().endswith('\\vcforpython\\9.0\\installdir'):
+                    # Visual C++ for Python registry key is installdir (root) not productdir (vc)
+                    comps = os.path.join(comps, 'VC')
+            elif msvc_version == '14.0Exp':
+                if key.lower().endswith('\\setup\\vs\\productdir'):
+                    # Visual Studio 14.0 Express registry key is installdir (root) not productdir (vc)
+                    comps = os.path.join(comps, 'VC')
             debug('found VC in registry: %s', comps)
             if os.path.exists(comps):
                 return comps
diff --git a/SCons/Tool/msvs.py b/SCons/Tool/msvs.py
--- a/SCons/Tool/msvs.py
+++ b/SCons/Tool/msvs.py
@@ -145,6 +145,8 @@ def msvs_parse_version(s):
 # the MSVS Project file invoke SCons the same way that scons.bat does,
 # which works regardless of how we were invoked.
 def getExecScriptMain(env, xml=None):
+    if 'SCONS_HOME' not in env:
+        env['SCONS_HOME'] = os.environ.get('SCONS_HOME')
     scons_home = env.get('SCONS_HOME')
     if not scons_home and 'SCONS_LIB_DIR' in os.environ:
         scons_home = os.environ['SCONS_LIB_DIR']
@@ -2109,7 +2111,9 @@ def generate(env) -> None:
     env['GET_MSVSSOLUTIONSUFFIX']  = GetMSVSSolutionSuffix
     env['MSVSPROJECTSUFFIX']  = '${GET_MSVSPROJECTSUFFIX}'
     env['MSVSSOLUTIONSUFFIX']  = '${GET_MSVSSOLUTIONSUFFIX}'
-    env['SCONS_HOME'] = os.environ.get('SCONS_HOME')
+
+    if 'SCONS_HOME' not in env:
+        env['SCONS_HOME'] = os.environ.get('SCONS_HOME')
 
 def exists(env):
     return msvc_setup_env_tool(env, tool=tool_name)


Test patch:
diff --git a/test/MSVS/vs-10.0-exec.py b/test/MSVS/vs-10.0-exec.py
--- a/test/MSVS/vs-10.0-exec.py
+++ b/test/MSVS/vs-10.0-exec.py
@@ -90,6 +90,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-10.0Exp-exec.py b/test/MSVS/vs-10.0Exp-exec.py
--- a/test/MSVS/vs-10.0Exp-exec.py
+++ b/test/MSVS/vs-10.0Exp-exec.py
@@ -91,6 +91,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-11.0-exec.py b/test/MSVS/vs-11.0-exec.py
--- a/test/MSVS/vs-11.0-exec.py
+++ b/test/MSVS/vs-11.0-exec.py
@@ -91,6 +91,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-11.0Exp-exec.py b/test/MSVS/vs-11.0Exp-exec.py
--- a/test/MSVS/vs-11.0Exp-exec.py
+++ b/test/MSVS/vs-11.0Exp-exec.py
@@ -91,6 +91,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-14.0-exec.py b/test/MSVS/vs-14.0-exec.py
--- a/test/MSVS/vs-14.0-exec.py
+++ b/test/MSVS/vs-14.0-exec.py
@@ -92,6 +92,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-14.0Exp-exec.py b/test/MSVS/vs-14.0Exp-exec.py
--- a/test/MSVS/vs-14.0Exp-exec.py
+++ b/test/MSVS/vs-14.0Exp-exec.py
@@ -55,10 +55,14 @@
 
 test.run(arguments = '-n -q -Q -f -', stdin = """\
 env = Environment(tools = ['msvc'], MSVS_VERSION='%(msvs_version)s')
-sconsEnv = repr(env['ENV'])
-print("os.environ.update(" + sconsEnv + ")")
+if env.WhereIs('cl'):
+    print("os.environ.update(%%s)" %% repr(env['ENV']))
 """ % locals())
 
+if test.stdout() == "":
+    msg = "Visual Studio %s missing cl.exe; skipping test.\n" % msvs_version
+    test.skip_test(msg)
+
 exec(test.stdout())
 
 
@@ -88,6 +92,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-14.1-exec.py b/test/MSVS/vs-14.1-exec.py
--- a/test/MSVS/vs-14.1-exec.py
+++ b/test/MSVS/vs-14.1-exec.py
@@ -92,6 +92,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-14.2-exec.py b/test/MSVS/vs-14.2-exec.py
--- a/test/MSVS/vs-14.2-exec.py
+++ b/test/MSVS/vs-14.2-exec.py
@@ -92,6 +92,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-14.3-exec.py b/test/MSVS/vs-14.3-exec.py
--- a/test/MSVS/vs-14.3-exec.py
+++ b/test/MSVS/vs-14.3-exec.py
@@ -92,6 +92,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcxproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-6.0-exec.py b/test/MSVS/vs-6.0-exec.py
--- a/test/MSVS/vs-6.0-exec.py
+++ b/test/MSVS/vs-6.0-exec.py
@@ -90,9 +90,11 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.run(chdir='sub dir',
          program=[test.get_msvs_executable(msvs_version)],
-         arguments=['Test.dsp', '/MAKE', 'foo - Win32 Release'])
+         arguments=['foo.dsp', '/MAKE', 'foo - Win32 Release'])
 
 test.run(program=test.workpath('sub dir', 'foo'), stdout="foo.c\n")
 
diff --git a/test/MSVS/vs-7.0-exec.py b/test/MSVS/vs-7.0-exec.py
--- a/test/MSVS/vs-7.0-exec.py
+++ b/test/MSVS/vs-7.0-exec.py
@@ -90,6 +90,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcproj'))
 
 test.run(chdir='sub dir',
diff --git a/test/MSVS/vs-7.1-exec.py b/test/MSVS/vs-7.1-exec.py
--- a/test/MSVS/vs-7.1-exec.py
+++ b/test/MSVS/vs-7.1-exec.py
@@ -90,6 +90,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcproj'))
 
 test.run(chdir='sub dir',
diff --git a/test/MSVS/vs-8.0-exec.py b/test/MSVS/vs-8.0-exec.py
--- a/test/MSVS/vs-8.0-exec.py
+++ b/test/MSVS/vs-8.0-exec.py
@@ -91,6 +91,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-8.0Exp-exec.py b/test/MSVS/vs-8.0Exp-exec.py
--- a/test/MSVS/vs-8.0Exp-exec.py
+++ b/test/MSVS/vs-8.0Exp-exec.py
@@ -91,6 +91,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-9.0-exec.py b/test/MSVS/vs-9.0-exec.py
--- a/test/MSVS/vs-9.0-exec.py
+++ b/test/MSVS/vs-9.0-exec.py
@@ -91,6 +91,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcproj'))
 
 import SCons.Platform.win32
diff --git a/test/MSVS/vs-9.0Exp-exec.py b/test/MSVS/vs-9.0Exp-exec.py
--- a/test/MSVS/vs-9.0Exp-exec.py
+++ b/test/MSVS/vs-9.0Exp-exec.py
@@ -91,6 +91,8 @@
 
 test.run(chdir='sub dir', arguments='.')
 
+test.unlink_files('sub dir', ['foo.exe', 'foo.obj', '.sconsign.dblite'])
+
 test.vcproj_sys_path(test.workpath('sub dir', 'foo.vcproj'))
 
 import SCons.Platform.win32
diff --git a/testing/framework/TestCmd.py b/testing/framework/TestCmd.py
--- a/testing/framework/TestCmd.py
+++ b/testing/framework/TestCmd.py
@@ -1872,6 +1872,31 @@ def unlink(self, file) -> None:
         file = self.canonicalize(file)
         os.unlink(file)
 
+    def unlink_files(self, dirpath, files):
+        """Unlinks a list of file names from the specified directory.
+
+        The directory path may be a list, in which case the elements are
+        concatenated with the os.path.join() method.
+
+        A file name may be a list, in which case the elements are
+        concatenated with the os.path.join() method.
+
+        The directory path and file name are concatenated with the
+        os.path.join() method.  The resulting file path is assumed to be
+        under the temporary working directory unless it is an absolute path
+        name.  An attempt to unlink the resulting file is made only when the
+        file exists otherwise the file path is ignored.
+        """
+        if is_List(dirpath):
+            dirpath = os.path.join(*dirpath)
+        for file in files:
+            if is_List(file):
+                file = os.path.join(*file)
+            filepath = os.path.join(dirpath, file)
+            filepath = self.canonicalize(filepath)
+            if os.path.exists(filepath):
+                self.unlink(filepath)
+
     def verbose_set(self, verbose) -> None:
         """Sets the verbose level."""
         self.verbose = verbose
diff --git a/testing/framework/TestCmdTests.py b/testing/framework/TestCmdTests.py
--- a/testing/framework/TestCmdTests.py
+++ b/testing/framework/TestCmdTests.py
@@ -2993,6 +2993,103 @@ def test_unlink(self):
                 os.chmod(wdir_file5, 0o600)
 
 
+class unlink_files_TestCase(TestCmdTestCase):
+    def test_unlink_files(self):
+        """Test unlink_files()"""
+        test = TestCmd.TestCmd(workdir = '', subdir = 'foo')
+        wdir_file1 = os.path.join(test.workdir, 'file1')
+        wdir_file2 = os.path.join(test.workdir, 'file2')
+        wdir_foo_file3a = os.path.join(test.workdir, 'foo', 'file3a')
+        wdir_foo_file3b = os.path.join(test.workdir, 'foo', 'file3b')
+        wdir_foo_file3c = os.path.join(test.workdir, 'foo', 'file3c')
+        wdir_foo_file3d = os.path.join(test.workdir, 'foo', 'file3d')
+        wdir_foo_file4a = os.path.join(test.workdir, 'foo', 'file4a')
+        wdir_foo_file4b = os.path.join(test.workdir, 'foo', 'file4b')
+        wdir_foo_file4c = os.path.join(test.workdir, 'foo', 'file4c')
+        wdir_foo_file4d = os.path.join(test.workdir, 'foo', 'file4d')
+        wdir_file5 = os.path.join(test.workdir, 'file5')
+
+        with open(wdir_file1, 'w') as f:
+            f.write("")
+        with open(wdir_file2, 'w') as f:
+            f.write("")
+        with open(wdir_foo_file3a, 'w') as f:
+            f.write("")
+        with open(wdir_foo_file3b, 'w') as f:
+            f.write("")
+        with open(wdir_foo_file3c, 'w') as f:
+            f.write("")
+        with open(wdir_foo_file3d, 'w') as f:
+            f.write("")
+        with open(wdir_foo_file4a, 'w') as f:
+            f.write("")
+        with open(wdir_foo_file4b, 'w') as f:
+            f.write("")
+        with open(wdir_foo_file4c, 'w') as f:
+            f.write("")
+        with open(wdir_foo_file4d, 'w') as f:
+            f.write("")
+        with open(wdir_file5, 'w') as f:
+            f.write("")
+
+        test.unlink_files('', [
+            'no_file_a',
+            'no_file_b',
+        ])
+
+        test.unlink_files('', [
+            'file1',
+            'file2',
+        ])
+        assert not os.path.exists(wdir_file1)
+        assert not os.path.exists(wdir_file2)
+
+        test.unlink_files('foo', [
+            'file3a',
+            'file3b',
+        ])
+        assert not os.path.exists(wdir_foo_file3a)
+        assert not os.path.exists(wdir_foo_file3b)
+
+        test.unlink_files(['foo'], [
+            'file3c',
+            'file3d',
+        ])
+        assert not os.path.exists(wdir_foo_file3c)
+        assert not os.path.exists(wdir_foo_file3d)
+
+        test.unlink_files('', [
+            ['foo', 'file4a'],
+            ['foo', 'file4b'],
+        ])
+        assert not os.path.exists(wdir_foo_file4a)
+        assert not os.path.exists(wdir_foo_file4b)
+
+        test.unlink_files([''], [
+            ['foo', 'file4c'],
+            ['foo', 'file4d'],
+        ])
+        assert not os.path.exists(wdir_foo_file4c)
+        assert not os.path.exists(wdir_foo_file4d)
+
+        # Make it so we can't unlink file5.
+        # For UNIX, remove write permission from the dir and the file.
+        # For Windows, open the file.
+        os.chmod(test.workdir, 0o500)
+        os.chmod(wdir_file5, 0o400)
+        with open(wdir_file5, 'r'):
+            try:
+                try:
+                    test.unlink_files('', ['file5'])
+                except OSError: # expect "Permission denied"
+                    pass
+                except:
+                    raise
+            finally:
+                os.chmod(test.workdir, 0o700)
+                os.chmod(wdir_file5, 0o600)
+
+
 class touch_TestCase(TestCmdTestCase):
     def test_touch(self) -> None:
         """Test touch()"""