Creating a Plugin¶
Overview¶
Plugins in FLYNC extend functionality through converters. The recommended way to create a plugin is to extend the BaseConverter class and register it using the @hookimpl decorator.
Step-by-Step Guide¶
1. Extend BaseConverter and give it a name¶
Create a class that inherits from BaseConverter and has the name attribute already defined:
from flync.model import FLYNCModel
from flync_converter.base.base_converter import BaseConverter
class MyConverter(BaseConverter):
name = "my_format"
def can_decode(self):
# should contain logic to scan the configured location
# and figure out if decoding is possible or not
return True
Important
Please make sure to specify the converter name. This will be the name used to register the converter in the full map and choose it later, so this needs to be a unique identifier.
2. Follow the Constructor Signature¶
Your converter must not override __init__ unless it calls super().__init__(config) with the same signature:
from typing import Optional
from flync_converter.base import BaseConverter, ConverterConfig
class MyConverter(BaseConverter):
name = "my_format"
def __init__(self, config: Optional[ConverterConfig] = None):
super().__init__(config)
# your additional init here
The toolchain registers converters by calling register_converters() with no arguments (i.e. MyConverter()), so the instance enters the registry with config = None. The config is then injected at runtime just before encode() or decode() is called:
# What the toolchain does internally — you do not call this yourself
converter = registry["my_format"] # config is None here
converter.config = ConverterConfig(config_path=str(destination))
converter.encode(model) # config is set here
This deferred-configuration pattern keeps all converters interchangeable. Because of it, your encode() and decode() implementations must guard against a missing config:
def encode(self, source):
if self.config is None:
raise ValueError("config must be set before encoding")
# proceed with self.config.config_path ...
def decode(self):
if self.config is None:
raise ValueError("config must be set before decoding")
# proceed with self.config.config_path ...
3. Define a Custom Config (optional)¶
If your converter needs more than just a path, subclass ConverterConfig and annotate config on your class. The TUI will auto-detect the model and render all extra fields — plain types become text inputs, enum fields become dropdowns.
from enum import Enum
from typing import Optional
from flync_converter.base import ConverterConfig, BaseConverter
class OutputFormat(Enum):
CLASSIC = "classic"
EXTENDED = "extended"
class MyConverterConfig(ConverterConfig):
output_format: OutputFormat = OutputFormat.CLASSIC
indent: int = 2
class MyConverter(BaseConverter):
name = "my_format"
config: MyConverterConfig # tells the TUI which model to use
The TUI resolves the config model in this order:
A
configclass annotation (as above — recommended).The
configparameter annotation on__init__if you override it.A
config_modelorConfigclass attribute set to the model class.
If none of these are present, the TUI falls back to the base ConverterConfig (single config_path field).
4. Implement Required Methods¶
Implement the encode() and/or decode() methods depending on your plugin’s purpose:
encode(): Convert a FLYNCModel to your target format
decode(): Parse your format and return a FLYNCModel
See the JsonConverter example for a complete implementation example.
5. Register Your Plugin¶
Use the @hookimpl decorator to register your converter:
from flync_converter.registry import hookimpl
@hookimpl
def register_converters():
return [MyConverter()]
6. Add entry point to package creation¶
Use the entry point property of most Python package managers to define that your plugin belongs to the converters.
Example in pyproject.toml:
[project.entry-points."flync_converter"]
my_format = "my_format.plugin"
Where the structure of your plugin would be as follows:
my_format/
├── pyproject.toml
├── README.md
├── src/
│ └── my_format/
│ ├── __init__.py
│ └── plugin.py
└── tests/
└── test_plugin.py
Full Example¶
Refer to the Plugin Examples section for a complete, production-ready plugin implementation that demonstrates:
Proper use of BaseConverter
File I/O handling
Error handling with validation
Plugin registration
Logging & Conversion Reports¶
Every conversion writes a report into the destination workspace’s .flync metadata directory: a shared log for the whole conversion, and one folder per converter taking part in it, source and destination:
<destination>/.flync/reports/logs.txt whole conversion
<destination>/.flync/reports/<converter_name>/config.yaml configuration your converter ran with
<destination>/.flync/reports/<converter_name>/logs.txt your converter's records
<destination>/.flync/reports/<converter_name>/... files your converter writes itself
Your converter decides which records are its own by listing logger names in report_loggers: its own logger, and the loggers of the libraries it delegates to. Records from those loggers are written to your converter’s logs.txt and to the shared log, whether the converter runs as source or destination, and the loggers’ level is lowered to the report level for the duration of the conversion and restored afterwards.
import logging
logger = logging.getLogger(__name__)
class MyConverter(BaseConverter):
name = "my_format"
report_loggers = (__name__, "my_format_library")
def can_decode(self):
logger.info("Scanning %s for decoding", self.config.config_path)
return True
The shared log also captures every logger under the main converter logger, flync_converter, so a converter that logs under flync_converter.converters.<name> reaches the shared log without listing anything. Without report_loggers it gets a folder but no logs.txt of its own.
The built-in converters list their own module logger. The FLYNC converter also lists flync.sdk, and logs the workspace diagnostics (one line per finding, with its error id) after loading or writing a workspace. The DBC converter also lists cantools.
Logs at or above the configured minimum level are written (
report_min_log_levelon the destination configuration, default"INFO"), formatted by the base library.Reporting is enabled by default and can be disabled per destination with
report_enabled=Falseon itsConverterConfig, either passed in or stored in the destination workspace. See the usage guide for the full configuration options.
Writing your own report files¶
During a conversion, self.report_dir is your converter’s report folder, <destination>/.flync/reports/<converter_name>. Write any converter-specific output there, for example a validation report or a mapping table. report_dir is None outside a conversion and when reporting is disabled:
def encode(self, source):
...
if self.report_dir is not None:
(self.report_dir / "mapping.csv").write_text(mapping_csv, encoding="utf-8")
Recording structured report data¶
Every converter has a self.report to record what happened during decode or encode. It has well-known groups with a fixed structure, and a free-form one:
Method |
Group |
Use it for |
|---|---|---|
|
|
model content the converter did not use or write |
|
|
content the converter’s format cannot represent |
|
|
any other data the converter wants to record |
def encode(self, source):
for ecu in source.ecus:
if not ecu.can_interfaces:
self.report.skipped(f"ecus.{ecu.name}", reason="no CAN interface")
...
self.report.add("dbc_files", [path.name for path in written])
The built-in converters record:
Converter |
Report |
|---|---|
YAML, JSON |
|
FLYNC |
|
DBC |
|
item is a readable path to the content, such as ecus.body_ecu. Outside a conversion, and with reporting disabled, self.report records nothing, so a converter never needs to check. The report goes to <destination>/.flync/reports/<converter_name>/, whether the converter is the source or the destination.
The converter’s reporters write it, one file each. The default is YamlReporter (report.yaml). List the reporters to use, the same way as report_loggers:
from flync_converter.base import JsonReporter, YamlReporter
class MyConverter(BaseConverter):
name = "my_format"
reporters = (YamlReporter(), JsonReporter())
A new format is a subclass of BaseReporter with a filename and a dump method. Values that are not plain (paths, enums, addresses) reach dump in their string form, and a reporter that fails is logged without affecting the conversion:
from flync_converter.base import BaseReporter
class TextReporter(BaseReporter):
filename = "report.txt"
def dump(self, data, stream):
for group, content in data.items():
stream.write(f"{group}: {content}\n")
A converter configuration class that subclasses ConverterConfig is stored in the destination workspace with its own fields, so those fields must be serializable to YAML.
Adding a Built-in Converter¶
The steps above describe external plugins distributed as separate packages. If you are contributing a converter that ships inside flync_converter itself (e.g. a new format in src/flync_converter/converters/), one extra step is required: you must also register the module in ConverterFactoryRegistry.load_builtin inside registry.py.
# src/flync_converter/registry.py
def load_builtin(self):
from .converters import dbc, flync_converter, json_converter, my_converter, yaml_converter
for mod in (json_converter, yaml_converter, flync_converter, dbc, my_converter):
...
Without this, the converter class and its register_converters hook exist but are never loaded, so it will not appear in the registry or the interactive TUI.
Warning
Forgetting to add the module to load_builtin is a common pitfall. External plugins are discovered automatically via entry points; built-in converters are not — they must be listed explicitly.