Skip to content

Commit

Permalink
Add support to export output to csv, json or HTML
Browse files Browse the repository at this point in the history
  • Loading branch information
tacgomes committed Dec 8, 2024
1 parent 99936db commit 60e44f7
Show file tree
Hide file tree
Showing 21 changed files with 1,104 additions and 36 deletions.
51 changes: 39 additions & 12 deletions src/investir/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from investir.logging import configure_logger
from investir.parser import ParserFactory
from investir.prettytable import OutputFormat
from investir.taxcalculator import TaxCalculator
from investir.transaction import Acquisition, Disposal, Transaction
from investir.trhistory import TrHistory
Expand Down Expand Up @@ -72,6 +73,12 @@ def __init__(self, opt1: str, opt2: str) -> None:
]


OutputFormatOpt = Annotated[
OutputFormat,
typer.Option("--output", "-o", help="Output format."),
]


def abort(message: str) -> None:
logger.critical(message)
raise typer.Exit(code=1)
Expand Down Expand Up @@ -230,7 +237,7 @@ def main_callback( # noqa: PLR0913


@app.command("orders")
def orders_command(
def orders_command( # noqa: PLR0913
files: FilesArg,
tax_year: TaxYearOpt = None,
ticker: TickerOpt = None,
Expand All @@ -240,6 +247,7 @@ def orders_command(
disposals_only: Annotated[
bool, typer.Option("--disposals", help="Show only disposals.")
] = False,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show share buy/sell orders.
Expand All @@ -257,22 +265,23 @@ def orders_command(

filters = create_filters(tax_year=tax_year, ticker=ticker, tr_type=tr_type)
if table := tr_hist.get_orders_table(filters):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


@app.command("dividends")
def dividends_command(
files: FilesArg,
tax_year: TaxYearOpt = None,
ticker: TickerOpt = None,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show share dividends paid out.
"""
tr_hist, _ = parse(files)
filters = create_filters(tax_year=tax_year, ticker=ticker)
if table := tr_hist.get_dividends_table(filters):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


@app.command("transfers")
Expand All @@ -285,6 +294,7 @@ def transfers_command(
withdrawals_only: Annotated[
bool, typer.Option("--withdrawals", help="Show only withdrawals.")
] = False,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show cash deposits and cash withdrawals.
Expand All @@ -303,22 +313,26 @@ def transfers_command(

filters = create_filters(tax_year=tax_year, amount_op=amount_op)
if table := tr_hist.get_transfers_table(filters):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


@app.command("interest")
def interest_command(files: FilesArg, tax_year: TaxYearOpt = None) -> None:
def interest_command(
files: FilesArg,
tax_year: TaxYearOpt = None,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show interest earned on cash.
"""
tr_hist, _ = parse(files)
filters = create_filters(tax_year=tax_year)
if table := tr_hist.get_interest_table(filters):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


@app.command("capital-gains")
def capital_gains_command(
def capital_gains_command( # noqa: PLR0913
files: FilesArg,
gains_only: Annotated[
bool, typer.Option("--gains", help="Show only capital gains.")
Expand All @@ -328,13 +342,19 @@ def capital_gains_command(
] = False,
tax_year: TaxYearOpt = None,
ticker: TickerOpt = None,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show capital gains report.
"""
if gains_only and losses_only:
raise MutuallyExclusiveOption("--gains", "--losses")

if format != OutputFormat.TEXT and tax_year is None:
raise click.exceptions.UsageError(
f"The {format.value} format requires the option --tax-year to be used"
)

_, tax_calculator = parse(files)
tax_year = Year(tax_year) if tax_year else None
ticker = Ticker(ticker) if ticker else None
Expand All @@ -354,10 +374,16 @@ def capital_gains_command(

if table:
print(end="\n" if tax_year_idx == 0 and config.logging_enabled else "")
print(boldify(f"Capital Gains Tax Report {tax_year_short_date(tax_year)}"))
print(tax_year_full_date(tax_year))
print(table.to_string(leading_nl=True))
print(summary)

if format == OutputFormat.TEXT:
print(
boldify(f"Capital Gains Tax Report {tax_year_short_date(tax_year)}")
)
print(tax_year_full_date(tax_year))
print(table.to_string(format))
print(summary)
else:
print(table.to_string(format, leading_nl=False))


@app.command("holdings")
Expand All @@ -367,14 +393,15 @@ def holdings_command(
show_gain_loss: Annotated[
bool, typer.Option("--show-gain-loss", help="Show unrealised gain/loss.")
] = False,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show current holdings.
"""
_, tax_calculator = parse(files)
ticker = Ticker(ticker) if ticker else None
if table := tax_calculator.get_holdings_table(ticker, show_gain_loss):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


def main() -> None:
Expand Down
75 changes: 51 additions & 24 deletions src/investir/prettytable.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from collections.abc import Callable, Sequence, Set
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Any

import prettytable

from investir.utils import boldify, unboldify


class OutputFormat(str, Enum):
TEXT = "text"
CSV = "csv"
JSON = "json"
HTML = "html"


def date_format(format: str) -> Callable[[str, Any], str]:
def _date_format(_field, val) -> str:
if isinstance(val, date):
Expand Down Expand Up @@ -37,27 +45,8 @@ def __init__(
show_total_fields: Sequence[str] | None = None,
**kwargs,
) -> None:
field_names = list(map(lambda f: boldify(f), field_names))
super().__init__(field_names, **kwargs)

for f in self.field_names:
plain_f = unboldify(f)

match plain_f.split()[0].strip(), plain_f.split()[-1]:
case ("Date", _) | (_, "Date"):
self.custom_format[f] = date_format("%d/%m/%Y")
self.align[f] = "l"
case ("Quantity", _):
self.custom_format[f] = decimal_format(8)
self.align[f] = "r"

case (_, "(£)") | (_, "(%)"):
self.custom_format[f] = decimal_format(2)
self.align[f] = "r"

case _:
self.align[f] = "l"

self.hrules = prettytable.HEADER
self.vrules = prettytable.NONE
self._hidden_fields: Set[str] = frozenset(hidden_fields or [])
Expand All @@ -66,17 +55,33 @@ def __init__(
def __bool__(self) -> bool:
return len(self.rows) > 0

def to_string(self, leading_nl: bool = True) -> str:
nl = "\n" if leading_nl else ""

if self.rows and self._show_total_fields:
def to_string(self, format: OutputFormat, leading_nl: bool = True) -> str:
if (
self.rows
and self._show_total_fields
and format in (OutputFormat.TEXT, OutputFormat.HTML)
):
self._insert_totals_row()

self._apply_formatting(bold_titles=format == OutputFormat.TEXT)

start_nl = "\n" if leading_nl else ""
end_nl = "\n" if format == OutputFormat.TEXT else ""

fields = [
f for f in self.field_names if unboldify(f) not in self._hidden_fields
]

return f"{nl}{self.get_string(fields=fields)}\n"
kwargs: dict[str, Any] = {"fields": fields}
if format == OutputFormat.JSON:
kwargs["default"] = str

table_str = self.get_formatted_string(format.value, **kwargs)

if format == OutputFormat.CSV:
table_str = table_str.rstrip()

return f"{start_nl}{table_str}{end_nl}"

def _insert_totals_row(self) -> None:
totals_row = []
Expand All @@ -96,3 +101,25 @@ def _insert_totals_row(self) -> None:
totals_row.append("")

self.add_row(totals_row)

def _apply_formatting(self, bold_titles: bool) -> None:
if bold_titles:
self.field_names = list(map(lambda f: boldify(f), self.field_names))

for f in self.field_names:
plain_f = unboldify(f) if bold_titles else f

match plain_f.split()[0].strip(), plain_f.split()[-1]:
case ("Date", _) | (_, "Date"):
self.custom_format[f] = date_format("%d/%m/%Y")
self.align[f] = "l"
case ("Quantity", _):
self.custom_format[f] = decimal_format(8)
self.align[f] = "r"

case (_, "(£)") | (_, "(%)"):
self.custom_format[f] = decimal_format(2)
self.align[f] = "r"

case _:
self.align[f] = "l"
32 changes: 32 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,31 @@ def _execute(
# Holdings
("holdings", "holdings"),
("holdings --ticker SWKS", "holdings_swks"),
# Output formats
("orders --output text", "orders"),
("orders --output csv", "orders_csv"),
("orders --output json", "orders_json"),
("orders --output html", "orders_html"),
("dividends --output text", "dividends"),
("dividends --output csv", "dividends_csv"),
("dividends --output json", "dividends_json"),
("dividends --output html", "dividends_html"),
("interest --output text", "interest"),
("interest --output csv", "interest_csv"),
("interest --output json", "interest_json"),
("interest --output html", "interest_html"),
("transfers --output text", "transfers"),
("transfers --output csv", "transfers_csv"),
("transfers --output json", "transfers_json"),
("transfers --output html", "transfers_html"),
("capital-gains --output text", "capital_gains"),
("capital-gains --tax-year 2022 --output csv", "capital_gains_csv"),
("capital-gains --tax-year 2022 --output json", "capital_gains_json"),
("capital-gains --tax-year 2022 --output html", "capital_gains_html"),
("holdings --output text", "holdings"),
("holdings --output csv", "holdings_csv"),
("holdings --output json", "holdings_json"),
("holdings --output html", "holdings_html"),
]


Expand Down Expand Up @@ -213,6 +238,13 @@ def test_capital_gains_command_mutually_exclusive_filters(execute):
assert result.exit_code != EX_OK


def test_capital_gains_command_tax_year_required(execute):
result = execute(["capital-gains", "--output", "json", DATA_FILE])
assert not result.stdout
assert "Usage:" in result.stderr
assert result.exit_code != EX_OK


def test_invocation_without_any_argument(execute):
result = execute([], global_opts=[])
assert not result.stderr
Expand Down
5 changes: 5 additions & 0 deletions tests/test_cli/capital_gains_csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Disposal Date,Identification,Security Name,ISIN,Quantity,Cost (£),Proceeds (£),Gain/loss (£)
2022-09-20,Section 104,Amazon,US0231351067,48.31896981,3493.12,4941.53,1448.41
2022-10-14,Section 104,Microsoft,US5949181045,1.32642,324.7926690016643403072609590,319.76,-5.0326690016643403072609590
2022-12-16,Section 104,Skyworks,US83088M1027,8.30000000,1094.155192117222311059989321,979.69,-114.465192117222311059989321
2023-03-03,Section 104,Skyworks,US83088M1027,2.10000000,277.2021570417068497862623581,312.95,35.7478429582931502137376419
56 changes: 56 additions & 0 deletions tests/test_cli/capital_gains_html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<table>
<thead>
<tr>
<th>Disposal Date</th>
<th>Identification</th>
<th>Security Name</th>
<th>ISIN</th>
<th>Quantity</th>
<th>Cost (£)</th>
<th>Proceeds (£)</th>
<th>Gain/loss (£)</th>
</tr>
</thead>
<tbody>
<tr>
<td>20/09/2022</td>
<td>Section 104</td>
<td>Amazon</td>
<td>US0231351067</td>
<td>48.31896981</td>
<td>3493.12</td>
<td>4941.53</td>
<td>1448.41</td>
</tr>
<tr>
<td>14/10/2022</td>
<td>Section 104</td>
<td>Microsoft</td>
<td>US5949181045</td>
<td>1.32642000</td>
<td>324.79</td>
<td>319.76</td>
<td>-5.03</td>
</tr>
<tr>
<td>16/12/2022</td>
<td>Section 104</td>
<td>Skyworks</td>
<td>US83088M1027</td>
<td>8.30000000</td>
<td>1094.16</td>
<td>979.69</td>
<td>-114.47</td>
</tr>
<tr>
<td>03/03/2023</td>
<td>Section 104</td>
<td>Skyworks</td>
<td>US83088M1027</td>
<td>2.10000000</td>
<td>277.20</td>
<td>312.95</td>
<td>35.75</td>
</tr>
</tbody>
</table>
Loading

0 comments on commit 60e44f7

Please sign in to comment.