Browser and install GUI for cookiecutter templates

⌈⌋ ⎇ branch:  cookiedough


Artifact [7a996d6a55]

Artifact 7a996d6a55d2272c0a64595397be094d3a21a71fc3cee390b5fe07b9dedb5e4f:

  • File cookiedough/rollout.py — part of check-in [3ad729af6e] at 2021-03-26 14:01:37 on branch trunk — Fix typo, again. (user: mario size: 18391)

# encoding: utf-8
# api: cookiedough
# type: function
# title: roll out
# description: deploy selected cookiecutter template
# category: action
# version: 0.6
# config:
#     { name: use_defaults, type: bool, value: 1, description: Use defaults from cookiecutter config, help: Apply default_context from ~/.config/cookiecutter/config over template vars, and before replay data. }
#     { name: replay, type: bool, value: 1, description: Use replay/ variables as defaults, help: Overrides input variables with previous inputs for the same template. }
#     { name: update_ccjson, type: bool, value: 1, description: Update parameters from cookiecutter.json files, help: avoids extra prompts if template infos outdated }
#     { name: hook_prompt, type: bool, value: 1, description: Display any additional prompts as GUI inputs, help: hook prompt.* functions }
#     { name: no_params, type: bool, value: 0, description: Don't prompt for template vars. Use terminal prompts instead., help: You might as well use cookiecutter directly then. }
#     { name: verbose, type: bool, value: 0, description: cookiecutter --verbose for more details on extraction, help: Will print any output to console }
# priority: core
# depends: python:cookiecutter
#
# Implements the parameter input window before invoking cookiecutter(1)
# for extracting a template. Also hooks cookiecutter.prompt.* functions,
# to avoid any extra terminal prompts.
#
# Additionally updates config[] dict from remote cookiecutter.json defaults
# and previous ~/.config/cookiecutter/replay/*.json input.
#
# Also contains the patch logic to modify cookiecutter to use ~/.config
# per default.
#


import sys, os, re, json, copy, time
import PySimpleGUI as sg
import requests, appdirs
from cookiedough import icons
from traceback import format_exc
import textwrap


# joined to main.conf
conf = {
    "use_defaults": False,
    "replay": True,
    "update_ccjson": True,
    "hook_prompt": True,
    "no_params": False,
    "verbose": False,
}


# finally, this is where cookiecutter gets invoked,
# params={} from the input window
# doc: https://cookiecutter.readthedocs.io/en/1.7.2/advanced/calling_from_python.html
def cutting(repo_url, params):
    import cookiecutter.main
    # params
    ccc = CookieCutterConfig()
    if m := re.match("^(.+)\?(?:d|dir|directory)=(.+)$", repo_url):
        repo_url, directory = m.groups() # from http://repo.git/?dir=template2/
    else:
        directory = None
    if params:
        no_input = True
    else:
        no_input = False # from conf[no_params], set by task.__init__
    # inject verbose flag (alternatively: use cookiecutter.cli instead of .main)
    if conf["verbose"]:
        import cookiecutter.log
        cookiecutter.log.configure_logger(stream_level='DEBUG', debug_file=None)
    # run
    dir = cookiecutter.main.cookiecutter(
        template=repo_url,
        #checkout=None,
        no_input=no_input,
        extra_context=params,
        #replay=None,
        #overwrite_if_exists=False,
        #output_dir='.',
        config_file=ccc.fn,
        default_config=ccc.default_config(),
        #password=None,
        directory=directory,
        #skip_if_file_exists=False,
        #accept_hooks=True,
    )
    return dir


# override cookiecutter.prompt.* functions - rather than having click.prompt() CLI inputs
def hijack_prompt():
    
    def yes_no(name, default):
        """ return click.prompt(question, default=default_value, type=click.BOOL) """
        return sg.popup_yes_no(name)

    def variable(name, default):
        """ return click.prompt(var_name, default=default_value) """
        w = sg.Window(f"Prompt for: `{name}`", layout=[
           [sg.T(name)],
           [sg.Input(default, key="_")],
           [sg.B("OK", key="ok")]
        ])
        event, data = w.read()
        w.close()
        return data.get("_")

    def choice(name, options):
        """ click.prompt( prompt, type=click.Choice(choices), default=default, show_choices=False ) """
        w = sg.Window(f"Prompt for: `{name}`", layout=[
           [sg.T(name)],
           [sg.Combo(options, options[0], key="_")],
           [sg.B("OK", key="ok")]
        ])
        event, data = w.read()
        w.close()
        return data.get("_", options[0])

    import cookiecutter.prompt
    cookiecutter.prompt.read_user_yes_no = yes_no
    cookiecutter.prompt.read_user_variable = variable
    cookiecutter.prompt.read_user_choice = choice
    # only run once
    hijack_prompt = lambda *a: ...


