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:

  1. A config class annotation (as above — recommended).

  2. The config parameter annotation on __init__ if you override it.

  3. A config_model or Config class 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_level on the destination configuration, default "INFO"), formatted by the base library.

  • Reporting is enabled by default and can be disabled per destination with report_enabled=False on its ConverterConfig, 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

self.report.skipped(item, reason)

skipped

model content the converter did not use or write

self.report.unsupported(item, reason)

unsupported

content the converter’s format cannot represent

self.report.add(key, value)

custom

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

input_files or output_file; a top-level key defined by several input files is skipped in all but the last one.

FLYNC

workspace and the workspace diagnostics (document, id, severity, location, message).

DBC

input_files or output_files. On encode, LIN buses, Ethernet PDU containers, container PDUs and signal groups are unsupported, unresolved PDU references skipped. On decode, value tables of bytearray signals are unsupported; out-of-range value table entries, bit rates outside the FLYNC allow-list and nodes taking part in no message are skipped.

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.