作者: Jim Wang 公众号: 巴博萨船长

摘要:Python 是如何访问和控制系统的环境变量的,Python中的父进程与子进程间的环境变量关系是何?能不能在运行时修改环境变量?子进程中对环境变量修改能不能传递给对父进程?如果能,怎么样实现这样的变化?如何使用subprocess.Popen运行vcvars.bat,然后在持续集成时,完成devenv清理和创建的前期准备。

Abstract: How does Python access and control the system‘s environment variables? What is the relationship between the environment variables of the parent process and the child process in Python? Can environment variables be modified at runtime? Can the modification of environment variables in the child process be passed to the parent process? If so, how to achieve such a change? How to use subprocess.Popen to run vcvars.bat, and then complete the preliminary preparations for the Clean and Build of devenv during CI.

作者: Jim Wang 公众号: 巴博萨船长

任务背景

 该任务是:当前所用的完成持续集成的脚本是Python语言实现的。在现有的脚本的核心内容是,使用subprocess.Popen方法来调用devenv命令,从而实现项目的清理clean和构建build。

今年伊始,项目中引用了.NET组件。一部分代码使用.NET完成了一个交互界面窗口。由于使用了.NET,编译的时候就需要对.NET的程序集进行强名称签名等一系列操作。例如,在导出类型库时会使用到的类型库导出工具Tlbexp.exe(一般在Visual Studio项目属性中**[生成]**部分中的“为COM互操作注册”被勾选时),以及程序集注册工具Regasm.exe等。此类工具的路径为:C:\Program Files (x86)\Microsoft Visual Studio 14.0\Common7\Tools\。理论上,当在命令行中使用devenv命令工具时,该工具就会自动查找上述的工具目录,并保证在编译解决方案的时候能够访问到这些工具。实践得知,devenv不能完成诸如此类环境变量的设置。进而在使用devenv时,因为项目配置中的所用到的工具因为无法找到会发生编译错误。

既然devenv不能实现理论结果,那就需要用到vsvars32.bat个batch文件了了。该文件是visual studio自带的文件,本质就是配置环境变量,工作目录的一些批处理命令。任务中遇到的问题是Tlbexp.exe无法找到,问题的本质是工具的路径不在系统环境中:

1
VS140COMNTOOLS=C:\Program Files (x86)\Microsoft Visual Studio 14.0\Common7\Tools\

初始代码

话不多少,直接上代码,项目一开始我们使用的是如下的代码,代码的主要内容是使用subprocess模块下的Popen方法来调用cmd命令devenv完成代码的清除clean和构建build,部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import os
import subprocess as sp

# 省略部分

def mCompile(slnPath, compilArgs):
"""[summary]
Args:
slnPath ([type]): [description]
compilArgs ([type]): [description]

Raises:
RuntimeError: [description]
ValueError: [description]
"""
compilerExe = "C://Porgamm Files (x86)//Microsoft Visual Studio XX.X/CommonX//IDE//devenv"
startupinfo = sp.STARTUPINFO()
startupinfo.dwFlags |= sp.STARTF_USESHOWWINDOW

logClean = "clean.txt"
stepClean = sp.Popen([compilerExe, "/clean", compilArgs, slnPath, "/OUT", logClean ], stdout=sp.PIPE, stderr=sp.STDOUT, startupinfo=startupinfo)
stdoutdata, stderrdata = stepClean .communicate()

if stepClean.returncode != 0:
msg = "Error: Failed to clean the solution " + slnPath
if stdoutdata != None:
msg = msg + "\nError Message: " + stdoutdata
raise RuntimeError(msg)

logBuild = "build.txt"
stepCompile = sp.Popen([compilerExe, "/rebuild", compilArgs, slnPath, "/OUT", logBuild], stdout=sp.PIPE, stderr=sp.STDOUT, startupinfo=startupinfo)
stdoutdata, stderrdata = stepCompile.communicate()
if stepCompile.returncode != 0:
msg = "Error: Failed to compile the solution " + slnPath
if stdoutdata != None:
msg = msg + "\nError Message: " + stdoutdata
raise RuntimeError(msg)

