feat: initial commit

This commit is contained in:
2021-11-21 13:58:52 +00:00
commit fb7fec7ea6
53 changed files with 4920 additions and 0 deletions

10
tembo/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""
Tembo package.
A simple folder organiser for your work notes.
"""
# flake8: noqa
from . import exceptions
from .journal.pages import PageCreatorOptions, ScopedPageCreator

12
tembo/__main__.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Entrypoint module.
Used when using `python -m tembo` to invoke the CLI.
"""
import sys
from tembo.cli.cli import main
if __name__ == "__main__":
sys.exit(main())

3
tembo/_version.py Normal file
View File

@@ -0,0 +1,3 @@
"""Module containing the version of tembo."""
__version__ = "0.0.8"

31
tembo/cli/__init__.py Normal file
View File

@@ -0,0 +1,31 @@
"""Subpackage that contains the CLI application."""
import os
from typing import Any
import panaetius
from panaetius.exceptions import LoggingDirectoryDoesNotExistException
if (config_path := os.environ.get("TEMBO_CONFIG")) is not None:
CONFIG: Any = panaetius.Config("tembo", config_path, skip_header_init=True)
else:
CONFIG = panaetius.Config("tembo", "~/tembo/.config", skip_header_init=True)
panaetius.set_config(CONFIG, "base_path", "~/tembo")
panaetius.set_config(CONFIG, "template_path", "~/tembo/.templates")
panaetius.set_config(CONFIG, "scopes", {})
panaetius.set_config(CONFIG, "logging.level", "DEBUG")
panaetius.set_config(CONFIG, "logging.path")
try:
logger = panaetius.set_logger(
CONFIG, panaetius.SimpleLogger(logging_level=CONFIG.logging_level)
)
except LoggingDirectoryDoesNotExistException:
_LOGGING_PATH = CONFIG.logging_path
CONFIG.logging_path = ""
logger = panaetius.set_logger(
CONFIG, panaetius.SimpleLogger(logging_level=CONFIG.logging_level)
)
logger.warning("Logging directory %s does not exist", _LOGGING_PATH)

210
tembo/cli/cli.py Normal file
View File

