/
installer.py
759 lines (582 loc) · 21.2 KB
/
installer.py
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
#!/usr/bin/env python3
# vimspector - A multi-language debugging system for Vim
# Copyright 2019 Ben Jackson
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from urllib import request
import contextlib
import functools
import gzip
import hashlib
import io
import os
import shutil
import ssl
import string
import subprocess
import sys
import tarfile
import time
import traceback
import zipfile
import json
from vimspector import install, gadgets
OUTPUT_VIEW = None
class Options:
vimspector_base = None
no_check_certificate = False
quiet = False
options = Options()
def Configure( **kwargs ):
for k, v in kwargs.items():
setattr( options, k, v )
def Print( *args, **kwargs ):
if not options.quiet:
print( *args, **kwargs )
class MissingExecutable( Exception ):
pass
def GetPATHAsList():
paths = os.environ[ 'PATH' ].split( os.pathsep )
if install.GetOS() == 'windows':
paths.insert( 0, os.getcwd() )
return paths
def FindExecutable( executable: str, paths=None ):
if not paths:
paths = GetPATHAsList()
if install.GetOS() == 'windows':
extensions = [ '.exe', '.bat', '.cmd' ]
else:
extensions = [ '' ]
for extension in extensions:
if executable.endswith( extension ):
candidate = executable
else:
candidate = executable + extension
for path in paths:
filename = os.path.abspath( os.path.join( path, candidate ) )
if not os.path.isfile( filename ):
continue
if not os.access( filename, os.F_OK | os.X_OK ):
continue
return filename
raise MissingExecutable( f"Unable to find executable { executable } in path" )
def CheckCall( cmd, *args, **kwargs ):
cmd[ 0 ] = FindExecutable( cmd[ 0 ] )
if options.quiet:
try:
subprocess.check_output( cmd, *args, stderr=subprocess.STDOUT, **kwargs )
except subprocess.CalledProcessError as e:
print( e.output.decode( 'utf-8' ) )
raise
else:
subprocess.check_call( cmd, *args, **kwargs )
def PathToAnyWorkingPython3():
# We can't rely on sys.executable because it's usually 'vim' (fixme, not with
# neovim?)
paths = GetPATHAsList()
if install.GetOS() == 'windows':
candidates = [ os.path.join( sys.exec_prefix, 'python.exe' ),
'python.exe' ]
else:
candidates = [ os.path.join( sys.exec_prefix, 'bin', 'python3' ),
'python3',
'python' ]
for candidate in candidates:
try:
return FindExecutable( candidate, paths=paths )
except MissingExecutable:
pass
raise RuntimeError( "Unable to find a working python3" )
def RunInstaller( api_prefix, leave_open, *args, **kwargs ):
from vimspector import utils, output, settings
import vim
if not args:
args = settings.List( 'install_gadgets' )
if not args:
return
args = GadgetListToInstallerArgs( *args )
vimspector_home = utils.GetVimValue( vim.vars, 'vimspector_home' )
vimspector_base_dir = utils.GetVimspectorBase()
global OUTPUT_VIEW
_ResetInstaller()
with utils.RestoreCurrentWindow():
vim.command( f'botright { settings.Int( "bottombar_height" ) }new' )
win = vim.current.window
OUTPUT_VIEW = output.OutputView( win, api_prefix )
cmd = [
PathToAnyWorkingPython3(),
'-u',
os.path.join( vimspector_home, 'install_gadget.py' ),
'--quiet',
'--update-gadget-config',
]
if not vimspector_base_dir == vimspector_home:
cmd.extend( [ '--basedir', vimspector_base_dir ] )
cmd.extend( args )
def handler( exit_code ):
if exit_code == 0:
if not leave_open:
_ResetInstaller()
utils.UserMessage( "Vimspector gadget installation complete!" )
vim.command( 'silent doautocmd User VimspectorInstallSuccess' )
if 'then' in kwargs:
kwargs[ 'then' ]()
else:
utils.UserMessage( 'Vimspector gadget installation reported errors',
error = True )
vim.command( 'silent doautocmd User VimspectorInstallFailed' )
OUTPUT_VIEW.RunJobWithOutput( 'Installer',
cmd,
completion_handler = handler,
syntax = 'vimspector-installer' )
OUTPUT_VIEW.ShowOutput( 'Installer' )
def RunUpdate( api_prefix, leave_open, *args ):
from vimspector import utils, settings
Configure( vimspector_base = utils.GetVimspectorBase() )
insatller_args = list( args )
insatller_args.extend( settings.List( 'install_gadgets' ) )
current_adapters = ReadAdapters( read_existing = True )
for adapter_name in current_adapters.keys():
insatller_args.extend( FindGadgetForAdapter( adapter_name ) )
if insatller_args:
insatller_args.append( '--upgrade' )
RunInstaller( api_prefix, leave_open, *insatller_args )
def _ResetInstaller():
global OUTPUT_VIEW
if OUTPUT_VIEW:
OUTPUT_VIEW.Reset()
OUTPUT_VIEW = None
def Abort():
_ResetInstaller()
from vimspector import utils
utils.UserMessage( 'Vimspector installation aborted',
persist = True,
error = True )
def GadgetListToInstallerArgs( *gadget_list ):
installer_args = []
for name in gadget_list:
if name.startswith( '-' ):
installer_args.append( name )
continue
try:
gadget = gadgets.GADGETS[ name ]
except KeyError:
continue
lang = gadget[ "language" ]
if isinstance( lang, list ):
lang = lang[ 0 ]
if not gadget.get( 'enabled', True ):
installer_args.append( f'--force-enable-{lang}' )
else:
installer_args.append( f'--enable-{lang}' )
return installer_args
def FindGadgetForAdapter( adapter_name ):
candidates = []
for name, gadget in gadgets.GADGETS.items():
v = {}
v.update( gadget.get( 'all', {} ) )
v.update( gadget.get( install.GetOS(), {} ) )
v.update( gadget.get( install.GetOSPlatform(), {} ) )
adapters = {}
adapters.update( v.get( 'adapters', {} ) )
adapters.update( gadget.get( 'adapters', {} ) )
if adapter_name in adapters:
candidates.append( name )
return candidates
class Manifest:
manifest: dict
def __init__( self ):
self.manifest = {}
self.Read()
def Read( self ):
try:
with open( install.GetManifestFile( options.vimspector_base ), 'r' ) as f:
self.manifest = json.load( f )
except OSError:
pass
def Write( self ):
with open( install.GetManifestFile( options.vimspector_base ), 'w' ) as f:
json.dump( self.manifest, f )
def Clear( self, name: str ):
try:
del self.manifest[ name ]
except KeyError:
pass
def Update( self, name: str, gadget_spec: dict ):
self.manifest[ name ] = gadget_spec
def RequiresUpdate( self, name: str, gadget_spec: dict ):
try:
current_spec = self.manifest[ name ]
except KeyError:
# It's new.
return True
# If anything changed in the spec, update
if not current_spec == gadget_spec:
return True
# Always update if the version string is 'master'. Probably a git repo
# that pulls master (which tbh we shouldn't have)
if current_spec.get( 'version' ) in ( 'master', '' ):
return True
if current_spec.get( 'repo', {} ).get( 'ref' ) == 'master':
return True
return False
def ReadAdapters( read_existing = True ):
all_adapters = {}
if read_existing:
try:
with open( install.GetGadgetConfigFile( options.vimspector_base ),
'r' ) as f:
all_adapters = json.load( f ).get( 'adapters', {} )
except OSError:
pass
# Include "built-in" adapter for multi-session mode
all_adapters.update( {
'multi-session': {
'port': '${port}',
'host': '${host}'
},
} )
return all_adapters
def WriteAdapters( all_adapters, to_file=None ):
adapter_config = json.dumps ( { 'adapters': all_adapters },
indent=2,
sort_keys=True )
if to_file:
to_file.write( adapter_config )
else:
with open( install.GetGadgetConfigFile( options.vimspector_base ),
'w' ) as f:
f.write( adapter_config )
def InstallGeneric( name, root, gadget ):
extension_path = gadget.get( 'extension_path', 'extension' )
extension = os.path.join( root, extension_path )
for f in gadget.get( 'make_executable', [] ):
MakeExecutable( os.path.join( extension, f ) )
MakeExtensionSymlink( name, root, extension_path )
def InstallCppTools( name, root, gadget ):
extension = os.path.join( root, 'extension' )
# It's hilarious, but the execute bits aren't set in the vsix. So they
# actually have javascript code which does this. It's just a horrible horrible
# hack that really is not funny.
for binary in [ 'OpenDebugAD7', 'OpenDebugAD7.exe' ]:
path = os.path.join( extension, 'debugAdapters', 'bin', binary )
if os.path.exists( path ):
MakeExecutable( path )
with open( os.path.join( extension, 'package.json' ) ) as f:
package = json.load( f )
runtime_dependencies = package[ 'runtimeDependencies' ]
for dependency in runtime_dependencies:
for binary in dependency.get( 'binaries' ):
file_path = os.path.abspath( os.path.join( extension, binary ) )
if os.path.exists( file_path ):
MakeExecutable( os.path.join( extension, binary ) )
MakeExtensionSymlink( name, root )
def InstallBashDebug( name, root, gadget ):
MakeExecutable( os.path.join( root, 'extension', 'bashdb_dir', 'bashdb' ) )
MakeExtensionSymlink( name, root )
def InstallDebugpy( name, root, gadget ):
wd = os.getcwd()
root = os.path.join( root, 'debugpy-{}'.format( gadget[ 'version' ] ) )
os.chdir( root )
try:
CheckCall( [ sys.executable, 'setup.py', 'build' ] )
finally:
os.chdir( wd )
MakeSymlink( name, root )
def InstallTclProDebug( name, root, gadget ):
configure = [ 'sh', './configure' ]
if install.GetOS() == 'macos':
# Apple removed the headers from system frameworks because they are
# determined to make life difficult. And the TCL configure scripts are super
# old so don't know about this. So we do their job for them and try and find
# a tclConfig.sh.
#
# NOTE however that in Apple's infinite wisdom, installing the "headers" in
# the other location is actually broken because the paths in the
# tclConfig.sh are pointing at the _old_ location. You actually do have to
# run the package installation which puts the headers back in order to work.
# This is why the below list is does not contain stuff from
# /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform
# '/Applications/Xcode.app/Contents/Developer/Platforms'
# '/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System'
# '/Library/Frameworks/Tcl.framework',
# '/Applications/Xcode.app/Contents/Developer/Platforms'
# '/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System'
# '/Library/Frameworks/Tcl.framework/Versions'
# '/Current',
for p in [ '/usr/local/opt/tcl-tk/lib', '/opt/homebrew/opt/tcl-tk/lib' ]:
if os.path.exists( os.path.join( p, 'tclConfig.sh' ) ):
configure.append( '--with-tcl=' + p )
break
with CurrentWorkingDir( os.path.join( root, 'lib', 'tclparser' ) ):
CheckCall( configure )
CheckCall( [ 'make' ] )
MakeSymlink( name, root )
def InstallNodeDebug( name, root, gadget ):
with CurrentWorkingDir( root ):
CheckCall( [ 'npm', 'install' ] )
CheckCall( [ 'npm', 'run', 'build' ] )
MakeSymlink( name, root )
def InstallLuaLocal( name, root, gadget ):
with CurrentWorkingDir( root ):
CheckCall( [ 'npm', 'install' ] )
CheckCall( [ 'npm', 'run', 'build' ] )
MakeSymlink( name, root )
def InstallGadget( name: str,
gadget: dict,
manifest: Manifest,
succeeded: list,
failed: list,
all_adapters: dict ):
try:
# Spec is an os-specific definition of the gadget
spec = {}
spec.update( gadget.get( 'all', {} ) )
spec.update( gadget.get( install.GetOS(), {} ) )
spec.update( gadget.get( install.GetOSPlatform(), {} ) )
def save_adapters():
# allow per-os adapter overrides. v already did that for us...
all_adapters.update( spec.get( 'adapters', {} ) )
# add any other "all" adapters
all_adapters.update( gadget.get( 'adapters', {} ) )
if 'download' in gadget:
if 'file_name' not in spec:
raise RuntimeError( "Unsupported OS/platform {} for gadget {}".format(
install.GetOSPlatform(),
name ) )
print( f"Installing {name}@{spec[ 'version' ]}..." )
spec[ 'download' ] = gadget[ 'download' ]
if not manifest.RequiresUpdate( name, spec ):
save_adapters()
print( " - Skip - up to date" )
return
destination = os.path.join(
install.GetGadgetDir( options.vimspector_base ),
'download',
name,
spec[ 'version' ] )
url = string.Template( gadget[ 'download' ][ 'url' ] ).substitute( spec )
file_path = DownloadFileTo(
url,
destination,
file_name = gadget[ 'download' ].get( 'target' ),
checksum = spec.get( 'checksum' ),
check_certificate = not options.no_check_certificate )
root = os.path.join( destination, 'root' )
ExtractZipTo(
file_path,
root,
format = gadget[ 'download' ].get( 'format', 'zip' ) )
elif 'repo' in gadget:
url = string.Template( gadget[ 'repo' ][ 'url' ] ).substitute( spec )
ref = string.Template( gadget[ 'repo' ][ 'ref' ] ).substitute( spec )
print( f"Installing {name}@{ref}..." )
spec[ 'repo' ] = gadget[ 'repo' ]
if not manifest.RequiresUpdate( name, spec ):
save_adapters()
print( " - Skip - up to date" )
return
destination = os.path.join(
install.GetGadgetDir( options.vimspector_base ),
'download',
name )
CloneRepoTo( url, ref, destination )
root = destination
if 'do' in gadget:
gadget[ 'do' ]( name, root, spec )
else:
InstallGeneric( name, root, spec )
save_adapters()
manifest.Update( name, spec )
succeeded.append( name )
print( f" - Done installing {name}" )
except Exception as e:
if not options.quiet:
traceback.print_exc()
failed.append( name )
print( f" - FAILED installing {name}: {e}".format( name, e ) )
@contextlib.contextmanager
def CurrentWorkingDir( d ):
cur_d = os.getcwd()
try:
os.chdir( d )
yield
finally:
os.chdir( cur_d )
def MakeExecutable( file_path ):
# TODO: import stat and use them by _just_ adding the X bit.
Print( 'Making executable: {}'.format( file_path ) )
os.chmod( file_path, 0o755 )
def WithRetry( f ):
retries = 5
timeout = 1 # seconds
@functools.wraps( f )
def wrapper( *args, **kwargs ):
thrown = None
for _ in range( retries ):
try:
return f( *args, **kwargs )
except Exception as e:
thrown = e
Print( "Failed - {}, will retry in {} seconds".format( e, timeout ) )
time.sleep( timeout )
raise thrown
return wrapper
@WithRetry
def UrlOpen( *args, **kwargs ):
return request.urlopen( *args, **kwargs )
def DownloadFileTo( url,
destination,
file_name = None,
checksum = None,
check_certificate = True ):
if not file_name:
file_name = url.split( '/' )[ -1 ]
file_path = os.path.abspath( os.path.join( destination, file_name ) )
if not os.path.isdir( destination ):
os.makedirs( destination )
if os.path.exists( file_path ):
if checksum:
if ValidateCheckSumSHA256( file_path, checksum ):
Print( "Checksum matches for {}, using it".format( file_path ) )
return file_path
else:
Print( "Checksum doesn't match for {}, removing it".format(
file_path ) )
Print( "Removing existing {}".format( file_path ) )
os.remove( file_path )
r = request.Request( url, headers = { 'User-Agent': 'Vimspector' } )
Print( "Downloading {} to {}/{}".format( url, destination, file_name ) )
if not check_certificate:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
kwargs = { "context": context }
else:
kwargs = {}
with contextlib.closing( UrlOpen( r, **kwargs ) ) as u:
with open( file_path, 'wb' ) as f:
f.write( u.read() )
if checksum:
if not ValidateCheckSumSHA256( file_path, checksum ):
raise RuntimeError(
'Checksum for {} ({}) does not match expected {}'.format(
file_path,
GetChecksumSHA254( file_path ),
checksum ) )
else:
Print( "Checksum for {}: {}".format( file_path,
GetChecksumSHA254( file_path ) ) )
return file_path
def GetChecksumSHA254( file_path ):
with open( file_path, 'rb' ) as existing_file:
return hashlib.sha256( existing_file.read() ).hexdigest()
def ValidateCheckSumSHA256( file_path, checksum ):
existing_sha256 = GetChecksumSHA254( file_path )
return existing_sha256 == checksum
def RemoveIfExists( destination ):
try:
os.remove( destination )
Print( "Removed file {}".format( destination ) )
return
except OSError:
pass
N = 1
def BackupDir():
return "{}.{}".format( destination, N )
while os.path.isdir( BackupDir() ):
Print( "Removing old dir {}".format( BackupDir() ) )
try:
shutil.rmtree( BackupDir() )
Print ( "OK, removed it" )
break
except OSError as e:
Print ( f"FAILED to remove {BackupDir()}: {e}" )
N = N + 1
if os.path.exists( destination ):
Print( "Removing dir {}".format( destination ) )
try:
shutil.rmtree( destination )
except OSError:
Print( "FAILED, moving {} to dir {}".format( destination, BackupDir() ) )
os.rename( destination, BackupDir() )
# Python's ZipFile module strips execute bits from files, for no good reason
# other than crappy code. Let's do it's job for it.
class ModePreservingZipFile( zipfile.ZipFile ):
def extract( self, member, path = None, pwd = None ):
if not isinstance( member, zipfile.ZipInfo ):
member = self.getinfo( member )
if path is None:
path = os.getcwd()
ret_val = self._extract_member( member, path, pwd )
attr = member.external_attr >> 16
os.chmod( ret_val, attr )
return ret_val
def ExtractZipTo( file_path, destination, format ):
Print( "Extracting {} to {}".format( file_path, destination ) )
RemoveIfExists( destination )
if format == 'zip':
with ModePreservingZipFile( file_path ) as f:
f.extractall( path = destination )
elif format == 'zip.gz':
with gzip.open( file_path, 'rb' ) as f:
file_contents = f.read()
with ModePreservingZipFile( io.BytesIO( file_contents ) ) as f:
f.extractall( path = destination )
elif format == 'tar':
try:
with tarfile.open( file_path ) as f:
f.extractall( path = destination )
except Exception:
# There seems to a bug in python's tarfile that means it can't read some
# windows-generated tar files
os.makedirs( destination )
with CurrentWorkingDir( destination ):
CheckCall( [ 'tar', 'zxvf', file_path ] )
def MakeExtensionSymlink( name, root, extension_path = 'extension' ):
MakeSymlink( name, os.path.join( root, extension_path ) ),
def MakeSymlink( link, pointing_to, in_folder = None ):
if not in_folder:
in_folder = install.GetGadgetDir( options.vimspector_base )
RemoveIfExists( os.path.join( in_folder, link ) )
in_folder = os.path.abspath( in_folder )
pointing_to_relative = os.path.relpath( os.path.abspath( pointing_to ),
in_folder )
link_path = os.path.join( in_folder, link )
if install.GetOS() == 'windows':
# While symlinks do exist on Windows, they require elevated privileges, so
# let's use a directory junction which is all we need.
link_path = os.path.abspath( link_path )
if os.path.isdir( link_path ):
os.rmdir( link_path )
CheckCall( [ 'cmd.exe', '/c', 'mklink', '/J', link_path, pointing_to ] )
else:
os.symlink( pointing_to_relative, link_path )
def CloneRepoTo( url, ref, destination ):
RemoveIfExists( destination )
git_in_repo = [ 'git', '-C', destination ]
CheckCall( [ 'git', 'clone', url, destination ] )
CheckCall( git_in_repo + [ 'checkout', ref ] )
CheckCall( git_in_repo + [ 'submodule', 'sync', '--recursive' ] )
CheckCall( git_in_repo + [ 'submodule', 'update', '--init', '--recursive' ] )
def AbortIfSUperUser( force_sudo ):
# TODO: We should probably check the effective uid too
is_su = False
if 'SUDO_COMMAND' in os.environ:
is_su = True
if is_su:
if force_sudo:
print( "*** RUNNING AS SUPER USER DUE TO force_sudo! "
" All bets are off. ***" )
else:
sys.exit( "This script should *not* be run as super user. Aborting." )