|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | +"""Just a little script to start a new project from the skeleton in |
| 4 | +this repository.""" |
| 5 | + |
| 6 | +from __future__ import (absolute_import, division, print_function, |
| 7 | + with_statement, unicode_literals) |
| 8 | + |
| 9 | +__author__ = "Stephan Sokolow (deitarion/SSokolow)" |
| 10 | +__appname__ = "Simple Project Template Applicator" |
| 11 | +__version__ = "0.1" |
| 12 | +__license__ = "MIT or Apache 2.0" |
| 13 | + |
| 14 | +import logging, os, re, shutil, subprocess, sys, time |
| 15 | +log = logging.getLogger(__name__) |
| 16 | + |
| 17 | +try: |
| 18 | + import pwd |
| 19 | +except ImportError: |
| 20 | + pwd = None |
| 21 | + |
| 22 | +def ensure_terminal(): |
| 23 | + """Re-exec self in the user's preferred terminal if stdin is not a tty.""" |
| 24 | + if not os.isatty(sys.stdin.fileno()): |
| 25 | + os.execvp('./xdg-terminal', ['./xdg-terminal'] + sys.argv) |
| 26 | + |
| 27 | +def get_author(): |
| 28 | + """Make a best effort to retrieve the current user's name and e-mail |
| 29 | + and combine them into a `user <email>` string. |
| 30 | + """ |
| 31 | + # TODO: Query cargo configuration too |
| 32 | + |
| 33 | + # Query git name and e-mail info |
| 34 | + gc_get = ['git', 'config', '--get'] |
| 35 | + try: |
| 36 | + user = subprocess.check_output(gc_get + ['user.name']).strip() |
| 37 | + email = subprocess.check_output(gc_get + ['user.email']).strip() |
| 38 | + |
| 39 | + # TODO: Make this encoding configurable? |
| 40 | + user, email = user.decode('utf8'), email.decode('utf8') |
| 41 | + except (OSError, subprocess.CalledProcessError, UnicodeDecodeError): |
| 42 | + user, email = None, None |
| 43 | + |
| 44 | + # If on a UNIXy system, fall back to the "Real Name" field in the account |
| 45 | + if pwd and not user: |
| 46 | + # Query the GECOS field as a fallback |
| 47 | + try: |
| 48 | + user = pwd.getpwuid(os.getuid()).pw_gecos.split(',')[0].strip() |
| 49 | + except KeyError: |
| 50 | + pass |
| 51 | + |
| 52 | + # Finally, fall back to the USER and EMAIL environment variables |
| 53 | + if not user: |
| 54 | + user = os.environ.get('USER', 'unknown') |
| 55 | + if not email: |
| 56 | + email = os.environ.get('EMAIL', None) |
| 57 | + |
| 58 | + # And now combine whatever we found |
| 59 | + author = [] |
| 60 | + if user: |
| 61 | + author.append(user) |
| 62 | + if email: |
| 63 | + author.append('<{}>'.format(email)) |
| 64 | + return ' '.join(author) |
| 65 | + |
| 66 | +def parse_ignores(path, base): |
| 67 | + """Load a .gitignore-style file as a list of paths. |
| 68 | +
|
| 69 | + TODO: Support generalized globs |
| 70 | + """ |
| 71 | + if not os.path.isfile(path): |
| 72 | + return [] |
| 73 | + |
| 74 | + base = os.path.realpath(base) |
| 75 | + results = [] |
| 76 | + with open(path) as fobj: |
| 77 | + for line in fobj: |
| 78 | + line = line.strip() |
| 79 | + |
| 80 | + # Skip blank lines and comments |
| 81 | + if not line or line.startswith('#'): |
| 82 | + continue |
| 83 | + |
| 84 | + # Force paths to be relative to the root of the repo |
| 85 | + line = line.lstrip(os.sep) |
| 86 | + if os.altsep: |
| 87 | + line = line.lstrip(os.altsep) |
| 88 | + |
| 89 | + # If the path is within the repo, add it |
| 90 | + line = os.path.realpath(os.path.join(base, line)) |
| 91 | + if line.startswith(base): |
| 92 | + results.append(line) |
| 93 | + |
| 94 | + return results |
| 95 | + |
| 96 | +def reset_git_history(repo_dir): |
| 97 | + """Delete .git if present, re-initialize, & create a new initial commit""" |
| 98 | + shutil.rmtree(os.path.join(repo_dir, '.git')) |
| 99 | + subprocess.check_call(['git', 'init', '-q'], cwd=repo_dir) |
| 100 | + subprocess.check_call(['git', 'add', '.'], cwd=repo_dir) |
| 101 | + subprocess.check_call(['git', 'commit', '-qm', |
| 102 | + 'Created new project from template'], cwd=repo_dir) |
| 103 | + |
| 104 | +def rmpath(path): |
| 105 | + """Wrapper for os.remove or shutil.rmtree as appropriate""" |
| 106 | + if os.path.isfile(path): |
| 107 | + return os.remove(path) |
| 108 | + elif os.path.isdir(path): |
| 109 | + return shutil.rmtree(path) |
| 110 | + |
| 111 | +def template_file(path, template_vars): |
| 112 | + """Ultra-primitive Django/Jinja/Twig/Liquid-style template applicator""" |
| 113 | + def timestamp_match(match_obj): |
| 114 | + """Callback for timestamp pattern matches""" |
| 115 | + return time.strftime(match_obj.group(1)) |
| 116 | + |
| 117 | + def match(match_obj): |
| 118 | + """Callback for template placeholder matches""" |
| 119 | + keyword = match_obj.group(1) |
| 120 | + date_pat = r'"now"\s*\|\s*date:\s*(?:"(.*?)"|\'(.*?)\')' |
| 121 | + if re.match(date_pat, keyword): |
| 122 | + return re.sub(date_pat, timestamp_match, keyword) |
| 123 | + |
| 124 | + # No fallback. We want to NOTICE if templating fails |
| 125 | + try: |
| 126 | + return template_vars[keyword] |
| 127 | + except KeyError: |
| 128 | + log.critical("No such template variable: %r", match_obj.group(1)) |
| 129 | + raise |
| 130 | + |
| 131 | + with open(path) as fobj: |
| 132 | + templated = re.sub(r'{{\s*(.*?)\s*}}', match, fobj.read()) |
| 133 | + with open(path, 'w') as fobj: |
| 134 | + fobj.write(templated) |
| 135 | + |
| 136 | +def new_project(dest_dir): |
| 137 | + """Apply the template to create a new project in the given folder""" |
| 138 | + src_dir, self_name = os.path.split(__file__) |
| 139 | + |
| 140 | + # Avoid corrupting source copy |
| 141 | + if os.path.realpath(src_dir) == os.path.realpath(dest_dir): |
| 142 | + raise ValueError("Template directory and new project directory cannot" |
| 143 | + "be the same location") |
| 144 | + |
| 145 | + # Ensure both paths are absolute and refuse to corrupt existing dest_dir |
| 146 | + src_dir = os.path.abspath(src_dir) |
| 147 | + dest_dir = os.path.abspath(dest_dir) |
| 148 | + if os.path.exists(dest_dir): |
| 149 | + raise ValueError("Path already exists: {}".format(dest_dir)) |
| 150 | + |
| 151 | + # -- Clone the template repo -- |
| 152 | + subprocess.check_call(['git', 'clone', '-q', '--', src_dir, dest_dir]) |
| 153 | + |
| 154 | + # -- Remove blacklisted files -- |
| 155 | + # (Use the committed copy of .genignore for consistenct) |
| 156 | + genignore = os.path.join(dest_dir, '.genignore') |
| 157 | + remove = [os.path.join(dest_dir, self_name), genignore] |
| 158 | + remove += parse_ignores(genignore, dest_dir) |
| 159 | + |
| 160 | + os.chdir(dest_dir) # Safety check |
| 161 | + for path in remove: |
| 162 | + rmpath(path) |
| 163 | + |
| 164 | + project_name = os.path.basename(dest_dir) |
| 165 | + tmpl_vars = { |
| 166 | + 'authors': get_author(), |
| 167 | + 'project-name': project_name.replace('_', '-'), |
| 168 | + 'crate_name': project_name.replace('-', '_'), |
| 169 | + } |
| 170 | + for parent, _, files in os.walk(dest_dir): |
| 171 | + for fname in files: |
| 172 | + # Skip non-Rust, non-TOML files |
| 173 | + # TODO: Make this configurable |
| 174 | + if not os.path.splitext(fname)[1].lower() in ['.toml', '.rs']: |
| 175 | + continue |
| 176 | + |
| 177 | + template_file(os.path.join(parent, fname), tmpl_vars) |
| 178 | + |
| 179 | + # -- Reset to a fresh git history and consider it done -- |
| 180 | + reset_git_history(dest_dir) |
| 181 | + log.info("Created new project at %s", dest_dir) |
| 182 | + |
| 183 | +def main(): |
| 184 | + """The main entry point, compatible with setuptools entry points.""" |
| 185 | + from argparse import ArgumentParser, RawDescriptionHelpFormatter |
| 186 | + parser = ArgumentParser(formatter_class=RawDescriptionHelpFormatter, |
| 187 | + description=__doc__.replace('\r\n', '\n').split('\n--snip--\n')[0]) |
| 188 | + parser.add_argument('--version', action='version', |
| 189 | + version="%%(prog)s v%s" % __version__) |
| 190 | + parser.add_argument('-v', '--verbose', action="count", |
| 191 | + default=2, help="Increase the verbosity. Use twice for extra effect.") |
| 192 | + parser.add_argument('-q', '--quiet', action="count", |
| 193 | + default=0, help="Decrease the verbosity. Use twice for extra effect.") |
| 194 | + parser.add_argument('destdir', default=None, nargs='*', |
| 195 | + help="The path for the new project directory") |
| 196 | + # Reminder: %(default)s can be used in help strings. |
| 197 | + |
| 198 | + args = parser.parse_args() |
| 199 | + |
| 200 | + # Set up clean logging to stderr |
| 201 | + log_levels = [logging.CRITICAL, logging.ERROR, logging.WARNING, |
| 202 | + logging.INFO, logging.DEBUG] |
| 203 | + args.verbose = min(args.verbose - args.quiet, len(log_levels) - 1) |
| 204 | + args.verbose = max(args.verbose, 0) |
| 205 | + logging.basicConfig(level=log_levels[args.verbose], |
| 206 | + format='%(levelname)s: %(message)s') |
| 207 | + |
| 208 | + while not args.destdir: |
| 209 | + ensure_terminal() |
| 210 | + destdir = input("Path for new project: ") |
| 211 | + if destdir: |
| 212 | + args.destdir.append(destdir) |
| 213 | + |
| 214 | + # TODO: Support the project name being relative to a config-file-specified |
| 215 | + # parent directory. |
| 216 | + |
| 217 | + for path in args.destdir: |
| 218 | + new_project(path) |
| 219 | + |
| 220 | + # TODO: Modulo a config file, open the preferred editing environment |
| 221 | + |
| 222 | +if __name__ == '__main__': |
| 223 | + main() |
| 224 | + |
| 225 | +# vim: set sw=4 sts=4 expandtab : |
0 commit comments