Merge branch 'python'
[swc-setup-windows-installer.git] / swc-windows-installer.py
1 #!/usr/bin/env python
2
3 """Software Carpentry Windows Installer
4
5 Helps mimic a *nix environment on Windows with as little work as possible.
6
7 The script:
8 * Installs GNU Make and makes it accessible from msysGit
9 * Installs nano and makes it accessible from msysGit
10 * Installs SQLite and makes it accessible from msysGit
11 * Creates a ~/nano.rc with links to syntax highlighting configs
12 * Provides standard nosetests behavior for msysGit
13 * Adds R's bin directory to the path (if we can find it)
14
15 To use:
16
17 1. Install Python, IPython, and Nose.  An easy way to do this is with
18    the Anaconda Python distribution
19    http://continuum.io/downloads
20 2. Install msysGit
21    https://github.com/msysgit/msysgit/releases
22 3. Install R (if your workshop uses R)
23    http://cran.r-project.org/bin/windows/base/rw-FAQ.html#Installation-and-Usage
24 4. Run swc-windows-installer.py.
25    You should be able to simply double click the file in Windows
26
27 """
28
29 import glob
30 import hashlib
31 try:  # Python 3
32     from io import BytesIO as _BytesIO
33 except ImportError:  # Python 2
34     from StringIO import StringIO as _BytesIO
35 import logging
36 import os
37 import re
38 import sys
39 import tarfile
40 try:  # Python 3
41     from urllib.request import urlopen as _urlopen
42 except ImportError:  # Python 2
43     from urllib2 import urlopen as _urlopen
44 import zipfile
45
46
47 __version__ = '0.2'
48
49 LOG = logging.getLogger('swc-windows-installer')
50 LOG.addHandler(logging.StreamHandler())
51 LOG.setLevel(logging.INFO)
52
53
54 if sys.version_info >= (3, 0):  # Python 3
55     open3 = open
56 else:
57     def open3(file, mode='r', newline=None):
58         if newline:
59             if newline != '\n':
60                 raise NotImplementedError(newline)
61             f = open(file, mode + 'b')
62         else:
63             f = open(file, mode)
64         return f
65
66
67 def download(url, sha1):
68     """Download a file and verify its hash"""
69     LOG.debug('download {}'.format(url))
70     r = _urlopen(url)
71     byte_content = r.read()
72     download_sha1 = hashlib.sha1(byte_content).hexdigest()
73     if download_sha1 != sha1:
74         raise ValueError(
75             'downloaded {!r} has the wrong SHA-1 hash: {} != {}'.format(
76                 url, download_sha1, sha1))
77     LOG.debug('SHA-1 for {} matches the expected {}'.format(url, sha1))
78     return byte_content
79
80
81 def splitall(path):
82     """Split a path into a list of components
83
84     >>> splitall('nano-2.2.6/doc/Makefile.am')
85     ['nano-2.2.6', 'doc', 'Makefile.am']
86     """
87     parts = []
88     while True:
89         head, tail = os.path.split(path)
90         if tail:
91             parts.insert(0, tail)
92         elif head:
93             parts.insert(0, head)
94             break
95         else:
96             break
97         path = head
98     return parts
99
100
101 def transform(tarinfo, strip_components=0):
102     """Transform TarInfo objects for extraction"""
103     path_components = splitall(tarinfo.name)
104     try:
105         tarinfo.name = os.path.join(*path_components[strip_components:])
106     except TypeError:
107         if len(path_components) <= strip_components:
108             return None
109         raise
110     return tarinfo
111
112
113 def tar_install(url, sha1, install_directory, compression='*',
114                 strip_components=0):
115     """Download and install a tar bundle"""
116     if not os.path.isdir(install_directory):
117         tar_bytes = download(url=url, sha1=sha1)
118         tar_io = _BytesIO(tar_bytes)
119         filename = os.path.basename(url)
120         mode = 'r:{}'.format(compression)
121         tar_file = tarfile.open(filename, mode, tar_io)
122         LOG.info('installing {} into {}'.format(url, install_directory))
123         os.makedirs(install_directory)
124         members = [
125             transform(tarinfo=tarinfo, strip_components=strip_components)
126             for tarinfo in tar_file]
127         tar_file.extractall(
128             path=install_directory,
129             members=[m for m in members if m is not None])
130     else:
131         LOG.info('existing installation at {}'.format(install_directory))
132
133
134 def zip_install(url, sha1, install_directory):
135     """Download and install a zipped bundle"""
136     if not os.path.isdir(install_directory):
137         zip_bytes = download(url=url, sha1=sha1)
138         zip_io = _BytesIO(zip_bytes)
139         zip_file = zipfile.ZipFile(zip_io)
140         LOG.info('installing {} into {}'.format(url, install_directory))
141         os.makedirs(install_directory)
142         zip_file.extractall(install_directory)
143     else:
144         LOG.info('existing installation at {}'.format(install_directory))
145
146
147 def install_msysgit_binary(name, sha1, install_directory,
148                            tag='Git-1.9.4-preview20140815'):
149     """Download and install a binary from msysGit's bin directory"""
150     bytes = download(
151         url='https://github.com/msysgit/msysgit/raw/{}/bin/{}'.format(
152             tag, name),
153         sha1=sha1)
154     LOG.info('installing {} into {}'.format(name, install_directory))
155     with open(os.path.join(install_directory, name), 'wb') as f:
156         f.write(bytes)
157
158
159 def install_nano(install_directory):
160     """Download and install the nano text editor"""
161     zip_install(
162         url='http://www.nano-editor.org/dist/v2.2/NT/nano-2.2.6.zip',
163         sha1='f5348208158157060de0a4df339401f36250fe5b',
164         install_directory=install_directory)
165
166
167 def install_nanorc(install_directory):
168     """Download and install nano syntax highlighting"""
169     tar_install(
170         url='http://www.nano-editor.org/dist/v2.2/nano-2.2.6.tar.gz',
171         sha1='f2a628394f8dda1b9f28c7e7b89ccb9a6dbd302a',
172         install_directory=install_directory,
173         strip_components=1)
174     home = os.path.expanduser('~')
175     nanorc = os.path.join(home, 'nano.rc')
176     if not os.path.isfile(nanorc):
177         syntax_dir = os.path.join(install_directory, 'doc', 'syntax')
178         LOG.info('include nanorc from {} in {}'.format(syntax_dir, nanorc))
179         with open3(nanorc, 'w', newline='\n') as f:
180             for filename in os.listdir(syntax_dir):
181                 if filename.endswith('.nanorc'):
182                     path = os.path.join(syntax_dir, filename)
183                     rel_path = os.path.relpath(path, home)
184                     include_path = make_posix_path(os.path.join('~', rel_path))
185                     f.write('include {}\n'.format(include_path))
186
187
188 def install_sqlite(install_directory):
189     """Download and install the SQLite shell"""
190     zip_install(
191         url='https://sqlite.org/2014/sqlite-shell-win32-x86-3080403.zip',
192         sha1='1a8ab0ca9f4c51afeffeb49bd301e1d7f64741bb',
193         install_directory=install_directory)
194
195
196 def create_nosetests_entry_point(python_scripts_directory):
197     """Creates a terminal-based nosetests entry point for msysGit"""
198     contents = '\n'.join([
199             '#!/usr/bin/env/ python',
200             'import sys',
201             'import nose',
202             "if __name__ == '__main__':",
203             '    sys.exit(nose.core.main())',
204             '',
205             ])
206     if not os.path.isdir(python_scripts_directory):
207         os.makedirs(python_scripts_directory)
208     path = os.path.join(python_scripts_directory, 'nosetests')
209     LOG.info('create nosetests entrypoint {}'.format(path))
210     with open(path, 'w') as f:
211         f.write(contents)
212
213
214 def get_r_bin_directory():
215     """Locate the R bin directory (if R is installed)
216     """
217     version_re = re.compile('^R-(\d+)[.](\d+)[.](\d+)$')
218     paths = {}
219     for pf in [
220             os.environ.get('ProgramW6432', r'c:\Program Files'),
221             os.environ.get('ProgramFiles', r'c:\Program Files'),
222             os.environ.get('ProgramFiles(x86)', r'c:\Program Files(x86)'),
223             ]:
224         bin_glob = os.path.join(pf, 'R', 'R-[0-9]*.[0-9]*.[0-9]*', 'bin')
225         for path in glob.glob(bin_glob):
226             version_dir = os.path.basename(os.path.dirname(path))
227             version_match = version_re.match(version_dir)
228             if version_match and version_match.groups() not in paths:
229                 paths[version_match.groups()] = path
230     if not paths:
231         LOG.info('no R installation found under {}'.format(pf))
232         return
233     LOG.debug('detected R installs:\n* {}'.format('\n* '.join([
234         v for k,v in sorted(paths.items())])))
235     version = sorted(paths.keys())[-1]
236     LOG.info('using R v{} bin directory at {}'.format(
237         '.'.join(version), paths[version]))
238     return paths[version]
239
240
241 def update_bash_profile(extra_paths=()):
242     """Create or append to a .bash_profile for Software Carpentry
243
244     Adds nano to the path, sets the default editor to nano, and adds
245     additional paths for other executables.
246     """
247     lines = [
248         '',
249         '# Add paths for Software-Carpentry-installed scripts and executables',
250         'export PATH=\"$PATH:{}\"'.format(':'.join(
251             make_posix_path(path) for path in extra_paths),),
252         '',
253         '# Make nano the default editor',
254         'export EDITOR=nano',
255         '',
256         ]
257     config_path = os.path.join(os.path.expanduser('~'), '.bash_profile')
258     LOG.info('update bash profile at {}'.format(config_path))
259     LOG.debug('extra paths:\n* {}'.format('\n* '.join(extra_paths)))
260     with open(config_path, 'a') as f:
261         f.write('\n'.join(lines))
262
263
264 def make_posix_path(windows_path):
265     """Convert a Windows path to a posix path"""
266     for regex, sub in [
267             (re.compile(r'\\'), '/'),
268             (re.compile('^[Cc]:'), '/c'),
269             ]:
270         windows_path = regex.sub(sub, windows_path)
271     return windows_path
272
273
274 def main():
275     swc_dir = os.path.join(os.path.expanduser('~'), '.swc')
276     bin_dir = os.path.join(swc_dir, 'bin')
277     nano_dir = os.path.join(swc_dir, 'lib', 'nano')
278     nanorc_dir = os.path.join(swc_dir, 'share', 'nanorc')
279     sqlite_dir = os.path.join(swc_dir, 'lib', 'sqlite')
280     create_nosetests_entry_point(python_scripts_directory=bin_dir)
281     install_msysgit_binary(
282         name='make.exe', sha1='ad11047985c33ff57074f8e09d347fe122e047a4',
283         install_directory=bin_dir)
284     install_nano(install_directory=nano_dir)
285     install_nanorc(install_directory=nanorc_dir)
286     install_sqlite(install_directory=sqlite_dir)
287     paths = [nano_dir, sqlite_dir, bin_dir]
288     r_dir = get_r_bin_directory()
289     if r_dir:
290         paths.append(r_dir)
291     update_bash_profile(extra_paths=paths)
292
293
294 if __name__ == '__main__':
295     import argparse
296
297     parser = argparse.ArgumentParser(
298         description=__doc__,
299         formatter_class=argparse.RawDescriptionHelpFormatter)
300     parser.add_argument(
301         '-v', '--verbose',
302         choices=['critical', 'error', 'warning', 'info', 'debug'],
303         help='Verbosity (defaults to {!r})'.format(
304             logging.getLevelName(LOG.level).lower()))
305     parser.add_argument(
306         '--version', action='version',
307         version='%(prog)s {}'.format(__version__))
308
309     args = parser.parse_args()
310
311     if args.verbose:
312         level = getattr(logging, args.verbose.upper())
313         LOG.setLevel(level)
314
315     LOG.info('Preparing your Software Carpentry awesomeness!')
316     LOG.info('installer version {}'.format(__version__))
317     main()
318     LOG.info('Installation complete.')