上述代码中有STARTUPINFO相关内容,为了方便代码解释在此列出如下清单,清单中对每个结构体每个成员都通过注释的方式对成员的功能一一进行了解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct _STARTUPINFO
{
DWORD cb; //包含STARTUPINFO结构中的字节数.如果Microsoft将来扩展该结构,它可用作版本控制手段.应用程序必须将cb初始化为sizeof ( STARTUPINFO )
PSTR lpReserved; //保留。必须初始化为N U L L
PSTR lpDesktop; //用于标识启动应用程序所在的桌面的名字。如果该桌面存在,新进程便与指定的桌面相关联。如果桌面不存在,便创建一个带有默认属性的桌面,并使用为新进程指定的名字。 如果lpDesktop是NULL(这是最常见的情况 ),那么该进程将与当前桌面相关联
PSTR lpTitle; //用于设定控制台窗口的名称。如果l p Ti t l e 是N U L L ,则可执行文件的名字将用作窗口名
DWORD dwX; //用于设定应用程序窗口在屏幕上应该放置的位置的x 和y 坐标(以像素为单位)。
DWORD dwY; //只有当子进程用CW_USEDEFAULT作为CreateWindow的x参数来创建它的第一个重叠窗口时, 才使用这两个坐标。若是创建控制台窗口的应用程序,这些成员用于指明控制台窗口的左上角

DWORD dwXSize; //用于设定应用程序窗口的宽度和长度(以像素为单位)只有dwYsize
DWORD dwYSize; // 当子进程将CW_USEDEFAULT 用作CreateWindow 的nWidth参数来创建它的第一个重叠窗口时,才使用这些值。若是创建控制台窗口的应用程序,这些成员将用于指明控制台窗口的宽度
DWORD dwXCountChars; //用于设定子应用程序的控制台窗口的宽度和高度(以字符为单位)
DWORD dwYCountChars;
DWORD dwFillAttribute; //用于设定子应用程序的控制台窗口使用的文本和背景颜色
DWORD dwFlags; //请参见下一段和表4 - 7 的说明
WORD wShowWindow; //用于设定如果子应用程序初次调用的ShowWindow 将SW_SHOWDEFAULT 作为 nCmdShow 参数传递时,该应用程序的第一个重叠窗口应该如何出现。本成员可以是通常用于ShowWindow 函数的任何一个SW_*标识符
WORD cbReserved2; //保留。必须被初始化为0
PBYTE lpReserved2; //保留。必须被初始化为N U L L
HANDLE hStdInput; //用于设定供控制台输入和输出用的缓存的句柄。按照默认设置,hStdInput 用于标识键盘缓存,hStdOutput 和hStdError用于标识控制台窗口的缓存
HANDLE hStdOutput;
HANDLE hStdError;
} STARTUPINFO, *LPSTARTUPINFO;

在原始代码中使用上述结构体的dwFlags成员,该成员的值被设定为STARTF_USESHOWWINDOW,意指使用wShowWindow这个成员属性,其他相关值,请参阅下列清单。这样做的目的是,当需要隐藏窗口时,结合示例代码,仅需要在代码中添加startupinfo.wShowWindow = sp.SW_HIDE即可,但因devenv这样的命令不包含窗体,为了保持简约,所以就没有写入这些代码。

1
2
3
4
5
6
7
8
9
10
                      表4-7 dwFlags 使用标志及含义
标志 含义

STARTF_USESIZE // 使用dwXSize 和dwYSize 成员
STARTF_USESHOWWINDOW //使用wShowWindow 成员
STARTF_USEPOSITION //使用dwX 和dwY 成员
STARTF_USECOUNTCHARS //使用dwXCountChars 和dwYCount Chars 成员
STARTF_USEFILLATTRIBUTE //使用dwFillAttribute 成员
STARTF_USESTDHANDLES //使用hStdInput 、hStdOutput 和hStdError 成员
STARTF_RUN_FULLSCREEN //强制在x86 计算机上运行的控制台应用程序以全屏幕方式启动运行

初步修改

我们要解决的“工具Tlbexp.exe无法找到”这个问题,就需要运行vsvars32.bat这batch文件,完成系统环境变量的更新。理论上仅需要在mComple这个函数中,即clean步骤前再次使用subprocess模块下的Popen方法来运行vsvars32.bat即可,相应代码应为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import os
import subprocess as sp

# 省略部分

def mCompile(slnPath, compilArgs):
"""[summary]
Args:
slnPath ([type]): [description]
compilArgs ([type]): [description]

Raises:
RuntimeError: [description]
ValueError: [description]
"""
vsvarsbatch = "C://Porgamm Files (x86)//Microsoft Visual Studio XX.X/CommonX//Tools//vsvars32.bat"
compilerExe = "C://Porgamm Files (x86)//Microsoft Visual Studio XX.X/CommonX//IDE//devenv"
startupinfo = sp.STARTUPINFO()
startupinfo.dwFlags |= sp.STARTF_USESHOWWINDOW

if vsvarsbatch != "": # run vsvars32.bat of visual studio
cmd = [vsvarsbatch]
stepVSARS = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.STDOUT, startupinfo=startupinfo)
stdoutdata, stderrdata = stepVSARS.communicate()

