Skip to content

Commit b6b0a8a

Browse files
jscotkahatclaude
andcommitted
Implement plugin/modular system for FMF metadata loaders (Phase 1)
Add a plugin architecture to support reading metadata from multiple file formats beyond .fmf files. This enables future support for bash scripts, Python/pytest tests, and other formats while maintaining full backward compatibility. ## Architecture ### Core Components **fmf/plugin.py** (148 lines): - Abstract Plugin base class - Methods: can_handle(), read(), write() - Attributes: extensions, file_patterns, priority (0-200) - Helper: _write_fmf_fallback() for plugins that can't write natively **fmf/plugin_loader.py** (73 lines): - PluginRegistry for managing built-in plugins - Static registration (no dynamic loading - security) - Priority-based plugin selection - Config validation against known plugin names **fmf/plugins/__init__.py** (23 lines): - Static registration of all built-in plugins - PLUGIN_NAMES dict for config validation - Single source of truth for available plugins **fmf/plugins/fmf.py** (110 lines): - FmfPlugin - refactored .fmf YAML loading - Uses ruamel.yaml for consistency - Priority 100 (default format) - Full write support via dict_to_yaml() ### Tree Integration **fmf/base.py**: - Import fmf.plugins to trigger registration - Re-export MAIN, SUFFIX for backward compatibility - _initialize(): Load plugins from config - grow(): Use get_plugin_for_file() for each file - __exit__(): Plugin write support in context manager ### Configuration .fmf/config format: ## Features ✅ **Static registration** - All plugins in fmf/plugins/__init__.py ✅ **Priority system** - 0-200 scale, configurable per plugin ✅ **Priority override** - Adjust via config ✅ **Security** - Only built-in plugins allowed ✅ **can_handle()** - Direct filtering, supports regex patterns ✅ **Write fallback** - _write_fmf_fallback() helper ✅ **Backward compatible** - 100% existing test pass ✅ **Mixed formats** - .fmf and other types in same tree ## Testing **tests/unit/test_plugin.py** (29 tests, 644 lines): - TestPluginRegistry (7 tests) - Registration, validation - TestFmfPlugin (5 tests) - Read, write, can_handle - TestTreeWithPlugins (6 tests) - Tree integration - TestPluginConfigurationOverride (3 tests) - Priority override - TestRealWorldExamples (2 tests) - Existing examples - TestMockPlugin (6 tests) - Multi-format with .txt files **Test data**: - tests/unit/data/plugin_basic/ - Config and .fmf files **Coverage**: All 275 tests pass ## Simplifications Made 1. **plugin_loader.py**: 169 → 73 lines (57% reduction) - Removed dynamic loading (importlib, inspect) - Removed get_supported_file_patterns() (unused) - Simplified to pure tracking + validation 2. **Static registration**: No environment variables, no file paths - All plugins registered in fmf/plugins/__init__.py - Config just validates plugin names - Security-focused design 3. **Direct filtering**: Use can_handle() not pre-filtering - Let plugins decide what they handle - Supports regex patterns in file_patterns - No verbose logging for non-matches ## Documentation **docs/concept.rst**: - New Plugins section - Configuration examples - Priority override documentation - Security model explanation **PLUGIN_FUTURE.md** (592 lines): - Phase 2: Bash plugin design - Phase 3: Python/pytest plugin design - Phase 4: Write-back support - Implementation steps (no code) - Configuration system - Testing strategy - Security model - Migration guide ## Backward Compatibility ✅ Trees without plugin config work (FmfPlugin auto-loaded) ✅ All existing .fmf files load correctly ✅ SUFFIX and MAIN constants still available ✅ All 267 existing tests pass unchanged ✅ No breaking changes to Tree API ## Security Model 🔒 **Only built-in plugins** from fmf/plugins/ directory 🔒 **No dynamic loading** from environment or arbitrary paths 🔒 **Static registration** in fmf/plugins/__init__.py 🔒 **Config validation** against PLUGIN_NAMES 🔒 **No code execution** (future Python plugin uses AST only) ## Future Phases **Phase 2**: BashPlugin - Read from # fmf-key: value comments **Phase 3**: PytestPlugin - Extract from marks and docstrings **Phase 4**: Enhanced write-back support ## Files Changed New files: - fmf/plugin.py (148 lines) - fmf/plugin_loader.py (73 lines) - fmf/plugins/__init__.py (23 lines) - fmf/plugins/fmf.py (110 lines) - tests/unit/test_plugin.py (644 lines) - tests/unit/data/plugin_basic/* (4 files) - PLUGIN_FUTURE.md (592 lines) Modified files: - fmf/base.py (minimal changes for plugin integration) - docs/concept.rst (added Plugins section) Total: ~1590 lines added ## Contributors Based on: - Original issue: #103 - POC branch: py_plugin - Design discussions and iterations Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 50556c1 commit b6b0a8a

