Adding Entities - AAraKKe/ynab-unlinked GitHub Wiki

Adding new Entities

YNAB Unlinked has been built to allow new Entities to be added by anyone that needs them. This means that, while it has the pontential to support any entity you need, support is limited to those that are supported at the moment.

To add support for a new Entity we just need:

  • To create the new entity object
  • To define its load command

The Entity protocol

Every supported entity in YNAB Unlinked is a class that must follow the Entity protocol. This means that the class must have the following methods:

def parse(self, input_file: Path, context: YnabUnlinkedContext) -> list[Transaction]
def name(self) -> str

Any class that implements those method is a valid entity. The name method should return a string that has a human readable name of your entity. It is used for console output.

The parse method

The parse method is the one that contains the logic about how to turn any input file into a list of Trasaction objects. A Transaction object is just a dataclass the date of the transaction, the payee and the amount.

YNAB Unlinked works on this abstraction level to dettach itself from any details of any particular entity. As long as there is a class that can turn an input file into a list of transaction, that is all we need.

The method receives the YnabUnlinkedContext object that contains the following information:

@dataclass
class YnabUnlinkedContext[T]:
    config: Config
    extras: T
    show: bool = False
    reconcile: bool = False
  • config: the current YNAB Unlinked configuration
  • show: whether the user wants to just show the transactions andn ot load them to YNAB.
  • reconcile: whether the user wants to reconcile the transactions
  • extras: this is wehre any extra information from the command of this entity can be store. The type is T defined when created so it can be properly typed on the parse command to be used apropriately.

Lets imagine I want to create an entity for MyOldBank, the entity class would live in src/ynab_unlinked/entities/oldbank. The name of the package will be used to name the command through which this entity is exposed. This old bank only processes mortgages payments and the date is always going to be the same, provided by the user. This is a very simple entity, but works fine as an example.

My entity class can look like:

# old_bank.py
import datetime as dt
from dataclasses import dataclass
from pathlib import Path

from ynab_unlinked.context_object import YnabUnlinkedContext
from ynab_unlinked.models import Transaction


@dataclass
class OldBankContext:
    date: dt.date


class MyOldBank:
    def parse(
        self, input_file: Path, context: YnabUnlinkedContext[OldBankContext]
    ) -> list[Transaction]:
        return [
            Transaction(
                date=context.extras.date, payee="Old Bank", amount=float(line.strip())
            )
            for line in input_file.read_text().splitlines()
        ]

    def name(self) -> str:
        return "Old Bank"

The entity command

Having the class representing your entity is the first step to be able to load information into YNAB. The next one is exposing it through the entity command. The command is a function that will be loaded as a Typer command. This means the arguments should be decorated accordingly for any option you want to expose through the command.

The context and input_file arguments must always be provided. The context is needed to process transactions (as we will see below) and the input file is needed to read transactions from. We will provide another one, date that contains the date to be used when parsing the transactions.

Lets create the command now

# command.py
import datetime as dt
from pathlib import Path

import typer
from typing_extensions import Annotated

from ynab_unlinked.context_object import YnabUnlinkedContext
from ynab_unlinked.process import process_transactions

from .old_bank import MyOldBank, OldBankContext


def command(
    context: typer.Context,
    input_file: Annotated[
        Path,
        typer.Argument(exists=True, file_okay=True, dir_okay=False, readable=True),
    ],
    date: Annotated[
        dt.datetime,
        typer.Option(
            "-d",
            "--date",
            help="The date these mortgage payments refer to",
            required=True,
        ),
    ] = dt.datetime.now(),
):
    print("Running now my command for My Old Bank!")

    # Get the object from the typer context needed to attach the date to it
    ctx: YnabUnlinkedContext = context.obj

    ctx.extras = OldBankContext(date=date)

    # This method call kick-starts the processing
    process_transactions(
        entity=MyOldBank(),
        input_file=input_file,
        context=ctx,
    )

The only thing missing is exporting the command method in the oldbank pacakge

# __init__.py
from .command import command

__all__ = ["command"]

And we can now execute our command!

hatch run yul load oldbank --help

 Usage: yul load oldbank [OPTIONS] INPUT_FILE

╭─ Arguments ────────────────────────────────────────────────────────────────────────────────────────╮
│ *    input_file      FILE  [default: None] [required]                                              │
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────╮
│ --date       -d      [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d   The date these mortgage payments refer │
│                      %H:%M:%S]                              to                                     │
│                                                             [default: 2025-05-02 17:54:01.275438]  │
│ --help                                                      Show this message and exit.            │
╰────────────────────────────────────────────────────────────────────────────────────────────────────╯