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
.coveragerc Normal file
View File

@@ -0,0 +1,10 @@
[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
[run]
source=tembo

62
.github/workflows/analysis.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: analysis
on:
push:
branches:
- main
jobs:
analysis:
runs-on: ubuntu-latest
steps:
# Checkout repo & install Python
- name: Check out repository
uses: actions/checkout@v2
with:
# disable shallow clones for better sonarcloud accuracy
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.9
# Install & configure Poetry
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
# Load cached venv
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v2
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
# Install dependencies if no cache
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root
# Install package if needed
- name: Install package
run: poetry install --no-interaction
# Run tests
- name: Run tests
run: |
source .venv/bin/activate
pytest -v --cov --junitxml=pytest.xml --suppress-tests-failed-exit-code
coverage xml -i
sed -i 's/\/home\/runner\/work\/tembo-core\/tembo-core\///g' coverage.xml
sed -i 's/\/home\/runner\/work\/tembo-core\/tembo-core\///g' pytest.xml
# Build docs
- name: Build docs
run: |
source .venv/bin/activate
coverage html
mkdocs gh-deploy
# Upload to sonarcloud
- name: SonarCloud Scan
uses: sonarsource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

63
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: tests
on:
pull_request:
types:
- opened
- synchronize
- reopened
jobs:
tests:
runs-on: ubuntu-latest
steps:
# Checkout repo & install Python
- name: Check out repository
uses: actions/checkout@v2
with:
# disable shallow clones for better sonarcloud accuracy
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.9
# Install & configure Poetry
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
# Load cached venv
- name: Load cached venv
id: cached-poetry-dependencies
uses: actions/cache@v2
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
# Install dependencies if no cache
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root
# Install package if needed
- name: Install package
run: poetry install --no-interaction
# Run tests
- name: Run tests
run: |
source .venv/bin/activate
pytest -v --cov --junitxml=pytest.xml
coverage xml -i
sed -i 's/\/home\/runner\/work\/tembo-core\/tembo-core\///g' coverage.xml
sed -i 's/\/home\/runner\/work\/tembo-core\/tembo-core\///g' pytest.xml
# Run linting
- name: Run prospector linting
run: |
source .venv/bin/activate
prospector "./tembo"
# Upload to sonarcloud
- name: SonarCloud Scan
uses: sonarsource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

145
.gitignore vendored Normal file
View File

@@ -0,0 +1,145 @@
# 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__
**/.DS_Store
.python-version
codecov

14
CHANGELOG.md Normal file
View File

@@ -0,0 +1,14 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
<!-- insertion marker -->
## [0.1.0](https://github.com/tembo-pages/tembo-core/releases/tag/0.1.0) - 2021-11-20
<small>[Compare with first commit](https://github.com/tembo-pages/tembo-core/compare/8884a942c5c2a2815a1bbc75fb106555402d2055...0.1.0)</small>
### Features
- update duties ([e2b4ef9](https://github.com/tembo-pages/tembo-core/commit/e2b4ef9f91c484d0d26ee5afcd308b6470f46370) by Daniel Tomlinson).

77
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,77 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to make participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when
an individual is representing the project or its community in public spaces.
Examples of representing a project or community include using an official
project e-mail address, posting via an official social media account, or acting
as an appointed representative at an online or offline event. Representation of
a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at [INSERT EMAIL ADDRESS]. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

15
LICENSE Normal file
View File

@@ -0,0 +1,15 @@
ISC License
Copyright (c) 2021 Daniel Tomlinson
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

109
README.md Normal file
View File

@@ -0,0 +1,109 @@
# Tembo
<img
src="https://raw.githubusercontent.com/tembo-pages/tembo-core/main/assets/tembo_logo.png"
width="200px"
/>
A simple folder organiser for your work notes.
![](https://img.shields.io/codecov/c/github/tembo-pages/tembo-core?style=flat-square)
![Sonar Coverage](https://img.shields.io/sonar/coverage/tembo-pages_tembo-core?server=https%3A%2F%2Fsonarcloud.io&style=flat-square)
![Sonar Tests](https://img.shields.io/sonar/tests/tembo-pages_tembo-core?compact_message&failed_label=failed&passed_label=passed&server=https%3A%2F%2Fsonarcloud.io&skipped_label=skipped&style=flat-square)
![Sonar Tech Debt](https://img.shields.io/sonar/tech_debt/tembo-pages_tembo-core?server=https%3A%2F%2Fsonarcloud.io&style=flat-square)
## config.yml
```yaml
# time tokens: https://strftime.org
tembo:
base_path: ~/tembo
# template_path: ~/tembo/templates
scopes:
- name: scratchpad
example: tembo new scratchpad
path: "scratchpad/{d:%B_%Y}"
filename: "{d:%B_%W}"
extension: md
template_filename: scratchpad.md.tpl
- name: wtodo
example: tembo new wtodo | directory is month_year, filename is month_week-of-year
path: "wtodo/{d:%B_%Y}"
filename: "week_{d:%W}"
extension: todo
template_filename: weekly.todo.tpl
- name: meeting
example: tembo new meeting $meeting_title
path: "meetings/{d:%B_%y}"
filename: "{d:%a_%d_%m_%y}-{input0}"
extension: md
template_filename: meeting.md.tpl
- name: knowledge
example: tembo new knowledge $project $filename
path: "knowledge/{input0}"
filename: "{input1}"
extension: md
template_filename: knowledge.md.tpl
logging:
level: INFO
path: ~/tembo/.logs
```
## templates
### knowledge
```
---
created: {d:%d-%m-%Y}
---
# {input0} - {input1}.md
```
### meeting
```
---
created: {d:%d-%m-%Y}
---
# {d:%A %d %B %Y} - {input0}
## People
Head:
Attendees:
## Actions
## Notes
```
### scratchpad
```
---
created: {d:%d-%m-%Y}
---
# Scratchpad - Week {d:%W} - {d:%B-%y}
```
### wtodo
```
---
created: {d:%d-%m-%Y}
---
Weekly TODO | Week {d:%W} {d:%B}-{d:%Y}
Work:
Documentation:
```

152
TODO.todo Normal file
View File

@@ -0,0 +1,152 @@
Priority:
✔ Version duty @done(21-11-09 23:54)
✔ Duty to `poetry build` and extract + copy `setup.py` to root @done(21-11-10 22:14)
✘ Duty to run tests in isolation @cancelled(21-11-10 22:30)
✔ Docstrings @done(21-11-15 21:37)
✔ Update trilium with latest docstrings (documenting __init__ at class level etc) @done(21-11-15 21:37)
Make sure the gist is updated for prospector with the right ignores
✔ Go over Panaetius @done(21-11-15 21:37)
✔ Update docstrings with latest @done(21-11-15 21:37)
✔ Write basic README.md page with 2 uses @done(21-11-15 21:37)
Script and Module usage
✔ Build and publish latest version @done(21-11-15 21:37)
☐ Write Tembo documentation with mkdocs
✔ Document duties in Trilium and create a gist for common duties @done(21-11-15 21:37)
☐ Document writing documentation in Trilium with example to Trilium
☐ Integrate sonarcloud with CI (github actions?)
Documentation:
Docstrings:
✔ Use Duty to write module docstrings @done(21-11-15 21:37)
✔ Use Duty to add Class docstrings @done(21-11-15 21:37)
✔ Document these in Trilium and rewrite the docstrings notes @done(21-11-15 21:37)
☐ Add the comment on Reddit (artie buco?) about imports in a module
✔ Add the CLI initialisation boilerplate to trilium @done(21-11-10 22:50)
✔ _version, base command, __init__.py etc @done(21-11-10 22:49)
☐ Write documentation using `mkdocs`
✔ Create a boilerplate `duties.py` for common tasks for future projects. Put in a gist. @done(21-11-15 21:37)
☐ 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.
Functionality:
✔ Use the python runner Duty @done(21-11-15 21:37)
<https://github.com/pawamoy/duty>
✔ Run tests @done(21-11-10 22:54)
✔ Update poetry @done(21-11-10 22:54)
☐ Build docs
✔ Document using Duty @done(21-11-15 21:37)
✔ Duty for auto insert version from `poetry version`. @done(21-11-09 23:53)
Need to decide what file to place `__version__` in.
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 :=
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:
✘ test logs: <https://stackoverflow.com/questions/53125305/testing-logging-output-with-pytest> @cancelled(21-11-09 23:15) @project(Priority)
document this
✔ Write the tests @done(21-11-07 15:36) @project(Priority)
✔ documented poetry with extras (panaetius `pyproject.toml`) @done(21-11-09 22:29) @project(Documentation)
✔ Document using `__main__.py` and `cli.py` @done(21-11-07 15:21) @project(Documentation.Docstrings)
Use Duty as an example
✔ Document regex usage @done(21-11-09 22:39) @project(Documentation)
✔ Document how to use pytest to read a logging message @done(21-11-09 22:57) @project(Documentation)
<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 @done(21-11-09 22:50) @project(Documentation)
When you use `with pytest.raises` you can use `.value` to access the attributes
reading `.value.code`
reading `str(.value)`
✔ Document working with exceptions @done(21-11-09 22:17) @project(Documentation)
✔ General pattern - raise exceptions in codebase, catch them in the CLI. @done(21-11-09 22:16) @project(Documentation)
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. @done(21-11-09 22:16) @project(Documentation)
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 @done(21-11-09 22:17) @project(Documentation)
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 @done(21-11-09 22:59) @project(Documentation)
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. @done(21-11-09 22:59) @project(Documentation)
✘ Can prospector ignore tests dir? document this in the gist if so @cancelled(21-11-09 23:08) @project(Documentation)
✔ Redo the documentation on a CLI, reorganise and inocropoate all the new tembo layouts @done(21-11-09 23:08) @project(Documentation)
✔ Document importing in inidivudal tests using `importlib.reload` @done(21-11-09 23:13) @project(Documentation.Testing)
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>
✔ Replace loggers with `click.echo` for command outputs. Keep logging messages for actual logging messages? @done(21-11-09 22:17) @project(Functionality)
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 @done(21-11-07 15:35) @project(Functionality)
✘ Use the complicated CLI example so the tembo new has its own module to define functions in @cancelled(21-11-07 15:35) @project(Functionality)
✔ Replace all logger errors with exceptions, move logger messages to the cli. @done(21-11-07 15:35) @project(Functionality)
✔ How to pass a successful save notification back to the CLI? Return a bool? Or is there some other way? @done(21-11-07 15:35) @project(Functionality)
✘ Replace pendulum with datetime @cancelled(21-11-07 15:35) @project(Functionality)
✔ Make options a property on the class, add to abstract @done(21-10-30 19:31) @project(Functionality)
✘ Make all internal tembo logs be debug @cancelled(21-11-09 22:20) @project(Logging)
✘ User can enable them with the config @cancelled(21-11-09 22:20) @project(Logging)
✔ Write tests! @2d @done(21-11-07 15:36) @project(Tests)
Use coverage as going along to make sure all bases are covered in the testing
✔ 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)

BIN
assets/tembo_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,7 @@
# CLI Reference
::: mkdocs-click
:module: tembo.cli.cli
:command: main
:prog_name: tembo
:style: table

4
docs/css/extra.css Normal file
View File

@@ -0,0 +1,4 @@
:root {
--md-primary-bg-color: #ee0f0f;
--md-primary-bg-color--light: #ee0f0f;
}

View File

@@ -0,0 +1,6 @@
/* Indentation. */
div.doc-contents:not(.first) {
padding-left: 25px;
border-left: 4px solid rgba(230, 230, 230);
margin-bottom: 80px;
}

28
docs/gen_reference.py Normal file
View File

@@ -0,0 +1,28 @@
"""Generate the code reference pages and navigation."""
from pathlib import Path
import mkdocs_gen_files
PACKAGE_NAME = "tembo"
nav = mkdocs_gen_files.Nav()
for path in sorted(Path(PACKAGE_NAME).glob("**/*.py")):
module_path = path.relative_to(PACKAGE_NAME).with_suffix("")
doc_path = path.relative_to(PACKAGE_NAME).with_suffix(".md")
full_doc_path = Path("code_reference", doc_path)
parts = list(module_path.parts)
parts[-1] = f"{parts[-1]}.py"
nav[parts] = doc_path
with mkdocs_gen_files.open(full_doc_path, "w") as fd:
code_ident = ".".join(module_path.parts)
print("::: " + PACKAGE_NAME + "." + code_ident, file=fd)
mkdocs_gen_files.set_edit_path(full_doc_path, path)
with mkdocs_gen_files.open("code_reference/SUMMARY.md", "w") as nav_file:
nav_file.writelines(nav.build_literate_nav())

1
docs/home/changelog.md Normal file
View File

@@ -0,0 +1 @@
--8<-- "CHANGELOG.md"

3
docs/home/license.md Normal file
View File

@@ -0,0 +1,3 @@
```
--8<-- "LICENSE.md"
```

8
docs/index.md Normal file
View File

@@ -0,0 +1,8 @@
# Tembo
```python
from tembo import Success
```
v0.0.8

449
duties.py Normal file
View File

@@ -0,0 +1,449 @@
from __future__ import annotations
import importlib
import os
import pathlib
import re
import shutil
import sys
from io import StringIO
from typing import List, Optional, Pattern
from urllib.request import urlopen
from duty import duty
PACKAGE_NAME = "tembo"
@duty(post=["export"])
def update_deps(ctx, dry: bool = False):
"""
Update the dependencies using Poetry.
Args:
ctx: The context instance (passed automatically).
dry (bool, optional) = If True will update the `poetry.lock` without updating the
dependencies themselves. Defaults to False.
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 test(ctx):
"""
Run tests using pytest.
Args:
ctx: The context instance (passed automatically).
"""
pytest_results = ctx.run(["pytest", "-v"], pty=True)
print(pytest_results)
@duty
def coverage(ctx):
"""
Generate a coverage report and save to XML and HTML.
Args:
ctx: The context instance (passed automatically).
Example:
`duty coverage`
"""
ctx.run(["coverage", "run", "--source", PACKAGE_NAME, "-m", "pytest"])
res = ctx.run(["coverage", "report"], pty=True)
print(res)
ctx.run(["coverage", "html"])
ctx.run(["coverage", "xml"])
@duty
def version(ctx, bump: str = "patch"):
"""
Bump the version using Poetry and update _version.py.
Args:
ctx: The context instance (passed automatically).
bump (str, optional) = poetry version flag. Available options are:
patch, minor, major, prepatch, preminor, premajor, prerelease.
Defaults to patch.
Example:
`duty version bump=major`
"""
# bump with poetry
result = ctx.run(["poetry", "version", bump])
new_version = re.search(r"(?:.*)(?:\s)(\d+\.\d+\.\d+)$", result)
print(new_version.group(0))
# update _version.py
version_file = pathlib.Path(PACKAGE_NAME) / "_version.py"
with version_file.open("w", encoding="utf-8") as version_file:
version_file.write(
f'"""Module containing the version of {PACKAGE_NAME}."""\n\n' + f'__version__ = "{new_version.group(1)}"\n'
)
print(f"Bumped _version.py to {new_version.group(1)}")
@duty
def build(ctx):
"""
Build with poetry and extract the setup.py and copy to project root.
Args:
ctx: The context instance (passed automatically).
Example:
`duty build`
"""
repo_root = pathlib.Path(".")
# build with poetry
result = ctx.run(["poetry", "build"])
print(result)
# extract the setup.py from the tar
extracted_tar = re.search(r"(?:.*)(?:Built\s)(.*)", result)
tar_file = pathlib.Path(f"./dist/{extracted_tar.group(1)}")
shutil.unpack_archive(tar_file, tar_file.parents[0])
# copy setup.py to repo root
extracted_path = tar_file.parents[0] / os.path.splitext(tar_file.stem)[0]
setup_py = extracted_path / "setup.py"
shutil.copyfile(setup_py, (repo_root / "setup.py"))
# cleanup
shutil.rmtree(extracted_path)
@duty
def export(ctx):
"""
Export the dependencies to a requirements.txt file.
Args:
ctx: The context instance (passed automatically).
Example:
`duty export`
"""
requirements_content = ctx.run(
[
"poetry",
"export",
"-f",
"requirements.txt",
"--without-hashes",
]
)
requirements_dev_content = ctx.run(
[
"poetry",
"export",
"-f",
"requirements.txt",
"--without-hashes",
"--dev",
]
)
requirements = pathlib.Path(".") / "requirements.txt"
requirements_dev = pathlib.Path(".") / "requirements_dev.txt"
with requirements.open("w", encoding="utf-8") as req:
req.write(requirements_content)
with requirements_dev.open("w", encoding="utf-8") as req:
req.write(requirements_dev_content)
@duty
def publish(ctx, password: str):
"""
Publish the package to pypi.org.
Args:
ctx: The context instance (passed automatically).
password (str): pypi.org password.
Example:
`duty publish password=$my_password`
"""
dist_dir = pathlib.Path(".") / "dist"
rm_result = rm_tree(dist_dir)
print(rm_result)
publish_result = ctx.run(["poetry", "publish", "-u", "dtomlinson", "-p", password, "--build"])
print(publish_result)
@duty(silent=True)
def clean(ctx):
"""
Delete temporary files.
Args:
ctx: The context instance (passed automatically).
"""
ctx.run("rm -rf .mypy_cache")
ctx.run("rm -rf .pytest_cache")
ctx.run("rm -rf tests/.pytest_cache")
ctx.run("rm -rf build")
ctx.run("rm -rf dist")
ctx.run("rm -rf pip-wheel-metadata")
ctx.run("rm -rf site")
ctx.run("rm -rf coverage.xml")
ctx.run("rm -rf pytest.xml")
ctx.run("rm -rf htmlcov")
ctx.run("find . -iname '.coverage*' -not -name .coveragerc | xargs rm -rf")
ctx.run("find . -type d -name __pycache__ | xargs rm -rf")
ctx.run("find . -name '*.rej' -delete")
@duty
def format(ctx):
"""
Format code using Black and isort.
Args:
ctx: The context instance (passed automatically).
"""
res = ctx.run(["black", "--line-length=99", PACKAGE_NAME], pty=True, title="Running Black")
print(res)
res = ctx.run(["isort", PACKAGE_NAME])
print(res)
@duty(pre=["check_code_quality", "check_types", "check_docs", "check_dependencies"])
def check(ctx):
"""
Check the code quality, check types, check documentation builds and check dependencies for vulnerabilities.
Args:
ctx: The context instance (passed automatically).
"""
@duty
def check_code_quality(ctx):
"""
Check the code quality using prospector.
Args:
ctx: The context instance (passed automatically).
"""
ctx.run(["prospector", PACKAGE_NAME], pty=True, title="Checking code quality with prospector")
@duty
def check_types(ctx):
"""
Check the types using mypy.
Args:
ctx: The context instance (passed automatically).
"""
ctx.run(["mypy", PACKAGE_NAME], pty=True, title="Checking types with MyPy")
@duty
def check_docs(ctx):
"""
Check the documentation builds successfully.
Args:
ctx: The context instance (passed automatically).
"""
ctx.run(["mkdocs", "build"], title="Building documentation")
@duty
def check_dependencies(ctx):
"""
Check dependencies with safety for vulnerabilities.
Args:
ctx: The context instance (passed automatically).
"""
for module in sys.modules:
if module.startswith("safety.") or module == "safety":
del sys.modules[module]
importlib.invalidate_caches()
from safety import safety
from safety.formatter import report
from safety.util import read_requirements
requirements = ctx.run(
"poetry export --dev --without-hashes",
title="Exporting dependencies as requirements",
allow_overrides=False,
)
def check_vulns():
packages = list(read_requirements(StringIO(requirements)))
vulns = safety.check(packages=packages, ignore_ids="41002", key="", db_mirror="", cached=False, proxy={})
output_report = report(vulns=vulns, full=True, checked_packages=len(packages))
print(vulns)
if vulns:
print(output_report)
ctx.run(
check_vulns,
stdin=requirements,
title="Checking dependencies",
pty=True,
)
def _latest(lines: List[str], regex: Pattern) -> Optional[str]:
for line in lines:
match = regex.search(line)
if match:
return match.groupdict()["version"]
return None
def _unreleased(versions, last_release):
for index, version in enumerate(versions):
if version.tag == last_release:
return versions[:index]
return versions
def update_changelog(
inplace_file: str,
marker: str,
version_regex: str,
commit_style: str,
) -> None:
"""
Update the given changelog file in place.
Arguments:
inplace_file: The file to update in-place.
marker: The line after which to insert new contents.
version_regex: A regular expression to find currently documented versions in the file.
template_url: The URL to the Jinja template used to render contents.
commit_style: The style of commit messages to parse.
"""
from git_changelog.build import Changelog
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(autoescape=False)
template = env.from_string(changelog_template())
changelog = Changelog(".", style=commit_style)
if len(changelog.versions_list) == 1:
last_version = changelog.versions_list[0]
if last_version.planned_tag is None:
planned_tag = "0.1.0"
last_version.tag = planned_tag
last_version.url += planned_tag
last_version.compare_url = last_version.compare_url.replace("HEAD", planned_tag)
with open(inplace_file, "r") as changelog_file:
lines = changelog_file.read().splitlines()
last_released = _latest(lines, re.compile(version_regex))
if last_released:
changelog.versions_list = _unreleased(changelog.versions_list, last_released)
rendered = template.render(changelog=changelog, inplace=True)
lines[lines.index(marker)] = rendered
with open(inplace_file, "w") as changelog_file: # noqa: WPS440
changelog_file.write("\n".join(lines).rstrip("\n") + "\n")
@duty
def changelog(ctx):
"""
Update the changelog in-place with latest commits.
Arguments:
ctx: The context instance (passed automatically).
"""
ctx.run(
update_changelog,
kwargs={
"inplace_file": "CHANGELOG.md",
"marker": "<!-- insertion marker -->",
"version_regex": r"^## \[v?(?P<version>[^\]]+)",
"commit_style": "angular",
},
title="Updating changelog",
pty=True,
)
def rm_tree(directory: pathlib.Path):
"""
Recursively delete a directory and all its contents.
Args:
directory (pathlib.Path): The directory to delete.
"""
for child in directory.glob("*"):
if child.is_file():
child.unlink()
else:
rm_tree(child)
directory.rmdir()
def changelog_template() -> str:
return """
{% if not inplace -%}
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
{% endif %}<!-- insertion marker -->
{% macro render_commit(commit) -%}
- {{ commit.style.subject|default(commit.subject) }} ([{{ commit.hash|truncate(7, True, '') }}]({{ commit.url }}) by {{ commit.author_name }}).
{%- if commit.text_refs.issues_not_in_subject %} References: {% for issue in commit.text_refs.issues_not_in_subject -%}
{% if issue.url %}[{{ issue.ref }}]({{ issue.url }}){%else %}{{ issue.ref }}{% endif %}{% if not loop.last %}, {% endif -%}
{%- endfor -%}{%- endif -%}
{%- endmacro -%}
{%- macro render_section(section) -%}
### {{ section.type or "Misc" }}
{% for commit in section.commits|sort(attribute='author_date',reverse=true)|unique(attribute='subject') -%}
{{ render_commit(commit) }}
{% endfor %}
{%- endmacro -%}
{%- macro render_version(version) -%}
{%- if version.tag or version.planned_tag -%}
## [{{ version.tag or version.planned_tag }}]({{ version.url }}){% if version.date %} - {{ version.date }}{% endif %}
<small>[Compare with {{ version.previous_version.tag|default("first commit") }}]({{ version.compare_url }})</small>
{%- else -%}
## Unrealeased
<small>[Compare with latest]({{ version.compare_url }})</small>
{%- endif %}
{% for type, section in version.sections_dict|dictsort -%}
{%- if type and type in changelog.style.DEFAULT_RENDER -%}
{{ render_section(section) }}
{% endif -%}
{%- endfor -%}
{%- endmacro -%}
{% for version in changelog.versions_list -%}
{{ render_version(version) }}
{%- endfor -%}
"""

107
mkdocs.yaml Normal file
View File

@@ -0,0 +1,107 @@
# Project Information
# Repository
site_name: Tembo
site_url: https://tembo-pages.github.io/tembo-core/
site_description: "Tembo: A simple folder organiser for your work notes."
site_author: Daniel Tomlinson
repo_url: https://github.com/tembo-pages/tembo-core
repo_name: tembo-pages/tembo-core
# Preview Controls
# set use_directory_urls false if browsing locally
# use_directory_urls: false
# Page Tree
nav:
- Home:
- Overview: index.md
- Changelog: home/changelog.md
- License: home/license.md
- Code Reference: code_reference/
- CLI Reference:
- tembo: cli_reference/main.md
- Development:
- Coverage report: coverage.md
# Theme
theme:
name: material
logo: assets/tembo_doc_logo.png
icon:
repo: fontawesome/brands/github
features:
- navigation.tabs
- navigation.top
- navigation.instant
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: deep orange
accent: orange
toggle:
icon: material/weather-sunny
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: deep orange
accent: orange
toggle:
icon: material/weather-night
name: Switch to light mode
# Extensions - see https://squidfunk.github.io/mkdocs-material/setup/extensions/?h= for all options
markdown_extensions:
- admonition
- codehilite:
guess_lang: true
- toc:
# sets the character used to bookmark the title
permalink: "¤"
- pymdownx.highlight:
# show title, linenums
# auto_title: true
# linenums: true
# linenums_style: pymdownx-inline
- pymdownx.details
- pymdownx.inlinehilite
- pymdownx.superfences
- pymdownx.snippets
- pymdownx.arithmatex:
generic: true
- mkdocs-click
# Plugins
plugins:
- search:
lang: en
- mkdocstrings:
handlers:
python:
import:
- https://docs.python.org/3/objects.inv
- gen-files:
scripts:
- docs/gen_reference.py
- literate-nav:
nav_file: SUMMARY.md
- coverage
# Customisation
extra_javascript:
- https://polyfill.io/v3/polyfill.min.js?features=es6
- https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
# CSS
extra_css:
- css/mkdocstrings.css
# - css/extra.css
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/tembo-pages/tembo-core
- icon: fontawesome/solid/paper-plane
link: mailto:dtomlinson@panaetius.co.uk
- icon: fontawesome/brands/twitter
link: https://twitter.com/dmot7291

2
mypy.ini Normal file
View File

@@ -0,0 +1,2 @@
[mypy]
ignore_missing_imports = true

1763
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: 99
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: 99
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

61
pyproject.toml Normal file
View File

@@ -0,0 +1,61 @@
[tool.poetry]
name = "tembo"
description = "A simple folder organiser for your work notes."
version = "0.0.8"
license = "ISC"
authors = ["dtomlinson <dtomlinson@panaetius.co.uk>"]
readme = "./README.md"
homepage = "https://tembo-pages.github.io/tembo-core/"
repository = "https://github.com/tembo-pages/tembo-core/"
documentation = "https://tembo-pages.github.io/tembo-core/"
keywords = ["notes", "organisation", "work"]
[tool.poetry.dependencies]
python = "^3.8"
click = "^8.0.3"
pendulum = "^2.1.2"
Jinja2 = "^3.0.2"
# panaetius = "^2.3.2"
panaetius = { path = "../../panaetius", develop = true }
[tool.poetry.dev-dependencies]
pytest = "^6.2.5"
pytest-cov = "^3.0.0"
pytest-datadir = "^1.3.1"
pytest-custom-exit-code = "^0.3.0"
coverage = "^6.0.2"
prospector = { extras = ["with_bandit", "with_mypy"], version = "^1.5.1" }
duty = "^0.7.0"
pyinstaller = "^4.5.1"
isort = "^5.10.0"
mkdocs = "^1.2.3"
mkdocs-material = "^7.3.6"
mkdocstrings = "^0.16.2"
mkdocs-gen-files = "^0.3.3"
mkdocs-literate-nav = "^0.4.0"
mkdocs-coverage = "^0.2.4"
mkdocs-click = "^0.4.0"
mypy = "^0.910"
safety = "^1.10.3"
git-changelog = "^0.5.0"
Jinja2 = "^3.0.3"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
"tembo" = "tembo.cli.cli:main"
[tool.black]
line-length = 120
[tool.isort]
line_length = 120
multi_line_output = 3
force_single_line = false
balanced_wrapping = true
default_section = "THIRDPARTY"
known_first_party = "duty"
include_trailing_comma = true

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
click==8.0.3; python_version >= "3.6"
colorama==0.4.4; python_version >= "3.6" and python_full_version < "3.0.0" and platform_system == "Windows" or platform_system == "Windows" and python_version >= "3.6" and python_full_version >= "3.5.0"
jinja2==3.0.3; python_version >= "3.6"
markupsafe==2.0.1; python_version >= "3.6"
panaetius @ /home/dtomlinson/git-repos/python/panaetius; python_version >= "3.7" and python_version < "4.0"
pendulum==2.1.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0")
python-dateutil==2.8.2; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0"
pytzdata==2020.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0"
pyyaml==6.0; python_version >= "3.7" and python_version < "4.0"
six==1.16.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0"
toml==0.10.2; python_version >= "3.7" and python_full_version < "3.0.0" and python_version < "4.0" or python_version >= "3.7" and python_version < "4.0" and python_full_version >= "3.3.0"

96
requirements_dev.txt Normal file
View File

@@ -0,0 +1,96 @@
altgraph==0.17.2; sys_platform == "darwin" and python_version >= "3.6"
ansimarkup==1.5.0; python_version >= "3.6"
astroid==2.8.5; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.6.1"
astunparse==1.6.3; python_full_version >= "3.6.1" and python_version >= "3.6" and python_version < "3.9"
atomicwrites==1.4.0; python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.4.0"
attrs==21.2.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6"
bandit==1.7.1; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.5"
certifi==2021.10.8; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.5"
charset-normalizer==2.0.7; python_full_version >= "3.6.0" and python_version >= "3.5"
click==8.0.3; python_version >= "3.6"
colorama==0.4.4; platform_system == "Windows" and python_version >= "3.6" and python_full_version >= "3.6.1" and sys_platform == "win32" and python_version < "4.0"
coverage==6.1.2; python_version >= "3.6"
dodgy==0.2.1; python_full_version >= "3.6.1" and python_version < "4.0"
dparse==0.5.1; python_version >= "3.5"
duty==0.7.0; python_version >= "3.6"
failprint==0.8.0; python_version >= "3.6"
flake8-polyfill==1.0.2; python_full_version >= "3.6.1" and python_version < "4.0"
flake8==2.3.0; python_full_version >= "3.6.1" and python_version < "4.0"
future==0.18.2; sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.6.0"
ghp-import==2.0.2; python_version >= "3.6"
git-changelog==0.5.0; python_full_version >= "3.6.2"
gitdb==4.0.9; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.7"
gitpython==3.1.24; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.7"
idna==3.3; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.5"
importlib-metadata==4.8.2; python_version < "3.10" and python_version >= "3.6"
iniconfig==1.1.1; python_version >= "3.6"
isort==5.10.1; python_full_version >= "3.6.1" and python_version < "4.0"
jinja2==3.0.3; python_version >= "3.6"
lazy-object-proxy==1.6.0; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.6.1"
macholib==1.15.2; sys_platform == "darwin" and python_version >= "3.6"
markdown==3.3.6; python_version >= "3.7" and python_version < "4.0"
markupsafe==2.0.1; python_version >= "3.6"
mccabe==0.6.1; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.6"
mergedeep==1.3.4; python_version >= "3.6"
mkdocs-autorefs==0.3.0; python_version >= "3.6" and python_version < "4.0"
mkdocs-click==0.4.0; python_version >= "3.7"
mkdocs-coverage==0.2.4; python_full_version >= "3.6.1"
mkdocs-gen-files==0.3.3; python_version >= "3.7" and python_version < "4.0"
mkdocs-literate-nav==0.4.0; python_version >= "3.6" and python_version < "4.0"
mkdocs-material-extensions==1.0.3; python_version >= "3.6"
mkdocs-material==7.3.6
mkdocs==1.2.3; python_version >= "3.6"
mkdocstrings==0.16.2; python_version >= "3.6"
mypy-extensions==0.4.3; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.5"
mypy==0.910; python_version >= "3.5"
packaging==21.3; python_version >= "3.6"
panaetius @ /home/dtomlinson/git-repos/python/panaetius; python_version >= "3.7" and python_version < "4.0"
pbr==5.8.0; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.6"
pefile==2021.9.3; sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.6.0"
pendulum==2.1.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0")
pep8-naming==0.10.0; python_full_version >= "3.6.1" and python_version < "4.0"
pep8==1.7.1; python_full_version >= "3.6.1" and python_version < "4.0"
platformdirs==2.4.0; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.6.1"
pluggy==1.0.0; python_version >= "3.6"
prospector==1.5.1; python_full_version >= "3.6.1" and python_version < "4.0"
ptyprocess==0.7.0; sys_platform != "win32" and python_version >= "3.6"
py==1.11.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6"
pycodestyle==2.8.0; python_full_version >= "3.6.1" and python_version < "4.0"
pydocstyle==6.1.1; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.6"
pyflakes==2.3.1; python_full_version >= "3.6.1" and python_version < "4.0"
pygments==2.10.0; python_version >= "3.5"
pyinstaller-hooks-contrib==2021.3; python_version >= "3.6"
pyinstaller==4.5.1; python_version >= "3.6"
pylint-celery==0.3; python_full_version >= "3.6.1" and python_version < "4.0"
pylint-django==2.4.4; python_full_version >= "3.6.1" and python_version < "4.0"
pylint-flask==0.6; python_full_version >= "3.6.1" and python_version < "4.0"
pylint-plugin-utils==0.6; python_full_version >= "3.6.1" and python_version < "4.0"
pylint==2.11.1; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.6.1"
pymdown-extensions==9.1; python_version >= "3.6"
pyparsing==3.0.6; python_version >= "3.6"
pytest-cov==3.0.0; python_version >= "3.6"
pytest-custom-exit-code==0.3.0; (python_version > "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0")
pytest-datadir==1.3.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.4.0")
pytest==6.2.5; python_version >= "3.6"
python-dateutil==2.8.2; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6"
pytkdocs==0.12.0; python_full_version >= "3.6.1" and python_version >= "3.6"
pytzdata==2020.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0"
pywin32-ctypes==0.2.0; sys_platform == "win32" and python_version >= "3.6"
pyyaml-env-tag==0.1; python_version >= "3.6"
pyyaml==6.0; python_version >= "3.7" and python_version < "4.0" and python_full_version >= "3.6.1"
requests==2.26.0; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version >= "3.5"
requirements-detector==0.7; python_full_version >= "3.6.1" and python_version < "4.0"
safety==1.10.3; python_version >= "3.5"
semver==2.13.0; python_full_version >= "3.6.2"
setoptconf-tmp==0.3.1; python_full_version >= "3.6.1" and python_version < "4.0"
six==1.16.0; python_full_version >= "3.6.1" and python_version >= "3.6" and python_version < "3.9"
smmap==5.0.0; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.7"
snowballstemmer==2.2.0; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.6"
stevedore==3.5.0; python_full_version >= "3.6.1" and python_version < "4.0" and python_version >= "3.6"
toml==0.10.2; python_version >= "3.7" and python_version < "4.0" and python_full_version >= "3.6.1" and (python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.6") and (python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.5")
tomli==1.2.2; python_version >= "3.6"
typing-extensions==4.0.0; python_full_version >= "3.6.1" and python_version < "3.10" and python_version >= "3.7"
urllib3==1.26.7; python_version >= "3.5" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version < "4" and python_version >= "3.5"
watchdog==2.1.6; python_version >= "3.6"
wrapt==1.13.3; python_version >= "3.6" and python_version < "4.0" and python_full_version >= "3.6.1"
zipp==3.6.0; python_version >= "3.6"

37
setup.py Normal file
View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
from setuptools import setup
packages = \
['tembo', 'tembo.cli', 'tembo.journal', 'tembo.utils']
package_data = \
{'': ['*']}
install_requires = \
['Jinja2>=3.0.2,<4.0.0',
'click>=8.0.3,<9.0.0',
'panaetius>=2.3.2,<3.0.0',
'pendulum>=2.1.2,<3.0.0']
entry_points = \
{'console_scripts': ['tembo = tembo.cli.cli:main']}
setup_kwargs = {
'name': 'tembo',
'version': '0.0.8',
'description': 'A simple folder organiser for your work notes.',
'long_description': '# Tembo\n\n<img\n src="https://raw.githubusercontent.com/tembo-pages/tembo-core/main/assets/tembo_logo.png"\n width="200px"\n/>\n\nA simple folder organiser for your work notes.\n\n![](https://img.shields.io/codecov/c/github/tembo-pages/tembo-core?style=flat-square)\n\n![Sonar Coverage](https://img.shields.io/sonar/coverage/tembo-pages_tembo-core?server=https%3A%2F%2Fsonarcloud.io&style=flat-square)\n![Sonar Tests](https://img.shields.io/sonar/tests/tembo-pages_tembo-core?compact_message&failed_label=failed&passed_label=passed&server=https%3A%2F%2Fsonarcloud.io&skipped_label=skipped&style=flat-square)\n![Sonar Tech Debt](https://img.shields.io/sonar/tech_debt/tembo-pages_tembo-core?server=https%3A%2F%2Fsonarcloud.io&style=flat-square)\n\n## config.yml\n\n```yaml\n# time tokens: https://strftime.org\ntembo:\n base_path: ~/tembo\n # template_path: ~/tembo/templates\n scopes:\n - name: scratchpad\n example: tembo new scratchpad\n path: "scratchpad/{d:%B_%Y}"\n filename: "{d:%B_%W}"\n extension: md\n template_filename: scratchpad.md.tpl\n - name: wtodo\n example: tembo new wtodo | directory is month_year, filename is month_week-of-year\n path: "wtodo/{d:%B_%Y}"\n filename: "week_{d:%W}"\n extension: todo\n template_filename: weekly.todo.tpl\n - name: meeting\n example: tembo new meeting $meeting_title\n path: "meetings/{d:%B_%y}"\n filename: "{d:%a_%d_%m_%y}-{input0}"\n extension: md\n template_filename: meeting.md.tpl\n - name: knowledge\n example: tembo new knowledge $project $filename\n path: "knowledge/{input0}"\n filename: "{input1}"\n extension: md\n template_filename: knowledge.md.tpl\n logging:\n level: INFO\n path: ~/tembo/.logs\n```\n\n## templates\n\n###\xa0knowledge\n\n```\n---\ncreated: {d:%d-%m-%Y}\n---\n\n# {input0} - {input1}.md\n```\n\n### meeting\n\n```\n---\ncreated: {d:%d-%m-%Y}\n---\n\n# {d:%A %d %B %Y} - {input0}\n\n## People\n\nHead:\n\nAttendees:\n\n## Actions\n\n\n## Notes\n\n```\n\n### scratchpad\n\n```\n---\ncreated: {d:%d-%m-%Y}\n---\n\n# Scratchpad - Week {d:%W} - {d:%B-%y}\n```\n\n### wtodo\n\n```\n---\ncreated: {d:%d-%m-%Y}\n---\n\nWeekly TODO | Week {d:%W} {d:%B}-{d:%Y}\n\nWork:\n\nDocumentation:\n```\n',
'author': 'dtomlinson',
'author_email': 'dtomlinson@panaetius.co.uk',
'maintainer': None,
'maintainer_email': None,
'url': 'https://tembo-pages.github.io/tembo-core/',
'packages': packages,
'package_data': package_data,
'install_requires': install_requires,
'entry_points': entry_points,
'python_requires': '>=3.8,<4.0',
}
setup(**setup_kwargs)

9
sonar-project.properties Normal file
View File

@@ -0,0 +1,9 @@
sonar.organization=tembo-pages
sonar.projectKey=tembo-pages_tembo-core
sonar.sources=tembo
sonar.tests=tests
sonar.python.version=3
sonar.python.coverage.reportPaths=coverage.xml
sonar.python.xunit.reportPath=pytest.xml

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

0
tests/__init__.py Normal file
View File

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
assert 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
assert 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,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