Merge branch 'refactor/optional_user_inputs' into develop

This commit is contained in:
2021-10-30 20:25:14 +01:00
6 changed files with 185 additions and 132 deletions

View File

@@ -1,7 +1,4 @@
Priority:
✔ Document the python/logging/typing in Trilium @done(21-10-25 14:33)
✔ Update typing annotations to include generics instead @done(21-10-25 22:38)
https://docs.python.org/3/library/collections.abc.html#collections-abstract-base-classes
☐ Write the tests
☐ test logs: <https://stackoverflow.com/questions/53125305/testing-logging-output-with-pytest>
document this
@@ -25,17 +22,14 @@ Documentation:
<https://stackoverflow.com/questions/53125305/testing-logging-output-with-pytest>
☐ Document using datadir with a module rather than a shared one. Link to tembo as an example.
☐ Can prospector ignore tests dir? document this in the gist if so
Functionality:
✔ Move any `tembo.CONFIG` calls out of `pages.py` and ensure these are passed in from the cli. @done(21-10-28 19:44)
✔ Make `config scope` a dict in `cli.py`. @done(21-10-28 19:44)
✔ Make example optional @done(21-10-29 00:15)
✔ Add the `--example` output to the miscounted token message so the user knows the correct command to use. @done(21-10-29 00:15)
✔ Page options dataclass @done(21-10-28 20:09)
☐ Replace loggers with `click.echo` for command outputs. Keep logging messages for actual logging messages?
Look at `_convert_to_path()` and see if it can be rewritten to make it clearer when there isn't a base path.
Currently checks to see if base_path is not None but this is never the case as a string must be passed in and if there isn't a base_path we pass in an empty string.
☐ Replace scoped page creator inputs so that the whole class uses the options dict rather than the variables passed around.
Refactor the tembo new command so the cli is split out into manageable methods
☐ Use the complicated CLI example so the tembo new has its own module to define functions in
☐ Replace all logger errors with exceptions, move logger messages to the cli.
✔ Make options a property on the class, add to abstract @done(21-10-30 19:31)
☐ Use the python runner Duty
<https://github.com/pawamoy/duty>
☐ Run tests
@@ -74,6 +68,17 @@ Logging:
clone the repo, delete .git, git init, configure and add git origin
Archive:
✔ Document the python/logging/typing in Trilium @done(21-10-25 14:33) @project(Priority)
✔ Update typing annotations to include generics instead @done(21-10-25 22:38) @project(Priority)
https://docs.python.org/3/library/collections.abc.html#collections-abstract-base-classes
✔ Move any `tembo.CONFIG` calls out of `pages.py` and ensure these are passed in from the cli. @done(21-10-28 19:44) @project(Functionality)
✔ Make `config scope` a dict in `cli.py`. @done(21-10-28 19:44) @project(Functionality)
✔ Make example optional @done(21-10-29 00:15) @project(Functionality)
✔ Add the `--example` output to the miscounted token message so the user knows the correct command to use. @done(21-10-29 00:15) @project(Functionality)
✔ Page options dataclass @done(21-10-28 20:09) @project(Functionality)
✔ Make user_input optional @important @done(21-10-30 03:20) @project(Functionality)
✔ Look at `_convert_to_path()` and see if it can be rewritten to make it clearer when there isn't a base path. @done(21-10-30 02:14) @project(Functionality)
✔ Replace scoped page creator inputs so that the whole class uses the options dict rather than the variables passed around. @done(21-10-30 03:20) @project(Functionality)
✔ Go through code TODOs @done(21-10-25 05:52) @project(Priority)
✔ Check code order and make sure things are where they should be @done(21-10-25 13:31) @project(Priority)
✔ Add version option @done(21-10-25 13:40) @project(Functionality)

View File

