#/bin/python3 import os import subprocess import sys import re import io import fileinput import time import signal from config.config import * from utils import utils from generators import generators def generate(args): """Wrapper for generate_blog(fresh=False).""" # pylint: disable=unused-argument exit(generators.generate_blog(fresh=False)) def regenerate(args): """Wrapper for generate_blog(fresh=True).""" # pylint: disable=unused-argument exit(generators.generate_blog(fresh=True)) def edit_post_with_editor(path): """Launch text editor to edit post at a given path. Text editor is $VISUAL, then if empty, $EDITOR, then if still empty, vi. """ if "VISUAL" in os.environ: editor = os.environ["VISUAL"] elif "EDITOR" in os.environ: editor = os.environ["EDITOR"] else: editor = "vi" subprocess.call([editor, path]) def new_post(title): """Create a new post with metadata pre-filled. The path to the new post is printed to stdout. Returns ------- 0 On success. """ date = utils.current_datetime() filename_date = date.strftime("%Y-%m-%d") iso_date = date.isoformat() display_date = "%s %d, %d" % (date.strftime("%B"), date.day, date.year) title_sanitized = utils.sanitize(title) filename = "%s-%s.md" % (filename_date, title_sanitized) fullpath = os.path.join(POSTSDIR, filename) if not os.path.isdir(POSTSDIR): if os.path.exists(POSTSDIR): os.remove(POSTSDIR) os.mkdir(POSTSDIR, mode=0o755) if os.path.exists(fullpath): sys.stderr.write("%serror: '%s' already exists, please pick a different title%s\n" % (RED, fullpath, RESET)) return 1 with open(fullpath, 'w', encoding='utf-8') as newpost: newpost.write("---\n") newpost.write('title: "%s"\n' % title) newpost.write("date: %s\n" % iso_date) newpost.write("date_display: %s\n" % display_date) newpost.write("---\n\n") sys.stderr.write("New post created in:\n") print(fullpath) edit_post_with_editor(fullpath) return 0 def new_post_cli(args): """CLI wrapper around new_post.""" new_post(args.title) def touch(filename): """Update the timestamp of a post to the current time.""" filename = os.path.basename(filename) fullpath = os.path.join(POSTSDIR, filename) if not os.path.exists(fullpath): sys.stderr.write("%serror: post %s not found %s\n" % (RED, fullpath, RESET)) return 1 filename_prefix_re = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}") if not filename_prefix_re.match(filename): sys.stderr.write(RED) sys.stderr.write("error: post %s is not a valid post\n" % filename) sys.stderr.write("error: the filename of a valid post begins with " "a date in the form xxxx-xx-xx\n") sys.stderr.write(RESET) return 1 # update timestamp in the metadata section of the post whatchanged = io.StringIO() date = utils.current_datetime() iso_date = date.isoformat() display_date = "%s %d, %d" % (date.strftime("%B"), date.day, date.year) filename_date = date.strftime("%Y-%m-%d") with fileinput.input(files=(fullpath), inplace=True) as lines: meta_fences = 0 for line in lines: if line.startswith("---"): meta_fences += 1 sys.stdout.write(line) continue if meta_fences >= 2: # already went past the metadata section sys.stdout.write(line) continue if line.startswith("date: "): updated_line = "date: %s\n" % iso_date sys.stdout.write(updated_line) whatchanged.write("-%s+%s\n" % (line, updated_line)) continue if line.startswith("date_display: "): updated_line = "date_display: %s\n" % display_date sys.stdout.write(updated_line) whatchanged.write("-%s+%s\n" % (line, updated_line)) continue sys.stdout.write(line) sys.stderr.write("\n%schangeset:%s\n\n%s" % (YELLOW, RESET, whatchanged.getvalue())) whatchanged.close() # check if the file needs to be renamed new_filename = filename_prefix_re.sub(filename_date, filename) if new_filename != filename: new_fullpath = os.path.join(POSTSDIR, new_filename) os.rename(fullpath, new_fullpath) sys.stderr.write("%srenamed to %s%s\n" % (YELLOW, new_filename, RESET)) return 0 def touch_cli(args): """CLI wrapper around touch.""" touch(args.filename) def deploy(args): """Deploys build directory to origin/master without regenerating. Returns ------- 0 On success. Exit early with nonzero status otherwise. """ # pylint: disable=unused-argument,too-many-statements # check whether root is dirty os.chdir(ROOTDIR) dirty = subprocess.check_output(["git", "status", "--porcelain"]) if dirty: sys.stderr.write(YELLOW) sys.stderr.write("Project root is dirty.\n") sys.stderr.write("You may want to commit in your changes " "to the source branch, since the SHA and title " "of the latest commit on the source branch will be " "incorporated into the commit message on " "the deployment branch. Type s[hell] on the " "next prompt to open an interactive shell.\n") sys.stderr.write(RESET) while True: sys.stderr.write("Continue? [yNs] ") answer = input() if not answer: # default abort = True break elif answer.startswith(('y', 'Y')): abort = False break elif answer.startswith(('n', 'N')): abort = True break elif answer.startswith(('s', 'S')): shell = (os.environ['SHELL'] if 'SHELL' in os.environ and os.environ['SHELL'] else 'zsh') subprocess.call(shell) stilldirty = subprocess.check_output(["git", "status", "--porcelain"]) if stilldirty: sys.stderr.write(YELLOW) sys.stderr.write("Project root is still dirty.\n") sys.stderr.write(RESET) else: sys.stderr.write("Please answer yes or no.\n") if abort: sys.stderr.write("%saborting deployment%s\n" % (RED, RESET)) return 1 # extract latest commit on the source branch source_commit = subprocess.check_output( ["git", "log", "-1", "--pretty=oneline", "source", "--"]).decode('utf-8').strip() # cd into BUILDDIR and assemble commit message sys.stderr.write("%scommand: cd '%s'%s\n" % (BLUE, BUILDDIR, RESET)) os.chdir(BUILDDIR) # extract updated time from atom.xml if not os.path.exists("atom.xml"): sys.stderr.write("atom.xml not found, cannot deploy\naborting\n") return 1 atomxml = ET.parse("atom.xml").getroot() updated = atomxml.find('{http://www.w3.org/2005/Atom}updated').text commit_message = ("Site updated at %s\n\nsource branch was at:\n%s\n" % (updated, source_commit)) # commit changes in BUILDDIR sys.stderr.write("%scommand: git add --all%s\n" % (BLUE, RESET)) subprocess.check_call(["git", "add", "--all"]) sys.stderr.write("%scommand: git commit --no-verify --gpg-sign --message='%s'%s\n" % (BLUE, commit_message, RESET)) try: subprocess.check_call(["git", "commit", "--gpg-sign", "--message=%s" % commit_message]) except subprocess.CalledProcessError: sys.stderr.write("\n%serror: git commit failed%s\n" % (RED, RESET)) return 1 # check dirty status dirty = subprocess.check_output(["git", "status", "--porcelain"]) if dirty: sys.stderr.write(RED) sys.stderr.write("error: failed to commit all changes; " "build directory still dirty\n") sys.stderr.write("error: please manually inspect what was left out\n") sys.stderr.write(RESET) return 1 # push to origin/master sys.stderr.write("%scommand: git push origin master%s\n" % (BLUE, RESET)) try: subprocess.check_call(["git", "push", "origin", "master"]) except subprocess.CalledProcessError: sys.stderr.write("\n%serror: git push failed%s\n" % (RED, RESET)) return 1 return 0 def gen_deploy(args): """Regenerate and deploy.""" # pylint: disable=unused-argument,too-many-branches # try to smartly determine the latest post, and prompt to touch it current_time = time.time() latest_post = None latest_postdate = 0 latest_mtime = 0 for name in os.listdir(POSTSDIR): matchobj = re.match(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})-.*\.md", name) if not matchobj: continue fullpath = os.path.join(POSTSDIR, name) mtime = os.path.getmtime(fullpath) # get post date from the date metadata field of the post postdate = 0 with open(fullpath) as postobj: for line in postobj: dateregex = r"^date: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}-\d{2}:?\d{2})" datematch = re.match(dateregex, line.rstrip()) if datematch: postdate = dateutil.parser.parse(datematch.group(1)).timestamp() break # skip the post if it is dated more than three days ago if current_time - postdate > 3 * 24 * 3600: continue if mtime > latest_mtime: latest_post = name latest_postdate = postdate latest_mtime = mtime # prompt for touching if the latest post determined above was # modified within the last hour but the date registered in the post # isn't within the last ten minutes if ((latest_post is not None and current_time - latest_mtime < 3600 and current_time - latest_postdate > 600)): sys.stderr.write("%sIt appears that %s might be a new post.\n" "Do you want to touch its timestamp?%s\n" % (GREEN, latest_post, RESET)) while True: yesnoquit = input("[ynq]: ") if yesnoquit.startswith(("Y", "y")): yesno = True break elif yesnoquit.startswith(("N", "n")): yesno = False break elif yesnoquit.startswith(("Q", "q")): sys.stderr.write("%saborting gen_deploy%s\n" % (RED, RESET)) return 1 else: sys.stderr.write("Please answer yes, no, or quit.\n") if yesno: sys.stderr.write("%stouching %s%s\n" % (BLUE, latest_post, RESET)) touch(latest_post) sys.stderr.write("\n") generators.generate_blog(fresh=True) deploy(None) def preview(args): """Serve the blog and auto regenerate upon changes.""" # pylint: disable=unused-argument server_process = utils.HTTPServerProcess(BUILDDIR) server_process.start() sys.stderr.write("watching for changes\n") sys.stderr.write("send SIGINT to stop\n") # install a SIGINT handler only for this process sigint_raised = False def sigint_mitigator(signum, frame): """Translate SIGINT to setting the sigint_raised flag.""" nonlocal sigint_raised sigint_raised = True signal.signal(signal.SIGINT, sigint_mitigator) # Watch and auto-regen. # No need to actually implement watch separately, since # generate_blog(fresh=False, report_total_errors=False) already # watches for modifications and only regens upon changes, and it is # completely silent when there's no change. while not sigint_raised: generators.generate_blog(fresh=False, report_total_errors=False) time.sleep(0.5) sys.stderr.write("\nSIGINT received, cleaning up...\n") server_process.join() return 0