-
Notifications
You must be signed in to change notification settings - Fork 32
Implement plugin/modular system for FMF metadata loaders (Phase 1) #303
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -123,6 +123,91 @@ In the example above files or directories named ``.plans`` or | |
| the ``.fmf`` directory cannot be used for storing metadata. | ||
|
|
||
|
|
||
| Plugins | ||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
|
|
||
| Starting with fmf 2.0, a plugin system allows reading metadata from | ||
| multiple file formats beyond ``.fmf`` (YAML) files. This enables | ||
| extracting test metadata directly from source files like Python tests | ||
| or Bash scripts. | ||
|
|
||
| .. _config-plugins: | ||
|
|
||
| Plugin Configuration | ||
| -------------------- | ||
|
|
||
| Plugins can be enabled in the ``.fmf/config`` file: | ||
|
|
||
| .. code-block:: yaml | ||
|
|
||
| plugins: | ||
| - fmf # Short name for FmfPlugin | ||
| # or use full path: | ||
| - fmf.plugins.fmf.FmfPlugin | ||
| # Future phases: | ||
| # - bash | ||
| # - pytest | ||
|
|
||
| The ``plugins`` section lists built-in plugins to load. Each plugin | ||
| handles specific file types (e.g., ``.sh`` for Bash, ``.py`` for Python). | ||
|
|
||
| **Security**: Only built-in plugins from ``fmf/plugins/`` can be loaded. | ||
| Arbitrary code execution from config files is prevented. | ||
|
|
||
| If no ``plugins`` section is present, only the default ``FmfPlugin`` | ||
| is loaded (for backward compatibility), which handles ``.fmf`` files. | ||
|
|
||
| Plugin Priority | ||
| --------------- | ||
|
|
||
| When multiple plugins can handle the same file extension, the plugin | ||
| with the highest priority (0-200) is selected. Built-in plugins use | ||
| these priorities: | ||
|
|
||
| * FmfPlugin: 100 (default format) | ||
| * BashPlugin: 50 (future) | ||
| * PytestPlugin: 50 (future) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is the priority even needed?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For me it seems more robust and flexibile, as FMF should have higest prio when loading data, and bash and python files as well. I've expected that someone can make it overlapping. so that this priority can make plugin handling more flexible. e.g. imagine situation, that someone decide to rewrite some testsuite from bash to python, and will have old bash code here with new tests (with same tests and same naming) causes that if bash will have higher prio by default, override python metadata. I understand that this is probably corner case, and we can/couldn't support this scenario at all.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Iiuc the only difference is if you have both an fmf and pytest/bash node, which order does the equivalent of elasticity feature resolve? We do not have it well-defined there either :/ But I would rather we well-define the order and let the rest be a user issue until we get some context on how a user would prefer. I do not have strong preference if fmf files should be on top of pytest/bash, I would mostly use it as additive to each other. The only thing that comes to mind are the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, this is completely right, that it is not well defined as well now, e.g. use Yes, We can do some sort of harcoded priority, probably better than this flexibility. For FMF files It is little bit harder, there are two points, first is that
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've forgot to mention also main idea about that priority, what was in my mind :-) I plan to write two python plugins, one will support using just doc strings (for project what do not want to be dependent on @cle what do you think in this case. also hardcode the prio based on my decision? Or allow to use both ways inside one plugin together, so that, decision and documentation will be then on this plugin itself (but this part could cause some nondeterministic behaviour where to write data back into files, theoretically could be also defined inside documentation for plugin in case it will be part of one plugin)
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I do, for example # test_someting.py
"""
.. fmf::
duration: 10m
"""
def normal_test(): ...
def long_test(): ...
"""
.. fmf::
duration+: "* 10"
"""
I would say they should be mutually exclusive. As for the order itself, if we do not resolve the order issue in fmf files itself, I think we can assume for now that the user will play nice and not override willy-nilly. Or at least make it bright red in the documentation that this is a yet to be defined behavior. PS: poor
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed on last hacking session to not have it user-defined. There were no strong opinions if fmf should be on top of python or vice-versa, so fine to make a case for either one. Similar with the |
||
|
|
||
| You can override plugin priorities in ``.fmf/config``: | ||
|
|
||
| .. code-block:: yaml | ||
|
|
||
| plugins: | ||
| - fmf | ||
| - bash | ||
|
|
||
| # Override priorities to prefer bash over fmf | ||
| bash: | ||
| priority: 120 # Higher than FmfPlugin (100) | ||
|
|
||
| # Or lower fmf priority | ||
| fmf: | ||
| priority: 30 # Lower than default (100) | ||
|
|
||
| This allows you to control which plugin takes precedence when | ||
| multiple formats are present in the same tree. | ||
|
|
||
| File Pattern Override | ||
| --------------------- | ||
|
|
||
| Future enhancement: Plugins will support custom file patterns via | ||
| configuration to filter which files are processed. | ||
|
|
||
| Available Plugins | ||
| ----------------- | ||
|
|
||
| **Phase 1 (Current):** | ||
|
|
||
| * ``fmf.plugins.fmf.FmfPlugin`` - YAML-based ``.fmf`` files (default) | ||
|
|
||
| **Future Phases:** | ||
|
|
||
| * ``fmf.plugins.bash.BashPlugin`` - Bash scripts with ``#:FMF:`` comments | ||
| * ``fmf.plugins.pytest.PytestPlugin`` - Python tests with pytest marks | ||
|
|
||
| See ``PLUGIN_FUTURE.md`` for detailed plugin implementation roadmap. | ||
|
|
||
|
|
||
| Names | ||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| """ | ||
| Abstract Plugin Base Class for FMF Metadata Loaders | ||
| """ | ||
|
|
||
| from abc import ABC, abstractmethod | ||
| from typing import Any, Dict, List, Optional | ||
|
|
||
|
|
||
| class Plugin(ABC): | ||
| """ | ||
| Abstract base class for FMF metadata loaders. | ||
|
|
||
| Each plugin handles one or more file extensions and provides | ||
| methods to read and write metadata in those formats. | ||
|
|
||
| Subclasses must define: | ||
| - extensions: List of file extensions (e.g., [".fmf", ".sh"]) | ||
| - file_patterns: List of regex patterns to match filenames | ||
| - priority: Integer 0-200, higher values preferred for conflicts | ||
| - can_handle(): Method to determine if plugin can handle a file | ||
| - read(): Method to extract metadata from a file | ||
|
|
||
| Optional: | ||
| - write(): Method to write metadata back (default: fallback to .fmf) | ||
| - CONFIG_SECTION: Name of config section in .fmf/config | ||
| """ | ||
|
|
||
| # Class attributes to be defined by subclasses | ||
| extensions: List[str] = [] # File extensions, e.g., [".fmf"] | ||
| file_patterns: List[str] = [] # Regex patterns for filenames | ||
| priority: int = 50 # 0-200, higher = preferred when conflicts occur | ||
| CONFIG_SECTION: Optional[str] = None # Config section name | ||
|
|
||
| @abstractmethod | ||
| def can_handle(self, filename: str) -> bool: | ||
| """ | ||
| Determine if this plugin can handle the given file. | ||
|
|
||
| Args: | ||
| filename: Absolute or relative path to file | ||
|
|
||
| Returns: | ||
| True if plugin can read this file, False otherwise | ||
| """ | ||
| pass | ||
|
|
||
| @abstractmethod | ||
| def read(self, filename: str) -> Dict[str, Any]: | ||
| """ | ||
| Read metadata from file and return as dictionary. | ||
|
|
||
| Args: | ||
| filename: Path to file to read | ||
|
|
||
| Returns: | ||
| Dictionary with fmf metadata structure. Can be nested | ||
| for hierarchical metadata (e.g., test classes with methods). | ||
|
|
||
| Raises: | ||
| FileError: If file cannot be read or parsed | ||
| """ | ||
| pass | ||
|
|
||
| @abstractmethod | ||
| def write( | ||
| self, | ||
| filename: str, | ||
| hierarchy: List[str], | ||
| data: Dict[str, Any], | ||
| append_dict: Dict[str, Any], | ||
| modified_dict: Dict[str, Any], | ||
| deleted_items: List[str]) -> None: | ||
| """ | ||
| Write modified metadata back to file. | ||
|
|
||
| Args: | ||
| filename: Original file path | ||
| hierarchy: Path components from tree root (e.g., ["/parent", "/child"]) | ||
| data: Complete node data dictionary | ||
| append_dict: Keys with + suffix (merge operations) | ||
| modified_dict: Modified keys | ||
| deleted_items: List of removed keys | ||
|
|
||
| Raises: | ||
| NotImplementedError: If plugin doesn't support writing | ||
|
|
||
| Note: | ||
| If your plugin cannot write back to the original format, | ||
| you can use self._write_fmf_fallback() to create a .fmf file | ||
| with the same base name instead. | ||
| """ | ||
| pass | ||
|
|
||
| def _write_fmf_fallback( | ||
| self, | ||
| filename: str, | ||
| hierarchy: List[str], | ||
| modified_dict: Dict[str, Any], | ||
| append_dict: Dict[str, Any]) -> None: | ||
| """ | ||
| Create a .fmf file as fallback for plugins that can't write. | ||
|
|
||
| This method constructs the hierarchical structure based on | ||
| the hierarchy path and writes it to a .fmf file alongside | ||
| the original file. | ||
|
|
||
| Args: | ||
| filename: Original file path | ||
| hierarchy: Path components from tree root | ||
| modified_dict: Modified keys to write | ||
| append_dict: Append operations (keys with +) | ||
| """ | ||
| import os | ||
|
|
||
| from ruamel.yaml import YAML | ||
|
|
||
| # Build hierarchical dictionary from hierarchy path | ||
| output = {} | ||
| current = output | ||
|
|
||
| for key in hierarchy: | ||
| if key not in current or current[key] is None: | ||
| current[key] = {} | ||
| current = current[key] | ||
|
|
||
| # Add modified data to the leaf node | ||
| current.update(modified_dict) | ||
|
|
||
| # Add append operations | ||
| for key, value in append_dict.items(): | ||
| # Use key+ notation for append operations | ||
| current[key + '+'] = value | ||
|
|
||
| # Generate .fmf filename from original file | ||
| path = os.path.dirname(filename) | ||
| basename = os.path.basename(filename) | ||
|
|
||
| # Remove original extension | ||
| for ext in self.extensions: | ||
| if basename.endswith(ext): | ||
| basename = basename[:-len(ext)] | ||
| break | ||
|
|
||
| fmf_file = os.path.join(path, basename + ".fmf") | ||
|
|
||
| # Write YAML to .fmf file | ||
| yaml = YAML() | ||
| yaml.default_flow_style = False | ||
|
|
||
| with open(fmf_file, 'w', encoding='utf-8') as f: | ||
| yaml.dump(output, f) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why 2.0? That would need a whole lot of other discussion rounds and gathering cleanups to do. For now it is additive so can skip that.
Also there are proper sphinx directives to indicate which version it targets
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've also though about that. and for me it also seems good idea to increase version, as this is significant change and probably we should also change version of
.fmf/versionto2.0But theoretically it does not matter so much, as it is backward compatible. So yes, we can remove this part at all if you want.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed on last hacking session to keep 1.x for now