Coverage report: + 96% +
+Shortcuts on this page
++ n + s + m + x + c change column sorting +
+diff --git a/coverage/.gitignore b/coverage/.gitignore new file mode 100644 index 0000000..ccccf14 --- /dev/null +++ b/coverage/.gitignore @@ -0,0 +1,2 @@ +# Created by coverage.py +* diff --git a/coverage/coverage_html.js b/coverage/coverage_html.js new file mode 100644 index 0000000..00e1848 --- /dev/null +++ b/coverage/coverage_html.js @@ -0,0 +1,575 @@ +// Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +// For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt + +// Coverage.py HTML report browser code. +/*jslint browser: true, sloppy: true, vars: true, plusplus: true, maxerr: 50, indent: 4 */ +/*global coverage: true, document, window, $ */ + +coverage = {}; + +// General helpers +function debounce(callback, wait) { + let timeoutId = null; + return function(...args) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + callback.apply(this, args); + }, wait); + }; +}; + +function checkVisible(element) { + const rect = element.getBoundingClientRect(); + const viewBottom = Math.max(document.documentElement.clientHeight, window.innerHeight); + const viewTop = 30; + return !(rect.bottom < viewTop || rect.top >= viewBottom); +} + +// Helpers for table sorting +function getCellValue(row, column = 0) { + const cell = row.cells[column] + if (cell.childElementCount == 1) { + const child = cell.firstElementChild + if (child instanceof HTMLTimeElement && child.dateTime) { + return child.dateTime + } else if (child instanceof HTMLDataElement && child.value) { + return child.value + } + } + return cell.innerText || cell.textContent; +} + +function rowComparator(rowA, rowB, column = 0) { + let valueA = getCellValue(rowA, column); + let valueB = getCellValue(rowB, column); + if (!isNaN(valueA) && !isNaN(valueB)) { + return valueA - valueB + } + return valueA.localeCompare(valueB, undefined, {numeric: true}); +} + +function sortColumn(th) { + // Get the current sorting direction of the selected header, + // clear state on other headers and then set the new sorting direction + const currentSortOrder = th.getAttribute("aria-sort"); + [...th.parentElement.cells].forEach(header => header.setAttribute("aria-sort", "none")); + if (currentSortOrder === "none") { + th.setAttribute("aria-sort", th.dataset.defaultSortOrder || "ascending"); + } else { + th.setAttribute("aria-sort", currentSortOrder === "ascending" ? "descending" : "ascending"); + } + + const column = [...th.parentElement.cells].indexOf(th) + + // Sort all rows and afterwards append them in order to move them in the DOM + Array.from(th.closest("table").querySelectorAll("tbody tr")) + .sort((rowA, rowB) => rowComparator(rowA, rowB, column) * (th.getAttribute("aria-sort") === "ascending" ? 1 : -1)) + .forEach(tr => tr.parentElement.appendChild(tr) ); +} + +// Find all the elements with data-shortcut attribute, and use them to assign a shortcut key. +coverage.assign_shortkeys = function () { + document.querySelectorAll("[data-shortcut]").forEach(element => { + document.addEventListener("keypress", event => { + if (event.target.tagName.toLowerCase() === "input") { + return; // ignore keypress from search filter + } + if (event.key === element.dataset.shortcut) { + element.click(); + } + }); + }); +}; + +// Create the events for the filter box. +coverage.wire_up_filter = function () { + // Cache elements. + const table = document.querySelector("table.index"); + const table_body_rows = table.querySelectorAll("tbody tr"); + const no_rows = document.getElementById("no_rows"); + + // Observe filter keyevents. + document.getElementById("filter").addEventListener("input", debounce(event => { + // Keep running total of each metric, first index contains number of shown rows + const totals = new Array(table.rows[0].cells.length).fill(0); + // Accumulate the percentage as fraction + totals[totals.length - 1] = { "numer": 0, "denom": 0 }; + + // Hide / show elements. + table_body_rows.forEach(row => { + if (!row.cells[0].textContent.includes(event.target.value)) { + // hide + row.classList.add("hidden"); + return; + } + + // show + row.classList.remove("hidden"); + totals[0]++; + + for (let column = 1; column < totals.length; column++) { + // Accumulate dynamic totals + cell = row.cells[column] + if (column === totals.length - 1) { + // Last column contains percentage + const [numer, denom] = cell.dataset.ratio.split(" "); + totals[column]["numer"] += parseInt(numer, 10); + totals[column]["denom"] += parseInt(denom, 10); + } else { + totals[column] += parseInt(cell.textContent, 10); + } + } + }); + + // Show placeholder if no rows will be displayed. + if (!totals[0]) { + // Show placeholder, hide table. + no_rows.style.display = "block"; + table.style.display = "none"; + return; + } + + // Hide placeholder, show table. + no_rows.style.display = null; + table.style.display = null; + + const footer = table.tFoot.rows[0]; + // Calculate new dynamic sum values based on visible rows. + for (let column = 1; column < totals.length; column++) { + // Get footer cell element. + const cell = footer.cells[column]; + + // Set value into dynamic footer cell element. + if (column === totals.length - 1) { + // Percentage column uses the numerator and denominator, + // and adapts to the number of decimal places. + const match = /\.([0-9]+)/.exec(cell.textContent); + const places = match ? match[1].length : 0; + const { numer, denom } = totals[column]; + cell.dataset.ratio = `${numer} ${denom}`; + // Check denom to prevent NaN if filtered files contain no statements + cell.textContent = denom + ? `${(numer * 100 / denom).toFixed(places)}%` + : `${(100).toFixed(places)}%`; + } else { + cell.textContent = totals[column]; + } + } + })); + + // Trigger change event on setup, to force filter on page refresh + // (filter value may still be present). + document.getElementById("filter").dispatchEvent(new Event("change")); +}; + +coverage.INDEX_SORT_STORAGE = "COVERAGE_INDEX_SORT_2"; + +// Loaded on index.html +coverage.index_ready = function () { + coverage.assign_shortkeys(); + coverage.wire_up_filter(); + document.querySelectorAll("[data-sortable] th[aria-sort]").forEach( + th => th.addEventListener("click", e => sortColumn(e.target)) + ); + + // Look for a localStorage item containing previous sort settings: + const stored_list = localStorage.getItem(coverage.INDEX_SORT_STORAGE); + + if (stored_list) { + const {column, direction} = JSON.parse(stored_list); + const th = document.querySelector("[data-sortable]").tHead.rows[0].cells[column]; + th.setAttribute("aria-sort", direction === "ascending" ? "descending" : "ascending"); + th.click() + } + + // Watch for page unload events so we can save the final sort settings: + window.addEventListener("unload", function () { + const th = document.querySelector('[data-sortable] th[aria-sort="ascending"], [data-sortable] [aria-sort="descending"]'); + if (!th) { + return; + } + localStorage.setItem(coverage.INDEX_SORT_STORAGE, JSON.stringify({ + column: [...th.parentElement.cells].indexOf(th), + direction: th.getAttribute("aria-sort"), + })); + }); +}; + +// -- pyfile stuff -- + +coverage.LINE_FILTERS_STORAGE = "COVERAGE_LINE_FILTERS"; + +coverage.pyfile_ready = function () { + // If we're directed to a particular line number, highlight the line. + var frag = location.hash; + if (frag.length > 2 && frag[1] === 't') { + document.querySelector(frag).closest(".n").classList.add("highlight"); + coverage.set_sel(parseInt(frag.substr(2), 10)); + } else { + coverage.set_sel(0); + } + + const on_click = function(sel, fn) { + const elt = document.querySelector(sel); + if (elt) { + elt.addEventListener("click", fn); + } + } + on_click(".button_toggle_run", coverage.toggle_lines); + on_click(".button_toggle_mis", coverage.toggle_lines); + on_click(".button_toggle_exc", coverage.toggle_lines); + on_click(".button_toggle_par", coverage.toggle_lines); + + on_click(".button_next_chunk", coverage.to_next_chunk_nicely); + on_click(".button_prev_chunk", coverage.to_prev_chunk_nicely); + on_click(".button_top_of_page", coverage.to_top); + on_click(".button_first_chunk", coverage.to_first_chunk); + + coverage.filters = undefined; + try { + coverage.filters = localStorage.getItem(coverage.LINE_FILTERS_STORAGE); + } catch(err) {} + + if (coverage.filters) { + coverage.filters = JSON.parse(coverage.filters); + } + else { + coverage.filters = {run: false, exc: true, mis: true, par: true}; + } + + for (cls in coverage.filters) { + coverage.set_line_visibilty(cls, coverage.filters[cls]); + } + + coverage.assign_shortkeys(); + coverage.init_scroll_markers(); + coverage.wire_up_sticky_header(); + + // Rebuild scroll markers when the window height changes. + window.addEventListener("resize", coverage.build_scroll_markers); +}; + +coverage.toggle_lines = function (event) { + const btn = event.target.closest("button"); + const category = btn.value + const show = !btn.classList.contains("show_" + category); + coverage.set_line_visibilty(category, show); + coverage.build_scroll_markers(); + coverage.filters[category] = show; + try { + localStorage.setItem(coverage.LINE_FILTERS_STORAGE, JSON.stringify(coverage.filters)); + } catch(err) {} +}; + +coverage.set_line_visibilty = function (category, should_show) { + const cls = "show_" + category; + const btn = document.querySelector(".button_toggle_" + category); + if (btn) { + if (should_show) { + document.querySelectorAll("#source ." + category).forEach(e => e.classList.add(cls)); + btn.classList.add(cls); + } + else { + document.querySelectorAll("#source ." + category).forEach(e => e.classList.remove(cls)); + btn.classList.remove(cls); + } + } +}; + +// Return the nth line div. +coverage.line_elt = function (n) { + return document.getElementById("t" + n)?.closest("p"); +}; + +// Set the selection. b and e are line numbers. +coverage.set_sel = function (b, e) { + // The first line selected. + coverage.sel_begin = b; + // The next line not selected. + coverage.sel_end = (e === undefined) ? b+1 : e; +}; + +coverage.to_top = function () { + coverage.set_sel(0, 1); + coverage.scroll_window(0); +}; + +coverage.to_first_chunk = function () { + coverage.set_sel(0, 1); + coverage.to_next_chunk(); +}; + +// Return a string indicating what kind of chunk this line belongs to, +// or null if not a chunk. +coverage.chunk_indicator = function (line_elt) { + const classes = line_elt?.className; + if (!classes) { + return null; + } + const match = classes.match(/\bshow_\w+\b/); + if (!match) { + return null; + } + return match[0]; +}; + +coverage.to_next_chunk = function () { + const c = coverage; + + // Find the start of the next colored chunk. + var probe = c.sel_end; + var chunk_indicator, probe_line; + while (true) { + probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + chunk_indicator = c.chunk_indicator(probe_line); + if (chunk_indicator) { + break; + } + probe++; + } + + // There's a next chunk, `probe` points to it. + var begin = probe; + + // Find the end of this chunk. + var next_indicator = chunk_indicator; + while (next_indicator === chunk_indicator) { + probe++; + probe_line = c.line_elt(probe); + next_indicator = c.chunk_indicator(probe_line); + } + c.set_sel(begin, probe); + c.show_selection(); +}; + +coverage.to_prev_chunk = function () { + const c = coverage; + + // Find the end of the prev colored chunk. + var probe = c.sel_begin-1; + var probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + var chunk_indicator = c.chunk_indicator(probe_line); + while (probe > 1 && !chunk_indicator) { + probe--; + probe_line = c.line_elt(probe); + if (!probe_line) { + return; + } + chunk_indicator = c.chunk_indicator(probe_line); + } + + // There's a prev chunk, `probe` points to its last line. + var end = probe+1; + + // Find the beginning of this chunk. + var prev_indicator = chunk_indicator; + while (prev_indicator === chunk_indicator) { + probe--; + if (probe <= 0) { + return; + } + probe_line = c.line_elt(probe); + prev_indicator = c.chunk_indicator(probe_line); + } + c.set_sel(probe+1, end); + c.show_selection(); +}; + +// Returns 0, 1, or 2: how many of the two ends of the selection are on +// the screen right now? +coverage.selection_ends_on_screen = function () { + if (coverage.sel_begin === 0) { + return 0; + } + + const begin = coverage.line_elt(coverage.sel_begin); + const end = coverage.line_elt(coverage.sel_end-1); + + return ( + (checkVisible(begin) ? 1 : 0) + + (checkVisible(end) ? 1 : 0) + ); +}; + +coverage.to_next_chunk_nicely = function () { + if (coverage.selection_ends_on_screen() === 0) { + // The selection is entirely off the screen: + // Set the top line on the screen as selection. + + // This will select the top-left of the viewport + // As this is most likely the span with the line number we take the parent + const line = document.elementFromPoint(0, 0).parentElement; + if (line.parentElement !== document.getElementById("source")) { + // The element is not a source line but the header or similar + coverage.select_line_or_chunk(1); + } else { + // We extract the line number from the id + coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); + } + } + coverage.to_next_chunk(); +}; + +coverage.to_prev_chunk_nicely = function () { + if (coverage.selection_ends_on_screen() === 0) { + // The selection is entirely off the screen: + // Set the lowest line on the screen as selection. + + // This will select the bottom-left of the viewport + // As this is most likely the span with the line number we take the parent + const line = document.elementFromPoint(document.documentElement.clientHeight-1, 0).parentElement; + if (line.parentElement !== document.getElementById("source")) { + // The element is not a source line but the header or similar + coverage.select_line_or_chunk(coverage.lines_len); + } else { + // We extract the line number from the id + coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); + } + } + coverage.to_prev_chunk(); +}; + +// Select line number lineno, or if it is in a colored chunk, select the +// entire chunk +coverage.select_line_or_chunk = function (lineno) { + var c = coverage; + var probe_line = c.line_elt(lineno); + if (!probe_line) { + return; + } + var the_indicator = c.chunk_indicator(probe_line); + if (the_indicator) { + // The line is in a highlighted chunk. + // Search backward for the first line. + var probe = lineno; + var indicator = the_indicator; + while (probe > 0 && indicator === the_indicator) { + probe--; + probe_line = c.line_elt(probe); + if (!probe_line) { + break; + } + indicator = c.chunk_indicator(probe_line); + } + var begin = probe + 1; + + // Search forward for the last line. + probe = lineno; + indicator = the_indicator; + while (indicator === the_indicator) { + probe++; + probe_line = c.line_elt(probe); + indicator = c.chunk_indicator(probe_line); + } + + coverage.set_sel(begin, probe); + } + else { + coverage.set_sel(lineno); + } +}; + +coverage.show_selection = function () { + // Highlight the lines in the chunk + document.querySelectorAll("#source .highlight").forEach(e => e.classList.remove("highlight")); + for (let probe = coverage.sel_begin; probe < coverage.sel_end; probe++) { + coverage.line_elt(probe).querySelector(".n").classList.add("highlight"); + } + + coverage.scroll_to_selection(); +}; + +coverage.scroll_to_selection = function () { + // Scroll the page if the chunk isn't fully visible. + if (coverage.selection_ends_on_screen() < 2) { + const element = coverage.line_elt(coverage.sel_begin); + coverage.scroll_window(element.offsetTop - 60); + } +}; + +coverage.scroll_window = function (to_pos) { + window.scroll({top: to_pos, behavior: "smooth"}); +}; + +coverage.init_scroll_markers = function () { + // Init some variables + coverage.lines_len = document.querySelectorAll('#source > p').length; + + // Build html + coverage.build_scroll_markers(); +}; + +coverage.build_scroll_markers = function () { + const temp_scroll_marker = document.getElementById('scroll_marker') + if (temp_scroll_marker) temp_scroll_marker.remove(); + // Don't build markers if the window has no scroll bar. + if (document.body.scrollHeight <= window.innerHeight) { + return; + } + + const marker_scale = window.innerHeight / document.body.scrollHeight; + const line_height = Math.min(Math.max(3, window.innerHeight / coverage.lines_len), 10); + + let previous_line = -99, last_mark, last_top; + + const scroll_marker = document.createElement("div"); + scroll_marker.id = "scroll_marker"; + document.getElementById('source').querySelectorAll( + 'p.show_run, p.show_mis, p.show_exc, p.show_exc, p.show_par' + ).forEach(element => { + const line_top = Math.floor(element.offsetTop * marker_scale); + const line_number = parseInt(element.id.substr(1)); + + if (line_number === previous_line + 1) { + // If this solid missed block just make previous mark higher. + last_mark.style.height = `${line_top + line_height - last_top}px`; + } else { + // Add colored line in scroll_marker block. + last_mark = document.createElement("div"); + last_mark.id = `m${line_number}`; + last_mark.classList.add("marker"); + last_mark.style.height = `${line_height}px`; + last_mark.style.top = `${line_top}px`; + scroll_marker.append(last_mark); + last_top = line_top; + } + + previous_line = line_number; + }); + + // Append last to prevent layout calculation + document.body.append(scroll_marker); +}; + +coverage.wire_up_sticky_header = function () { + const header = document.querySelector('header'); + const header_bottom = ( + header.querySelector('.content h2').getBoundingClientRect().top - + header.getBoundingClientRect().top + ); + + function updateHeader() { + if (window.scrollY > header_bottom) { + header.classList.add('sticky'); + } else { + header.classList.remove('sticky'); + } + } + + window.addEventListener('scroll', updateHeader); + updateHeader(); +}; + +document.addEventListener("DOMContentLoaded", () => { + if (document.body.classList.contains("indexfile")) { + coverage.index_ready(); + } else { + coverage.pyfile_ready(); + } +}); diff --git a/coverage/covindex.html b/coverage/covindex.html new file mode 100644 index 0000000..8930c12 --- /dev/null +++ b/coverage/covindex.html @@ -0,0 +1,138 @@ + + +
+ +Shortcuts on this page
++ n + s + m + x + c change column sorting +
+| Module | +statements | +missing | +excluded | +coverage | +
|---|---|---|---|---|
| tembo/__init__.py | +2 | +0 | +0 | +100% | +
| tembo/__main__.py | +4 | +4 | +0 | +0% | +
| tembo/_version.py | +1 | +0 | +0 | +100% | +
| tembo/cli/__init__.py | +19 | +5 | +0 | +74% | +
| tembo/cli/cli.py | +95 | +0 | +0 | +100% | +
| tembo/exceptions.py | +12 | +0 | +0 | +100% | +
| tembo/journal/__init__.py | +1 | +0 | +0 | +100% | +
| tembo/journal/pages.py | +141 | +2 | +6 | +99% | +
| tembo/utils/__init__.py | +4 | +0 | +0 | +100% | +
| Total | +279 | +11 | +6 | +96% | +
+ No items found using the specified filter. +
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""Subpackage that contains the CLI application."""
+ +3import os
+4from typing import Any
+ +6import panaetius
+7from panaetius.exceptions import LoggingDirectoryDoesNotExistException
+ +9if (config_path := os.environ.get("TEMBO_CONFIG")) is not None:
+10 CONFIG: Any = panaetius.Config("tembo", config_path, skip_header_init=True)
+11else:
+12 CONFIG = panaetius.Config("tembo", "~/tembo/.config", skip_header_init=True)
+ + +15panaetius.set_config(CONFIG, "base_path", "~/tembo")
+16panaetius.set_config(CONFIG, "template_path", "~/tembo/.templates")
+17panaetius.set_config(CONFIG, "scopes", {})
+18panaetius.set_config(CONFIG, "logging.level", "DEBUG")
+19panaetius.set_config(CONFIG, "logging.path")
+ +21try:
+22 logger = panaetius.set_logger(
+23 CONFIG, panaetius.SimpleLogger(logging_level=CONFIG.logging_level)
+24 )
+25except LoggingDirectoryDoesNotExistException:
+26 _LOGGING_PATH = CONFIG.logging_path
+27 CONFIG.logging_path = ""
+28 logger = panaetius.set_logger(
+29 CONFIG, panaetius.SimpleLogger(logging_level=CONFIG.logging_level)
+30 )
+31 logger.warning("Logging directory %s does not exist", _LOGGING_PATH)
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""Submodule which contains the CLI implementation using Click."""
+ +3from __future__ import annotations
+ +5import pathlib
+6from typing import Collection
+ +8import click
+ +10import tembo.cli
+11from tembo import exceptions
+12from tembo._version import __version__
+13from tembo.journal import pages
+14from tembo.utils import Success
+ +16CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
+ + +19@click.group(context_settings=CONTEXT_SETTINGS, options_metavar="<options>")
+20@click.version_option(
+21 __version__,
+22 "-v",
+23 "--version",
+24 prog_name="Tembo",
+25 message=f"Tembo v{__version__} 🐘",
+26)
+27def main():
+28 """Tembo - an organiser for work notes."""
+ + +31@click.command(options_metavar="<options>", name="list")
+32def list_all():
+33 """List all scopes defined in the config.yml."""
+34 _all_scopes = [user_scope["name"] for user_scope in tembo.cli.CONFIG.scopes]
+35 _all_scopes_joined = "', '".join(_all_scopes)
+36 cli_message(f"{len(_all_scopes)} names found in config.yml: '{_all_scopes_joined}'")
+37 raise SystemExit(0)
+ + +40@click.command(options_metavar="<options>")
+41@click.argument("scope", metavar="<scope>")
+42@click.argument(
+43 "inputs",
+44 nargs=-1,
+45 metavar="<inputs>",
+46)
+47@click.option(
+48 "--dry-run",
+49 is_flag=True,
+50 default=False,
+51 help="Show the full path of the page to be created without actually saving the page to disk "
+52 "and exit.",
+53)
+54@click.option(
+55 "--example",
+56 is_flag=True,
+57 default=False,
+58 help="Show the example command in the config.yml if it exists and exit.",
+59)
+60def new(scope: str, inputs: Collection[str], dry_run: bool, example: bool): # noqa
+61 """
+62 Create a new page.
+ +64 \b
+65 `<scope>` The name of the scope in the config.yml.
+66 \b
+67 `<inputs>` Any input token values that are defined in the config.yml for this scope.
+68 Accepts multiple inputs separated by a space.
+ +70 \b
+71 Example:
+72 `tembo new meeting my_presentation`
+73 """
+74 # check that the name exists in the config.yml
+75 try:
+76 _new_verify_name_exists(scope)
+77 except (
+78 exceptions.ScopeNotFound,
+79 exceptions.EmptyConfigYML,
+80 exceptions.MissingConfigYML,
+81 ) as tembo_exception:
+82 cli_message(tembo_exception.args[0])
+83 raise SystemExit(1) from tembo_exception
+ +85 # get the scope configuration from the config.yml
+86 try:
+87 config_scope = _new_get_config_scope(scope)
+88 except exceptions.MandatoryKeyNotFound as mandatory_key_not_found:
+89 cli_message(mandatory_key_not_found.args[0])
+90 raise SystemExit(1) from mandatory_key_not_found
+ +92 # if --example flag, return the example to the user
+93 _new_show_example(example, config_scope)
+ +95 # if the name is in the config.yml, create the scoped page
+96 scoped_page = _new_create_scoped_page(config_scope, inputs)
+ +98 if dry_run:
+99 cli_message(f"{scoped_page.path} will be created")
+100 raise SystemExit(0)
+ +102 try:
+103 result = scoped_page.save_to_disk()
+104 if isinstance(result, Success):
+105 cli_message(f"Saved {result.message} to disk")
+106 raise SystemExit(0)
+107 except exceptions.ScopedPageAlreadyExists as scoped_page_already_exists:
+108 cli_message(f"File {scoped_page_already_exists}")
+109 raise SystemExit(0) from scoped_page_already_exists
+ + +112def _new_create_scoped_page(config_scope: dict, inputs: Collection[str]) -> pages.Page:
+113 page_creator_options = pages.PageCreatorOptions(
+114 base_path=tembo.cli.CONFIG.base_path,
+115 template_path=tembo.cli.CONFIG.template_path,
+116 page_path=config_scope["path"],
+117 filename=config_scope["filename"],
+118 extension=config_scope["extension"],
+119 name=config_scope["name"],
+120 example=config_scope["example"],
+121 user_input=inputs,
+122 template_filename=config_scope["template_filename"],
+123 )
+124 try:
+125 return pages.ScopedPageCreator(page_creator_options).create_page()
+126 except exceptions.BasePathDoesNotExistError as base_path_does_not_exist_error:
+127 cli_message(base_path_does_not_exist_error.args[0])
+128 raise SystemExit(1) from base_path_does_not_exist_error
+129 except exceptions.TemplateFileNotFoundError as template_file_not_found_error:
+130 cli_message(template_file_not_found_error.args[0])
+131 raise SystemExit(1) from template_file_not_found_error
+132 except exceptions.MismatchedTokenError as mismatched_token_error:
+133 if config_scope["example"] is not None:
+134 cli_message(
+135 f"Your tembo config.yml/template specifies {mismatched_token_error.expected}"
+136 + f" input tokens, you gave {mismatched_token_error.given}. "
+137 + f'Example: {config_scope["example"]}'
+138 )
+139 raise SystemExit(1) from mismatched_token_error
+140 cli_message(
+141 f"Your tembo config.yml/template specifies {mismatched_token_error.expected}"
+142 + f" input tokens, you gave {mismatched_token_error.given}"
+143 )
+ +145 raise SystemExit(1) from mismatched_token_error
+ + +148def _new_verify_name_exists(scope: str) -> None:
+149 _name_found = scope in [user_scope["name"] for user_scope in tembo.cli.CONFIG.scopes]
+150 if _name_found:
+151 return
+152 if len(tembo.cli.CONFIG.scopes) > 0:
+153 # if the name is missing in the config.yml, raise error
+154 raise exceptions.ScopeNotFound(f"Scope {scope} not found in config.yml")
+155 # raise error if no config.yml found
+156 if pathlib.Path(tembo.cli.CONFIG.config_path).exists():
+157 raise exceptions.EmptyConfigYML(
+158 f"Config.yml found in {tembo.cli.CONFIG.config_path} is empty"
+159 )
+160 raise exceptions.MissingConfigYML(f"No config.yml found in {tembo.cli.CONFIG.config_path}")
+ + +163def _new_get_config_scope(scope: str) -> dict:
+164 config_scope = {}
+165 optional_keys = ["example", "template_filename"]
+166 for option in [
+167 "name",
+168 "path",
+169 "filename",
+170 "extension",
+171 "example",
+172 "template_filename",
+173 ]:
+174 try:
+175 config_scope.update(
+176 {
+177 option: str(user_scope[option])
+178 for user_scope in tembo.cli.CONFIG.scopes
+179 if user_scope["name"] == scope
+180 }
+181 )
+182 except KeyError as key_error:
+183 if key_error.args[0] in optional_keys:
+184 config_scope.update({key_error.args[0]: None})
+185 continue
+186 raise exceptions.MandatoryKeyNotFound(f"Key {key_error} not found in config.yml")
+187 return config_scope
+ + +190def _new_show_example(example: bool, config_scope: dict) -> None:
+191 if example:
+192 if isinstance(config_scope["example"], str):
+193 cli_message(f'Example for {config_scope["name"]}: {config_scope["example"]}')
+194 else:
+195 cli_message("No example in config.yml")
+196 raise SystemExit(0)
+ + +199def cli_message(message: str) -> None:
+200 """
+201 Relay a message to the user using the CLI.
+ +203 Args:
+204 message (str): THe message to be displayed.
+205 """
+206 click.echo(f"[TEMBO] {message} 🐘")
+ + +209main.add_command(new)
+210main.add_command(list_all)
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""
+2Tembo package.
+ +4A simple folder organiser for your work notes.
+5"""
+ +7# flake8: noqa
+ +9from . import exceptions
+10from .journal.pages import PageCreatorOptions, ScopedPageCreator
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""
+2Entrypoint module.
+ +4Used when using `python -m tembo` to invoke the CLI.
+5"""
+ +7import sys
+ +9from tembo.cli.cli import main
+ +11if __name__ == "__main__":
+12 sys.exit(main())
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""Module containing the version of tembo."""
+ +3__version__ = "1.0.0"
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""Module containing custom exceptions."""
+ + +4class MismatchedTokenError(Exception):
+5 """
+6 Raised when the number of input tokens does not match the user config.
+ +8 Attributes:
+9 expected (int): number of input tokens in the user config.
+10 given (int): number of input tokens passed in.
+11 """
+ +13 def __init__(self, expected: int, given: int) -> None:
+14 """
+15 Initialise the exception.
+ +17 Args:
+18 expected (int): number of input tokens in the user config.
+19 given (int): number of input tokens passed in.
+20 """
+21 self.expected = expected
+22 self.given = given
+23 super().__init__()
+ + +26class BasePathDoesNotExistError(Exception):
+27 """Raised if the base path does not exist."""
+ + +30class TemplateFileNotFoundError(Exception):
+31 """Raised if the template file does not exist."""
+ + +34class ScopedPageAlreadyExists(Exception):
+35 """Raised if the scoped page file already exists."""
+ + +38class MissingConfigYML(Exception):
+39 """Raised if the config.yml file is missing."""
+ + +42class EmptyConfigYML(Exception):
+43 """Raised if the config.yml file is empty."""
+ + +46class ScopeNotFound(Exception):
+47 """Raised if the scope does not exist in the config.yml."""
+ + +50class MandatoryKeyNotFound(Exception):
+51 """Raised if a mandatory key is not found in the config.yml."""
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""Subpackage containing utility objects."""
+ +3from dataclasses import dataclass
+ + +6@dataclass
+7class Success:
+8 """
+9 A Tembo success object.
+ +11 This is returned from [Page][tembo.journal.pages.ScopedPage] methods such as
+12 [save_to_disk()][tembo.journal.pages.ScopedPage.save_to_disk]
+ +14 Attributes:
+15 message (str): A success message.
+16 """
+ +18 message: str
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""Subpackage containing the logic to create Tembo journals & pages."""
+ +3# flake8: noqa
+ +5from tembo.journal import pages
+Shortcuts on this page
++ r + m + x + toggle line displays +
++ j + k next/prev highlighted chunk +
++ 0 (zero) top of page +
++ 1 (one) first highlighted chunk +
+1"""Submodule containing the factories & page objects to create Tembo pages."""
+ +3from __future__ import annotations
+ +5import pathlib
+6import re
+7from abc import ABCMeta, abstractmethod
+8from dataclasses import dataclass
+9from typing import Collection, Optional
+ +11import jinja2
+12import pendulum
+13from jinja2.exceptions import TemplateNotFound
+ +15import tembo.utils
+16from tembo import exceptions
+ + +19@dataclass
+20class PageCreatorOptions:
+21 """
+22 Options [dataclass][dataclasses.dataclass] to create a Page.
+ +24 This is passed to an implemented instance of [PageCreator][tembo.journal.pages.PageCreator]
+ +26 Attributes:
+27 base_path (str): The base path.
+28 page_path (str): The path of the page relative to the base path.
+29 filename (str): The filename of the page.
+30 extension (str): The extension of the page.
+31 name (str): The name of the scope.
+32 user_input (Collection[str] | None, optional): User input tokens.
+33 example (str | None, optional): User example command.
+34 template_path (str | None, optional): The path which contains the templates. This should
+35 be the full path and not relative to the base path.
+36 template_filename (str | None, optional): The template filename with extension relative
+37 to the template path.
+38 """
+ +40 base_path: str
+41 page_path: str
+42 filename: str
+43 extension: str
+44 name: str
+45 user_input: Optional[Collection[str]] = None
+46 example: Optional[str] = None
+47 template_path: Optional[str] = None
+48 template_filename: Optional[str] = None
+ + +51class PageCreator:
+52 """
+53 A PageCreator factory base class.
+ +55 This factory should implement methods to create [Page][tembo.journal.pages.Page] objects.
+ +57 !!! abstract
+58 This factory is an abstract base class and should be implemented for each
+59 [Page][tembo.journal.pages.Page] type.
+ +61 The private methods
+ +63 - `_check_base_path_exists()`
+64 - `_convert_base_path_to_path()`
+65 - `_load_template()`
+ +67 are not abstract and are shared between all [Page][tembo.journal.pages.Page] types.
+68 """
+ +70 @abstractmethod
+71 def __init__(self, options: PageCreatorOptions) -> None:
+72 """
+73 When implemented this should initialise the `PageCreator` factory.
+ +75 Args:
+76 options (PageCreatorOptions): An instance of
+77 [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions]
+ +79 !!! abstract
+80 This method is abstract and should be implemented for each
+81 [Page][tembo.journal.pages.Page] type.
+82 """
+83 raise NotImplementedError
+ +85 @property
+86 @abstractmethod
+87 def options(self) -> PageCreatorOptions:
+88 """
+89 When implemented this should return the `PageCreatorOptions` on the class.
+ +91 Returns:
+92 PageCreatorOptions: the instance of
+93 [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions] set on the class.
+ +95 !!! abstract
+96 This method is abstract and should be implemented for each
+97 [Page][tembo.journal.pages.Page] type.
+98 """
+99 raise NotImplementedError
+ +101 @abstractmethod
+102 def create_page(self) -> Page:
+103 """
+104 When implemented this should create a `Page` object.
+ +106 Returns:
+107 Page: an implemented instance of [Page][tembo.journal.pages.Page] such as
+108 [ScopedPage][tembo.journal.pages.ScopedPage].
+ +110 !!! abstract
+111 This method is abstract and should be implemented for each
+112 [Page][tembo.journal.pages.Page] type.
+113 """
+114 raise NotImplementedError
+ +116 def _check_base_path_exists(self) -> None:
+117 """
+118 Check that the base path exists.
+ +120 Raises:
+121 exceptions.BasePathDoesNotExistError: raised if the base path does not exist.
+122 """
+123 if not pathlib.Path(self.options.base_path).expanduser().exists():
+124 raise exceptions.BasePathDoesNotExistError(
+125 f"Tembo base path of {self.options.base_path} does not exist."
+126 )
+ +128 def _convert_base_path_to_path(self) -> pathlib.Path:
+129 """
+130 Convert the `base_path` from a `str` to a `pathlib.Path` object.
+ +132 Returns:
+133 pathlib.Path: the `base_path` as a `pathlib.Path` object.
+134 """
+135 path_to_file = (
+136 pathlib.Path(self.options.base_path).expanduser()
+137 / pathlib.Path(self.options.page_path.replace(" ", "_")).expanduser()
+138 / self.options.filename.replace(" ", "_")
+139 )
+140 # check for existing `.` in the extension
+141 extension = (
+142 self.options.extension[1:]
+143 if self.options.extension[0] == "."
+144 else self.options.extension
+145 )
+146 # return path with a file
+147 return path_to_file.with_suffix(f".{extension}")
+ +149 def _load_template(self) -> str:
+150 """
+151 Load the template file.
+ +153 Raises:
+154 exceptions.TemplateFileNotFoundError: raised if the template file is specified but
+155 not found.
+ +157 Returns:
+158 str: the contents of the template file.
+159 """
+160 if self.options.template_filename is None:
+161 return ""
+162 if self.options.template_path is not None:
+163 converted_template_path = pathlib.Path(self.options.template_path).expanduser()
+164 else:
+165 converted_template_path = (
+166 pathlib.Path(self.options.base_path).expanduser() / ".templates"
+167 )
+ +169 file_loader = jinja2.FileSystemLoader(converted_template_path)
+170 env = jinja2.Environment(loader=file_loader, autoescape=True)
+ +172 try:
+173 loaded_template = env.get_template(self.options.template_filename)
+174 except TemplateNotFound as template_not_found:
+175 _template_file = f"{converted_template_path}/{template_not_found.args[0]}"
+176 raise exceptions.TemplateFileNotFoundError(
+177 f"Template file {_template_file} does not exist."
+178 ) from template_not_found
+179 return loaded_template.render()
+ + +182class ScopedPageCreator(PageCreator):
+183 """
+184 Factory to create a scoped page.
+ +186 Attributes:
+187 base_path (str): base path of tembo.
+188 page_path (str): path of the page relative to the base path.
+189 filename (str): filename relative to the page path.
+190 extension (str): extension of file.
+191 """
+ +193 def __init__(self, options: PageCreatorOptions) -> None:
+194 """
+195 Initialise a `ScopedPageCreator` factory.
+ +197 Args:
+198 options (PageCreatorOptions): An instance of
+199 [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions].
+200 """
+201 self._all_input_tokens: list[str] = []
+202 self._options = options
+ +204 @property
+205 def options(self) -> PageCreatorOptions:
+206 """
+207 Return the `PageCreatorOptions` instance set on the factory.
+ +209 Returns:
+210 PageCreatorOptions:
+211 An instance of [PageCreatorOptions][tembo.journal.pages.PageCreatorOptions].
+212 """
+213 return self._options
+ +215 def create_page(self) -> Page:
+216 """
+217 Create a [ScopedPage][tembo.journal.pages.ScopedPage] object.
+ +219 This method will
+ +221 - Check the `base_path` exists
+222 - Verify the input tokens match the number defined in the `config.yml`
+223 - Substitue the input tokens in the filepath
+224 - Load the template contents and substitue the input tokens
+ +226 Raises:
+227 exceptions.MismatchedTokenError: Raises
+228 [MismatchedTokenError][tembo.exceptions.MismatchedTokenError] if the number of
+229 input tokens does not match the number of unique input tokens defined.
+230 exceptions.BasePathDoesNotExistError: Raises
+231 [BasePathDoesNotExistError][tembo.exceptions.BasePathDoesNotExistError] if the
+232 base path does not exist.
+233 exceptions.TemplateFileNotFoundError: Raises
+234 [TemplateFileNotFoundError][tembo.exceptions.TemplateFileNotFoundError] if the
+235 template file is specified but not found.
+ + +238 Returns:
+239 Page: A [ScopedPage][tembo.journal.pages.ScopedPage] object using the
+240 `PageCreatorOptions`.
+241 """
+242 try:
+243 self._check_base_path_exists()
+244 except exceptions.BasePathDoesNotExistError as base_path_does_not_exist_error:
+245 raise base_path_does_not_exist_error
+246 self._all_input_tokens = self._get_input_tokens()
+247 try:
+248 self._verify_input_tokens()
+249 except exceptions.MismatchedTokenError as mismatched_token_error:
+250 raise mismatched_token_error
+ +252 path = self._convert_base_path_to_path()
+253 path = pathlib.Path(self._substitute_tokens(str(path)))
+ +255 try:
+256 template_contents = self._load_template()
+257 except exceptions.TemplateFileNotFoundError as template_not_found_error:
+258 raise template_not_found_error
+259 if self.options.template_filename is not None:
+260 template_contents = self._substitute_tokens(template_contents)
+ +262 return ScopedPage(path, template_contents)
+ +264 def _get_input_tokens(self) -> list[str]:
+265 """Get the input tokens from the path & user template."""
+266 path = str(
+267 pathlib.Path(
+268 self.options.base_path,
+269 self.options.page_path,
+270 self.options.filename,
+271 )
+272 .expanduser()
+273 .with_suffix(f".{self.options.extension}")
+274 )
+275 template_contents = self._load_template()
+276 # get the input tokens from both the path and the template
+277 all_input_tokens = []
+278 for tokenified_string in (path, template_contents):
+279 all_input_tokens.extend(re.findall(r"(\{input\d*\})", tokenified_string))
+280 return sorted(list(set(all_input_tokens)))
+ +282 def _verify_input_tokens(self) -> None:
+283 """
+284 Verify the input tokens.
+ +286 The number of input tokens should match the number of unique input tokens defined in the
+287 path and the user's template.
+ +289 Raises:
+290 exceptions.MismatchedTokenError: Raises
+291 [MismatchedTokenError][tembo.exceptions.MismatchedTokenError] if the number of
+292 input tokens does not match the number of unique input tokens defined.
+293 """
+294 if len(self._all_input_tokens) > 0 and self.options.user_input is None:
+295 raise exceptions.MismatchedTokenError(expected=len(self._all_input_tokens), given=0)
+296 if self.options.user_input is None:
+297 return
+298 if len(self._all_input_tokens) != len(self.options.user_input):
+299 raise exceptions.MismatchedTokenError(
+300 expected=len(self._all_input_tokens),
+301 given=len(self.options.user_input),
+302 )
+ +304 def _substitute_tokens(self, tokenified_string: str) -> str:
+305 """For a tokened string, substitute input, name and date tokens."""
+306 tokenified_string = self.__substitute_input_tokens(tokenified_string)
+307 tokenified_string = self.__substitute_name_tokens(tokenified_string)
+308 tokenified_string = self.__substitute_date_tokens(tokenified_string)
+309 return tokenified_string
+ +311 def __substitute_input_tokens(self, tokenified_string: str) -> str:
+312 """
+313 Substitue the input tokens in a `str` with the user input.
+ +315 Args:
+316 tokenified_string (str): a string with input tokens.
+ +318 Returns:
+319 str: the string with the input tokens replaced by the user input.
+ +321 Examples:
+322 A `user_input` of `("monthly_meeting",)` with a `tokenified_string` of
+323 `/meetings/{input0}/` results in a string of `/meetings/monthly_meeting/`
+324 """
+325 if self.options.user_input is not None:
+326 for input_value, extracted_token in zip(
+327 self.options.user_input, self._all_input_tokens
+328 ):
+329 tokenified_string = tokenified_string.replace(
+330 extracted_token, input_value.replace(" ", "_")
+331 )
+332 return tokenified_string
+ +334 def __substitute_name_tokens(self, tokenified_string: str) -> str:
+335 """Find any `{name}` tokens and substitute for the name value in a `str`."""
+336 name_extraction = re.findall(r"(\{name\})", tokenified_string)
+337 for extracted_input in name_extraction:
+338 tokenified_string = tokenified_string.replace(extracted_input, self.options.name)
+339 return tokenified_string
+ +341 @staticmethod
+342 def __substitute_date_tokens(tokenified_string: str) -> str:
+343 """Find any {d:%d-%M-%Y} tokens in a `str`."""
+344 # extract the full token string
+345 date_extraction_token = re.findall(r"(\{d\:[^}]*\})", tokenified_string)
+346 for extracted_token in date_extraction_token:
+347 # extract the inner %d-%M-%Y only
+348 strftime_value = re.match(r"\{d\:([^\}]*)\}", extracted_token)
+349 if strftime_value is not None:
+350 strftime_value = strftime_value.group(1)
+351 if isinstance(strftime_value, str):
+352 tokenified_string = tokenified_string.replace(
+353 extracted_token, pendulum.now().strftime(strftime_value)
+354 )
+355 return tokenified_string
+ + +358class Page(metaclass=ABCMeta):
+359 """
+360 Abstract Page class.
+ +362 This interface is used to define a `Page` object.
+ +364 A `Page` represents a note/page that will be saved to disk.
+ +366 !!! abstract
+367 This object is an abstract base class and should be implemented for each `Page` type.
+368 """
+ +370 @abstractmethod
+371 def __init__(self, path: pathlib.Path, page_content: str) -> None:
+372 """
+373 When implemented this should initalise a Page object.
+ +375 Args:
+376 path (pathlib.Path): the full path of the page including the filename as a
+377 [Path][pathlib.Path].
+378 page_content (str): the contents of the page.
+ +380 !!! abstract
+381 This method is abstract and should be implemented for each `Page` type.
+382 """
+383 raise NotImplementedError
+ +385 @property
+386 @abstractmethod
+387 def path(self) -> pathlib.Path:
+388 """
+389 When implemented this should return the full path of the page including the filename.
+ +391 Returns:
+392 pathlib.Path: the path as a [Path][pathlib.Path] object.
+ +394 !!! abstract
+395 This property is abstract and should be implemented for each `Page` type.
+396 """
+397 raise NotImplementedError
+ +399 @abstractmethod
+400 def save_to_disk(self) -> tembo.utils.Success:
+401 """
+402 When implemented this should save the page to disk.
+ +404 Returns:
+405 tembo.utils.Success: A Tembo [Success][tembo.utils.__init__.Success] object.
+ +407 !!! abstract
+408 This method is abstract and should be implemented for each `Page` type.
+409 """
+410 raise NotImplementedError
+ + +413class ScopedPage(Page):
+414 """
+415 A page that uses substitute tokens.
+ +417 Attributes:
+418 path (pathlib.Path): a [Path][pathlib.Path] object of the page's filepath including the
+419 filename.
+420 page_content (str): the content of the page from the template.
+421 """
+ +423 def __init__(self, path: pathlib.Path, page_content: str) -> None:
+424 """
+425 Initalise a scoped page object.
+ +427 Args:
+428 path (pathlib.Path): a [Path][pathlib.Path] object of the page's filepath including
+429 the filename.
+430 page_content (str): the content of the page from the template.
+431 """
+432 self._path = path
+433 self.page_content = page_content
+ +435 def __str__(self) -> str:
+436 """
+437 Return a `str` representation of a `ScopedPage`.
+ +439 Examples:
+440 ```
+441 >>> str(ScopedPage(Path("/home/bob/tembo/meetings/my_meeting_0.md"), ""))
+442 ScopedPage("/home/bob/tembo/meetings/my_meeting_0.md")
+443 ```
+ +445 Returns:
+446 str: The `ScopedPage` as a `str`.
+447 """
+448 return f'ScopedPage("{self.path}")'
+ +450 @property
+451 def path(self) -> pathlib.Path:
+452 """
+453 Return the full path of the page.
+ +455 Returns:
+456 pathlib.path: The full path of the page as a [Path][pathlib.Path] object.
+457 """
+458 return self._path
+ +460 def save_to_disk(self) -> tembo.utils.Success:
+461 """
+462 Save the scoped page to disk and write the `page_content`.
+ +464 Raises:
+465 exceptions.ScopedPageAlreadyExists: If the page already exists a
+466 [ScopedPageAlreadyExists][tembo.exceptions.ScopedPageAlreadyExists] exception
+467 is raised.
+ +469 Returns:
+470 tembo.utils.Success: A [Success][tembo.utils.__init__.Success] with the path of the
+471 ScopedPage as the message.
+472 """
+473 # create the parent directories
+474 scoped_page_file = pathlib.Path(self.path)
+475 scoped_page_file.parents[0].mkdir(parents=True, exist_ok=True)
+476 if scoped_page_file.exists():
+477 raise exceptions.ScopedPageAlreadyExists(f"{self.path} already exists")
+478 with scoped_page_file.open("w", encoding="utf-8") as scoped_page:
+479 scoped_page.write(self.page_content)
+480 return tembo.utils.Success(str(self.path))
+