Skip to content

Commit

Permalink
Redesign 'formats' API to support formats which need to store data fo…
Browse files Browse the repository at this point in the history
…r their parsers.
  • Loading branch information
kpfleming committed Aug 1, 2023
1 parent 2408d28 commit 5af7504
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 133 deletions.
65 changes: 42 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ find the plugin's hooks (and must match the name specified in the
import codecs

from jinjanator_plugins import (
Format,
FormatOptionUnknownError,
FormatOptionUnsupportedError,
FormatOptionValueError,
Expand All @@ -144,16 +143,20 @@ def is_len12_test(value):


class SpamFormat:
@staticmethod
def parser(data_string, options):
ham = False
name = "spam"
suffixes = (".spam",)
option_names = ("ham",)

def __init__(self, options):
self.ham = False

if options:
for option in options:
if option == "ham":
ham = True
self.ham = True

if ham:
def parse(self, data_string):
if self.ham:
return {
"ham": "ham",
"cheese": "ham and cheese",
Expand All @@ -166,8 +169,6 @@ class SpamFormat:
"potatoes": "spam and potatoes",
}

fmt = Format(name="spam", parser=parser, suffixes=[".spam"], options=["ham"])


@plugin_identity_hook
def plugin_identities():
Expand All @@ -186,7 +187,7 @@ def plugin_tests():

@plugin_formats_hook
def plugin_formats():
return {SpamFormat.fmt.name: SpamFormat.fmt}
return {SpamFormat.name: SpamFormat}
```

Note that the real example makes use of type annotations, but they
Expand Down Expand Up @@ -217,9 +218,9 @@ provided (which Jinjanator would have read from a data file), and
instead returns one of two canned responses based on whether the `ham`
option has been provided by the user.

The `parser` static method is the function which does the work, and
the `fmt` class variable is a `Format` object providing the details of
the format.
The `parse` method is the function which does the work; the `__init__`
method handles options provided by the user; the class attributes
provide details of the format.

#### plugin_identities

Expand Down Expand Up @@ -278,24 +279,42 @@ found.

The function must return a dictionary, with each key being a format
function name (the name which will be used in the `--format` argument
to Jinjanator, if needed) and the corresponding value being a Format
object. That object contains the name of the format (for use in error
messages), a reference to the format parser function, a (possibly
empty) list of file suffixes which can be matched to this format
during format auto-detection, and a (possibly empty) list of options
which the user can provide to modify the parser's behavior.

Note that the function *must* be named `plugin_formats`; it is the
to Jinjanator, if needed) and the corresponding value being a class
which implements the requirements of the `Format` protocol (defined in
[__init__.py](src/jinjanator-plugins/__init__.py)).

In particular these requirements include:

* a class attribute called `name` which contains the name of the
format (for use in error messages)

* a class attribute called 'suffixes' which contains a (possibly
empty) list of file suffixes which can be matched to this format
during format auto-detection

* a class attribute called 'option_names' which contains a (possibly
empty) list of options which the user can provide to modify the
parser's behavior

* a constructor method (`__init__`) which accepts a (possibly empty)
list of options provided by the user, and performs any validation
needed on them, storing the results in the `self` object

* a `parse` method which accepts a (possibly empty) string containing
the input data, and parses it according to the format's needs, using
any previously-validated options stored in the `self` object

Note that the hook function *must* be named `plugin_formats`; it is the
second part of the 'magic' mechanism mentioned above.

Format functions can accept 'options' to modify their behavior, and
Format classes can accept 'options' to modify their behavior, and
should raise the exceptions listed below, when needed, to inform the
user if one of the provided options does not meet the format's
requirements.

* `FormatOptionUnknownError` will be raised automatically by the
Jinjanator CLI based on the content of the `options` attribute of
the `Format` object.
Jinjanator CLI based on the content of the `option_names` attribute of
the format class.

* `FormatOptionUnsupportedError` should be raised when a provided
option is not supported in combination with the other provided
Expand Down
1 change: 1 addition & 0 deletions changelog.d/+api23.4.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Major redesign of the 'formats' API; formats are now classes and can store data for their needs.
37 changes: 17 additions & 20 deletions plugin_example/jinjanator_plugin_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

import codecs

from typing import Mapping
from typing import Iterable, Mapping

from jinjanator_plugins import (
Filters,
Format,
FormatOptionUnknownError,
FormatOptionUnsupportedError,
FormatOptionValueError,
Expand All @@ -29,29 +28,29 @@ def is_len12_test(value: str) -> bool:


class SpamFormat:
@staticmethod
def parser(
data_string: str, # noqa: ARG004
options: list[str] | None = None,
) -> Mapping[str, str]:
ham = False
name = "spam"
suffixes: Iterable[str] | None = (".spam",)
option_names: Iterable[str] | None = ("ham",)

def __init__(self, options: Iterable[str] | None) -> None:
self.ham = False

if options:
for option in options:
if option == "ham":
ham = True
self.ham = True
elif option == "uns":
raise FormatOptionUnsupportedError(
SpamFormat.fmt, option, "is not supported"
)
raise FormatOptionUnsupportedError(self, option, "is not supported")
elif option == "val":
raise FormatOptionValueError(
SpamFormat.fmt, option, "", "is not valid"
)
raise FormatOptionValueError(self, option, "", "is not valid")
else:
raise FormatOptionUnknownError(SpamFormat.fmt, option)
raise FormatOptionUnknownError(self, option)

if ham:
def parse(
self,
data_string: str, # noqa: ARG002
) -> Mapping[str, str]:
if self.ham:
return {
"ham": "ham",
"cheese": "ham and cheese",
Expand All @@ -64,8 +63,6 @@ def parser(
"potatoes": "spam and potatoes",
}

fmt = Format(name="spam", parser=parser, suffixes=[".spam"], options=["ham"])


@plugin_identity_hook
def plugin_identities() -> Identity:
Expand All @@ -84,4 +81,4 @@ def plugin_tests() -> Tests:

@plugin_formats_hook
def plugin_formats() -> Formats:
return {f.name: f for f in [SpamFormat.fmt]}
return {SpamFormat.name: SpamFormat}
10 changes: 5 additions & 5 deletions plugin_example/tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,27 @@ def test_test() -> None:


def test_format() -> None:
result = plugin.SpamFormat.parser("", [])
result = plugin.SpamFormat([]).parse("")
assert "cheese" in result
assert "spam and cheese" == result["cheese"]


def test_format_option() -> None:
result = plugin.SpamFormat.parser("", ["ham"])
result = plugin.SpamFormat(["ham"]).parse("")
assert "cheese" in result
assert "ham and cheese" == result["cheese"]


def test_format_option_unknown() -> None:
with pytest.raises(FormatOptionUnknownError):
plugin.SpamFormat.parser("", ["unk"])
plugin.SpamFormat(["unk"])


def test_format_option_unsupported() -> None:
with pytest.raises(FormatOptionUnsupportedError):
plugin.SpamFormat.parser("", ["uns"])
plugin.SpamFormat(["uns"])


def test_format_option_value() -> None:
with pytest.raises(FormatOptionValueError):
plugin.SpamFormat.parser("", ["val"])
plugin.SpamFormat(["val"])
65 changes: 47 additions & 18 deletions plugin_example/tests/test_plugin_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,59 @@ def hook_callers() -> PluginHookCallers:


def test_identity(hook_callers: PluginHookCallers) -> None:
result = hook_callers.plugin_identities()
assert len(result) == 1
assert "example" == result[0]
result = iter(hook_callers.plugin_identities())

_identity = next(result)
with pytest.raises(StopIteration):
next(result)

assert "example" == _identity


def test_filter(hook_callers: PluginHookCallers) -> None:
result = hook_callers.plugin_filters()
assert len(result) == 1
assert "rot13" in result[0]
assert plugin.rot13_filter == result[0]["rot13"]
result = iter(hook_callers.plugin_filters())

filters = next(result)
with pytest.raises(StopIteration):
next(result)

assert "rot13" in filters
assert 1 == len(filters.keys())


def test_test(hook_callers: PluginHookCallers) -> None:
result = hook_callers.plugin_tests()
assert len(result) == 1
assert "len12" in result[0]
assert plugin.is_len12_test == result[0]["len12"]
result = iter(hook_callers.plugin_tests())

tests = next(result)
with pytest.raises(StopIteration):
next(result)

assert "len12" in tests
assert 1 == len(tests.keys())


def test_format(hook_callers: PluginHookCallers) -> None:
result = hook_callers.plugin_formats()
assert len(result) == 1
assert "spam" in result[0]
assert len(result[0]["spam"].suffixes) == 1
assert ".spam" == result[0]["spam"].suffixes[0]
assert len(result[0]["spam"].options) == 1
assert "ham" == result[0]["spam"].options[0]
result = iter(hook_callers.plugin_formats())

fmts = next(result)
with pytest.raises(StopIteration):
next(result)

assert "spam" in fmts
_fmt = fmts["spam"]

assert _fmt.suffixes is not None
suffixes = iter(_fmt.suffixes)
_suffix = next(suffixes)
with pytest.raises(StopIteration):
next(suffixes)

assert ".spam" == _suffix

assert _fmt.option_names is not None
option_names = iter(_fmt.option_names)
_option = next(option_names)
with pytest.raises(StopIteration):
next(option_names)

assert "ham" == _option
66 changes: 47 additions & 19 deletions plugin_example/tests/test_plugin_discovery_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from typing import cast

import jinjanator_plugin_example as plugin # type: ignore[import]
import pluggy # type: ignore[import]
import pytest

Expand All @@ -21,30 +20,59 @@ def hook_callers() -> PluginHookCallers:


def test_identity(hook_callers: PluginHookCallers) -> None:
result = hook_callers.plugin_identities()
assert len(result) == 1
assert "example" == result[0]
result = iter(hook_callers.plugin_identities())

_identity = next(result)
with pytest.raises(StopIteration):
next(result)

assert "example" == _identity


def test_filter(hook_callers: PluginHookCallers) -> None:
result = hook_callers.plugin_filters()
assert len(result) == 1
assert "rot13" in result[0]
assert plugin.rot13_filter == result[0]["rot13"]
result = iter(hook_callers.plugin_filters())

filters = next(result)
with pytest.raises(StopIteration):
next(result)

assert "rot13" in filters
assert 1 == len(filters.keys())


def test_test(hook_callers: PluginHookCallers) -> None:
result = hook_callers.plugin_tests()
assert len(result) == 1
assert "len12" in result[0]
assert plugin.is_len12_test == result[0]["len12"]
result = iter(hook_callers.plugin_tests())

tests = next(result)
with pytest.raises(StopIteration):
next(result)

assert "len12" in tests
assert 1 == len(tests.keys())


def test_format(hook_callers: PluginHookCallers) -> None:
result = hook_callers.plugin_formats()
assert len(result) == 1
assert "spam" in result[0]
assert len(result[0]["spam"].suffixes) == 1
assert ".spam" == result[0]["spam"].suffixes[0]
assert len(result[0]["spam"].options) == 1
assert "ham" == result[0]["spam"].options[0]
result = iter(hook_callers.plugin_formats())

fmts = next(result)
with pytest.raises(StopIteration):
next(result)

assert "spam" in fmts
_fmt = fmts["spam"]

assert _fmt.suffixes is not None
suffixes = iter(_fmt.suffixes)
_suffix = next(suffixes)
with pytest.raises(StopIteration):
next(suffixes)

assert ".spam" == _suffix

assert _fmt.option_names is not None
option_names = iter(_fmt.option_names)
_option = next(option_names)
with pytest.raises(StopIteration):
next(option_names)

assert "ham" == _option
Loading

0 comments on commit 5af7504

Please sign in to comment.