# fetch remote cookiecutter.json, and update config[] list
def update_ccjson(config, url):
    try:
        ccjson = requests.get(url, headers={"User-Agent": "cookiedough/0.1.x (Python; amd64; requests)"}).json()
    except:
        return sg.popup(format_exc())

    have = [d["name"] for d in config]
    for k,v in ccjson.items():
        if not re.match("^\w+", k):
            continue
        elif k in have:
            pass
        else:
            config.append({
                "name": k,
                "value": v,
                "type": "str" if isinstance(v, str) else "select",
                "description": "(newly added parameter)"
            })

# scan local ~/.config/cookiecutter/replay/* dir, get override values from there
def update_replay(d, replay=None):
    if not replay:
        replay = {}
        try:
            m = re.search("(/[\w\-]+)(\.git|\.zip)?(\?.+$|$)", d["repo"])
            fn = CookieCutterConfig().replay + m[1] + ".json"
            with open(fn, "r", encoding="utf-8") as f:
                replay = json.load(f)["cookiecutter"]
            # we could check if _template matches up with d[repo] at this point (likely couldn't account for ?directory=)
        except:
            return
    for e in d["config"]:
        if e["type"] not in ("str", "int", "select", ):
            continue # `dict` type should not be updated, `select` is okay, since default value: was split out
        if e["name"] in replay.keys():
            e["value"] = replay[e["name"]]

# read from `~/.config/cookiecutter/config` default_context: vars
def update_defaults(d):
    cfg = CookieCutterConfig().read_config()["default_context"]
    update_replay(d, cfg)  # just reuse _replay with an injected value dict


def wrap(text):
    return "\n".join(textwrap.wrap(text, width=60))

#-- parameter window
class task():

    def __init__(self, d, main):
        self.d = d  # template meta data
        self.main = main
        if conf["hook_prompt"]:
            hijack_prompt()
        if conf["update_ccjson"]:
            update_ccjson(self.d["config"], self.d["cookiecutterjson_url"])
        if conf["no_params"]:
            cutting(self.d["repo"], {})
        else:
            if conf["use_defaults"]:
                update_defaults(self.d)
            if conf["replay"]:
                update_replay(self.d, None)
            self.w = self.create_win()
            main.win_register(self.w, self.event)

    # pre-invocation window with input fields
    def create_win(self):
        return sg.Window(title=f"Parameters for {self.d['name']}", size=(500,700), margins=(0,0), resizable=0, icon=icons.flame, layout=[
            [sg.Column(size=(500,65), background_color="#343131", pad=(0,0), layout=[
                [sg.T(self.d["name"], font="Sans 16 bold", size=(60,1), pad=(20,15), background_color="#343131", text_color="#f3f3f3")]
            ])],
            [sg.Column(size=(500,560), background_color="#2980b9", pad=(0,0), scrollable=True, vertical_scroll_only=True, layout=[
                [w] for w in self.fields(self.d["config"])
            ])],
            [sg.Column(size=(500,75), pad=(20,10), layout=[
                [sg.B("           Bakin'  \n           time!  ", image_data=icons.bakin, key="#bake", size=(40,2)), sg.Button("Target dir", key="#chdir"), sg.B("Cancel", key="#cancel")],
            ])],
        ])

    # convert config[] list to input widgets
    def fields(self, cfg):
        bg = { "background_color": "#2980b9" }
        pad = { "background_color": "#c6d7e3", "pad": ((20,2),(0)) }
        ls = []
        for e in cfg:
            ls.append(sg.Text(e["name"], font="Sans 12 bold", pad=((10,13),(10,2)), **bg))
            _disabled = e["class"] != "cookiecutter"
            if e["type"] == "select":
                values = e.get("select") or e.get("value") or [""]
                ls.append(sg.Combo(values, default_value=e["value"], key=e["name"], font="Roboto 12", text_color="black", disabled=_disabled, **pad))
            elif e["type"] == "dict":
                # display only, not editable, and cookiecutter will source it from the cc.json
                ls.append(sg.Multiline(json.dumps(e["value"], indent=2), size=(50,3), disabled=True, text_color="black", **pad))
            else:
                ls.append(sg.Input(e["value"], key=e["name"], font="Roboto 11", text_color="black", disabled=_disabled, **pad))
            if e.get("description"):
                ls.append(sg.Text(wrap(e["description"]), font="Sans 11", text_color="#111", pad=(20,1), **bg))
        ls.append(sg.T("", **bg))
        return ls

    # window actions
    def event(self, event, data):
        if event == "#cancel":
            self.w.close()
        elif event == "#chdir":
            self.main.working_directory(...)
        elif event == "#bake":
            self.w.close()
            self.bake(data)

    # invoke cookiecutter from data{} widget values
    def bake(self, data):
        params = {
           # assemble all but _control and __private vars (cc will apply those itself)
           k:v  for  k,v  in data.items()  if  re.match("^[a-z]+", k, re.I)
        }
        dir = cutting(self.d["repo"], params)
        if not conf["verbose"]:
            print("cookiecutting done.")
        self.open_target(dir)
        self.main.status(f"{dir} created")

    # open extracted dir
    def open_target(self, dir):
        if os.path.exists(dir):
            os.system("xdg-open %r &" % dir)


