Skip to content
Open
20 changes: 20 additions & 0 deletions examples/table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from matrix import Bot, Table

bot = Bot()


@bot.command()
async def weather(ctx):
weather = Table(title="Los Angeles", columns=2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I would remove columns=2 since it's the default value.


weather.add_field("Description", "Clear Sky")
weather.add_field("Visibility", "10000m | 32808ft")
weather.add_field("Temperature", "71.33°F | 21.85°C")
weather.add_field("Feels Like", "71.33°F | 21.85°C")
weather.add_field("Atmospheric Pressure", "1012 hPa")
weather.add_field("Humidity", "66%")

await ctx.reply(component=weather)


bot.start(config="config.yaml")
2 changes: 2 additions & 0 deletions matrix/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .space import Space
from .message import Message
from .extension import Extension
from .component import Table

__all__ = [
"Bot",
Expand All @@ -34,4 +35,5 @@
"Space",
"Message",
"Extension",
"Table",
]
106 changes: 106 additions & 0 deletions matrix/component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
from html import escape
from abc import ABC, abstractmethod

CELL_TEMPLATE = "<td><strong>{name}</strong><br>{value}</td>"

ROW_TEMPLATE = "<tr>{cells}</tr>"

TABLE_TEMPLATE = "<h2>{title}</h2><table><tbody>{rows}</tbody></table>"


class Component(ABC):
"""Base class for message components."""

@abstractmethod
def to_plain_text(self) -> str:
pass

@abstractmethod
def render(self) -> str:
pass


class Table(Component):
"""A component that renders labeled fields as a table.

Fields are displayed in rows using the configured number of columns.
Incomplete rows are padded with empty cells. Field names, values, and the
table title are HTML-escaped when rendered.
"""

def __init__(self, *, title: str, columns: int = 2) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should add a validation on columns to in case someone puts 0 or a negative amount of columns:

if columns < 1:
    raise ValueError(...)

nit: I think we should call that column_count or something. When I see columns I expect a list of columns.

self.title: str = title
self.columns: int = columns
self.fields: list[tuple[str, str]] = []

def __str__(self) -> str:
return self.render()

def add_field(self, name: str, value: str) -> None:
"""Add a labeled field to the table.

## Example

```python
table = Table(title="User Info")
table.add_field("Name", "Astra")
```
"""
self.fields.append((name, value))

def to_plain_text(self) -> str:
"""Render the table as plain text.

## Example

```python
table = Table(title="User Info")
table.add_field("Name", "Astra")

result = table.to_plain_text()
# User Info
# Name: Astra
```
"""
return "\n".join(
[self.title, *[f"{name}: {value}" for name, value in self.fields]]
)

def render(self) -> str:
"""Render the table as HTML with escaped field content.

Incomplete rows are padded with empty cells based on the configured
column count.

## Example

```python
table = Table(title="User Info")
table.add_field("Name", "Astra")
table.add_field("Role", "Engineer")

html = table.render()
```
"""
cells = []
for name, value in self.fields:
cells.append(
CELL_TEMPLATE.format(
name=escape(name),
value=escape(value),
)
)

rows = []
for i in range(0, len(cells), self.columns):
row_cells = cells[i : i + self.columns]

while len(row_cells) < self.columns:
row_cells.append("<td></td>")

rows.append(ROW_TEMPLATE.format(cells="".join(row_cells)))

return TABLE_TEMPLATE.format(
title=escape(self.title),
rows="".join(rows),
)
15 changes: 15 additions & 0 deletions matrix/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from dataclasses import dataclass
from markdown import markdown
from typing import Any
from .component import Component


class BaseMessageContent(ABC):
Expand Down Expand Up @@ -172,3 +173,17 @@ def build(self) -> dict:
"key": self.emoji,
}
}


@dataclass
class ComponentContent(BaseMessageContent):
msgtype = "m.text"
component: Component

def build(self) -> dict:
return {
"msgtype": self.msgtype,
"body": self.component.to_plain_text(),
"format": "org.matrix.custom.html",
"formatted_body": self.component.render(),
}
5 changes: 4 additions & 1 deletion matrix/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from .errors import MatrixError
from .message import Message
from .room import Room
from .types import File, Image
from .types import File
from matrix.component import Component
from .member import Member

