diff --git a/actual/cli/config.py b/actual/cli/config.py new file mode 100644 index 0000000..1b5d5b1 --- /dev/null +++ b/actual/cli/config.py @@ -0,0 +1,71 @@ +import os +from enum import Enum +from pathlib import Path +from typing import Optional + +import pydantic +import yaml +from rich.console import Console + +from actual import Actual + +console = Console() + +CONFIG_PATH = Path.home() / ".actual" / "config.yaml" + + +class OutputType(Enum): + table = "table" + json = "json" + + +class State(pydantic.BaseModel): + output: OutputType = pydantic.Field("table", alias="defaultOutput", description="Default output for CLI.") + + +class BudgetConfig(pydantic.BaseModel): + url: str = pydantic.Field(..., description="") + password: str = pydantic.Field(..., description="") + file_id: str = pydantic.Field(..., alias="fileId") + encryption_password: Optional[str] = pydantic.Field(None, alias="encryptionPassword") + + model_config = pydantic.ConfigDict(populate_by_name=True) + + +class Config(pydantic.BaseModel): + default_context: str = pydantic.Field("", alias="defaultContext", description="Default budget context for CLI.") + budgets: dict[str, BudgetConfig] = pydantic.Field( + default_factory=dict, description="Dict of configured budgets on CLI." + ) + + def save(self): + """Saves the current configuration to a file.""" + os.makedirs(CONFIG_PATH.parent, exist_ok=True) + with open(CONFIG_PATH, "w") as file: + yaml.dump(self.model_dump(by_alias=True), file) + + @classmethod + def load(cls): + """Load the configuration file. If it doesn't exist, create a basic config.""" + if not CONFIG_PATH.exists(): + console.print("[yellow]Config file not found! Creating a new one...[/yellow]") + # Create a basic config with default values + default_config = cls() + default_config.save() + return default_config + else: + with open(CONFIG_PATH, "r") as file: + config = yaml.safe_load(file) + return cls.model_validate(config) + + def actual(self) -> Actual: + context = self.default_context + budget_config = self.budgets.get(context) + if not budget_config: + raise ValueError(f"Could not find budget with context '{context}'") + return Actual( + budget_config.url, + password=budget_config.password, + file=budget_config.file_id, + encryption_password=budget_config.encryption_password, + ) diff --git a/actual/cli/main.py b/actual/cli/main.py new file mode 100644 index 0000000..75eb1f6 --- /dev/null +++ b/actual/cli/main.py @@ -0,0 +1,236 @@ +import pathlib +from typing import Optional + +import typer +from rich.console import Console +from rich.table import Table + +from actual import Actual, get_accounts, get_transactions +from actual.cli.config import BudgetConfig, Config, OutputType, State +from actual.queries import get_payees +from actual.version import __version__ + +app = typer.Typer() + +console = Console() +config: Config = Config.load() +state: State = State() + + +@app.callback() +def main(output: OutputType = typer.Option("table", "--output", "-o", help="Output format: table or json")): + if output: + state.output = output + + +@app.command() +def init( + url: str = typer.Option(None, "--url", help="URL of the actual server"), + password: str = typer.Option(None, "--password", help="Password for the budget"), + encryption_password: str = typer.Option(None, "--encryption-password", help="Encryption password for the budget"), + context: str = typer.Option(None, "--context", help="Context for this budget context"), + file_id: str = typer.Option(None, "--file", help="File ID or name on the remote server"), +): + """ + Initializes an actual budget config interactively if options are not provided. + """ + if not url: + url = typer.prompt("Please enter the URL of the actual server", default="http://localhost:5006") + + if not password: + password = typer.prompt("Please enter the Actual server password.", hide_input=True) + + # test the login + server = Actual(url, password=password) + + if not file_id: + files = server.list_user_files() + options = [file for file in files.data if not file.deleted] + for idx, option in enumerate(options): + console.print(f"[purple]({idx + 1}) {option.name}[/purple]") + file_id_idx = typer.prompt("Please enter the budget index", type=int) + assert file_id_idx - 1 in range(len(options)), "Did not select one of the options, exiting." + server.set_file(options[file_id_idx - 1]) + else: + server.set_file(file_id) + file_id = server._file.file_id + + if not encryption_password and server._file.encrypt_key_id: + encryption_password = typer.prompt("Please enter the encryption password for the budget", hide_input=True) + # test the file + server.download_budget(encryption_password) + else: + encryption_password = None + + if not context: + # take the default context name as the file name in lowercase + default_context = server._file.name.lower() + context = typer.prompt("Name of the context for this budget", default=default_context) + + config.budgets[context] = BudgetConfig( + url=url, + password=password, + encryption_password=encryption_password, + file_id=file_id, + ) + if not config.default_context: + config.default_context = context + config.save() + console.print(f"[green]Initialized budget '{context}'[/green]") + + +@app.command() +def set_context(context: str = typer.Argument(..., help="Context for this budget context")): + """Sets the default context for the CLI.""" + if context not in config.budgets: + raise ValueError(f"Context '{context}' was not registered.") + config.default_context = context + config.save() + + +@app.command() +def version(): + """ + Shows the library and server version. + """ + actual = config.actual() + info = actual.info() + if state.output == OutputType.table: + console.print(f"Library Version: {__version__}") + console.print(f"Server Version: {info.build.version}") + else: + console.print({"library_version": __version__, "server_version": info.build.version}) + + +@app.command() +def accounts(): + """ + Show all accounts. + """ + # Mock data for demonstration purposes + accounts_data = [] + with config.actual() as actual: + accounts_raw_data = get_accounts(actual.session) + for account in accounts_raw_data: + accounts_data.append( + { + "name": account.name, + "balance": float(account.balance), + } + ) + + if state.output == OutputType.table: + table = Table(title="Accounts") + table.add_column("Account Name", justify="left", style="cyan", no_wrap=True) + table.add_column("Balance", justify="right", style="green") + + for account in accounts_data: + table.add_row(account["name"], f"{account['balance']:.2f}") + + console.print(table) + else: + typer.echo(accounts_data) + + +@app.command() +def transactions(): + """ + Show all transactions. + """ + transactions_data = [] + with config.actual() as actual: + transactions_raw_data = get_transactions(actual.session) + for transaction in transactions_raw_data: + transactions_data.append( + { + "date": transaction.get_date().isoformat(), + "payee": transaction.payee.name, + "notes": transaction.notes or "", + "category": (transaction.category.name if transaction.category else None), + "amount": transaction.get_amount(), + } + ) + + if state.output == OutputType.table: + table = Table(title="Transactions") + table.add_column("Date", justify="left", style="cyan", no_wrap=True) + table.add_column("Payee", justify="left", style="magenta") + table.add_column("Notes", justify="left", style="yellow") + table.add_column("Category", justify="left", style="cyan") + table.add_column("Amount", justify="right", style="green") + + for transaction in transactions_data: + color = "green" if transaction["amount"] >= 0 else "red" + table.add_row( + transaction["date"], + transaction["payee"], + transaction["notes"], + transaction["category"], + f"[{color}]{transaction['amount']:.2f}[/]", + ) + + console.print(table) + else: + typer.echo(transactions_data) + + +@app.command() +def payees(): + """ + Show all payees. + """ + payees_data = [] + with config.actual() as actual: + payees_raw_data = get_payees(actual.session) + for payee in payees_raw_data: + payees_data.append({"name": payee.name, "balance": payee.balance}) + + if state.output == "table": + table = Table(title="Payees") + table.add_column("Name", justify="left", style="cyan", no_wrap=True) + table.add_column("Balance", justify="right") + + for payee in payees_data: + color = "green" if payee["balance"] >= 0 else "red" + table.add_row( + payee["name"], + f"[{color}]{payee['balance']:.2f}[/]", + ) + console.print(table) + else: + typer.echo(payees_data) + + +@app.command() +def export( + filename: Optional[pathlib.Path] = typer.Argument(help="Name of the file to export, in zip format."), +): + """ + Generates an export from the budget (for CLI backups). + """ + with config.actual() as actual: + actual.export_data(filename) + actual_metadata = actual.get_metadata() + budget_name = actual_metadata["budgetName"] + budget_id = actual_metadata["id"] + console.print(f"[green]Exported budget '{budget_name}' (budget id '{budget_id}') to {filename}.[/green]") + + +@app.command() +def metadata(): + """Displays all metadata for the current budget.""" + with config.actual() as actual: + actual_metadata = actual.get_metadata() + if state.output == OutputType.table: + table = Table(title="Metadata") + table.add_column("Key", justify="left", style="cyan", no_wrap=True) + table.add_column("Value", justify="left") + for key, value in actual_metadata.items(): + table.add_row(key, str(value)) + console.print(table) + else: + typer.echo(actual_metadata) + + +if __name__ == "__main__": + app() diff --git a/requirements.txt b/requirements.txt index eb91ac1..e39195a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,7 @@ proto-plus>=1 protobuf>=4 cryptography>=42 python-dateutil>=2.9.0 +# for the cli +rich>=13 +typer>=0.12.0 +pyyaml>=6.0 diff --git a/setup.py b/setup.py index 5814d9d..2de6b9e 100644 --- a/setup.py +++ b/setup.py @@ -17,4 +17,11 @@ "Issues": "https://github.com/bvanelli/actualpy/issues", }, install_requires=["cryptography", "proto-plus", "python-dateutil", "requests", "sqlmodel"], + extras_require={ + "cli": ["rich", "typer", "pyyaml"], + }, + entry_points=""" + [console_scripts] + actual=actual.cli.main:app + """, )