Use implicit namespace plugins for import and export (#1216)

* behavior outline
* FIrst pass at allow external plugins
* remove template exporter
* Add listing of active plugins to '--version' output
* Documentation for plugins
* [Docs] add custom imports and exporters to site TOC
* [Docs] better linewrapping
* enforce positive initial linewrap
  Check column widths
  update gitignore
  throw error when linewrap too small
  simply check for large enough linewrap value
* delete unused error message
* PR feedback
  make exception more informative
  update check_linewrap signature in src and test
  make check_linewrap a free function
* delete unused function
* delete else..pass block
* newline for make format
* Include dates_exporter
* Use Base classes for importer and exporters.
* [Docs] improve documentation of custom Importers and Exporters
* [Testing] separate run with external plugin!
* basic behavior test
* prototype unittest for JSON Exporter
  test for unimplemented method
* make format
  delete unused imports
* Remove 'importer' or 'exporter' from filenames where not needed
* [Test] run different tests with or without the external plugins installed
* [Test] move test rot13 plugin into git tree
  from 0dc912af82
* consolidate demo plugins to common package
* [Docs] name page for plugins
* [Docs] include the sample plug in code files directly
* style fixes
* [test] determine whether to run external plug in tests based on installed packages
* improved code documentation
* style fixes for GitHub actions
* Convert "short" and "pretty" (and "default") formaters to plugins
  further to https://github.com/jrnl-org/jrnl/pull/1177
* more code clean up
  tests pass locally...now for GitHub...
* [tests] dynamically determine jrnl version for plugin tests
* [GitHub Actions] direct install of testing plugins
* Remove template code
* [plugins] meta --> collector
* [Docs] create scripted entries using an custom importer
* (closer to) being able to run behave tests outside project root directory
* We already know when exporter to use
  Don't re-calculate it!
* [Tests] don't name test plugin 'testing"
  If so named, pip won't install it.
* [Test] run behave tests with test plugins outside project root
* [Test] behave tests pass locally
* [Docs] fix typo
* [GitHub Actions] run test commands from poetry's shell
* black-ify code
* [GitHub Actions] move downstream (rather than up) to run tests
* [GitHub Actions] set shell to poetry
* [GitHub Workflows] Manually activate virtual environment
* [GitHub Actions] Skip Windows & Python 3.8
  Can't seem to find Python exe?
* [GiotHub Actions] explicitly use virtual env
* [GitHub Actions] create virutal env directly
* [GitHub Actions] better activate of Windows virtual env
* [GitHub Actions] create virtual env on Mac
* [Github Actions] install wheel and upgrade pip
* [GitHub Actions] skip virtual environments altogether
* [GitHub Actions] change directory for behave test
* Remove Windows exclusions from CI as per note -- they should be working now

Co-authored-by: Suhas <sugas182@gmail.com>
Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com>
This commit is contained in:
MinchinWeb 2021-05-29 18:21:45 -06:00 committed by Jonathan Wren
parent cef3a98b4e
commit 4392e29742
45 changed files with 1021 additions and 383 deletions

View file

@ -1,9 +1,17 @@
import os
from pathlib import Path
import shutil
from jrnl.os_compat import on_windows
try:
from jrnl.contrib.exporter import flag as testing_exporter
except ImportError:
testing_exporter = None
CWD = os.getcwd()
HERE = Path(__file__).resolve().parent
TARGET_CWD = HERE.parent # project root folder
# @see https://behave.readthedocs.io/en/latest/tutorial.html#debug-on-error-in-case-of-step-failures
BEHAVE_DEBUG_ON_ERROR = False
@ -15,6 +23,8 @@ def setup_debug_on_error(userdata):
def before_all(context):
# always start in project root directory
os.chdir(TARGET_CWD)
setup_debug_on_error(context.config.userdata)
@ -27,10 +37,10 @@ def before_all(context):
def clean_all_working_dirs():
if os.path.exists("test.txt"):
os.remove("test.txt")
if os.path.exists(HERE / "test.txt"):
os.remove(HERE / "test.txt")
for folder in ("configs", "journals", "cache"):
working_dir = os.path.join("features", folder)
working_dir = HERE / folder
if os.path.exists(working_dir):
shutil.rmtree(working_dir)
@ -46,20 +56,28 @@ def before_feature(context, feature):
feature.skip("Skipping on Windows")
return
if "skip_only_with_external_plugins" in feature.tags and testing_exporter is None:
feature.skip("Requires test external plugins installed")
return
if "skip_no_external_plugins" in feature.tags and testing_exporter:
feature.skip("Skipping with external plugins installed")
return
def before_scenario(context, scenario):
"""Before each scenario, backup all config and journal test data."""
# Clean up in case something went wrong
clean_all_working_dirs()
for folder in ("configs", "journals"):
original = os.path.join("features", "data", folder)
working_dir = os.path.join("features", folder)
original = HERE / "data" / folder
working_dir = HERE / folder
if not os.path.exists(working_dir):
os.mkdir(working_dir)
for filename in os.listdir(original):
source = os.path.join(original, filename)
source = original / filename
if os.path.isdir(source):
shutil.copytree(source, os.path.join(working_dir, filename))
shutil.copytree(source, (working_dir / filename))
else:
shutil.copy2(source, working_dir)
@ -73,11 +91,22 @@ def before_scenario(context, scenario):
scenario.skip("Skipping on Windows")
return
if (
"skip_only_with_external_plugins" in scenario.effective_tags
and testing_exporter is None
):
scenario.skip("Requires test external plugins installed")
return
if "skip_no_external_plugins" in scenario.effective_tags and testing_exporter:
scenario.skip("Skipping with external plugins installed")
return
def after_scenario(context, scenario):
"""After each scenario, restore all test data and remove working_dirs."""
if os.getcwd() != CWD:
os.chdir(CWD)
if os.getcwd() != TARGET_CWD:
os.chdir(TARGET_CWD)
# only clean up if debugging is off and the scenario passed
if BEHAVE_DEBUG_ON_ERROR and scenario.status != "failed":

View file

@ -26,6 +26,7 @@ Feature: Custom formats
| basic_folder |
| basic_dayone |
@skip_no_external_plugins
Scenario Outline: JSON format
Given we use the config "<config>.yaml"
And we use the password "test" if prompted
@ -48,6 +49,7 @@ Feature: Custom formats
| basic_folder |
| basic_dayone |
@skip_no_external_plugins
Scenario: Exporting dayone to json
Given we use the config "dayone.yaml"
When we run "jrnl --export json"
@ -91,6 +93,7 @@ Feature: Custom formats
| basic_folder |
| basic_dayone |
@skip_no_external_plugins
Scenario Outline: Exporting using filters should only export parts of the journal
Given we use the config "<config>.yaml"
And we use the password "test" if prompted
@ -112,6 +115,7 @@ Feature: Custom formats
| basic_folder |
| basic_dayone |
@skip # template exporters have been removed
Scenario Outline: Exporting using custom templates
Given we use the config "<config>.yaml"
And we load template "sample.template"

86
features/plugins.feature Normal file
View file

@ -0,0 +1,86 @@
Feature: Functionality of Importer and Exporter Plugins
@skip_no_external_plugins
Scenario Outline: List buildin plugin names in --version
Given We use the config "basic_onefile.yaml"
When We run "jrnl --version"
Then the output should contain pyproject.toml version
And The output should contain "<plugin_name> : <version> from jrnl.<source>.<type>.<filename>"
And the output should not contain ".contrib."
Examples:
| plugin_name | version | source | type | filename |
| jrnl | <pyproject.toml version> | plugins | importer | jrnl |
| boxed | <pyproject.toml version> | plugins | exporter | fancy |
| dates | <pyproject.toml version> | plugins | exporter | dates |
| default | <pyproject.toml version> | plugins | exporter | pretty |
| fancy | <pyproject.toml version> | plugins | exporter | fancy |
| json | <pyproject.toml version> | plugins | exporter | json |
| markdown | <pyproject.toml version> | plugins | exporter | markdown |
| md | <pyproject.toml version> | plugins | exporter | markdown |
| pretty | <pyproject.toml version> | plugins | exporter | pretty |
| short | <pyproject.toml version> | plugins | exporter | short |
| tags | <pyproject.toml version> | plugins | exporter | tag |
| text | <pyproject.toml version> | plugins | exporter | text |
| txt | <pyproject.toml version> | plugins | exporter | text |
| xml | <pyproject.toml version> | plugins | exporter | xml |
| yaml | <pyproject.toml version> | plugins | exporter | yaml |
@skip_only_with_external_plugins
Scenario Outline: List external plugin names in --version
Given We use the config "basic_onefile.yaml"
When We run "jrnl --version"
Then the output should contain pyproject.toml version
And The output should contain "<plugin_name> : <version> from jrnl.<source>.<type>.<filename>"
Examples:
| plugin_name | version | source | type | filename |
| jrnl | <pyproject.toml version> | plugins | importer | jrnl |
| json | v1.0.0 | contrib | importer | simple_json |
| boxed | <pyproject.toml version> | plugins | exporter | fancy |
| dates | <pyproject.toml version> | plugins | exporter | dates |
| default | <pyproject.toml version> | plugins | exporter | pretty |
| fancy | <pyproject.toml version> | plugins | exporter | fancy |
| json | v1.0.0 | contrib | exporter | custom_json |
| markdown | <pyproject.toml version> | plugins | exporter | markdown |
| md | <pyproject.toml version> | plugins | exporter | markdown |
| pretty | <pyproject.toml version> | plugins | exporter | pretty |
| rot13 | v1.0.0 | contrib | exporter | rot13 |
| short | <pyproject.toml version> | plugins | exporter | short |
| tags | <pyproject.toml version> | plugins | exporter | tag |
| testing | v0.0.1 | contrib | exporter | flag |
| text | <pyproject.toml version> | plugins | exporter | text |
| txt | v1.0.0 | contrib | exporter | rot13 |
| xml | <pyproject.toml version> | plugins | exporter | xml |
| yaml | <pyproject.toml version> | plugins | exporter | yaml |
@skip_only_with_external_plugins
Scenario Outline: Do not list overridden plugin names in --version
Given We use the config "basic_onefile.yaml"
When We run "jrnl --version"
Then the output should contain pyproject.toml version
And the output should not contain "<plugin_name> : <version> from jrnl.<source>.<type>.<filename>"
Examples:
| plugin_name | version | source | type | filename |
| json | <pyproject.toml version> | plugins | exporter | json |
| txt | <pyproject.toml version> | plugins | exporter | text |
@skip_only_with_external_plugins
Scenario Outline: JSON format
Given we use the config "<config>.yaml"
And we use the password "test" if prompted
When we run "jrnl --format json"
Then we should get no error
And the output should be parsable as json
And "entries" in the json output should have 3 elements
And entry 1 should not have an array "tags"
And entry 2 should not have an array "tags"
And entry 3 should not have an array "tags"
Examples: configs
| config |
| basic_onefile |
| basic_encrypted |
| basic_folder |
| basic_dayone |

View file

@ -3,7 +3,6 @@
import ast
from collections import defaultdict
from jrnl.args import parse_args
import os
from pathlib import Path
import re
@ -14,20 +13,23 @@ from behave import given
from behave import then
from behave import when
import keyring
import toml
import yaml
from yaml.loader import SafeLoader
import jrnl.time
from jrnl import Journal
from jrnl import __version__
from jrnl import plugins
from jrnl.args import parse_args
from jrnl.behave_testing import _mock_getpass
from jrnl.behave_testing import _mock_input
from jrnl.behave_testing import _mock_time_parse
from jrnl.cli import cli
from jrnl.config import load_config
from jrnl.os_compat import split_args
from jrnl.override import apply_overrides, _recursively_apply
from jrnl.override import _recursively_apply
from jrnl.override import apply_overrides
import jrnl.time
try:
import parsedatetime.parsedatetime_consts as pdt
@ -279,42 +281,6 @@ def extension_editor_file(context, suffix):
assert filename_suffix == suffix
def _mock_getpass(inputs):
def prompt_return(prompt=""):
if type(inputs) == str:
return inputs
try:
return next(inputs)
except StopIteration:
raise KeyboardInterrupt
return prompt_return
def _mock_input(inputs):
def prompt_return(prompt=""):
try:
val = next(inputs)
print(prompt, val)
return val
except StopIteration:
raise KeyboardInterrupt
return prompt_return
def _mock_time_parse(context):
original_parse = jrnl.time.parse
if "now" not in context:
return original_parse
def wrapper(input, *args, **kwargs):
input = context.now if input == "now" else input
return original_parse(input, *args, **kwargs)
return wrapper
@when('we run "{command}" and enter')
@when('we run "{command}" and enter nothing')
@when('we run "{command}" and enter "{inputs}"')
@ -461,8 +427,9 @@ def run(context, command, text=""):
@given('we load template "{filename}"')
def load_template(context, filename):
full_path = os.path.join("features/data/templates", filename)
exporter = plugins.template_exporter.__exporter_from_file(full_path)
plugins.__exporter_types[exporter.names[0]] = exporter
plugins.collector.__exporter_types[exporter.names[0]] = exporter
@when('we set the keyring password of "{journal}" to "{password}"')
@ -540,6 +507,11 @@ def check_output_version_inline(context):
@then('the output should contain "{text}" or "{text2}"')
def check_output_inline(context, text=None, text2=None):
text = text or context.text
if "<pyproject.toml version>" in text:
pyproject = (Path(__file__) / ".." / ".." / ".." / "pyproject.toml").resolve()
pyproject_contents = toml.load(pyproject)
pyproject_version = pyproject_contents["tool"]["poetry"]["version"]
text = text.replace("<pyproject.toml version>", pyproject_version)
out = context.stdout_capture.getvalue()
assert (text and text in out) or (text2 and text2 in out)

View file

@ -89,6 +89,14 @@ def entry_array_count(context, entry_number, name, items_number):
assert len(out_json["entries"][entry_number - 1][name]) == items_number
@then('entry {entry_number:d} should not have an array "{name}"')
def entry_not_array_item(context, entry_number, name):
# note that entry_number is 1-indexed.
out = context.stdout_capture.getvalue()
out_json = json.loads(out)
assert name not in out_json["entries"][entry_number - 1]
@then("the output should be a valid XML string")
def assert_valid_xml_string(context):
output = context.stdout_capture.getvalue()

View file

@ -1,11 +1,11 @@
from jrnl.jrnl import run
from unittest import mock
# from __future__ import with_statement
from jrnl.args import parse_args
from behave import then
from features.steps.core import _mock_getpass, _mock_time_parse
from jrnl.args import parse_args
from jrnl.behave_testing import _mock_getpass
from jrnl.behave_testing import _mock_time_parse
from jrnl.jrnl import run
@then("the editor {editor} should have been called")

View file

@ -183,6 +183,8 @@ Feature: Writing new entries.
And we run "jrnl -until 1980"
Then the output should be "1979-05-01 09:00 Being born hurts."
# the testing plugins override the JSON exporter
@skip_no_external_plugins
Scenario: Writing into Dayone adds extended metadata
Given we use the config "dayone.yaml"
When we run "jrnl 01 may 1979: Being born hurts."