Merge branch 'develop' into main

This commit is contained in:
2021-11-07 15:05:49 +00:00
35 changed files with 3142 additions and 8 deletions

BIN
.DS_Store vendored

Binary file not shown.

7
.coveragerc Normal file
View File

@@ -0,0 +1,7 @@
[report]
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover
# Don't complain if tests don't hit defensive assertion code:
raise NotImplementedError

142
.gitignore vendored Normal file
View File

@@ -0,0 +1,142 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# custom
.vscode/
**/__pycache__

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.8.11

View File

@@ -1,3 +1,5 @@
# Tembo # Tembo
<center><img src="./assets/tembo_logo.png" width="200px"></center> <center><img src="./assets/tembo_logo.png" width="200px"></center>
A simple folder organiser for your work notes.

147
TODO.todo Normal file
View File

@@ -0,0 +1,147 @@
Priority:
☐ Write the tests
☐ test logs: <https://stackoverflow.com/questions/53125305/testing-logging-output-with-pytest>
document this
☐ Docstrings
Documentation:
Docstrings:
☐ Use Duty to write module docstrings
☐ Use Duty to add Class docstrings
☐ Document these in Trilium and rewrite the docstrings notes
☐ Add the comment on Reddit (artie buco?) about imports in a module
☐ Document using `__main__.py` and `cli.py`
Use Duty as an example
☐ Document regex usage
☐ Write documentation using `mkdocs`
☐ Create a boilerplate `duties.py` for common tasks for future projects. Put in a gist.
☐ Look at how to use github actions
Use <https://github.com/pdm-project/pdm/tree/main/.github/workflows> for an example
☐ Build the docs using a github action.
☐ Document how to use pytest to read a logging message
<https://stackoverflow.com/questions/53125305/testing-logging-output-with-pytest>
- caplog as fixture
- reading `caplog.records[0].message`
see `_old_test_pages.py`
☐ Document testing value of an exception raised
When you use `with pytest.raises` you can use `.value` to access the attributes
reading `.value.code`
reading `str(.value)`
☐ Document working with exceptions
☐ General pattern - raise exceptions in codebase, catch them in the CLI.
Allows people to use via an API and handle the exceptions themselves.
You can use python builtins but custom exceptions are better for internal control
☐ Capturing exceptions in the CLI.
Access the message of the exception with `.args[0]`.
use `raise SystemExit(1) from exception` in order to gracefully exit
☐ Adding custom args to an exception
Overwrite `__init__`, access them in pytest with `.value.$args`
Access them in a try,except with `raise $excpetion as $name; $name.$arg`
☐ Document capturing stdout
Use `capsys`
`assert capsys.readouterr().out`
A new line may be inserted if using `click.echo()`
<https://docs.pytest.org/en/6.2.x/capture.html>
☐ 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
☐ Redo the documentation on a CLI, reorganise and inocropoate all the new tembo layouts
Testing:
☐ Document importing in inidivudal tests using `importlib.reload`
Globally import the module
Use `importlib.reload(module)` in each test instead of explicitly importing the module.
This is because the import is cached.
<https://stackoverflow.com/questions/32234156/how-to-unimport-a-python-module-which-is-already-imported>
Functionality:
☐ Replace loggers with `click.echo` for command outputs. Keep logging messages for actual logging messages?
Define a format: [TEMBO:$datetime] $message 🐘 - document this in general python for CLI
☐ 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.
☐ How to pass a successful save notification back to the CLI? Return a bool? Or is there some other way?
☐ Replace pendulum with datetime
✔ 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
☐ Update poetry
☐ Build docs
☐ Document using Duty
☐ Duty for auto insert version from `poetry version`.
Need to decide what file to place `__version__` in.
Logging:
☐ Make all internal tembo logs be debug
☐ User can enable them with the config
VSCode:
PyInstaller:
☐ Document build error: <https://github.com/pyenv/pyenv/issues/1095>
PYTHON_CONFIGURE_OPTS="--enable-framework" pyenv install 3.8.11 mac
PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install 3.8.11 linux
☐ Freeze a click app: <https://stackoverflow.com/questions/45090083/freeze-a-program-created-with-pythons-click-pacage>
☐ If python 3.9 can be used with Pyinstaller, rewrite the code to use the latest Python features
dict.update -> |=
walrus :=
Tests:
☐ Write tests! @2d
Use coverage as going along to make sure all bases are covered in the testing
VSCode:
☐ Look at <https://github.com/CodeWithSwastik/vscode-ext>
Logging:
Documentation:
Tembo:
☐ Document creating new Tembo config
☐ ~/tembo needs creating
☐ ~/tembo/.config
☐ ~/tembo/.templates
☐ ~/tembo/logs
☐ Document how to overwrite these with ENV vars
☐ have a git repo with all the above already configured and walk user through
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)
✘ Document usage of Panaetius in a module @cancelled(21-10-25 13:31) @project(Logging.Documentation)
✘ Uses `strftime` tokens: <https://strftime.org> @cancelled(21-10-25 13:32) @project(Logging.Documentation)
✔ Document latest typing. @done(21-10-25 14:14) @project(Logging.Documentation)
✔ Using from `__future__` with `|` @done(21-10-25 13:48) @project(Logging.Documentation)
✔ `using Tuple[str, ...]` @done(21-10-25 13:49) @project(Logging.Documentation)
✔ `Sequence` vs `Collection` @done(21-10-25 13:55) @project(Logging.Documentation)
✔ Document how to do docstrings in python. Don't document `__init__` do it in class. @done(21-10-25 13:57) @project(Logging.Documentation)
✔ Document using jinja2 briefly and link to Tembo (link to <https://zetcode.com/python/jinja/>) @done(21-10-25 14:21) @project(Logging.Documentation)
✔ How to raise + debug an exception? @done(21-10-25 14:32) @project(Logging.Documentation.Logging)
✔ Document how to raise a logger.critical instead of exception @done(21-10-25 14:32) @project(Logging.Documentation.Logging)
✔ tokens @done(21-10-25 05:35) @project(Bug)
✔ Handle case where there are no scopes in the config and command is invoked. @done(21-10-25 04:32) @project(Functionality)
✔ Have an `--example` flag to `new` that prints an example given in the `config.yml` @done(21-10-25 04:55) @project(Functionality)
✔ Should be a `tembo new --list` to list all possible names. @done(21-10-25 05:28) @project(Functionality)
✘ When template not found, raise a Tembo error @cancelled(21-10-25 05:29) @project(Functionality)
✔ Convert spaces to underscores in filepath @done(21-10-25 05:35) @project(Functionality)
✘ Add update notification? @cancelled(21-10-25 05:29) @project(Functionality)
✔ `TEMBO_CONFIG` should follow same pattern as other env vars and be a python string when read in @done(21-10-24 05:31) @project(Functionality)
✘ Uses Pendulum tokens: https://pendulum.eustace.io/docs/#tokens @cancelled(21-10-24 05:32) @project(Logging.Documentation)

39
dev/notes/test.md Normal file
View File

@@ -0,0 +1,39 @@
# testing notes
## options
optional:
- user_input
- example
- template_filename
- template_path
required:
- base_path
- page_path
- filename
- extension
- name
## tests to write
- user input does not match number of input tokens
- no user input
- mismatched user input
- with/without example
- dry run
## tests done
- path/page filenames can contain spaces and they are converted
- user input is None
- page using/not using input tokens
- page using/not using date tokens
- page using/not using name tokens
- page with/without a template
- the given base path does not exist
- the given template file does not exist
- page already exists

31
duties.py Normal file
View File

@@ -0,0 +1,31 @@
from duty import duty
@duty
def test(ctx):
ctx.run(["echo", "test"], title="test command")
@duty
def update_deps(ctx, dry: bool = False):
"""Update the dependencies using Poetry.
Example:
`duty update_deps dry=False`
"""
dry_run = "--dry-run" if dry else ""
ctx.run(
["poetry", "update", dry_run],
title=f"Updating poetry deps {dry_run}",
)
@duty
def coverage(ctx):
"""Generate a coverage HTML report.
Example:
`duty coverage`
"""
ctx.run(["coverage", "run", "--source", "tembo", "-m", "pytest"])
ctx.run(["coverage", "html"])

1211
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

125
prospector.yaml Normal file
View File

@@ -0,0 +1,125 @@
output-format: vscode
doc-warnings: true
strictness: none
ignore-patterns:
- (^|/)\..+
# https://pylint.pycqa.org/en/latest/technical_reference/features.html
pylint:
run: true
disable:
# disables TODO warnings
- fixme
# !doc docstrings
- missing-module-docstring
- missing-class-docstring
- missing-function-docstring
# ! doc end of docstrings
# disables warnings about abstract methods not overridden
- abstract-method
# used when an ancestor class method has an __init__ method which is not called by a derived class.
- super-init-not-called
# either all return statements in a function should return an expression, or none of them should.
# - inconsistent-return-statements
# Used when an expression that is not a function call is assigned to nothing. Probably something else was intended.
# - expression-not-assigned
# Used when a line is longer than a given number of characters.
- line-too-long
enable:
options:
max-locals: 15
max-returns: 6
max-branches: 12
max-statements: 50
max-parents: 7
max-attributes: 20
min-public-methods: 0
max-public-methods: 25
max-module-lines: 1000
max-line-length: 88
max-args: 8
mccabe:
run: true
options:
max-complexity: 10
# https://pep8.readthedocs.io/en/release-1.7.x/intro.html#error-codes
pep8:
run: true
options:
max-line-length: 88
single-line-if-stmt: n
disable:
# line too long
- E501
pyroma:
run: false
disable:
- PYR19
- PYR16
# https://pep257.readthedocs.io/en/latest/error_codes.html
# http://www.pydocstyle.org/en/6.1.1/error_codes.html
pep257:
disable:
# !doc docstrings
# Missing docstring in public package
- D104
# Missing docstring in __init__
- D107
# Missing docstring in public module
- D100
# Missing docstring in public class
- D101
# Missing docstring in public method
- D102
# Missing docstring in public function
- D103
# Missing docstring in magic method
- D105
# One-line docstring should fit on one line with quotes
- D200
# No blank lines allowed after function docstring
- D202
# Multi-line docstring summary should start at the second line
- D213
# First word of the docstring should not be This
- D404
# DEFAULT IGNORES
# 1 blank line required before class docstring
- D203
# Multi-line docstring summary should start at the first line
- D212
# !doc end of docstrings
# Section name should end with a newline
- D406
# Missing dashed underline after section
- D407
# Missing blank line after last section
- D413
# https://flake8.pycqa.org/en/latest/user/error-codes.html
pyflakes:
disable:
# module imported but unused
- F401
dodgy:
run: true
bandit:
run: true
# options:
# ignore assert warning
# - B101
mypy:
run: true
options:
# https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-type-hints-for-third-party-library
ignore-missing-imports: true
# https://mypy.readthedocs.io/en/stable/running_mypy.html#following-imports
follow-imports: normal

View File

@@ -5,11 +5,23 @@ description = ""
authors = ["dtomlinson <dtomlinson@panaetius.co.uk>"] authors = ["dtomlinson <dtomlinson@panaetius.co.uk>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.7" python = "^3.8"
click = "^8.0.3"
pendulum = "^2.1.2"
Jinja2 = "^3.0.2"
panaetius = { path = "../panaetius", develop = true }
pytest-datadir = "^1.3.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^5.2" pytest = "^6.2.5"
prospector = { extras = ["with_bandit", "with_mypy"], version = "^1.5.1" }
duty = "^0.7.0"
pyinstaller = "^4.5.1"
coverage = "^6.0.2"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
"tembo" = "tembo.cli.cli:run"

View File

@@ -1 +1,4 @@
__version__ = '0.1.0' from .journal.pages import ScopedPageCreator, PageCreatorOptions
from . import exceptions
__version__ = "0.1.0"

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

@@ -0,0 +1,30 @@
import os
import panaetius
from panaetius.exceptions import LoggingDirectoryDoesNotExistException
__version__ = "0.1.0"
if (config_path := os.environ.get("TEMBO_CONFIG")) is not None:
CONFIG = 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)

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

@@ -0,0 +1,198 @@
from __future__ import annotations
import pathlib
from typing import Collection
import click
import tembo.cli
from tembo.journal import pages
from tembo.utils import Success
from tembo import exceptions
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CONTEXT_SETTINGS, options_metavar="<options>")
@click.version_option(
tembo.__version__,
"-v",
"--version",
prog_name="Tembo",
message=f"Tembo v{tembo.__version__} 🐘",
)
def run():
"""
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)
@click.option("--example", is_flag=True, default=False)
def new(scope: str, inputs: Collection[str], dry_run: bool, example: bool):
r"""
Create a new page.
<scope>\n
The name of the scope in the config.yml.
<inputs>\n
Any input token values that are defined in the config.yml for this scope.
Accepts multiple inputs separated by a space.
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} input tokens, you gave {mismatched_token_error.given}. Example: {config_scope["example"]}'
)
raise SystemExit(1) from mismatched_token_error
cli_message(
f"Your tembo config.yml/template specifies {mismatched_token_error.expected} 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:
click.echo(f"[TEMBO] {message} 🐘")
run.add_command(new)
run.add_command(list_all)

36
tembo/exceptions.py Normal file
View File

@@ -0,0 +1,36 @@
"""Tembo exceptions."""
class MismatchedTokenError(Exception):
def __init__(self, expected: int, given: int) -> None:
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 @@
from tembo.journal import pages

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

@@ -0,0 +1,280 @@
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
import tembo.utils
# 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:
@abstractmethod
def __init__(self, options: PageCreatorOptions) -> None:
raise NotImplementedError
@property
@abstractmethod
def options(self) -> PageCreatorOptions:
raise NotImplementedError
@abstractmethod
def create_page(self) -> Page:
raise NotImplementedError
def _check_base_path_exists(self) -> None:
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:
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:
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:
self._all_input_tokens: list[str] = []
self._options = options
@property
def options(self) -> PageCreatorOptions:
return self._options
def create_page(self) -> Page:
self._check_base_path_exists()
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 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]:
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(all_input_tokens)
def _verify_input_tokens(self) -> None:
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),
)
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
@property
@abstractmethod
def path(self) -> pathlib.Path:
raise NotImplementedError
@abstractmethod
def save_to_disk(self) -> tembo.utils.Success:
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}\")"
@property
def path(self) -> pathlib.Path:
return self._path
def save_to_disk(self) -> tembo.utils.Success:
"""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.
# 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))

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

@@ -0,0 +1,12 @@
from dataclasses import dataclass
@dataclass
class Success:
"""Success message.
Attributes:
message (str): A success message.
"""
message: str

View File

View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,5 @@
tembo:
scopes:
- name: some_scope
path: "some_scope"
extension: md

View File

@@ -0,0 +1,8 @@
tembo:
scopes:
- name: some_scope
example: tembo new some_scope
path: some_scope
filename: "{name}"
extension: md
template_filename: some_nonexistent_template.md.tpl

View File

@@ -0,0 +1,6 @@
tembo:
scopes:
- name: some_scope
path: "some_scope"
filename: "{name}"
extension: md

View File

@@ -0,0 +1,16 @@
tembo:
scopes:
- name: some_scope
example: tembo new some_scope
path: "some_scope"
filename: "{name}"
extension: md
- name: some_scope_no_example
path: "some_scope"
filename: "{name}"
extension: md
- name: another_some_scope
example: tembo new another_some_scope
path: "another_some_scope"
filename: "{name}"
extension: md

View File

@@ -0,0 +1 @@
already exists

303
tests/test_cli/test_cli.py Normal file
View File

@@ -0,0 +1,303 @@
import importlib
import os
import pathlib
import pytest
import tembo.cli
from tembo.cli.cli import new, list_all
def test_new_dry_run(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "success")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
scope = "some_scope"
dry_run = "--dry-run"
# act
with pytest.raises(SystemExit) as system_exit:
new([scope, dry_run])
# assert
assert system_exit.value.code == 0
assert (
capsys.readouterr().out
== f"[TEMBO] {tmpdir}/some_scope/some_scope.md will be created 🐘\n"
)
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_new_success(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "success")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
scoped_page_file = pathlib.Path(tmpdir / "some_scope" / "some_scope").with_suffix(
".md"
)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_scope"])
# assert
assert scoped_page_file.exists()
assert system_exit.value.code == 0
assert capsys.readouterr().out == f"[TEMBO] Saved {scoped_page_file} to disk 🐘\n"
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_new_success_already_exists(shared_datadir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "success")
os.environ["TEMBO_BASE_PATH"] = str(shared_datadir)
importlib.reload(tembo.cli)
scoped_page_file = pathlib.Path(
shared_datadir / "some_scope" / "some_scope"
).with_suffix(".md")
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_scope"])
# assert
assert scoped_page_file.exists()
assert system_exit.value.code == 0
assert (
capsys.readouterr().out == f"[TEMBO] File {scoped_page_file} already exists 🐘\n"
)
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_new_scope_not_found(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "success")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
scoped_page_file = pathlib.Path(tmpdir / "some_scope" / "some_scope").with_suffix(
".md"
)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_nonexistent_scope"])
# assert
assert not scoped_page_file.exists()
assert system_exit.value.code == 1
assert (
capsys.readouterr().out
== "[TEMBO] Scope some_nonexistent_scope not found in config.yml 🐘\n"
)
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_new_empty_config(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "empty")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_nonexistent_scope"])
# assert
assert system_exit.value.code == 1
assert (
capsys.readouterr().out
== f"[TEMBO] Config.yml found in {shared_datadir}/config/empty is empty 🐘\n"
)
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_new_missing_config(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "missing")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_nonexistent_scope"])
# assert
assert system_exit.value.code == 1
assert (
capsys.readouterr().out
== f"[TEMBO] No config.yml found in {shared_datadir}/config/missing 🐘\n"
)
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_new_missing_mandatory_key(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "missing_keys")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_scope"])
# assert
assert system_exit.value.code == 1
assert (
capsys.readouterr().out == "[TEMBO] Key 'filename' not found in config.yml 🐘\n"
)
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
@pytest.mark.parametrize(
"path,message",
[
("success", "[TEMBO] Example for some_scope: tembo new some_scope 🐘\n"),
("optional_keys", "[TEMBO] No example in config.yml 🐘\n"),
],
)
def test_new_show_example(path, message, shared_datadir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / path)
importlib.reload(tembo.cli)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_scope", "--example"])
# assert
assert system_exit.value.code == 0
assert capsys.readouterr().out == message
# cleanup
del os.environ["TEMBO_CONFIG"]
def test_new_base_path_does_not_exist(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "success")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir / "nonexistent" / "path")
importlib.reload(tembo.cli)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_scope"])
# assert
assert system_exit.value.code == 1
assert (
capsys.readouterr().out
== f"[TEMBO] Tembo base path of {tmpdir}/nonexistent/path does not exist. 🐘\n"
)
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_new_template_file_does_not_exist(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "missing_template")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
os.environ["TEMBO_TEMPLATE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_scope"])
# assert
assert (
capsys.readouterr().out
== f"[TEMBO] Template file {tmpdir}/some_nonexistent_template.md.tpl does not exist. 🐘\n"
)
assert system_exit.value.code == 1
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_TEMPLATE_PATH"]
def test_new_mismatched_tokens_with_example(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "success")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_scope", "input0", "input1"])
# assert
assert system_exit.value.code == 1
capsys.readouterr().out == "[TEMBO] Your tembo config.yml/template specifies 0 input tokens, you gave 2. Example: tembo new some_scope 🐘\n"
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_new_mismatched_tokens_without_example(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "success")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
# act
with pytest.raises(SystemExit) as system_exit:
new(["some_scope_no_example", "input0", "input1"])
# assert
assert system_exit.value.code == 1
capsys.readouterr().out == "[TEMBO] Your tembo config.yml/template specifies 0 input tokens, you gave 2 🐘\n"
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]
def test_list_all_success(shared_datadir, tmpdir, capsys):
# arrange
os.environ["TEMBO_CONFIG"] = str(shared_datadir / "config" / "success")
os.environ["TEMBO_BASE_PATH"] = str(tmpdir)
importlib.reload(tembo.cli)
scoped_page_file = pathlib.Path(tmpdir / "some_scope" / "some_scope").with_suffix(
".md"
)
# act
with pytest.raises(SystemExit) as system_exit:
list_all([])
# assert
assert system_exit.value.code == 0
assert (
capsys.readouterr().out
== "[TEMBO] 3 names found in config.yml: 'some_scope', 'some_scope_no_example', 'another_some_scope' 🐘\n"
)
# cleanup
del os.environ["TEMBO_CONFIG"]
del os.environ["TEMBO_BASE_PATH"]

View File

@@ -0,0 +1,126 @@
import pathlib
import pytest
import jinja2
from tembo.journal.pages import PageCreator, ScopedPageCreator
def test_page_creator_convert_to_path_missing_base_path(caplog):
# arrange
base_path = "/some/nonexistent/path"
page_path = "some_page"
filename = "some_filename"
extension = "ex"
# act
with pytest.raises(SystemExit) as system_exit:
PageCreator._convert_base_path_to_path(
base_path=base_path,
page_path=page_path,
filename=filename,
extension=extension,
)
# assert
assert system_exit.value.code == 1
assert caplog.records[0].levelname == "CRITICAL"
assert (
caplog.records[0].message
== "Tembo base path of /some/nonexistent/path does not exist - exiting"
)
@pytest.mark.parametrize(
"page_path,filename,extension",
[
("some_pagepath", "some_filename", "ex"),
("some pagepath", "some filename", "ex"),
],
)
def test_page_creator_convert_to_path_full_path_to_file(
page_path, filename, extension, tmpdir
):
# arrange
path_to_file = (
pathlib.Path(tmpdir)
/ pathlib.Path(page_path)
/ pathlib.Path(filename).with_suffix(f".{extension}")
)
base_path = tmpdir
# act
converted_path = PageCreator._convert_base_path_to_path(
base_path, page_path, filename, extension
)
# assert
assert str(path_to_file).replace(" ", "_") == str(converted_path)
def test_page_creator_convert_to_path_full_path_no_file(tmpdir):
# arrange
full_path = pathlib.Path("/some/path")
base_path = ""
page_path = "/some/path"
filename = ""
extension = ""
# act
converted_path = PageCreator._convert_base_path_to_path(
base_path, page_path, filename, extension
)
# assert
assert str(full_path).replace(" ", "_") == str(converted_path)
def test_page_creator_load_template_with_base_path_success(datadir):
# arrange
# default template_path would be datadir/.templates
base_path = str(datadir)
template_filename = "some_template.md.tpl"
# act
template_contents = ScopedPageCreator()._load_template(
base_path, template_filename, None
)
# assert
assert template_contents == "template contents"
def test_page_creator_load_template_overriden_template_path_success(datadir):
# arrange
base_path = str(datadir)
template_filename = "some_template.md.tpl"
template_path = str(datadir / ".templates")
# act
# we explicitly pass in the template_path to override the default
template_contents = ScopedPageCreator()._load_template(
base_path, template_filename, template_path
)
# assert
assert template_contents == "template contents"
def test_page_creator_load_template_missing_template_file(datadir, caplog):
# arrange
base_path = str(datadir)
template_filename = "some_nonexistent_template.md.tpl"
template_path = str(datadir / ".templates")
# act
with pytest.raises(SystemExit) as system_exit:
template_contents = ScopedPageCreator()._load_template(
base_path, template_filename, template_path
)
# assert
assert system_exit.value.code == 1
assert caplog.records[0].message == (
f"Template file {template_path}/some_nonexistent_template.md.tpl not found "
"- exiting"
)

View File

@@ -0,0 +1,388 @@
from datetime import date
import pathlib
import pytest
from tembo import PageCreatorOptions, ScopedPageCreator
from tembo import exceptions
from tembo.utils import Success
DATE_TODAY = date.today().strftime("%d-%m-%Y")
def test_create_page_base_path_does_not_exist(tmpdir):
# arrange
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(
exceptions.BasePathDoesNotExistError
) as base_path_does_not_exist_error:
scoped_page = ScopedPageCreator(options).create_page()
# assert
assert (
str(base_path_does_not_exist_error.value)
== f"Tembo base path of {base_path} does not exist."
)
@pytest.mark.parametrize("template_path", [(None), ("/nonexistent/path")])
def test_create_page_template_file_does_not_exist(template_path, tmpdir):
# arrange
options = PageCreatorOptions(
base_path=str(tmpdir),
page_path="some_path",
filename="some_filename",
extension="some_extension",
name="some_name",
user_input=None,
example=None,
template_filename="template.md.tpl",
template_path=template_path,
)
# act
with pytest.raises(
exceptions.TemplateFileNotFoundError
) as template_file_not_found_error:
scoped_page = ScopedPageCreator(options).create_page()
# assert
if template_path is None:
assert str(template_file_not_found_error.value) == (
f"Template file {options.base_path}/.templates/{options.template_filename} does not exist."
)
else:
assert str(template_file_not_found_error.value) == (
f"Template file {template_path}/{options.template_filename} does not exist."
)
def test_create_page_already_exists(datadir):
# arrange
options = PageCreatorOptions(
base_path=str(datadir),
page_path="does_exist",
filename="some_note",
extension="md",
name="some_name",
user_input=None,
example=None,
template_filename=None,
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path) / options.page_path / options.filename
).with_suffix(f".{options.extension}")
# act
scoped_page = ScopedPageCreator(options).create_page()
with pytest.raises(exceptions.ScopedPageAlreadyExists) as page_already_exists:
result = scoped_page.save_to_disk()
# assert
assert scoped_page_file.exists()
assert str(page_already_exists.value) == f"{scoped_page_file} already exists"
with scoped_page_file.open("r", encoding="utf-8") as scoped_page_contents:
assert scoped_page_contents.readlines() == ["this file already exists\n"]
def test_create_page_without_template(tmpdir):
# arrange
options = PageCreatorOptions(
base_path=str(tmpdir),
page_path="some_path",
filename="some_filename",
extension="some_extension",
name="some_name",
user_input=None,
example=None,
template_filename=None,
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path) / options.page_path / options.filename
).with_suffix(f".{options.extension}")
# act
scoped_page = ScopedPageCreator(options).create_page()
result = scoped_page.save_to_disk()
# assert
assert scoped_page_file.exists()
assert isinstance(result, Success)
assert result.message == str(scoped_page_file)
with scoped_page_file.open("r", encoding="utf-8") as scoped_page_contents:
assert scoped_page_contents.readlines() == []
def test_create_page_with_template(datadir, caplog):
# arrange
options = PageCreatorOptions(
base_path=str(datadir),
page_path="some_path",
filename="some_note",
extension="md",
name="some_name",
user_input=None,
example=None,
template_filename="some_template_no_tokens.md.tpl",
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path) / options.page_path / options.filename
).with_suffix(f".{options.extension}")
# act
scoped_page = ScopedPageCreator(options).create_page()
result = scoped_page.save_to_disk()
# assert
assert scoped_page_file.exists()
assert isinstance(result, Success)
assert result.message == str(scoped_page_file)
with scoped_page_file.open("r", encoding="utf-8") as scoped_page_contents:
assert scoped_page_contents.readlines() == [
"scoped page file\n",
"\n",
"no tokens",
]
@pytest.mark.parametrize(
"user_input,template_filename,page_contents",
[
(None, "some_template_date_tokens.md.tpl", f"some date token: {DATE_TODAY}"),
(
("first_input", "second_input"),
"some_template_input_tokens.md.tpl",
"some input tokens second_input first_input",
),
(None, "some_template_name_tokens.md.tpl", "some name token some_name"),
],
)
def test_create_tokened_page_tokens_in_template(
datadir, caplog, user_input, template_filename, page_contents
):
# arrange
options = PageCreatorOptions(
base_path=str(datadir),
page_path="some_path",
filename="some_note",
extension="md",
name="some_name",
user_input=user_input,
example=None,
template_filename=template_filename,
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path) / options.page_path / options.filename
).with_suffix(f".{options.extension}")
# act
scoped_page = ScopedPageCreator(options).create_page()
result = scoped_page.save_to_disk()
# assert
assert scoped_page_file.exists()
assert isinstance(result, Success)
assert result.message == str(scoped_page_file)
with scoped_page_file.open("r", encoding="utf-8") as scoped_page_contents:
assert scoped_page_contents.readline() == page_contents
@pytest.mark.parametrize(
"user_input,filename,tokened_filename",
[
(None, "date_token_{d:%d-%m-%Y}", f"date_token_{DATE_TODAY}"),
(None, "name_token_{name}", "name_token_some_name"),
(
("first_input", "second input"),
"input_token_{input1}_{input0}",
"input_token_second_input_first_input",
),
],
)
def test_create_tokened_page_tokens_in_filename(
datadir, caplog, user_input, filename, tokened_filename
):
# arrange
options = PageCreatorOptions(
base_path=str(datadir),
page_path="some_path",
filename=filename,
extension="md",
name="some_name",
user_input=user_input,
example=None,
template_filename=None,
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path) / options.page_path / tokened_filename
).with_suffix(f".{options.extension}")
# act
scoped_page = ScopedPageCreator(options).create_page()
result = scoped_page.save_to_disk()
# assert
assert scoped_page_file.exists()
assert isinstance(result, Success)
assert result.message == str(scoped_page_file)
def test_create_tokened_page_input_tokens_preserve_order(datadir, caplog):
# arrange
tokened_filename = "input_token_fourth_input_first_input"
options = PageCreatorOptions(
base_path=str(datadir),
page_path="some_path",
filename="input_token_{input3}_{input0}",
extension="md",
name="some_name",
user_input=("first_input", "second_input", "third_input", "fourth_input"),
example=None,
template_filename="some_template_input_tokens_preserve_order.md.tpl",
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path) / options.page_path / tokened_filename
).with_suffix(f".{options.extension}")
# act
scoped_page = ScopedPageCreator(options).create_page()
result = scoped_page.save_to_disk()
# assert
assert scoped_page_file.exists()
assert isinstance(result, Success)
assert result.message == str(scoped_page_file)
with scoped_page_file.open(mode="r", encoding="utf-8") as scoped_page_contents:
assert scoped_page_contents.readline() == "third_input second_input"
@pytest.mark.parametrize(
"user_input,expected,given",
[
(None, 3, 0),
(("first_input", "second_input"), 3, 2),
(("first_input", "second_input", "third_input", "fourth_input"), 3, 4),
],
)
def test_create_page_mismatched_tokens(tmpdir, user_input, expected, given):
# arrange
options = PageCreatorOptions(
base_path=str(tmpdir),
page_path="some_path",
filename="input_token_{input0}_{input1}_{input2}",
extension="md",
name="some_name",
user_input=user_input,
example=None,
template_filename=None,
template_path=None,
)
# act
with pytest.raises(exceptions.MismatchedTokenError) as mismatched_token_error:
scoped_page = ScopedPageCreator(options).create_page()
# assert
assert mismatched_token_error.value.expected == expected
assert mismatched_token_error.value.given == given
def test_create_page_spaces_in_path(tmpdir, caplog):
# arrange
options = PageCreatorOptions(
base_path=str(tmpdir),
page_path="some path with a space",
filename="some filename with a space",
extension="md",
name="some_name",
user_input=None,
example=None,
template_filename=None,
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path)
/ options.page_path.replace(" ", "_")
/ options.filename.replace(" ", "_")
).with_suffix(f".{options.extension}")
# act
scoped_page = ScopedPageCreator(options).create_page()
result = scoped_page.save_to_disk()
# assert
assert scoped_page_file.exists()
assert isinstance(result, Success)
assert result.message == str(scoped_page_file)
def test_create_page_dot_in_extension(tmpdir, caplog):
# arrange
options = PageCreatorOptions(
base_path=str(tmpdir),
page_path="some_path",
filename="some_filename",
extension=".md",
name="some_name",
user_input=None,
example=None,
template_filename=None,
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path) / options.page_path / options.filename
).with_suffix(f".{options.extension[1:]}")
# act
scoped_page = ScopedPageCreator(options).create_page()
result = scoped_page.save_to_disk()
# assert
assert scoped_page_file.exists()
assert isinstance(result, Success)
assert result.message == str(scoped_page_file)
def test_create_page_str_representation(tmpdir):
# arrange
options = PageCreatorOptions(
base_path=str(tmpdir),
page_path="some_path",
filename="some_filename",
extension="md",
name="some_name",
user_input=None,
example=None,
template_filename=None,
template_path=None,
)
scoped_page_file = (
pathlib.Path(options.base_path) / options.page_path / options.filename
).with_suffix(f".{options.extension}")
# act
scoped_page = ScopedPageCreator(options).create_page()
# assert
assert str(scoped_page) == f"ScopedPage(\"{scoped_page_file}\")"

View File

@@ -0,0 +1 @@
some date token: {d:%d-%m-%Y}

View File

@@ -0,0 +1 @@
some input tokens {input1} {input0}

View File

@@ -0,0 +1 @@
some name token {name}

View File

@@ -0,0 +1,3 @@
scoped page file
no tokens

View File

@@ -0,0 +1 @@
this file already exists

View File

@@ -1,5 +0,0 @@
from tembo import __version__
def test_version():
assert __version__ == '0.1.0'