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: 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 ☐ Write the tests
☐ test logs: <https://stackoverflow.com/questions/53125305/testing-logging-output-with-pytest> ☐ test logs: <https://stackoverflow.com/questions/53125305/testing-logging-output-with-pytest>
document this document this
@@ -25,17 +22,14 @@ Documentation:
<https://stackoverflow.com/questions/53125305/testing-logging-output-with-pytest> <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. ☐ 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: 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? ☐ 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. Refactor the tembo new command so the cli is split out into manageable methods
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. ☐ Use the complicated CLI example so the tembo new has its own module to define functions in
☐ Replace scoped page creator inputs so that the whole class uses the options dict rather than the variables passed around. ☐ 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 ☐ Use the python runner Duty
<https://github.com/pawamoy/duty> <https://github.com/pawamoy/duty>
☐ Run tests ☐ Run tests
@@ -74,6 +68,17 @@ Logging:
clone the repo, delete .git, git init, configure and add git origin clone the repo, delete .git, git init, configure and add git origin
Archive: 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) ✔ 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) ✔ 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) ✔ Add version option @done(21-10-25 13:40) @project(Functionality)

View File

@@ -1,9 +1,10 @@
# testing notes # testing notes
optional: optional:
- template_path - user_input
- example - example
- template_filename - template_filename
- template_path
required: required:
- base_path - base_path
@@ -11,20 +12,46 @@ required:
- filename - filename
- extension - extension
- name - 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 - 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): class MismatchedTokenError(Exception):
pass pass
class BasePathDoesNotExistError(Exception):
pass

View File

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

View File

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

View File

@@ -1,13 +1,31 @@
import pytest 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 # 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 # act
with pytest.raises(SystemExit) as system_exit:
scoped_page_creator = ScopedPageCreator().create_page(options)
# assert # 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"