Skip to content

Commit

Permalink
feat: Add command line interface with basic commands.
Browse files Browse the repository at this point in the history
  • Loading branch information
bvanelli committed Aug 29, 2024
1 parent 0762863 commit bd70c3b
Show file tree
Hide file tree
Showing 4 changed files with 318 additions and 0 deletions.
71 changes: 71 additions & 0 deletions actual/cli/config.py
Original file line number Diff line number Diff line change
@@ -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,
)
236 changes: 236 additions & 0 deletions actual/cli/main.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
""",
)

0 comments on commit bd70c3b

Please sign in to comment.