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 5, 2024
1 parent c104a32 commit cfcf32a
Show file tree
Hide file tree
Showing 21 changed files with 1,126 additions and 31 deletions.
52 changes: 40 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,
output_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(output_format, leading_nl=config.logging_enabled))


@app.command("dividends")
def dividends_command(
files: FilesArg,
tax_year: TaxYearOpt = None,
ticker: TickerOpt = None,
output_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(output_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,
output_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(output_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,
output_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(output_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,20 @@ def capital_gains_command(
] = False,
tax_year: TaxYearOpt = None,
ticker: TickerOpt = None,
output_format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show capital gains report.
"""
if gains_only and losses_only:
raise MutuallyExclusiveOption("--gains", "--losses")

if output_format != OutputFormat.TEXT and tax_year is None:
raise click.exceptions.UsageError(
f"The {output_format.value} format requires the option "
f"--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 +375,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 output_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(output_format))
print(summary)
else:
print(table.to_string(output_format, leading_nl=False))


@app.command("holdings")
Expand All @@ -367,14 +394,15 @@ def holdings_command(
show_gain_loss: Annotated[
bool, typer.Option("--show-gain-loss", help="Show unrealised gain/loss.")
] = False,
output_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(output_format, leading_nl=config.logging_enabled))


def main() -> None:
Expand Down
59 changes: 40 additions & 19 deletions src/investir/prettytable.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from collections.abc import Callable
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 All @@ -31,12 +39,41 @@ def _decimal_format(_field, val) -> str:

class PrettyTable(prettytable.PrettyTable):
def __init__(self, *args, **kwargs) -> None:
kwargs["field_names"] = list(map(lambda f: boldify(f), kwargs["field_names"]))

super().__init__(*args, **kwargs)
self.hrules = prettytable.HEADER
self.vrules = prettytable.NONE
self.invisible_fields: set[str] = set()

def hide_field(self, field_name: str) -> None:
self.invisible_fields.add(field_name)

def to_string(self, output_format: OutputFormat, leading_nl: bool = True) -> str:
self._apply_formatting(output_format == OutputFormat.TEXT)

leading = "\n" if leading_nl else ""
trailing = "\n" if output_format == OutputFormat.TEXT else ""

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

kwargs: dict[str, Any] = {"fields": fields}
if output_format == OutputFormat.JSON:
kwargs["default"] = str

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

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

return f"{leading}{table_str}{trailing}"

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)
plain_f = unboldify(f) if bold_titles else f

match plain_f.split()[0].strip(), plain_f.split()[-1]:
case ("Date", _) | (_, "Date"):
Expand All @@ -53,21 +90,5 @@ def __init__(self, *args, **kwargs) -> None:
case _:
self.align[f] = "l"

self.hrules = prettytable.HEADER
self.vrules = prettytable.NONE
self.invisible_fields: set[str] = set()

def hide_field(self, field_name: str) -> None:
self.invisible_fields.add(field_name)

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

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

return f"{nl}{self.get_string(fields=fields)}\n"

def __bool__(self) -> bool:
return len(self.rows) > 0
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 @@ -210,6 +235,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 cfcf32a

Please sign in to comment.