##// END OF EJS Templates
packaging: remove py2exe / Python 2.7 support...
Gregory Szorc -
r49703:17d5e25b default
parent child Browse files
Show More
@@ -1,195 +1,154 b''
1 1 # cli.py - Command line interface for automation
2 2 #
3 3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 # no-check-code because Python 3 native.
9 9
10 10 import argparse
11 11 import os
12 12 import pathlib
13 13
14 14 from . import (
15 15 inno,
16 16 wix,
17 17 )
18 18
19 19 HERE = pathlib.Path(os.path.abspath(os.path.dirname(__file__)))
20 20 SOURCE_DIR = HERE.parent.parent.parent
21 21
22 22
23 def build_inno(pyoxidizer_target=None, python=None, iscc=None, version=None):
24 if not pyoxidizer_target and not python:
25 raise Exception("--python required unless building with PyOxidizer")
26
27 if python and not os.path.isabs(python):
28 raise Exception("--python arg must be an absolute path")
29
23 def build_inno(pyoxidizer_target, iscc=None, version=None):
30 24 if iscc:
31 25 iscc = pathlib.Path(iscc)
32 26 else:
33 27 iscc = (
34 28 pathlib.Path(os.environ["ProgramFiles(x86)"])
35 29 / "Inno Setup 5"
36 30 / "ISCC.exe"
37 31 )
38 32
39 33 build_dir = SOURCE_DIR / "build"
40 34
41 if pyoxidizer_target:
42 35 inno.build_with_pyoxidizer(
43 36 SOURCE_DIR, build_dir, pyoxidizer_target, iscc, version=version
44 37 )
45 else:
46 inno.build_with_py2exe(
47 SOURCE_DIR,
48 build_dir,
49 pathlib.Path(python),
50 iscc,
51 version=version,
52 )
53 38
54 39
55 40 def build_wix(
41 pyoxidizer_target,
56 42 name=None,
57 pyoxidizer_target=None,
58 python=None,
59 43 version=None,
60 44 sign_sn=None,
61 45 sign_cert=None,
62 46 sign_password=None,
63 47 sign_timestamp_url=None,
64 extra_packages_script=None,
65 48 extra_wxs=None,
66 49 extra_features=None,
67 50 extra_pyoxidizer_vars=None,
68 51 ):
69 if not pyoxidizer_target and not python:
70 raise Exception("--python required unless building with PyOxidizer")
71
72 if python and not os.path.isabs(python):
73 raise Exception("--python arg must be an absolute path")
74
75 52 kwargs = {
76 53 "source_dir": SOURCE_DIR,
77 54 "version": version,
55 "target_triple": pyoxidizer_target,
56 "extra_pyoxidizer_vars": extra_pyoxidizer_vars,
78 57 }
79 58
80 if pyoxidizer_target:
81 fn = wix.build_installer_pyoxidizer
82 kwargs["target_triple"] = pyoxidizer_target
83 kwargs["extra_pyoxidizer_vars"] = extra_pyoxidizer_vars
84 else:
85 fn = wix.build_installer_py2exe
86 kwargs["python_exe"] = pathlib.Path(python)
87
88 if extra_packages_script:
89 if pyoxidizer_target:
90 raise Exception(
91 "pyoxidizer does not support --extra-packages-script"
92 )
93 kwargs["extra_packages_script"] = extra_packages_script
94 59 if extra_wxs:
95 60 kwargs["extra_wxs"] = dict(
96 61 thing.split("=") for thing in extra_wxs.split(",")
97 62 )
98 63 if extra_features:
99 64 kwargs["extra_features"] = extra_features.split(",")
100 65
101 66 if sign_sn or sign_cert:
102 67 kwargs["signing_info"] = {
103 68 "name": name,
104 69 "subject_name": sign_sn,
105 70 "cert_path": sign_cert,
106 71 "cert_password": sign_password,
107 72 "timestamp_url": sign_timestamp_url,
108 73 }
109 74
110 fn(**kwargs)
75 wix.build_installer_pyoxidizer(**kwargs)
111 76
112 77
113 78 def get_parser():
114 79 parser = argparse.ArgumentParser()
115 80
116 81 subparsers = parser.add_subparsers()
117 82
118 83 sp = subparsers.add_parser("inno", help="Build Inno Setup installer")
119 84 sp.add_argument(
120 85 "--pyoxidizer-target",
121 86 choices={"i686-pc-windows-msvc", "x86_64-pc-windows-msvc"},
87 required=True,
122 88 help="Build with PyOxidizer targeting this host triple",
123 89 )
124 sp.add_argument("--python", help="path to python.exe to use")
125 90 sp.add_argument("--iscc", help="path to iscc.exe to use")
126 91 sp.add_argument(
127 92 "--version",
128 93 help="Mercurial version string to use "
129 94 "(detected from __version__.py if not defined",
130 95 )
131 96 sp.set_defaults(func=build_inno)
132 97
133 98 sp = subparsers.add_parser(
134 99 "wix", help="Build Windows installer with WiX Toolset"
135 100 )
136 101 sp.add_argument("--name", help="Application name", default="Mercurial")
137 102 sp.add_argument(
138 103 "--pyoxidizer-target",
139 104 choices={"i686-pc-windows-msvc", "x86_64-pc-windows-msvc"},
105 required=True,
140 106 help="Build with PyOxidizer targeting this host triple",
141 107 )
142 sp.add_argument("--python", help="Path to Python executable to use")
143 108 sp.add_argument(
144 109 "--sign-sn",
145 110 help="Subject name (or fragment thereof) of certificate "
146 111 "to use for signing",
147 112 )
148 113 sp.add_argument(
149 114 "--sign-cert", help="Path to certificate to use for signing"
150 115 )
151 116 sp.add_argument("--sign-password", help="Password for signing certificate")
152 117 sp.add_argument(
153 118 "--sign-timestamp-url",
154 119 help="URL of timestamp server to use for signing",
155 120 )
156 121 sp.add_argument("--version", help="Version string to use")
157 122 sp.add_argument(
158 "--extra-packages-script",
159 help=(
160 "Script to execute to include extra packages in " "py2exe binary."
161 ),
162 )
163 sp.add_argument(
164 123 "--extra-wxs", help="CSV of path_to_wxs_file=working_dir_for_wxs_file"
165 124 )
166 125 sp.add_argument(
167 126 "--extra-features",
168 127 help=(
169 128 "CSV of extra feature names to include "
170 129 "in the installer from the extra wxs files"
171 130 ),
172 131 )
173 132
174 133 sp.add_argument(
175 134 "--extra-pyoxidizer-vars",
176 135 help="json map of extra variables to pass to pyoxidizer",
177 136 )
178 137
179 138 sp.set_defaults(func=build_wix)
180 139
181 140 return parser
182 141
183 142
184 143 def main():
185 144 parser = get_parser()
186 145 args = parser.parse_args()
187 146
188 147 if not hasattr(args, "func"):
189 148 parser.print_help()
190 149 return
191 150
192 151 kwargs = dict(vars(args))
193 152 del kwargs["func"]
194 153
195 154 args.func(**kwargs)
@@ -1,182 +1,140 b''
1 1 # downloads.py - Code for downloading dependencies.
2 2 #
3 3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 # no-check-code because Python 3 native.
9 9
10 10 import gzip
11 11 import hashlib
12 12 import pathlib
13 13 import urllib.request
14 14
15 15
16 16 DOWNLOADS = {
17 17 'gettext': {
18 18 'url': 'https://versaweb.dl.sourceforge.net/project/gnuwin32/gettext/0.14.4/gettext-0.14.4-bin.zip',
19 19 'size': 1606131,
20 20 'sha256': '60b9ef26bc5cceef036f0424e542106cf158352b2677f43a01affd6d82a1d641',
21 21 'version': '0.14.4',
22 22 },
23 23 'gettext-dep': {
24 24 'url': 'https://versaweb.dl.sourceforge.net/project/gnuwin32/gettext/0.14.4/gettext-0.14.4-dep.zip',
25 25 'size': 715086,
26 26 'sha256': '411f94974492fd2ecf52590cb05b1023530aec67e64154a88b1e4ebcd9c28588',
27 27 },
28 'py2exe': {
29 'url': 'https://versaweb.dl.sourceforge.net/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.zip',
30 'size': 149687,
31 'sha256': '6bd383312e7d33eef2e43a5f236f9445e4f3e0f6b16333c6f183ed445c44ddbd',
32 'version': '0.6.9',
33 },
34 # The VC9 CRT merge modules aren't readily available on most systems because
35 # they are only installed as part of a full Visual Studio 2008 install.
36 # While we could potentially extract them from a Visual Studio 2008
37 # installer, it is easier to just fetch them from a known URL.
38 'vc9-crt-x86-msm': {
39 'url': 'https://github.com/indygreg/vc90-merge-modules/raw/9232f8f0b2135df619bf7946eaa176b4ac35ccff/Microsoft_VC90_CRT_x86.msm',
40 'size': 615424,
41 'sha256': '837e887ef31b332feb58156f429389de345cb94504228bb9a523c25a9dd3d75e',
42 },
43 'vc9-crt-x86-msm-policy': {
44 'url': 'https://github.com/indygreg/vc90-merge-modules/raw/9232f8f0b2135df619bf7946eaa176b4ac35ccff/policy_9_0_Microsoft_VC90_CRT_x86.msm',
45 'size': 71168,
46 'sha256': '3fbcf92e3801a0757f36c5e8d304e134a68d5cafd197a6df7734ae3e8825c940',
47 },
48 'vc9-crt-x64-msm': {
49 'url': 'https://github.com/indygreg/vc90-merge-modules/raw/9232f8f0b2135df619bf7946eaa176b4ac35ccff/Microsoft_VC90_CRT_x86_x64.msm',
50 'size': 662528,
51 'sha256': '50d9639b5ad4844a2285269c7551bf5157ec636e32396ddcc6f7ec5bce487a7c',
52 },
53 'vc9-crt-x64-msm-policy': {
54 'url': 'https://github.com/indygreg/vc90-merge-modules/raw/9232f8f0b2135df619bf7946eaa176b4ac35ccff/policy_9_0_Microsoft_VC90_CRT_x86_x64.msm',
55 'size': 71168,
56 'sha256': '0550ea1929b21239134ad3a678c944ba0f05f11087117b6cf0833e7110686486',
57 },
58 'virtualenv': {
59 'url': 'https://files.pythonhosted.org/packages/37/db/89d6b043b22052109da35416abc3c397655e4bd3cff031446ba02b9654fa/virtualenv-16.4.3.tar.gz',
60 'size': 3713208,
61 'sha256': '984d7e607b0a5d1329425dd8845bd971b957424b5ba664729fab51ab8c11bc39',
62 'version': '16.4.3',
63 },
64 'wix': {
65 'url': 'https://github.com/wixtoolset/wix3/releases/download/wix3111rtm/wix311-binaries.zip',
66 'size': 34358269,
67 'sha256': '37f0a533b0978a454efb5dc3bd3598becf9660aaf4287e55bf68ca6b527d051d',
68 'version': '3.11.1',
69 },
70 28 }
71 29
72 30
73 31 def hash_path(p: pathlib.Path):
74 32 h = hashlib.sha256()
75 33
76 34 with p.open('rb') as fh:
77 35 while True:
78 36 chunk = fh.read(65536)
79 37 if not chunk:
80 38 break
81 39
82 40 h.update(chunk)
83 41
84 42 return h.hexdigest()
85 43
86 44
87 45 class IntegrityError(Exception):
88 46 """Represents an integrity error when downloading a URL."""
89 47
90 48
91 49 def secure_download_stream(url, size, sha256):
92 50 """Securely download a URL to a stream of chunks.
93 51
94 52 If the integrity of the download fails, an IntegrityError is
95 53 raised.
96 54 """
97 55 h = hashlib.sha256()
98 56 length = 0
99 57
100 58 with urllib.request.urlopen(url) as fh:
101 59 if (
102 60 not url.endswith('.gz')
103 61 and fh.info().get('Content-Encoding') == 'gzip'
104 62 ):
105 63 fh = gzip.GzipFile(fileobj=fh)
106 64
107 65 while True:
108 66 chunk = fh.read(65536)
109 67 if not chunk:
110 68 break
111 69
112 70 h.update(chunk)
113 71 length += len(chunk)
114 72
115 73 yield chunk
116 74
117 75 digest = h.hexdigest()
118 76
119 77 if length != size:
120 78 raise IntegrityError(
121 79 'size mismatch on %s: wanted %d; got %d' % (url, size, length)
122 80 )
123 81
124 82 if digest != sha256:
125 83 raise IntegrityError(
126 84 'sha256 mismatch on %s: wanted %s; got %s' % (url, sha256, digest)
127 85 )
128 86
129 87
130 88 def download_to_path(url: str, path: pathlib.Path, size: int, sha256: str):
131 89 """Download a URL to a filesystem path, possibly with verification."""
132 90
133 91 # We download to a temporary file and rename at the end so there's
134 92 # no chance of the final file being partially written or containing
135 93 # bad data.
136 94 print('downloading %s to %s' % (url, path))
137 95
138 96 if path.exists():
139 97 good = True
140 98
141 99 if path.stat().st_size != size:
142 100 print('existing file size is wrong; removing')
143 101 good = False
144 102
145 103 if good:
146 104 if hash_path(path) != sha256:
147 105 print('existing file hash is wrong; removing')
148 106 good = False
149 107
150 108 if good:
151 109 print('%s exists and passes integrity checks' % path)
152 110 return
153 111
154 112 path.unlink()
155 113
156 114 tmp = path.with_name('%s.tmp' % path.name)
157 115
158 116 try:
159 117 with tmp.open('wb') as fh:
160 118 for chunk in secure_download_stream(url, size, sha256):
161 119 fh.write(chunk)
162 120 except IntegrityError:
163 121 tmp.unlink()
164 122 raise
165 123
166 124 tmp.rename(path)
167 125 print('successfully downloaded %s' % url)
168 126
169 127
170 128 def download_entry(
171 129 name: dict, dest_path: pathlib.Path, local_name=None
172 130 ) -> pathlib.Path:
173 131 entry = DOWNLOADS[name]
174 132
175 133 url = entry['url']
176 134
177 135 local_name = local_name or url[url.rindex('/') + 1 :]
178 136
179 137 local_path = dest_path / local_name
180 138 download_to_path(url, local_path, entry['size'], entry['sha256'])
181 139
182 140 return local_path, entry
@@ -1,244 +1,154 b''
1 1 # inno.py - Inno Setup functionality.
2 2 #
3 3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 # no-check-code because Python 3 native.
9 9
10 10 import os
11 11 import pathlib
12 12 import shutil
13 13 import subprocess
14 14
15 15 import jinja2
16 16
17 from .py2exe import (
18 build_py2exe,
19 stage_install,
20 )
21 17 from .pyoxidizer import create_pyoxidizer_install_layout
22 18 from .util import (
23 find_legacy_vc_runtime_files,
24 19 normalize_windows_version,
25 20 process_install_rules,
26 21 read_version_py,
27 22 )
28 23
29 EXTRA_PACKAGES = {
30 'dulwich',
31 'keyring',
32 'pygments',
33 'win32ctypes',
34 }
35
36 EXTRA_INCLUDES = {
37 '_curses',
38 '_curses_panel',
39 }
40 24
41 25 EXTRA_INSTALL_RULES = [
42 26 ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'),
43 27 ]
44 28
45 29 PACKAGE_FILES_METADATA = {
46 30 'ReadMe.html': 'Flags: isreadme',
47 31 }
48 32
49 33
50 def build_with_py2exe(
51 source_dir: pathlib.Path,
52 build_dir: pathlib.Path,
53 python_exe: pathlib.Path,
54 iscc_exe: pathlib.Path,
55 version=None,
56 ):
57 """Build the Inno installer using py2exe.
58
59 Build files will be placed in ``build_dir``.
60
61 py2exe's setup.py doesn't use setuptools. It doesn't have modern logic
62 for finding the Python 2.7 toolchain. So, we require the environment
63 to already be configured with an active toolchain.
64 """
65 if not iscc_exe.exists():
66 raise Exception('%s does not exist' % iscc_exe)
67
68 vc_x64 = r'\x64' in os.environ.get('LIB', '')
69 arch = 'x64' if vc_x64 else 'x86'
70 inno_build_dir = build_dir / ('inno-py2exe-%s' % arch)
71 staging_dir = inno_build_dir / 'stage'
72
73 requirements_txt = (
74 source_dir / 'contrib' / 'packaging' / 'requirements-windows-py2.txt'
75 )
76
77 inno_build_dir.mkdir(parents=True, exist_ok=True)
78
79 build_py2exe(
80 source_dir,
81 build_dir,
82 python_exe,
83 'inno',
84 requirements_txt,
85 extra_packages=EXTRA_PACKAGES,
86 extra_includes=EXTRA_INCLUDES,
87 )
88
89 # Purge the staging directory for every build so packaging is
90 # pristine.
91 if staging_dir.exists():
92 print('purging %s' % staging_dir)
93 shutil.rmtree(staging_dir)
94
95 # Now assemble all the packaged files into the staging directory.
96 stage_install(source_dir, staging_dir)
97
98 # We also install some extra files.
99 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
100
101 # hg.exe depends on VC9 runtime DLLs. Copy those into place.
102 for f in find_legacy_vc_runtime_files(vc_x64):
103 if f.name.endswith('.manifest'):
104 basename = 'Microsoft.VC90.CRT.manifest'
105 else:
106 basename = f.name
107
108 dest_path = staging_dir / basename
109
110 print('copying %s to %s' % (f, dest_path))
111 shutil.copyfile(f, dest_path)
112
113 build_installer(
114 source_dir,
115 inno_build_dir,
116 staging_dir,
117 iscc_exe,
118 version,
119 arch="x64" if vc_x64 else None,
120 suffix="-python2",
121 )
122
123
124 34 def build_with_pyoxidizer(
125 35 source_dir: pathlib.Path,
126 36 build_dir: pathlib.Path,
127 37 target_triple: str,
128 38 iscc_exe: pathlib.Path,
129 39 version=None,
130 40 ):
131 41 """Build the Inno installer using PyOxidizer."""
132 42 if not iscc_exe.exists():
133 43 raise Exception("%s does not exist" % iscc_exe)
134 44
135 45 inno_build_dir = build_dir / ("inno-pyoxidizer-%s" % target_triple)
136 46 staging_dir = inno_build_dir / "stage"
137 47
138 48 inno_build_dir.mkdir(parents=True, exist_ok=True)
139 49 create_pyoxidizer_install_layout(
140 50 source_dir, inno_build_dir, staging_dir, target_triple
141 51 )
142 52
143 53 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
144 54
145 55 build_installer(
146 56 source_dir,
147 57 inno_build_dir,
148 58 staging_dir,
149 59 iscc_exe,
150 60 version,
151 61 arch="x64" if "x86_64" in target_triple else None,
152 62 )
153 63
154 64
155 65 def build_installer(
156 66 source_dir: pathlib.Path,
157 67 inno_build_dir: pathlib.Path,
158 68 staging_dir: pathlib.Path,
159 69 iscc_exe: pathlib.Path,
160 70 version,
161 71 arch=None,
162 72 suffix="",
163 73 ):
164 74 """Build an Inno installer from staged Mercurial files.
165 75
166 76 This function is agnostic about how to build Mercurial. It just
167 77 cares that Mercurial files are in ``staging_dir``.
168 78 """
169 79 inno_source_dir = source_dir / "contrib" / "packaging" / "inno"
170 80
171 81 # The final package layout is simply a mirror of the staging directory.
172 82 package_files = []
173 83 for root, dirs, files in os.walk(staging_dir):
174 84 dirs.sort()
175 85
176 86 root = pathlib.Path(root)
177 87
178 88 for f in sorted(files):
179 89 full = root / f
180 90 rel = full.relative_to(staging_dir)
181 91 if str(rel.parent) == '.':
182 92 dest_dir = '{app}'
183 93 else:
184 94 dest_dir = '{app}\\%s' % rel.parent
185 95
186 96 package_files.append(
187 97 {
188 98 'source': rel,
189 99 'dest_dir': dest_dir,
190 100 'metadata': PACKAGE_FILES_METADATA.get(str(rel), None),
191 101 }
192 102 )
193 103
194 104 print('creating installer')
195 105
196 106 # Install Inno files by rendering a template.
197 107 jinja_env = jinja2.Environment(
198 108 loader=jinja2.FileSystemLoader(str(inno_source_dir)),
199 109 # Need to change these to prevent conflict with Inno Setup.
200 110 comment_start_string='{##',
201 111 comment_end_string='##}',
202 112 )
203 113
204 114 try:
205 115 template = jinja_env.get_template('mercurial.iss')
206 116 except jinja2.TemplateSyntaxError as e:
207 117 raise Exception(
208 118 'template syntax error at %s:%d: %s'
209 119 % (
210 120 e.name,
211 121 e.lineno,
212 122 e.message,
213 123 )
214 124 )
215 125
216 126 content = template.render(package_files=package_files)
217 127
218 128 with (inno_build_dir / 'mercurial.iss').open('w', encoding='utf-8') as fh:
219 129 fh.write(content)
220 130
221 131 # Copy additional files used by Inno.
222 132 for p in ('mercurial.ico', 'postinstall.txt'):
223 133 shutil.copyfile(
224 134 source_dir / 'contrib' / 'win32' / p, inno_build_dir / p
225 135 )
226 136
227 137 args = [str(iscc_exe)]
228 138
229 139 if arch:
230 140 args.append('/dARCH=%s' % arch)
231 141 args.append('/dSUFFIX=-%s%s' % (arch, suffix))
232 142 else:
233 143 args.append('/dSUFFIX=-x86%s' % suffix)
234 144
235 145 if not version:
236 146 version = read_version_py(source_dir)
237 147
238 148 args.append('/dVERSION=%s' % version)
239 149 args.append('/dQUAD_VERSION=%s' % normalize_windows_version(version))
240 150
241 151 args.append('/Odist')
242 152 args.append(str(inno_build_dir / 'mercurial.iss'))
243 153
244 154 subprocess.run(args, cwd=str(source_dir), check=True)
@@ -1,338 +1,190 b''
1 1 # util.py - Common packaging utility code.
2 2 #
3 3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 # no-check-code because Python 3 native.
9 9
10 import distutils.version
11 import getpass
12 10 import glob
13 11 import os
14 12 import pathlib
15 13 import re
16 14 import shutil
17 15 import subprocess
18 import tarfile
19 16 import zipfile
20 17
21 18
22 def extract_tar_to_directory(source: pathlib.Path, dest: pathlib.Path):
23 with tarfile.open(source, 'r') as tf:
24 tf.extractall(dest)
25
26
27 19 def extract_zip_to_directory(source: pathlib.Path, dest: pathlib.Path):
28 20 with zipfile.ZipFile(source, 'r') as zf:
29 21 zf.extractall(dest)
30 22
31 23
32 24 def find_vc_runtime_dll(x64=False):
33 25 """Finds Visual C++ Runtime DLL to include in distribution."""
34 26 # We invoke vswhere to find the latest Visual Studio install.
35 27 vswhere = (
36 28 pathlib.Path(os.environ["ProgramFiles(x86)"])
37 29 / "Microsoft Visual Studio"
38 30 / "Installer"
39 31 / "vswhere.exe"
40 32 )
41 33
42 34 if not vswhere.exists():
43 35 raise Exception(
44 36 "could not find vswhere.exe: %s does not exist" % vswhere
45 37 )
46 38
47 39 args = [
48 40 str(vswhere),
49 41 # -products * is necessary to return results from Build Tools
50 42 # (as opposed to full IDE installs).
51 43 "-products",
52 44 "*",
53 45 "-requires",
54 46 "Microsoft.VisualCpp.Redist.14.Latest",
55 47 "-latest",
56 48 "-property",
57 49 "installationPath",
58 50 ]
59 51
60 52 vs_install_path = pathlib.Path(
61 53 os.fsdecode(subprocess.check_output(args).strip())
62 54 )
63 55
64 56 # This just gets us a path like
65 57 # C:\Program Files (x86)\Microsoft Visual Studio\2019\Community
66 58 # Actually vcruntime140.dll is under a path like:
67 59 # VC\Redist\MSVC\<version>\<arch>\Microsoft.VC14<X>.CRT\vcruntime140.dll.
68 60
69 61 arch = "x64" if x64 else "x86"
70 62
71 63 search_glob = (
72 64 r"%s\VC\Redist\MSVC\*\%s\Microsoft.VC14*.CRT\vcruntime140.dll"
73 65 % (vs_install_path, arch)
74 66 )
75 67
76 68 candidates = glob.glob(search_glob, recursive=True)
77 69
78 70 for candidate in reversed(candidates):
79 71 return pathlib.Path(candidate)
80 72
81 73 raise Exception("could not find vcruntime140.dll")
82 74
83 75
84 def find_legacy_vc_runtime_files(x64=False):
85 """Finds Visual C++ Runtime DLLs to include in distribution."""
86 winsxs = pathlib.Path(os.environ['SYSTEMROOT']) / 'WinSxS'
87
88 prefix = 'amd64' if x64 else 'x86'
89
90 candidates = sorted(
91 p
92 for p in os.listdir(winsxs)
93 if p.lower().startswith('%s_microsoft.vc90.crt_' % prefix)
94 )
95
96 for p in candidates:
97 print('found candidate VC runtime: %s' % p)
98
99 # Take the newest version.
100 version = candidates[-1]
101
102 d = winsxs / version
103
104 return [
105 d / 'msvcm90.dll',
106 d / 'msvcp90.dll',
107 d / 'msvcr90.dll',
108 winsxs / 'Manifests' / ('%s.manifest' % version),
109 ]
110
111
112 def windows_10_sdk_info():
113 """Resolves information about the Windows 10 SDK."""
114
115 base = pathlib.Path(os.environ['ProgramFiles(x86)']) / 'Windows Kits' / '10'
116
117 if not base.is_dir():
118 raise Exception('unable to find Windows 10 SDK at %s' % base)
119
120 # Find the latest version.
121 bin_base = base / 'bin'
122
123 versions = [v for v in os.listdir(bin_base) if v.startswith('10.')]
124 version = sorted(versions, reverse=True)[0]
125
126 bin_version = bin_base / version
127
128 return {
129 'root': base,
130 'version': version,
131 'bin_root': bin_version,
132 'bin_x86': bin_version / 'x86',
133 'bin_x64': bin_version / 'x64',
134 }
135
136
137 76 def normalize_windows_version(version):
138 77 """Normalize Mercurial version string so WiX/Inno accepts it.
139 78
140 79 Version strings have to be numeric ``A.B.C[.D]`` to conform with MSI's
141 80 requirements.
142 81
143 82 We normalize RC version or the commit count to a 4th version component.
144 83 We store this in the 4th component because ``A.B.C`` releases do occur
145 84 and we want an e.g. ``5.3rc0`` version to be semantically less than a
146 85 ``5.3.1rc2`` version. This requires always reserving the 3rd version
147 86 component for the point release and the ``X.YrcN`` release is always
148 87 point release 0.
149 88
150 89 In the case of an RC and presence of ``+`` suffix data, we can't use both
151 90 because the version format is limited to 4 components. We choose to use
152 91 RC and throw away the commit count in the suffix. This means we could
153 92 produce multiple installers with the same normalized version string.
154 93
155 94 >>> normalize_windows_version("5.3")
156 95 '5.3.0'
157 96
158 97 >>> normalize_windows_version("5.3rc0")
159 98 '5.3.0.0'
160 99
161 100 >>> normalize_windows_version("5.3rc1")
162 101 '5.3.0.1'
163 102
164 103 >>> normalize_windows_version("5.3rc1+hg2.abcdef")
165 104 '5.3.0.1'
166 105
167 106 >>> normalize_windows_version("5.3+hg2.abcdef")
168 107 '5.3.0.2'
169 108 """
170 109 if '+' in version:
171 110 version, extra = version.split('+', 1)
172 111 else:
173 112 extra = None
174 113
175 114 # 4.9rc0
176 115 if version[:-1].endswith('rc'):
177 116 rc = int(version[-1:])
178 117 version = version[:-3]
179 118 else:
180 119 rc = None
181 120
182 121 # Ensure we have at least X.Y version components.
183 122 versions = [int(v) for v in version.split('.')]
184 123 while len(versions) < 3:
185 124 versions.append(0)
186 125
187 126 if len(versions) < 4:
188 127 if rc is not None:
189 128 versions.append(rc)
190 129 elif extra:
191 130 # hg<commit count>.<hash>+<date>
192 131 versions.append(int(extra.split('.')[0][2:]))
193 132
194 133 return '.'.join('%d' % x for x in versions[0:4])
195 134
196 135
197 def find_signtool():
198 """Find signtool.exe from the Windows SDK."""
199 sdk = windows_10_sdk_info()
200
201 for key in ('bin_x64', 'bin_x86'):
202 p = sdk[key] / 'signtool.exe'
203
204 if p.exists():
205 return p
206
207 raise Exception('could not find signtool.exe in Windows 10 SDK')
208
209
210 def sign_with_signtool(
211 file_path,
212 description,
213 subject_name=None,
214 cert_path=None,
215 cert_password=None,
216 timestamp_url=None,
217 ):
218 """Digitally sign a file with signtool.exe.
219
220 ``file_path`` is file to sign.
221 ``description`` is text that goes in the signature.
222
223 The signing certificate can be specified by ``cert_path`` or
224 ``subject_name``. These correspond to the ``/f`` and ``/n`` arguments
225 to signtool.exe, respectively.
226
227 The certificate password can be specified via ``cert_password``. If
228 not provided, you will be prompted for the password.
229
230 ``timestamp_url`` is the URL of a RFC 3161 timestamp server (``/tr``
231 argument to signtool.exe).
232 """
233 if cert_path and subject_name:
234 raise ValueError('cannot specify both cert_path and subject_name')
235
236 while cert_path and not cert_password:
237 cert_password = getpass.getpass('password for %s: ' % cert_path)
238
239 args = [
240 str(find_signtool()),
241 'sign',
242 '/v',
243 '/fd',
244 'sha256',
245 '/d',
246 description,
247 ]
248
249 if cert_path:
250 args.extend(['/f', str(cert_path), '/p', cert_password])
251 elif subject_name:
252 args.extend(['/n', subject_name])
253
254 if timestamp_url:
255 args.extend(['/tr', timestamp_url, '/td', 'sha256'])
256
257 args.append(str(file_path))
258
259 print('signing %s' % file_path)
260 subprocess.run(args, check=True)
261
262
263 PRINT_PYTHON_INFO = '''
264 import platform; print("%s:%s" % (platform.architecture()[0], platform.python_version()))
265 '''.strip()
266
267
268 def python_exe_info(python_exe: pathlib.Path):
269 """Obtain information about a Python executable."""
270
271 res = subprocess.check_output([str(python_exe), '-c', PRINT_PYTHON_INFO])
272
273 arch, version = res.decode('utf-8').split(':')
274
275 version = distutils.version.LooseVersion(version)
276
277 return {
278 'arch': arch,
279 'version': version,
280 'py3': version >= distutils.version.LooseVersion('3'),
281 }
282
283
284 136 def process_install_rules(
285 137 rules: list, source_dir: pathlib.Path, dest_dir: pathlib.Path
286 138 ):
287 139 for source, dest in rules:
288 140 if '*' in source:
289 141 if not dest.endswith('/'):
290 142 raise ValueError('destination must end in / when globbing')
291 143
292 144 # We strip off the source path component before the first glob
293 145 # character to construct the relative install path.
294 146 prefix_end_index = source[: source.index('*')].rindex('/')
295 147 relative_prefix = source_dir / source[0:prefix_end_index]
296 148
297 149 for res in glob.glob(str(source_dir / source), recursive=True):
298 150 source_path = pathlib.Path(res)
299 151
300 152 if source_path.is_dir():
301 153 continue
302 154
303 155 rel_path = source_path.relative_to(relative_prefix)
304 156
305 157 dest_path = dest_dir / dest[:-1] / rel_path
306 158
307 159 dest_path.parent.mkdir(parents=True, exist_ok=True)
308 160 print('copying %s to %s' % (source_path, dest_path))
309 161 shutil.copy(source_path, dest_path)
310 162
311 163 # Simple file case.
312 164 else:
313 165 source_path = pathlib.Path(source)
314 166
315 167 if dest.endswith('/'):
316 168 dest_path = pathlib.Path(dest) / source_path.name
317 169 else:
318 170 dest_path = pathlib.Path(dest)
319 171
320 172 full_source_path = source_dir / source_path
321 173 full_dest_path = dest_dir / dest_path
322 174
323 175 full_dest_path.parent.mkdir(parents=True, exist_ok=True)
324 176 shutil.copy(full_source_path, full_dest_path)
325 177 print('copying %s to %s' % (full_source_path, full_dest_path))
326 178
327 179
328 180 def read_version_py(source_dir):
329 181 """Read the mercurial/__version__.py file to resolve the version string."""
330 182 p = source_dir / 'mercurial' / '__version__.py'
331 183
332 184 with p.open('r', encoding='utf-8') as fh:
333 185 m = re.search('version = b"([^"]+)"', fh.read(), re.MULTILINE)
334 186
335 187 if not m:
336 188 raise Exception('could not parse %s' % p)
337 189
338 190 return m.group(1)
@@ -1,586 +1,96 b''
1 1 # wix.py - WiX installer functionality
2 2 #
3 3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 # no-check-code because Python 3 native.
9 9
10 import collections
11 10 import json
12 11 import os
13 12 import pathlib
14 import re
15 13 import shutil
16 import subprocess
17 14 import typing
18 import uuid
19 import xml.dom.minidom
20 15
21 from .downloads import download_entry
22 from .py2exe import (
23 build_py2exe,
24 stage_install,
25 )
26 16 from .pyoxidizer import (
27 17 build_docs_html,
28 create_pyoxidizer_install_layout,
29 18 run_pyoxidizer,
30 19 )
31 from .util import (
32 extract_zip_to_directory,
33 normalize_windows_version,
34 process_install_rules,
35 sign_with_signtool,
36 )
37
38
39 EXTRA_PACKAGES = {
40 'dulwich',
41 'distutils',
42 'keyring',
43 'pygments',
44 'win32ctypes',
45 }
46
47 EXTRA_INCLUDES = {
48 '_curses',
49 '_curses_panel',
50 }
51
52 EXTRA_INSTALL_RULES = [
53 ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'),
54 ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'),
55 ]
56
57 STAGING_REMOVE_FILES = [
58 # We use the RTF variant.
59 'copying.txt',
60 ]
61
62 SHORTCUTS = {
63 # hg.1.html'
64 'hg.file.5d3e441c_28d9_5542_afd0_cdd4234f12d5': {
65 'Name': 'Mercurial Command Reference',
66 },
67 # hgignore.5.html
68 'hg.file.5757d8e0_f207_5e10_a2ec_3ba0a062f431': {
69 'Name': 'Mercurial Ignore Files',
70 },
71 # hgrc.5.html
72 'hg.file.92e605fd_1d1a_5dc6_9fc0_5d2998eb8f5e': {
73 'Name': 'Mercurial Configuration Files',
74 },
75 }
76
77
78 def find_version(source_dir: pathlib.Path):
79 version_py = source_dir / 'mercurial' / '__version__.py'
80
81 with version_py.open('r', encoding='utf-8') as fh:
82 source = fh.read().strip()
83
84 m = re.search('version = b"(.*)"', source)
85 return m.group(1)
86
87
88 def ensure_vc90_merge_modules(build_dir):
89 x86 = (
90 download_entry(
91 'vc9-crt-x86-msm',
92 build_dir,
93 local_name='microsoft.vcxx.crt.x86_msm.msm',
94 )[0],
95 download_entry(
96 'vc9-crt-x86-msm-policy',
97 build_dir,
98 local_name='policy.x.xx.microsoft.vcxx.crt.x86_msm.msm',
99 )[0],
100 )
101
102 x64 = (
103 download_entry(
104 'vc9-crt-x64-msm',
105 build_dir,
106 local_name='microsoft.vcxx.crt.x64_msm.msm',
107 )[0],
108 download_entry(
109 'vc9-crt-x64-msm-policy',
110 build_dir,
111 local_name='policy.x.xx.microsoft.vcxx.crt.x64_msm.msm',
112 )[0],
113 )
114 return {
115 'x86': x86,
116 'x64': x64,
117 }
118
119
120 def run_candle(wix, cwd, wxs, source_dir, defines=None):
121 args = [
122 str(wix / 'candle.exe'),
123 '-nologo',
124 str(wxs),
125 '-dSourceDir=%s' % source_dir,
126 ]
127
128 if defines:
129 args.extend('-d%s=%s' % define for define in sorted(defines.items()))
130
131 subprocess.run(args, cwd=str(cwd), check=True)
132
133
134 def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str:
135 """Create XML string listing every file to be installed."""
136
137 # We derive GUIDs from a deterministic file path identifier.
138 # We shoehorn the name into something that looks like a URL because
139 # the UUID namespaces are supposed to work that way (even though
140 # the input data probably is never validated).
141
142 doc = xml.dom.minidom.parseString(
143 '<?xml version="1.0" encoding="utf-8"?>'
144 '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">'
145 '</Wix>'
146 )
147
148 # Assemble the install layout by directory. This makes it easier to
149 # emit XML, since each directory has separate entities.
150 manifest = collections.defaultdict(dict)
151
152 for root, dirs, files in os.walk(staging_dir):
153 dirs.sort()
154
155 root = pathlib.Path(root)
156 rel_dir = root.relative_to(staging_dir)
157
158 for i in range(len(rel_dir.parts)):
159 parent = '/'.join(rel_dir.parts[0 : i + 1])
160 manifest.setdefault(parent, {})
161
162 for f in sorted(files):
163 full = root / f
164 manifest[str(rel_dir).replace('\\', '/')][full.name] = full
165
166 component_groups = collections.defaultdict(list)
167
168 # Now emit a <Fragment> for each directory.
169 # Each directory is composed of a <DirectoryRef> pointing to its parent
170 # and defines child <Directory>'s and a <Component> with all the files.
171 for dir_name, entries in sorted(manifest.items()):
172 # The directory id is derived from the path. But the root directory
173 # is special.
174 if dir_name == '.':
175 parent_directory_id = 'INSTALLDIR'
176 else:
177 parent_directory_id = 'hg.dir.%s' % dir_name.replace(
178 '/', '.'
179 ).replace('-', '_')
180
181 fragment = doc.createElement('Fragment')
182 directory_ref = doc.createElement('DirectoryRef')
183 directory_ref.setAttribute('Id', parent_directory_id)
184
185 # Add <Directory> entries for immediate children directories.
186 for possible_child in sorted(manifest.keys()):
187 if (
188 dir_name == '.'
189 and '/' not in possible_child
190 and possible_child != '.'
191 ):
192 child_directory_id = ('hg.dir.%s' % possible_child).replace(
193 '-', '_'
194 )
195 name = possible_child
196 else:
197 if not possible_child.startswith('%s/' % dir_name):
198 continue
199 name = possible_child[len(dir_name) + 1 :]
200 if '/' in name:
201 continue
202
203 child_directory_id = 'hg.dir.%s' % possible_child.replace(
204 '/', '.'
205 ).replace('-', '_')
206
207 directory = doc.createElement('Directory')
208 directory.setAttribute('Id', child_directory_id)
209 directory.setAttribute('Name', name)
210 directory_ref.appendChild(directory)
211
212 # Add <Component>s for files in this directory.
213 for rel, source_path in sorted(entries.items()):
214 if dir_name == '.':
215 full_rel = rel
216 else:
217 full_rel = '%s/%s' % (dir_name, rel)
218
219 component_unique_id = (
220 'https://www.mercurial-scm.org/wix-installer/0/component/%s'
221 % full_rel
222 )
223 component_guid = uuid.uuid5(uuid.NAMESPACE_URL, component_unique_id)
224 component_id = 'hg.component.%s' % str(component_guid).replace(
225 '-', '_'
226 )
227
228 component = doc.createElement('Component')
229
230 component.setAttribute('Id', component_id)
231 component.setAttribute('Guid', str(component_guid).upper())
232 component.setAttribute('Win64', 'yes' if is_x64 else 'no')
233
234 # Assign this component to a top-level group.
235 if dir_name == '.':
236 component_groups['ROOT'].append(component_id)
237 elif '/' in dir_name:
238 component_groups[dir_name[0 : dir_name.index('/')]].append(
239 component_id
240 )
241 else:
242 component_groups[dir_name].append(component_id)
243
244 unique_id = (
245 'https://www.mercurial-scm.org/wix-installer/0/%s' % full_rel
246 )
247 file_guid = uuid.uuid5(uuid.NAMESPACE_URL, unique_id)
248
249 # IDs have length limits. So use GUID to derive them.
250 file_guid_normalized = str(file_guid).replace('-', '_')
251 file_id = 'hg.file.%s' % file_guid_normalized
252
253 file_element = doc.createElement('File')
254 file_element.setAttribute('Id', file_id)
255 file_element.setAttribute('Source', str(source_path))
256 file_element.setAttribute('KeyPath', 'yes')
257 file_element.setAttribute('ReadOnly', 'yes')
258
259 component.appendChild(file_element)
260 directory_ref.appendChild(component)
261
262 fragment.appendChild(directory_ref)
263 doc.documentElement.appendChild(fragment)
264
265 for group, component_ids in sorted(component_groups.items()):
266 fragment = doc.createElement('Fragment')
267 component_group = doc.createElement('ComponentGroup')
268 component_group.setAttribute('Id', 'hg.group.%s' % group)
269
270 for component_id in component_ids:
271 component_ref = doc.createElement('ComponentRef')
272 component_ref.setAttribute('Id', component_id)
273 component_group.appendChild(component_ref)
274
275 fragment.appendChild(component_group)
276 doc.documentElement.appendChild(fragment)
277
278 # Add <Shortcut> to files that have it defined.
279 for file_id, metadata in sorted(SHORTCUTS.items()):
280 els = doc.getElementsByTagName('File')
281 els = [el for el in els if el.getAttribute('Id') == file_id]
282
283 if not els:
284 raise Exception('could not find File[Id=%s]' % file_id)
285
286 for el in els:
287 shortcut = doc.createElement('Shortcut')
288 shortcut.setAttribute('Id', 'hg.shortcut.%s' % file_id)
289 shortcut.setAttribute('Directory', 'ProgramMenuDir')
290 shortcut.setAttribute('Icon', 'hgIcon.ico')
291 shortcut.setAttribute('IconIndex', '0')
292 shortcut.setAttribute('Advertise', 'yes')
293 for k, v in sorted(metadata.items()):
294 shortcut.setAttribute(k, v)
295
296 el.appendChild(shortcut)
297
298 return doc.toprettyxml()
299
300
301 def build_installer_py2exe(
302 source_dir: pathlib.Path,
303 python_exe: pathlib.Path,
304 msi_name='mercurial',
305 version=None,
306 extra_packages_script=None,
307 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
308 extra_features: typing.Optional[typing.List[str]] = None,
309 signing_info: typing.Optional[typing.Dict[str, str]] = None,
310 ):
311 """Build a WiX MSI installer using py2exe.
312
313 ``source_dir`` is the path to the Mercurial source tree to use.
314 ``arch`` is the target architecture. either ``x86`` or ``x64``.
315 ``python_exe`` is the path to the Python executable to use/bundle.
316 ``version`` is the Mercurial version string. If not defined,
317 ``mercurial/__version__.py`` will be consulted.
318 ``extra_packages_script`` is a command to be run to inject extra packages
319 into the py2exe binary. It should stage packages into the virtualenv and
320 print a null byte followed by a newline-separated list of packages that
321 should be included in the exe.
322 ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}.
323 ``extra_features`` is a list of additional named Features to include in
324 the build. These must match Feature names in one of the wxs scripts.
325 """
326 arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86'
327
328 hg_build_dir = source_dir / 'build'
329
330 requirements_txt = (
331 source_dir / 'contrib' / 'packaging' / 'requirements-windows-py2.txt'
332 )
333
334 build_py2exe(
335 source_dir,
336 hg_build_dir,
337 python_exe,
338 'wix',
339 requirements_txt,
340 extra_packages=EXTRA_PACKAGES,
341 extra_packages_script=extra_packages_script,
342 extra_includes=EXTRA_INCLUDES,
343 )
344
345 build_dir = hg_build_dir / ('wix-%s' % arch)
346 staging_dir = build_dir / 'stage'
347
348 build_dir.mkdir(exist_ok=True)
349
350 # Purge the staging directory for every build so packaging is pristine.
351 if staging_dir.exists():
352 print('purging %s' % staging_dir)
353 shutil.rmtree(staging_dir)
354
355 stage_install(source_dir, staging_dir, lower_case=True)
356
357 # We also install some extra files.
358 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
359
360 # And remove some files we don't want.
361 for f in STAGING_REMOVE_FILES:
362 p = staging_dir / f
363 if p.exists():
364 print('removing %s' % p)
365 p.unlink()
366
367 return run_wix_packaging(
368 source_dir,
369 build_dir,
370 staging_dir,
371 arch,
372 version=version,
373 python2=True,
374 msi_name=msi_name,
375 suffix="-python2",
376 extra_wxs=extra_wxs,
377 extra_features=extra_features,
378 signing_info=signing_info,
379 )
380 20
381 21
382 22 def build_installer_pyoxidizer(
383 23 source_dir: pathlib.Path,
384 24 target_triple: str,
385 25 msi_name='mercurial',
386 26 version=None,
387 27 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
388 28 extra_features: typing.Optional[typing.List[str]] = None,
389 29 signing_info: typing.Optional[typing.Dict[str, str]] = None,
390 30 extra_pyoxidizer_vars=None,
391 31 ):
392 32 """Build a WiX MSI installer using PyOxidizer."""
393 33 hg_build_dir = source_dir / "build"
394 34 build_dir = hg_build_dir / ("wix-%s" % target_triple)
395 35
396 36 build_dir.mkdir(parents=True, exist_ok=True)
397 37
398 38 # Need to ensure docs HTML is built because this isn't done as part of
399 39 # `pip install Mercurial`.
400 40 build_docs_html(source_dir)
401 41
402 42 build_vars = {}
403 43
404 44 if msi_name:
405 45 build_vars["MSI_NAME"] = msi_name
406 46
407 47 if version:
408 48 build_vars["VERSION"] = version
409 49
410 50 if extra_features:
411 51 build_vars["EXTRA_MSI_FEATURES"] = ";".join(extra_features)
412 52
413 53 if signing_info:
414 54 if signing_info["cert_path"]:
415 55 build_vars["SIGNING_PFX_PATH"] = signing_info["cert_path"]
416 56 if signing_info["cert_password"]:
417 57 build_vars["SIGNING_PFX_PASSWORD"] = signing_info["cert_password"]
418 58 if signing_info["subject_name"]:
419 59 build_vars["SIGNING_SUBJECT_NAME"] = signing_info["subject_name"]
420 60 if signing_info["timestamp_url"]:
421 61 build_vars["TIME_STAMP_SERVER_URL"] = signing_info["timestamp_url"]
422 62
423 63 if extra_pyoxidizer_vars:
424 64 build_vars.update(json.loads(extra_pyoxidizer_vars))
425 65
426 66 if extra_wxs:
427 67 raise Exception(
428 68 "support for extra .wxs files has been temporarily dropped"
429 69 )
430 70
431 71 out_dir = run_pyoxidizer(
432 72 source_dir,
433 73 build_dir,
434 74 target_triple,
435 75 build_vars=build_vars,
436 76 target="msi",
437 77 )
438 78
439 79 msi_dir = out_dir / "msi"
440 80 msi_files = [f for f in os.listdir(msi_dir) if f.endswith(".msi")]
441 81
442 82 if len(msi_files) != 1:
443 83 raise Exception("expected exactly 1 .msi file; got %d" % len(msi_files))
444 84
445 85 msi_filename = msi_files[0]
446 86
447 87 msi_path = msi_dir / msi_filename
448 88 dist_path = source_dir / "dist" / msi_filename
449 89
450 90 dist_path.parent.mkdir(parents=True, exist_ok=True)
451 91
452 92 shutil.copyfile(msi_path, dist_path)
453 93
454 94 return {
455 95 "msi_path": dist_path,
456 96 }
457
458
459 def run_wix_packaging(
460 source_dir: pathlib.Path,
461 build_dir: pathlib.Path,
462 staging_dir: pathlib.Path,
463 arch: str,
464 version: str,
465 python2: bool,
466 msi_name: typing.Optional[str] = "mercurial",
467 suffix: str = "",
468 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
469 extra_features: typing.Optional[typing.List[str]] = None,
470 signing_info: typing.Optional[typing.Dict[str, str]] = None,
471 ):
472 """Invokes WiX to package up a built Mercurial.
473
474 ``signing_info`` is a dict defining properties to facilitate signing the
475 installer. Recognized keys include ``name``, ``subject_name``,
476 ``cert_path``, ``cert_password``, and ``timestamp_url``. If populated,
477 we will sign both the hg.exe and the .msi using the signing credentials
478 specified.
479 """
480
481 orig_version = version or find_version(source_dir)
482 version = normalize_windows_version(orig_version)
483 print('using version string: %s' % version)
484 if version != orig_version:
485 print('(normalized from: %s)' % orig_version)
486
487 if signing_info:
488 sign_with_signtool(
489 staging_dir / "hg.exe",
490 "%s %s" % (signing_info["name"], version),
491 subject_name=signing_info["subject_name"],
492 cert_path=signing_info["cert_path"],
493 cert_password=signing_info["cert_password"],
494 timestamp_url=signing_info["timestamp_url"],
495 )
496
497 wix_dir = source_dir / 'contrib' / 'packaging' / 'wix'
498
499 wix_pkg, wix_entry = download_entry('wix', build_dir)
500 wix_path = build_dir / ('wix-%s' % wix_entry['version'])
501
502 if not wix_path.exists():
503 extract_zip_to_directory(wix_pkg, wix_path)
504
505 if python2:
506 ensure_vc90_merge_modules(build_dir)
507
508 source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
509
510 defines = {'Platform': arch}
511
512 # Derive a .wxs file with the staged files.
513 manifest_wxs = build_dir / 'stage.wxs'
514 with manifest_wxs.open('w', encoding='utf-8') as fh:
515 fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64'))
516
517 run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines)
518
519 for source, rel_path in sorted((extra_wxs or {}).items()):
520 run_candle(wix_path, build_dir, source, rel_path, defines=defines)
521
522 source = wix_dir / 'mercurial.wxs'
523 defines['Version'] = version
524 defines['Comments'] = 'Installs Mercurial version %s' % version
525
526 if python2:
527 defines["PythonVersion"] = "2"
528 defines['VCRedistSrcDir'] = str(build_dir)
529 else:
530 defines["PythonVersion"] = "3"
531
532 if (staging_dir / "lib").exists():
533 defines["MercurialHasLib"] = "1"
534
535 if extra_features:
536 assert all(';' not in f for f in extra_features)
537 defines['MercurialExtraFeatures'] = ';'.join(extra_features)
538
539 run_candle(wix_path, build_dir, source, source_build_rel, defines=defines)
540
541 msi_path = (
542 source_dir
543 / 'dist'
544 / ('%s-%s-%s%s.msi' % (msi_name, orig_version, arch, suffix))
545 )
546
547 args = [
548 str(wix_path / 'light.exe'),
549 '-nologo',
550 '-ext',
551 'WixUIExtension',
552 '-sw1076',
553 '-spdb',
554 '-o',
555 str(msi_path),
556 ]
557
558 for source, rel_path in sorted((extra_wxs or {}).items()):
559 assert source.endswith('.wxs')
560 source = os.path.basename(source)
561 args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
562
563 args.extend(
564 [
565 str(build_dir / 'stage.wixobj'),
566 str(build_dir / 'mercurial.wixobj'),
567 ]
568 )
569
570 subprocess.run(args, cwd=str(source_dir), check=True)
571
572 print('%s created' % msi_path)
573
574 if signing_info:
575 sign_with_signtool(
576 msi_path,
577 "%s %s" % (signing_info["name"], version),
578 subject_name=signing_info["subject_name"],
579 cert_path=signing_info["cert_path"],
580 cert_password=signing_info["cert_password"],
581 timestamp_url=signing_info["timestamp_url"],
582 )
583
584 return {
585 'msi_path': msi_path,
586 }
@@ -1,96 +1,95 b''
1 1 #require test-repo
2 2
3 3 $ . "$TESTDIR/helpers-testrepo.sh"
4 4 $ check_code="$TESTDIR"/../contrib/check-code.py
5 5 $ cd "$TESTDIR"/..
6 6
7 7 New errors are not allowed. Warnings are strongly discouraged.
8 8 (The writing "no-che?k-code" is for not skipping this file when checking.)
9 9
10 10 $ testrepohg locate \
11 11 > -X contrib/python-zstandard \
12 12 > -X hgext/fsmonitor/pywatchman \
13 13 > -X mercurial/thirdparty \
14 14 > -X mercurial/pythoncapi_compat.h \
15 15 > | sed 's-\\-/-g' | "$check_code" --warnings --per-file=0 - || false
16 16 Skipping contrib/automation/hgautomation/__init__.py it has no-che?k-code (glob)
17 17 Skipping contrib/automation/hgautomation/aws.py it has no-che?k-code (glob)
18 18 Skipping contrib/automation/hgautomation/cli.py it has no-che?k-code (glob)
19 19 Skipping contrib/automation/hgautomation/linux.py it has no-che?k-code (glob)
20 20 Skipping contrib/automation/hgautomation/pypi.py it has no-che?k-code (glob)
21 21 Skipping contrib/automation/hgautomation/ssh.py it has no-che?k-code (glob)
22 22 Skipping contrib/automation/hgautomation/try_server.py it has no-che?k-code (glob)
23 23 Skipping contrib/automation/hgautomation/windows.py it has no-che?k-code (glob)
24 24 Skipping contrib/automation/hgautomation/winrm.py it has no-che?k-code (glob)
25 25 Skipping contrib/fuzz/FuzzedDataProvider.h it has no-che?k-code (glob)
26 26 Skipping contrib/fuzz/standalone_fuzz_target_runner.cc it has no-che?k-code (glob)
27 27 Skipping contrib/packaging/hgpackaging/cli.py it has no-che?k-code (glob)
28 28 Skipping contrib/packaging/hgpackaging/downloads.py it has no-che?k-code (glob)
29 29 Skipping contrib/packaging/hgpackaging/inno.py it has no-che?k-code (glob)
30 Skipping contrib/packaging/hgpackaging/py2exe.py it has no-che?k-code (glob)
31 30 Skipping contrib/packaging/hgpackaging/pyoxidizer.py it has no-che?k-code (glob)
32 31 Skipping contrib/packaging/hgpackaging/util.py it has no-che?k-code (glob)
33 32 Skipping contrib/packaging/hgpackaging/wix.py it has no-che?k-code (glob)
34 33 Skipping i18n/polib.py it has no-che?k-code (glob)
35 34 Skipping mercurial/statprof.py it has no-che?k-code (glob)
36 35 Skipping tests/testlib/badserverext.py it has no-che?k-code (glob)
37 36
38 37 @commands in debugcommands.py should be in alphabetical order.
39 38
40 39 >>> import re
41 40 >>> commands = []
42 41 >>> with open('mercurial/debugcommands.py', 'rb') as fh:
43 42 ... for line in fh:
44 43 ... m = re.match(br"^@command\('([a-z]+)", line)
45 44 ... if m:
46 45 ... commands.append(m.group(1))
47 46 >>> scommands = list(sorted(commands))
48 47 >>> for i, command in enumerate(scommands):
49 48 ... if command != commands[i]:
50 49 ... print('commands in debugcommands.py not sorted; first differing '
51 50 ... 'command is %s; expected %s' % (commands[i], command))
52 51 ... break
53 52
54 53 Prevent adding new files in the root directory accidentally.
55 54
56 55 $ testrepohg files 'glob:*'
57 56 .arcconfig
58 57 .clang-format
59 58 .editorconfig
60 59 .hgignore
61 60 .hgsigs
62 61 .hgtags
63 62 .jshintrc
64 63 CONTRIBUTING
65 64 CONTRIBUTORS
66 65 COPYING
67 66 Makefile
68 67 README.rst
69 68 hg
70 69 hgeditor
71 70 hgweb.cgi
72 71 pyproject.toml
73 72 rustfmt.toml
74 73 setup.py
75 74
76 75 Prevent adding modules which could be shadowed by ancient .so/.dylib.
77 76
78 77 $ testrepohg files \
79 78 > mercurial/base85.py \
80 79 > mercurial/bdiff.py \
81 80 > mercurial/diffhelpers.py \
82 81 > mercurial/mpatch.py \
83 82 > mercurial/osutil.py \
84 83 > mercurial/parsers.py \
85 84 > mercurial/zstd.py
86 85 [1]
87 86
88 87 Keep python3 tests sorted:
89 88 $ sort < contrib/python3-whitelist > $TESTTMP/py3sorted
90 89 $ cmp contrib/python3-whitelist $TESTTMP/py3sorted || echo 'Please sort passing tests!'
91 90
92 91 Keep Windows line endings in check
93 92
94 93 $ testrepohg files 'set:eol(dos)'
95 94 contrib/win32/hg.bat
96 95 contrib/win32/mercurial.ini
1 NO CONTENT: file was removed
1 NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now