Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,26 @@ This report was generated with just a small number of lines of Python: ::
report + "##Subsection Header"
report + "Fusce vel lectus ultricies,... "

Otter also supports pandas DataFrames (requires pandas to be installed): ::

import pandas as pd

df = pd.DataFrame({
'Name': ['Alice', 'Bob', 'Charlie'],
'Age': [25, 30, 35],
'City': ['New York', 'London', 'Paris']
})

with report:
report + "## Data Table"
report + df

The DataFrame will be automatically converted to a nicely formatted HTML table.

Features
--------

* TODO Add support for pandas data tables
* Support for pandas DataFrames (optional dependency)
* TODO Add support for custom headers and footers

Credits
Expand Down
21 changes: 21 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,24 @@ This will add a plot of a sinusoid to the report.
Otter will automatically try and handle a number of other data formats automatically, and make them
aesthetically pleasing. Dictionaries get turned into tables, for example, as do numpy arrays, while lists are turned into HTML lists.

Pandas DataFrames
^^^^^^^^^^^^^^^^^

If you have pandas installed, Otter can also automatically render pandas DataFrames as HTML tables. For example: ::

import pandas as pd

df = pd.DataFrame({
'Name': ['Alice', 'Bob', 'Charlie'],
'Age': [25, 30, 35],
'City': ['New York', 'London', 'Paris']
})

with report:
report + "## Employee Data"
report + df

The DataFrame will be automatically converted to a nicely formatted HTML table with proper headers and styling that matches the Bootstrap theme.

Note: pandas is an optional dependency. Install it with ``pip install pandas`` or ``pip install otter-report[pandas]`` to enable DataFrame support.

32 changes: 24 additions & 8 deletions otter/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
import markdown
import tabulate

# Try to import pandas, but make it optional
try:
import pandas as pd
PANDAS_AVAILABLE = True
except ImportError:
pd = None
PANDAS_AVAILABLE = False

md_extensions = [
'markdown.extensions.tables',
'markdown.extensions.extra'
Expand Down Expand Up @@ -84,14 +92,6 @@ def dict_to_table(dictionary):
table + Row([key, val])
return table

handlers = {
str: lambda x: markdown.markdown(str(x), output_format='xhtml5', extensions=md_extensions),
matplotlib.figure.Figure: plot.Figure,
list: OrderedList,
dict: dict_to_table,
numpy.ndarray: lambda x: tabulate.tabulate(x, tablefmt=MyHTMLFormat)
}

from functools import partial
def my_html_row_with_attrs(celltag, cell_values, colwidths, colaligns):
alignment = { "left": '',
Expand All @@ -113,3 +113,19 @@ def my_html_row_with_attrs(celltag, cell_values, colwidths, colaligns):
headerrow=partial(my_html_row_with_attrs, "th"),
datarow=partial(my_html_row_with_attrs, "td"),
padding=0, with_header_hide=None)

def dataframe_to_table(df):
"""Convert a pandas DataFrame to an HTML table using tabulate."""
return tabulate.tabulate(df, headers='keys', tablefmt=MyHTMLFormat)

handlers = {
str: lambda x: markdown.markdown(str(x), output_format='xhtml5', extensions=md_extensions),
matplotlib.figure.Figure: plot.Figure,
list: OrderedList,
dict: dict_to_table,
numpy.ndarray: lambda x: tabulate.tabulate(x, tablefmt=MyHTMLFormat)
}

# Add pandas DataFrame handler if pandas is available
if PANDAS_AVAILABLE:
handlers[pd.DataFrame] = dataframe_to_table
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ dependencies = [
]

[project.optional-dependencies]
pandas = [
"pandas",
]
dev = [
"bumpversion>=0.6.0",
"wheel>=0.42",
Expand Down
83 changes: 83 additions & 0 deletions tests/test_otter.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,89 @@ def test_container_creation(self):
self.assertIn("<div class='container'>", output)


class TestPandasSupport(unittest.TestCase):
"""Test pandas DataFrame support."""

def setUp(self):
"""Set up test fixtures."""
try:
import pandas as pd
self.pandas_available = True
self.pd = pd
except ImportError:
self.pandas_available = False
self.pd = None
self.test_dir = tempfile.mkdtemp()
self.test_file = os.path.join(self.test_dir, "test_pandas.html")

def tearDown(self):
"""Clean up test fixtures."""
if os.path.exists(self.test_dir):
try:
shutil.rmtree(self.test_dir)
except (OSError, PermissionError):
# On some platforms (e.g., Windows), files may not be released immediately.
import time
time.sleep(0.1)
shutil.rmtree(self.test_dir)

Copilot AI Dec 27, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TestPandasSupport class is missing a tearDown method to clean up resources. The test_pandas_dataframe_in_report method creates temporary directories but handles cleanup in a try-finally block within the test itself. For consistency with the existing TestOtterBasics class pattern (lines 24-38), consider adding a tearDown method to handle cleanup uniformly across all tests in this class.

Suggested change
def tearDown(self):
"""Clean up any temporary resources created by tests."""
# Remove a temporary directory if one was created and stored on the instance
if hasattr(self, "test_dir") and self.test_dir and os.path.isdir(self.test_dir):
shutil.rmtree(self.test_dir)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored TestPandasSupport to use setUp/tearDown pattern for consistency with TestOtterBasics. The test_dir and test_file are now created in setUp and cleaned up in tearDown, eliminating the try-finally block. Commit: a97a8ba

def test_pandas_dataframe_handler(self):
"""Test that pandas DataFrames are converted to HTML tables."""
if not self.pandas_available:
self.skipTest("pandas is not installed")

# Create a simple DataFrame
df = self.pd.DataFrame({
'A': [1, 2, 3],
'B': [4, 5, 6],
'C': [7, 8, 9]
})

# Check that the handler exists
self.assertIn(self.pd.DataFrame, html.handlers)

# Convert DataFrame to HTML
result = html.handlers[self.pd.DataFrame](df)

# Check that the result is a string containing table HTML
self.assertIsInstance(result, str)
self.assertIn("<table", result)
self.assertIn("</table>", result)

def test_pandas_dataframe_in_report(self):
"""Test adding a pandas DataFrame to an Otter report."""
if not self.pandas_available:
self.skipTest("pandas is not installed")

report = otter.Otter(self.test_file, title="Pandas Test")

# Create a simple DataFrame
df = self.pd.DataFrame({
'Name': ['Alice', 'Bob', 'Charlie'],
'Age': [25, 30, 35],
'City': ['New York', 'London', 'Paris']
})

# Add DataFrame to report
report + df

# Check that it was added
self.assertEqual(len(report.items), 1)

# Render the report
report.show()

# Check that the file was created
self.assertTrue(os.path.exists(self.test_file))

# Check that the file contains the DataFrame data
with open(self.test_file, 'r') as f:
content = f.read()
self.assertIn("Alice", content)
self.assertIn("Bob", content)
self.assertIn("Charlie", content)


if __name__ == '__main__':
import sys
sys.exit(unittest.main())