Skip to content

Commit b2804b3

Browse files
author
bosd
committed
feat(exporter): Add progress bar and rich panels to export command
Use rich.progress to display a progress bar during the export process, and rich.panel to display success and error messages, providing better feedback to the user.
1 parent 7831d61 commit b2804b3

File tree

4 files changed

+96
-21
lines changed

4 files changed

+96
-21
lines changed

src/odoo_data_flow/export_threaded.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import csv
88
import sys
99
from time import time
10-
from typing import Any, Optional
10+
from typing import Any, Optional, overload
1111

1212
from .lib import conf_lib
1313
from .lib.internal.rpc_thread import RpcThread
@@ -82,6 +82,36 @@ def get_data(self) -> list[list[Any]]:
8282
return all_data
8383

8484

85+
@overload
86+
def export_data(
87+
config_file: str,
88+
model: str,
89+
domain: list[Any],
90+
header: list[str],
91+
context: Optional[dict[str, Any]],
92+
output: str,
93+
max_connection: int,
94+
batch_size: int,
95+
separator: str,
96+
encoding: str,
97+
) -> tuple[bool, str]: ...
98+
99+
100+
@overload
101+
def export_data(
102+
config_file: str,
103+
model: str,
104+
domain: list[Any],
105+
header: list[str],
106+
context: Optional[dict[str, Any]],
107+
output: None,
108+
max_connection: int,
109+
batch_size: int,
110+
separator: str,
111+
encoding: str,
112+
) -> tuple[list[str], Optional[list[list[Any]]]]: ...
113+
114+
85115
def export_data(
86116
config_file: str,
87117
model: str,
@@ -93,7 +123,7 @@ def export_data(
93123
batch_size: int = 100,
94124
separator: str = ";",
95125
encoding: str = "utf-8",
96-
) -> tuple[Optional[list[str]], Optional[list[list[Any]]]]:
126+
) -> Any:
97127
"""Export Data.
98128
99129
The main function for exporting data. It can either write to a file or
@@ -103,11 +133,12 @@ def export_data(
103133
connection = conf_lib.get_connection_from_config(config_file)
104134
model_obj = connection.get_model(model)
105135
except Exception as e:
106-
log.error(
136+
message = (
107137
f"Failed to connect to Odoo or get model '{model}'. "
108138
f"Please check your configuration. Error: {e}"
109139
)
110-
return None, None
140+
log.error(message)
141+
return (False, message) if output else (None, None)
111142

112143
rpc_thread = RPCThreadExport(max_connection, model_obj, header, context)
113144
start_time = time()
@@ -139,9 +170,11 @@ def export_data(
139170
writer.writerow(header)
140171
writer.writerows(all_exported_data)
141172
log.info("File writing complete.")
173+
return True, "Export complete."
142174
except OSError as e:
143-
log.error(f"Failed to write to output file {output}: {e}")
144-
return None, None
175+
message = f"Failed to write to output file {output}: {e}"
176+
log.error(message)
177+
return False, message
145178
else:
146179
log.info("Returning exported data in-memory.")
147180
return header, all_exported_data

src/odoo_data_flow/exporter.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@
33
import ast
44
from typing import Any, Optional
55

6+
from rich.console import Console
7+
from rich.panel import Panel
8+
69
from . import export_threaded
710
from .logging_config import log
811

912

13+
def _show_error_panel(title: str, message: str) -> None:
14+
"""Displays a formatted error panel to the console."""
15+
console = Console(stderr=True, style="bold red")
16+
console.print(Panel(message, title=title, border_style="red"))
17+
18+
1019
def run_export(
1120
config: str,
1221
filename: str,
@@ -32,16 +41,21 @@ def run_export(
3241
if not isinstance(parsed_domain, list):
3342
raise TypeError("Domain must be a list of tuples.")
3443
except Exception as e:
35-
log.error(f"Invalid domain provided. Must be a valid Python list string. {e}")
44+
_show_error_panel(
45+
"Invalid Domain",
46+
f"The --domain argument must be a valid Python list string.\nError: {e}",
47+
)
3648
return
3749

3850
try:
3951
parsed_context = ast.literal_eval(context)
4052
if not isinstance(parsed_context, dict):
4153
raise TypeError("Context must be a dictionary.")
4254
except Exception as e:
43-
log.error(
44-
f"Invalid context provided. Must be a valid Python dictionary string. {e}"
55+
_show_error_panel(
56+
"Invalid Context",
57+
"The --context argument must be a valid Python dictionary string."
58+
f"\nError: {e}",
4559
)
4660
return
4761

@@ -53,7 +67,7 @@ def run_export(
5367
log.info(f"Workers: {worker}, Batch Size: {batch_size}")
5468

5569
# Call the core export function with an output filename
56-
export_threaded.export_data(
70+
success, message = export_threaded.export_data(
5771
config,
5872
model,
5973
parsed_domain,
@@ -66,7 +80,18 @@ def run_export(
6680
encoding=encoding,
6781
)
6882

69-
log.info("Export process finished.")
83+
console = Console()
84+
if success:
85+
console.print(
86+
Panel(
87+
f"Export process for model [bold cyan]{model}[/bold cyan] "
88+
f"finished successfully.",
89+
title="[bold green]Export Complete[/bold green]",
90+
border_style="green",
91+
)
92+
)
93+
else:
94+
_show_error_panel("Export Aborted", message)
7095

7196

7297
def run_export_for_migration(

src/odoo_data_flow/lib/internal/rpc_thread.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
import concurrent.futures
88
from typing import Any, Callable, Optional
99

10-
from rich.progress import Progress
10+
from rich.progress import (
11+
BarColumn,
12+
Progress,
13+
SpinnerColumn,
14+
TextColumn,
15+
TimeRemainingColumn,
16+
)
1117

1218
from ...logging_config import log
1319

@@ -60,7 +66,17 @@ def wait(self) -> None:
6066
"""
6167
log.info(f"Waiting for {len(self.futures)} tasks to complete...")
6268