if stepVSARS.returncode != 0:
msg = "Error: Failed to raun vsvars32.bat"
if stdoutdata != None:
msg = msg + "\nError Message: " + stdoutdata
raise RuntimeError(msg)
if stepVSARS.wait() != 0:
raise ValueError(stderrdata.decode("mbcs"))

logClean = "clean.txt"
stepClean = sp.Popen([compilerExe, "/clean", compilArgs, slnPath, "/OUT", logClean ], stdout=sp.PIPE, stderr=sp.STDOUT, startupinfo=startupinfo)
stdoutdata, stderrdata = stepClean .communicate()

# 省略部分

可现实是这样的代码达不到预期的结果,根本原因在于:程序中,进程之间的数据是隔离的,内存空间是不能共享的,进程之间要进行数据通信必须借助其他手段。子进程的结果父进程获取不到,其他的子进程当然也无法得到。

还会有疑问的地方可能是,可我们的问题焦点是系统的环境变量?程序中,每个进程都有自己的运行环境,这样的环境只能从父进程被继承到子进程中,而不能反向地从子进程传递到父进程。实际上,默认情况下subprocess模块下的Popen方法一直践行着这种规则,Popen方法有env这个参数,该参数用于指定子进程的环境变量,如果 env = None,子进程的环境变量将从父进程中继承。也就是说,如果环境变量不加修改,那么子进程的环境变量与父进程一致,与其他子进程也一致。由于环境变量传递的单向性,我们上述的初始修改代码是不能完成任务要求的。

既然常规方法不行,那就使用一个非常规的方式,我们可以使用被称为“外带数据”(out-of-band)的进程通讯方式,将子进程中环境变量的变化传递给父进程。

最终修改

针对初始修改中依旧存在的问题,我们再次对代码进行了修改。最终版本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import os
import subprocess as sp

# 省略部分

def mCompile(slnPath, compilArgs):
"""[summary]
Args:
slnPath ([type]): [description]
compilArgs ([type]): [description]

Raises:
RuntimeError: [description]
ValueError: [description]
"""
vvsvarsbatch = "C://Porgamm Files (x86)//Microsoft Visual Studio XX.X/CommonX//Tools//vsvars32.bat"
compilerExe = "C://Porgamm Files (x86)//Microsoft Visual Studio XX.X/CommonX//IDE//devenv"
startupinfo = sp.STARTUPINFO()
startupinfo.dwFlags |= sp.STARTF_USESHOWWINDOW

if vsvarsbatch != "": # run vsvars32.bat of visual studio
cmd = [vsvarsbatch, '&&', 'set']
stepVSARS = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.STDOUT, startupinfo=startupinfo)
stdoutdata, stderrdata = stepVSARS.communicate()

if stepVSARS.returncode != 0:
msg = "Error: Failed to raun vsvars32.bat"
if stdoutdata != None:
msg = msg + "\nError Message: " + stdoutdata
raise RuntimeError(msg)
if stepVSARS.wait() != 0:
raise ValueError(stderrdata.decode("mbcs"))

output = stdoutdata.decode("mbcs").split("\r\n")
dict_new_env = dict((e[0].upper(), e[1]) for e in [p.rstrip().split("=", 1) for p in output] if len(e) == 2)
os.environ.update(dict_new_env)

logClean = "clean.txt"
stepClean = sp.Popen([compilerExe, "/clean", compilArgs, slnPath, "/OUT", logClean ], stdout=sp.PIPE, stderr=sp.STDOUT, startupinfo=startupinfo)
stdoutdata, stderrdata = stepClean .communicate()

# 省略部分

在这次修改中,代码cmd = [vsvarsbatch] 变成了cmd = [vsvarsbatch, ‘&&’, ‘set’]**,因为vsvars32.bat脚本对环境变量的更新,是没有标准输出(stdout)的,即直接是看不到变化的,所以这里使用set,将子进程所有环境变量都打印到标准输出中,当组合命令**运行结束之后,在检查命令是否异常结束之后,通过stdoutdata获取set命令的标准输出,然后在将得到的标准输出转换为字典格式,再然后更新当前的环境变量,这样父进程中的环境变量得到了更新。之后代码中的子进程都会使用更新之后的环境变量,这样就达到了任务要求,解决了任务难题。

小结

个人认为,项目发生变化会带来新的问题,这并不意外。解决问题时,初始思考的不全面也是正常的,遇见问题认真分析仔细研究终究会找到解决问题的方法的。当然,解决一个问题的可以有很多不同的方法,上述方法也仅仅是一家之言,并非最优,若读者有别的思路也欢迎关注我的个人微信公众号,一起讨论学习。


版权声明:
文章首发于 Jim Wang's blog , 转载文章请务必以超链接形式标明文章出处,作者信息及本版权声明。