if TYPE_CHECKING:
Expand Down Expand Up @@ -61,6 +62,7 @@ async def reply(
raw: bool = False,
notice: bool = False,
file: File | None = None,
component: Component | None = None,
) -> Message:
"""Reply to the command with a message.

Expand Down Expand Up @@ -106,6 +108,7 @@ async def cat(ctx: Context):
try:
return await self.room.send(
content,
component=component,
raw=raw,
notice=notice,
file=file,
Expand Down
28 changes: 28 additions & 0 deletions matrix/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
RoomGetStateEventError,
)

from matrix.component import Component
from matrix.api import matrix_call
from matrix.message import Message
from matrix.content import (
Expand All @@ -20,6 +21,7 @@
ImageContent,
AudioContent,
VideoContent,
ComponentContent,
)
from matrix.types import File, Image, Audio, Video

Expand Down Expand Up @@ -118,6 +120,7 @@ async def send(
raw: bool = False,
notice: bool = False,
file: File | None = None,
component: Component | None = None,
) -> Message:
"""Send a message to the room.

Expand All @@ -131,6 +134,10 @@ async def send(
## Example

```python
# Send component-formatted message
table = Table(title="Los Angeles")
await room.send(component=table)

# Send a markdown-formatted text message
await room.send("Hello **world**!")

Expand All @@ -143,13 +150,34 @@ async def send(
await room.send(file=image)
```
"""
if component:
return await self.send_component(component)

if content:
return await self.send_text(content, raw=raw, notice=notice)

if file:
return await self.send_file(file)
raise ValueError("You must provide content or file.")

async def send_component(
self,
component: Component,
) -> Message:
"""Send a component-formatted message to the room.

## Example

```python
# Send component-formatted message
table = Table(title="Los Angeles")
await room.send_component(table)
```
"""
payload: ComponentContent = ComponentContent(component=component)

return await self._send_payload(payload)

async def send_text(
self,
content: str,
Expand Down
85 changes: 85 additions & 0 deletions tests/test_component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import pytest
from matrix.component import Table


@pytest.fixture
def table():
return Table(title="User Info")


def test_to_plain_text__expect_title_and_fields(table):
table.add_field("Name", "Astra")
table.add_field("Role", "Engineer")

result = table.to_plain_text()

assert result == "User Info\nName: Astra\nRole: Engineer"


def test_to_plain_text__with_no_fields__expect_title_only(table):
result = table.to_plain_text()

assert result == "User Info"


def test_render__expect_html_table(table):
table.add_field("Name", "Astra")
table.add_field("Role", "Engineer")

result = table.render()

assert "<h2>User Info</h2>" in result
assert "<table>" in result
assert "<strong>Name</strong>" in result
assert "Astra" in result
assert "<strong>Role</strong>" in result
assert "Engineer" in result


def test_render__with_odd_number_of_fields__expect_empty_padding_cell(table):
table.add_field("Name", "Astra")
table.add_field("Role", "Engineer")
table.add_field("Location", "CA")

result = table.render()

assert result.count("<tr>") == 2
assert result.count("<td>") == 4
assert result.count("<td></td>") == 1
assert result.count("<strong>") == 3


def test_render__with_custom_columns__expect_rows_grouped_by_column_count():
table = Table(title="User Info", columns=3)
table.add_field("Name", "Astra")
table.add_field("Role", "Engineer")
table.add_field("Location", "CA")
table.add_field("Status", "Active")

result = table.render()

assert result.count("<tr>") == 2
assert result.count("<td>") == 6
assert result.count("<td></td>") == 2
assert result.count("<strong>") == 4


def test_render__with_html_content__expect_escaped_html():
table = Table(title="<User Info>")
table.add_field("<Name>", "<Astra & Co>")

result = table.render()

assert "&lt;User Info&gt;" in result
assert "&lt;Name&gt;" in result
assert "&lt;Astra &amp; Co&gt;" in result

assert "<User Info>" not in result
assert "<Name>" not in result
assert "<Astra & Co>" not in result


def test_str__expect_rendered_html(table):
table.add_field("Name", "Astra")

assert str(table) == table.render()
Loading