63-
with Progress() as progress:
69+
progress = Progress(
70+
SpinnerColumn(),
71+
TextColumn("[bold blue]{task.description}", justify="right"),
72+
BarColumn(bar_width=None),
73+
"[progress.percentage]{task.percentage:>3.0f}%",
74+
TextColumn("•"),
75+
TextColumn("[green]{task.completed} of {task.total} records"),
76+
TextColumn("•"),
77+
TimeRemainingColumn(),
78+
)
79+
with progress:
6480
task = progress.add_task("[cyan]Processing...", total=len(self.futures))
6581

6682
# Use as_completed to process results as they finish,

tests/test_exporter.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def test_run_export(mock_export_data: MagicMock) -> None:
1313
`export_threaded.export_data` function with the correct parameters.
1414
"""
1515
# 1. Setup
16+
mock_export_data.return_value = (True, "Export complete.")
1617
config_file = "conf/test.conf"
1718
filename = "output.csv"
1819
model = "res.partner"
@@ -86,8 +87,8 @@ def test_run_export_for_migration(mock_export_data: MagicMock) -> None:
8687
assert data == [["1", "Test Partner"]]
8788

8889

89-
@patch("odoo_data_flow.exporter.log.error")
90-
def test_run_export_invalid_domain(mock_log_error: MagicMock) -> None:
90+
@patch("odoo_data_flow.exporter._show_error_panel")
91+
def test_run_export_invalid_domain(mock_show_error_panel: MagicMock) -> None:
9192
"""Tests that `run_export` logs an error for a malformed domain string."""
9293
# 1. Action
9394
run_export(
@@ -99,8 +100,8 @@ def test_run_export_invalid_domain(mock_log_error: MagicMock) -> None:
99100
)
100101

101102
# 2. Assertions
102-
mock_log_error.assert_called_once()
103-
assert "Invalid domain provided" in mock_log_error.call_args[0][0]
103+
mock_show_error_panel.assert_called_once()
104+
assert "Invalid Domain" in mock_show_error_panel.call_args[0][0]
104105

105106

106107
@patch("odoo_data_flow.export_threaded.conf_lib.get_connection_from_config")
@@ -124,8 +125,8 @@ def test_run_export_calls_rpc_thread_wait(
124125
assert mock_rpc_thread.return_value.get_data.called
125126

126127

127-
@patch("odoo_data_flow.exporter.log.error")
128-
def test_run_export_invalid_context(mock_log_error: MagicMock) -> None:
128+
@patch("odoo_data_flow.exporter._show_error_panel")
129+
def test_run_export_invalid_context(mock_show_error_panel: MagicMock) -> None:
129130
"""Tests that `run_export` logs an error for a malformed context string."""
130131
# 1. Action
131132
run_export(
@@ -137,8 +138,8 @@ def test_run_export_invalid_context(mock_log_error: MagicMock) -> None:
137138
)
138139

139140
# 2. Assertions
140-
mock_log_error.assert_called_once()
141-
assert "Invalid context provided" in mock_log_error.call_args[0][0]
141+
mock_show_error_panel.assert_called_once()
142+
assert "Invalid Context" in mock_show_error_panel.call_args[0][0]
142143

143144

144145
@patch("odoo_data_flow.exporter.export_threaded.export_data")

0 commit comments

Comments
 (0)