-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add command line interface with basic commands.
- Loading branch information
Showing
4 changed files
with
318 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters