diff --git a/docs/plugins/index.md b/docs/plugins/index.md index ae715937..ad635d48 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -1,10 +1,204 @@ # Plugins -lunchable supports plugins with other, external, services. See below for what's been built already. -If you can't find what you're looking for, consider building it yourself and opening a pull-request. +To install all the known plugins, and their dependencies, install +lunchable with the `plugins` extra: -### [PushLunch](pushlunch.md): Push Notifications via Pushover +```shell +pipx install "lunchable[plugins]" +``` -### [SplitLunch](splitlunch.md): Splitwise Integration +lunchable supports CLI plugins with other external packages. See below for what's been built already. +If you can't find what you're looking for, consider building it yourself and opening a pull-request +to add it to the list below: -### [PrimeLunch](primelunch.md): Amazon Transaction Updater +- [PushLunch](pushlunch.md): Push Notifications via Pushover +- [SplitLunch](splitlunch.md): Splitwise Integration +- [PrimeLunch](primelunch.md): Amazon Transaction Updater + +## LunchableApp + +Lunchable provides a [LunchableApp](#lunchable.plugins.LunchableApp) +class that can be used to easily build plugins, apps, +and more. Notice a few of the main attributes / methods of the `LunchableApp` class: + + attribute / method | description | type +------------------------------------------------------------------------------|--------------------------------------------------------------------------------|------------------------------------------------------- + **`lunch`** | The `LunchMoney` client | [LunchMoney](../interacting.md#lunchmoney) + **`data`** ¹ | The `LunchableData` object | [LunchableData](#lunchable.plugins.app.LunchableData) + [refresh_data](#lunchable.plugins.LunchableApp.refresh_data) | Refresh all data (besides Transactions) | `method` + [refresh_transactions](#lunchable.plugins.LunchableApp.refresh_transactions) | Refresh transactions, takes same parameters as `LunchMoney.get_transactions()` | `method` + [refresh](#lunchable.plugins.LunchableApp.refresh) ² | Refresh the data for one particular model, takes **kwargs | `method` + [clear_transactions](#lunchable.plugins.LunchableApp.clear_transactions) ³ | Clear all transactions from the internal data | `method` + +> ¹ This attribute contains all of the data that is loaded from LunchMoney. It has attributes +> for `assets`, `categories`, `plaid_accounts`, `tags`, `transactions`, `crypto` and `user`. +> These attributes (except for `user`) are `dict[int, LunchableModel]` objects, where the key is +> the ID of the object and the value is the object itself. + +> ² This method refreshes all of the data for one particular model. For example, +> `refresh(AssetsObject)` will refresh the assets on the underling `data.assets` +> attribute and return a `dict[int, AssetsObject]` object. + +> ³ This the same as running `app.data.transactions.clear()` + +### An Example App + +```python +from __future__ import annotations + +from typing import Any + +from lunchable.models import AssetsObject, TransactionUpdateObject +from lunchable.plugins import LunchableApp + + +class MyCustomApp(LunchableApp): + """ + My Custom App + """ + + def do_something_with_assets(self) -> None: + """ + Do something with the assets + """ + if not self.data.assets: + # If the data hasn't been loaded yet, load it + # The following method loads all of the data besides Transactions + # (Assets, Categories, Plaid Accounts, Tags, Crypto, User) + self.refresh_data() + for asset_id, asset in self.data.assets.items(): + # Do something with the asset + print(asset_id, asset) + + def do_something_with_transactions(self) -> None: + """ + Do something with the transactions + """ + if not self.data.transactions: + # If the transactions haven't been loaded yet, load them + self.refresh_transactions(start_date="2021-01-01", end_date="2021-01-31") + # Refresh the latest assets + latest_assets: dict[int, AssetsObject] = self.refresh(model=AssetsObject) + for transaction_id, transaction in self.data.transactions.items(): + if transaction.asset_id: + asset = latest_assets[transaction.asset_id] + print(transaction_id, transaction, asset) + + def update_transaction(self, transaction_id: int, payee: str) -> dict[str, Any]: + """ + You can do anything you want with the `self + """ + update_transaction = TransactionUpdateObject(payee=payee) + response = self.lunch.update_transaction(transaction_id=transaction_id, + transaction=update_transaction) + return response + + +if __name__ == "__main__": + app = MyCustomApp(access_token="xxxxxxxx") + app.do_something_with_assets() + app.do_something_with_transactions() + app.update_transaction(transaction_id=12345, payee="New Payee") +``` + +#### Choose a subset of data to load + +If you don't want to load all of the data, you can specify which data you want to load by +specifying the `lunchable_models` attribute of the `LunchableApp` class. The following example +will only sync the `assets` and `plaid_accounts` data when the `refresh_data()` method is called: + +```python +from __future__ import annotations + +from typing import ClassVar + +from lunchable.models import AssetsObject, PlaidAccountObject, LunchableModel + +from lunchable.plugins import LunchableApp + + +class CustomApp(LunchableApp): + """ + Custom Lunchable App + + This app syncs Plaid Accounts and Assets when its `refresh_data` method + is called. + """ + + lunchable_models: ClassVar[list[type[LunchableModel]]] = [ + PlaidAccountObject, + AssetsObject, + ] + + def do_something_with_assets(self) -> None: + """ + Do something with the assets + """ + if not self.data.plaid_accounts: + self.refresh_data() + for plaid_account_id, plaid_account in self.data.plaid_accounts.items(): + print(plaid_account_id, plaid_account) +``` + +## Building a Plugin + +Plugins are built separate Python packages and are detected by lunchable via +the `lunchable.cli` entrypoint, these are +[click](https://github.com/pallets/click/) command line applications. +To add your own plugin to lunchable you'll need to add a new entrypoint to +your package. The below example shows how to do this with hatch, a modern, +standards-based Python package manager: + +```python +import click + + +@click.group +def plugin_name(): + """ + Plugin description + """ + pass + + +@plugin_name.command +def command(): + """ + Plugin description + """ + pass +``` + +```toml +[project.entry-points."lunchable.cli"] +your-package = "your_package.cli:plugin_name" +``` + +The above example will add a new `command` / `group` to the lunchable `plugins` CLI. When +your package is installed into the same environment as lunchable, your plugin will be +accessible via the `lunchable plugins` command: + +```shell +lunchable plugins plugin-name command +``` + +## API Documentation + +::: lunchable.plugins.LunchableApp + handler: python + options: + show_bases: false + allow_inspection: true + inherited_members: true + group_by_category: true + heading_level: 3 + show_source: false + +::: lunchable.plugins.app.LunchableData + handler: python + options: + show_bases: false + allow_inspection: true + group_by_category: true + heading_level: 3 + show_source: false diff --git a/docs/plugins/primelunch.md b/docs/plugins/primelunch.md index f31e4a07..071b6511 100644 --- a/docs/plugins/primelunch.md +++ b/docs/plugins/primelunch.md @@ -96,7 +96,7 @@ lunchable plugins primelunch run \ ## Command Line Documentation ::: mkdocs-click - :module: lunchable._cli + :module: lunchable.plugins.primelunch.cli :command: run_primelunch :prog_name: lunchable plugins primelunch run :style: table diff --git a/docs/plugins/pushlunch.md b/docs/plugins/pushlunch.md index 1e498676..fe96821e 100644 --- a/docs/plugins/pushlunch.md +++ b/docs/plugins/pushlunch.md @@ -43,7 +43,7 @@ docker run --rm \ from lunchable.plugins.pushlunch import PushLunch ``` -::: lunchable.plugins.pushlunch.PushLunch +::: lunchable.plugins.pushlunch.pushover.PushLunch handler: python options: show_bases: false diff --git a/docs/plugins/splitlunch.md b/docs/plugins/splitlunch.md index e184ec1a..654132e1 100644 --- a/docs/plugins/splitlunch.md +++ b/docs/plugins/splitlunch.md @@ -103,10 +103,10 @@ docker run \ ## Run via Python ```python -from lunchable.plugins.splitlunch import SplitLunch +from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch ``` -::: lunchable.plugins.splitlunch.SplitLunch +::: lunchable.plugins.splitlunch.lunchmoney_splitwise.SplitLunch handler: python options: show_bases: false diff --git a/lunchable/_cli.py b/lunchable/_cli.py index ac0e8347..ee888cbc 100644 --- a/lunchable/_cli.py +++ b/lunchable/_cli.py @@ -2,14 +2,15 @@ Lunchmoney CLI """ -import datetime import logging import sys from json import JSONDecodeError -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional import click import httpx +from click_plugins import with_plugins +from importlib_metadata import entry_points from pydantic_core import to_jsonable_python from rich import print, print_json, traceback @@ -70,6 +71,11 @@ def transactions() -> None: def plugins() -> None: """ Interact with Lunchable Plugins + + Install lunchable with the "plugins" extra to get + all the known plugins + + pipx install "lunchable[plugins]" """ @@ -147,246 +153,6 @@ def lunchmoney_transactions( print_json(data=json_data) -@plugins.group() -def splitlunch() -> None: - """ - Splitwise Plugin for lunchable, SplitLunch 💲🍱 - """ - pass - - -dated_after = click.option( - "--dated-after", - default=None, - help="ISO 8601 Date time. Return expenses later that this date", -) -dated_before = click.option( - "--dated-before", - default=None, - help="ISO 8601 Date time. Return expenses earlier than this date", -) - - -@splitlunch.command("expenses") -@click.option( - "--limit", default=None, help="Limit the amount of Results. 0 returns everything." -) -@click.option("--offset", default=None, help="Number of expenses to be skipped") -@click.option("--limit", default=None, help="Number of expenses to be returned") -@click.option("--group-id", default=None, help="GroupID of the expenses") -@click.option("--friendship-id", default=None, help="FriendshipID of the expenses") -@dated_after -@dated_before -@click.option( - "--updated-after", - default=None, - help="ISO 8601 Date time. Return expenses updated after this date", -) -@click.option( - "--updated-before", - default=None, - help="ISO 8601 Date time. Return expenses updated before this date", -) -def splitlunch_expenses(**kwargs: Union[int, str, bool]) -> None: - """ - Retrieve Splitwise Expenses - """ - from lunchable.plugins.splitlunch import SplitLunch - - splitlunch = SplitLunch() - if set(kwargs.values()) == {None}: - kwargs["limit"] = 5 - expenses = splitlunch.get_expenses(**kwargs) # type: ignore[arg-type] - json_data = to_jsonable_python(expenses) - print_json(data=json_data) - - -tag_transactions = click.option( - "--tag-transactions", - is_flag=True, - help="Tag the resulting transactions with a `Splitwise` tag.", -) -financial_partner_id = click.option( - "--financial-partner-id", - default=None, - type=click.INT, - help="Splitwise ID of your financial partner.", -) -financial_partner_email = click.option( - "--financial-partner-email", - default=None, - help="Splitwise Email Address of your financial partner.", -) -financial_partner_group_id = click.option( - "--financial-partner-group-id", - default=None, - type=click.INT, - help="Splitwise Group ID for financial partner transactions.", -) - - -@splitlunch.command("splitlunch") -@tag_transactions -def make_splitlunch(**kwargs: Union[int, str, bool]) -> None: - """ - Split all `SplitLunch` tagged transactions in half. - - One of these new splits will be recategorized to `Reimbursement`. - """ - from lunchable.plugins.splitlunch import SplitLunch - - splitlunch = SplitLunch() - results = splitlunch.make_splitlunch(**kwargs) # type: ignore[arg-type] - json_data = to_jsonable_python(results) - print_json(data=json_data) - - -@splitlunch.command("splitlunch-import") -@tag_transactions -@financial_partner_id -@financial_partner_email -@financial_partner_group_id -def make_splitlunch_import(**kwargs: Union[int, str, bool]) -> None: - """ - Import `SplitLunchImport` tagged transactions to Splitwise and Split them in Lunch Money - - Send a transaction to Splitwise and then split the original transaction in Lunch Money. - One of these new splits will be recategorized to `Reimbursement`. Any tags will be - reapplied. - """ - from lunchable.plugins.splitlunch import SplitLunch - - financial_partner_id: Optional[int] = kwargs.pop("financial_partner_id") # type: ignore[assignment] - financial_partner_email: Optional[str] = kwargs.pop("financial_partner_email") # type: ignore[assignment] - financial_partner_group_id: Optional[int] = kwargs.pop("financial_partner_group_id") # type: ignore[assignment] - splitlunch = SplitLunch( - financial_partner_id=financial_partner_id, - financial_partner_email=financial_partner_email, - financial_partner_group_id=financial_partner_group_id, - ) - results = splitlunch.make_splitlunch_import(**kwargs) # type: ignore[arg-type] - json_data = to_jsonable_python(results) - print_json(data=json_data) - - -@splitlunch.command("splitlunch-direct-import") -@tag_transactions -@financial_partner_id -@financial_partner_email -@financial_partner_group_id -def make_splitlunch_direct_import(**kwargs: Union[int, str, bool]) -> None: - """ - Import `SplitLunchDirectImport` tagged transactions to Splitwise and Split them in Lunch Money - - Send a transaction to Splitwise and then split the original transaction in Lunch Money. - One of these new splits will be recategorized to `Reimbursement`. Any tags will be - reapplied. - """ - from lunchable.plugins.splitlunch import SplitLunch - - financial_partner_id: Optional[int] = kwargs.pop("financial_partner_id") # type: ignore[assignment] - financial_partner_email: Optional[str] = kwargs.pop("financial_partner_email") # type: ignore[assignment] - financial_partner_group_id: Optional[int] = kwargs.pop("financial_partner_group_id") # type: ignore[assignment] - splitlunch = SplitLunch( - financial_partner_id=financial_partner_id, - financial_partner_email=financial_partner_email, - financial_partner_group_id=financial_partner_group_id, - ) - results = splitlunch.make_splitlunch_direct_import(**kwargs) # type: ignore[arg-type] - json_data = to_jsonable_python(results) - print_json(data=json_data) - - -@splitlunch.command("update-balance") -def update_splitwise_balance() -> None: - """ - Update the Splitwise Asset Balance - """ - from lunchable.plugins.splitlunch import SplitLunch - - splitlunch = SplitLunch() - updated_asset = splitlunch.update_splitwise_balance() - json_data = to_jsonable_python(updated_asset) - print_json(data=json_data) - - -@splitlunch.command("refresh") -@dated_after -@dated_before -@click.option( - "--allow-self-paid/--no-allow-self-paid", - default=False, - help="Allow self-paid expenses to be imported (filtered out by default).", -) -@click.option( - "--allow-payments/--no-allow-payments", - default=False, - help="Allow payments to be imported (filtered out by default).", -) -def refresh_splitwise_transactions( - dated_before: Optional[datetime.datetime], - dated_after: Optional[datetime.datetime], - allow_self_paid: bool, - allow_payments: bool, -) -> None: - """ - Import New Splitwise Transactions to Lunch Money and - - This function gets all transactions from Splitwise, all transactions from - your Lunch Money Splitwise account and compares the two. This also updates - the account balance. - """ - from lunchable.plugins.splitlunch import SplitLunch - - splitlunch = SplitLunch() - response = splitlunch.refresh_splitwise_transactions( - dated_before=dated_before, - dated_after=dated_after, - allow_self_paid=allow_self_paid, - allow_payments=allow_payments, - ) - json_data = to_jsonable_python(response) - print_json(data=json_data) - - -@plugins.group() -def pushlunch() -> None: - """ - Push Notifications for Lunch Money: PushLunch 📲 - """ - pass - - -@pushlunch.command("notify") -@click.option( - "--continuous", - is_flag=True, - help="Whether to continuously check for more uncleared transactions, " - "waiting a fixed amount in between checks.", -) -@click.option( - "--interval", - default=None, - help="Sleep Interval in Between Tries - only applies if `continuous` is set. " - "Defaults to 60 (minutes). Cannot be less than 5 (minutes)", -) -@click.option( - "--user-key", - default=None, - help="Pushover User Key. Defaults to `PUSHOVER_USER_KEY` env var", -) -def notify(continuous: bool, interval: int, user_key: str) -> None: - """ - Send a Notification for each Uncleared Transaction - """ - from lunchable.plugins.pushlunch import PushLunch - - push = PushLunch(user_key=user_key) - if interval is not None: - interval = int(interval) - push.notify_uncleared_transactions(continuous=continuous, interval=interval) - - @cli.command() @click.argument("URL") @click.option("-X", "--request", default="GET", help="Specify request command to use") @@ -423,58 +189,5 @@ def http(context: LunchMoneyContext, url: str, request: str, data: str) -> None: print_json(data=json_data) -@plugins.group() -def primelunch() -> None: - """ - PrimeLunch CLI - Syncing LunchMoney with Amazon - """ - - -@primelunch.command("run") -@click.option( - "-f", - "--file", - "csv_file", - type=click.Path(exists=True, resolve_path=True), - help="File Path of the Amazon Export", - required=True, -) -@click.option( - "-w", - "--window", - "window", - type=click.INT, - help="Allowable time window between Amazon transaction date and " - "credit card transaction date", - default=7, -) -@click.option( - "-a", - "--all", - "update_all", - is_flag=True, - type=click.BOOL, - help="Whether to skip the confirmation step and simply update all matched " - "transactions", - default=False, -) -@click.option( - "-t", - "--token", - "access_token", - type=click.STRING, - help="LunchMoney Access Token - defaults to the LUNCHMONEY_ACCESS_TOKEN environment variable", - envvar="LUNCHMONEY_ACCESS_TOKEN", -) -def run_primelunch( - csv_file: str, window: int, update_all: bool, access_token: str -) -> None: - """ - Run the PrimeLunch Update Process - """ - from lunchable.plugins.primelunch.primelunch import PrimeLunch - - primelunch = PrimeLunch( - file_path=csv_file, time_window=window, access_token=access_token - ) - primelunch.process_transactions(confirm=not update_all) +discovered_plugins = entry_points(group="lunchable.cli") +with_plugins(discovered_plugins)(plugins) diff --git a/lunchable/plugins/__init__.py b/lunchable/plugins/__init__.py index 0714b2b5..e79bd437 100644 --- a/lunchable/plugins/__init__.py +++ b/lunchable/plugins/__init__.py @@ -2,10 +2,9 @@ Optional Plugins for LunchMoney """ -from .base.base_app import LunchableApp, LunchableModelType, LunchableTransactionsApp +from lunchable.plugins.app import LunchableApp, LunchableModelType __all__ = [ - "LunchableTransactionsApp", "LunchableApp", "LunchableModelType", ] diff --git a/lunchable/plugins/app.py b/lunchable/plugins/app.py new file mode 100644 index 00000000..2c8989ba --- /dev/null +++ b/lunchable/plugins/app.py @@ -0,0 +1,348 @@ +""" +Base Classes for lunchable-app +""" + +from __future__ import annotations + +import datetime +import functools +import logging +from abc import ABC, abstractmethod +from typing import Any, Callable, ClassVar, Dict, List, Tuple, Type, TypeVar, overload + +from pydantic import BaseModel, Field + +from lunchable import LunchMoney +from lunchable.models import ( + AssetsObject, + CategoriesObject, + CryptoObject, + LunchableModel, + PlaidAccountObject, + TagsObject, + TransactionObject, + UserObject, +) + +logger = logging.getLogger(__name__) + +LunchableModelType = TypeVar("LunchableModelType", bound=LunchableModel) + + +class LunchableData(BaseModel): + """ + Data Container for Lunchable App Data + """ + + plaid_accounts: Dict[int, PlaidAccountObject] = Field( + default_factory=dict, description="Plaid Accounts" + ) + transactions: Dict[int, TransactionObject] = Field( + default_factory=dict, description="Transactions" + ) + categories: Dict[int, CategoriesObject] = Field( + default_factory=dict, description="Categories" + ) + assets: Dict[int, AssetsObject] = Field(default_factory=dict, description="Assets") + tags: Dict[int, TagsObject] = Field(default_factory=dict, description="Tags") + crypto: Dict[int, CryptoObject] = Field(default_factory=dict, description="Crypto") + user: UserObject = Field( + UserObject( + user_id=0, user_name="", user_email="", account_id=0, budget_name="" + ), + description="User", + ) + + @property + def asset_map(self) -> Dict[int, PlaidAccountObject | AssetsObject]: + """ + Asset Mapping Across Plaid Accounts and Assets + + Returns + ------- + Dict[int, Union[PlaidAccountObject, AssetsObject]] + """ + return { + **self.plaid_accounts, + **self.assets, + } + + @property + def plaid_accounts_list(self) -> List[PlaidAccountObject]: + """ + List of Plaid Accounts + + Returns + ------- + List[PlaidAccountObject] + """ + return list(self.plaid_accounts.values()) + + @property + def assets_list(self) -> List[AssetsObject]: + """ + List of Assets + + Returns + ------- + List[AssetsObject] + """ + return list(self.assets.values()) + + @property + def transactions_list(self) -> List[TransactionObject]: + """ + List of Transactions + + Returns + ------- + List[TransactionObject] + """ + return list(self.transactions.values()) + + @property + def categories_list(self) -> List[CategoriesObject]: + """ + List of Categories + + Returns + ------- + List[CategoriesObject] + """ + return list(self.categories.values()) + + @property + def tags_list(self) -> List[TagsObject]: + """ + List of Tags + + Returns + ------- + List[TagsObject] + """ + return list(self.tags.values()) + + @property + def crypto_list(self) -> List[CryptoObject]: + """ + List of Crypto + + Returns + ------- + List[CryptoObject] + """ + return list(self.crypto.values()) + + +class BaseLunchableApp(ABC): + """ + Abstract Base Class for Lunchable Apps + """ + + @property + def _lunchable_data_mapping( + self, + ) -> Dict[ + Type[LunchableModel], + Tuple[str, Callable[[], List[LunchableModel] | LunchableModel]], + ]: + """ + Mapping of Lunchable Objects to their Data Collecting Info + """ + return { + PlaidAccountObject: ("plaid_accounts", self.lunch.get_plaid_accounts), + TransactionObject: ("transactions", self.lunch.get_transactions), + CategoriesObject: ("categories", self.lunch.get_categories), + AssetsObject: ("assets", self.lunch.get_assets), + TagsObject: ("tags", self.lunch.get_tags), + UserObject: ("user", self.lunch.get_user), + CryptoObject: ("crypto", self.lunch.get_crypto), + } + + def __init__(self, access_token: str | None = None): + """ + Lunchable App Initialization + + Parameters + ---------- + access_token: Optional[str] + Lunchmoney Developer API Access Token. Inherited from + `LUNCHMONEY_ACCESS_TOKEN` environment variable if not provided + """ + self.lunch = LunchMoney(access_token=access_token) + self.data = LunchableData() + + @property + @abstractmethod + def lunchable_models(self) -> List[Type[LunchableModel]]: + """ + Every LunchableApp should define which data objects it depends on + + Returns + ------- + List[LunchableDataModel] + """ + + @overload + def refresh(self, model: Type[UserObject], **kwargs: Any) -> UserObject: + ... + + @overload + def refresh( + self, model: Type[LunchableModelType], **kwargs: Any + ) -> Dict[int, LunchableModelType]: + ... + + def refresh( + self, model: Type[LunchableModel], **kwargs: Any + ) -> LunchableModel | Dict[int, LunchableModel]: + """ + Refresh a Lunchable Model + + Parameters + ---------- + model: Type[LunchableModel] + Type of Lunchable Model to refresh + kwargs: Any + Additional keyword arguments to pass to the function that + fetches the data. + + Returns + ------- + LunchableModel | Dict[int, LunchableModel] + Unless you're requesting the `UserObject`, this method will return a + dictionary of the refreshed data, keyed by the object's ID. + """ + try: + attr_name, data_getter = self._lunchable_data_mapping[model] + fetch_data_function = functools.partial(data_getter, **kwargs) + except KeyError as e: + msg = f"Model not supported by Lunchable App: {model.__name__}" + raise NotImplementedError(msg) from e + fetched_data = fetch_data_function() + if isinstance(model, UserObject): + data_mapping = fetched_data + else: + data_mapping = {item.id: item for item in fetched_data} + setattr(self.data, attr_name, data_mapping) + return data_mapping + + def refresh_data(self, models: List[Type[LunchableModel]] | None = None) -> None: + """ + Refresh the data in the Lunchable App + + Parameters + ---------- + models: List[Type[LunchableModel]] | None + Explicit list of Lunchable Models to refresh. If not provided, + all models defined in will be refreshed (which by default is + all of them except for transactions) + """ + refresh_models = models or self.lunchable_models + for model in refresh_models: + self.refresh(model) + + def refresh_transactions( + self, + start_date: datetime.date | datetime.datetime | str | None = None, + end_date: datetime.date | datetime.datetime | str | None = None, + tag_id: int | None = None, + recurring_id: int | None = None, + plaid_account_id: int | None = None, + category_id: int | None = None, + asset_id: int | None = None, + group_id: int | None = None, + is_group: bool | None = None, + status: str | None = None, + debit_as_negative: bool | None = None, + pending: bool | None = None, + params: Dict[str, Any] | None = None, + ) -> Dict[int, TransactionObject]: + """ + Refresh App data with the latest transactions + + Parameters + ---------- + start_date: Optional[Union[datetime.date, datetime.datetime, str]] + Denotes the beginning of the time period to fetch transactions for. + Defaults to beginning of current month. Required if end_date exists. + Format: YYYY-MM-DD. + end_date: Optional[Union[datetime.date, datetime.datetime, str]] + Denotes the end of the time period you'd like to get transactions for. + Defaults to end of current month. Required if start_date exists. + tag_id: Optional[int] + Filter by tag. Only accepts IDs, not names. + recurring_id: Optional[int] + Filter by recurring expense + plaid_account_id: Optional[int] + Filter by Plaid account + category_id: Optional[int] + Filter by category. Will also match category groups. + asset_id: Optional[int] + Filter by asset + group_id: Optional[int] + Filter by group_id (if the transaction is part of a specific group) + is_group: Optional[bool] + Filter by group (returns transaction groups) + status: Optional[str] + Filter by status (Can be cleared or uncleared. For recurring + transactions, use recurring) + debit_as_negative: Optional[bool] + Pass in true if you'd like expenses to be returned as negative amounts and + credits as positive amounts. Defaults to false. + pending: Optional[bool] + Pass in true if you'd like to include imported transactions with a + pending status. + params: Optional[dict] + Additional Query String Params + + Returns + ------- + Dict[int, TransactionObject] + """ + transactions = self.lunch.get_transactions( + start_date=start_date, + end_date=end_date, + tag_id=tag_id, + recurring_id=recurring_id, + plaid_account_id=plaid_account_id, + category_id=category_id, + asset_id=asset_id, + group_id=group_id, + is_group=is_group, + status=status, + debit_as_negative=debit_as_negative, + pending=pending, + params=params, + ) + transaction_map = {item.id: item for item in transactions} + self.data.transactions.update(transaction_map) + return transaction_map + + def clear_transactions(self) -> None: + """ + Clear Transactions from the App + """ + self.data.transactions.clear() + + +class LunchableApp(BaseLunchableApp): + """ + Pre-Built Lunchable App + + This app comes with a `data` property which represents all the base data + the app should need. + + This app comes with a `refresh_data` method which will refresh all of the + data in the app, except for transactions. To refresh transactions, use the + `refresh_transactions` method. + """ + + lunchable_models: ClassVar[List[Type[LunchableModel]]] = [ + PlaidAccountObject, + CategoriesObject, + AssetsObject, + TagsObject, + UserObject, + CryptoObject, + ] diff --git a/lunchable/plugins/base/__init__.py b/lunchable/plugins/base/__init__.py deleted file mode 100644 index 2b61f51c..00000000 --- a/lunchable/plugins/base/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Base Apps for Plugins -""" - -from .base_app import LunchableApp, LunchableModelType, LunchableTransactionsApp - -__all__ = [ - "LunchableTransactionsApp", - "LunchableApp", - "LunchableModelType", -] diff --git a/lunchable/plugins/base/base_app.py b/lunchable/plugins/base/base_app.py deleted file mode 100644 index 03d656ab..00000000 --- a/lunchable/plugins/base/base_app.py +++ /dev/null @@ -1,525 +0,0 @@ -""" -Base App Class -""" - -import base64 -import datetime -import json -import logging -import shutil -from abc import ABC, abstractmethod -from hashlib import sha256 -from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union - -from pydantic import BaseModel -from pydantic.json import pydantic_encoder - -from lunchable import LunchMoney -from lunchable._config import FileConfig -from lunchable.models import ( - AssetsObject, - CategoriesObject, - CryptoObject, - LunchableModel, - PlaidAccountObject, - TagsObject, - TransactionObject, - UserObject, -) - -logger = logging.getLogger(__name__) - -LunchableModelType = TypeVar("LunchableModelType", bound=LunchableModel) - - -class LunchableDataModel(LunchableModel): - """ - Core Data Model Defining App Dependencies - """ - - model: Type[LunchableModel] - function: Callable[[Any], Any] - kwargs: Dict[str, Any] = {} - - -class LunchableDataContainer(BaseModel): - """ - Data Container for Lunchable App Data - """ - - plaid_accounts: Dict[int, PlaidAccountObject] = {} - transactions: Dict[int, TransactionObject] = {} - categories: Dict[int, CategoriesObject] = {} - assets: Dict[int, AssetsObject] = {} - tags: Dict[int, TagsObject] = {} - user: UserObject = UserObject( - user_id=0, user_name="", user_email="", account_id=0, budget_name="" - ) - crypto: Dict[int, CryptoObject] = {} - - @property - def asset_map(self) -> Dict[int, Union[PlaidAccountObject, AssetsObject]]: - """ - Asset Mapping Across Plaid Accounts and Assets - - Returns - ------- - Dict[int, Union[PlaidAccountObject, AssetsObject]] - """ - asset_map: Dict[int, Union[PlaidAccountObject, AssetsObject]] = {} - asset_map.update(self.plaid_accounts) - asset_map.update(self.assets) - return asset_map - - @property - def plaid_accounts_list(self) -> List[PlaidAccountObject]: - """ - List of Plaid Accounts - - Returns - ------- - List[PlaidAccountObject] - """ - return list(self.plaid_accounts.values()) - - @property - def assets_list(self) -> List[AssetsObject]: - """ - List of Assets - - Returns - ------- - List[AssetsObject] - """ - return list(self.assets.values()) - - @property - def transactions_list(self) -> List[TransactionObject]: - """ - List of Transactions - - Returns - ------- - List[TransactionObject] - """ - return list(self.transactions.values()) - - @property - def categories_list(self) -> List[CategoriesObject]: - """ - List of Categories - - Returns - ------- - List[CategoriesObject] - """ - return list(self.categories.values()) - - @property - def tags_list(self) -> List[TagsObject]: - """ - List of Tags - - Returns - ------- - List[TagsObject] - """ - return list(self.tags.values()) - - @property - def crypto_list(self) -> List[CryptoObject]: - """ - List of Crypto - - Returns - ------- - List[CryptoObject] - """ - return list(self.crypto.values()) - - -class BaseLunchableApp(ABC): - """ - Abstract Base Class for Lunchable Apps - """ - - __lunchable_object_mapping__: Dict[str, str] = { - PlaidAccountObject.__name__: "plaid_accounts", - TransactionObject.__name__: "transactions", - CategoriesObject.__name__: "categories", - AssetsObject.__name__: "assets", - TagsObject.__name__: "tags", - UserObject.__name__: "user", - CryptoObject.__name__: "crypto", - } - - @property - @abstractmethod - def lunchable_models(self) -> List[LunchableDataModel]: - """ - Every LunchableApp should define which data objects it depends on - - Returns - ------- - List[LunchableDataModel] - """ - - @property - @abstractmethod - def __builtin_data_models__(self) -> List[LunchableDataModel]: - """ - Every LunchableApp should define which data objects are built-in - - Returns - ------- - List[LunchableDataModel] - """ - - def __init__(self, cache_time: int = 0, access_token: Optional[str] = None): - """ - Lunchable App Initialization - - Parameters - ---------- - cache_time: int - Amount of time until the cache should be refreshed - (in seconds). Defaults to 0 which always polls for the latest data - access_token: Optional[str] - Lunchmoney Developer API Access Token - """ - self.lunch = LunchMoney(access_token=access_token) - self.lunch_data = LunchableDataContainer() - self.data_dir = FileConfig.DATA_DIR.joinpath(self.__class__.__name__).joinpath( - sha256(self.lunch.access_token.encode("utf-8")).hexdigest() - ) - self.cache_time = cache_time - if self.cache_time > 0: - self.data_dir.mkdir(exist_ok=True, parents=True) - - def _cache_single_object( - self, - model: Type[LunchableModelType], - function: Callable[[Any], Any], - kwargs: Optional[Dict[str, Any]] = None, - force: bool = False, - ) -> Union[LunchableModelType, List[LunchableModelType]]: - """ - Cache a Core Lunchable Data Object - - Parameters - ---------- - model: Type[LunchableModel] - function: Callable - kwargs: Optional[Dict[str, Any]] - force: bool - - Returns - ------- - Any - """ - if kwargs is None: - kwargs = {} - data_file = self.data_dir.joinpath(f"{model.__name__}.lunch") - if force is True: - refresh = True - elif self.cache_time > 0 and data_file.exists(): - modified_time = datetime.datetime.fromtimestamp( - data_file.stat().st_mtime, tz=datetime.timezone.utc - ) - current_time = datetime.datetime.now(tz=datetime.timezone.utc) - file_age = current_time - modified_time - refresh = file_age > datetime.timedelta(seconds=self.cache_time) - else: - refresh = True - if refresh is True: - data_objects = function(**kwargs) # type: ignore[call-arg] - if self.cache_time > 0: - plain_data: str = json.dumps(data_objects, default=pydantic_encoder) - base64_data: bytes = base64.b64encode(plain_data.encode("utf-8")) - data_file.write_bytes(base64_data) - else: - file_text: bytes = data_file.read_bytes() - json_body: bytes = base64.b64decode(file_text) - json_data: Union[Dict[str, Any], List[Dict[str, Any]]] = json.loads( - json_body.decode("utf-8") - ) - if isinstance(json_data, dict): - data_objects = model.model_validate(json_data) - else: - data_objects = [model.model_validate(item) for item in json_data] - return data_objects - - def get_latest_cache( - self, - include: Optional[List[Type[LunchableModel]]] = None, - exclude: Optional[List[Type[LunchableModel]]] = None, - force: bool = False, - ) -> None: - """ - Cache the Underlying Data Objects - - Parameters - ---------- - include : Optional[List[Type[LunchableModel]]] - Models to refresh cache for (instead of all of them) - exclude : Optional[List[Type[LunchableModel]]] - Models to skip cache refreshing - force: bool - Whether to force the cache - - Returns - ------- - None - """ - models_to_process = self.lunchable_models + self.__builtin_data_models__ - if include is not None: - new_models_to_process: List[LunchableDataModel] = [] - data_model_mapping = {item.model: item for item in models_to_process} - for model_class in include: - new_models_to_process.append(data_model_mapping[model_class]) - models_to_process = new_models_to_process - exclusions = exclude if exclude is not None else [] - for data_model in models_to_process: - if data_model.model in exclusions: - continue - cache = self._cache_single_object( - model=data_model.model, - function=data_model.function, - kwargs=data_model.kwargs, - force=force, - ) - cache_attribute: Union[Dict[int, LunchableModel], LunchableModel] - if isinstance(cache, list): - cache_attribute = {item.id: item for item in cache} - else: - cache_attribute = cache - setattr( - self.lunch_data, - self.__lunchable_object_mapping__[data_model.model.__name__], - cache_attribute, - ) - - def refresh_transactions( - self, - start_date: Optional[Union[datetime.date, datetime.datetime, str]] = None, - end_date: Optional[Union[datetime.date, datetime.datetime, str]] = None, - tag_id: Optional[int] = None, - recurring_id: Optional[int] = None, - plaid_account_id: Optional[int] = None, - category_id: Optional[int] = None, - asset_id: Optional[int] = None, - group_id: Optional[int] = None, - is_group: Optional[bool] = None, - status: Optional[str] = None, - offset: Optional[int] = None, - limit: Optional[int] = None, - debit_as_negative: Optional[bool] = None, - pending: Optional[bool] = None, - params: Optional[Dict[str, Any]] = None, - ) -> Dict[int, TransactionObject]: - """ - Refresh App data with the latest transactions - - Parameters - ---------- - start_date: Optional[Union[datetime.date, datetime.datetime, str]] - Denotes the beginning of the time period to fetch transactions for. Defaults - to beginning of current month. Required if end_date exists. Format: YYYY-MM-DD. - end_date: Optional[Union[datetime.date, datetime.datetime, str]] - Denotes the end of the time period you'd like to get transactions for. - Defaults to end of current month. Required if start_date exists. - tag_id: Optional[int] - Filter by tag. Only accepts IDs, not names. - recurring_id: Optional[int] - Filter by recurring expense - plaid_account_id: Optional[int] - Filter by Plaid account - category_id: Optional[int] - Filter by category. Will also match category groups. - asset_id: Optional[int] - Filter by asset - group_id: Optional[int] - Filter by group_id (if the transaction is part of a specific group) - is_group: Optional[bool] - Filter by group (returns transaction groups) - status: Optional[str] - Filter by status (Can be cleared or uncleared. For recurring - transactions, use recurring) - offset: Optional[int] - Sets the offset for the records returned - limit: Optional[int] - Sets the maximum number of records to return. Note: The server will not - respond with any indication that there are more records to be returned. - Please check the response length to determine if you should make another - call with an offset to fetch more transactions. - debit_as_negative: Optional[bool] - Pass in true if you'd like expenses to be returned as negative amounts and - credits as positive amounts. Defaults to false. - pending: Optional[bool] - Pass in true if you'd like to include imported transactions with a pending status. - params: Optional[dict] - Additional Query String Params - - Returns - ------- - Dict[int, TransactionObject] - """ - transactions = self.lunch.get_transactions( - start_date=start_date, end_date=end_date, status=status - ) - transaction_map = {item.id: item for item in transactions} - self.lunch_data.transactions = transaction_map - return transaction_map - - def delete_cache(self) -> None: - """ - Delete any corresponding cache files - """ - if self.data_dir.exists(): - shutil.rmtree(self.data_dir) - - -class LunchableApp(BaseLunchableApp): - """ - Pre-Built Lunchable App - - This app comes with a `data` property which represents all the base data - the app should need. Extend the `data_models` property to items like - `TransactionObject`s to interact with transactions - """ - - @property - def lunchable_models(self) -> List[LunchableDataModel]: - """ - Which Data Should this app get - """ - return [] - - @property - def __builtin_data_models__(self) -> List[LunchableDataModel]: - """ - Built-In Models to Populate Most LunchableApp instances - - Returns - ------- - List[LunchableDataModel] - """ - return [ - LunchableDataModel( - model=CategoriesObject, function=self.lunch.get_categories - ), - LunchableDataModel( - model=PlaidAccountObject, - function=self.lunch.get_plaid_accounts, - ), - LunchableDataModel( - model=AssetsObject, - function=self.lunch.get_assets, - ), - LunchableDataModel( - model=TagsObject, - function=self.lunch.get_tags, - ), - LunchableDataModel( - model=UserObject, - function=self.lunch.get_user, - ), - LunchableDataModel( - model=CryptoObject, - function=self.lunch.get_crypto, - ), - ] - - -class LunchableTransactionsBaseApp(LunchableApp, ABC): - """ - LunchableApp supporting transactions - """ - - data_models: List[LunchableDataModel] = [] - - @property - @abstractmethod - def start_date(self) -> datetime.date: - """ - LunchableTransactionsApp requires a Start Date - - Returns - ------- - datetime.date - """ - - @property - @abstractmethod - def end_date(self) -> datetime.date: - """ - LunchableTransactionsApp requires a End Date - - Returns - ------- - datetime.date - """ - - @property - def __builtin_data_models__(self) -> List[LunchableDataModel]: - """ - Which Data Should this app get - """ - return [ - *super().__builtin_data_models__, - LunchableDataModel( - model=TransactionObject, - function=self.lunch.get_transactions, - kwargs={"start_date": self.start_date, "end_date": self.end_date}, - ), - ] - - def refresh_transactions( # type: ignore[override] - self, - start_date: Optional[datetime.date] = None, - end_date: Optional[datetime.date] = None, - ) -> Dict[int, TransactionObject]: - """ - Refresh App data with the latest transactions - - Returns - ------- - Dict[int, TransactionObject] - """ - transactions = self.lunch.get_transactions( - start_date=start_date if start_date is not None else self.start_date, - end_date=end_date if end_date is not None else self.end_date, - ) - transaction_map = {item.id: item for item in transactions} - self.lunch_data.transactions = transaction_map - return transaction_map - - -class LunchableTransactionsApp(LunchableTransactionsBaseApp): - """ - Pre-Built Lunchable App with the last 365 days worth of transactions - """ - - @property - def start_date(self) -> datetime.date: - """ - LunchableTransactionsApp requires a Start Date - - Returns - ------- - datetime.date - """ - today = datetime.date.today() - return today.replace(year=today.year - 100) - - @property - def end_date(self) -> datetime.date: - """ - LunchableTransactionsApp requires a End Date - - Returns - ------- - datetime.date - """ - today = datetime.date.today() - return today.replace(year=today.year + 100) diff --git a/lunchable/plugins/base/pandas_app.py b/lunchable/plugins/base/pandas_app.py deleted file mode 100644 index 6b0797e9..00000000 --- a/lunchable/plugins/base/pandas_app.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Apps with Pandas Support -""" - -from typing import Iterable, List, Type - -import numpy as np -import pandas as pd - -from lunchable.models import LunchableModel -from lunchable.plugins import LunchableApp, LunchableModelType, LunchableTransactionsApp - - -class LunchablePandasApp(LunchableApp): - """ - LunchableApp with Pandas Super Powers - """ - - @staticmethod - def models_to_df(models: Iterable[LunchableModel]) -> pd.DataFrame: - """ - Convert Transactions Array to DataFrame - - Parameters - ---------- - models: List[LunchableModel] - - Returns - ------- - pd.DataFrame - """ - if not isinstance(models, list): - models = list(models) - return pd.DataFrame( - [item.model_dump() for item in models], - columns=models[0].__fields__.keys(), - ) - - @staticmethod - def df_to_models( - df: pd.DataFrame, model_type: Type[LunchableModelType] - ) -> List[LunchableModelType]: - """ - Convert DataFrame to Transaction Array - - Parameters - ---------- - df: pd.DataFrame - model_type: Type[LunchableModel] - - Returns - ------- - List[LunchableModel] - """ - array_df = df.copy() - array_df = array_df.fillna(np.NaN).replace([np.NaN], [None]) - model_array = array_df.to_dict(orient="records") - return [model_type.model_validate(item) for item in model_array] - - -class LunchablePandasTransactionsApp(LunchableTransactionsApp, LunchablePandasApp): - """ - LunchableTransactionsApp with Pandas Super Powers - """ diff --git a/lunchable/plugins/primelunch/cli.py b/lunchable/plugins/primelunch/cli.py new file mode 100644 index 00000000..ad6a750a --- /dev/null +++ b/lunchable/plugins/primelunch/cli.py @@ -0,0 +1,58 @@ +import click + + +@click.group +def primelunch() -> None: + """ + PrimeLunch CLI - Syncing LunchMoney with Amazon + """ + + +@primelunch.command("run") +@click.option( + "-f", + "--file", + "csv_file", + type=click.Path(exists=True, resolve_path=True), + help="File Path of the Amazon Export", + required=True, +) +@click.option( + "-w", + "--window", + "window", + type=click.INT, + help="Allowable time window between Amazon transaction date and " + "credit card transaction date", + default=7, +) +@click.option( + "-a", + "--all", + "update_all", + is_flag=True, + type=click.BOOL, + help="Whether to skip the confirmation step and simply update all matched " + "transactions", + default=False, +) +@click.option( + "-t", + "--token", + "access_token", + type=click.STRING, + help="LunchMoney Access Token - defaults to the LUNCHMONEY_ACCESS_TOKEN environment variable", + envvar="LUNCHMONEY_ACCESS_TOKEN", +) +def run_primelunch( + csv_file: str, window: int, update_all: bool, access_token: str +) -> None: + """ + Run the PrimeLunch Update Process + """ + from lunchable.plugins.primelunch.primelunch import PrimeLunch + + primelunch = PrimeLunch( + file_path=csv_file, time_window=window, access_token=access_token + ) + primelunch.process_transactions(confirm=not update_all) diff --git a/lunchable/plugins/primelunch/primelunch.py b/lunchable/plugins/primelunch/primelunch.py index 6d9c02e1..f12edd58 100644 --- a/lunchable/plugins/primelunch/primelunch.py +++ b/lunchable/plugins/primelunch/primelunch.py @@ -9,7 +9,7 @@ import logging import os import pathlib -from typing import Any, Optional, Union +from typing import Any, Iterable, List, Optional, Type, Union from rich import print, table from rich.prompt import Confirm @@ -18,17 +18,17 @@ from lunchable.exceptions import LunchMoneyImportError from lunchable.models import ( CategoriesObject, + LunchableModel, TransactionObject, TransactionUpdateObject, UserObject, ) +from lunchable.plugins import LunchableApp, LunchableModelType try: import numpy as np import pandas as pd from numpy import datetime64 - - from lunchable.plugins.base.pandas_app import LunchablePandasApp except ImportError as e: msg = f'PrimeLunch requires the `primelunch` extras to be installed: `pip install "{__application__}[primelunch]"`' raise LunchMoneyImportError(msg) from e @@ -36,7 +36,7 @@ logger = logging.getLogger(__name__) -class PrimeLunch(LunchablePandasApp): +class PrimeLunch(LunchableApp): """ PrimeLunch: Amazon Notes Updater """ @@ -50,10 +50,51 @@ def __init__( """ Initialize and set internal data """ - super().__init__(cache_time=0, access_token=access_token) + super().__init__(access_token=access_token) self.file_path = pathlib.Path(file_path) self.time_window = time_window + @staticmethod + def models_to_df(models: Iterable[LunchableModel]) -> pd.DataFrame: + """ + Convert Transactions Array to DataFrame + + Parameters + ---------- + models: List[LunchableModel] + + Returns + ------- + pd.DataFrame + """ + if not isinstance(models, list): + models = list(models) + return pd.DataFrame( + [item.model_dump() for item in models], + columns=models[0].model_fields.keys(), + ) + + @staticmethod + def df_to_models( + df: pd.DataFrame, model_type: Type[LunchableModelType] + ) -> List[LunchableModelType]: + """ + Convert DataFrame to Transaction Array + + Parameters + ---------- + df: pd.DataFrame + model_type: Type[LunchableModel] + + Returns + ------- + List[LunchableModel] + """ + array_df = df.copy() + array_df = array_df.fillna(np.NaN).replace([np.NaN], [None]) + model_array = array_df.to_dict(orient="records") + return [model_type.model_validate(item) for item in model_array] + def amazon_to_df(self) -> pd.DataFrame: """ Read an Amazon Data File to a DataFrame @@ -255,17 +296,17 @@ def cache_transactions( start_date, end_cache_date, ) - self.get_latest_cache(include=[CategoriesObject, UserObject]) + self.refresh_data(models=[UserObject, CategoriesObject]) self.refresh_transactions(start_date=start_date, end_date=end_cache_date) logger.info( 'Scanning LunchMoney Budget: "%s"', - html.unescape(self.lunch_data.user.budget_name), + html.unescape(self.data.user.budget_name), ) logger.info( "%s transactions returned from LunchMoney", - len(self.lunch_data.transactions), + len(self.data.transactions), ) - return self.lunch_data.transactions + return self.data.transactions def print_transaction( self, transaction: TransactionObject, former_transaction: TransactionObject @@ -284,7 +325,7 @@ def print_transaction( if former_transaction.category_id is not None: transaction_table.add_row( "📊 Category", - self.lunch_data.categories[former_transaction.category_id].name, + self.data.categories[former_transaction.category_id].name, ) if ( former_transaction.original_name is not None @@ -318,7 +359,7 @@ def update_transaction( ------- Optional[Dict[str, Any]] """ - former_transaction = self.lunch_data.transactions[transaction.id] + former_transaction = self.data.transactions[transaction.id] response = None stripped_notes = transaction.notes.strip() # type: ignore[union-attr] acceptable_length = min(349, len(stripped_notes)) @@ -360,7 +401,7 @@ def process_transactions(self, confirm: bool = True) -> None: ) self.cache_transactions(start_date=min_date, end_date=max_date) transaction_df = self.models_to_df( - models=self.lunch_data.transactions.values(), + models=self.data.transactions.values(), ) amazon_transaction_df = self.filter_amazon_transactions(df=transaction_df) merged_data = self.merge_transactions( diff --git a/lunchable/plugins/pushlunch/__init__.py b/lunchable/plugins/pushlunch/__init__.py index f6affef9..de419977 100644 --- a/lunchable/plugins/pushlunch/__init__.py +++ b/lunchable/plugins/pushlunch/__init__.py @@ -1,9 +1,3 @@ """ PushLunch lunchable Plugin """ - -from .pushover import PushLunch - -__all__ = [ - "PushLunch", -] diff --git a/lunchable/plugins/pushlunch/cli.py b/lunchable/plugins/pushlunch/cli.py new file mode 100644 index 00000000..3a009d5f --- /dev/null +++ b/lunchable/plugins/pushlunch/cli.py @@ -0,0 +1,39 @@ +import click + + +@click.group +def pushlunch() -> None: + """ + Push Notifications for Lunch Money: PushLunch 📲 + """ + pass + + +@pushlunch.command("notify") +@click.option( + "--continuous", + is_flag=True, + help="Whether to continuously check for more uncleared transactions, " + "waiting a fixed amount in between checks.", +) +@click.option( + "--interval", + default=None, + help="Sleep Interval in Between Tries - only applies if `continuous` is set. " + "Defaults to 60 (minutes). Cannot be less than 5 (minutes)", +) +@click.option( + "--user-key", + default=None, + help="Pushover User Key. Defaults to `PUSHOVER_USER_KEY` env var", +) +def notify(continuous: bool, interval: int, user_key: str) -> None: + """ + Send a Notification for each Uncleared Transaction + """ + from lunchable.plugins.pushlunch.pushover import PushLunch + + push = PushLunch(user_key=user_key) + if interval is not None: + interval = int(interval) + push.notify_uncleared_transactions(continuous=continuous, interval=interval) diff --git a/lunchable/plugins/pushlunch/pushover.py b/lunchable/plugins/pushlunch/pushover.py index 01fe75ac..0c521438 100644 --- a/lunchable/plugins/pushlunch/pushover.py +++ b/lunchable/plugins/pushlunch/pushover.py @@ -74,9 +74,8 @@ def __init__( "a `PUSHOVER_USER_KEY` environment variable" ) self._params = {"user": user_key, "token": token} - self.get_latest_cache( - include=[AssetsObject, PlaidAccountObject, CategoriesObject] - ) + self.refresh_data(models=[AssetsObject, PlaidAccountObject, CategoriesObject]) + self.notified_transactions: List[int] = [] def send_notification( @@ -171,10 +170,10 @@ class hasn't already posted this particular notification if transaction.category_id is None: category = "N/A" else: - category = self.lunch_data.categories[transaction.category_id].name + category = self.data.categories[transaction.category_id].name account_id = transaction.plaid_account_id or transaction.asset_id assert account_id is not None - account = self.lunch_data.asset_map[account_id] + account = self.data.asset_map[account_id] if isinstance(account, AssetsObject): account_name = account.display_name or account.name else: diff --git a/lunchable/plugins/splitlunch/README.md b/lunchable/plugins/splitlunch/README.md index 663ffc5e..617412e4 100644 --- a/lunchable/plugins/splitlunch/README.md +++ b/lunchable/plugins/splitlunch/README.md @@ -22,7 +22,7 @@ pip install lunchable[splitlunch] ``` ```python -from lunchable.plugins.splitlunch import SplitLunch +from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch splitlunch = SplitLunch() splitlunch.refresh_splitwise_transactions() diff --git a/lunchable/plugins/splitlunch/__init__.py b/lunchable/plugins/splitlunch/__init__.py index bbb0c75d..29d0b8d7 100644 --- a/lunchable/plugins/splitlunch/__init__.py +++ b/lunchable/plugins/splitlunch/__init__.py @@ -1,9 +1,3 @@ """ Splitwise Plugin for Lunchmoney """ - -from .exceptions import SplitLunchError -from .lunchmoney_splitwise import SplitLunch -from .models import SplitLunchExpense - -__all__ = ["SplitLunch", "SplitLunchError", "SplitLunchExpense"] diff --git a/lunchable/plugins/splitlunch/cli.py b/lunchable/plugins/splitlunch/cli.py new file mode 100644 index 00000000..9a703a26 --- /dev/null +++ b/lunchable/plugins/splitlunch/cli.py @@ -0,0 +1,211 @@ +import datetime +import logging +from typing import Optional, Union + +import click +from pydantic_core import to_jsonable_python +from rich import print_json + +logger = logging.getLogger(__name__) + + +@click.group +def splitlunch() -> None: + """ + Splitwise Plugin for lunchable, SplitLunch 💲🍱 + """ + pass + + +dated_after = click.option( + "--dated-after", + default=None, + help="ISO 8601 Date time. Return expenses later that this date", +) +dated_before = click.option( + "--dated-before", + default=None, + help="ISO 8601 Date time. Return expenses earlier than this date", +) + + +@splitlunch.command("expenses") +@click.option( + "--limit", default=None, help="Limit the amount of Results. 0 returns everything." +) +@click.option("--offset", default=None, help="Number of expenses to be skipped") +@click.option("--limit", default=None, help="Number of expenses to be returned") +@click.option("--group-id", default=None, help="GroupID of the expenses") +@click.option("--friendship-id", default=None, help="FriendshipID of the expenses") +@dated_after +@dated_before +@click.option( + "--updated-after", + default=None, + help="ISO 8601 Date time. Return expenses updated after this date", +) +@click.option( + "--updated-before", + default=None, + help="ISO 8601 Date time. Return expenses updated before this date", +) +def splitlunch_expenses(**kwargs: Union[int, str, bool]) -> None: + """ + Retrieve Splitwise Expenses + """ + from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch + + splitlunch = SplitLunch() + if set(kwargs.values()) == {None}: + kwargs["limit"] = 5 + expenses = splitlunch.get_expenses(**kwargs) # type: ignore[arg-type] + json_data = to_jsonable_python(expenses) + print_json(data=json_data) + + +tag_transactions = click.option( + "--tag-transactions", + is_flag=True, + help="Tag the resulting transactions with a `Splitwise` tag.", +) +financial_partner_id = click.option( + "--financial-partner-id", + default=None, + type=click.INT, + help="Splitwise ID of your financial partner.", +) +financial_partner_email = click.option( + "--financial-partner-email", + default=None, + help="Splitwise Email Address of your financial partner.", +) +financial_partner_group_id = click.option( + "--financial-partner-group-id", + default=None, + type=click.INT, + help="Splitwise Group ID for financial partner transactions.", +) + + +@splitlunch.command("splitlunch") +@tag_transactions +def make_splitlunch(**kwargs: Union[int, str, bool]) -> None: + """ + Split all `SplitLunch` tagged transactions in half. + + One of these new splits will be recategorized to `Reimbursement`. + """ + from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch + + splitlunch = SplitLunch() + results = splitlunch.make_splitlunch(**kwargs) # type: ignore[arg-type] + json_data = to_jsonable_python(results) + print_json(data=json_data) + + +@splitlunch.command("splitlunch-import") +@tag_transactions +@financial_partner_id +@financial_partner_email +@financial_partner_group_id +def make_splitlunch_import(**kwargs: Union[int, str, bool]) -> None: + """ + Import `SplitLunchImport` tagged transactions to Splitwise and Split them in Lunch Money + + Send a transaction to Splitwise and then split the original transaction in Lunch Money. + One of these new splits will be recategorized to `Reimbursement`. Any tags will be + reapplied. + """ + from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch + + financial_partner_id: Optional[int] = kwargs.pop("financial_partner_id") # type: ignore[assignment] + financial_partner_email: Optional[str] = kwargs.pop("financial_partner_email") # type: ignore[assignment] + financial_partner_group_id: Optional[int] = kwargs.pop("financial_partner_group_id") # type: ignore[assignment] + splitlunch = SplitLunch( + financial_partner_id=financial_partner_id, + financial_partner_email=financial_partner_email, + financial_partner_group_id=financial_partner_group_id, + ) + results = splitlunch.make_splitlunch_import(**kwargs) # type: ignore[arg-type] + json_data = to_jsonable_python(results) + print_json(data=json_data) + + +@splitlunch.command("splitlunch-direct-import") +@tag_transactions +@financial_partner_id +@financial_partner_email +@financial_partner_group_id +def make_splitlunch_direct_import(**kwargs: Union[int, str, bool]) -> None: + """ + Import `SplitLunchDirectImport` tagged transactions to Splitwise and Split them in Lunch Money + + Send a transaction to Splitwise and then split the original transaction in Lunch Money. + One of these new splits will be recategorized to `Reimbursement`. Any tags will be + reapplied. + """ + from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch + + financial_partner_id: Optional[int] = kwargs.pop("financial_partner_id") # type: ignore[assignment] + financial_partner_email: Optional[str] = kwargs.pop("financial_partner_email") # type: ignore[assignment] + financial_partner_group_id: Optional[int] = kwargs.pop("financial_partner_group_id") # type: ignore[assignment] + splitlunch = SplitLunch( + financial_partner_id=financial_partner_id, + financial_partner_email=financial_partner_email, + financial_partner_group_id=financial_partner_group_id, + ) + results = splitlunch.make_splitlunch_direct_import(**kwargs) # type: ignore[arg-type] + json_data = to_jsonable_python(results) + print_json(data=json_data) + + +@splitlunch.command("update-balance") +def update_splitwise_balance() -> None: + """ + Update the Splitwise Asset Balance + """ + from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch + + splitlunch = SplitLunch() + updated_asset = splitlunch.update_splitwise_balance() + json_data = to_jsonable_python(updated_asset) + print_json(data=json_data) + + +@splitlunch.command("refresh") +@dated_after +@dated_before +@click.option( + "--allow-self-paid/--no-allow-self-paid", + default=False, + help="Allow self-paid expenses to be imported (filtered out by default).", +) +@click.option( + "--allow-payments/--no-allow-payments", + default=False, + help="Allow payments to be imported (filtered out by default).", +) +def refresh_splitwise_transactions( + dated_before: Optional[datetime.datetime], + dated_after: Optional[datetime.datetime], + allow_self_paid: bool, + allow_payments: bool, +) -> None: + """ + Import New Splitwise Transactions to Lunch Money and + + This function gets all transactions from Splitwise, all transactions from + your Lunch Money Splitwise account and compares the two. This also updates + the account balance. + """ + import lunchable.plugins.splitlunch.lunchmoney_splitwise + + splitlunch = lunchable.plugins.splitlunch.lunchmoney_splitwise.SplitLunch() + response = splitlunch.refresh_splitwise_transactions( + dated_before=dated_before, + dated_after=dated_after, + allow_self_paid=allow_self_paid, + allow_payments=allow_payments, + ) + json_data = to_jsonable_python(response) + print_json(data=json_data) diff --git a/mkdocs.yaml b/mkdocs.yaml index 7916f84b..6a8276a0 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -5,7 +5,7 @@ nav: - Home 🏠: index.md - Usage 📖: usage.md - LunchMoney 🍽️: interacting.md -- Plugins 🧩: +- Apps and Plugins 🧩: - plugins/index.md - PushLunch 📲: plugins/pushlunch.md - SplitLunch 🍱: plugins/splitlunch.md diff --git a/pyproject.toml b/pyproject.toml index 2b6d180f..782e22fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,9 @@ dependencies = [ "click>=8.0.1", "pydantic>=2,<3", "rich>=10.0.0", - "httpx" + "httpx", + "importlib_metadata>=3.6", + "click-plugins>=1.1.1" ] description = "A simple Python SDK around the Lunch Money Developer API" dynamic = ["version"] @@ -33,14 +35,21 @@ name = "lunchable" readme = "README.md" requires-python = ">=3.8,<4" +[project.entry-points."lunchable.cli"] +primelunch = "lunchable.plugins.primelunch.cli:primelunch" +pushlunch = "lunchable.plugins.pushlunch.cli:pushlunch" +splitlunch = "lunchable.plugins.splitlunch.cli:splitlunch" + [project.optional-dependencies] all = [ "pandas", "python-dateutil", "splitwise>=2.3.0,<3.0.0" ] -apps = [ - "pandas" +plugins = [ + "pandas", + "python-dateutil", + "splitwise>=2.3.0,<3.0.0" ] primelunch = [ "pandas" diff --git a/requirements.txt b/requirements.txt index a078801f..9f277e65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ # # This file is autogenerated by hatch-pip-compile with Python 3.11 # +# - click-plugins>=1.1.1 # - click>=8.0.1 # - httpx +# - importlib-metadata>=3.6 # - pydantic<3,>=2 # - rich>=10.0.0 # - pandas @@ -22,6 +24,10 @@ certifi==2023.11.17 charset-normalizer==3.3.2 # via requests click==8.1.7 + # via + # hatch.envs.default + # click-plugins +click-plugins==1.1.1 # via hatch.envs.default h11==0.14.0 # via httpcore @@ -34,6 +40,8 @@ idna==3.6 # anyio # httpx # requests +importlib-metadata==7.0.1 + # via hatch.envs.default markdown-it-py==3.0.0 # via rich mdurl==0.1.2 @@ -80,3 +88,5 @@ tzdata==2023.3 # via pandas urllib3==2.1.0 # via requests +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements/requirements-all.py3.10.txt b/requirements/requirements-all.py3.10.txt index 802e9e24..7a0fbe50 100644 --- a/requirements/requirements-all.py3.10.txt +++ b/requirements/requirements-all.py3.10.txt @@ -5,8 +5,10 @@ # - pytest-cov # - pytest-mock # - vcrpy~=5.1.0 +# - click-plugins>=1.1.1 # - click>=8.0.1 # - httpx +# - importlib-metadata>=3.6 # - pydantic<3,>=2 # - rich>=10.0.0 # - pandas @@ -26,6 +28,10 @@ certifi==2023.11.17 charset-normalizer==3.3.2 # via requests click==8.1.7 + # via + # hatch.envs.all.py3.10 + # click-plugins +click-plugins==1.1.1 # via hatch.envs.all.py3.10 coverage==7.3.3 # via @@ -47,6 +53,8 @@ idna==3.6 # httpx # requests # yarl +importlib-metadata==7.0.1 + # via hatch.envs.all.py3.10 iniconfig==2.0.0 # via pytest markdown-it-py==3.0.0 @@ -122,3 +130,5 @@ wrapt==1.16.0 # via vcrpy yarl==1.9.4 # via vcrpy +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements/requirements-all.py3.11.txt b/requirements/requirements-all.py3.11.txt index 5c01b02e..7b70629d 100644 --- a/requirements/requirements-all.py3.11.txt +++ b/requirements/requirements-all.py3.11.txt @@ -5,8 +5,10 @@ # - pytest-cov # - pytest-mock # - vcrpy~=5.1.0 +# - click-plugins>=1.1.1 # - click>=8.0.1 # - httpx +# - importlib-metadata>=3.6 # - pydantic<3,>=2 # - rich>=10.0.0 # - pandas @@ -26,6 +28,10 @@ certifi==2023.11.17 charset-normalizer==3.3.2 # via requests click==8.1.7 + # via + # hatch.envs.all.py3.11 + # click-plugins +click-plugins==1.1.1 # via hatch.envs.all.py3.11 coverage==7.3.3 # via @@ -43,6 +49,8 @@ idna==3.6 # httpx # requests # yarl +importlib-metadata==7.0.1 + # via hatch.envs.all.py3.11 iniconfig==2.0.0 # via pytest markdown-it-py==3.0.0 @@ -114,3 +122,5 @@ wrapt==1.16.0 # via vcrpy yarl==1.9.4 # via vcrpy +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements/requirements-all.py3.12.txt b/requirements/requirements-all.py3.12.txt index ae42a258..bcda3aa7 100644 --- a/requirements/requirements-all.py3.12.txt +++ b/requirements/requirements-all.py3.12.txt @@ -5,8 +5,10 @@ # - pytest-cov # - pytest-mock # - vcrpy~=5.1.0 +# - click-plugins>=1.1.1 # - click>=8.0.1 # - httpx +# - importlib-metadata>=3.6 # - pydantic<3,>=2 # - rich>=10.0.0 # - pandas @@ -26,6 +28,10 @@ certifi==2023.11.17 charset-normalizer==3.3.2 # via requests click==8.1.7 + # via + # hatch.envs.all.py3.12 + # click-plugins +click-plugins==1.1.1 # via hatch.envs.all.py3.12 coverage==7.3.3 # via @@ -43,6 +49,8 @@ idna==3.6 # httpx # requests # yarl +importlib-metadata==7.0.1 + # via hatch.envs.all.py3.12 iniconfig==2.0.0 # via pytest markdown-it-py==3.0.0 @@ -114,3 +122,5 @@ wrapt==1.16.0 # via vcrpy yarl==1.9.4 # via vcrpy +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements/requirements-all.py3.8.txt b/requirements/requirements-all.py3.8.txt index 897e2dfb..57802fdf 100644 --- a/requirements/requirements-all.py3.8.txt +++ b/requirements/requirements-all.py3.8.txt @@ -5,8 +5,10 @@ # - pytest-cov # - pytest-mock # - vcrpy~=5.1.0 +# - click-plugins>=1.1.1 # - click>=8.0.1 # - httpx +# - importlib-metadata>=3.6 # - pydantic<3,>=2 # - rich>=10.0.0 # - pandas @@ -26,6 +28,10 @@ certifi==2023.11.17 charset-normalizer==3.3.2 # via requests click==8.1.7 + # via + # hatch.envs.all.py3.8 + # click-plugins +click-plugins==1.1.1 # via hatch.envs.all.py3.8 coverage==7.3.3 # via @@ -47,6 +53,8 @@ idna==3.6 # httpx # requests # yarl +importlib-metadata==7.0.1 + # via hatch.envs.all.py3.8 iniconfig==2.0.0 # via pytest markdown-it-py==3.0.0 @@ -126,3 +134,5 @@ wrapt==1.16.0 # via vcrpy yarl==1.9.4 # via vcrpy +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements/requirements-all.py3.9.txt b/requirements/requirements-all.py3.9.txt index 4b482602..c3c72d9a 100644 --- a/requirements/requirements-all.py3.9.txt +++ b/requirements/requirements-all.py3.9.txt @@ -5,8 +5,10 @@ # - pytest-cov # - pytest-mock # - vcrpy~=5.1.0 +# - click-plugins>=1.1.1 # - click>=8.0.1 # - httpx +# - importlib-metadata>=3.6 # - pydantic<3,>=2 # - rich>=10.0.0 # - pandas @@ -26,6 +28,10 @@ certifi==2023.11.17 charset-normalizer==3.3.2 # via requests click==8.1.7 + # via + # hatch.envs.all.py3.9 + # click-plugins +click-plugins==1.1.1 # via hatch.envs.all.py3.9 coverage==7.3.3 # via @@ -47,6 +53,8 @@ idna==3.6 # httpx # requests # yarl +importlib-metadata==7.0.1 + # via hatch.envs.all.py3.9 iniconfig==2.0.0 # via pytest markdown-it-py==3.0.0 @@ -124,3 +132,5 @@ wrapt==1.16.0 # via vcrpy yarl==1.9.4 # via vcrpy +zipp==3.17.0 + # via importlib-metadata diff --git a/requirements/requirements-docs.txt b/requirements/requirements-docs.txt index ec4784ae..16c5b63e 100644 --- a/requirements/requirements-docs.txt +++ b/requirements/requirements-docs.txt @@ -1,7 +1,7 @@ # # This file is autogenerated by hatch-pip-compile with Python 3.11 # -# [constraints] requirements.txt (SHA256: 1d7f43c7c1ecbc074ece84ddfddf590007473fce44e813c9dca4481292391f67) +# [constraints] requirements.txt (SHA256: fab1a8fdd5601fab426b875234fb9b2fa894f0e59667df5182eb30bc9d261f69) # # - markdown-callouts # - markdown-exec @@ -16,8 +16,10 @@ # - mkdocstrings-python # - pymdown-extensions # - griffe-fieldz +# - click-plugins>=1.1.1 # - click>=8.0.1 # - httpx +# - importlib-metadata>=3.6 # - pydantic<3,>=2 # - rich>=10.0.0 # - pandas @@ -49,9 +51,14 @@ click==8.1.7 # via # -c requirements.txt # hatch.envs.docs + # click-plugins # mkdocs # mkdocs-click # mkdocstrings +click-plugins==1.1.1 + # via + # -c requirements.txt + # hatch.envs.docs colorama==0.4.6 # via # griffe @@ -84,6 +91,10 @@ idna==3.6 # anyio # httpx # requests +importlib-metadata==7.0.1 + # via + # -c requirements.txt + # hatch.envs.docs jinja2==3.1.2 # via # mkdocs @@ -251,3 +262,7 @@ urllib3==2.1.0 # requests watchdog==3.0.0 # via mkdocs +zipp==3.17.0 + # via + # -c requirements.txt + # importlib-metadata diff --git a/requirements/requirements-lint.txt b/requirements/requirements-lint.txt index b37755ed..f8acc070 100644 --- a/requirements/requirements-lint.txt +++ b/requirements/requirements-lint.txt @@ -1,7 +1,7 @@ # # This file is autogenerated by hatch-pip-compile with Python 3.11 # -# [constraints] requirements.txt (SHA256: 1d7f43c7c1ecbc074ece84ddfddf590007473fce44e813c9dca4481292391f67) +# [constraints] requirements.txt (SHA256: fab1a8fdd5601fab426b875234fb9b2fa894f0e59667df5182eb30bc9d261f69) # # - mypy>=1.6.1 # - ruff~=0.1.7 @@ -12,7 +12,7 @@ annotated-types==0.6.0 # via # -c requirements.txt # pydantic -mypy==1.7.1 +mypy==1.8.0 # via hatch.envs.lint mypy-extensions==1.0.0 # via mypy @@ -24,7 +24,7 @@ pydantic-core==2.14.5 # via # -c requirements.txt # pydantic -ruff==0.1.8 +ruff==0.1.14 # via hatch.envs.lint typing-extensions==4.9.0 # via diff --git a/requirements/requirements-test.txt b/requirements/requirements-test.txt index 02d30c06..29c8e88c 100644 --- a/requirements/requirements-test.txt +++ b/requirements/requirements-test.txt @@ -1,14 +1,16 @@ # # This file is autogenerated by hatch-pip-compile with Python 3.11 # -# [constraints] requirements.txt (SHA256: 1d7f43c7c1ecbc074ece84ddfddf590007473fce44e813c9dca4481292391f67) +# [constraints] requirements.txt (SHA256: fab1a8fdd5601fab426b875234fb9b2fa894f0e59667df5182eb30bc9d261f69) # # - pytest # - pytest-cov # - pytest-mock # - vcrpy~=5.1.0 +# - click-plugins>=1.1.1 # - click>=8.0.1 # - httpx +# - importlib-metadata>=3.6 # - pydantic<3,>=2 # - rich>=10.0.0 # - pandas @@ -38,6 +40,11 @@ click==8.1.7 # via # -c requirements.txt # hatch.envs.test + # click-plugins +click-plugins==1.1.1 + # via + # -c requirements.txt + # hatch.envs.test coverage==7.3.3 # via # coverage @@ -61,6 +68,10 @@ idna==3.6 # httpx # requests # yarl +importlib-metadata==7.0.1 + # via + # -c requirements.txt + # hatch.envs.test iniconfig==2.0.0 # via pytest markdown-it-py==3.0.0 @@ -166,3 +177,7 @@ wrapt==1.16.0 # via vcrpy yarl==1.9.4 # via vcrpy +zipp==3.17.0 + # via + # -c requirements.txt + # importlib-metadata diff --git a/tests/plugins/pushlunch/test_pushlunch.py b/tests/plugins/pushlunch/test_pushlunch.py index d11dd697..6d3671b6 100644 --- a/tests/plugins/pushlunch/test_pushlunch.py +++ b/tests/plugins/pushlunch/test_pushlunch.py @@ -6,7 +6,7 @@ from typing import List from lunchable.models import TransactionObject -from lunchable.plugins.pushlunch import PushLunch +from lunchable.plugins.pushlunch.pushover import PushLunch from tests.conftest import lunchable_cassette logger = logging.getLogger(__name__) diff --git a/tests/plugins/splitlunch/test_splitwise.py b/tests/plugins/splitlunch/test_splitwise.py index 988b705b..457bd9d7 100644 --- a/tests/plugins/splitlunch/test_splitwise.py +++ b/tests/plugins/splitlunch/test_splitwise.py @@ -20,7 +20,7 @@ def test_import_splitwise(): """ test_case = True try: - from lunchable.plugins.splitlunch import SplitLunch # noqa + from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch # noqa except ImportError: test_case = False finally: @@ -33,7 +33,7 @@ def test_update_balance(): """ Update the Balance """ - from lunchable.plugins.splitlunch import SplitLunch + from lunchable.plugins.splitlunch.lunchmoney_splitwise import SplitLunch lunch = SplitLunch() lunch.update_splitwise_balance()