Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

External Plugins v2 #1216

Merged
merged 60 commits into from
May 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
d3589fa
behavior outline
sriniv27 Mar 4, 2021
e25869e
FIrst pass at allow external plugins
minchinweb Dec 14, 2020
3795159
remove template exporter
minchinweb Mar 9, 2021
a550861
Add listing of active plugins to '--version' output
minchinweb Mar 9, 2021
a72115d
Documentation for plugins
minchinweb Mar 17, 2021
46c28d7
[Docs] add custom imports and exporters to site TOC
minchinweb Mar 17, 2021
4eb0ab0
[Docs] better linewrapping
minchinweb Mar 17, 2021
2d4ba1b
enforce positive initial linewrap
sriniv27 Mar 4, 2021
e063fa9
delete unused error message
sriniv27 Mar 21, 2021
502d47f
PR feedback
sriniv27 Apr 10, 2021
2e09d88
delete unused function
sriniv27 Apr 10, 2021
8bf1da1
delete else..pass block
sriniv27 Apr 10, 2021
2b054d7
newline for make format
sriniv27 Apr 10, 2021
51fc091
Include dates_exporter
minchinweb May 6, 2021
2706314
Use Base classes for importer and exporters.
minchinweb May 6, 2021
a75f153
[Docs] improve documentation of custom Importers and Exporters
minchinweb May 6, 2021
cc682a3
[Testing] separate run with external plugin!
minchinweb May 6, 2021
6c6929b
basic behavior test
sriniv27 May 6, 2021
c76b978
prototype unittest for JSON Exporter
sriniv27 May 6, 2021
788b32b
make format
sriniv27 May 6, 2021
aa6e0a7
Remove 'importer' or 'exporter' from filenames where not needed
minchinweb May 8, 2021
6c98c7c
[Test] run different tests with or without the external plugins insta…
minchinweb May 8, 2021
09d6528
[Test] move test rot13 plugin into git tree
minchinweb May 8, 2021
b8f0e6a
consolidate demo plugins to common package
minchinweb May 8, 2021
cdaf9eb
[Docs] name page for plugins
minchinweb May 8, 2021
90b7478
[Docs] include the sample plug in code files directly
minchinweb May 8, 2021
b9c9ce4
style fixes
minchinweb May 8, 2021
2a70636
[test] determine whether to run external plug in tests based on insta…
minchinweb May 9, 2021
f22d985
improved code documentation
minchinweb May 9, 2021
d400146
Fix for low line lenghts on fancy exporter
minchinweb May 9, 2021
c8ca3a0
Manually resolve merge conficts
minchinweb May 9, 2021
e1d2f87
style fixes for GitHub actions
minchinweb May 9, 2021
1fd04ad
Convert "short" and "pretty" (and "default") formaters to plugins
minchinweb May 9, 2021
432681f
more code clean up
minchinweb May 9, 2021
5a341cf
[tests] dynamically determine jrnl version for plugin tests
minchinweb May 9, 2021
4209a82
[GitHub Actions] direct install of testing plugins
minchinweb May 9, 2021
0e5748a
Remove template code
minchinweb May 16, 2021
65914e2
[plugins] meta --> collector
minchinweb May 16, 2021
96f1d77
[Docs] create scripted entries using an custom importer
minchinweb May 16, 2021
c990c8c
(closer to) being able to run behave tests outside project root direc…
minchinweb May 17, 2021
d63ba47
We already know when exporter to use
minchinweb May 17, 2021
3492dd2
[Tests] don't name test plugin 'testing"
minchinweb May 17, 2021
9888d98
[Test] run behave tests with test plugins outside project root
minchinweb May 17, 2021
e64ca2d
[Test] behave tests pass locally
minchinweb May 17, 2021
96369ca
[Docs] fix typo
minchinweb May 17, 2021
c4439f4
[GitHub Actions] run test commands from poetry's shell
minchinweb May 17, 2021
5de162e
black-ify code
minchinweb May 17, 2021
a3e7c7f
[GitHub Actions] move downstream (rather than up) to run tests
minchinweb May 17, 2021
d5d3d43
[GitHub Actions] set shell to poetry
minchinweb May 17, 2021
89fcdce
[GitHub Workflows] Manually activate virtual environment
minchinweb May 17, 2021
68c870a
[GitHub Actions] Skip Windows & Python 3.8
minchinweb May 17, 2021
f4c9d68
[GiotHub Actions] explicitly use virtual env
minchinweb May 18, 2021
d590dee
[GitHub Actions] create virutal env directly
minchinweb May 18, 2021
cac2ab4
[GitHub Actions] better activate of Windows virtual env
minchinweb May 18, 2021
86177be
[GitHub Actions] create virtual env on Mac
minchinweb May 18, 2021
8afa3c0
[Github Actions] install wheel and upgrade pip
minchinweb May 18, 2021
ffe4a5f
[GitHub Actions] skip virtual environments altogether
minchinweb May 18, 2021
ab3b235
[GitHub Actions] change directory for behave test
minchinweb May 18, 2021
e061e78
Merge branch 'develop' into external-plugins
micahellison May 29, 2021
9f16382
Remove Windows exclusions from CI as per note -- they should be worki…
micahellison May 29, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ jobs:
matrix:
python-version: [ 3.7, 3.8, 3.9 ]
os: [ ubuntu-latest, macos-latest, windows-latest ]
exclude: # Added for GitHub Actions PR problem 2020-12-19 -- remove later!
- os: windows-latest
python-version: 3.9