12 files changed

Lines changed: 1768 additions & 20 deletions

File tree

PLUGIN_FUTURE.md

Lines changed: 592 additions & 0 deletions
Large diffs are not rendered by default.

docs/concept.rst

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,91 @@ In the example above files or directories named ``.plans`` or
123123
the ``.fmf`` directory cannot be used for storing metadata.
124124

125125

126+
Plugins
127+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
128+
129+
Starting with fmf 2.0, a plugin system allows reading metadata from
130+
multiple file formats beyond ``.fmf`` (YAML) files. This enables
131+
extracting test metadata directly from source files like Python tests
132+
or Bash scripts.
133+
134+
.. _config-plugins:
135+
136+
Plugin Configuration
137+
--------------------
138+
139+
Plugins can be enabled in the ``.fmf/config`` file:
140+
141+
.. code-block:: yaml
142+
143+
plugins:
144+
- fmf # Short name for FmfPlugin
145+
# or use full path:
146+
- fmf.plugins.fmf.FmfPlugin
147+
# Future phases:
148+
# - bash
149+
# - pytest
150+
151+
The ``plugins`` section lists built-in plugins to load. Each plugin
152+
handles specific file types (e.g., ``.sh`` for Bash, ``.py`` for Python).
153+
154+
**Security**: Only built-in plugins from ``fmf/plugins/`` can be loaded.
155+
Arbitrary code execution from config files is prevented.
156+
157+
If no ``plugins`` section is present, only the default ``FmfPlugin``
158+
is loaded (for backward compatibility), which handles ``.fmf`` files.
159+
160+
Plugin Priority
161+
---------------
162+
163+
When multiple plugins can handle the same file extension, the plugin
164+
with the highest priority (0-200) is selected. Built-in plugins use
165+
these priorities:
166+
167+
* FmfPlugin: 100 (default format)
168+
* BashPlugin: 50 (future)
169+
* PytestPlugin: 50 (future)
170+
171+
You can override plugin priorities in ``.fmf/config``:
172+
173+
.. code-block:: yaml
174+
175+
plugins:
176+
- fmf
177+
- bash
178+
179+
# Override priorities to prefer bash over fmf
180+
bash:
181+
priority: 120 # Higher than FmfPlugin (100)
182+
183+
# Or lower fmf priority
184+
fmf:
185+
priority: 30 # Lower than default (100)
186+
187+
This allows you to control which plugin takes precedence when
188+
multiple formats are present in the same tree.
189+
190+
File Pattern Override
191+
---------------------
192+
193+
Future enhancement: Plugins will support custom file patterns via
194+
configuration to filter which files are processed.
195+
196+
Available Plugins
197+
-----------------
198+
199+
**Phase 1 (Current):**
200+
201+
* ``fmf.plugins.fmf.FmfPlugin`` - YAML-based ``.fmf`` files (default)
202+
203+
**Future Phases:**
204+
205+
* ``fmf.plugins.bash.BashPlugin`` - Bash scripts with ``#:FMF:`` comments
206+
* ``fmf.plugins.pytest.PytestPlugin`` - Python tests with pytest marks
207+
208+
See ``PLUGIN_FUTURE.md`` for detailed plugin implementation roadmap.
209+
210+
126211
Names
127212
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
128213

fmf/base.py

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,21 @@
1212
from typing import Any, Dict, Optional, Protocol
1313

1414
from ruamel.yaml import YAML
15-
from ruamel.yaml.constructor import DuplicateKeyError
1615
from ruamel.yaml.error import YAMLError
1716

1817
import fmf.context
18+
import fmf.plugins # noqa: F401 # Load built-in plugins
1919
import fmf.utils as utils
20+
from fmf.plugin_loader import get_registry
21+
# Re-export constants for backward compatibility
22+
from fmf.plugins.fmf import MAIN, SUFFIX
2023
from fmf.utils import dict_to_yaml, log
2124

2225
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2326
# Constants
2427
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2528

26-
SUFFIX = ".fmf"
27-
MAIN = "main" + SUFFIX
29+
# SUFFIX and MAIN moved to fmf.plugins.fmf and re-exported above
2830
IGNORED_DIRECTORIES = ['/dev', '/proc', '/sys']
2931
ADJUST_CONTROL_KEYS = ['because', 'continue', 'when']
3032

@@ -210,6 +212,18 @@ def _initialize(self, path):
210212
except YAMLError as error:
211213
raise utils.FileError(f"Failed to parse '{config_file_path}'.\n{error}")
212214

