tzk/tzk/__main__.py

438 lines
16 KiB
Python
Raw Normal View History

2021-08-25 19:36:40 +00:00
from abc import ABC, abstractmethod, abstractclassmethod
import argparse
import os
from pathlib import Path
import shutil
2021-08-25 20:38:26 +00:00
import sys
2021-08-26 16:51:11 +00:00
import traceback
from typing import Optional
from tzk.config import cm, DEFAULT_INIT_OPTS
from tzk import git
from tzk import tw
2021-09-21 17:07:15 +00:00
from tzk.util import (BuildError, fail, numerize, require_dependencies, pushd,
TZK_VERSION)
2021-08-25 19:36:40 +00:00
VERSION_INFO = f"tzk version {TZK_VERSION}"
2021-08-25 19:36:40 +00:00
class CliCommand(ABC):
2021-08-27 15:06:52 +00:00
"""
Base class for subcommands of tzk.
"""
#: The text of the subcommand to be used on the command line.
cmd = None # type: str
2021-08-27 15:06:52 +00:00
#: Help string for argparse to display for this subcommand.
help = None # type: str
2021-08-25 19:36:40 +00:00
@abstractclassmethod
def setup_arguments(cls, parser: argparse.ArgumentParser) -> None:
2021-08-27 15:06:52 +00:00
"""
Given the :arg:`parser`, add any arguments this subcommand wants to accept.
"""
2021-08-25 19:36:40 +00:00
raise NotImplementedError
@abstractmethod
2021-08-27 15:06:52 +00:00
def execute(self, args: argparse.Namespace) -> None:
"""
Given the :arg:`args` passed to this subcommand, do whatever the command does.
"""
2021-08-25 19:36:40 +00:00
raise NotImplementedError
class CommitCommand(CliCommand):
cmd = "commit"
help = "Commit all changes to the wiki repository."
2021-08-25 19:36:40 +00:00
@classmethod
def setup_arguments(cls, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
2021-08-25 20:06:42 +00:00
"-m", "--message",
metavar="MSG",
help="Commit message to use.",
2021-08-27 17:52:58 +00:00
default=cm().commit_message,
2021-08-25 20:38:26 +00:00
)
parser.add_argument(
"-r", "--remote",
metavar="REMOTE",
2021-08-27 17:52:58 +00:00
help="Name of the Git remote to push to.",
default=cm().commit_remote,
2021-08-25 20:06:42 +00:00
)
parser.add_argument(
"-l", "--local",
help="Don't push the results to any configured remote repository.",
2021-08-25 20:38:26 +00:00
action="store_true",
2021-08-25 20:06:42 +00:00
)
2021-08-25 19:36:40 +00:00
def execute(self, args: argparse.Namespace) -> None:
cm().require_config()
chdir_to_wiki()
if cm().commit_require_branch:
2021-08-25 20:38:26 +00:00
current_branch = git.read("rev-parse", "--abbrev-ref", "HEAD")
if current_branch != cm().commit_require_branch:
fail(f"You are on the '{current_branch}' branch, "
2021-09-20 17:16:38 +00:00
f"but your tzk configuration requires you to be on the "
f"'{cm().commit_require_branch}' branch to commit.")
2021-08-25 20:38:26 +00:00
git.exec("add", "-A")
2021-08-27 17:52:58 +00:00
if git.rc("commit", "-m", args.message) == 0 and args.remote and not args.local:
2021-08-25 20:38:26 +00:00
git.exec("push", args.remote)
2021-08-25 20:06:42 +00:00
2021-08-25 20:25:50 +00:00
class ListenCommand(CliCommand):
cmd = "listen"
2021-09-21 17:07:15 +00:00
help = "Start a TiddlyWiki server in the current tzk repository."
2021-08-25 20:25:50 +00:00
@classmethod
def setup_arguments(cls, parser: argparse.ArgumentParser) -> None:
2021-08-25 20:25:50 +00:00
parser.add_argument(
"-p", "--port",
metavar="PORT",
help="Port to listen on.",
default=str(cm().listen_port or "8080"),
2021-08-25 20:25:50 +00:00
)
parser.add_argument(
"--username",
metavar="USERNAME",
default=cm().listen_username or "",
2021-08-25 20:25:50 +00:00
help="Username to use for basic authentication, if any.",
)
parser.add_argument(
"--password",
metavar="PASSWORD",
default=cm().listen_password or "",
2021-08-25 20:25:50 +00:00
help="Password to use for basic authentication, if any.",
)
def execute(self, args: argparse.Namespace) -> None:
cm().require_config()
chdir_to_wiki()
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"
2021-09-21 17:07:15 +00:00
help = "Set up a new tzk repository."
@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=DEFAULT_INIT_OPTS['wiki_name'],
)
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=DEFAULT_INIT_OPTS['tw_version_spec'],
)
parser.add_argument(
"-a", "--author",
metavar="AUTHOR_NAME",
help="The author to be credited in the package.json, if not your current username.",
default=DEFAULT_INIT_OPTS['author'],
2021-08-25 20:25:50 +00:00
)
def _precheck(self):
require_dependencies()
if os.path.exists("package.json"):
fail("A 'package.json' file already exists in the current directory. "
2021-09-20 17:16:38 +00:00
"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)
2021-08-25 20:25:50 +00:00
2021-08-25 19:36:40 +00:00
class PreflightCommand(CliCommand):
cmd = "preflight"
help = "Check if tzk and all its dependencies are correctly installed."
@classmethod
def setup_arguments(cls, parser: argparse.ArgumentParser) -> None:
pass
def execute(self, args: argparse.Namespace) -> None:
require_dependencies()
print("You're all set! Change into a directory you want to "
"turn into your tzk repository and run 'tzk init'.")
2021-09-21 17:07:15 +00:00
class VersionCommand(CliCommand):
cmd = "version"
help = "Find out what version your tzk is at."
@classmethod
def setup_arguments(cls, parser: argparse.ArgumentParser) -> None:
pass
def execute(self, args: argparse.Namespace) -> None:
print(VERSION_INFO)
2021-09-21 17:07:15 +00:00
2021-08-26 16:51:11 +00:00
class BuildCommand(CliCommand):
cmd = "build"
help = ("Build another wiki or derivative product, "
"such as a public version of the wiki, "
2021-09-20 17:16:38 +00:00
"from this tzk repository.")
2021-08-26 16:51:11 +00:00
@classmethod
def setup_arguments(cls, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"product",
metavar="PRODUCT",
help="Name of the product you want to build (defined in your config file).",
)
parser.add_argument(
"-s", "--skip-builder",
metavar="BUILDER_NAME",
help="Function name of a builder to skip even if part of the PRODUCT. "
"This option can be specified multiple times.",
action="append",
default=[],
)
2021-08-26 16:51:11 +00:00
def _precheck(self, product: str) -> None:
if cm().products is None:
2021-08-26 16:51:11 +00:00
fail("No 'products' dictionary is defined in your config file.")
if product not in cm().products:
2021-08-26 16:51:11 +00:00
fail(f"No '{product}' product found in the products dictionary "
f"in your config file. (Available: {', '.join(cm().products.keys())})")
if not cm().products[product]:
2021-08-26 16:51:11 +00:00
fail(f"No build steps are defined in the '{product}' product "
f"in your config file.")
def execute(self, args: argparse.Namespace) -> None:
cm().require_config()
chdir_to_wiki()
2021-08-26 16:51:11 +00:00
self._precheck(args.product)
2021-08-27 15:06:52 +00:00
# Find the build steps for the product the user specified.
steps = cm().products[args.product]
2021-08-26 16:51:11 +00:00
print(f"tzk: Starting build of product '{args.product}'.")
print(f"tzk: Found {len(steps)} build {numerize(len(steps), 'step')}.")
2021-08-26 16:51:11 +00:00
2021-08-27 17:51:47 +00:00
failed = False
2021-08-27 15:06:52 +00:00
# For each build step...
2021-08-27 17:51:47 +00:00
try:
for idx, step in enumerate(steps, 1):
# Explain what we're doing. Use first line of the builder's docstring
# as a summary, if present.
if hasattr(step, '__doc__') and step.__doc__ is not None:
2021-08-27 17:51:47 +00:00
short_description = step.__doc__.strip().split('\n')[0].rstrip('.')
print(f"tzk: Step {idx}/{len(steps)}: {short_description}")
else:
print(f"tzk: Step {idx}/{len(steps)}")
# If the user asked to skip this builder on the command line, do so.
if step.__name__ in args.skip_builder:
print(f"tzk: Skipping step {idx} due to --skip-builder parameter.")
continue
# Execute step and handle any errors.
2021-08-26 16:51:11 +00:00
step()
2021-08-27 17:51:47 +00:00
except BuildError as e:
failed = True
print(f"tzk: ERROR: {str(e)}")
print(f"tzk: Build of product '{args.product}' failed on step {idx}, "
2021-09-20 17:13:57 +00:00
f"backed by builder '{step.__name__}'. ")
2021-08-27 17:51:47 +00:00
print(f"tzk: Add '--skip-builder {step.__name__}' if you'd like "
2021-09-20 17:13:57 +00:00
f"to skip this step.")
2021-08-27 17:51:47 +00:00
except Exception:
failed = True
print(f"tzk: Build of product '{args.product}' failed on step {idx}: "
2021-09-20 17:13:57 +00:00
f"unhandled exception. "
f"The original error follows:")
2021-08-27 17:51:47 +00:00
traceback.print_exc()
finally:
for idx, step in enumerate(steps, 1):
if hasattr(step, 'cleaner'):
print(f"tzk: Running cleanup routine registered by step {idx}...")
step.cleaner()
if failed:
sys.exit(1)
else:
print(f"tzk: Build of product '{args.product}' completed successfully.")
2021-08-26 16:51:11 +00:00
2021-08-25 19:36:40 +00:00
class ConvertCommand(CliCommand):
cmd = "convert"
help = "Convert a tzk repository to a single-file wiki or vice versa."
@classmethod
def setup_arguments(cls, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"source",
metavar="SOURCE",
help="Wiki to convert. "
"Either a folder containing a tzk_config.py file or an HTML file.",
)
parser.add_argument(
"destination",
metavar="DEST",
help="Output location for the converted wiki. "
"Either a folder (existing or not) or the name of an HTML file."
)
parser.add_argument(
"-f", "--force",
action="store_true",
help="Overwrite the destination location if it already exists.",
)
def _precheck(self, args: argparse.Namespace) -> None:
if not os.path.exists(args.source):
fail(f"The source location '{args.source}' does not exist.")
if os.path.exists(args.destination) and not args.force:
fail(f"The destination location '{args.destination}' already exists. "
f"(Use --force to overwrite it.)")
def execute(self, args: argparse.Namespace) -> None:
require_dependencies()
self._precheck(args)
source = Path(args.source).absolute()
destination = Path(args.destination)
source_type = 'file' if source.is_file() else 'folder'
dest_type = 'file' if destination.name.endswith(".html") else 'folder'
# If types are the same, there's nothing to convert...
if source_type == dest_type:
fail("The source or the destination may not be of the same type. "
"One must be a folder and the other an HTML file.")
# Conversion from folder to file using --render.
if source_type == 'folder':
if not Path(source / "tzk_config.py").exists():
fail("The source folder '{source}' does not contain "
"a tzk_config.py file.")
with pushd(str(source)):
source_wiki_folder = Path.cwd() / cm(cache=[]).wiki_folder
tw.exec(
(
("output", str(destination.parent)),
("render", "$:/core/save/all", destination.name, "text/plain"),
),
base_wiki_folder=str(source_wiki_folder)
)
# Conversion from file to folder using --savewikifolder.
elif source_type == 'file':
if destination.exists() and args.force:
doing_what = "Overwriting existing"
shutil.rmtree(destination)
else:
doing_what = "Creating new"
print(f"tzk: {doing_what} tzk repository in destination '{destination}'...")
os.mkdir(destination)
def installer(wiki_name):
tw.exec(
(
("load", str(source)),
("savewikifolder", wiki_name),
),
)
with pushd(str(destination)):
tw.install(_tw_func=installer, **DEFAULT_INIT_OPTS) # type: ignore
else:
raise AssertionError(f"Invalid source type {source_type}.")
def chdir_to_wiki():
"""
For most operations, we want the current directory to be the wiki folder.
"""
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}' "
2021-08-27 17:52:58 +00:00
f"specified in your config file, but that directory does not exist.")
if not os.path.exists("tiddlywiki.info"):
fail(f"After changing directory into {cm().wiki_folder} per your config file: "
2021-09-20 17:13:57 +00:00
f"Expected a 'tiddlywiki.info' file in {os.getcwd()}. "
f"Please check that your wiki is initialized "
f"and you specified the correct wiki_folder_name.")
def launch():
# If this isn't a tzk directory but we have an environment variable listing one,
# go there before doing anything else.
if (not os.path.exists("tzk_config.py")
and os.environ.get('TZK_DIRECTORY')
and len(sys.argv) > 1
and sys.argv[1] != "init"): # we can't init an existing TZK_DIRECTORY
try:
os.chdir(os.environ['TZK_DIRECTORY'])
except (FileNotFoundError, NotADirectoryError):
fail(f"The current directory contains no 'tzk_config.py' "
f"and the TZK_DIRECTORY environment variable is set to "
f"'{os.environ['TZK_DIRECTORY']}', so I tried to go there, "
f"but it doesn't exist or is not a directory.")
if not os.path.exists("tzk_config.py"):
fail(f"The current directory contains no 'tzk_config.py' "
f"and the TZK_DIRECTORY environment variable is set to "
f"'{os.environ['TZK_DIRECTORY']}', so I tried to go there, "
f"but there's no tzk_config.py there either.")
2021-09-21 17:07:15 +00:00
parser = argparse.ArgumentParser(
description=f"TiddlyZettelKasten {TZK_VERSION} CLI\n"
2022-03-15 01:59:57 +00:00
f"Copyright (c) 2021-2022 Soren Bjornstad and the tzk community.\n"
2021-09-21 17:44:53 +00:00
f"MIT license; see https://github.com/sobjornstad/tzk/blob/master/LICENSE for details.",
2021-09-21 17:07:15 +00:00
epilog="For full documentation, see https://tzk.readthedocs.io/en/latest/.",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--version",
action="version",
version=VERSION_INFO,
help=VersionCommand.help
)
2021-08-25 19:36:40 +00:00
subparsers = parser.add_subparsers()
for command in sorted(CliCommand.__subclasses__(), key=lambda i: i.__name__):
2021-09-21 17:07:15 +00:00
subparser = subparsers.add_parser(command.cmd,
help=command.help,
description=command.help)
subparser.set_defaults(_cls=command)
command.setup_arguments(subparser) # type: ignore
args = parser.parse_args()
if not hasattr(args, '_cls'):
2021-08-27 15:06:52 +00:00
# no subcommand was given
parser.print_help()
sys.exit(0)
args._cls().execute(args)
if __name__ == '__main__':
2021-08-27 15:06:52 +00:00
launch()