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