@@ -0,0 +1,210 @@
"""Submodule which contains the CLI implementation using Click."""
from __future__ import annotations
import pathlib
from typing import Collection
import click
import tembo.cli
from tembo import exceptions
from tembo._version import __version__
from tembo.journal import pages
from tembo.utils import Success
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CONTEXT_SETTINGS, options_metavar="<options>")
@click.version_option(
__version__,
"-v",
"--version",
prog_name="Tembo",
message=f"Tembo v{__version__} 🐘",
)
def main():
"""Tembo - an organiser for work notes."""
@click.command(options_metavar="<options>", name="list")
def list_all():
"""List all scopes defined in the config.yml."""
_all_scopes = [user_scope["name"] for user_scope in tembo.cli.CONFIG.scopes]
_all_scopes_joined = "', '".join(_all_scopes)
cli_message(f"{len(_all_scopes)} names found in config.yml: '{_all_scopes_joined}'")
raise SystemExit(0)
@click.command(options_metavar="<options>")
@click.argument("scope", metavar="<scope>")
@click.argument(
"inputs",
nargs=-1,
metavar="<inputs>",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="Show the full path of the page to be created without actually saving the page to disk "
"and exit.",
)
@click.option(
"--example",
is_flag=True,
default=False,
help="Show the example command in the config.yml if it exists and exit.",
)
def new(scope: str, inputs: Collection[str], dry_run: bool, example: bool): # noqa
"""
Create a new page.
\b
`<scope>` The name of the scope in the config.yml.
\b
`<inputs>` Any input token values that are defined in the config.yml for this scope.
Accepts multiple inputs separated by a space.
\b
Example:
`tembo new meeting my_presentation`
"""
# check that the name exists in the config.yml
try:
_new_verify_name_exists(scope)
except (
exceptions.ScopeNotFound,
exceptions.EmptyConfigYML,
exceptions.MissingConfigYML,
) as tembo_exception:
cli_message(tembo_exception.args[0])
raise SystemExit(1) from tembo_exception
# get the scope configuration from the config.yml
try:
config_scope = _new_get_config_scope(scope)
except exceptions.MandatoryKeyNotFound as mandatory_key_not_found:
cli_message(mandatory_key_not_found.args[0])
raise SystemExit(1) from mandatory_key_not_found
# if --example flag, return the example to the user
_new_show_example(example, config_scope)
# if the name is in the config.yml, create the scoped page
scoped_page = _new_create_scoped_page(config_scope, inputs)
if dry_run:
cli_message(f"{scoped_page.path} will be created")
raise SystemExit(0)
try:
result = scoped_page.save_to_disk()
if isinstance(result, Success):
cli_message(f"Saved {result.message} to disk")
raise SystemExit(0)
except exceptions.ScopedPageAlreadyExists as scoped_page_already_exists:
cli_message(f"File {scoped_page_already_exists}")
raise SystemExit(0) from scoped_page_already_exists
def _new_create_scoped_page(config_scope: dict, inputs: Collection[str]) -> pages.Page:
page_creator_options = pages.PageCreatorOptions(
base_path=tembo.cli.CONFIG.base_path,
template_path=tembo.cli.CONFIG.template_path,
page_path=config_scope["path"],
filename=config_scope["filename"],
extension=config_scope["extension"],
name=config_scope["name"],
example=config_scope["example"],
user_input=inputs,
template_filename=config_scope["template_filename"],
)
try:
return pages.ScopedPageCreator(page_creator_options).create_page()
except exceptions.BasePathDoesNotExistError as base_path_does_not_exist_error:
cli_message(base_path_does_not_exist_error.args[0])
raise SystemExit(1) from base_path_does_not_exist_error
except exceptions.TemplateFileNotFoundError as template_file_not_found_error:
cli_message(template_file_not_found_error.args[0])
raise SystemExit(1) from template_file_not_found_error
except exceptions.MismatchedTokenError as mismatched_token_error:
if config_scope["example"] is not None:
cli_message(
f"Your tembo config.yml/template specifies {mismatched_token_error.expected}"
+ f" input tokens, you gave {mismatched_token_error.given}. "
+ f'Example: {config_scope["example"]}'
)
raise SystemExit(1) from mismatched_token_error
cli_message(
f"Your tembo config.yml/template specifies {mismatched_token_error.expected}"
+ f" input tokens, you gave {mismatched_token_error.given}"
)
raise SystemExit(1) from mismatched_token_error
def _new_verify_name_exists(scope: str) -> None:
_name_found = scope in [user_scope["name"] for user_scope in tembo.cli.CONFIG.scopes]
if _name_found:
return
if len(tembo.cli.CONFIG.scopes) > 0:
# if the name is missing in the config.yml, raise error
raise exceptions.ScopeNotFound(f"Scope {scope} not found in config.yml")
# raise error if no config.yml found
if pathlib.Path(tembo.cli.CONFIG.config_path).exists():
raise exceptions.EmptyConfigYML(
f"Config.yml found in {tembo.cli.CONFIG.config_path} is empty"
)
raise exceptions.MissingConfigYML(f"No config.yml found in {tembo.cli.CONFIG.config_path}")
def _new_get_config_scope(scope: str) -> dict:
config_scope = {}
optional_keys = ["example", "template_filename"]
for option in [
"name",
"path",
"filename",
"extension",
"example",
"template_filename",
]:
try:
config_scope.update(
{
option: str(user_scope[option])
for user_scope in tembo.cli.CONFIG.scopes
if user_scope["name"] == scope
}
)
except KeyError as key_error:
if key_error.args[0] in optional_keys:
config_scope.update({key_error.args[0]: None})
continue
raise exceptions.MandatoryKeyNotFound(f"Key {key_error} not found in config.yml")
return config_scope
def _new_show_example(example: bool, config_scope: dict) -> None:
if example:
if isinstance(config_scope["example"], str):
cli_message(f'Example for {config_scope["name"]}: {config_scope["example"]}')
else:
cli_message("No example in config.yml")
raise SystemExit(0)
def cli_message(message: str) -> None:
"""
Relay a message to the user using the CLI.
Args:
message (str): THe message to be displayed.
"""
click.echo(f"[TEMBO] {message} 🐘")
main.add_command(new)
main.add_command(list_all)

