tzk/tzk/builders.py

822 lines
35 KiB
Python

"""
*Builders* are small executable chunks that together can be linked into a useful build
process.
Builders are decorated with :func:`tzk_builder`, which causes them to be
lazy-evaluated: that is, when they're initially called in the configuration,
instead of running the function and returning its result, a zero-argument
function with all of the arguments wrapped up is returned, to be run at a later
time. This allows the configuration file to be read at any time to retrieve
information about the defined products without actually running any build steps.
"""
from collections.abc import Mapping
from contextlib import contextmanager
import functools
import itertools
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
from typing import Callable, Dict, Generator, List, Optional, Set, Sequence, Tuple
from tzk import git
from tzk import tw
from tzk.util import alter_tiddlywiki_info, BuildError, pushd
def tzk_builder(func):
"""
Decorator which makes a function lazy-evaluable: that is, when it's
initially called, it returns a zero-argument lambda with the arguments
initially passed wrapped up in it. Calling that lambda has the effect
of executing the builder.
We use this in tzk to allow the user to use function calls in her config
to define the build steps, while not requiring her to write a bunch of
ugly and confusing lambda:'s in the config. The functions that will be called
are prepared during the config and executed later.
"""
@functools.wraps(func)
def new_func(*args, **kwargs):
my_args = args
my_kwargs = kwargs
@functools.wraps(new_func)
def inner():
func(*my_args, **my_kwargs)
return inner
return new_func
def stop(message: str) -> None:
"Stop the build due to an error condition."
raise BuildError(message)
def info(message: str) -> None:
"Print information about this build step to the console."
print(message)
# Global state available to all builders.
build_state = {}
@tzk_builder
def say_hi(username: str) -> None:
"""
Say hi to the specified user.
This function is intended for testing. It will normally succeed and print
the provided *username*, but if you give "Jeff" as the username, the step
will fail, and if you give "General Failure" as the username, it will
cause an unhandled exception.
:param username: The name of the person to say hi to.
"""
if username.lower() == 'general failure':
raise Exception("")
elif username.lower() == 'jeff':
# https://twitter.com/yephph/status/1249246702126546944
stop("Sorry, the name Jeff does not work with our database schema.")
else:
print(f"Hello {username}!")
@tzk_builder
def require_branch(branchname: str) -> None:
"""
Require a specific Git branch to be checked out.
If the branch isn't checked out, the build will fail immediately. This may
be helpful if you want to be sure you aren't accidentally building a wiki
from tentative changes or an old version.
:param branchname: The name of the branch that must be checked out.
"""
if git.read("branch", "--show-current") != branchname:
stop(f"You may only run this build from the {branchname} branch.")
@tzk_builder
def require_clean_working_tree() -> None:
"""
Require the working tree of the Git repository to be clean.
If there are any unstaged changes to existing files or staged changes,
the build will fail immediately.
For the standard build process, it is not necessary for the working tree
to be clean. However, if you use any custom build steps that compare history,
or you simply want to ensure that you always have a recent checkpoint in your
local version whenever you publish another version, this may be a useful
requirement.
"""
pleasecommit = "Please commit or stash them before publishing (try 'tzk commit')."
if git.rc("diff-index", "--quiet", "--cached", "HEAD", "--") != 0:
stop(f"There are staged changes. {pleasecommit}")
if git.rc("diff-files", "--quiet") != 0:
stop(f"There are uncommitted changes. {pleasecommit}")
@tzk_builder
def new_output_folder():
"""
Create a new temporary folder to hold intermediate steps of the product being built.
The path to this temporary folder will be stored in the ``public_wiki_folder``
key of the ``builders.build_state`` dictionary. Future build steps can access
the work in progress here. A cleaner is registered to delete this folder
when all steps complete, so any finished product should be copied out by a
later build step once it is complete.
"""
assert 'public_wiki_folder' not in build_state
build_state['public_wiki_folder'] = tempfile.mkdtemp()
def new_output_folder_cleaner():
if 'public_wiki_folder' in build_state:
shutil.rmtree(build_state['public_wiki_folder'])
new_output_folder.cleaner = new_output_folder_cleaner
@tzk_builder
def export_public_tiddlers(export_filter: str) -> None:
"""
Export specified tiddlers to a new wiki in the temporary build folder.
:func:`new_output_folder()` must be run prior to this builder.
:param export_filter: A TiddlyWiki filter describing the tiddlers to be selected
for inclusion in the new wiki.
"""
assert 'public_wiki_folder' in build_state, "new_output_folder builder must run first"
tw.exec((
("savewikifolder", build_state['public_wiki_folder'], export_filter),
))
def _find_kill_phrases(phrases: Set[str]):
"""
Search all tiddlers in the public_wiki_folder for the specified kill phrases.
"""
regexes = [re.compile(phrase) for phrase in phrases]
failures = []
tid_files = (Path(build_state['public_wiki_folder']) / "tiddlers").glob("**/*.tid")
for tid_file in tid_files:
with tid_file.open() as f:
for line in f:
for regex in regexes:
if re.search(regex, line):
failures.append((regex, str(tid_file), line))
return failures
@tzk_builder
def check_for_kill_phrases(kill_phrase_file: str = None) -> None:
"""
Fail the build if any of a series of regexes matches a tiddler's source in the temp wiki.
The temp wiki should be created first
using the :func:`export_public_tiddlers()` builder.
The kill phrases are Python-format regular expressions and may be configured
within the wiki, currently in $:/sib/gui/KillPhrases.
:param kill_phrase_file: The path from the source wiki's root directory to the
config tiddler containing kill phrases. In the default
Zettelkasten edition, this is
"tiddlers/$__config_zettelkasten_Build_KillPhrases.tid";
if you change the way paths are determined, you can give
a different path here.
"""
assert 'public_wiki_folder' in build_state, "new_output_folder builder must run first"
if kill_phrase_file is None:
kill_phrase_file = "tiddlers/$__config_zettelkasten_Build_KillPhrases.tid"
kill_phrases = set()
with open(kill_phrase_file) as f:
reading = False
for line in f:
if not line.strip():
reading = True
if line.strip() and reading:
kill_phrases.add(line)
failures = _find_kill_phrases(kill_phrases)
if failures:
result = ["Kill phrases were found in your public wiki:"]
for failed_regex, failed_file, failed_line in failures:
trimmed_file = failed_file.replace(build_state['public_wiki_folder'], '')
result.append(f"'{failed_regex.pattern}' matched file {trimmed_file}:\n"
f" {failed_line.strip()}")
stop('\n'.join(result))
@tzk_builder
def save_attachments_externally(attachment_filter: str = "[is[image]]",
extimage_folder: str = "extimage") -> None:
"""
Save embedded files in the temp wiki into an external folder.
The temp wiki should be created first
using the :func:`export_public_tiddlers()` builder.
Note that this builder **does not finish externalizing images**.
It saves the images outside the wiki,
but it does not change the ``_canonical_uri`` and ``text`` fields
on each image tiddler to point to this new location.
For the latter step, use the
``externalize_attachments``, ``attachment_filter``, and ``canonical_uri_template``
parameters to the :func:`compile_html_file` step.
:param attachment_filter: The tiddlers to be saved to the external folder;
by default, ``[is[image]]``.
:param extimage_folder: The name of the external folder to save to. This must
be the default of ``extimage`` to work with the default
``canonical_uri_template``
in the :func:`compile_html_file()` builder,
but you can use a different name and a different
canonical URI template if you prefer.
"""
assert 'public_wiki_folder' in build_state
tw.exec(
(
("savetiddlers", attachment_filter, extimage_folder),
),
base_wiki_folder=build_state['public_wiki_folder']
)
@tzk_builder
def compile_html_file(
wiki_name: str = "index.html",
output_folder: str = "output/public_site/",
overwrite: bool = True,
externalize_attachments: bool = False,
attachment_filter: str = "[is[image]]",
canonical_uri_template: str = "$:/core/templates/canonical-uri-external-image",
) -> None:
"""
Compile a single HTML file from the temp wiki.
Before compiling an HTML file,
you should create a temp wiki using the :func:`export_public_tiddlers()` builder,
then run any other build steps you want to use
to make changes to the wiki being built.
Once you compile the HTML file,
it will be copied out to an output location outside the temp folder,
and the content can no longer be changed through tzk.
:param wiki_name: The filename of the single-file wiki to create.
Default ``index.html``.
:param output_folder: The path to the folder
where the single-file wiki and any externalized attachments
will be placed, relative to the private wiki's root directory.
Default ``output/public_site``.
:param overwrite: If the ``output_folder`` already exists,
should we overwrite any files in it with the same name?
Default True.
:param externalize_attachments: If True, update tiddlers that match
the ``attachment_filter`` parameter to point to
external versions of their content.
Only useful if you have previously run
the ``save_attachments_externally`` builder.
Default False.
:param attachment_filter: If externalizing attachments,
which tiddlers should be externalized?
Default ``[is[image]]``.
:param canonical_uri_template: What TiddlyWiki template should be used
to determine the new content
of the ``_canonical_uri`` field?
Default ``$:/core/templates/canonical-uri-external-image``.
If you're not sure what this is, don't change it.
"""
assert 'public_wiki_folder' in build_state
commands: List[Tuple[str, ...]] = []
if externalize_attachments:
commands.extend([
("setfield", attachment_filter, "_canonical_uri",
canonical_uri_template, "text/plain"),
("setfield", attachment_filter, "text", "", "text/plain"),
])
commands.append(("render", "$:/core/save/all", wiki_name, "text/plain"))
tw.exec(commands, base_wiki_folder=build_state['public_wiki_folder'])
if os.path.exists(output_folder) and not overwrite:
stop(f"The output folder '{os.path.abspath(output_folder)}' already exists. "
f"(To overwrite any files existing in the output folder, "
f"set overwrite = True for this builder.)")
shutil.copytree(
Path(build_state['public_wiki_folder']) / "output",
Path(output_folder),
dirs_exist_ok=True
)
info(f"Successfully copied built output to {os.path.abspath(output_folder)}.")
def _private_people_replacement_table(
initialer: Callable[[str], str] = None) -> Dict[str, str]:
"Build table of private people and their transformed initials."
def _initials_from_tiddler_name(name: str) -> str:
m = re.match(r"^(?:Mr|Ms|Mx|The)(?P<camel_case_name>.*?)\.tid", name)
assert m
return '.'.join(i for i in m.group('camel_case_name') if i.isupper()) + '.'
if initialer is None:
initialer = _initials_from_tiddler_name
tiddlers = (Path.cwd() / "tiddlers").glob("**/*.tid")
person_tiddlers = (i for i in tiddlers if re.match("^(Mr|Ms|Mx|The)", i.name))
private_person_tiddlers = []
for pt in person_tiddlers:
with pt.open() as f:
for line in f:
if line.startswith("tags:"):
if re.search(r'\bPublic\b', line):
# If there's a tags line in the file and it contains the
# Public tag, we skip it.
break
else:
# Otherwise, if there's a tags line in the file and it
# doesn't contain the Public tag, it's private.
private_person_tiddlers.append(pt)
break
if not line.strip():
# And if there's no tags line in the file at all,
# it's private by default.
private_person_tiddlers.append(pt)
break
return {
i.name.replace('.tid', ''): initialer(i.name)
for i in private_person_tiddlers
}
def _privatize_line(line: str, replacement_table: Dict[str, str],
replace_link_text: bool = False) -> Optional[str]:
"""
Given a line and a table of replacements to make, replace all instances
of all private people defined in the replacement table.
Basics:
>>> _privatize_line("MsAlice is a test person.", {'MsAlice': 'A.'})
'[[A.|PrivatePerson]] is a test person.'
>>> _privatize_line("This woman, known as MsAlice, is a test person.", \
{'MsAlice': 'A.'})
'This woman, known as [[A.|PrivatePerson]], is a test person.'
>>> _privatize_line("[[MsAlice]] is a test person.", {'MsAlice': 'A.'})
'[[A.|PrivatePerson]] is a test person.'
>>> _privatize_line("When we talk about [[MsAlice]] in the middle of a " \
"sentence, that's fine too.", {'MsAlice': 'A.'})
"When we talk about [[A.|PrivatePerson]] in the middle of a sentence, that's fine too."
Content inside a macro:
>>> _privatize_line('''Text with a footnote.''' \
'''<<fnote "Here's my footnote about MsAlice.">>''', \
{'MsAlice': 'A.'})
'Text with a footnote.<<fnote "Here\\'s my footnote about [[A.|PrivatePerson]].">>'
Links with different text and target:
>>> _privatize_line("We can talk about [[Alice|MsAlice]] " \
"with different text.", {'MsAlice': 'A.'})
'We can talk about [[Alice|PrivatePerson]] with different text.'
Multiple replacements with different people:
>>> _privatize_line("We can have [[MsAlice]] and MrBob talk to each other " \
"in the same line.", {'MsAlice': 'A.', 'MrBob': 'B.'})
'We can have [[A.|PrivatePerson]] and [[B.|PrivatePerson]] talk to each other in the same line.'
Multiple replacements with the same person:
>>> _privatize_line("We can have MsAlice talk to herself (MsAlice) " \
"in the same line.", {'MsAlice': 'A.'})
'We can have [[A.|PrivatePerson]] talk to herself ([[A.|PrivatePerson]]) in the same line.'
>>> _privatize_line("Likewise [[MsAlice]] can do it with brackets " \
"([[MsAlice]]).", {'MsAlice': 'A.'})
'Likewise [[A.|PrivatePerson]] can do it with brackets ([[A.|PrivatePerson]]).'
>>> _privatize_line('We can talk about [[Alice|MsAlice]] lots of ways, ' \
'like MsAlice and [[MsAlice]].', {'MsAlice': 'A.'})
'We can talk about [[Alice|PrivatePerson]] lots of ways, like [[A.|PrivatePerson]] and [[A.|PrivatePerson]].'
Replacements with alternate link text:
>>> _privatize_line('We can talk about [[Alice|MsAlice]] and [[Bob|MrBob]] as well', \
{'MsAlice': 'A.', 'MrBob': 'B.'}, replace_link_text=True)
'We can talk about [[A.|PrivatePerson]] and [[B.|PrivatePerson]] as well'
We don't want to replace places where a CamelCase match is a substring of another
word. This is expected to yield no output because there's nothing to replace:
>>> _privatize_line("But an EmbeddedCamelWithMsAliceInIt isn't her.", \
{'MsAlice': 'A.'})
"""
def iteroccurrences(needle: str) -> Generator[int, int, None]:
"""
Iterate over the start indices of occurrences of substring
``needle`` in the line /line/ in outer scope.
(We have to use outer scope because it can be changed while we're iterating
and the generator is only bound to arguments once.)
"""
idx = -1
while True:
idx = line.find(needle, idx + 1)
if idx == -1:
return
else:
additional_increments = yield idx
if additional_increments is not None:
idx += additional_increments
def anchored_at_one_end(start_index: int, end_index: int) -> bool:
return start_index == 0 or end_index == len(line)
def is_camelcase_link(start_index: int, end_index: int) -> bool:
return (anchored_at_one_end(start_index, end_index)
or (line[start_index-1] != '[' and line[end_index] != ']'))
def is_bare_bracketed_link(start_index: int, end_index: int) -> bool:
return (not anchored_at_one_end(start_index, end_index)
and line[start_index-2:start_index] == '[['
and line[end_index:end_index+2] == ']]')
def is_textual_bracketed_link(start_index: int, end_index: int) -> bool:
return (not anchored_at_one_end(start_index, end_index)
and line[start_index-1] == '|'
and line[end_index:end_index+2] == ']]')
dirty = False
increment_iterator_by = 0
for replace_person, replace_initials in replacement_table.items():
iterator = iteroccurrences(replace_person)
try:
while True:
# NOTE: the "end" index is one after the last index in the string,
# as is needed for slice notation.
if increment_iterator_by:
start_idx = iterator.send(increment_iterator_by)
increment_iterator_by = 0
else:
start_idx = next(iterator)
end_idx = start_idx + len(replace_person)
new_line = None
if is_camelcase_link(start_idx, end_idx):
# camel-case link or unlinked reference in text
def is_spurious_substring():
# If there's not a non-alphanumeric character on both sides of
# the "link", we may be making a clbuttic replacement.
# <https://en.wikipedia.org/wiki/Scunthorpe_problem>
start_ok = start_idx == 0 or not line[start_idx-1].isalnum()
end_ok = end_idx == len(line) or not line[end_idx].isalnum()
return not (start_ok and end_ok)
if not is_spurious_substring():
new_line = (line[0:start_idx]
+ f'[[{replace_initials}|PrivatePerson]]'
+ line[end_idx:])
elif is_bare_bracketed_link(start_idx, end_idx):
# link with the person as the target and text
replacement = replace_initials + '|PrivatePerson'
new_line = line[0:start_idx] + replacement + line[end_idx:]
elif is_textual_bracketed_link(start_idx, end_idx):
# link with the person as the target only;
# beware that you might have put something private in the text
if replace_link_text:
start_of_link = line[0:start_idx].rfind('[[', 0, start_idx) + 2
new_line = line[0:start_of_link] + f"{replace_initials}|PrivatePerson" + line[end_idx:]
else:
new_line = line[0:start_idx] + 'PrivatePerson' + line[end_idx:]
else:
link = line[start_idx:end_idx]
raise ValueError("Unknown type of link '{link}'.")
if new_line:
line = new_line
dirty = True
# If we changed the length of the string by modifying it,
# we need to update our stored position within the string.
increment_iterator_by = len(new_line) - len(line)
except StopIteration:
pass
if dirty:
return line
else:
return None
@tzk_builder
def replace_private_people(initialer: Callable[[str], str] = None, replace_link_text: bool = False) -> None:
"""
Replace the names of people who are not marked Public with their initials.
If you have lots of PAO (People, Animals, and Organizations) in your Zettelkasten
and many of them are personal friends,
you might prefer not to make everything you said about them
easily searchable on the internet.
This is more challenging than simply not marking their tiddlers public,
since there will also probably be links to their tiddlers in other tiddlers --
and those links contain their full names, if you put them in the title.
This builder replaces all links, bracketed or WikiCamelCase,
to the names of all people *not* tagged Public
with the initials suggested by their CamelCase titles
(e.g., MsJaneDoe becomes J.D.). The links point to the tiddler ``PrivatePerson``,
which explains this process.
:param initialer: If you don't like the way that initials
are generated from tiddler filenames by default,
you can customize it by passing a callable
that takes one string argument
(a tiddler filename without the full path, e.g., ``MsJaneDoe.tid``)
and returns a string to be considered the "initials" of that person.
:param replace_link_text: If you have links in the form
``So then [[John said|MrJohnDoe]] something about this``,
then enabling this option ensures that the link is fully
replaced with
``So then [[J.D.|PrivatePerson]] something about this``.
This means that when using this feature, having the
link text also be meaningful after redaction is important.
.. warning ::
Using this link replacement feature does not redact everything, just the link
(and the link text with `replace_link_text` enabled). So *do not* rely on it
for redacting everything. Making a tiddler public still needs consideration and
tooling is there to help, not to replace your own judgment.
"""
assert 'public_wiki_folder' in build_state
replacement_table = _private_people_replacement_table(initialer)
root = (Path(build_state['public_wiki_folder']) / "tiddlers")
tid_files = itertools.chain(root.glob("**/*.tid"), root.glob("**/*.json"))
for tiddler in tid_files:
dirty = False
with tiddler.open() as f:
lines = f.readlines()
for i in range(len(lines)):
private_line = _privatize_line(lines[i], replacement_table, replace_link_text)
if private_line is not None:
lines[i] = private_line
dirty = True
if dirty:
with tiddler.open("w") as f:
f.writelines(lines)
def _set_fields(mappings: Dict[str, str],
editing_func: Callable[[Path, List[str], str], None]) -> None:
"""
Read the text of a .tid file into memory and call an editing_func on it
in order to modify some of its fields.
:param mappings: Mapping of tiddler filenames to new values of some field
(this function is field-agnostic; the editing_func should be
aware of what field is to be edited).
:param editing_func: A function which will make the changes.
The editing_func is called once for each item in the mapping
and receives three arguments:
:param tiddler_path: The full path to the tiddler on the filesystem.
The editing_func should write changes back to this file,
if any are appropriate.
:param tiddler_lines: A sequence of strings, each one line of the original file.
:param new_text: The new field value specified for this tiddler in the provided
mapping dict of tiddler name/new value.
"""
for tiddler, new_text in mappings.items():
tiddler_path = (Path(build_state['public_wiki_folder']) / "tiddlers" / tiddler)
try:
with tiddler_path.open("r") as f:
tiddler_lines = f.readlines()
except FileNotFoundError:
stop(f"File {tiddler_path} not found. "
f"(Did you forget to end the name with '.tid'?)")
editing_func(tiddler_path, tiddler_lines, new_text)
def _set_text_field(mappings: Dict[str, str]) -> None:
"Set the 'text' field to a new value in each pair of tiddler name/value."
def editor(tiddler_path: Path, tiddler_lines: Sequence[str], new_text: str) -> None:
if not str(tiddler_path).endswith('.tid'):
# will be a separate meta file, so the whole thing is the text field
first_blank_line_index = -1
else:
first_blank_line_index = next(idx
for idx, value in enumerate(tiddler_lines)
if not value.strip())
with tiddler_path.open("w") as f:
f.writelines(tiddler_lines[0:first_blank_line_index+1])
f.write(new_text)
_set_fields(mappings, editor)
def _set_nontext_field(field: str, mappings: Dict[str, str]) -> None:
"Set the specified /field/ to a new value in each pair of tiddler name/value."
def editor(tiddler_path: Path, tiddler_lines: Sequence[str], new_text: str) -> None:
# First check if the field exists and is currently not set to new_text...
line_to_change = -1
for idx, line in enumerate(tiddler_lines):
m = re.match(f"^{field}: (?P<value>.*)", line)
if m:
if m.group('value') != new_text:
line_to_change = idx
break
# ...and if so, write the file again with the change in place.
if line_to_change != -1:
with tiddler_path.open("w") as f:
for idx, line in enumerate(tiddler_lines):
if idx == line_to_change:
f.write(f"{field}: {new_text}\n")
else:
f.write(line)
_set_fields(mappings, editor)
@tzk_builder
def set_tiddler_values(text: Optional[Dict[str, str]] = None,
**kwargs: Dict[str, str]) -> None:
"""
Set fields of selected config or other tiddlers to arbitrary new values.
This can be used to make customizations that can't easily be done with feature
flags or other wikitext solutions within the wiki -- for instance, changing the
subtitle or what buttons are visible. It's also used to implement feature flags
in the first place by changing the ``$:/config/sib/CurrentEditionPublicity``
tiddler to ``public``, so at minimum, the build of a public wiki should use:
.. code-block:: python
builders.set_tiddler_values({
'$__config_sib_CurrentEditionPublicity.tid': 'public',
})
Any number of arguments can be provided. The name of each argument is the name of
the field to edit. One positional argument may be used with no name; this argument
is assumed to be mappings for replacing the 'text' field.
"""
assert 'public_wiki_folder' in build_state
for field_name, field_mapping in kwargs.items():
if not isinstance(field_mapping, Mapping):
raise AssertionError(
"Arguments to set_tiddler_values must be dictionaries "
"mapping tiddler names to values.")
if text is not None:
_set_text_field(text)
for field, mappings in kwargs.items():
_set_nontext_field(field, mappings)
@tzk_builder
def delete_tiddlers(tiddlers: Sequence[str]) -> None:
"""
Delete selected tiddlers from the output.
This is hopefully self-explanatory.
:param tiddlers: A list of filenames of tiddlers to delete.
"""
assert 'public_wiki_folder' in build_state
for tiddler in tiddlers:
tiddler_path = (Path(build_state['public_wiki_folder']) / "tiddlers" / tiddler)
tiddler_path.unlink()
@tzk_builder
def publish_wiki_to_github(
output_folder: str = "output/public_site/",
commit_message: str = "publish checkpoint",
remote: str = "origin",
refspec: str = "master",
push = True) -> None:
"""
Publish the built wiki to GitHub.
:param output_folder: Path to a folder containing the Git repository you'd like
to publish, relative to your private wiki's root directory.
This folder should contain the version of your wiki
built by the :func:`compile_html_file()` builder,
either directly or in a subfolder somewhere.
You need to clone or create the repository yourself
and set up the remotes as appropriate
before running this step.
Default ``output/public_site``.
:param commit_message: Message to use when committing the newly built wiki.
Note that all changes in the repository will be committed.
Default ``publish checkpoint``.
:param remote: The repository remote to push changes to.
If you don't know what that is,
the default of ``origin`` is probably correct.
:param refspec: The local branch or refspec to push.
Default ``master``.
:param push: If set to False, don't push after committing the changes
(mostly useful for testing purposes).
Default True.
"""
with pushd(output_folder):
if not os.path.isdir(".git"):
stop(f"The output folder {output_folder} isn't a Git repository. "
f"Please go initialize it and then try again.")
git.exec("add", "-A")
rc = git.rc("commit", "-m", commit_message)
if rc == 0:
if push:
git.exec("push", remote, refspec)
elif rc == 1:
info("No changes to commit or publish. "
"You probably rebuilt without changing the wiki in between.")
else:
stop(f"'git commit' returned unknown return code {rc}.")
@tzk_builder
def shell(shell_command: str) -> None:
"""
Run an arbitrary shell command.
The builder will fail if a return code other than 0 is returned,
otherwise it will succeed. Output will be printed to stdout.
:param shell_command: A string to be passed to your system's shell.
"""
info("$ " + shell_command)
try:
output = subprocess.check_output(shell_command, shell=True, text=True)
except subprocess.CalledProcessError as e:
if e.output.strip():
stop(f"Command exited with return code {e.returncode}:\n{e.output}")
else:
stop(f"Command exited with return code {e.returncode} (no output).")
else:
if output.strip():
info(f"Command exited with return code 0:\n{output}")
else:
info(f"Command exited with return code 0 (no output).")
@tzk_builder
def editionify(target_folder: str, description: str) -> None:
"""
Copy the output folder to a target location and set its edition description.
This generates a TiddlyWiki edition based on the temporary output. By
copying it into an appropriate location or setting the environment variable
:envvar:`TIDDLYWIKI_EDITION_PATH` to the parent directory of the edition's folder,
it's possible to quickly generate new TiddlyWikis based on the edition
"template" (``tiddlywiki --init EDITION_NAME``, where the ``EDITION_NAME`` is the
name of the folder).
:param target_folder: The folder to copy the output folder to.
This folder *will be deleted* if it already exists
prior to the copy.
:param description: The description of this edition to use in the new edition's
``tiddlywiki.info`` file.
"""
try:
shutil.rmtree(target_folder)
except FileNotFoundError:
pass
shutil.copytree(
build_state['public_wiki_folder'],
target_folder,
)
def editor(tinfo):
tinfo['description'] = description
return tinfo
alter_tiddlywiki_info(Path(target_folder) / "tiddlywiki.info", editor)
@tzk_builder
def add_plugins(plugins: Sequence[str]) -> None:
"""
Add one or more plugins to the tiddlywiki.info file.
:param plugins: A list of plugin names (e.g., "tiddlywiki/codemirror") to add.
"""
def editor(tinfo):
tinfo['plugins'].extend(plugins)
return tinfo
alter_tiddlywiki_info(Path(build_state['public_wiki_folder']) / "tiddlywiki.info",
editor)