Skip to content

Creating an Extension

An extension is a Python package that implements one or more ActionHandlers. Each handler provides the logic for executing a specific action (e.g. running a linter, formatter, or build tool).

1. Create the package

my_linter/
    pyproject.toml
    my_linter/
        __init__.py
        handler.py

pyproject.toml — declare finecode_extension_api as a dependency:

[project]
name = "my_linter"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["finecode_extension_api~=0.4.0"]

[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"

2. Implement a handler

Import the action you want to handle and subclass ActionHandler:

# my_linter/handler.py
from finecode_extension_api import code_action
from finecode_extension_api.actions.code_quality.lint_files_action import (
    LintFilesAction,
    LintFilesRunPayload,
    LintFilesRunContext,
    LintFilesRunResult,
    LintMessage,
)


class MyLinterHandler(
    code_action.ActionHandler[
        LintFilesRunPayload,
        LintFilesRunContext,
        LintFilesRunResult,
    ]
):
    action = LintFilesAction

    async def run(
        self, payload: LintFilesRunPayload, context: LintFilesRunContext
    ) -> LintFilesRunResult:
        diagnostics: list[LintMessage] = []

        for file_path in payload.file_paths:
            # run your tool and collect results
            messages = run_my_tool(file_path)
            diagnostics.extend(messages)

        return LintFilesRunResult(diagnostics=diagnostics)

3. Export from __init__.py

# my_linter/__init__.py
from my_linter.handler import MyLinterHandler

__all__ = ["MyLinterHandler"]

4. Register the handler in a project

Add the handler to the target action in pyproject.toml:

[tool.finecode.action.lint]
source = "finecode_extension_api.actions.LintAction"
handlers = [
    {
        name = "my_linter",
        source = "my_linter.MyLinterHandler",
        env = "dev_no_runtime",
        dependencies = ["my_linter~=0.1.0"]
    }
]

Then run python -m finecode prepare-envs to install your handler into the venv.

Source strings

Every source = value in TOML config is resolved at runtime as import path.

Built-in action classes are all re-exported from finecode_extension_api.actions, so their source strings take the short form finecode_extension_api.actions.<ClassName> — you do not need to know which subgroup (code_quality/, environments/, etc.) a class lives in. See the Built-in Actions reference for the full list.

Handler classes are referenced by their full import path: my_linter.MyLinterHandler (module my_linter, member MyLinterHandler).

Handler configuration

To make your handler configurable, define a config model and declare CONFIG_TYPE:

import dataclasses
from finecode_extension_api import code_action
from finecode_extension_api.actions.code_quality.lint_files_action import (
    LintFilesAction, LintFilesRunPayload, LintFilesRunContext, LintFilesRunResult,
)


@dataclasses.dataclass
class MyLinterConfig:
    line_length: int = 88
    extend_ignore: list[str] = dataclasses.field(default_factory=list)


class MyLinterHandler(
    code_action.ActionHandler[
        LintFilesRunPayload,
        LintFilesRunContext,
        LintFilesRunResult,
    ]
):
    action = LintFilesAction
    CONFIG_TYPE = MyLinterConfig

    async def run(
        self, payload: LintFilesRunPayload, context: LintFilesRunContext
    ) -> LintFilesRunResult:
        config: MyLinterConfig = context.handler_config
        # use config.line_length, config.extend_ignore, ...
        ...

Users can then configure it in pyproject.toml:

[[tool.finecode.action_handler]]
source = "my_linter.MyLinterHandler"
config.line_length = 100
config.extend_ignore = ["E501"]

Or via CLI/env vars at runtime (see Configuration).

Handler lifecycle

For handlers that need to start a background process (e.g. a language server), inject lifecycle: code_action.ActionHandlerLifecycle and register callbacks in __init__:

class MyLspHandler(code_action.ActionHandler[...]):
    def __init__(self, config: MyConfig, lifecycle: code_action.ActionHandlerLifecycle) -> None:
        self.config = config
        lifecycle.on_initialize(self._start)
        lifecycle.on_shutdown(self._shutdown)

    def _start(self) -> None:
        # called once when the handler is first loaded
        self._process = start_my_server()

    def _shutdown(self) -> None:
        # called when the Extension Runner shuts down
        self._process.terminate()

    async def run(self, payload, context):
        ...

Logging in handlers

Handlers receive an ILogger instance via dependency injection by declaring logger: ilogger.ILogger in their constructor:

from finecode_extension_api.interfaces import ilogger

class MyToolHandler(code_action.ActionHandler[...]):
    def __init__(self, ..., logger: ilogger.ILogger) -> None:
        self.logger = logger

What the framework already logs

The framework logs the full action payload at TRACE level before calling your handler. Enable it with "finecode_extension_runner" = "TRACE" in your ER log groups config. Do not repeat this in the handler — it creates noise without adding value (R-504).

What handlers should log

For handlers that spawn an external process, log the following at DEBUG:

  • exit code — always
  • stdout and stderr — when non-empty
  • result summary — key metrics from the tool's report (counts, durations)

Emit a WARNING when the handler's primary job is to produce results but returns nothing:

process = await self.command_runner.run(cmd, cwd=project_dir)
await process.wait_for_end()

exit_code = process.get_exit_code()
stdout = process.get_output()
stderr = process.get_error_output()

self.logger.debug(f"mytool exit code: {exit_code}")
if stdout:
    self.logger.debug(f"mytool stdout:\n{stdout}")
if stderr:
    self.logger.debug(f"mytool stderr:\n{stderr}")

# ... parse the tool's report into results ...

if not results:
    self.logger.warning(f"No results produced. cmd={cmd!r} exit_code={exit_code}")

Exit code handling

Do not rely on the absence of an output file as the sole error indicator. Check exit codes explicitly and document their meaning for your specific tool (R-501):

# exit codes 0 (ok) and 1 (warnings) are normal for mytool; 2 = usage error
if exit_code not in (0, 1):
    descriptions: dict[int | None, str] = {
        2: "command-line usage error — check your config",
    }
    reason = descriptions.get(exit_code, f"unexpected exit code {exit_code}")
    raise code_action.ActionFailedException(
        f"mytool exited with code {exit_code}: {reason}.\nOutput:\n{stderr or stdout}"
    )

See Designing Actions Rules R-501 through R-504 for the normative statements.

Sequential handlers: using current_result

If your handler runs in sequential mode and depends on the result of a previous handler, read it from the context:

async def run(self, payload, context):
    previous: MyActionResult = context.current_result
    # extend or modify the previous result
    ...

Warning

context.current_result raises RuntimeError in concurrent handler mode. Only use it when the action's HANDLER_EXECUTION is HandlerExecution.SEQUENTIAL (the default).

Available actions to handle

See the Built-in Actions reference for the full list of action classes, payload types, and result types you can implement handlers for.

If you are defining a new action (not just a handler), start with Designing Actions, then use Designing Your First Action for the workflow and the Designing Actions Rules for the normative constraints. Use the Designing Actions Reference when the action needs advanced patterns.

Tool versioning

An extension wraps a specific tool and has its own version lifecycle separate from the tool's. Extension authors must declare which tool versions the handler supports.

Compatibility range

Declare the tool as a versioned dependency in the extension's pyproject.toml:

dependencies = ["ruff (>=0.8.0,<1.0.0)"]

This range is a compatibility guarantee: every version within it exposes an interface that the handler code can correctly drive. "Interface" means whatever channel the handler uses to communicate with the tool — for example:

  • CLI: the command flags, invocation format, output format, and exit codes the handler relies on
  • LSP: the capabilities, request/response shapes, and initialization options of the tool's language server
  • HTTP/JSON API: the request and response schemas of any programmatic API the tool exposes
  • Python API: the public classes and functions the handler imports directly from the tool's package

An interface change that breaks the handler — a renamed flag, a changed JSON schema, a removed LSP capability — requires updating the range.

Extension author responsibilities

  • Declare the widest range you can actually support. Overly narrow ranges force unnecessary upgrades on users.
  • When a new tool version introduces interface changes that require handler updates: update the handler code and widen or shift the range accordingly.
  • When a new tool version adds features or changes behavior but leaves the interface the handler uses unchanged: the existing range already covers it — no update needed.
  • Annotate significant range changes in the changelog so users can see which tool versions a given extension version supports.

User's responsibility

Within the extension's declared range, users choose the exact tool version for their project. This is a behavioral decision: a user may prefer a specific version because it produces the results they expect, avoids a known regression in the tool, or matches the version their team has standardized on.

In almost all cases, the user wants the same tool version everywhere a given extension is used. Configure that once at the extension level, keyed by the extension's package name:

[tool.finecode.extension.fine_python_ruff]
dependencies_override = ["ruff==0.9.0", "ruff-plugin-foo==1.2.3"]

The override applies to every env that contains a handler from fine_python_ruff.

Pinning at the handler level

When you genuinely need different versions for different handlers from the same extension (rare), override per handler:

[[tool.finecode.action_handler]]
source = "fine_python_ruff.RuffLintFilesHandler"
dependencies_override = ["ruff==0.9.0"]

Handler-level overrides take precedence over extension-level overrides for the targeted handler.

How overrides are resolved

FineCode resolves each env's full dependency tree first, then applies overrides by package name in this order (later wins):

  1. The extension's declared dependency range (from the extension's pyproject.toml)
  2. Extension-level dependencies_override
  3. Handler-level dependencies_override