51
tembo/exceptions.py Normal file
View File

@@ -0,0 +1,51 @@
"""Module containing custom exceptions."""
class MismatchedTokenError(Exception):
"""
Raised when the number of input tokens does not match the user config.
Attributes:
expected (int): number of input tokens in the user config.
given (int): number of input tokens passed in.
"""
def __init__(self, expected: int, given: int) -> None:
"""
Initialise the exception.
Args:
expected (int): number of input tokens in the user config.
given (int): number of input tokens passed in.
"""
self.expected = expected
self.given = given
super().__init__()
class BasePathDoesNotExistError(Exception):
"""Raised if the base path does not exist."""
class TemplateFileNotFoundError(Exception):
"""Raised if the template file does not exist."""
class ScopedPageAlreadyExists(Exception):
"""Raised if the scoped page file already exists."""
class MissingConfigYML(Exception):
"""Raised if the config.yml file is missing."""
class EmptyConfigYML(Exception):
"""Raised if the config.yml file is empty."""
class ScopeNotFound(Exception):
"""Raised if the scope does not exist in the config.yml."""
class MandatoryKeyNotFound(Exception):
"""Raised if a mandatory key is not found in the config.yml."""

View File

@@ -0,0 +1,5 @@
"""Subpackage containing the logic to create Tembo journals & pages."""
# flake8: noqa
from tembo.journal import pages

480
tembo/journal/pages.py Normal file
View File

