##// END OF EJS Templates
packaging: make the path to Win32 requirements absolute when building WiX...
Matt Harbison -
r44724:847e582f stable
parent child Browse files
Show More
@@ -1,497 +1,499 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 10 import collections
11 11 import os
12 12 import pathlib
13 13 import re
14 14 import shutil
15 15 import subprocess
16 16 import typing
17 17 import uuid
18 18 import xml.dom.minidom
19 19
20 20 from .downloads import download_entry
21 21 from .py2exe import (
22 22 build_py2exe,
23 23 stage_install,
24 24 )
25 25 from .util import (
26 26 extract_zip_to_directory,
27 27 normalize_windows_version,
28 28 process_install_rules,
29 29 sign_with_signtool,
30 30 )
31 31
32 32
33 33 EXTRA_PACKAGES = {
34 34 'dulwich',
35 35 'distutils',
36 36 'keyring',
37 37 'pygments',
38 38 'win32ctypes',
39 39 }
40 40
41 41
42 42 EXTRA_INSTALL_RULES = [
43 43 ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'),
44 44 ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'),
45 45 ]
46 46
47 47 STAGING_REMOVE_FILES = [
48 48 # We use the RTF variant.
49 49 'copying.txt',
50 50 ]
51 51
52 52 SHORTCUTS = {
53 53 # hg.1.html'
54 54 'hg.file.5d3e441c_28d9_5542_afd0_cdd4234f12d5': {
55 55 'Name': 'Mercurial Command Reference',
56 56 },
57 57 # hgignore.5.html
58 58 'hg.file.5757d8e0_f207_5e10_a2ec_3ba0a062f431': {
59 59 'Name': 'Mercurial Ignore Files',
60 60 },
61 61 # hgrc.5.html
62 62 'hg.file.92e605fd_1d1a_5dc6_9fc0_5d2998eb8f5e': {
63 63 'Name': 'Mercurial Configuration Files',
64 64 },
65 65 }
66 66
67 67
68 68 def find_version(source_dir: pathlib.Path):
69 69 version_py = source_dir / 'mercurial' / '__version__.py'
70 70
71 71 with version_py.open('r', encoding='utf-8') as fh:
72 72 source = fh.read().strip()
73 73
74 74 m = re.search('version = b"(.*)"', source)
75 75 return m.group(1)
76 76
77 77
78 78 def ensure_vc90_merge_modules(build_dir):
79 79 x86 = (
80 80 download_entry(
81 81 'vc9-crt-x86-msm',
82 82 build_dir,
83 83 local_name='microsoft.vcxx.crt.x86_msm.msm',
84 84 )[0],
85 85 download_entry(
86 86 'vc9-crt-x86-msm-policy',
87 87 build_dir,
88 88 local_name='policy.x.xx.microsoft.vcxx.crt.x86_msm.msm',
89 89 )[0],
90 90 )
91 91
92 92 x64 = (
93 93 download_entry(
94 94 'vc9-crt-x64-msm',
95 95 build_dir,
96 96 local_name='microsoft.vcxx.crt.x64_msm.msm',
97 97 )[0],
98 98 download_entry(
99 99 'vc9-crt-x64-msm-policy',
100 100 build_dir,
101 101 local_name='policy.x.xx.microsoft.vcxx.crt.x64_msm.msm',
102 102 )[0],
103 103 )
104 104 return {
105 105 'x86': x86,
106 106 'x64': x64,
107 107 }
108 108
109 109
110 110 def run_candle(wix, cwd, wxs, source_dir, defines=None):
111 111 args = [
112 112 str(wix / 'candle.exe'),
113 113 '-nologo',
114 114 str(wxs),
115 115 '-dSourceDir=%s' % source_dir,
116 116 ]
117 117
118 118 if defines:
119 119 args.extend('-d%s=%s' % define for define in sorted(defines.items()))
120 120
121 121 subprocess.run(args, cwd=str(cwd), check=True)
122 122
123 123
124 124 def make_post_build_signing_fn(
125 125 name,
126 126 subject_name=None,
127 127 cert_path=None,
128 128 cert_password=None,
129 129 timestamp_url=None,
130 130 ):
131 131 """Create a callable that will use signtool to sign hg.exe."""
132 132
133 133 def post_build_sign(source_dir, build_dir, dist_dir, version):
134 134 description = '%s %s' % (name, version)
135 135
136 136 sign_with_signtool(
137 137 dist_dir / 'hg.exe',
138 138 description,
139 139 subject_name=subject_name,
140 140 cert_path=cert_path,
141 141 cert_password=cert_password,
142 142 timestamp_url=timestamp_url,
143 143 )
144 144
145 145 return post_build_sign
146 146
147 147
148 148 def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str:
149 149 """Create XML string listing every file to be installed."""
150 150
151 151 # We derive GUIDs from a deterministic file path identifier.
152 152 # We shoehorn the name into something that looks like a URL because
153 153 # the UUID namespaces are supposed to work that way (even though
154 154 # the input data probably is never validated).
155 155
156 156 doc = xml.dom.minidom.parseString(
157 157 '<?xml version="1.0" encoding="utf-8"?>'
158 158 '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">'
159 159 '</Wix>'
160 160 )
161 161
162 162 # Assemble the install layout by directory. This makes it easier to
163 163 # emit XML, since each directory has separate entities.
164 164 manifest = collections.defaultdict(dict)
165 165
166 166 for root, dirs, files in os.walk(staging_dir):
167 167 dirs.sort()
168 168
169 169 root = pathlib.Path(root)
170 170 rel_dir = root.relative_to(staging_dir)
171 171
172 172 for i in range(len(rel_dir.parts)):
173 173 parent = '/'.join(rel_dir.parts[0 : i + 1])
174 174 manifest.setdefault(parent, {})
175 175
176 176 for f in sorted(files):
177 177 full = root / f
178 178 manifest[str(rel_dir).replace('\\', '/')][full.name] = full
179 179
180 180 component_groups = collections.defaultdict(list)
181 181
182 182 # Now emit a <Fragment> for each directory.
183 183 # Each directory is composed of a <DirectoryRef> pointing to its parent
184 184 # and defines child <Directory>'s and a <Component> with all the files.
185 185 for dir_name, entries in sorted(manifest.items()):
186 186 # The directory id is derived from the path. But the root directory
187 187 # is special.
188 188 if dir_name == '.':
189 189 parent_directory_id = 'INSTALLDIR'
190 190 else:
191 191 parent_directory_id = 'hg.dir.%s' % dir_name.replace('/', '.')
192 192
193 193 fragment = doc.createElement('Fragment')
194 194 directory_ref = doc.createElement('DirectoryRef')
195 195 directory_ref.setAttribute('Id', parent_directory_id)
196 196
197 197 # Add <Directory> entries for immediate children directories.
198 198 for possible_child in sorted(manifest.keys()):
199 199 if (
200 200 dir_name == '.'
201 201 and '/' not in possible_child
202 202 and possible_child != '.'
203 203 ):
204 204 child_directory_id = 'hg.dir.%s' % possible_child
205 205 name = possible_child
206 206 else:
207 207 if not possible_child.startswith('%s/' % dir_name):
208 208 continue
209 209 name = possible_child[len(dir_name) + 1 :]
210 210 if '/' in name:
211 211 continue
212 212
213 213 child_directory_id = 'hg.dir.%s' % possible_child.replace(
214 214 '/', '.'
215 215 )
216 216
217 217 directory = doc.createElement('Directory')
218 218 directory.setAttribute('Id', child_directory_id)
219 219 directory.setAttribute('Name', name)
220 220 directory_ref.appendChild(directory)
221 221
222 222 # Add <Component>s for files in this directory.
223 223 for rel, source_path in sorted(entries.items()):
224 224 if dir_name == '.':
225 225 full_rel = rel
226 226 else:
227 227 full_rel = '%s/%s' % (dir_name, rel)
228 228
229 229 component_unique_id = (
230 230 'https://www.mercurial-scm.org/wix-installer/0/component/%s'
231 231 % full_rel
232 232 )
233 233 component_guid = uuid.uuid5(uuid.NAMESPACE_URL, component_unique_id)
234 234 component_id = 'hg.component.%s' % str(component_guid).replace(
235 235 '-', '_'
236 236 )
237 237
238 238 component = doc.createElement('Component')
239 239
240 240 component.setAttribute('Id', component_id)
241 241 component.setAttribute('Guid', str(component_guid).upper())
242 242 component.setAttribute('Win64', 'yes' if is_x64 else 'no')
243 243
244 244 # Assign this component to a top-level group.
245 245 if dir_name == '.':
246 246 component_groups['ROOT'].append(component_id)
247 247 elif '/' in dir_name:
248 248 component_groups[dir_name[0 : dir_name.index('/')]].append(
249 249 component_id
250 250 )
251 251 else:
252 252 component_groups[dir_name].append(component_id)
253 253
254 254 unique_id = (
255 255 'https://www.mercurial-scm.org/wix-installer/0/%s' % full_rel
256 256 )
257 257 file_guid = uuid.uuid5(uuid.NAMESPACE_URL, unique_id)
258 258
259 259 # IDs have length limits. So use GUID to derive them.
260 260 file_guid_normalized = str(file_guid).replace('-', '_')
261 261 file_id = 'hg.file.%s' % file_guid_normalized
262 262
263 263 file_element = doc.createElement('File')
264 264 file_element.setAttribute('Id', file_id)
265 265 file_element.setAttribute('Source', str(source_path))
266 266 file_element.setAttribute('KeyPath', 'yes')
267 267 file_element.setAttribute('ReadOnly', 'yes')
268 268
269 269 component.appendChild(file_element)
270 270 directory_ref.appendChild(component)
271 271
272 272 fragment.appendChild(directory_ref)
273 273 doc.documentElement.appendChild(fragment)
274 274
275 275 for group, component_ids in sorted(component_groups.items()):
276 276 fragment = doc.createElement('Fragment')
277 277 component_group = doc.createElement('ComponentGroup')
278 278 component_group.setAttribute('Id', 'hg.group.%s' % group)
279 279
280 280 for component_id in component_ids:
281 281 component_ref = doc.createElement('ComponentRef')
282 282 component_ref.setAttribute('Id', component_id)
283 283 component_group.appendChild(component_ref)
284 284
285 285 fragment.appendChild(component_group)
286 286 doc.documentElement.appendChild(fragment)
287 287
288 288 # Add <Shortcut> to files that have it defined.
289 289 for file_id, metadata in sorted(SHORTCUTS.items()):
290 290 els = doc.getElementsByTagName('File')
291 291 els = [el for el in els if el.getAttribute('Id') == file_id]
292 292
293 293 if not els:
294 294 raise Exception('could not find File[Id=%s]' % file_id)
295 295
296 296 for el in els:
297 297 shortcut = doc.createElement('Shortcut')
298 298 shortcut.setAttribute('Id', 'hg.shortcut.%s' % file_id)
299 299 shortcut.setAttribute('Directory', 'ProgramMenuDir')
300 300 shortcut.setAttribute('Icon', 'hgIcon.ico')
301 301 shortcut.setAttribute('IconIndex', '0')
302 302 shortcut.setAttribute('Advertise', 'yes')
303 303 for k, v in sorted(metadata.items()):
304 304 shortcut.setAttribute(k, v)
305 305
306 306 el.appendChild(shortcut)
307 307
308 308 return doc.toprettyxml()
309 309
310 310
311 311 def build_installer(
312 312 source_dir: pathlib.Path,
313 313 python_exe: pathlib.Path,
314 314 msi_name='mercurial',
315 315 version=None,
316 316 post_build_fn=None,
317 317 extra_packages_script=None,
318 318 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
319 319 extra_features: typing.Optional[typing.List[str]] = None,
320 320 ):
321 321 """Build a WiX MSI installer.
322 322
323 323 ``source_dir`` is the path to the Mercurial source tree to use.
324 324 ``arch`` is the target architecture. either ``x86`` or ``x64``.
325 325 ``python_exe`` is the path to the Python executable to use/bundle.
326 326 ``version`` is the Mercurial version string. If not defined,
327 327 ``mercurial/__version__.py`` will be consulted.
328 328 ``post_build_fn`` is a callable that will be called after building
329 329 Mercurial but before invoking WiX. It can be used to e.g. facilitate
330 330 signing. It is passed the paths to the Mercurial source, build, and
331 331 dist directories and the resolved Mercurial version.
332 332 ``extra_packages_script`` is a command to be run to inject extra packages
333 333 into the py2exe binary. It should stage packages into the virtualenv and
334 334 print a null byte followed by a newline-separated list of packages that
335 335 should be included in the exe.
336 336 ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}.
337 337 ``extra_features`` is a list of additional named Features to include in
338 338 the build. These must match Feature names in one of the wxs scripts.
339 339 """
340 340 arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86'
341 341
342 342 hg_build_dir = source_dir / 'build'
343 343 dist_dir = source_dir / 'dist'
344 344 wix_dir = source_dir / 'contrib' / 'packaging' / 'wix'
345 345
346 requirements_txt = 'requirements_win32.txt'
346 requirements_txt = (
347 source_dir / 'contrib' / 'packaging' / 'requirements_win32.txt'
348 )
347 349
348 350 build_py2exe(
349 351 source_dir,
350 352 hg_build_dir,
351 353 python_exe,
352 354 'wix',
353 355 requirements_txt,
354 356 extra_packages=EXTRA_PACKAGES,
355 357 extra_packages_script=extra_packages_script,
356 358 )
357 359
358 360 orig_version = version or find_version(source_dir)
359 361 version = normalize_windows_version(orig_version)
360 362 print('using version string: %s' % version)
361 363 if version != orig_version:
362 364 print('(normalized from: %s)' % orig_version)
363 365
364 366 if post_build_fn:
365 367 post_build_fn(source_dir, hg_build_dir, dist_dir, version)
366 368
367 369 build_dir = hg_build_dir / ('wix-%s' % arch)
368 370 staging_dir = build_dir / 'stage'
369 371
370 372 build_dir.mkdir(exist_ok=True)
371 373
372 374 # Purge the staging directory for every build so packaging is pristine.
373 375 if staging_dir.exists():
374 376 print('purging %s' % staging_dir)
375 377 shutil.rmtree(staging_dir)
376 378
377 379 stage_install(source_dir, staging_dir, lower_case=True)
378 380
379 381 # We also install some extra files.
380 382 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
381 383
382 384 # And remove some files we don't want.
383 385 for f in STAGING_REMOVE_FILES:
384 386 p = staging_dir / f
385 387 if p.exists():
386 388 print('removing %s' % p)
387 389 p.unlink()
388 390
389 391 wix_pkg, wix_entry = download_entry('wix', hg_build_dir)
390 392 wix_path = hg_build_dir / ('wix-%s' % wix_entry['version'])
391 393
392 394 if not wix_path.exists():
393 395 extract_zip_to_directory(wix_pkg, wix_path)
394 396
395 397 ensure_vc90_merge_modules(hg_build_dir)
396 398
397 399 source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
398 400
399 401 defines = {'Platform': arch}
400 402
401 403 # Derive a .wxs file with the staged files.
402 404 manifest_wxs = build_dir / 'stage.wxs'
403 405 with manifest_wxs.open('w', encoding='utf-8') as fh:
404 406 fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64'))
405 407
406 408 run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines)
407 409
408 410 for source, rel_path in sorted((extra_wxs or {}).items()):
409 411 run_candle(wix_path, build_dir, source, rel_path, defines=defines)
410 412
411 413 source = wix_dir / 'mercurial.wxs'
412 414 defines['Version'] = version
413 415 defines['Comments'] = 'Installs Mercurial version %s' % version
414 416 defines['VCRedistSrcDir'] = str(hg_build_dir)
415 417 if extra_features:
416 418 assert all(';' not in f for f in extra_features)
417 419 defines['MercurialExtraFeatures'] = ';'.join(extra_features)
418 420
419 421 run_candle(wix_path, build_dir, source, source_build_rel, defines=defines)
420 422
421 423 msi_path = (
422 424 source_dir / 'dist' / ('%s-%s-%s.msi' % (msi_name, orig_version, arch))
423 425 )
424 426
425 427 args = [
426 428 str(wix_path / 'light.exe'),
427 429 '-nologo',
428 430 '-ext',
429 431 'WixUIExtension',
430 432 '-sw1076',
431 433 '-spdb',
432 434 '-o',
433 435 str(msi_path),
434 436 ]
435 437
436 438 for source, rel_path in sorted((extra_wxs or {}).items()):
437 439 assert source.endswith('.wxs')
438 440 source = os.path.basename(source)
439 441 args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
440 442
441 443 args.extend(
442 444 [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),]
443 445 )
444 446
445 447 subprocess.run(args, cwd=str(source_dir), check=True)
446 448
447 449 print('%s created' % msi_path)
448 450
449 451 return {
450 452 'msi_path': msi_path,
451 453 }
452 454
453 455
454 456 def build_signed_installer(
455 457 source_dir: pathlib.Path,
456 458 python_exe: pathlib.Path,
457 459 name: str,
458 460 version=None,
459 461 subject_name=None,
460 462 cert_path=None,
461 463 cert_password=None,
462 464 timestamp_url=None,
463 465 extra_packages_script=None,
464 466 extra_wxs=None,
465 467 extra_features=None,
466 468 ):
467 469 """Build an installer with signed executables."""
468 470
469 471 post_build_fn = make_post_build_signing_fn(
470 472 name,
471 473 subject_name=subject_name,
472 474 cert_path=cert_path,
473 475 cert_password=cert_password,
474 476 timestamp_url=timestamp_url,
475 477 )
476 478
477 479 info = build_installer(
478 480 source_dir,
479 481 python_exe=python_exe,
480 482 msi_name=name.lower(),
481 483 version=version,
482 484 post_build_fn=post_build_fn,
483 485 extra_packages_script=extra_packages_script,
484 486 extra_wxs=extra_wxs,
485 487 extra_features=extra_features,
486 488 )
487 489
488 490 description = '%s %s' % (name, version)
489 491
490 492 sign_with_signtool(
491 493 info['msi_path'],
492 494 description,
493 495 subject_name=subject_name,
494 496 cert_path=cert_path,
495 497 cert_password=cert_password,
496 498 timestamp_url=timestamp_url,
497 499 )
General Comments 0
You need to be logged in to leave comments. Login now