215+
# Initialize plugin registry
216+
registry = get_registry()
217+
218+
# Load plugins from config if specified
219+
if "plugins" in self.config:
220+
registry.load_from_config(self.config)
221+
log.debug("Plugins loaded from config.")
222+
else:
223+
# Default: only FmfPlugin (already auto-registered)
224+
# This ensures backward compatibility when no config exists
225+
log.debug("No plugin config found, using default FmfPlugin.")
226+
213227
def _merge_plus(self, data, key, value, prepend=False):
214228
"""
215229
Handle extending attributes using the '+' suffix
@@ -716,35 +730,48 @@ def grow(self, path):
716730
log.debug("Skipping '{0}' (not accessible).".format(path))
717731
return
718732

719-
# Investigate main.fmf as the first file (for correct inheritance)
720-
filenames = sorted(
721-
[filename for filename in filenames if filename.endswith(SUFFIX)])
722-
try:
723-
filenames.insert(0, filenames.pop(filenames.index(MAIN)))
724-
except ValueError:
725-
pass
733+
# Get plugin registry
734+
registry = get_registry()
735+
736+
# Prioritize main.fmf first if it exists (for correct inheritance)
737+
if MAIN in filenames:
738+
filenames = sorted([f for f in filenames if f != MAIN])
739+
filenames.insert(0, MAIN)
740+
else:
741+
filenames = sorted(filenames)
726742

727-
# Check every metadata file and load data (ignore hidden)
743+
# Check every file and load data if a plugin can handle it
728744
for filename in filenames:
745+
# Skip hidden files (unless enabled in config)
729746
if filename.startswith(".") and filename not in self.explore_include:
730747
continue
748+
731749
fullpath = os.path.abspath(os.path.join(dirpath, filename))
732-
log.info("Checking file {0}".format(fullpath))
750+
751+
# Find appropriate plugin for this file
752+
# This uses can_handle() which can check regex patterns
753+
plugin_class = registry.get_plugin_for_file(filename)
754+
if not plugin_class:
755+
# No plugin can handle this file, skip silently
756+
continue
757+
758+
# Read file using plugin
759+
log.info(f"Processing '{fullpath}' with {plugin_class.__name__}")
733760
try:
734-
with open(fullpath, encoding='utf-8') as datafile:
735-
# Workadound ruamel s390x read issue - fmf/issues/164
736-
content = datafile.read()
737-
data = YAML(typ="safe").load(content)
738-
except (YAMLError, DuplicateKeyError) as error:
761+
plugin = plugin_class()
762+
data = plugin.read(fullpath)
763+
except Exception as error:
739764
raise utils.FileError(
740-
f"Failed to parse '{fullpath}'.\n{error}")
765+
f"Failed to read '{fullpath}' with {plugin_class.__name__}.\n{error}")
766+
741767
log.data(pretty(data))
742-
# Handle main.fmf as data for self
768+
769+
# Handle main.fmf specially (backward compatibility)
743770
if filename == MAIN:
744771
self.sources.append(fullpath)
745772
self._raw_data = copy.deepcopy(data)
746773
self.update(data)
747-
# Handle other *.fmf files as children
774+
# Handle other files as children
748775
else:
749776
self.child(os.path.splitext(filename)[0], data, fullpath)
750777

@@ -1075,9 +1102,31 @@ def __enter__(self):
10751102
def __exit__(self, exc_type, exc_val, exc_tb):
10761103
"""
10771104
Experimental: Store modified metadata to disk
1105+
1106+
Uses the plugin system to write data back to the source file.
1107+
Falls back to direct YAML write if no plugin is found.
10781108
"""
10791109

10801110
_, full_data, source = self._locate_raw_data()
1111+
1112+
# Try to use plugin for writing
1113+
registry = get_registry()
1114+
plugin_class = registry.get_plugin_for_file(source)
1115+
1116+
if plugin_class:
1117+
try:
1118+
plugin = plugin_class()
1119+
# For now, we pass full_data as the data parameter
1120+
# hierarchy, append_dict, modified_dict, deleted_items are not used yet
1121+
plugin.write(source, [], full_data, {}, {}, [])
1122+
return
1123+
except NotImplementedError:
1124+
# Plugin doesn't support write, fall back to default
1125+
log.debug(f"Plugin {plugin_class.__name__} doesn't support write, using default")
1126+
except Exception as error:
1127+
log.warning(f"Plugin write failed: {error}, using default")
1128+
1129+
# Fallback: direct YAML write (backward compatibility)
10811130
with open(source, "w", encoding='utf-8') as file:
10821131
file.write(dict_to_yaml(full_data))
10831132

fmf/plugin.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""
2+
Abstract Plugin Base Class for FMF Metadata Loaders
3+
"""
4+
5+
from abc import ABC, abstractmethod
6+
from typing import Any, Dict, List, Optional
7+
8+
9+
class Plugin(ABC):
10+
"""
11+
Abstract base class for FMF metadata loaders.
12+
13+
Each plugin handles one or more file extensions and provides
14+
methods to read and write metadata in those formats.
15+
16+
Subclasses must define:
17+
- extensions: List of file extensions (e.g., [".fmf", ".sh"])
18+
- file_patterns: List of regex patterns to match filenames
19+
- priority: Integer 0-200, higher values preferred for conflicts
20+
- can_handle(): Method to determine if plugin can handle a file
21+
- read(): Method to extract metadata from a file
22+
23+
Optional:
24+
- write(): Method to write metadata back (default: fallback to .fmf)
25+
- CONFIG_SECTION: Name of config section in .fmf/config
26+
"""
27+
28+
# Class attributes to be defined by subclasses
29+
extensions: List[str] = [] # File extensions, e.g., [".fmf"]
30+
file_patterns: List[str] = [] # Regex patterns for filenames
31+
priority: int = 50 # 0-200, higher = preferred when conflicts occur
32+
CONFIG_SECTION: Optional[str] = None # Config section name
33+
34+
@abstractmethod
35+
def can_handle(self, filename: str) -> bool:
36+
"""
37+
Determine if this plugin can handle the given file.
38+
39+
Args:
40+
filename: Absolute or relative path to file
41+
42+
Returns:
43+
True if plugin can read this file, False otherwise
44+
"""
45+
pass
46+
47+
@abstractmethod
48+
def read(self, filename: str) -> Dict[str, Any]:
49+
"""
50+
Read metadata from file and return as dictionary.
51+
52+
Args:
53+
filename: Path to file to read
54+
55+
Returns:
56+
Dictionary with fmf metadata structure. Can be nested
57+
for hierarchical metadata (e.g., test classes with methods).
58+
59+
Raises:
60+
FileError: If file cannot be read or parsed
61+
"""
62+
pass
63+
64+
@abstractmethod
65+
def write(
66+
self,
67+
filename: str,
68+
hierarchy: List[str],
69+
data: Dict[str, Any],
70+
append_dict: Dict[str, Any],
71+
modified_dict: Dict[str, Any],
72+
deleted_items: List[str]) -> None:
73+
"""
74+
Write modified metadata back to file.
75+
76+
Args:
77+
filename: Original file path
78+
hierarchy: Path components from tree root (e.g., ["/parent", "/child"])
79+
data: Complete node data dictionary
80+
append_dict: Keys with + suffix (merge operations)
81+
modified_dict: Modified keys
82+
deleted_items: List of removed keys
83+
84+
Raises:
85+
NotImplementedError: If plugin doesn't support writing
86+
87+
Note:
88+
If your plugin cannot write back to the original format,
89+
you can use self._write_fmf_fallback() to create a .fmf file
90+
with the same base name instead.
91+
"""
92+
pass
93+
94+
def _write_fmf_fallback(
95+
self,
96+
filename: str,
97+
hierarchy: List[str],
98+
modified_dict: Dict[str, Any],
99+
append_dict: Dict[str, Any]) -> None:
100+
"""
101+
Create a .fmf file as fallback for plugins that can't write.
102+
103+
This method constructs the hierarchical structure based on
104+
the hierarchy path and writes it to a .fmf file alongside
105+
the original file.
106+
107+
Args:
108+
filename: Original file path
109+
hierarchy: Path components from tree root
110+
modified_dict: Modified keys to write
111+
append_dict: Append operations (keys with +)
112+
"""
113+
import os
114+
115+
from ruamel.yaml import YAML
116+
117+
# Build hierarchical dictionary from hierarchy path
118+
output = {}
119+
current = output
120+
121+
for key in hierarchy:
122+
if key not in current or current[key] is None:
123+
current[key] = {}
124+
current = current[key]
125+
126+
# Add modified data to the leaf node
127+
current.update(modified_dict)
128+
129+
# Add append operations
130+
for key, value in append_dict.items():
131+
# Use key+ notation for append operations
132+
current[key + '+'] = value
133+
134+
# Generate .fmf filename from original file
135+
path = os.path.dirname(filename)
136+
basename = os.path.basename(filename)
137+
138+
# Remove original extension
139+
for ext in self.extensions:
140+
if basename.endswith(ext):
141+
basename = basename[:-len(ext)]
142+
break
143+
144+
fmf_file = os.path.join(path, basename + ".fmf")
145+
146+
# Write YAML to .fmf file
147+
yaml = YAML()
148+
yaml.default_flow_style = False
149+
150+
with open(fmf_file, 'w', encoding='utf-8') as f:
151+
yaml.dump(output, f)

0 commit comments

Comments
 (0)