@@ -0,0 +1,480 @@
"""Submodule containing the factories & page objects to create Tembo pages."""
from __future__ import annotations
import pathlib
import re
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
from typing import Collection, Optional
import jinja2
import pendulum
from jinja2.exceptions import TemplateNotFound
import tembo.utils
from tembo import exceptions
@dataclass
class PageCreatorOptions:
"""
Options [dataclass][dataclasses.dataclass] to create a Page.
This is passed to an implemented instance of [PageCreator][tembo.journal.pages.PageCreator]
Attributes:
base_path (str): The base path.
page_path (str): The path of the page relative to the base path.
filename (str): The filename of the page.
extension (str): The extension of the page.
name (str): The name of the scope.
user_input (Collection[str] | None, optional): User input tokens.
example (str | None, optional): User example command.
template_path (str | None, optional): The path which contains the templates. This should
be the full path and not relative to the base path.
template_filename (str | None, optional): The template filename with extension relative
to the template path.
"""
base_path: str
page_path: str
filename: str
extension: str
name: str
user_input: Optional[Collection[str]] = None
example: Optional[str] = None
template_path: Optional[str] = None
template_filename: Optional[str] = None
class PageCreator:
"""
A PageCreator factory base class.
This factory should implement methods to create [Page][tembo.journal.pages.Page] objects.
!!! abstract
This factory is an abstract base class and should be implemented for each
[Page][tembo.journal.pages.Page] type.
The private methods
- `_check_base_path_exists()`
- `_convert_base_path_to_path()`
- `_load_template()`
are not abstract and are shared between all [Page][tembo.journal.pages.Page] types.
"""
@abstractmethod
def __init__(self, options: PageCreatorOptions) -> None:
"""
When implemented this should initialise the `PageCreator` factory.
Args:
options (PageCreatorOptions): An instance of
[PageCreatorOptions][tembo.journal.pages.PageCreatorOptions]
!!! abstract
This method is abstract and should be implemented for each
[Page][tembo.journal.pages.Page] type.
"""
raise NotImplementedError
@property
@abstractmethod
def options(self) -> PageCreatorOptions:
"""
When implemented this should return the `PageCreatorOptions` on the class.
Returns:
PageCreatorOptions: the instance of
[PageCreatorOptions][tembo.journal.pages.PageCreatorOptions] set on the class.
!!! abstract
This method is abstract and should be implemented for each
[Page][tembo.journal.pages.Page] type.
"""
raise NotImplementedError
@abstractmethod
def create_page(self) -> Page:
"""
When implemented this should create a `Page` object.
Returns:
Page: an implemented instance of [Page][tembo.journal.pages.Page] such as
[ScopedPage][tembo.journal.pages.ScopedPage].
!!! abstract
This method is abstract and should be implemented for each
[Page][tembo.journal.pages.Page] type.
"""
raise NotImplementedError
def _check_base_path_exists(self) -> None:
"""
Check that the base path exists.
Raises:
exceptions.BasePathDoesNotExistError: raised if the base path does not exist.
"""
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."
)
def _convert_base_path_to_path(self) -> pathlib.Path:
"""
Convert the `base_path` from a `str` to a `pathlib.Path` object.
Returns:
pathlib.Path: the `base_path` as a `pathlib.Path` object.
"""
path_to_file = (
pathlib.Path(self.options.base_path).expanduser()
/ pathlib.Path(self.options.page_path.replace(" ", "_")).expanduser()
/ self.options.filename.replace(" ", "_")
)
# check for existing `.` in the extension
extension = (
self.options.extension[1:]
if self.options.extension[0] == "."
else self.options.extension
)
# return path with a file
return path_to_file.with_suffix(f".{extension}")
def _load_template(self) -> str:
"""
Load the template file.
Raises:
exceptions.TemplateFileNotFoundError: raised if the template file is specified but
not found.
Returns:
str: the contents of the template file.
"""
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, options: PageCreatorOptions) -> None:
"""
Initialise a `ScopedPageCreator` factory.
Args:
options (PageCreatorOptions): An instance of
[PageCreatorOptions][tembo.journal.pages.PageCreatorOptions].
"""
self._all_input_tokens: list[str] = []
self._options = options
@property
def options(self) -> PageCreatorOptions:
"""
Return the `PageCreatorOptions` instance set on the factory.
Returns:
PageCreatorOptions:
An instance of [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions].
"""
return self._options
def create_page(self) -> Page:
"""
Create a [ScopedPage][tembo.journal.pages.ScopedPage] object.
This method will
- Check the `base_path` exists
- Verify the input tokens match the number defined in the `config.yml`
- Substitue the input tokens in the filepath
- Load the template contents and substitue the input tokens
Raises:
exceptions.MismatchedTokenError: Raises
[MismatchedTokenError][tembo.exceptions.MismatchedTokenError] if the number of
input tokens does not match the number of unique input tokens defined.
exceptions.BasePathDoesNotExistError: Raises
[BasePathDoesNotExistError][tembo.exceptions.BasePathDoesNotExistError] if the
base path does not exist.
exceptions.TemplateFileNotFoundError: Raises
[TemplateFileNotFoundError][tembo.exceptions.TemplateFileNotFoundError] if the
template file is specified but not found.
Returns:
Page: A [ScopedPage][tembo.journal.pages.ScopedPage] object using the
`PageCreatorOptions`.
"""
try:
self._check_base_path_exists()
except exceptions.BasePathDoesNotExistError as base_path_does_not_exist_error:
raise base_path_does_not_exist_error
self._all_input_tokens = self._get_input_tokens()
try:
self._verify_input_tokens()
except exceptions.MismatchedTokenError as mismatched_token_error:
raise mismatched_token_error
path = self._convert_base_path_to_path()
path = pathlib.Path(self._substitute_tokens(str(path)))
try:
template_contents = self._load_template()
except exceptions.TemplateFileNotFoundError as template_not_found_error:
raise template_not_found_error
if self.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]:
"""Get the input tokens from the path & user template."""
path = str(
pathlib.Path(
self.options.base_path,
self.options.page_path,
self.options.filename,
)
.expanduser()
.with_suffix(f".{self.options.extension}")
)
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(list(set(all_input_tokens)))
def _verify_input_tokens(self) -> None:
"""
Verify the input tokens.
The number of input tokens should match the number of unique input tokens defined in the
path and the user's template.
Raises:
exceptions.MismatchedTokenError: Raises
[MismatchedTokenError][tembo.exceptions.MismatchedTokenError] if the number of
input tokens does not match the number of unique input tokens defined.
"""
if len(self._all_input_tokens) > 0 and self.options.user_input is None:
raise exceptions.MismatchedTokenError(expected=len(self._all_input_tokens), given=0)
if self.options.user_input is None:
return
if len(self._all_input_tokens) != len(self.options.user_input):
raise exceptions.MismatchedTokenError(
expected=len(self._all_input_tokens),
given=len(self.options.user_input),
)
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:
"""
Substitue the input tokens in a `str` with the user input.
Args:
tokenified_string (str): a string with input tokens.
Returns:
str: the string with the input tokens replaced by the user input.
Examples:
A `user_input` of `("monthly_meeting",)` with a `tokenified_string` of
`/meetings/{input0}/` results in a string of `/meetings/monthly_meeting/`
"""
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 in a `str`."""
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 in a `str`."""
# 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):
"""
Abstract Page class.
This interface is used to define a `Page` object.
A `Page` represents a note/page that will be saved to disk.
!!! abstract
This object is an abstract base class and should be implemented for each `Page` type.
"""
@abstractmethod
def __init__(self, path: pathlib.Path, page_content: str) -> None:
"""
When implemented this should initalise a Page object.
Args:
path (pathlib.Path): the full path of the page including the filename as a
[Path][pathlib.Path].
page_content (str): the contents of the page.
!!! abstract
This method is abstract and should be implemented for each `Page` type.
"""
raise NotImplementedError
@property
@abstractmethod
def path(self) -> pathlib.Path:
"""
When implemented this should return the full path of the page including the filename.
Returns:
pathlib.Path: the path as a [Path][pathlib.Path] object.
!!! abstract
This property is abstract and should be implemented for each `Page` type.
"""
raise NotImplementedError
@abstractmethod
def save_to_disk(self) -> tembo.utils.Success:
"""
When implemented this should save the page to disk.
Returns:
tembo.utils.Success: A Tembo [Success][tembo.utils.__init__.Success] object.
!!! abstract
This method is abstract and should be implemented for each `Page` type.
"""
raise NotImplementedError
class ScopedPage(Page):
"""
A page that uses substitute tokens.
Attributes:
path (pathlib.Path): a [Path][pathlib.Path] object of the page's filepath including the
filename.
page_content (str): the content of the page from the template.
"""
def __init__(self, path: pathlib.Path, page_content: str) -> None:
"""
Initalise a scoped page object.
Args:
path (pathlib.Path): a [Path][pathlib.Path] object of the page's filepath including
the filename.
page_content (str): the content of the page from the template.
"""
self._path = path
self.page_content = page_content
def __str__(self) -> str:
"""
Return a `str` representation of a `ScopedPage`.
Examples:
```
>>> str(ScopedPage(Path("/home/bob/tembo/meetings/my_meeting_0.md"), ""))
ScopedPage("/home/bob/tembo/meetings/my_meeting_0.md")
```
Returns:
str: The `ScopedPage` as a `str`.
"""
return f'ScopedPage("{self.path}")'
@property
def path(self) -> pathlib.Path:
"""
Return the full path of the page.
Returns:
pathlib.path: The full path of the page as a [Path][pathlib.Path] object.
"""
return self._path
def save_to_disk(self) -> tembo.utils.Success:
"""
Save the scoped page to disk and write the `page_content`.
Raises:
exceptions.ScopedPageAlreadyExists: If the page already exists a
[ScopedPageAlreadyExists][tembo.exceptions.ScopedPageAlreadyExists] exception
is raised.
Returns:
tembo.utils.Success: A [Success][tembo.utils.__init__.Success] with the path of the
ScopedPage as the message.
"""
# create the parent directories
scoped_page_file = pathlib.Path(self.path)
scoped_page_file.parents[0].mkdir(parents=True, exist_ok=True)
if scoped_page_file.exists():
raise exceptions.ScopedPageAlreadyExists(f"{self.path} already exists")
with scoped_page_file.open("w", encoding="utf-8") as scoped_page:
scoped_page.write(self.page_content)
return tembo.utils.Success(str(self.path))

18
tembo/utils/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""Subpackage containing utility objects."""
from dataclasses import dataclass
@dataclass
class Success:
"""
A Tembo success object.
This is returned from [Page][tembo.journal.pages.ScopedPage] methods such as
[save_to_disk()][tembo.journal.pages.ScopedPage.save_to_disk]
Attributes:
message (str): A success message.
"""
message: str