steps:
- uses: actions/checkout@v2
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/testing_external_plugins.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Testing

on:
push:
branches: [ develop, release ]
paths:
- 'jrnl/**'
- 'features/**'
- 'tests/**'
- 'poetry.lock'
- 'pyproject.toml'
pull_request:
branches: [ develop ]
paths:
- 'jrnl/**'
- 'features/**'
- 'tests/**'
- 'poetry.lock'
- 'pyproject.toml'

jobs:
test-namespace-plugins:
if: >
! contains(github.event.head_commit.message, '[ci skip]')
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [ 3.7, 3.8, 3.9 ]
os: [ ubuntu-latest, macos-latest, windows-latest ]
exclude: # Added for GitHub Actions PR problem 2020-12-19 -- remove later!
- os: windows-latest
python-version: 3.9

steps:
- uses: actions/checkout@v2

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install pip setuptools wheel --upgrade
python -m pip install .
python -m pip install ./tests/external_plugins_src/
python -m pip install pytest behave
# installed test plugins aren't recognized by "behave" if run from the
# project's root folder

- name: Test with pytest
if: success() || failure()
run: pytest --junitxml=reports/pytest/results.xml

- name: Test with behave
if: success() || failure()
run: cd features && behave --no-skipped --format progress2 --junit --junit-directory ../reports/behave
5 changes: 4 additions & 1 deletion docs/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ used alone (e.g. `jrnl --format json`) to display all entries from the selected

This page shows examples of all the built-in formats, but since `jrnl` supports adding
more formats through plugins, you may have more available on your system. Please see
`jrnl --help` for a list of which formats are available on your system.
`jrnl --version` for a list of which formats are available on your system. Note
that plugins can also override built-in formats, so review your installed
plugins if your output does not match what is listed here. You can also [write
your own plugins](./plugins.md) to create custom formats.

Any of these formats can be used interchangeably, and are only grouped into "display",
"data", and "report" formats below for convenience.
Expand Down
181 changes: 181 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<!-- Copyright (C) 2012-2021 jrnl contributors
License: https://www.gnu.org/licenses/gpl-3.0.html -->

# Extending jrnl

*jrnl* can be extended with custom importers and exporters.

Note that custom importers and exporters can be given the same name as a
built-in importer or exporter to override it.

Custom Importers and Exporters are traditional Python packages, and are
installed (into *jrnl*) simply by installing them so they are available to the
Python interpreter that is running *jrnl*.

Exporter are also used as "formatters" when entries are written to the command
line.

## Rational

I added this feature because *jrnl* was overall working well for me, but I
found myself maintaining a private fork so I could have a slightly customized
export format. Implementing (import and) export plugins was seen as a way to
maintain my custom exporter without the need to maintaining my private fork.

This implementation tries to keep plugins as light as possible, and as free of
boilerplate code as reasonable. As well, internal importers and exporters are
implemented in almost exactly the same way as custom importers and exporters,
and so it is hoped that plugins can be moved from "contributed" to "internal"
easily, or that internal plugins can serve as a base and/or a demonstration for
external plugins.

-- @MinchinWeb, May 2021

## Entry Class

Both the Importers and the Exporters work on the `Entry` class. Below is a
(selective) description of the class, it's properties and functions:

- **Entry** (class) at `jrnl.Entry.Entry`.
- **title** (string): a single line that represents a entry's title.
- **date** (datetime.datetime): the date and time assigned to an entry.
- **body** (string): the main body of the entry. Can be basically any
length. *jrnl* assumes no particular structure here.
- **starred** (boolean): is an entry starred? Presumably, starred entries
are of particular importance.
- **tags** (list of strings): the tags attached to an entry. Each tag
includes the pre-facing "tag symbol".
- **\_\_init\_\_(journal, date=None, text="", starred=False)**: contractor
method
- **journal** (*jrnl.Journal.Journal*): a link to an existing Journal
class. Mainly used to access it's configuration.
- **date** (datetime.datetime)
- **text** (string): assumed to include both the title and the body.
When the title, body, or tags of an entry are requested, this text
will the parsed to determine the tree.
- **starred** (boolean)

Entries also have "advanced" metadata if they are using the DayOne backend, but
we'll ignore that for the purposes of this demo.

## Custom Importer

