from __future__ import annotations from abc import ABCMeta, abstractmethod from dataclasses import dataclass import pathlib import re from typing import Collection import pendulum import jinja2 from jinja2.exceptions import TemplateNotFound import tembo from tembo import exceptions # 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] | 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 def _convert_base_path_to_path(self) -> pathlib.Path: # check if Tembo base path exists if not pathlib.Path(self.options.base_path).expanduser().exists(): raise exceptions.BasePathDoesNotExistError( f"Tembo base path of {self.options.base_path} does not exist." ) path_to_file = ( 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 = ( self.options.extension[1:] if self.options.extension[0] == "." else self.options.extension ) except IndexError: # REVIEW: try putting a . in the config yaml and see what error is raised # this is no longer generic it just gets the full path to the file. # 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) -> 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: converted_template_path = ( pathlib.Path(self.options.base_path).expanduser() / ".templates" ) file_loader = jinja2.FileSystemLoader(converted_template_path) env = jinja2.Environment(loader=file_loader, autoescape=True) try: loaded_template = env.get_template(self.options.template_filename) except TemplateNotFound as template_not_found: _template_file = f"{converted_template_path}/{template_not_found.args[0]}" raise exceptions.TemplateFileNotFoundError( f"Template file {_template_file} does not exist." ) from template_not_found return loaded_template.render() class ScopedPageCreator(PageCreator): """Factory to create a scoped page. Attributes: base_path (str): base path of tembo. page_path (str): path of the page relative to the base path. filename (str): filename relative to the page path. extension (str): extension of file. """ def __init__(self) -> None: 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._options = options self._all_input_tokens = self._get_input_tokens() self._verify_input_tokens() path = self._convert_base_path_to_path() path = pathlib.Path(self._substitute_tokens(str(path))) template_contents = self._load_template() if options.template_filename is not None: template_contents = self._substitute_tokens(template_contents) return ScopedPage(path, template_contents) def _get_input_tokens(self) -> list[str]: path = str( pathlib.Path( self.options.base_path, self.options.page_path, self.options.filename, self.options.extension, ).expanduser() ) 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) -> 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(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(self.options.user_input), ) raise SystemExit(1) return 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) 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) -> 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 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, self.options.name ) return tokenified_string @staticmethod def __substitute_date_tokens(tokenified_string: str) -> str: """Find any {d:%d-%M-%Y} tokens.""" # extract the full token string date_extraction_token = re.findall(r"(\{d\:[^}]*\})", tokenified_string) for extracted_token in date_extraction_token: # extract the inner %d-%M-%Y only strftime_value = re.match(r"\{d\:([^\}]*)\}", extracted_token) if strftime_value is not None: strftime_value = strftime_value.group(1) if isinstance(strftime_value, str): tokenified_string = tokenified_string.replace( extracted_token, pendulum.now().strftime(strftime_value) ) return tokenified_string class Page(metaclass=ABCMeta): @abstractmethod def __init__(self, path: pathlib.Path, page_content: str) -> None: raise NotImplementedError @abstractmethod def save_to_disk(self, dry_run: bool) -> None: raise NotImplementedError class ScopedPage(Page): """A page that uses substitute tokens. Attributes: path (pathlib.Path): a `Path` object of the page's filepath. page_content (str): the content of the page from the template. """ def __init__(self, path: pathlib.Path, page_content: str) -> None: """Create a scoped page object. Args: path (pathlib.Path): a `pathlib.Path` object of the page's filepath. page_content (str): the content of the page from the template. """ self.path = path self.page_content = page_content def __str__(self) -> str: return f"ScopedPage({self.path})" def save_to_disk(self, dry_run: bool = False) -> None: """Save the scoped page to disk and write the `page_content`. If the page already exists a message will be logged to stdout and no file will be saved. If `dry_run=True` a message will be logged to stdout and no file will be saved. Args: dry_run (bool, optional): If `True` will log the `path` to stdout and not save the page to disk. Defaults to False. Raises: SystemExit: Exit code 0 if dry run is `True`, page is successfully saved or if page already exists. """ # TODO: move this functionality to the CLI so the page is created and the message # returned to the user from the CLI. if dry_run: tembo.logger.info("%s will be created", self.path) raise SystemExit(0) # create the parent directories scoped_note_file = pathlib.Path(self.path) scoped_note_file.parents[0].mkdir(parents=True, exist_ok=True) if scoped_note_file.exists(): raise exceptions.ScopedPageAlreadyExists(f"{self.path} already exists") with scoped_note_file.open("w", encoding="utf-8") as scoped_page: scoped_page.write(self.page_content) # TODO: pass this back somehow tembo.logger.info("Saved %s to disk", self.path) if __name__ == "__main__": c = ScopedPageCreator() # # raises error # # print(c._substitute_tokens("scratchpad/{input0}-{d:DD-MM-YYYY}-{d:dddd}-{d:A}-file.md", None)) # print( # c._substitute_tokens( # "scratchpad/{d:A}/{d:DD-MM-YYYY}-{d:dddd}-{d:A}-file-{input0}.md", ("last",) # ) # ) # print( # c.create_page( # "~/tembo", # "{name}", # "{input0}-{input1}-file", # "md", # "scratchpad", # ("first", "second"), # ) # ) # print( # c.create_page( # "~/tembo", # "{name}/{d:MMMM-YY}", # "{input0}-{d:DD-MM-YYYY}-{d:dddd}-{d:A}-file", # "md", # "scratchpad", # ("first",), # ) # ) # print( # c.create_page( # "~/tembo", # "{name}/{d:DD-MM-YYYY}-{d:dddd}-{d:A}", # "file", # "md", # "scratchpad", # None, # ) # ) # print( # c.create_page( # "~/tembo", # "{name}/{d:A}/{d:DD-MM-YYYY}-{d:dddd}-{d:A}", # "file-{input0}-{name}", # ".md", # "meeting", # ("last",), # "scratchpad.md.tpl", # ) # ) # test_page_with_template = c.create_page( # "~/tembo", # "{name}/{d:A}/{d:DD-MM-YYYY}-{d:dddd}-{d:A}", # "file-{input0}-{name}", # ".md", # "meeting", # ("last",), # "scratchpad.md.tpl", # ) # print(test_page_with_template) # test_page_with_template.save_to_disk(False) # print( # c.create_page( # "~/tembo", # "{name}/{d:A}/{d:DD-MM-YYYY}-{d:dddd}-{d:A}", # "file-{input0}-{name}", # ".md", # "meeting", # ("last",), # "scratchpad_templates/scratchpad.md.tpl", # ) # )