Skip to content

Commit b88b34b

Browse files
Stephan SokolowStephan Sokolow
Stephan Sokolow
authored and
Stephan Sokolow
committed
Convert apply.sh to Python and start to improve it
- The project path is now optional on the command-line and will trigger an interactive prompt if omitted - If stdin isn't connected to a TTY, the script will re-launch itself in the user's preferred terminal (via a bundled copy of xdg-terminal) so it's suitable for launching via non-terminal means - The ultra-stupid {{var}} template applicator is now smart enough to parse arbitrary strftime strings out of date substitutions. - The lookup for {{authors}} is now smart enough to fall back to the GECOS field (conditional on the platform being Unixy) and then to $USER and $EMAIL if lookup from the git config fails. - There are now safety checks to prevent problems if the destination path is the same as the source path or it already exists - It will now template arbitrary .toml files in addition to Cargo.toml - Properly support both {{project-name}} and {{crate_name}}
1 parent 9b9f781 commit b88b34b

File tree

5 files changed

+730
-37
lines changed

5 files changed

+730
-37
lines changed

.genignore

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
/LICENSE.mit
33
/LICENSE.apache2
44
/test_justfile.py
5+
/xdg-terminal

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ license of choice. You can replace this</td>
7575
</tr>
7676
<tr><th colspan="2">Development Automation</th></tr>
7777
<tr>
78-
<td><code>apply.sh</code></td>
78+
<td><code>apply.py</code></td>
7979
<td>Run this to generate new projects as a workaround for cargo-generate's
8080
incompatibility with justfile syntax</td>
8181
</tr>

apply.py

+225
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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 :

apply.sh

-36
This file was deleted.

0 commit comments

Comments
 (0)