From a48f527ec8f8757f3ec60cc7592007204670f657 Mon Sep 17 00:00:00 2001 From: "Soren I. Bjornstad" Date: Thu, 26 Aug 2021 09:46:24 -0500 Subject: [PATCH] add feature for automatic init of a TZK repository --- .gitignore | 1 + config.py | 41 ++++++++++++-- git.py | 8 +-- tw.py | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++-- tzk.py | 104 +++++++++++++++++++++++++++------- util.py | 7 +++ 6 files changed, 285 insertions(+), 36 deletions(-) create mode 100644 util.py diff --git a/.gitignore b/.gitignore index c18dd8d..38da6d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__/ +.vscode/ diff --git a/config.py b/config.py index 7507d25..7078d07 100644 --- a/config.py +++ b/config.py @@ -1,27 +1,56 @@ +import datetime import importlib import os from pathlib import Path import sys +from typing import Any + +from util import fail class ConfigurationManager: def __init__(self): - config_path = Path.cwd() + self.config_path = Path.cwd() - for child in sorted(config_path.iterdir()): + for child in sorted(self.config_path.iterdir()): if child.is_file() and child.name.endswith('.py'): mod_name = child.name.rsplit('.', 1)[0] if mod_name == 'tzk_config': - sys.path.insert(0, str(config_path)) + sys.path.insert(0, str(self.config_path)) self.conf_mod = importlib.import_module(mod_name) del sys.path[0] break else: - print( + fail( f"Your TZK config file could not be found. " f"Please ensure there is a file called tzk_config.py " - f"in the current directory.", file=sys.stderr) - sys.exit(1) + f"in the current directory.") def __getattr__(self, attr): return getattr(self.conf_mod, attr, None) + + def write_attr(self, attr: str, value: str) -> bool: + """ + Try to add a simple attribute = string value config parameter to the + config file, if it doesn't already exist. More complicated data types + are not supported. + + Return: + False if the attribute already has a value. + True if successful. + + Raises: + File access errors if the config file is inaccessible. + """ + + if hasattr(self.conf_mod, attr): + return False + else: + setattr(self.conf_mod, attr, value) + with open(self.config_path / "tzk_config.py", "a") as f: + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + f.write(f"\n# Added automatically by tzk at {now}\n") + f.write(f'{attr} = "{value}"\n') + return True + +cm = ConfigurationManager() diff --git a/git.py b/git.py index d34c865..b429da3 100644 --- a/git.py +++ b/git.py @@ -1,8 +1,8 @@ import subprocess from typing import Sequence -def exec(*args: Sequence[str]): - return subprocess.call(["git", *args]) +def exec(*args: str): + return subprocess.check_call(["git", *args]) -def read(*args: Sequence[str]): - return subprocess.check_output(["git", *args], text=True).strip() \ No newline at end of file +def read(*args: str): + return subprocess.check_output(["git", *args], text=True).strip() diff --git a/tw.py b/tw.py index fcfe24f..56b04a1 100644 --- a/tw.py +++ b/tw.py @@ -1,12 +1,162 @@ +from contextlib import contextmanager +import functools +import json +import os +from pathlib import Path import subprocess -from typing import Sequence +from textwrap import dedent +from typing import Optional, Sequence + +from config import cm +import git -def exec(args: Sequence[Sequence[str]]) -> None: - bin_dir = subprocess.check_output(("npm", "bin"), text=True).strip() - call_args = [bin_dir + "/tiddlywiki"] +@functools.lru_cache(1) +def _npm_bin() -> str: + return subprocess.check_output(("npm", "bin"), text=True).strip() + +@contextmanager +def _pushd(directory: str): + """ + Change directory into the directory /directory/ until the end of the with-block, + then return to previous directory. + """ + old_directory = os.getcwd() + try: + os.chdir(directory) + yield + finally: + os.chdir(old_directory) + +def _tw_path() -> str: + return _npm_bin() + "/tiddlywiki" + +@functools.lru_cache(1) +def _whoami() -> str: + try: + return subprocess.check_output(("whoami",), text=True).strip() + except subprocess.CalledProcessError: + return "user" + + +def exec(args: Sequence[Sequence[str]]) -> int: + call_args = [_tw_path()] for tw_arg in args: call_args.append(f"--{tw_arg[0]}") for inner_arg in tw_arg[1:]: call_args.append(inner_arg) - return subprocess.call(call_args) \ No newline at end of file + return subprocess.call(call_args) + + +def _init_npm(wiki_name: str, tw_version_spec: str, author: str) -> None: + """ + Create a package.json file for this repository, requiring TiddlyWiki + at the specified version, and install the npm dependencies. + """ + print("tzk: Creating package.json...") + PACKAGE_JSON = dedent(""" + { + "name": "%(wiki_name)s", + "version": "1.0.0", + "description": "My nice notes", + "dependencies": { + "tiddlywiki": "%(tw_version_spec)s" + }, + "author": "%(author)s", + "license": "See copyright notice in wiki" + } + """).strip() % ({'tw_version_spec': tw_version_spec, 'author': author, + 'wiki_name': wiki_name}) + with open("package.json", "w") as f: + f.write(PACKAGE_JSON) + + print("tzk: Installing npm packages from package.json...") + subprocess.check_call(("npm", "install")) + + +def _init_tw(wiki_name: str) -> None: + """ + Create a new TiddlyWiki in the subfolder named 'wiki_name' + using 'tiddlywiki --init'. + """ + print("tzk: Creating new TiddlyWiki...") + try: + os.mkdir(wiki_name) + except FileExistsError: + pass + with _pushd(wiki_name): + subprocess.check_call((_tw_path(), "--init")) + + +def _save_wikifolder_to_config(wiki_name: str) -> bool: + """ + Set the wiki_folder config option to the wiki_name we initialized with, + if it's not already set in the config. + + Return True if the option ended set to wiki_name, False otherwise. + """ + print("tzk: Writing new wiki folder to config file...") + if not cm.write_attr("wiki_folder", wiki_name): + if cm.wiki_folder == wiki_name: + print("tzk: (Looks like it was already there.)") + else: + print(f"tzk: WARNING: The wiki_folder option in your config appears " + f"to be set to '{cm.wiki_folder}', rather than the wiki folder " + f"you're initializing, {wiki_name}. Please check your config file " + "and update this option if necessary.") + return False + return True + + +def _add_filesystem_plugins(wiki_name: str) -> None: + print("tzk: Adding filesystem plugins to tiddlywiki.info...") + info_path = Path.cwd() / wiki_name / "tiddlywiki.info" + with info_path.open("r") as f: + info_data = json.load(f) + info_data['plugins'] = ["tiddlywiki/filesystem", "tiddlywiki/tiddlyweb"] + with info_path.open("w") as f: + json.dump(info_data, f) + + +def _init_gitignore() -> None: + print("tzk: Creating gitignore...") + GITIGNORE = dedent(""" + __pycache__/ + node_modules/ + .peru/ + output/ + + \$__StoryList.tid + """).strip() + with open(".gitignore", "w") as f: + f.write(GITIGNORE) + + +def _initial_commit() -> None: + print("tzk: Initializing new Git repository for wiki...") + git.exec("init") + + print("tzk: Committing changes to repository...") + git.exec("add", "-A") + git.exec("commit", "-m", "Initial commit") + + +def install(wiki_name: str, tw_version_spec: str, author: Optional[str]): + # assert: caller has checked npm and git are installed + warnings = False + + if author is None: + author = _whoami() + + _init_npm(wiki_name, tw_version_spec, author) + _init_tw(wiki_name) + warnings |= not _save_wikifolder_to_config(wiki_name) + _add_filesystem_plugins(wiki_name) + _init_gitignore() + _initial_commit() + + if warnings: + print("tzk: Initialization completed with warnings. Read the output and " + "make any changes required, then run 'tzk listen' to start the server.") + else: + print("tzk: Initialized successfully. Run 'tzk listen' to start the server.") diff --git a/tzk.py b/tzk.py index 39ef390..66e467d 100644 --- a/tzk.py +++ b/tzk.py @@ -1,20 +1,26 @@ from abc import ABC, abstractmethod, abstractclassmethod import argparse import os +import shutil import sys +from typing import Optional -import config +from config import cm import git import tw +from util import fail class CliCommand(ABC): + cmd = None # type: str + help = None # type: str + @abstractclassmethod - def setup_arguments(self, parser: argparse.ArgumentParser) -> None: + def setup_arguments(cls, parser: argparse.ArgumentParser) -> None: raise NotImplementedError @abstractmethod - def execute(self, parser: argparse.ArgumentParser) -> None: + def execute(self, parser: argparse.Namespace) -> None: raise NotImplementedError @@ -23,7 +29,7 @@ class CommitCommand(CliCommand): help = "Commit all changes to the wiki repository." @classmethod - def setup_arguments(self, parser: argparse.ArgumentParser) -> None: + def setup_arguments(cls, parser: argparse.ArgumentParser) -> None: parser.add_argument( "-m", "--message", metavar="MSG", @@ -46,11 +52,9 @@ class CommitCommand(CliCommand): if cm.commit_require_branch: current_branch = git.read("rev-parse", "--abbrev-ref", "HEAD") if current_branch != cm.commit_require_branch: - print(f"You are on the '{current_branch}' branch, " - f"but your TZK configuration requires you to be on the " - f"'{cm.commit_require_branch}' branch to commit.", - file=sys.stderr) - sys.exit(1) + fail(f"You are on the '{current_branch}' branch, " + f"but your TZK configuration requires you to be on the " + f"'{cm.commit_require_branch}' branch to commit.") git.exec("add", "-A") git.exec("commit", "-m", args.message) @@ -63,7 +67,7 @@ class ListenCommand(CliCommand): help = "Start a TiddlyWiki server in the current directory." @classmethod - def setup_arguments(self, parser: argparse.ArgumentParser) -> None: + def setup_arguments(cls, parser: argparse.ArgumentParser) -> None: parser.add_argument( "-p", "--port", metavar="PORT", @@ -84,27 +88,85 @@ class ListenCommand(CliCommand): ) def execute(self, args: argparse.Namespace) -> None: - tw.exec( - [ - ("listen", - f"port={args.port}", - f"username={args.username}", - f"password={args.password}") - ] + try: + tw.exec( + [ + ("listen", + f"port={args.port}", + f"username={args.username}", + f"password={args.password}") + ] + ) + except KeyboardInterrupt: + # We'll terminate anyway now that we're at the end of execute() -- + # no need to display the traceback to the user. + pass + + +class InitCommand(CliCommand): + cmd = "init" + help = "Set up a new TZK directory." + + @classmethod + def setup_arguments(cls, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "-n", "--wiki-name", + metavar="NAME", + help="The wiki will be installed in a subfolder of the current directory, called NAME.", + default="wiki", + ) + parser.add_argument( + "-v", "--tiddlywiki-version-spec", + metavar="SPEC", + help="NPM version spec for the version of TiddlyWiki to start your package.json at.", + default="^5.1.23", + ) + parser.add_argument( + "-a", "--author", + metavar="AUTHOR_NAME", + help="The author to be credited in the package.json, if not your current username.", + default=None, ) + def _precheck(self): + if shutil.which("npm") is None: + fail("TZK requires NPM. Please install NPM and make it available on your PATH.\n" + "https://docs.npmjs.com/downloading-and-installing-node-js-and-npm") + + if shutil.which("git") is None: + fail("TZK requires Git. Please install Git and make it available on your PATH.\n" + "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git") + + if os.path.exists("package.json"): + fail("A 'package.json' file already exists in the current directory. " + "Perhaps you've already initialized a TZK repository here?") + + def execute(self, args: argparse.Namespace) -> None: + self._precheck() + tw.install(args.wiki_name, args.tiddlywiki_version_spec, args.author) -cm = config.ConfigurationManager() -os.chdir("zk-wiki") -# TODO: confirm we're in the right directory parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() for command in CliCommand.__subclasses__(): subparser = subparsers.add_parser(command.cmd, help=command.help) subparser.set_defaults(_cls=command) - command.setup_arguments(subparser) + command.setup_arguments(subparser) # type: ignore args = parser.parse_args() + +# For all operations except 'init', we start in the wiki folder. +if not args._cls.cmd == "init": + if not cm.wiki_folder: + fail("No 'wiki_folder' option found in config. Set this option to the name " + "of the wiki subfolder within the current directory.") + + try: + os.chdir(cm.wiki_folder) + except FileNotFoundError: + fail(f"Tried to change directory into the wiki_folder '{cm.wiki_folder}' " + f"specified in your config file, but that directory does not exist.") + # TODO: confirm we're in the right directory + args._cls().execute(args) diff --git a/util.py b/util.py new file mode 100644 index 0000000..11ec9f8 --- /dev/null +++ b/util.py @@ -0,0 +1,7 @@ +import sys +from typing import NoReturn + +def fail(msg: str, exit_code: int = 1) -> NoReturn: + print(msg, file=sys.stderr) + sys.exit(exit_code) +