@@ -1,9 +1,10 @@
# testing notes
optional:
- template_path
- user_input
- example
- template_filename
- template_path
required:
- base_path
@@ -11,20 +12,46 @@ required:
- filename
- extension
- name
- user_input - should be optional
- page with a template
- page without a template
- page using date tokens
- page using input tokens
- page using name tokens
- page with/without a template
- user input is None
- the given base path does not exist
- page using/not using input tokens
- user input does not match number of input tokens
- no user input
- mismatched user input
- with/without example
- page using/not using date tokens
- page using/not using name tokens
- path/page filenames can contain spaces and they are converted
@dataclass
class PageCreatorOptions:
"""Options dataclass to create a Page.
- user input does match number of input tokensE
Attributes:
base_path (str):
page_path (str):
filename (str):
extension (str):
name (str):
user_input (Collection[str] | None, optional):
example (str | None, optional):
template_filename (str | None, optional):
template_path (str | None, optional):
"""
base_path: str
page_path: str
filename: str
extension: str
name: str
user_input: Collection[str] | None = None
example: str | None = None
template_filename: str | None = None
template_path: str | None = None

View File

@@ -3,3 +3,7 @@
class MismatchedTokenError(Exception):
pass
class BasePathDoesNotExistError(Exception):
pass

View File

