diff --git a/.github/workflows/test_pyfdl.yml b/.github/workflows/test_pyfdl.yml index 020d021..80492d6 100644 --- a/.github/workflows/test_pyfdl.yml +++ b/.github/workflows/test_pyfdl.yml @@ -28,7 +28,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest mktestdocs + python -m pip install flake8 pytest mktestdocs pyyaml - name: Install PyFDL run: | pip install . diff --git a/docs/Classes/canvas.md b/docs/FDL Classes/canvas.md similarity index 100% rename from docs/Classes/canvas.md rename to docs/FDL Classes/canvas.md diff --git a/docs/Classes/canvas_template.md b/docs/FDL Classes/canvas_template.md similarity index 100% rename from docs/Classes/canvas_template.md rename to docs/FDL Classes/canvas_template.md diff --git a/docs/Classes/common.md b/docs/FDL Classes/common.md similarity index 100% rename from docs/Classes/common.md rename to docs/FDL Classes/common.md diff --git a/docs/Classes/context.md b/docs/FDL Classes/context.md similarity index 100% rename from docs/Classes/context.md rename to docs/FDL Classes/context.md diff --git a/docs/Classes/errors.md b/docs/FDL Classes/errors.md similarity index 100% rename from docs/Classes/errors.md rename to docs/FDL Classes/errors.md diff --git a/docs/Classes/fdl.md b/docs/FDL Classes/fdl.md similarity index 100% rename from docs/Classes/fdl.md rename to docs/FDL Classes/fdl.md diff --git a/docs/Classes/framing_decision.md b/docs/FDL Classes/framing_decision.md similarity index 100% rename from docs/Classes/framing_decision.md rename to docs/FDL Classes/framing_decision.md diff --git a/docs/Classes/framing_intent.md b/docs/FDL Classes/framing_intent.md similarity index 100% rename from docs/Classes/framing_intent.md rename to docs/FDL Classes/framing_intent.md diff --git a/docs/Classes/header.md b/docs/FDL Classes/header.md similarity index 100% rename from docs/Classes/header.md rename to docs/FDL Classes/header.md diff --git a/docs/Handlers/handlers.md b/docs/Handlers/handlers.md new file mode 100644 index 0000000..9df0094 --- /dev/null +++ b/docs/Handlers/handlers.md @@ -0,0 +1,4 @@ +# Handlers + +## FDLHandler +This is the built-in handler for reading and writing fdl files diff --git a/docs/Plugins/plugins.md b/docs/Plugins/plugins.md new file mode 100644 index 0000000..4623767 --- /dev/null +++ b/docs/Plugins/plugins.md @@ -0,0 +1,113 @@ +# About Plugins + +PyFDL supports plugins for expanding the toolkit. +This may be useful to add support for converting frame line definitions in another formats to FDL or +reading metadata from files to mention a few examples. + +## Plugin types + +### Handlers +Plugins that take care of reading/writing files to and/or from FDL are called `handlers`. +PyFDL comes with a built-in [`FDLHandler`](../Handlers/handlers.md#fdlhandler) which takes care of reading and writing +FDL files. + +## Writing a handler plugin +There are only a couple of requirements for a handler. +1. The handler needs to be a class with a `name` variable. If your handler deals with files you should + provide a `suffixes` variable containing a list of suffixes to support. Suffixes include the dot like. ".yml" +2. The module needs to provide a function, example: `register_plugin(registry)` which accepts one argument for + the registry. This function will call `registry.add_handler()` with an instance of your + handler. + +Example of a YAML Handler: +```python +import yaml +from pathlib import Path +from typing import Optional, Any +from pyfdl import FDL + +class YAMLHandler: + def __init__(self): + # Name is required + self.name = 'yaml' + # Suffixes may be used to automagically select this handler based on path + self.suffixes = [".yml", ".yaml"] + + def write_to_string(self, fdl: FDL, validate: bool = True, **yaml_args: Optional[Any]) -> str: + if validate: + fdl.validate() + + return yaml.dump(fdl.to_dict(), **yaml_args) + + def write_to_file(self, fdl: FDL, path: Path, validate: bool = True, **yaml_args: Optional[Any]) -> str: + if validate: + fdl.validate() + print('yoho') + with path.open('w') as f: + f.write(yaml.dump(fdl.to_dict(), **yaml_args)) + + def custom_method(self, fdl: FDL, brand: str) -> FDL: + fdl.fdl_creator = brand + return fdl + + +def register_plugin(registry: 'PluginRegistry'): + registry.add_handler(YAMLHandler()) +``` + +## Installing a plugin +### Install via pip +Ideally you package your plugin according to best practices and share it via PyPi for other to use. +However, the only requirement from PyFDL's perspective is that you install the plugin to PyFDL's +plugin namespace: `pyfdl.plugins`. + +In your pyproject.toml or setup.py make sure to add your package/module like so: +``` toml + +[project.entry-points."pyfdl.plugins"] +# If you choose to call your register function something else, make sure to adjust the entry below. +yaml_handler = "yaml_handler:register_plugin" +``` + +You are free to name your register function whatever you like, but make sure you add it after the module name +If you don't provide a function name PyFDL will assume the function is named `register_plugin` and will +ignore your plugin if that is not the case. + +### Install at runtime +You may also add your handler directly in your code. +To do this, simply get a hold of the registry and add your handler. + +```python +from pyfdl.plugins import get_registry + +registry = get_registry() +registry.add_handler(YAMLHandler()) +``` + +## Using your handler +If your handler provides one or more of the following methods: `read_from_file` `read_from_string`, +`write_to_file` or `write_to_string`, PyFDL will choose your handler based on either path (suffix) or +directly asking for this specific handler. + +```python +import pyfdl +from pathlib import Path +from tempfile import NamedTemporaryFile + +fdl = pyfdl.FDL() +fdl.apply_defaults() +pyfdl.write_to_string(fdl, handler_name='yaml', indent=4) +pyfdl.write_to_file(fdl, path=NamedTemporaryFile(suffix='.yml', delete=False).name) +``` +If your handler doesn't provide one of the methods above or you have others exposed you can use them like so: +```python +from pyfdl.handlers import get_handler + +my_handler = get_handler(func_name='custom_method', handler_name='yaml') +fdl = FDL() +fdl.apply_defaults() +assert fdl.fdl_creator == 'PyFDL' +my_handler.custom_method(fdl, "my brand") +assert fdl.fdl_creator == 'my brand' + +``` diff --git a/docs/Plugins/registry.md b/docs/Plugins/registry.md new file mode 100644 index 0000000..2851c36 --- /dev/null +++ b/docs/Plugins/registry.md @@ -0,0 +1,8 @@ +# Plugin Registry +The `PluginRegistry` is in charge of loading and keeping track of PyFDL's plugins. + +____ + +::: pyfdl.plugins.registry + options: + inherited_members: false diff --git a/docs/getting_started.md b/docs/getting_started.md index 540385f..fedbc1c 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -13,12 +13,12 @@ round values of dimensions accordingly. A canvas+framing decision for a "raw" camera canvas should in theory keep more precision than a canvas+framing decision for a conformed VFX plate. -The rules for rounding strategy are the same as for [CanvasTemplate.round](Classes/common.md#pyfdl.RoundStrategy) +The rules for rounding strategy are the same as for [CanvasTemplate.round](FDL Classes/common.md#pyfdl.RoundStrategy) -The [default](Classes/common.md#pyfdl.DEFAULT_ROUNDING_STRATEGY) strategy is to round dimensions to +The [default](FDL Classes/common.md#pyfdl.DEFAULT_ROUNDING_STRATEGY) strategy is to round dimensions to even numbers, but this may be overridden by setting the rounding strategy to -[`NO_ROUNDING`](Classes/common.md#pyfdl.NO_ROUNDING) +[`NO_ROUNDING`](FDL Classes/common.md#pyfdl.NO_ROUNDING) Here are a couple examples of setting the rounding strategy: ```python @@ -42,7 +42,7 @@ fdl.set_rounding_strategy({'even': 'whole', 'mode': 'up'}) ```python import pyfdl from pyfdl import Canvas, FramingIntent, Dimensions, Point -from io import StringIO +from tempfile import NamedTemporaryFile fdl = pyfdl.FDL() @@ -80,20 +80,19 @@ fdl.place_canvas_in_context(context_label="PanavisionDXL2", canvas=canvas) # Finally, let's create a framing decision canvas.place_framing_intent(framing_intent=framing_intent) -# Validate our FDL and save it (using StringIO as example) -with StringIO() as f: - pyfdl.dump(fdl, f, validate=True) +# Validate our FDL and save it +with NamedTemporaryFile(suffix='.fdl', delete=False) as f: + pyfdl.write_to_file(fdl, f.name, validate=True) ``` ### Create a Canvas from a Canvas Template ```python import pyfdl -from io import StringIO from pathlib import Path +from tempfile import NamedTemporaryFile fdl_file = Path('tests/sample_data/Scenario-9__OriginalFDL_UsedToMakePlate.fdl') -with fdl_file.open('r') as f: - fdl = pyfdl.load(f) +fdl = pyfdl.read_from_file(fdl_file) # Select the first canvas in the first context context = fdl.contexts[0] @@ -113,7 +112,7 @@ new_canvas = pyfdl.Canvas.from_canvas_template( # Place the new canvas along side the source fdl.place_canvas_in_context(context_label=context.label, canvas=new_canvas) -# Validate and "save" -with StringIO() as f: - pyfdl.dump(fdl, f, validate=True) +# Validate and write to file. +with NamedTemporaryFile(suffix='.fdl', delete=False) as f: + pyfdl.write_to_file(fdl, f.name, validate=True) ``` diff --git a/docs/top_level_functions.md b/docs/top_level_functions.md index d41b3ec..740ad9e 100644 --- a/docs/top_level_functions.md +++ b/docs/top_level_functions.md @@ -3,10 +3,10 @@ These functions follow the convention of other file parsing packages like python's builtin json package and provide both reading and writing to and from strings and objects. -::: pyfdl.load +::: pyfdl.read_from_file -::: pyfdl.loads +::: pyfdl.read_from_string -::: pyfdl.dump +::: pyfdl.write_to_file -::: pyfdl.dumps +::: pyfdl.write_to_string diff --git a/pyproject.toml b/pyproject.toml index 31519ef..6b0cf22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,8 @@ path = "src/pyfdl/__init__.py" [tool.hatch.envs.test] dependencies = [ "pytest", - "mktestdocs" + "mktestdocs", + "pyyaml" ] [tool.hatch.envs.test.scripts] diff --git a/src/pyfdl/__init__.py b/src/pyfdl/__init__.py index 6a8b97d..cdfbe7a 100644 --- a/src/pyfdl/__init__.py +++ b/src/pyfdl/__init__.py @@ -1,7 +1,3 @@ -import json - -from typing import IO, Union - from .common import ( Base, Dimensions, @@ -20,8 +16,9 @@ from .canvas import Canvas from .context import Context from .canvas_template import CanvasTemplate -from .pyfdl import FDL +from .fdl import FDL from .errors import FDLError, FDLValidationError +from .handlers import read_from_file, read_from_string, write_to_string, write_to_file __all__ = [ 'Base', @@ -39,78 +36,14 @@ 'FramingDecision', 'FramingIntent', 'Header', - 'load', - 'loads', 'NO_ROUNDING', 'Point', + 'read_from_file', + 'read_from_string', 'RoundStrategy', - 'TypedCollection' + 'TypedCollection', + 'write_to_file', + 'write_to_string' ] __version__ = "0.1.0.dev0" - - -def load(fp: IO, validate: bool = True) -> FDL: - """ - Load an FDL from a file. - - Args: - fp: file pointer - validate: validate incoming json with jsonschema - - Raises: - jsonschema.exceptions.ValidationError: if the contents doesn't follow the spec - - Returns: - FDL: - """ - raw = fp.read() - return loads(raw, validate=validate) - - -def loads(s: str, validate: bool = True) -> FDL: - """Load an FDL from string. - - Args: - s: string representation of an FDL - validate: validate incoming json with jsonschema - - Returns: - FDL: - - """ - fdl = FDL.from_dict(json.loads(s)) - - if validate: - fdl.validate() - - return fdl - - -def dump(obj: FDL, fp: IO, validate: bool = True, indent: Union[int, None] = 2): - """Dump an FDL to a file. - - Args: - obj: object to serialize - fp: file pointer - validate: validate outgoing json with jsonschema - indent: amount of spaces - """ - fp.write(dumps(obj, validate=validate, indent=indent)) - - -def dumps(obj: FDL, validate: bool = True, indent: Union[int, None] = 2) -> str: - """Dump an FDL to string - - Args: - obj: object to serialize - validate: validate outgoing json with jsonschema - indent: amount of spaces - - Returns: - string: representation of the resulting json - """ - if validate: - obj.validate() - - return json.dumps(obj.to_dict(), indent=indent, sort_keys=False) diff --git a/src/pyfdl/errors.py b/src/pyfdl/errors.py index 0fdfb21..13f8c73 100644 --- a/src/pyfdl/errors.py +++ b/src/pyfdl/errors.py @@ -4,3 +4,11 @@ class FDLError(Exception): class FDLValidationError(FDLError): pass + + +class HandlerError(FDLError): + pass + + +class UnknownHandlerError(HandlerError): + pass diff --git a/src/pyfdl/pyfdl.py b/src/pyfdl/fdl.py similarity index 100% rename from src/pyfdl/pyfdl.py rename to src/pyfdl/fdl.py diff --git a/src/pyfdl/handlers/__init__.py b/src/pyfdl/handlers/__init__.py new file mode 100644 index 0000000..90c004d --- /dev/null +++ b/src/pyfdl/handlers/__init__.py @@ -0,0 +1,100 @@ +from pathlib import Path +from typing import Optional, Any, Union + +from pyfdl.plugins import get_registry + + +def get_handler(func_name: str, path: Union[Path, str] = None, handler_name: str = None) -> Any: + """ + Convenience function to get a handler matching the provided arguments. + + Args: + func_name: desired function name in handler + path: to file. Used to get handler based on suffix + handler_name: ask for a specific handler by name + + Returns: + handler: + """ + + if handler_name is None and path is None: + raise RuntimeError(f'"handler_name" and "path" can\'t both be None. Please provide one or the other') + + _registry = get_registry() + if handler_name is not None: + handler = _registry.get_handler_by_name(handler_name, func_name=func_name) + + else: + path = Path(path) + handler = _registry.get_handler_by_suffix(path.suffix, func_name=func_name) + + return handler + + +def read_from_file(path: Union[Path, str], handler_name: str = None, **handler_kwargs: Optional[Any]) -> 'FDL': + """ + Handler agnostic function for producing an FDL from a file. A suitable handler will be + chosen based on `path` or `handler_name`. + + Args: + path: to the file in question + handler_name: name of handler to use + **handler_kwargs: arguments passed to handler + + Returns: + FDL: + """ + path = Path(path) + handler = get_handler(func_name='read_from_file', path=path, handler_name=handler_name) + + return handler.read_from_file(path, **handler_kwargs) + + +def read_from_string(s: str, handler_name: str = 'fdl', **handler_kwargs: Optional[Any]) -> 'FDL': + """ + Handler agnostic function for producing an FDL based on a string. A suitable handler will be + chosen based on `handler_name`. Defaults to "fdl". + + Args: + s: string to convert into an FDL + handler_name: name of handler to use + **handler_kwargs: arguments passed to handler + + Returns: + FDL: + """ + handler = get_handler(func_name='read_from_string', handler_name=handler_name) + return handler.read_from_string(s, **handler_kwargs) + + +def write_to_file(fdl: 'FDL', path: Union[Path, str], handler_name: str = None, **handler_kwargs: Optional[Any]): + """ + Handler agnostic function to write a file based on an FDL. A suitable handler will be chosen based + on `path` or `handler_name` + + Args: + fdl: to write + path: to file + handler_name: name of handler to use + **handler_kwargs: arguments passed to handler + """ + path = Path(path) + handler = get_handler(func_name='write_to_file', path=path, handler_name=handler_name) + handler.write_to_file(fdl, path, **handler_kwargs) + + +def write_to_string(fdl: 'FDL', handler_name: str = 'fdl', **handler_kwargs: Optional[Any]): + """ + Handler agnostic function for producing a string representation of an FDL. A suitable handler will + be chosen based on `handler_name`. + + Args: + fdl: to write + handler_name: name of hanlder to use + **handler_kwargs: arguments passed to handler + + Returns: + + """ + handler = get_handler(func_name='write_to_string', handler_name=handler_name) + return handler.write_to_string(fdl, **handler_kwargs) diff --git a/src/pyfdl/handlers/fdl_handler.py b/src/pyfdl/handlers/fdl_handler.py new file mode 100644 index 0000000..64359f0 --- /dev/null +++ b/src/pyfdl/handlers/fdl_handler.py @@ -0,0 +1,98 @@ +import json +from pathlib import Path +from typing import IO, Union + +from pyfdl import FDL + + +class FDLHandler: + def __init__(self): + """ + The default built-in FDL handler. Takes care of reading and writing FDL files + """ + self.name = 'fdl' + self.suffixes = ['.fdl'] + + def read_from_file(self, path: Path, validate: bool = True) -> FDL: + """ + Read an FDL from a file. + + Args: + path: to fdl file + validate: validate incoming json with jsonschema + + Raises: + jsonschema.exceptions.ValidationError: if the contents doesn't follow the spec + + Returns: + FDL: + """ + + with path.open('r') as fp: + raw = fp.read() + return self.read_from_string(raw, validate=validate) + + def read_from_string(self, s: str, validate: bool = True) -> FDL: + """Read an FDL from a string. + + Args: + s: string representation of an FDL + validate: validate incoming json with jsonschema + + Raises: + jsonschema.exceptions.ValidationError: if the contents doesn't follow the spec + + Returns: + FDL: + + """ + fdl = FDL.from_dict(json.loads(s)) + + if validate: + fdl.validate() + + return fdl + + def write_to_file(self, fdl: FDL, path: Path, validate: bool = True, indent: Union[int, None] = 2): + """Dump an FDL to a file. + + Args: + fdl: object to serialize + path: path to store fdl file + validate: validate outgoing json with jsonschema + indent: amount of spaces + + Raises: + jsonschema.exceptions.ValidationError: if the contents doesn't follow the spec + """ + with path.open('w') as fp: + fp.write(self.write_to_string(fdl, validate=validate, indent=indent)) + + def write_to_string(self, fdl: FDL, validate: bool = True, indent: Union[int, None] = 2) -> str: + """Dump an FDL to string + + Args: + fdl: object to serialize + validate: validate outgoing json with jsonschema + indent: amount of spaces + + Raises: + jsonschema.exceptions.ValidationError: if the contents doesn't follow the spec + + Returns: + string: representation of the resulting json + """ + if validate: + fdl.validate() + + return json.dumps(fdl.to_dict(), indent=indent, sort_keys=False) + + +def register_plugin(registry: 'PluginReistry'): + """ + Mandatory function to register handler in the registry. Called by the PluginRegistry itself. + + Args: + registry: The PluginRegistry passes itself to this function + """ + registry.add_handler(FDLHandler()) diff --git a/src/pyfdl/plugins/__init__.py b/src/pyfdl/plugins/__init__.py new file mode 100644 index 0000000..180be43 --- /dev/null +++ b/src/pyfdl/plugins/__init__.py @@ -0,0 +1 @@ +from .registry import get_registry diff --git a/src/pyfdl/plugins/registry.py b/src/pyfdl/plugins/registry.py new file mode 100644 index 0000000..34e8921 --- /dev/null +++ b/src/pyfdl/plugins/registry.py @@ -0,0 +1,141 @@ +import sys +from importlib import import_module, resources +from importlib.metadata import entry_points +from typing import Union, Any + +from pyfdl.errors import UnknownHandlerError, HandlerError + +_REGISTRY = None + + +class PluginRegistry: + def __init__(self): + """ + The `PluginRegistry` loads and registers all plugins and built in handlers. + """ + + self.handlers = {} + self.load_builtin() + self.load_plugins() + + def load_builtin(self): + """ + Load the built-in handlers + """ + anchor = 'pyfdl.handlers' + for module in resources.files(anchor).iterdir(): + if module.name.startswith('_') or module.suffix != '.py': + continue + module_name = module.stem + mod = import_module(f'{anchor}.{module_name}') + if hasattr(mod, 'register_plugin'): + mod.register_plugin(self) + + def load_plugins(self): + """ + Load plugins from the "pyfdl.plugins" namespace. + """ + try: + plugin_packages = entry_points(group='pyfdl.plugins') + except TypeError: + # Python < 3.10 + plugin_packages = entry_points().get('pyfdl.plugins', []) + + for plugin in plugin_packages: + try: + if plugin.attr is not None: + register_func = plugin.load() + else: + mod = plugin.load() + register_func = getattr(mod, 'register_plugin', None) + + if register_func is None: + print( + f'Unable to find a registration function in plugin: "{plugin.name}". ' + f'Please consult documentation on plugins to solve this.', + file=sys.stderr + ) + + register_func(self) + + except (ModuleNotFoundError, TypeError) as err: + print( + f'Unable to load plugin: "{plugin.name}" due to: "{err}"', + file=sys.stderr + ) + + def add_handler(self, handler: Any): + """ + Add a handler to the collection of handlers + Args: + handler: plugin or built-in handler to add + """ + self.handlers.setdefault(handler.name, handler) + + def get_handler_by_name(self, handler_name: str, func_name: str) -> Union['Handler', None]: + """ + Get a registered handler by `handler_name`, and + make sure it has a function (`func_name`) to call + + Args: + handler_name: name handler to look for + func_name: name of function in handler to call + + Returns: + handler: + + Raises: + error: if no handler by name and function is registered + """ + handler = self.handlers.get(handler_name) + if hasattr(handler, func_name): + return handler + + raise UnknownHandlerError( + f'No handler by name: "{handler_name}" with function: "{func_name}" seems to be registered. ' + f'Please check that a plugin containing this handler is properly installed' + f'or use one of the following registered handlers: {sorted(self.handlers.keys())}' + ) + + def get_handler_by_suffix(self, suffix: str, func_name: str) -> Union['Handler', None]: + """ + Get a registered handler by `suffix`, and + make sure it has a function (`func_name`) to call + + + Args: + suffix: including the dot (".fdl") + func_name: name of function in handler to call + + Returns: + handler: + + Raises: + error: + """ + for handler in self.handlers.values(): + if not hasattr(handler, 'suffixes'): + continue + + if suffix in handler.suffixes and hasattr(handler, func_name): + return handler + + raise UnknownHandlerError( + f'No handler supporting suffix: "{suffix}" and function: "{func_name}" seems to be registered. ' + f'Please check that a plugin supporting this suffix is properly installed' + f'or use one of the following registered handlers: {sorted(self.handlers.keys())}' + ) + + +def get_registry(reload: bool = False) -> PluginRegistry: + """ + Get the active registry containing plugins and built-in handlers + + Returns: + registry: + """ + global _REGISTRY + if _REGISTRY is None or reload: + _REGISTRY = PluginRegistry() + + return _REGISTRY diff --git a/tests/conftest.py b/tests/conftest.py index 0438063..13f6008 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from pathlib import Path +import tempfile import pytest import pyfdl @@ -7,6 +9,17 @@ def consistent_rounding(): pyfdl.Base.set_rounding_strategy(pyfdl.DEFAULT_ROUNDING_STRATEGY) +@pytest.fixture(autouse=True) +def cleanup_temp_files(): + yield + + files = list(Path(tempfile.gettempdir()).glob('*.fdl')) + files += Path(tempfile.gettempdir()).glob('*.yml') + # Remove temp files + for file in files: + file.unlink(missing_ok=True) + + @pytest.fixture(scope="function") def base_subclass(): class BaseSubclass(pyfdl.Base): @@ -177,3 +190,16 @@ def sample_canvas_template_obj(): @pytest.fixture def sample_rounding_strategy_obj(): return pyfdl.RoundStrategy(even="even", mode="up") + + +@pytest.fixture +def simple_handler(): + class SimpleHandler: + def __init__(self): + self.name = 'simple' + self.suffixes = ['.ext'] + + def read_from_string(self, s: str) -> str: + return s + + return SimpleHandler diff --git a/tests/sample_data/faulty-handler-plugin/README.md b/tests/sample_data/faulty-handler-plugin/README.md new file mode 100644 index 0000000..fcba3ce --- /dev/null +++ b/tests/sample_data/faulty-handler-plugin/README.md @@ -0,0 +1,12 @@ +# faulty_handler_plugin +This is a "faulty" handler plugin used to test pyfdl's plugin system + +## Installation + +```console +pip install -e handler-plugin +``` + +## License + +`faulty-handler-plugin` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/tests/sample_data/faulty-handler-plugin/pyproject.toml b/tests/sample_data/faulty-handler-plugin/pyproject.toml new file mode 100644 index 0000000..6ae1d36 --- /dev/null +++ b/tests/sample_data/faulty-handler-plugin/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "faulty-handler-plugin" +dynamic = ["version"] +description = '' +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +keywords = [] +authors = [ + { name = "Alan Smithee", email = "alan@whomadeit.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "pyfdl" +] + +[tool.hatch.version] +path = "src/faulty_handler_plugin/__about__.py" + +[project.entry-points."pyfdl.plugins"] +# Notice intentional typo (plugi) below. Provokes a ModuleNotFoundError +faulty_handler_plugin1 = "faulty_handler_plugi:register_plugin_func" +# This plugin has no "register_plugin" function and will not import properly +faulty_handler_plugin2 = "faulty_handler_plugin" +# This will install, but fail on call as the plugin is missing a suffixes variable +faulty_handler_plugin3 = "faulty_handler_plugin:my_reg_func" diff --git a/tests/sample_data/faulty-handler-plugin/src/faulty_handler_plugin/__about__.py b/tests/sample_data/faulty-handler-plugin/src/faulty_handler_plugin/__about__.py new file mode 100644 index 0000000..050959f --- /dev/null +++ b/tests/sample_data/faulty-handler-plugin/src/faulty_handler_plugin/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2024-present apetrynet +# +# SPDX-License-Identifier: MIT +__version__ = "0.0.1" diff --git a/tests/sample_data/faulty-handler-plugin/src/faulty_handler_plugin/__init__.py b/tests/sample_data/faulty-handler-plugin/src/faulty_handler_plugin/__init__.py new file mode 100644 index 0000000..0141956 --- /dev/null +++ b/tests/sample_data/faulty-handler-plugin/src/faulty_handler_plugin/__init__.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2024-present apetrynet +# +# SPDX-License-Identifier: MIT + + +class MyFaultyHandler: + def __init__(self): + self.name = "myfaultyhandler" + self.suffixes = [] + + def write_to_string(self, s: str, some_arg: bool) -> str: + return ( + f'I went through all this trouble and all I got was this bool value: {some_arg} ' + f'and this string: {s}' + ) + + +# This module is missing a required "register_plugin" function and will fail on import + +def my_reg_func(registrar: 'PluginRegistry'): + registrar.add_handler(MyFaultyHandler()) diff --git a/tests/sample_data/handler-plugin/README.md b/tests/sample_data/handler-plugin/README.md new file mode 100644 index 0000000..c70ae61 --- /dev/null +++ b/tests/sample_data/handler-plugin/README.md @@ -0,0 +1,12 @@ +# handler_plugin +This is a "functioning" handler plugin used to test pyfdl's plugin system + +## Installation + +```console +pip install -e handler-plugin +``` + +## License + +`handler-plugin` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/tests/sample_data/handler-plugin/pyproject.toml b/tests/sample_data/handler-plugin/pyproject.toml new file mode 100644 index 0000000..44f780e --- /dev/null +++ b/tests/sample_data/handler-plugin/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "handler-plugin" +dynamic = ["version"] +description = '' +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +keywords = [] +authors = [ + { name = "Alan Smithee", email = "alan@whomadeit.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "pyfdl" +] + +[tool.hatch.version] +path = "src/handler_plugin/__about__.py" + +[project.entry-points."pyfdl.plugins"] +handler_plugin1 = "handler_plugin:register_plugin_func" +handler_plugin2 = "handler_plugin" diff --git a/tests/sample_data/handler-plugin/src/handler_plugin/__about__.py b/tests/sample_data/handler-plugin/src/handler_plugin/__about__.py new file mode 100644 index 0000000..050959f --- /dev/null +++ b/tests/sample_data/handler-plugin/src/handler_plugin/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2024-present apetrynet +# +# SPDX-License-Identifier: MIT +__version__ = "0.0.1" diff --git a/tests/sample_data/handler-plugin/src/handler_plugin/__init__.py b/tests/sample_data/handler-plugin/src/handler_plugin/__init__.py new file mode 100644 index 0000000..ced2310 --- /dev/null +++ b/tests/sample_data/handler-plugin/src/handler_plugin/__init__.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2024-present apetrynet +# +# SPDX-License-Identifier: MIT + + +class MyHandler1: + def __init__(self): + self.name = "myhandler1" + self.suffixes = [] + + def write_to_string(self, s: str, some_arg: bool) -> str: + return ( + f'I went through all this trouble and all I got was this bool value: {some_arg} ' + f'and this string: {s}' + ) + + +class MyHandler2: + def __init__(self): + self.name = "myhandler2" + self.suffixes = [] + + def write_to_string(self, s: str, some_arg: bool) -> str: + return ( + f'I went through all this trouble and all I got was this bool value: {some_arg} ' + f'and this string: {s}' + ) + + +# This func is named in pyproject.toml +def register_plugin_func(registrar: 'PluginRegistrar'): + registrar.add_handler(MyHandler1()) + + +# This is the required function if no function is provided like above +def register_plugin(registrar: 'PluginRegistrar'): + registrar.add_handler(MyHandler2()) diff --git a/tests/test_docs.py b/tests/test_docs.py index 7df7b96..b0cfddb 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -3,7 +3,13 @@ from mktestdocs import check_md_file +# files listed here will set memory to True for sequential code blocks +USE_MEM = ["plugins.md"] + @pytest.mark.parametrize('fpath', pathlib.Path("docs").glob("**/*.md"), ids=str) def test_all_docs(fpath): - check_md_file(fpath=fpath) + mem = False + if fpath.name in USE_MEM: + mem = True + check_md_file(fpath=fpath, memory=mem) diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 0000000..d4b10c6 --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,28 @@ +import pytest + +from pyfdl.handlers import get_handler +from pyfdl.plugins import get_registry +from pyfdl.errors import UnknownHandlerError + + +@pytest.mark.parametrize( + 'path, handler_name, expected_name', + ( + ['/some/filename.fdl', None, 'fdl'], + [None, 'fdl', 'fdl'], + [None, 'simple', 'simple'], + ['/some/filename.ext', None, 'simple'] + ) +) +def test_get_handler(simple_handler, path, handler_name, expected_name): + reg = get_registry(reload=True) + reg.add_handler(simple_handler()) + + handler = get_handler(func_name='read_from_string', path=path, handler_name=handler_name) + assert handler.name == expected_name + + with pytest.raises(RuntimeError): + get_handler(func_name='read_from_string') + + with pytest.raises(UnknownHandlerError): + get_handler(func_name='read_from_string', handler_name='bogus') diff --git a/tests/test_pyfdl.py b/tests/test_pyfdl.py index 6730102..70c5b71 100644 --- a/tests/test_pyfdl.py +++ b/tests/test_pyfdl.py @@ -12,57 +12,40 @@ ) -def test_load_unvalidated(): - with SAMPLE_FDL_FILE.open('r') as fdl_file: - fdl = pyfdl.load(fdl_file, validate=False) +def test_read_from_file_unvalidated(): + fdl = pyfdl.read_from_file(SAMPLE_FDL_FILE, validate=False) assert isinstance(fdl, pyfdl.FDL) - assert isinstance(fdl.header, pyfdl.Header) - assert fdl.header.uuid == fdl.uuid - assert fdl.uuid != "" -def test_load_validated(): - with SAMPLE_FDL_FILE.open('r') as fdl_file: - fdl = pyfdl.load(fdl_file, validate=True) +def test_read_from_file_validated(): + fdl = pyfdl.read_from_file(SAMPLE_FDL_FILE, validate=True) assert isinstance(fdl, pyfdl.FDL) - with SAMPLE_FDL_FILE.open('r') as f: - raw = json.load(f) - - assert raw == fdl.to_dict() - - -def test_loads(): - with SAMPLE_FDL_FILE.open('r') as fdl_file: - raw = fdl_file.read() - fdl = pyfdl.loads(raw) +def test_read_from_string(): + raw = SAMPLE_FDL_FILE.read_text() + fdl = pyfdl.read_from_string(raw) + assert isinstance(fdl, pyfdl.FDL) assert fdl.to_dict() == json.loads(raw) -def test_dump(tmp_path): +def test_write_to_file(tmp_path): my_path = Path(tmp_path, 'myfdl.fdl') - with SAMPLE_FDL_FILE.open('r') as fdl_file: - fdl1 = pyfdl.load(fdl_file) - - with my_path.open('w') as fp: - pyfdl.dump(fdl1, fp) + fdl1 = pyfdl.read_from_file(SAMPLE_FDL_FILE) - with my_path.open('r') as fp: - fdl2 = pyfdl.load(fp) + pyfdl.write_to_file(fdl1, my_path) + fdl2 = pyfdl.read_from_file(my_path) assert fdl1.to_dict() == fdl2.to_dict() -def test_dumps(): - with SAMPLE_FDL_FILE.open('r') as fdl_file: - raw = fdl_file.read() - - fdl = pyfdl.loads(raw) +def test_write_to_string(): + raw = SAMPLE_FDL_FILE.read_text() + fdl = pyfdl.read_from_string(raw) - assert json.loads(pyfdl.dumps(fdl)) == json.loads(raw) + assert json.loads(pyfdl.write_to_string(fdl)) == json.loads(raw) def test_init_empty_fdl(): diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..f587ffc --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,89 @@ +import pip +import pytest + +import pyfdl.plugins.registry +from pyfdl.plugins import get_registry +from pyfdl.handlers.fdl_handler import FDLHandler +from pyfdl.errors import UnknownHandlerError + + +@pytest.fixture +def install_plugins(): + pip.main( + ['install', 'tests/sample_data/handler-plugin', 'tests/sample_data/faulty-handler-plugin'] + ) + yield + + pip.main( + ['uninstall', '-y', 'handler-plugin', 'faulty-handler-plugin'] + ) + + +def test_loading_registry(): + pyfdl.plugins.registry._REGISTRY = None + assert isinstance(get_registry(), pyfdl.plugins.registry.PluginRegistry) + assert pyfdl.plugins.registry._REGISTRY is not None + + id_ = id(get_registry()) + get_registry(reload=True) + assert id_ != id(get_registry()) + + +def test_load_builtin(): + _registry = get_registry() + assert isinstance(_registry.handlers['fdl'], FDLHandler) + + +def test_load_plugin(capsys, install_plugins): + _registry = get_registry(reload=True) + assert _registry.handlers.get('myhandler1') is not None + assert _registry.handlers.get('myhandler2') is not None + + # faulty-handler-plugin intentionally fails importing + expected_err1 = \ + """Unable to load plugin: "faulty_handler_plugin1" due to: "No module named 'faulty_handler_plugi'""" + + expected_err2 = \ + """Unable to find a registration function in plugin: "faulty_handler_plugin2".""" + + captured_messages = capsys.readouterr().err + assert expected_err1 in captured_messages + assert expected_err2 in captured_messages + + +def test_add_handler(simple_handler): + _r = get_registry() + handler = simple_handler() + _r.add_handler(handler) + + assert handler.name in _r.handlers + + +def test_get_handler_by_name(simple_handler): + _r = get_registry(reload=True) + _handler = simple_handler() + _r.add_handler(_handler) + + handler = _r.get_handler_by_name(handler_name='simple', func_name='read_from_string') + assert handler is _handler + + with pytest.raises(UnknownHandlerError): + _r.get_handler_by_name(handler_name='simple', func_name='bogus') + + with pytest.raises(UnknownHandlerError): + _r.get_handler_by_name(handler_name='bogus', func_name='bogus') + + +def test_get_handler_by_suffix(simple_handler): + _r = get_registry(reload=True) + _handler = simple_handler() + _r.add_handler(_handler) + + handler = _r.get_handler_by_suffix(suffix='.ext', func_name='read_from_string') + assert handler is _handler + + with pytest.raises(UnknownHandlerError): + _r.get_handler_by_suffix(suffix='.ext', func_name='bogus') + + with pytest.raises(UnknownHandlerError): + _r.get_handler_by_suffix(suffix='bogus', func_name='bogus')