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