If you have a (custom) datasource that you want to import into your jrnl
(perhaps like a blog export), you can write a custom importer to do this.

An importer takes the source data, turns it into Entries and then appends those
entries to a Journal. Here is a basic Importer, assumed to be provided with a
nicely formatted JSON file:

~~~ python
{%
include-markdown "../tests/external_plugins_src/jrnl/contrib/importer/simple_json.py"
comments=false
%}
~~~

Note that the above is very minimal, doesn't do any error checking, and doesn't
try to import all possible entry metadata.

Another potential use of a custom importer is to effectively create a scripted
entry creator. For example, maybe each day you want to create a journal entry
that contains the answers to specific questions; you could create a custom
"importer" that would ask you the questions, and then create an entry containing
the answers provided.

Some implementation notes:

- The importer class must be named **Importer**, and should sub-class
**jrnl.plugins.base.BaseImporter**.
- The importer module must be within the **jrnl.contrib.importer** namespace.
- The importer must not have any `__init__.py` files in the base directories
(but you can have one for your importer base directory if it is in a
directory rather than a single file).
- The importer must be installed as a Python package available to the same
Python interpreter running jrnl.
- The importer must expose at least the following the following members:
- **version** (string): the version of the plugin. Displayed to help the
user debug their installations.
- **names** (list of strings): these are the "names" that can be passed to
the CLI to involve your importer. If you specify one used by a built-in
plugin, it will overwrite it (effectively making the built-in one
unavailable).
- **import_(journal, input=None)**: the actual importer. Must append
entries to the journal passed to it. It is recommended to accept either a
filename or standard input as a source.

## Custom Exporter

Custom exporters are useful to make *jrnl*'s data available to other programs.
One common usecase would to generate the input to be used by a static site
generator or blogging engine.

An exporter take either a whole journal or a specific entry and exports it.
Below is a basic JSON Exporter; note that a more extensive JSON exporter is
included in *jrnl* and so this (if installed) would override the built in
exporter.

~~~ python
{%
include-markdown "../tests/external_plugins_src/jrnl/contrib/exporter/custom_json.py"
comments=false
%}
~~~

Note that the above is very minimal, doesn't do any error checking, and doesn't
export all entry metadata.

Some implementation notes:

- the exporter class must be named **Exporter** and should sub-class
**jrnl.plugins.base.BaseExporter**.
- the exporter module must be within the **jrnl.contrib.exporter** namespace.
- The exporter must not have any `__init__.py` files in the base directories
(but you can have one for your exporter base directory if it is in a
directory rather than a single file).
- The exporter must be installed as a Python package available to the same
Python interpreter running jrnl.
- the exporter should expose at least the following the following members
(there are a few more you will need to define if you don't subclass
`jrnl.plugins.base.BaseExporter`):
- **version** (string): the version of the plugin. Displayed to help the
user debug their installations.
- **names** (list of strings): these are the "names" that can be passed to
the CLI to invole your exporter. If you specific one used by a built-in
plugin, it will overwrite it (effectively making the built-in one
unavailable).
- **extension** (string): the file extention used on exported entries.
- **export_entry(entry)**: given an entry, returns a string of the formatted,
exported entry.
- **export_journal(journal)**: (optional) given a journal, returns a string
of the formatted, exported entries of the journal. If not implemented,
*jrnl* will call **export_entry()** on each entry in turn and then
concatenate the results together.

### Special Exporters

There are a few "special" exporters, in that they are called by *jrnl* in
situations other than a traditional export. They are:

- **short** -- called by `jrnl --short`. Displays each entry on a single line.
The default is to print the timestamp of the entry, followed by the title.
The built-in (default) plugin is at `jrnl.plugins.exporter.short`.
- **default** -- called when a different format is not specified. The built-in
(default) plugin is at `jrnl.plugins.exporter.pretty`.

## Development Tips

- Editable installs (`pip install -e ...`) don't seem to play nice with
the namespace layout. If your plugin isn't appearing, try a non-editable
install of both *jrnl* and your plugin.
- If you run *jrnl* from the main project root directory (the one that contains
*jrnl*'s source code), namespace plugins won't be recognized. This is (I
suspect) because the Python interpreter will find your *jrnl* source directory
(which doesn't contain your namespace plugins) before it find your
"site-packages" directory (i.e. installed packages, which will recognize
namespace packages).
- Don't name your plugin file "testing.py" or it won't be installed (at least
automatically) by pip.
- For examples, you can look to the *jrnl*'s internal importers and exporters.
As well, there are some basic external examples included in *jrnl*'s git repo
at `tests/external_plugins_src` (including the example code above).
3 changes: 2 additions & 1 deletion docs_theme/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
mkdocs==1.1
mkdocs==1.1.2
mkdocs-include-markdown-plugin==2.8.0
47 changes: 38 additions & 9 deletions features/environment.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)


Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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":
Expand Down
4 changes: 4 additions & 0 deletions features/format.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
Loading