Skip to content

Latest commit

 

History

History
260 lines (199 loc) · 8.55 KB

File metadata and controls

260 lines (199 loc) · 8.55 KB

Writing a Plugin

Plugins are how faff connects to external systems. A plugin is a Python package that implements one or both of two interfaces: PlanSource (pull vocabulary and trackers) and Audience (compile and submit timesheets).

faff-core embeds a Python interpreter and loads plugins from ~/.faff/plugins/ at runtime.

faff-core provides the base classes. Install faff-core (the Python bindings package) as a dependency in your plugin to get access to PlanSource, Audience, and all the model types.

Plugin types

PlanSource

A PlanSource plugin fetches a plan from an external system for a given date. The plan provides vocabulary (roles, impacts, modes, subjects) and tracker data that faff uses to populate session pickers.

Audience

An Audience plugin compiles a log into a timesheet for a specific recipient and submits it. Compilation involves filtering sessions (only those relevant to this audience), optionally transforming data, and packaging the result for submission.

A plugin can implement both PlanSource and Audience — this is common when the same system provides both trackers and receives time reports.

Base classes

Import from faff_core.plugins:

from faff_core.plugins import Plugin, PlanSource, Audience
from faff_core.models import Log, Plan, Timesheet

Plugin (base)

All plugins extend Plugin. The constructor is called by faff-core with the plugin's configuration:

class Plugin(ABC):
    def __init__(
        self,
        plugin: str,        # Plugin package name
        name: str,          # Instance name (from remote config)
        config: dict,       # Plugin-specific config (from remote's [connection])
        defaults: dict,     # Default vocabulary values
        state_path: Path,   # Directory for persistent state
    ):
        ...
        self.id = slugify(self.name)   # URL-safe identifier
        self.state_path = state_path   # Write tokens, caches here

PlanSource

class PlanSource(Plugin):
    @abstractmethod
    def pull_plan(self, date: datetime.date) -> Plan:
        """
        Fetch a plan for the given date.

        Return a Plan with roles, impacts, modes, subjects, trackers,
        and any hints derived from the remote's data.
        """

Audience

class Audience(Plugin):
    @abstractmethod
    def compile_time_sheet(self, log: Log) -> Timesheet:
        """
        Compile a timesheet from a log.

        Filter and transform log sessions into a Timesheet appropriate
        for this audience. Return an empty Timesheet if there's nothing
        to report (e.g. no sessions with this audience's trackers).
        """

    @abstractmethod
    def submit_timesheet(self, timesheet: Timesheet) -> None:
        """
        Submit a compiled timesheet to the external system.

        Raise an exception if submission fails — faff-core will record
        the error in the timesheet's .meta file.
        """

Example plugin

Here's a minimal plugin that implements both PlanSource and Audience:

import datetime
import requests
from pathlib import Path
from faff_core.plugins import PlanSource, Audience
from faff_core.models import Log, Plan, Timesheet, Session


class MySystemPlugin(PlanSource, Audience):

    def __init__(self, plugin, name, config, defaults, state_path):
        super().__init__(plugin, name, config, defaults, state_path)
        self.api_url = "https://api.mysystem.example.com"
        self.api_key = config["api_key"]

    # ── PlanSource ────────────────────────────────────────────────────────

    def pull_plan(self, date: datetime.date) -> Plan:
        # Fetch projects from the external system
        resp = requests.get(
            f"{self.api_url}/projects",
            headers={"Authorization": f"Bearer {self.api_key}"},
        )
        resp.raise_for_status()
        projects = resp.json()

        # Build tracker map: {source:id → human-readable name}
        trackers = {
            f"{self.id}:{p['id']}": p["name"]
            for p in projects
        }

        return Plan(
            source=self.id,
            valid_from=date,
            valid_until=None,
            roles=self.defaults.get("roles", []),
            impacts=self.defaults.get("impacts", []),
            modes=self.defaults.get("modes", []),
            subjects=[],
            trackers=trackers,
            hints=[],
        )

    # ── Audience ──────────────────────────────────────────────────────────

    def compile_time_sheet(self, log: Log) -> Timesheet:
        # Include only sessions that have one of our trackers
        our_prefix = f"{self.id}:"
        sessions = [
            s for s in log.timeline
            if any(t.startswith(our_prefix) for t in s.trackers)
        ]

        actor = {
            "name": self.config.get("actor", {}).get("name", ""),
            "email": self.config.get("actor", {}).get("email", ""),
        }

        return Timesheet(
            actor=actor,
            date=log.date,
            compiled=datetime.datetime.now(datetime.timezone.utc),
            timezone=log.timezone,
            timeline=sessions,
        )

    def submit_timesheet(self, timesheet: Timesheet) -> None:
        for session in timesheet.timeline:
            duration_hours = (
                (session.end - session.start).total_seconds() / 3600
            )
            tracker_id = next(
                t.split(":", 1)[1]
                for t in session.trackers
                if t.startswith(f"{self.id}:")
            )
            resp = requests.post(
                f"{self.api_url}/time-entries",
                headers={"Authorization": f"Bearer {self.api_key}"},
                json={
                    "project_id": tracker_id,
                    "date": str(timesheet.date),
                    "hours": round(duration_hours, 2),
                    "notes": session.note or session.title or "",
                },
            )
            resp.raise_for_status()

Plugin package structure

A plugin is a Python package. Typical layout:

~/.faff/plugins/
└── faff-plugin-my-system/
    ├── pyproject.toml       # or setup.py
    ├── plugin/
    │   ├── __init__.py
    │   └── plugin.py        # Plugin class(es)
    └── README.md

The package must expose its plugin class(es) at a path faff-core can discover. By convention, the main class is importable as {package_name}.plugin.{ClassName}.

Configuring a plugin

Plugins are configured in ~/.faff/config.toml (or equivalently in a ~/.faff/remotes/{id}.toml file).

In config.toml:

[[plan_remote]]
name   = "my-system"
plugin = "my-system"       # package name without faff-plugin- prefix

[plan_remote.config]
api_key = "sk-..."

[plan_remote.defaults]
roles   = ["engineer", "manager"]
impacts = ["development", "maintenance"]
modes   = ["coding", "meeting", "admin"]

[[timesheet_audience]]
name   = "my-system"
plugin = "my-system"

[timesheet_audience.config.actor]
name  = "Alice Smith"
email = "alice@example.com"

Persistent state

Use self.state_path (a Path) to store anything that needs to persist between runs — OAuth tokens, cached responses, last-pull timestamps:

import json

def _save_token(self, token: str) -> None:
    token_file = self.state_path / "token.json"
    token_file.write_text(json.dumps({"access_token": token}))

def _load_token(self) -> str | None:
    token_file = self.state_path / "token.json"
    if token_file.exists():
        return json.loads(token_file.read_text())["access_token"]
    return None

faff-core creates self.state_path before calling __init__. The directory is ~/.faff/plugin_state/{plugin-id}/.

Working with faff-core models

All models come from the faff-core Rust library via Python bindings. See faff-core for the full model reference.

Key things to know:

  • Models are immutable. Operations return new instances rather than mutating.
  • log.timeline is a list of Session objects.
  • session.trackers is a list of source:id strings.
  • session.start and session.end are timezone-aware datetime objects.
  • Build a Plan by passing all fields as constructor arguments.

See also