For each package the user targets:

  • If the package is already in the resolved tree — the user's specifier replaces whatever the extension declared. This lets you pin to a version outside the extension's stated compatibility range if you need to.
  • If the package is not in the resolved tree — it is added as a new dependency. This covers tool extensions or plugins that the extension itself does not declare.

When you override a package to a version outside the extension's compatibility range, the extension author's range becomes documentation only — you take responsibility for verifying that the handler code works correctly with the chosen version.

When two extensions wrap the same tool

Two extensions may legitimately depend on the same tool (e.g. a lint extension and a format extension both wrapping ruff). Each env resolves a single version of the tool that satisfies the union of every extension's declared range, so when both extensions are used together in the same env you get one consistent tool version automatically.

If you want to pin that shared tool, place the override on one of the extensions — typically the one that drives the version choice. If both extensions declare a dependencies_override for the same package in the same env with conflicting specifiers, the package manager will fail to install: resolve the conflict by removing one of the overrides or by using a handler-level override only on the handler you want to differ.

If you genuinely need different versions of the same tool for different extensions, place each extension's handlers in a different env.

Package naming

Extension package names follow the pattern fine_<lang>_<qualifier>. Use a tool name or capability descriptor as the qualifier, and keep bare fine_<word> names reserved for presets.

See Package Naming for the shared extension and preset naming convention.