@@ -13,69 +13,90 @@ from jinja2.exceptions import TemplateNotFound
import tembo
# TODO: flesh this out with details for the optional args
@dataclass
class PageCreatorOptions:
"""Options dataclass to create a Page.
Attributes:
base_path (str):
page_path (str):
filename (str):
extension (str):
name (str):
user_input (Collection[str] | None, optional):
example (str | None, optional):
template_filename (str | None, optional):
template_path (str | None, optional):
"""
base_path: str
page_path: str
filename: str
extension: str
name: str
user_input: Collection[str]
user_input: Collection[str] | None = None
example: str | None = None
template_filename: str | None = None
template_path: str | None = None
class PageCreator:
def __init__(self, options: PageCreatorOptions) -> None:
raise NotImplementedError
@property
@abstractmethod
def options(self) -> PageCreatorOptions:
raise NotImplementedError
@abstractmethod
def create_page(self, options: PageCreatorOptions) -> Page:
raise NotImplementedError
@staticmethod
def _convert_to_path(
base_path: str, page_path: str, filename: str, extension: str
) -> pathlib.Path:
def _convert_base_path_to_path(self) -> pathlib.Path:
# check if Tembo base path exists
if not pathlib.Path(base_path).expanduser().exists():
if not pathlib.Path(self.options.base_path).expanduser().exists():
tembo.logger.critical(
"Tembo base path of %s does not exist - exiting", base_path
"Tembo base path of %s does not exist - exiting", self.options.base_path
)
raise SystemExit(1)
path_to_file = (
pathlib.Path(base_path).expanduser()
/ pathlib.Path(page_path.replace(" ", "_")).expanduser()
/ filename.replace(" ", "_")
pathlib.Path(self.options.base_path).expanduser()
/ pathlib.Path(self.options.page_path.replace(" ", "_")).expanduser()
/ self.options.filename.replace(" ", "_")
)
try:
# check for existing `.` in the extension
extension = extension[1:] if extension[0] == "." else extension
extension = (
self.options.extension[1:]
if self.options.extension[0] == "."
else self.options.extension
)
except IndexError:
# IndexError means the path is not a file, just a path
return path_to_file
# return path with a file
return path_to_file.with_suffix(f".{extension}")
def _load_template(
self, base_path: str, template_filename: str, template_path: str | None
) -> str:
# check for overriden template_path
if template_path is not None:
converted_template_path = pathlib.Path(template_path).expanduser()
def _load_template(self) -> str:
if self.options.template_filename is None:
return ""
if self.options.template_path is not None:
converted_template_path = pathlib.Path(
self.options.template_path
).expanduser()
else:
# default template_path is base_path / .templates
converted_template_path = self._convert_to_path(
base_path, ".templates", "", ""
)
# load the template folder
converted_template_path = pathlib.Path()
file_loader = jinja2.FileSystemLoader(converted_template_path)
env = jinja2.Environment(loader=file_loader, autoescape=True)
# load the template contents
try:
loaded_template = env.get_template(template_filename)
loaded_template = env.get_template(self.options.template_filename)
except TemplateNotFound as template_not_found:
tembo.logger.critical(
"Template file %s not found - exiting",
str(template_path) + "/" + str(template_not_found.message),
str(self.options.template_path) + "/" + str(template_not_found.message),
)
raise SystemExit(1) from template_not_found
return loaded_template.render()
@@ -92,124 +113,102 @@ class ScopedPageCreator(PageCreator):
"""
def __init__(self) -> None:
self.base_path = ""
self.page_path = ""
self.filename = ""
self.extension = ""
self._all_input_tokens: list[str] = []
self._options: PageCreatorOptions
@property
def options(self) -> PageCreatorOptions:
return self._options
def create_page(self, options: PageCreatorOptions) -> Page:
self.base_path = options.base_path
self.page_path = options.page_path
self.filename = options.filename
self.extension = options.extension
self._options = options
# verify the user input length matches the number of input tokens in the
# tembo config/templates
self._all_input_tokens = self._get_input_tokens(
options.template_filename, options.template_path
)
self._verify_input_tokens(options.user_input, options.example)
self._all_input_tokens = self._get_input_tokens()
self._verify_input_tokens()
# get the path of the scoped page
path = self._convert_to_path(
self.base_path, self.page_path, self.filename, self.extension
)
path = self._convert_base_path_to_path()
path = pathlib.Path(self._substitute_tokens(str(path)))
# substitute tokens in the filepath
path = pathlib.Path(
self._substitute_tokens(str(path), options.user_input, options.name)
)
# get the template file
template_contents = self._get_template_contents(
options.template_filename, options.template_path
)
# substitute tokens in template_contents
template_contents = self._load_template()
if options.template_filename is not None:
template_contents = self._substitute_tokens(
template_contents, options.user_input, options.name
)
template_contents = self._substitute_tokens(template_contents)
return ScopedPage(path, template_contents)
def _get_template_contents(
self, template_filename: str | None, template_path: str | None
) -> str:
return (
self._load_template(self.base_path, template_filename, template_path)
if template_filename is not None
else ""
)
def _get_input_tokens(
self, template_filename: str | None, template_path: str | None
) -> list[str]:
def _get_input_tokens(self) -> list[str]:
path = str(
pathlib.Path(
self.base_path, self.page_path, self.filename, self.extension
self.options.base_path,
self.options.page_path,
self.options.filename,
self.options.extension,
).expanduser()
)
template_contents = self._get_template_contents(
template_filename, template_path
)
template_contents = self._load_template()
# get the input tokens from both the path and the template
all_input_tokens = []
for tokenified_string in (path, template_contents):
all_input_tokens.extend(re.findall(r"(\{input\d*\})", tokenified_string))
return sorted(all_input_tokens)
def _verify_input_tokens(
self, user_input: Collection[str], example: str | None
) -> None:
if len(self._all_input_tokens) != len(user_input):
if example is not None:
def _verify_input_tokens(self) -> None:
if len(self._all_input_tokens) > 0 and self.options.user_input is None:
if self.options.example is not None:
tembo.logger.critical(
"Your tembo.config/template specifies %s input tokens, you gave 0. Example command: %s",
len(self._all_input_tokens),
self.options.example,
)
else:
tembo.logger.critical(
"Your tembo.config/template specifies %s input tokens, you gave 0.",
len(self._all_input_tokens),
)
raise SystemExit(1)
if self.options.user_input is None:
return
if len(self._all_input_tokens) != len(self.options.user_input):
if self.options.example is not None:
tembo.logger.critical(
"Your tembo.config/template specifies %s input tokens, you gave %s. Example command: %s",
len(self._all_input_tokens),
len(user_input),
example,
len(self.options.user_input),
self.options.example,
)
else:
tembo.logger.critical(
"Your tembo.config/template specifies %s input tokens, you gave %s.",
len(self._all_input_tokens),
len(user_input),
len(self.options.user_input),
)
raise SystemExit(1)
return
def _substitute_tokens(
self,
tokenified_string: str,
user_input: Collection[str],
name: str,
) -> str:
def _substitute_tokens(self, tokenified_string: str) -> str:
"""For a tokened string, substitute input, name and date tokens."""
tokenified_string = self.__substitute_input_tokens(
tokenified_string, user_input
)
tokenified_string = self.__substitute_name_tokens(tokenified_string, name)
tokenified_string = self.__substitute_input_tokens(tokenified_string)
tokenified_string = self.__substitute_name_tokens(tokenified_string)
tokenified_string = self.__substitute_date_tokens(tokenified_string)
return tokenified_string
def __substitute_input_tokens(
self,
tokenified_string: str,
user_input: Collection[str],
) -> str:
for input_value, extracted_token in zip(user_input, self._all_input_tokens):
# REVIEW: test this for spaces in the filename/input token
tokenified_string = tokenified_string.replace(
extracted_token, input_value.replace(" ", "_")
)
def __substitute_input_tokens(self, tokenified_string: str) -> str:
if self.options.user_input is not None:
for input_value, extracted_token in zip(
self.options.user_input, self._all_input_tokens
):
tokenified_string = tokenified_string.replace(
extracted_token, input_value.replace(" ", "_")
)
return tokenified_string
@staticmethod
def __substitute_name_tokens(tokenified_string: str, name: str) -> str:
def __substitute_name_tokens(self, tokenified_string: str) -> str:
"""Find any `{name}` tokens and substitute for the name value."""
name_extraction = re.findall(r"(\{name\})", tokenified_string)
for extracted_input in name_extraction:
tokenified_string = tokenified_string.replace(extracted_input, name)
tokenified_string = tokenified_string.replace(
extracted_input, self.options.name
)
return tokenified_string
@staticmethod

View File

@@ -15,7 +15,7 @@ def test_page_creator_convert_to_path_missing_base_path(caplog):
# act
with pytest.raises(SystemExit) as system_exit:
PageCreator._convert_to_path(
PageCreator._convert_base_path_to_path(
base_path=base_path,
page_path=page_path,
filename=filename,
@@ -50,7 +50,7 @@ def test_page_creator_convert_to_path_full_path_to_file(
base_path = tmpdir
# act
converted_path = PageCreator._convert_to_path(
converted_path = PageCreator._convert_base_path_to_path(
base_path, page_path, filename, extension
)
@@ -67,7 +67,7 @@ def test_page_creator_convert_to_path_full_path_no_file(tmpdir):
extension = ""
# act
converted_path = PageCreator._convert_to_path(
converted_path = PageCreator._convert_base_path_to_path(
base_path, page_path, filename, extension
)

View File

@@ -1,13 +1,31 @@
import pytest
from tembo.journal.pages import PageCreatorOptions
from tembo.journal.pages import PageCreatorOptions, ScopedPageCreator
def test_scoped_page_creator_create_page_missing_base_path():
def test_create_page_base_path_does_not_exist(tmpdir, caplog):
# arrange
options = PageCreatorOptions()
base_path = str(tmpdir / "nonexistent" / "path")
options = PageCreatorOptions(
base_path=base_path,
page_path="",
filename="",
extension="",
name="",
user_input=None,
example=None,
template_filename=None,
template_path=None,
)
# act
with pytest.raises(SystemExit) as system_exit:
scoped_page_creator = ScopedPageCreator().create_page(options)
# assert
pass
assert system_exit.value.code == 1
assert (
caplog.records[0].message
== f"Tembo base path of {base_path} does not exist - exiting"
)
assert caplog.records[0].levelname == "CRITICAL"