diff --git a/Makefile b/Makefile index f89b9d3..6c9a024 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := test -isort = isort src docs/examples tests setup.py -black = black --target-version py37 src docs/examples tests setup.py +isort = isort src tests setup.py +black = black --target-version py37 src tests setup.py .PHONY: install install: @@ -67,7 +67,7 @@ mypy: @echo "" .PHONY: test -test: test-code test-examples +test: test-code .PHONY: test-code test-code: @@ -123,7 +123,7 @@ clean: @echo "" .PHONY: docs -docs: test-examples +docs: @echo "-------------------------" @echo "- Serving documentation -" @echo "-------------------------" @@ -158,7 +158,7 @@ build-package: clean @echo "" .PHONY: build-docs -build-docs: test-examples +build-docs: @echo "--------------------------" @echo "- Building documentation -" @echo "--------------------------" diff --git a/docs/adr/001-high_level_problem_analysis.md b/docs/adr/001-high_level_problem_analysis.md index 7aaad83..2b53335 100644 --- a/docs/adr/001-high_level_problem_analysis.md +++ b/docs/adr/001-high_level_problem_analysis.md @@ -3,7 +3,7 @@ Date: 2021-07-12 # Status -Draft +Accepted # Context diff --git a/docs/adr/002-initial_program_design.md b/docs/adr/002-initial_program_design.md index 3adbb76..9087f07 100644 --- a/docs/adr/002-initial_program_design.md +++ b/docs/adr/002-initial_program_design.md @@ -3,7 +3,7 @@ Date: 2021-07-12 # Status -Draft +Accepted # Context @@ -86,8 +86,7 @@ processing sessions. Postponing it will make the item show at the next session. The element prioritization is done by: * Element category type: Each category will have a priority. -* Element creation date: The user will choose the order to address first the - oldest or the newest elements. +* Element creation date: Ordered by oldest first. It will measure the time spent between each element, and if it surpasses a defined amount, it will warn the user, so it is aware of it and can act diff --git a/docs/adr/003-markup_definition.md b/docs/adr/003-markup_definition.md index 5f5da23..e49510f 100644 --- a/docs/adr/003-markup_definition.md +++ b/docs/adr/003-markup_definition.md @@ -15,7 +15,6 @@ The information to extract from each element is: * Description. * Body * Element type. -* Priority. # Proposals diff --git a/docs/adr/adr.md b/docs/adr/adr.md index 2cb9720..7266913 100644 --- a/docs/adr/adr.md +++ b/docs/adr/adr.md @@ -15,9 +15,9 @@ graph TD click 002 "https://lyz-code.github.io/pynbox/adr/002-initial_program_design" _blank click 002 "https://lyz-code.github.io/pynbox/adr/003-markup_definition" _blank - 001:::draft - 002:::draft - 003:::draft + 001:::accepted + 002:::accepted + 003:::accepted classDef draft fill:#CDBFEA; classDef proposed fill:#B1CCE8; diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..2a15158 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,39 @@ +The first time you use `pynbox`, it will create the default configuration in +`~/.local/share/pynbox` or the path of the `-c` command line argument. + +# database_url + +`pynbox` uses [`repository_orm`](https://lyz-code.github.io/repository-orm/) to +persist the elements. By default, it uses the +[TinyDB](https://tinydb.readthedocs.io/en/latest/usage.html) backend, if you +encounter performance issues, move to +[SQLite](https://lyz-code.github.io/repository-orm/pypika_repository/), and then +to MySQL. Refer to the docs of +[`repository_orm`](https://lyz-code.github.io/repository-orm/) to do so. + +# max_time + +You should not spend too much time processing your inbox, the idea is that if it +will take you more than 2 minutes to process an element, it's better to create +a task to address it. `max_time` defines the maximum number of seconds to +process an element. A warning will be shown if it takes you longer. + +# types + +It's where you define the element categories, their regular expressions and +their priority. For example: + +```yaml +types: + task: + regexp: t\. + priority: 4 + idea: + regexp: i\. +``` + +If the priority is not defined, it's assumed to be `3`. + +!!! note "The regular expression needs to be a non capturing one" + If you use parenthesis use `(?:)` instead of `()`. diff --git a/docs/creating_new_elements.md b/docs/creating_new_elements.md new file mode 100644 index 0000000..0d3f299 --- /dev/null +++ b/docs/creating_new_elements.md @@ -0,0 +1,52 @@ + +To add new elements into pynbox, you need to use the defined [markup +language](#pynbox-markup-language) either through the [command +line](#command-line) or through a [file](#parse-file). + +# Pynbox markup language + +It's designed to use the minimum number of friendly keystrokes both for a mobile +and a laptop. The format of each element is: + +``` +{{ type_identifier }} {{ description }} +{{ body }} +``` + +Where: + +* `type_identifier`: Is a case insensitive string of the fewest letters as + possible, that identifies the type of the element. It can be for example + `t.` for a task, or `i.` for an idea. The identifiers are defined in the + [configuration file](configuration.md). +* `description`: A short sentence that defines the element. +* `body`: An optional group of paragraphs with more information of the element. + +For example: + +``` +T. buy groceries +* milk +* tofu +i. to have an inbox management tool would be awesome! +``` + +# Command line + +You can use the `add` subcommand to put new items into the inbox. + +```bash +pynbox add t. buy milk +``` + +# Parse a file + +If you need to add elements with a body, more than one element, or want to +import them from other device, using a file might be easier. + +Write the elements one after the other in the file and then use the `parse` +command. + +```bash +pynbox parse file.txt +``` diff --git a/docs/examples/simple-example.py b/docs/examples/simple-example.py deleted file mode 100644 index 41d97dc..0000000 --- a/docs/examples/simple-example.py +++ /dev/null @@ -1 +0,0 @@ -import pynbox # noqa diff --git a/docs/img/screencast.gif b/docs/img/screencast.gif new file mode 100644 index 0000000..f98be89 Binary files /dev/null and b/docs/img/screencast.gif differ diff --git a/docs/index.md b/docs/index.md index eafa2f6..af60a02 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,13 @@ [![Actions Status](https://github.com/lyz-code/pynbox/workflows/Build/badge.svg)](https://github.com/lyz-code/pynbox/actions) [![Coverage Status](https://coveralls.io/repos/github/lyz-code/pynbox/badge.svg?branch=master)](https://coveralls.io/github/lyz-code/pynbox?branch=master) -Task management inbox tool +`pynbox` aims to help you with the daily [emptying of the +inbox](https://lyz-code.github.io/blue-book/task_tools/#inbox) by: + +* Prioritizing the elements by their type. +* Giving insights on the inbox status. +* Giving feedback on the inbox processing process. +* Making the insertion of new elements as effortless as possible. # Installing @@ -10,11 +16,21 @@ Task management inbox tool pip install pynbox ``` -# A Simple Example +# Quick overview -```python -{! examples/simple-example.py !} # noqa -``` +![ ](screencast.gif) + +# Proposed workflow + +When you're on your desktop you can add elements directly with the [command +line](creating_new_elements.md#command-line), when you're on the go, use any +text editor, such as +[Markor](https://f-droid.org/en/packages/net.gsantner.markor/), and sync the +file with [Syncthing](https://lyz-code.github.io/blue-book/linux/syncthing/) to +your laptop, and then [parse the file](creating_new_elements.md#parse-a-file). + +To process the elements, you can daily use `pynbox process`. If you want to +focus on a category, use `pynbox process category`. # References diff --git a/docs/requirements.txt b/docs/requirements.txt index 4f6fe45..d1d34e2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -28,7 +28,7 @@ htmlmin==0.1.12 # via mkdocs-minify-plugin idna==3.2 # via requests -importlib-metadata==4.6.3 +importlib-metadata==4.6.4 # via # click # markdown diff --git a/mkdocs.yml b/mkdocs.yml index 882d6c9..c9084ee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,12 +4,16 @@ site_author: Lyz site_url: https://lyz-code.github.io/pynbox nav: - pynbox: index.md + - Creating new elements: creating_new_elements.md + - Configuration: configuration.md - Reference: reference.md - Contributing: contributing.md - Architecture Decision Records: - adr/adr.md - '001: High level problem analysis': | adr/001-high_level_problem_analysis.md + - '002: Initial program design': adr/002-initial_program_design.md + - '003: Markup Definition': adr/003-markup_definition.md plugins: - search diff --git a/mypy.ini b/mypy.ini index 3a3e372..a55311d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -25,3 +25,9 @@ ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True + +[mypy-pkg_resources.*] +ignore_missing_imports = True + +[mypy-pexpect.*] +ignore_missing_imports = True diff --git a/requirements-dev.in b/requirements-dev.in index fc21404..5089351 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -7,6 +7,7 @@ pytest-cov pytest-pythonpath pytest-xdist pytest-freezegun +pexpect # Linting yamllint diff --git a/requirements-dev.txt b/requirements-dev.txt index d10ee8c..f2acbe6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -66,7 +66,14 @@ click==8.0.1 # safety # yamlfix colorama==0.4.4 - # via flakehell + # via + # -r requirements.txt + # flakehell + # rich +commonmark==0.9.1 + # via + # -r requirements.txt + # rich coverage==5.5 # via pytest-cov deepdiff==5.5.0 @@ -116,7 +123,7 @@ flake8-annotations-complexity==0.0.6 # via -r requirements-dev.in flake8-bugbear==21.4.3 # via -r requirements-dev.in -flake8-comprehensions==3.5.0 +flake8-comprehensions==3.6.0 # via -r requirements-dev.in flake8-debugger==4.0.0 # via -r requirements-dev.in @@ -175,7 +182,7 @@ idna==3.2 # via # -r docs/requirements.txt # requests -importlib-metadata==4.6.3 +importlib-metadata==4.6.4 # via # -r docs/requirements.txt # -r requirements.txt @@ -314,6 +321,8 @@ pep517==0.11.0 # via pip-tools pep8-naming==0.12.1 # via -r requirements-dev.in +pexpect==4.8.0 + # via -r requirements-dev.in pip-tools==6.2.0 # via -r requirements-dev.in platformdirs==2.2.0 @@ -322,6 +331,12 @@ pluggy==0.13.1 # via pytest pre-commit==2.14.0 # via -r requirements-dev.in +prompt-toolkit==3.0.19 + # via + # -r requirements.txt + # questionary +ptyprocess==0.7.0 + # via pexpect py==1.10.0 # via # pytest @@ -343,8 +358,10 @@ pyflakes==2.3.1 pygments==2.9.0 # via # -r docs/requirements.txt + # -r requirements.txt # flakehell # mkdocs-material + # rich pylint==2.9.6 # via -r requirements-dev.in pymdown-extensions==8.2 @@ -410,6 +427,8 @@ pyyaml-env-tag==0.1 # via # -r docs/requirements.txt # mkdocs +questionary==1.10.0 + # via -r requirements.txt regex==2021.8.3 # via black repository-orm==0.5.3 @@ -419,6 +438,8 @@ requests==2.26.0 # -r docs/requirements.txt # mkdocs-htmlproofer-plugin # safety +rich==10.7.0 + # via -r requirements.txt ruamel.yaml==0.17.10 # via -r requirements.txt ruamel.yaml.clib==0.2.6 @@ -499,6 +520,7 @@ typing-extensions==3.10.0.0 # mypy # pydantic # pytkdocs + # rich # tinydb urllib3==1.26.6 # via @@ -511,6 +533,10 @@ watchdog==2.1.3 # via # -r docs/requirements.txt # mkdocs +wcwidth==0.2.5 + # via + # -r requirements.txt + # prompt-toolkit wheel==0.37.0 # via # -r docs/requirements.txt diff --git a/requirements.txt b/requirements.txt index 0e0c5bb..1ccf3da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,20 +6,32 @@ # click==8.0.1 # via pynbox (setup.py) +colorama==0.4.4 + # via rich +commonmark==0.9.1 + # via rich deepdiff==5.5.0 # via repository-orm -importlib-metadata==4.6.3 +importlib-metadata==4.6.4 # via click ordered-set==4.0.2 # via deepdiff +prompt-toolkit==3.0.19 + # via questionary pydantic==1.8.2 # via repository-orm +pygments==2.9.0 + # via rich pymysql==1.0.2 # via repository-orm pypika==0.48.8 # via repository-orm +questionary==1.10.0 + # via pynbox (setup.py) repository-orm==0.5.3 # via pynbox (setup.py) +rich==10.7.0 + # via pynbox (setup.py) ruamel.yaml==0.17.10 # via pynbox (setup.py) ruamel.yaml.clib==0.2.6 @@ -38,7 +50,10 @@ typing-extensions==3.10.0.0 # via # importlib-metadata # pydantic + # rich # tinydb +wcwidth==0.2.5 + # via prompt-toolkit yoyo-migrations==7.3.2 # via repository-orm zipp==3.5.0 diff --git a/setup.py b/setup.py index 1cdbe2d..6964210 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,11 @@ """Python package building configuration.""" import logging -import os import re -import shutil from glob import glob from os.path import basename, splitext from setuptools import find_packages, setup -from setuptools.command.egg_info import egg_info -from setuptools.command.install import install log = logging.getLogger(__name__) @@ -20,40 +16,6 @@ raise ValueError("The version is not specified in the version.py file.") version = version_match["version"] - -class PostInstallCommand(install): # type: ignore - """Post-installation for installation mode.""" - - def run(self) -> None: - """Create required directories and files.""" - install.run(self) - - try: - data_directory = os.path.expanduser("~/.local/share/pynbox") - os.makedirs(data_directory) - log.info("Data directory created") - except FileExistsError: - log.info("Data directory already exits") - - config_path = os.path.join(data_directory, "config.yaml") - if os.path.isfile(config_path) and os.access(config_path, os.R_OK): - log.info( - "Configuration file already exists, check the documentation " - "for the new version changes." - ) - else: - shutil.copyfile("assets/config.yaml", config_path) - log.info("Copied default configuration template") - - -class PostEggInfoCommand(egg_info): # type: ignore - """Post-installation for egg_info mode.""" - - def run(self) -> None: - """Create required directories and files.""" - egg_info.run(self) - - with open("README.md", "r") as readme_file: readme = readme_file.read() @@ -69,9 +31,9 @@ def run(self) -> None: url="https://github.com/lyz-code/pynbox", packages=find_packages("src"), package_dir={"": "src"}, - package_data={"pynbox": ["py.typed"]}, + package_data={"pynbox": ["py.typed", "assets/config.yaml"]}, py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], - python_requires=">=3.6", + python_requires=">=3.7", classifiers=[ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", @@ -90,10 +52,11 @@ def run(self) -> None: [console_scripts] pynbox=pynbox.entrypoints.cli:cli """, - cmdclass={"install": PostInstallCommand, "egg_info": PostEggInfoCommand}, install_requires=[ "Click", "ruamel.yaml", "repository-orm", + "questionary", + "rich", ], ) diff --git a/src/pynbox/adapters/__init__.py b/src/pynbox/adapters/__init__.py deleted file mode 100644 index ef9912f..0000000 --- a/src/pynbox/adapters/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Module to store the functions shared by the different adapters.""" - -import logging - -log = logging.getLogger(__name__) diff --git a/assets/config.yaml b/src/pynbox/assets/config.yaml similarity index 72% rename from assets/config.yaml rename to src/pynbox/assets/config.yaml index f1036ad..b84f523 100644 --- a/assets/config.yaml +++ b/src/pynbox/assets/config.yaml @@ -5,8 +5,12 @@ # * mysql: mysql://scott:tiger@localhost/mydatabase database_url: tinydb://~/.local/share/pynbox/database.tinydb +# Maximum time to process an element. A warning will be shown if it takes you +# longer. +max_time: 120 + # List of element types. Each element can define: -# * regexp: regular expression that identifies it +# * regexp: non capturing regular expression that identifies it # * priority: type priority, by default 3. types: task: diff --git a/src/pynbox/config.py b/src/pynbox/config.py index 75143e9..b157554 100644 --- a/src/pynbox/config.py +++ b/src/pynbox/config.py @@ -1,15 +1,17 @@ """Define the configuration of the main program.""" import logging +import operator import os from collections import UserDict from typing import Any, Dict, List, Union -# It complains that ruamel.yaml doesn't have the object YAML, but it does. from ruamel.yaml import YAML # type: ignore from ruamel.yaml.parser import ParserError from ruamel.yaml.scanner import ScannerError +# It complains that ruamel.yaml doesn't have the object YAML, but it does. + log = logging.getLogger(__name__) @@ -64,8 +66,6 @@ def get( try: value = value[config_key] except KeyError as error: - if default is not None: - return default raise ConfigError( f"Failed to fetch the configuration {config_key} " f"when searching for {original_key}" @@ -112,7 +112,7 @@ def load(self) -> None: except (ParserError, ScannerError) as error: raise ConfigError(str(error)) from error except FileNotFoundError as error: - raise ConfigError( + raise FileNotFoundError( "The configuration file {self.config_path} could not be found." ) from error @@ -122,3 +122,20 @@ def save(self) -> None: yaml = YAML() yaml.default_flow_style = False yaml.dump(self.data, file_cursor) + + def types(self) -> List[str]: + """Return an ordered list of element types.""" + types_dict = [] + for type_name, type_value in self["types"].items(): + try: + types_dict.append( + {"name": type_name, "priority": type_value["priority"]} + ) + except KeyError: + types_dict.append({"name": type_name, "priority": 3}) + return [ + type_["name"] + for type_ in sorted( + types_dict, key=operator.itemgetter("priority"), reverse=True + ) + ] diff --git a/src/pynbox/entrypoints/__init__.py b/src/pynbox/entrypoints/__init__.py index 9e58ae8..88ed689 100644 --- a/src/pynbox/entrypoints/__init__.py +++ b/src/pynbox/entrypoints/__init__.py @@ -5,8 +5,12 @@ """ import logging +import os import sys +from contextlib import suppress +from shutil import copyfile +import pkg_resources from repository_orm import Repository, load_repository from pynbox.config import Config, ConfigError @@ -19,16 +23,26 @@ def load_config(config_path: str) -> Config: """Load the configuration from the file.""" log.debug(f"Loading the configuration from file {config_path}") - try: - config = Config(config_path) - except ConfigError as error: - log.error(f"Configuration Error: {str(error)}") - sys.exit(1) - except FileNotFoundError: - log.error(f"Error opening configuration file {config_path}") - sys.exit(1) + config_path = os.path.expanduser(config_path) - return config + with suppress(FileExistsError): + data_directory = os.path.expanduser("~/.local/share/pynbox") + os.makedirs(data_directory) + log.debug("Data directory created") # pragma: no cover + + while True: + try: + return Config(config_path) + except ConfigError as error: + log.error(f"Configuration Error: {str(error)}") + sys.exit(1) + except FileNotFoundError: + log.info(f"Error opening configuration file {config_path}") + copyfile( + pkg_resources.resource_filename("pynbox", "assets/config.yaml"), + config_path, + ) + log.info("Copied default configuration template") def get_repo(config: Config) -> Repository: @@ -52,9 +66,9 @@ def load_logger(verbose: bool = False) -> None: # pragma no cover logging.addLevelName(logging.WARNING, "[\033[33m+\033[0m]") if verbose: logging.basicConfig( - stream=sys.stderr, level=logging.DEBUG, format=" %(levelname)s %(message)s" + stream=sys.stderr, level=logging.DEBUG, format="%(levelname)s %(message)s" ) else: logging.basicConfig( - stream=sys.stderr, level=logging.INFO, format=" %(levelname)s %(message)s" + stream=sys.stderr, level=logging.INFO, format="%(levelname)s %(message)s" ) diff --git a/src/pynbox/entrypoints/cli.py b/src/pynbox/entrypoints/cli.py index 49b1bd0..28f7355 100644 --- a/src/pynbox/entrypoints/cli.py +++ b/src/pynbox/entrypoints/cli.py @@ -1,7 +1,15 @@ -"""Command line interface definition.""" +"""Define the command line interface.""" + +import time +from typing import List, Optional import click from click.core import Context +from questionary import Choice, select +from rich import box +from rich.console import Console +from rich.table import Table +from rich.text import Text from .. import services, version from . import get_repo, load_config, load_logger @@ -21,9 +29,10 @@ def cli(ctx: Context, config_path: str, verbose: bool) -> None: """Command line interface main click entrypoint.""" ctx.ensure_object(dict) - ctx.obj["config"] = load_config(config_path) ctx.obj["repo"] = get_repo(ctx.obj["config"]) + ctx.obj["verbose"] = verbose + load_logger(verbose) @@ -35,6 +44,108 @@ def parse(ctx: Context, file_path: str) -> None: services.parse_file(ctx.obj["config"], ctx.obj["repo"], file_path) +@cli.command() +@click.argument("element_strings", nargs=-1) +@click.pass_context +def add(ctx: Context, element_strings: List[str]) -> None: + """Parse a markup file and add the elements to the repository.""" + repo = ctx.obj["repo"] + + element = services.parse(ctx.obj["config"], " ".join(element_strings))[0] + + repo.add(element) + repo.commit() + + +@cli.command() +@click.pass_context +def status(ctx: Context) -> None: + """Print the status of the inbox.""" + status_data = services.status(ctx.obj["repo"], ctx.obj["config"]) + + total_elements = sum([v for k, v in status_data.items()]) + table = Table(box=box.MINIMAL_HEAVY_HEAD, show_footer=True) + + table.add_column("Type", justify="left", style="green", footer="Total") + table.add_column("Elements", style="magenta", footer=str(total_elements)) + + for type_, elements in status_data.items(): + table.add_row(type_.title(), str(elements)) + + console = Console() + console.print(table) + + +@cli.command() +@click.argument("type_", required=False, default=None) +@click.pass_context +def process(ctx: Context, type_: Optional[str] = None) -> None: + """Create a TUI interface to process the elements.""" + console = Console() + session_start = time.time() + repo = ctx.obj["repo"] + config = ctx.obj["config"] + choices = [ + Choice(title="Done", shortcut_key="d"), + Choice(title="Skip", shortcut_key="s"), + Choice(title="Delete", shortcut_key="e"), + Choice(title="Quit", shortcut_key="q"), + ] + elements = services.elements(repo, config, type_) + processed_elements = 0 + for element in elements: + if element.body is None or element.body == "": + prompt = f"[{element.type_.title()}] {element.description}" + else: + prompt = ( + f"[{element.type_.title()}] {element.description}\n\n{element.body}" + ) + start = time.time() + choice = select( + prompt, + qmark="\n", + choices=choices, + use_shortcuts=True, + ).ask() + end = time.time() + + if end - start > config["max_time"]: + text = Text.assemble( + ("\nWARNING!", "bold red"), + " it took you more than ", + (str(round(config["max_time"] / 60)), "green"), + " minutes to process the last element: ", + (str(round((end - start) / 60)), "red"), + ) + console.print(text) + print() + + if choice == "Done": + element.close() + processed_elements += 1 + elif choice == "Delete": + element.delete() + processed_elements += 1 + elif choice == "Skip": + element.skip() + elif choice == "Quit": + break + repo.add(element) + repo.commit() + session_end = time.time() + + text = Text.assemble( + "It took you ", + (str(round((session_end - session_start) / 60)), "magenta"), + " minutes to process ", + (str(processed_elements), "green"), + " elements. There are still ", + (str(len(elements) - processed_elements), "red"), + " left.", + ) + console.print(text) + + @cli.command(hidden=True) def null() -> None: """Do nothing. diff --git a/src/pynbox/model.py b/src/pynbox/model.py index 1843394..cc3ef11 100644 --- a/src/pynbox/model.py +++ b/src/pynbox/model.py @@ -13,6 +13,7 @@ class ElementState(str, Enum): OPEN = "open" CLOSED = "closed" + DELETED = "deleted" class Element(Entity): @@ -22,6 +23,21 @@ class Element(Entity): description: str body: Optional[str] = None priority: int = 3 + skips: int = 0 state: ElementState = ElementState.OPEN created: datetime = Field(default_factory=datetime.now) closed: Optional[datetime] = None + + def close(self) -> None: + """Close an element.""" + self.state = ElementState.CLOSED + self.closed = datetime.now() + + def delete(self) -> None: + """Delete an element.""" + self.state = ElementState.DELETED + self.closed = datetime.now() + + def skip(self) -> None: + """Skip an element.""" + self.skips += 1 diff --git a/src/pynbox/services.py b/src/pynbox/services.py index fabc9b7..ba69944 100644 --- a/src/pynbox/services.py +++ b/src/pynbox/services.py @@ -4,15 +4,19 @@ and handlers to achieve the program's purpose. """ +import logging import re -from typing import List +from contextlib import suppress +from typing import Dict, List, Optional -from repository_orm import Repository +from repository_orm import EntityNotFoundError, Repository from .config import Config from .exceptions import ParseError from .model import Element +log = logging.getLogger(__name__) + def parse(config: Config, text: str) -> List[Element]: """Extract Elements from a text. @@ -33,12 +37,12 @@ def parse(config: Config, text: str) -> List[Element]: } priority_regexp = re.compile(r"\s([h])(?:\s|$)", re.IGNORECASE) + log.debug("Parsing elements") for line in text.splitlines(): for type_, regexp in type_regexps.items(): match = regexp.match(line) if match: - if element is not None: - elements.append(element) + elements = _register_element(element, elements) element = Element(type_=type_, description=match.groups()[0]) if priority_regexp.search(line): element.description = priority_regexp.sub("", element.description) @@ -46,13 +50,34 @@ def parse(config: Config, text: str) -> List[Element]: break else: if element is None: - raise ParseError("No element to append the body of line {line}") + raise ParseError(f"No element to append the body of line {line}") if element.body is None: - element.body = line + element.body = f"{line}" else: - element.body = (element.body + line).strip() + element.body += f"\n{line}" + + elements = _register_element(element, elements) + return elements + + +def _register_element( + element: Optional[Element], elements: List[Element] +) -> List[Element]: + """Register an element in a list of elements. + + Args: + element: Element to register + elements: existing elements. + Returns: + list of elements with the new element introduced. + """ if element is not None: + if element.body is not None: + element.body = element.body.strip() + log.debug(f"{element.type_}: {element.description}\n\n{element.body}") + else: + log.debug(f"{element.type_}: {element.description}") elements.append(element) return elements @@ -74,3 +99,47 @@ def parse_file(config: Config, repo: Repository, file_path: str) -> None: with open(file_path, "w") as file_descriptor: file_descriptor.write("") + + +def elements( + repo: Repository, config: Config, type_: Optional[str] = None +) -> List[Element]: + """Fetch and order the elements to process. + + Args: + repo: Repository where the elements live. + type_: type of element to process. + + Returns: + Ordered list of elements to process. + """ + if type_ is None: + types = config.types() + else: + types = [type_] + + elements = [] + for type_ in types: + with suppress(EntityNotFoundError): + elements.extend(repo.search({"state": "open", "type_": type_}, Element)) + + return elements + + +def status(repo: Repository, config: Config) -> Dict[str, int]: + """Get the number of open elements per type. + + Args: + repo: Repository where the elements live. + type_: type of element to process. + + Returns: + Number of open tasks per type + """ + status = {} + for type_ in config.types(): + with suppress(EntityNotFoundError): + elements = len(repo.search({"state": "open", "type_": type_}, Element)) + if elements > 0: + status[type_] = elements + return status diff --git a/tests/assets/config.yaml b/tests/assets/config.yaml index b7a6734..4dbcffd 100644 --- a/tests/assets/config.yaml +++ b/tests/assets/config.yaml @@ -1,4 +1,5 @@ --- +max_time: 120 types: task: regexp: t\. diff --git a/tests/conftest.py b/tests/conftest.py index 3d6e92e..375eb2d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,8 +4,10 @@ import pytest from _pytest.tmpdir import TempdirFactory +from repository_orm import FakeRepository from pynbox.config import Config +from pynbox.model import Element @pytest.fixture(name="config") @@ -19,3 +21,9 @@ def fixture_config(tmpdir_factory: TempdirFactory) -> Config: config.save() return config + + +@pytest.fixture(name="repo") +def repo_() -> FakeRepository: + """Configure a FakeRepository instance.""" + return FakeRepository([Element]) diff --git a/tests/e2e/test_cli.py b/tests/e2e/test_cli.py index 861993e..fe5e790 100644 --- a/tests/e2e/test_cli.py +++ b/tests/e2e/test_cli.py @@ -1,10 +1,14 @@ """Test the command line interface.""" import logging +import os import re -from typing import List +import sys +from typing import Any, List +import pexpect import pytest +from _pytest.capture import CaptureFixture from _pytest.logging import LogCaptureFixture from click.testing import CliRunner from py._path.local import LocalPath @@ -12,7 +16,7 @@ from pynbox.config import Config from pynbox.entrypoints.cli import cli -from pynbox.model import Element +from pynbox.model import Element, ElementState from pynbox.version import __version__ log = logging.getLogger(__name__) @@ -35,6 +39,34 @@ def test_version(runner: CliRunner) -> None: ) +def test_verbose_is_supported(config: Config, runner: CliRunner) -> None: + """ + Given: A configured program + When: Any command is used with the -v flag + Then: The program runs without problem + """ + result = runner.invoke(cli, ["-v", "null"]) + + assert result.exit_code == 0 + + +def test_creates_config_if_inexistent(config: Config, runner: CliRunner) -> None: + """ + Given: The configuration file doesn't exist + When: Any command is used + Then: The default configuration is copied. + """ + os.remove(config.config_path) + + result = runner.invoke(cli, ["null"]) + + assert result.exit_code == 0 + assert os.path.isfile(config.config_path) + with open(config.config_path, "r") as file_descriptor: + config_text = file_descriptor.read() + assert "max_time" in config_text + + def test_load_config_handles_configerror_exceptions( runner: CliRunner, tmpdir: LocalPath, caplog: LogCaptureFixture ) -> None: @@ -80,3 +112,231 @@ def test_parse_stores_elements( assert elements[0].description == "Task title" with open(parse_file, "r") as file_descriptor: assert file_descriptor.read() == "" + + +def test_add_elements(runner: CliRunner, config: Config, tmpdir: LocalPath) -> None: + """ + Given: A configured program + When: add command is used + Then: the element is stored in the repository + """ + result = runner.invoke(cli, ["add", "t.", "Task", "title"]) + + assert result.exit_code == 0 + repo = load_repository(models=[Element], database_url=config["database_url"]) + elements: List[Element] = repo.all() + assert len(elements) == 1 + assert elements[0].type_ == "task" + assert elements[0].description == "Task title" + + +def test_do_element(config: Config) -> None: + """ + Given: An element in the repository + When: the inbox processing command is used and the done key is pressed + Then: the element is marked as done and the date is stored + """ + # Add the element + repo = load_repository(models=[Element], database_url=config["database_url"]) + repo.add(Element(type_="task", description="Task title", body="task body")) + repo.commit() + # Load the TUI + tui = pexpect.spawn(f"pynbox -c {config.config_path} process", timeout=5) + tui.logfile = sys.stdout.buffer + tui.expect(".*Quit.*") + + tui.sendline("d") # act + + tui.expect_exact(pexpect.EOF) + element = repo.all(Element)[0] + assert element.state == ElementState.CLOSED + assert element.closed is not None + + +def test_delete_element(config: Config) -> None: + """ + Given: An element in the repository + When: the inbox processing command is used and the delete key is pressed + Then: the element is marked as deleted and the date is stored + """ + # Add the element + repo = load_repository(models=[Element], database_url=config["database_url"]) + repo.add(Element(type_="task", description="Task title")) + repo.commit() + # Load the TUI + tui = pexpect.spawn(f"pynbox -c {config.config_path} process", timeout=5) + tui.logfile = sys.stdout.buffer + tui.expect(".*Quit.*") + + tui.sendline("e") # act + + tui.expect_exact(pexpect.EOF) + element = repo.all(Element)[0] + assert element.state == ElementState.DELETED + assert element.closed is not None + + +def test_skip_element(config: Config) -> None: + """ + Given: An element in the repository + When: the inbox processing command is used and the skip key is pressed + Then: the element is skipped and the skipped count is increased + """ + # Add the element + repo = load_repository(models=[Element], database_url=config["database_url"]) + repo.add(Element(type_="task", description="Task title")) + repo.commit() + # Load the TUI + tui = pexpect.spawn(f"pynbox -c {config.config_path} process", timeout=5) + tui.logfile = sys.stdout.buffer + tui.expect(".*Quit.*") + + tui.sendline("s") # act + + tui.expect_exact(pexpect.EOF) + element = repo.all(Element)[0] + assert element.state == ElementState.OPEN + assert element.skips == 1 + assert element.closed is None + + +def test_quit(config: Config) -> None: + """ + Given: An element in the repository + When: the inbox processing command is used and the quit key is pressed + Then: the element is not changed and the program ends + """ + # Add the element + repo = load_repository(models=[Element], database_url=config["database_url"]) + repo.add(Element(type_="task", description="Task title")) + repo.commit() + # Load the TUI + tui = pexpect.spawn(f"pynbox -c {config.config_path} process", timeout=5) + tui.logfile = sys.stdout.buffer + tui.expect(".*Quit.*") + + tui.sendline("q") # act + + tui.expect_exact(pexpect.EOF) + element = repo.all(Element)[0] + assert element.state == ElementState.OPEN + assert element.closed is None + + +def test_process_can_select_subset_of_types(config: Config) -> None: + """ + Given: Two elements with different types in the repository + When: the inbox processing command is used specifying the type + Then: only the element of that type is shown + + As we only give it one command (done), if both elements were shown, the test + will return an error as it will reach the timeout of pexpect. + """ + # Add the elements + repo = load_repository(models=[Element], database_url=config["database_url"]) + repo.add(Element(type_="task", description="Task title")) + repo.add(Element(type_="idea", description="Idea title")) + repo.commit() + # Load the TUI + tui = pexpect.spawn(f"pynbox -c {config.config_path} process idea", timeout=5) + tui.logfile = sys.stdout.buffer + tui.expect(".*Quit.*") + + tui.sendline("d") # act + + tui.expect_exact(pexpect.EOF) + task = repo.all(Element)[0] + idea = repo.all(Element)[1] + assert task.state == ElementState.OPEN + assert idea.state == ElementState.CLOSED + + +def test_process_shows_warning_if_max_time_surpassed( + config: Config, capsys: CaptureFixture[Any] +) -> None: + """ + Given: An element in the repository + When: the inbox processing command is used, and the user takes longer than max_time + to process an element + Then: A warning is shown in the terminal. + """ + # Configure the max_time to 0 so the warning is always raised + config["max_time"] = 0 + config.save() + # Add the elements + repo = load_repository(models=[Element], database_url=config["database_url"]) + repo.add(Element(type_="task", description="Task title")) + repo.commit() + # Load the TUI + tui = pexpect.spawn(f"pynbox -c {config.config_path} process", timeout=5) + tui.logfile = sys.stdout.buffer + tui.expect(".*Quit.*") + + tui.sendline("d") # act + + tui.expect_exact(pexpect.EOF) + out, err = capsys.readouterr() + assert re.search( + ( + "WARNING!.* it took you more than .*0.* minutes " + "to process the last element: .*0.*" + ), + out, + ) + + +def test_process_shows_a_report_of_the_status_of_the_inbox( + config: Config, capsys: CaptureFixture[Any] +) -> None: + """ + Given: Two elements in the repository + When: the inbox processing command is used on just one. + Then: A report of the state of the inbox is shown. + """ + # Add the elements + repo = load_repository(models=[Element], database_url=config["database_url"]) + repo.add(Element(type_="task", description="Task title")) + repo.add(Element(type_="task", description="Task title 2")) + repo.commit() + # Load the TUI + tui = pexpect.spawn(f"pynbox -c {config.config_path} process", timeout=5) + tui.logfile = sys.stdout.buffer + tui.expect(".*Quit.*") + tui.sendline("d") + + tui.sendline("q") # act + + tui.expect_exact(pexpect.EOF) + out, err = capsys.readouterr() + assert "" in out + assert re.search( + ( + "It took you .*0.* minutes to process .*1.* elements. " + "There are still .*1.* left" + ), + out, + ) + + +def test_status_returns_the_pending_element_numbers_by_type( + runner: CliRunner, config: Config +) -> None: + """ + Given: Three elements in the repository + When: The status command is called + Then: A list of types with the number of pending elements are returned + """ + # Add the elements + repo = load_repository(models=[Element], database_url=config["database_url"]) + repo.add(Element(type_="idea", description="Idea title")) + repo.add(Element(type_="task", description="Task title")) + repo.add(Element(type_="task", description="Task title 2")) + repo.commit() + + result = runner.invoke(cli, ["status"]) + + assert result.exit_code == 0 + assert re.search( + r"Task.*2.*\n.*Idea.*1", + result.stdout, + ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index bf521f9..3f05ad5 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -50,7 +50,7 @@ def test_load_handles_file_not_found(config: Config) -> None: """ config.config_path = "inexistent.yaml" - with pytest.raises(ConfigError): + with pytest.raises(FileNotFoundError): config.load() diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index a76b241..fafbe4b 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -1,12 +1,15 @@ """Tests the service layer.""" +from datetime import datetime from textwrap import dedent import pytest +from repository_orm import Repository from pynbox import services from pynbox.config import Config from pynbox.exceptions import ParseError +from pynbox.model import Element def test_parse_processes_one_element(config: Config) -> None: @@ -57,6 +60,8 @@ def test_parse_processes_one_element_with_body(config: Config) -> None: t. Task title Task body + + Second paragraph """ ) @@ -65,7 +70,7 @@ def test_parse_processes_one_element_with_body(config: Config) -> None: assert len(result) == 1 assert result[0].type_ == "task" assert result[0].description == "Task title" - assert result[0].body == "Task body" + assert result[0].body == "Task body\n\nSecond paragraph" def test_parse_processes_two_elements_with_body(config: Config) -> None: @@ -119,3 +124,44 @@ def test_parse_extracts_priority_from_description(config: Config) -> None: assert result[0].type_ == "task" assert result[0].description == "Task title" assert result[0].priority == 5 + + +def test_elements_returns_ordered_items(config: Config, repo: Repository) -> None: + """ + Given: Three tasks, of different types_ and creation dates + When: elements is called + Then: The elements are returned ordered first by priority of type and then by + creation date with the oldest first. + """ + elements = [ + Element( + description="Low priority and old", + type_="idea", + created=datetime(2020, 1, 2), + ), + Element( + description="Low priority and new", + type_="idea", + created=datetime(2020, 2, 2), + ), + Element( + description="High priority and old", + type_="task", + created=datetime(2020, 1, 1), + ), + Element( + description="High priority and new", + type_="task", + created=datetime(2020, 2, 1), + ), + ] + for element in elements: + repo.add(element) + repo.commit() + + result = services.elements(repo, config) + + assert result[0] == elements[2] + assert result[1] == elements[3] + assert result[2] == elements[0] + assert result[3] == elements[1]