class CookieCutterConfig():
    """ fix xdg support for cookiecutter """
    
    def __init__(self):
        self.dir = appdirs.user_config_dir("cookiecutter", "cookiecutter")
        self.fn = self.dir + "/config"
        self.replay = self.dir + "/replay"
        self.cache = appdirs.user_cache_dir("cookiecutters", "cookiecutters")
    
    def default_config(self):
        """ supply overrides per cookiecutter.main(default_config=…) """
        return {
            "default_context": {
                #"full_name": "Susan Exemplary",
                #"email": "sample@example.com",
                #"github_username": "samp54",
            },
            "cookiecutters_dir": self.cache,
            "replay_dir": self.replay,
        }

    def read_config(self):
        """ read actual config file """
        current = {}
        try:
            import yaml
            with open(self.fn, "r", encoding="utf-8") as f:
                current = yaml.load(f, Loader=yaml.FullLoader)
        except:
            current = self.default_config()
        if not "default_context" in current:
            current["default_context"] = {}
        return current

    def patch(self):
        """ fix cookiecutter/config.py in-place """
        import cookiecutter.config  # alternatively: `locate "site-packages/cookiecutter/config.py"`
        target_dir = re.sub("[\w.]+$", "", cookiecutter.config.__file__)
        patch_file = re.sub("[\w.]+$", "config.patch", __file__)
        os.system(f"patch -b -d {target_dir} < {patch_file}")

    def move(self):
        """ shuffle old locations to new """
        self.create(self.dir)
        from_to = {
            "~/.cookiecutterrc": self.fn,
            "~/.cookiecutters/": self.cache,
            "~/.cookiecutter_replay/": self.replay,
        }
        for orig, new in from_to.items():
            orig = os.path.expanduser(orig)
            if os.path.exists(orig) and not os.path.exists(new):
                os.rename(orig, new)
                self.nobackup(new)

    def create(self, *dirs):
        """ create config+cache dirs, if missing """
        if not dirs:
            dirs = [self.replay, self.cache]
        for dir in dirs:
            if not os.path.exists(dir):
                os.makedirs(dir, 0o755, True)
                self.nobackup(dir)

    def nobackup(self, dir):
        """ touch .nobackup for cache dir """
        if dir.find("/.cache/") > 0:
             open(dir + "/.nobackup", "a").close()
    

    def prompt_defaults(self):
        """ insert common default fields into cc config """
        import yaml, pluginconf.gui
        current = self.read_config()
        # display
        licenses = [
            "MIT", "BSD-2-Clause", "AFL-3.0", "AGPL-3.0-or-later", "Apache-2.0", "Artistic-2.0", "BSD-3-Clause", "CC-BY-4.0",
            "EUPL-1.2", "EPL-2.0", "GPL-2.0-or-later", "GPL-3.0-or-later", "LGPL-2.1-or-later", "LGPL-3.0-or-later", "MPL-2.0",
            "OSL-3.0", "Python-2.0", "QPL-1.0", "Ruby", "TCL", "WTFPL", "Zend-2.0", "ZPL-2.1"]
        yn = { "y":"y", "n":"n" }
        plugins = { "cc": {
            "id": "cc", "title": "cookiecutter defaults", "description": "common prompts/variables in templates",
            "doc": "List of most commonly used variables. Unfortunately there's\na bit of duplication/overlap here, because the cookiecutter\nproject never recommended any standard names.",
            "version": "2021.3", "type": "config", "category": "variables",
            "config": [
                { "name": 'email', "type": "str", "value": "", "description": "Author email address" },
                { "name": 'author_name', "type": "str", "value": "", "description": "Author name" },
                { "name": 'github_username' , "type": "str", "value": "", "description": "GH account name" },
                { "name": 'open_source_license' , "type": "select", "select": {k:k for k in licenses}, "value": "", "description": "Default license name" },
                { "name": 'author_email', "type": "str", "value": "", "description": "Author email address" },
               #{ "name": 'author', "type": "str", "value": "", "description": "Author name, probably full name, no email adr" },
               #{ "name": 'license', "type": "str", "value": "", "description": "Standard license for new projects" },
                { "name": 'year', "type": "str", "value": time.strftime("%Y"), "description": "Current/release year" },
                { "name": 'python_version', "type": "str", "value": "", "description": "Minimum python version for most templates" },
                { "name": 'pypi_username', "type": "str", "value": "", "description": "Upload account for https://pypi.org/" },
               #{ "name": 'github_user', "type": "str", "value": "", "description": "GH account name" },
                { "name": 'timezone', "type": "str", "value": "GMT", "description": "default timezone" },
                { "name": 'use_pypi_deployment_with_travis', "type": "str", "value": "", "description": "" },
                { "name": 'python_interpreter', "type": "str", "value": "", "description": "Ambigious. Often a version number for wheel deps" },
                { "name": 'company', "type": "str", "value": "", "description": "Vendor/company name" },
                { "name": 'company_name', "type": "str", "value": "", "description": "Vendor/company name" },
                { "name": 'debug', "type": "str", "value": "", "description": "Ambiguous: Template or project flag" },
               #{ "name": 'copyright_holder', "type": "str", "value": "", "description": "(c) author name" },
                { "name": 'keywords', "type": "str", "value": "", "description": "Usually for packaging" },
                { "name": 'copyright', "type": "str", "value": "", "description": "(c) copyright notice" },
               #{ "name": 'maintainer', "type": "str", "value": "", "description": "Project author/vendor/maintainer" },
                { "name": 'create_author_file', "type": "select", "select": yn, "value": "", "description": "*Flags*. Beware that many newer templates use 'yes' and 'no' instead" },
                { "name": 'use_pytest', "type": "select", "select": yn, "value": "", "description": "" },
                { "name": 'use_docker', "type": "select", "select": yn, "value": "", "description": "" },
                { "name": 'use_celery', "type": "select", "select": yn, "value": "", "description": "" },
                { "name": 'use_pycharm', "type": "select", "select": yn, "value": "", "description": "" },
                { "name": 'use_travis', "type": "select", "select": yn, "value": "", "description": "" },
               #{ "name": 'git_username' , "type": "str", "value": "", "description": "GH account name" },
               #{ "name": 'project_license', "type": "str", "value": "", "description": "" },
               #{ "name": 'copyright_year', "type": "str", "value": time.strftime("%Y"), "description": "Publication year" },
               #{ "name": 'author_full_name', "type": "str", "value": "", "description": "Complete author name (first last)" },
               #{ "name": 'project_author', "type": "str", "value": "", "description": "Author name (maybe abbrv.)" },
            ]
        }}
        aliases = {
            "author_name": ["author", "copyright_holder", "maintainer", "author_full_name", "project_author"],
            "github_username": ["github_user", "git_username"],
            "open_source_license": ["license", "project_license"],
            "year": ["copyright_year"], 
        }
        save = pluginconf.gui.window(
            current["default_context"], {"cc":1}, files=[], plugins=plugins, opt_label=True,
            title="cookiecutter/config default_context variables", size=(590,770)
        )
        if not save:
            return
        # remove empty fields
        for k,v in copy.copy(current["default_context"]).items():
            if v == "":
                del current["default_context"][k]
        # copy aliases
        for k,als in aliases.items():
            if k in current["default_context"]:
                for a in als:
                    current["default_context"][a] = current["default_context"][k]
        # save
        with open(self.fn, "w", encoding="utf-8") as f:
            yaml.dump(current, f)