Skip to content

devboard ¤

Devboard package.

A development dashboard for projects, issues, pull requests, and other work.

Classes:

  • Board –

    A set of columns and the policies used to refresh their items.

  • Checkbox –

    A checkbox, added to rows to make them selectable.

  • Column –

    A Devboard column.

  • DataTable –

    A Devboard data table.

  • Devboard –

    The Devboard application.

  • Modal –

    A modal screen.

  • ModalMixin –

    Mixin class to add a modal method.

  • NotifyMixin –

    Mixin class to add notify methods.

  • Project –

    A class representing development projects.

  • Row –

    A Devboard row.

  • SelectableRow –

    A selectable row.

  • SelectableRowsDataTable –

    Data table with selectable rows.

  • Status –

    Git status data.

Functions:

  • get_parser –

    Return the CLI argument parser.

  • main –

    Run the main program.

  • row_action –

    Adapt a row callback into a Textual action.

  • rows_action –

    Adapt a batch callback into a Textual action.

Board ¤

A set of columns and the policies used to refresh their items.

Parameters:

  • columns ¤

    (Iterable[Column | type[Column]]) –

    Column instances or classes displayed by the board.

  • bindings ¤

    (Iterable[BindingType] | None, default: None ) –

    Application bindings that extend the inherited BINDINGS. Bindings override earlier entries for the same key. Omit this argument or pass an empty iterable to keep the defaults.

  • force_refresh_on_startup ¤

    (bool, default: False ) –

    Whether startup uses the forced item hook.

Methods:

Attributes:

Source code in src/devboard/_internal/board.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
def __init__(
    self,
    columns: Iterable[Column | type[Column]],
    *,
    bindings: Iterable[BindingType] | None = None,
    force_refresh_on_startup: bool = False,
) -> None:
    """Initialize the board.

    Parameters:
        columns: Column instances or classes displayed by the board.
        bindings: Application bindings that extend the inherited `BINDINGS`.
            Bindings override earlier entries for the same key. Omit this argument or pass an empty iterable to keep the defaults.
        force_refresh_on_startup: Whether startup uses the forced item hook.
    """
    self.columns: tuple[Column | type[Column], ...] = tuple(columns)
    """Column instances or classes displayed by the board."""
    binding_specs: list[BindingType] = []
    for cls in reversed(type(self).__mro__):
        binding_specs.extend(cls.__dict__.get("BINDINGS", ()))
    if bindings is not None:
        binding_specs.extend(bindings)

    # Resolve aliases first so an override replaces only the keys it specifies.
    self.bindings: tuple[BindingType, ...] = tuple(
        {binding.key: binding for binding in Binding.make_bindings(binding_specs)}.values(),
    )
    """Application bindings after combining defaults, subclass bindings, and constructor bindings."""
    self.force_refresh_on_startup: bool = force_refresh_on_startup
    """Whether startup uses the forced item hook."""

BINDINGS class-attribute instance-attribute ¤

BINDINGS: ClassVar = [
    Binding("question_mark", "show_help", "Help"),
    Binding("ctrl+c", "exit", "Exit", priority=True),
    Binding("escape", "exit", "Exit"),
    Binding(
        "ctrl+r",
        "refresh_board",
        "Refresh board",
        show=False,
    ),
    Binding(
        "ctrl+f5",
        "force_refresh_board",
        "Force refresh board",
        show=False,
    ),
    Binding(
        "ctrl+f", "filter_board", "Filter board", show=False
    ),
]

Application bindings extended or overridden by subclasses and constructor bindings.

bindings instance-attribute ¤

bindings: tuple[BindingType, ...] = tuple(
    {
        binding.key: binding
        for binding in Binding.make_bindings(binding_specs)
    }.values()
)

Application bindings after combining defaults, subclass bindings, and constructor bindings.

columns instance-attribute ¤

columns: tuple[Column | type[Column], ...] = tuple(columns)

Column instances or classes displayed by the board.

force_refresh_on_startup instance-attribute ¤

force_refresh_on_startup: bool = force_refresh_on_startup

Whether startup uses the forced item hook.

force_refresh_item ¤

force_refresh_item(item: Any) -> None

Prepare one item for a forced scan.

Source code in src/devboard/_internal/board.py
656
657
658
def force_refresh_item(self, item: Any, /) -> None:
    """Prepare one item for a forced scan."""
    self.refresh_item(item)

matches_filter ¤

matches_filter(row: Row[Any], value: str) -> bool

Match filter text against displayed cells, ignoring case.

Override this method to match source items instead. For example, a backlog board can compare row.item.repository with value. An empty filter shows every row.

Source code in src/devboard/_internal/board.py
660
661
662
663
664
665
666
667
def matches_filter(self, row: Row[Any], value: str, /) -> bool:
    """Match filter text against displayed cells, ignoring case.

    Override this method to match source items instead. For example, a
    backlog board can compare `row.item.repository` with `value`.
    An empty filter shows every row.
    """
    return not value or any(value.casefold() in str(cell).casefold() for cell in row.data)

refresh_item ¤

refresh_item(item: Any) -> None

Prepare one item for a normal scan.

Source code in src/devboard/_internal/board.py
653
654
def refresh_item(self, item: Any, /) -> None:
    """Prepare one item for a normal scan."""

Checkbox dataclass ¤

Checkbox(checked: bool = False)

A checkbox, added to rows to make them selectable.

Methods:

  • check –

    Uncheck the checkbox.

  • toggle –

    Toggle the checkbox.

  • uncheck –

    Uncheck the checkbox.

Attributes:

checked class-attribute instance-attribute ¤

checked: bool = False

Whether the checkbox is checked.

check ¤

check() -> None

Uncheck the checkbox.

Source code in src/devboard/_internal/datatable.py
55
56
57
def check(self) -> None:
    """Uncheck the checkbox."""
    self.checked = True

toggle ¤

toggle() -> bool

Toggle the checkbox.

Source code in src/devboard/_internal/datatable.py
63
64
65
66
def toggle(self) -> bool:
    """Toggle the checkbox."""
    self.checked = not self.checked
    return self.checked

uncheck ¤

uncheck() -> None

Uncheck the checkbox.

Source code in src/devboard/_internal/datatable.py
59
60
61
def uncheck(self) -> None:
    """Uncheck the checkbox."""
    self.checked = False

Column ¤

Bases: Container, ModalMixin, NotifyMixin, Generic[_ItemT]

A Devboard column.

Methods:

Attributes:

BINDINGS class-attribute instance-attribute ¤

BINDINGS: ClassVar = [
    Binding(
        "ctrl+e",
        "toggle_collapse",
        "Collapse/expand column",
        show=False,
    ),
    Binding(
        "ctrl+x",
        "toggle_maximize",
        "Maximize/unmaximize column",
        show=False,
    ),
]

Column key bindings.

CACHE_VERSION class-attribute instance-attribute ¤

CACHE_VERSION: int = 1

Version of the column's serialized cell format.

DEFAULT_CLASSES class-attribute instance-attribute ¤

DEFAULT_CLASSES = 'box'

Textual CSS classes.

DEFAULT_CSS class-attribute instance-attribute ¤

DEFAULT_CSS = "\n    Column .column-header {\n        height: 1;\n    }\n\n    Column .column-title {\n        width: 1fr;\n        padding: 0 1;\n        text-wrap: nowrap;\n        text-overflow: ellipsis;\n    }\n\n    Column .column-count {\n        width: auto;\n        padding: 0 1;\n        color: $text-muted;\n    }\n\n    Column.-collapsed {\n        width: 3;\n    }\n\n    Column.-collapsed .column-header {\n        height: auto;\n    }\n\n    Column.-collapsed .column-title {\n        padding: 0;\n        text-wrap: wrap;\n        text-overflow: fold;\n        text-style: bold;\n    }\n\n    Column.-collapsed .column-count {\n        display: none;\n    }\n\n    Column.-collapsed DataTable {\n        display: none;\n    }\n    "

Styles owned by the reusable column widget.

HEADERS class-attribute instance-attribute ¤

HEADERS: tuple[str, ...] = ()

The data table headers.

THREADED class-attribute instance-attribute ¤

THREADED: bool = True

Whether synchronous row actions run in background threads.

TITLE class-attribute instance-attribute ¤

TITLE: str = ''

The title of the column.

app instance-attribute ¤

app: App

Textual application.

is_cached class-attribute instance-attribute ¤

is_cached: Reactive[bool] = reactive(
    default=False, init=False
)

Whether the column displays cached data.

is_collapsed class-attribute instance-attribute ¤

is_collapsed: Reactive[bool] = reactive(
    default=False,
    init=False,
    layout=True,
    toggle_class="-collapsed",
)

Whether the column is collapsed.

table property ¤

table: DataTable[_ItemT]

Data table.

action_toggle_collapse ¤

action_toggle_collapse() -> None

Collapse or expand the column.

Source code in src/devboard/_internal/board.py
274
275
276
277
def action_toggle_collapse(self) -> None:
    """Collapse or expand the column."""
    self.is_collapsed = not self.is_collapsed
    self.screen.set_focus(self if self.is_collapsed else self.table)

action_toggle_maximize ¤

action_toggle_maximize() -> None

Maximize the column or restore the layout from before it was maximized.

Source code in src/devboard/_internal/board.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def action_toggle_maximize(self) -> None:
    """Maximize the column or restore the layout from before it was maximized."""
    if self._layout_before_maximize is None:
        columns = list(self.screen.query(Column))
        self._layout_before_maximize = [(column, column.is_collapsed) for column in columns]
        for column in columns:
            if column is self:
                column._expand()
            else:
                column._collapse()
        self.screen.set_focus(self.table)
        return

    layout = self._layout_before_maximize
    self._layout_before_maximize = None
    for column, was_collapsed in layout:
        if was_collapsed:
            column._collapse()
        else:
            column._expand()
    self.screen.set_focus(self if self.is_collapsed else self.table)

compose ¤

compose() -> ComposeResult

Compose column widgets.

Source code in src/devboard/_internal/board.py
231
232
233
234
235
236
def compose(self) -> ComposeResult:
    """Compose column widgets."""
    with Horizontal(classes="column-header"):
        yield Static("▶ " + self.TITLE, classes="column-title")
        yield Static("0", classes="column-count", markup=False)
    yield DataTable(id="table")

deserialize_cell ¤

deserialize_cell(value: Any) -> Any

Restore a cell value loaded from the cache.

Source code in src/devboard/_internal/board.py
439
440
441
def deserialize_cell(self, value: Any) -> Any:
    """Restore a cell value loaded from the cache."""
    return value

filter_rows ¤

filter_rows(
    predicate: Callable[[Row[_ItemT]], bool] | None,
) -> None

Show matching rows, or clear this column's filter with None.

The predicate receives each row, including its source item and display cells. It also applies after refreshes. Call this method on the UI thread.

Source code in src/devboard/_internal/board.py
411
412
413
414
415
416
417
418
419
420
421
def filter_rows(self, predicate: Callable[[Row[_ItemT]], bool] | None) -> None:
    """Show matching rows, or clear this column's filter with `None`.

    The predicate receives each row, including its source item and display
    cells. It also applies after refreshes. Call this method on the UI thread.
    """
    was_empty = not self.table.row_count
    self.table.filter_rows((lambda row: predicate(cast("Row[_ItemT]", row))) if predicate is not None else None)
    if was_empty and self.table.row_count:
        self._expand()
    self._finalize()

item_key ¤

item_key(item: _ItemT) -> Hashable

Return the identity used to share and cache an item.

Keys must be hashable and unique across the board. Objects can expose a devboard_key attribute to provide a stable identity. Hashable objects otherwise use their own identity. Unhashable objects use their process-local identity and should override this method if their rows need to be restored from the on-disk cache.

Source code in src/devboard/_internal/board.py
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
def item_key(self, item: _ItemT, /) -> Hashable:
    """Return the identity used to share and cache an item.

    Keys must be hashable and unique across the board.
    Objects can expose a `devboard_key` attribute to provide a stable
    identity. Hashable objects otherwise use their own identity. Unhashable
    objects use their process-local identity and should override this method
    if their rows need to be restored from the on-disk cache.
    """
    key = getattr(item, "devboard_key", item)
    try:
        hash(key)
    except TypeError:
        return id(item)
    return cast("Hashable", key)

list_items ¤

list_items() -> Iterable[_ItemT]

List the items to scan for this column.

Source code in src/devboard/_internal/board.py
508
509
510
def list_items(self) -> Iterable[_ItemT]:
    """List the items to scan for this column."""
    return ()

modal ¤

modal(text: RenderableType) -> None

Ask the UI thread to show text or a Rich renderable in a modal.

Source code in src/devboard/_internal/modal.py
63
64
65
def modal(self, text: RenderableType) -> None:
    """Ask the UI thread to show text or a Rich renderable in a modal."""
    self.app.call_later(self._push_modal, text)

notify_error ¤

notify_error(message: str, timeout: float = 3.0) -> None

Notify error.

Source code in src/devboard/_internal/notifications.py
40
41
42
def notify_error(self, message: str, timeout: float = 3.0) -> None:
    """Notify error."""
    self.app.notify(f"[b red]ERROR[/]  {message}", severity="error", timeout=timeout)

notify_info ¤

notify_info(message: str, timeout: float = 3.0) -> None

Notify information.

Source code in src/devboard/_internal/notifications.py
28
29
30
def notify_info(self, message: str, timeout: float = 3.0) -> None:
    """Notify information."""
    self.app.notify(f"[b blue]INFO[/]  {message}", severity="information", timeout=timeout)

notify_success ¤

notify_success(message: str, timeout: float = 3.0) -> None

Notify success.

Source code in src/devboard/_internal/notifications.py
32
33
34
def notify_success(self, message: str, timeout: float = 3.0) -> None:
    """Notify success."""
    self.app.notify(f"[b green]SUCCESS[/]  {message}", severity="information", timeout=timeout)

notify_warning ¤

notify_warning(message: str, timeout: float = 3.0) -> None

Notify warning.

Source code in src/devboard/_internal/notifications.py
36
37
38
def notify_warning(self, message: str, timeout: float = 3.0) -> None:
    """Notify warning."""
    self.app.notify(f"[b yellow]WARNING[/]  {message}", severity="warning", timeout=timeout)

populate_rows ¤

populate_rows(item: _ItemT) -> list[tuple[Any, ...]]

Build table rows for an item.

Source code in src/devboard/_internal/board.py
528
529
530
def populate_rows(self, item: _ItemT, /) -> list[tuple[Any, ...]]:  # noqa: ARG002
    """Build table rows for an item."""
    return []

report_progress ¤

report_progress(description: str | None = None) -> None

Show progress on the left of the footer.

Devboard automatically clears progress associated with a worker when that worker finishes or is cancelled.

Parameters:

  • description ¤

    (str | None, default: None ) –

    Progress text. Omit the text to clear the current progress.

Source code in src/devboard/_internal/board.py
423
424
425
426
427
428
429
430
431
432
433
def report_progress(self, description: str | None = None) -> None:
    """Show progress on the left of the footer.

    Devboard automatically clears progress associated with a worker when that worker finishes or is cancelled.

    Parameters:
        description: Progress text. Omit the text to clear the current progress.
    """
    report_progress = getattr(self.app, "_report_progress", None)
    if report_progress is not None:
        report_progress(self, description)

serialize_cell ¤

serialize_cell(value: Any) -> Any

Convert a cell value to data that the cache can store.

Source code in src/devboard/_internal/board.py
435
436
437
def serialize_cell(self, value: Any) -> Any:
    """Convert a cell value to data that the cache can store."""
    return value

update ¤

update() -> None

Update the column (ask the app to recompute its data).

Source code in src/devboard/_internal/board.py
405
406
407
408
409
def update(self) -> None:
    """Update the column (ask the app to recompute its data)."""
    refresh_board = getattr(self.app, "refresh_board", None)
    if refresh_board is not None:
        self.app.call_later(refresh_board, [self])

DataTable ¤

DataTable(*args: Any, **kwargs: Any)

Bases: SelectableRowsDataTable[_ItemT], Generic[_ItemT]

A Devboard data table.

Methods:

Attributes:

Source code in src/devboard/_internal/datatable.py
182
183
184
185
186
187
188
189
190
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the table and its row-to-item associations."""
    self._row_items: dict[RowKey, _ItemT] = {}
    self._associated_item: _ItemT | object = _MISSING_ITEM
    self._hidden_rows: dict[RowKey, tuple[list[Any], int | None, Any]] = {}
    self._row_order: list[RowKey] = []
    self._display_order: list[RowKey] = []
    self._row_filter: Callable[[SelectableRow[_ItemT]], bool] | None = None
    super().__init__(*args, **kwargs)

BINDINGS class-attribute instance-attribute ¤

BINDINGS: ClassVar = [
    Binding(
        "space",
        "toggle_select_row",
        "Toggle select",
        show=False,
    ),
    Binding(
        "ctrl+a, *",
        "toggle_select_all",
        "Toggle select all",
        show=False,
    ),
    Binding(
        "exclamation_mark",
        "reverse_select",
        "Reverse selection",
        show=False,
    ),
    Binding(
        "shift+up",
        "toggle_select_up",
        "Expand selection up",
        show=False,
    ),
    Binding(
        "shift+down",
        "toggle_select_down",
        "Expand selection down",
        show=False,
    ),
]

Key bindings for selecting rows.

ROW class-attribute instance-attribute ¤

ROW = Row

The class to instantiate rows.

all_rows property ¤

all_rows: Iterator[Row[_ItemT]]

All Devboard rows, including rows hidden by a filter.

current_row property ¤

current_row: Row[_ItemT]

Currently selected row.

selectable_rows property ¤

selectable_rows: Iterator[Row[_ItemT]]

Rows, as Devboard rows.

selected_rows property ¤

selected_rows: Iterator[Row[_ItemT]]

Selected Devboard rows.

action_reverse_select ¤

action_reverse_select() -> None

Reverse selection.

Source code in src/devboard/_internal/datatable.py
311
312
313
314
315
def action_reverse_select(self) -> None:
    """Reverse selection."""
    for row in self.selectable_rows:
        row.toggle_select()
    self.force_refresh()

action_toggle_select_all ¤

action_toggle_select_all() -> None

Toggle-select all rows.

Source code in src/devboard/_internal/datatable.py
300
301
302
303
304
305
306
307
308
309
def action_toggle_select_all(self) -> None:
    """Toggle-select all rows."""
    rows = list(self.selectable_rows)
    if all(row.selected for row in rows):
        for row in rows:
            row.unselect()
    else:
        for row in rows:
            row.select()
    self.force_refresh()

action_toggle_select_down ¤

action_toggle_select_down() -> None

Toggle selection down.

Source code in src/devboard/_internal/datatable.py
329
330
331
332
333
334
335
336
337
338
339
def action_toggle_select_down(self) -> None:
    """Toggle selection down."""
    try:
        row = self.current_row
        next_row = row.next
    except CellDoesNotExist:
        pass
    else:
        next_row.toggle_select()
        self.move_cursor(row=next_row.index)
        self.force_refresh()

action_toggle_select_row ¤

action_toggle_select_row() -> None

Toggle-select current row.

Source code in src/devboard/_internal/datatable.py
291
292
293
294
295
296
297
298
def action_toggle_select_row(self) -> None:
    """Toggle-select current row."""
    try:
        row = self.current_row
    except CellDoesNotExist:
        return
    row.toggle_select()
    self.force_refresh()

action_toggle_select_up ¤

action_toggle_select_up() -> None

Toggle selection up.

Source code in src/devboard/_internal/datatable.py
317
318
319
320
321
322
323
324
325
326
327
def action_toggle_select_up(self) -> None:
    """Toggle selection up."""
    try:
        row = self.current_row
        previous_row = row.previous
    except CellDoesNotExist:
        pass
    else:
        previous_row.toggle_select()
        self.move_cursor(row=previous_row.index)
        self.force_refresh()

add_row ¤

add_row(
    *cells: Any,
    height: int | None = 1,
    key: str | None = None,
    label: Any | None = None,
) -> RowKey

Add a row and update the column's row count.

Source code in src/devboard/_internal/board.py
116
117
118
119
120
def add_row(self, *cells: Any, height: int | None = 1, key: str | None = None, label: Any | None = None) -> RowKey:
    """Add a row and update the column's row count."""
    row_key = super().add_row(*cells, height=height, key=key, label=label)
    self.post_message(_TableRowsChanged())
    return row_key

add_rows ¤

add_rows(rows: Iterable[Iterable]) -> list[RowKey]

Add rows.

Automatically insert a column with checkboxes in position 0.

Source code in src/devboard/_internal/datatable.py
220
221
222
223
224
225
def add_rows(self, rows: Iterable[Iterable]) -> list[RowKey]:
    """Add rows.

    Automatically insert a column with checkboxes in position 0.
    """
    return [self.add_row(*row) for row in rows]

clear ¤

clear(columns: bool = True) -> DataTable[_ItemT]

Clear the table and update the column's row count.

Source code in src/devboard/_internal/board.py
122
123
124
125
126
def clear(self, columns: bool = True) -> DataTable[_ItemT]:  # noqa: FBT001,FBT002
    """Clear the table and update the column's row count."""
    super().clear(columns)
    self.post_message(_TableRowsChanged())
    return self

filter_rows ¤

filter_rows(
    predicate: Callable[[SelectableRow[_ItemT]], bool]
    | None,
) -> None

Filter rows and update the column's visible row count.

Source code in src/devboard/_internal/board.py
135
136
137
138
def filter_rows(self, predicate: Callable[[SelectableRow[_ItemT]], bool] | None) -> None:
    """Filter rows and update the column's visible row count."""
    super().filter_rows(predicate)
    self.post_message(_TableRowsChanged())

force_refresh ¤

force_refresh() -> None

Force refresh table.

Source code in src/devboard/_internal/datatable.py
412
413
414
415
def force_refresh(self) -> None:
    """Force refresh table."""
    for row in self.selectable_rows:
        self.update_cell(row.key, "checkbox", row.checkbox)

get_row ¤

get_row(row_key: RowKey | str) -> list[Any]

Return row cells, including cells retained while a row is hidden.

Source code in src/devboard/_internal/datatable.py
252
253
254
255
256
257
def get_row(self, row_key: RowKey | str) -> list[Any]:
    """Return row cells, including cells retained while a row is hidden."""
    key = RowKey(row_key) if isinstance(row_key, str) else row_key
    if key in self._hidden_rows:
        return list(self._hidden_rows[key][0])
    return super().get_row(row_key)

remove_row ¤

remove_row(row_key: RowKey | str) -> None

Remove a row and report when the table becomes empty.

Source code in src/devboard/_internal/board.py
128
129
130
131
132
133
def remove_row(self, row_key: RowKey | str) -> None:
    """Remove a row and report when the table becomes empty."""
    super().remove_row(row_key)
    self.post_message(_TableRowsChanged())
    if not self.row_count:
        self.post_message(_TableEmptied())

sort ¤

sort(
    *columns: Any,
    key: Callable[[Any], Any] | None = None,
    reverse: bool = False,
) -> SelectableRowsDataTable[_ItemT]

Sort all rows and retain the active filter.

Source code in src/devboard/_internal/datatable.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
def sort(
    self,
    *columns: Any,
    key: Callable[[Any], Any] | None = None,
    reverse: bool = False,
) -> SelectableRowsDataTable[_ItemT]:
    """Sort all rows and retain the active filter."""
    column_indices = [self.get_column_index(column) for column in columns]

    def sort_key(row_key: RowKey) -> Any:
        cells = self.get_row(row_key)
        values = tuple(cells[index] for index in column_indices) if columns else tuple(cells)
        value = values[0] if len(columns) == 1 else values
        return key(value) if key is not None else value

    self._display_order = sorted(self._row_order, key=sort_key, reverse=reverse)
    self._sort_visible_rows()
    return self

Devboard ¤

Devboard(
    *args: Any,
    board: str | Path | None = None,
    background_tasks: bool = True,
    workers: int | None = None,
    **kwargs: Any,
)

Bases: App, ModalMixin

The Devboard application.

Parameters:

  • board ¤

    (str | Path | None, default: None ) –

    The board to display (name or file path).

  • background_tasks ¤

    (bool, default: True ) –

    Whether to run forced startup hooks and use the on-disk cache. Disable this option for deterministic tests and screenshots.

  • workers ¤

    (int | None, default: None ) –

    How many items to scan concurrently. Overrides the workers config setting.

Methods:

Attributes:

  • BINDINGS (ClassVar) –

    Application shortcuts extended or overridden by board bindings.

  • CSS_PATH –

    Path to the CSS file.

  • app (App) –

    Textual application.

  • board (Board) –

    The loaded board definition.

Source code in src/devboard/_internal/app.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def __init__(
    self,
    *args: Any,
    board: str | Path | None = None,
    background_tasks: bool = True,
    workers: int | None = None,
    **kwargs: Any,
) -> None:
    """Initialize the app.

    Parameters:
        board: The board to display (name or file path).
        background_tasks: Whether to run forced startup hooks and use the on-disk cache.
            Disable this option for deterministic tests and screenshots.
        workers: How many items to scan concurrently. Overrides the `workers` config setting.
    """
    super().__init__(*args, **kwargs)
    self._board_source: str | Path | None = board
    self._board_key: str = str(board)
    self._background_tasks: bool = background_tasks
    self._scan_workers: int | None = workers
    self._scanning: bool = False
    self._pending_item_refreshes: deque[tuple[_ItemIdentity, list[Column], bool]] = deque()
    self._progress_by_source: dict[object, str] = {}
    self._filter_values: dict[Column, str] = {}
    self._progress = Static("", id="task-progress", markup=False)
    self.board: Board = self._load_board()
    """The loaded board definition."""
    self._columns = self.board.columns
    self._bind_board_actions()

BINDINGS class-attribute instance-attribute ¤

BINDINGS: ClassVar = [
    Binding("ctrl+k", "toggle_help_panel", "Keys"),
    Binding(
        "ctrl+p",
        "command_palette",
        "Palette",
        show=False,
        priority=True,
        tooltip="Open the command palette",
    ),
]

Application shortcuts extended or overridden by board bindings.

CSS_PATH class-attribute instance-attribute ¤

CSS_PATH = Path(__file__).parent / 'devboard.tcss'

Path to the CSS file.

app instance-attribute ¤

app: App

Textual application.

board instance-attribute ¤

board: Board = self._load_board()

The loaded board definition.

action_exit ¤

action_exit() -> None

Exit application.

Source code in src/devboard/_internal/app.py
253
254
255
256
257
def action_exit(self) -> None:
    """Exit application."""
    self._pending_item_refreshes.clear()
    self.workers.cancel_all()
    self.exit()

action_filter_board ¤

action_filter_board() -> None

Prompt for a filter to apply to all columns.

Source code in src/devboard/_internal/app.py
244
245
246
def action_filter_board(self) -> None:
    """Prompt for a filter to apply to all columns."""
    self._prompt_filter(list(self.query(Column)), "Filter board")

action_filter_column ¤

action_filter_column() -> None

Prompt for a filter to apply to the focused column.

Source code in src/devboard/_internal/app.py
248
249
250
251
def action_filter_column(self) -> None:
    """Prompt for a filter to apply to the focused column."""
    if (column := self._focused_column()) is not None:
        self._prompt_filter([column], "Filter column")

action_force_refresh_board ¤

action_force_refresh_board() -> None

Force-refresh all columns.

Source code in src/devboard/_internal/app.py
222
223
224
def action_force_refresh_board(self) -> None:
    """Force-refresh all columns."""
    self.force_refresh_board()

action_force_refresh_column ¤

action_force_refresh_column() -> None

Force-refresh the focused column.

Source code in src/devboard/_internal/app.py
231
232
233
234
def action_force_refresh_column(self) -> None:
    """Force-refresh the focused column."""
    if (column := self._focused_column()) is not None:
        self.force_refresh_board([column])

action_force_refresh_item ¤

action_force_refresh_item() -> None

Force-refresh the item under the cursor in every column that lists it.

Source code in src/devboard/_internal/app.py
240
241
242
def action_force_refresh_item(self) -> None:
    """Force-refresh the item under the cursor in every column that lists it."""
    self._scan_current_item(force=True)

action_refresh_board ¤

action_refresh_board() -> None

Refresh all columns.

Source code in src/devboard/_internal/app.py
218
219
220
def action_refresh_board(self) -> None:
    """Refresh all columns."""
    self.refresh_board()

action_refresh_column ¤

action_refresh_column() -> None

Refresh the focused column.

Source code in src/devboard/_internal/app.py
226
227
228
229
def action_refresh_column(self) -> None:
    """Refresh the focused column."""
    if (column := self._focused_column()) is not None:
        self.refresh_board([column])

action_refresh_item ¤

action_refresh_item() -> None

Refresh the item under the cursor in every column that lists it.

Source code in src/devboard/_internal/app.py
236
237
238
def action_refresh_item(self) -> None:
    """Refresh the item under the cursor in every column that lists it."""
    self._scan_current_item(force=False)

action_show_help ¤

action_show_help() -> None

Show help.

Source code in src/devboard/_internal/app.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def action_show_help(self) -> None:
    """Show help."""
    lines = ["## Main keys\n\n"]
    lines.extend(self._binding_specs_help(binding for _, binding in self._bindings))
    lines.append("\n\n## Selection\n\n")
    lines.extend(self._bindings_help(DataTable, search_up=True))
    lines.append("\n\n## Columns\n\n")
    lines.extend(self._bindings_help(Column))
    common_column_bindings = list(Binding.make_bindings(Column.BINDINGS))
    lines.append("\n\n## Current board\n\n")
    for column in self.query(Column):
        lines.append(f"\n\n### {column.__class__.TITLE}\n\n")
        bindings = (binding for _, binding in column._bindings if binding not in common_column_bindings)
        lines.extend(self._binding_specs_help(bindings))
    self.push_screen(Modal(text=Markdown("\n".join(lines))))

action_show_help_panel ¤

action_show_help_panel() -> None

Show widget help and grouped bindings for the focused column.

Source code in src/devboard/_internal/app.py
206
207
208
209
def action_show_help_panel(self) -> None:
    """Show widget help and grouped bindings for the focused column."""
    if not self.screen.query(HelpPanel):
        self.screen.mount(_KeysPanel())

action_toggle_help_panel ¤

action_toggle_help_panel() -> None

Show or hide Textual's keys and widget help panel.

Source code in src/devboard/_internal/app.py
211
212
213
214
215
216
def action_toggle_help_panel(self) -> None:
    """Show or hide Textual's keys and widget help panel."""
    if self.screen.query(HelpPanel):
        self.action_hide_help_panel()
    else:
        self.action_show_help_panel()

compose ¤

compose() -> ComposeResult

Compose the layout.

Source code in src/devboard/_internal/app.py
123
124
125
126
127
128
129
130
131
132
def compose(self) -> ComposeResult:
    """Compose the layout."""
    for column in self._columns:
        if isinstance(column, Column):
            yield column
        else:
            yield column()
    with Horizontal(id="status-bar"):
        yield self._progress
        yield Footer()

filter_rows ¤

filter_rows(
    value: str | None,
    columns: Iterable[Column] | None = None,
) -> None

Filter specific columns, or all columns when none are specified.

Use the board's matches_filter() hook to select rows. A nonempty value replaces each target column's filter. None or an empty string clears it. Filtering preserves hidden rows and does not scan items or write the cache. Call this method on the UI thread.

Source code in src/devboard/_internal/app.py
311
312
313
314
315
316
317
318
319
320
321
322
def filter_rows(self, value: str | None, columns: Iterable[Column] | None = None) -> None:
    """Filter specific columns, or all columns when none are specified.

    Use the board's `matches_filter()` hook to select rows. A nonempty value
    replaces each target column's filter. `None` or an empty string clears it.
    Filtering preserves hidden rows and does not scan items or write the cache.
    Call this method on the UI thread.
    """
    predicate = (lambda row: self.board.matches_filter(row, value)) if value else None
    for column in columns if columns is not None else self.query(Column):
        column.filter_rows(predicate)
        self._filter_values[column] = value or ""

force_refresh_board ¤

force_refresh_board(
    columns: Iterable[Column] | None = None,
) -> None

Refresh columns after forcing their items to update external state.

Source code in src/devboard/_internal/app.py
307
308
309
def force_refresh_board(self, columns: Iterable[Column] | None = None) -> None:
    """Refresh columns after forcing their items to update external state."""
    self.scan(columns, force=True)

get_system_commands ¤

get_system_commands(
    screen: Screen,
) -> Iterable[SystemCommand]

Add refresh and filtering actions to the command palette.

Source code in src/devboard/_internal/app.py
134
135
136
137
138
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
    """Add refresh and filtering actions to the command palette."""
    yield from super().get_system_commands(screen)
    for action, (title, description) in {**_REFRESH_COMMANDS, **_FILTER_COMMANDS}.items():
        yield SystemCommand(title, description, getattr(self, f"action_{action}"))

modal ¤

modal(text: RenderableType) -> None

Ask the UI thread to show text or a Rich renderable in a modal.

Source code in src/devboard/_internal/modal.py
63
64
65
def modal(self, text: RenderableType) -> None:
    """Ask the UI thread to show text or a Rich renderable in a modal."""
    self.app.call_later(self._push_modal, text)

on_mount ¤

on_mount() -> None

Populate columns when the application starts.

Source code in src/devboard/_internal/app.py
140
141
142
143
def on_mount(self) -> None:
    """Populate columns when the application starts."""
    force = self._background_tasks and self.board.force_refresh_on_startup
    self.scan(initial=True, force=force)

refresh_board ¤

refresh_board(
    columns: Iterable[Column] | None = None,
) -> None

Refresh columns without forcing their items to update external state.

Source code in src/devboard/_internal/app.py
303
304
305
def refresh_board(self, columns: Iterable[Column] | None = None) -> None:
    """Refresh columns without forcing their items to update external state."""
    self.scan(columns)

scan ¤

scan(
    columns: Iterable[Column] | None = None,
    *,
    initial: bool = False,
    force: bool = False,
) -> None

Recompute columns data in the background.

A single scan feeds all columns: each item is read once, by a small pool of threads, and the resulting rows are dispatched to every column as they arrive. Each completed scan saves the displayed board to the cache when background tasks are enabled.

Parameters:

  • columns ¤

    (Iterable[Column] | None, default: None ) –

    The columns to update (all of them by default).

  • initial ¤

    (bool, default: False ) –

    Whether this is the initial scan, which can display cached data.

  • force ¤

    (bool, default: False ) –

    Whether to use the board's forced item hook.

Source code in src/devboard/_internal/app.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
def scan(
    self,
    columns: Iterable[Column] | None = None,
    *,
    initial: bool = False,
    force: bool = False,
) -> None:
    """Recompute columns data in the background.

    A single scan feeds all columns: each item is read once,
    by a small pool of threads, and the resulting rows are dispatched
    to every column as they arrive. Each completed scan saves the displayed
    board to the cache when background tasks are enabled.

    Parameters:
        columns: The columns to update (all of them by default).
        initial: Whether this is the initial scan, which can display cached data.
        force: Whether to use the board's forced item hook.
    """
    if self._scanning:
        return
    self._scanning = True
    column_list = list(columns) if columns is not None else list(self.query(Column))
    self.run_worker(partial(self._scan, column_list, initial=initial, force=force), thread=True)

Modal ¤

Modal(*args: Any, text: RenderableType, **kwargs: Any)

Bases: ModalScreen

A modal screen.

Methods:

  • compose –

    Screen composition.

  • on_key –

    Dismiss on any unbound key.

Attributes:

  • text –

    Text content.

Source code in src/devboard/_internal/modal.py
37
38
39
40
41
42
43
44
def __init__(self, *args: Any, text: RenderableType, **kwargs: Any) -> None:
    """Initialize the screen."""
    super().__init__(*args, **kwargs)
    if isinstance(text, str):
        self.text = Text.from_ansi(text)
        """Text content."""
    else:
        self.text = text

text instance-attribute ¤

text = Text.from_ansi(text)

Text content.

compose ¤

compose() -> ComposeResult

Screen composition.

Source code in src/devboard/_internal/modal.py
46
47
48
def compose(self) -> ComposeResult:
    """Screen composition."""
    yield VerticalScroll(Static(self.text), id="modal-contents")

on_key ¤

on_key(event: Key) -> None

Dismiss on any unbound key.

Source code in src/devboard/_internal/modal.py
50
51
52
53
54
def on_key(self, event: Key) -> None:
    """Dismiss on any unbound key."""
    if event.key not in self.app.active_bindings:
        event.stop()
        self.dismiss()

ModalMixin ¤

Mixin class to add a modal method.

Methods:

  • modal –

    Ask the UI thread to show text or a Rich renderable in a modal.

Attributes:

  • app (App) –

    Textual application.

app instance-attribute ¤

app: App

Textual application.

modal ¤

modal(text: RenderableType) -> None

Ask the UI thread to show text or a Rich renderable in a modal.

Source code in src/devboard/_internal/modal.py
63
64
65
def modal(self, text: RenderableType) -> None:
    """Ask the UI thread to show text or a Rich renderable in a modal."""
    self.app.call_later(self._push_modal, text)

NotifyMixin ¤

Mixin class to add notify methods.

Methods:

Attributes:

  • app (App) –

    Textual application.

app instance-attribute ¤

app: App

Textual application.

notify_error ¤

notify_error(message: str, timeout: float = 3.0) -> None

Notify error.

Source code in src/devboard/_internal/notifications.py
40
41
42
def notify_error(self, message: str, timeout: float = 3.0) -> None:
    """Notify error."""
    self.app.notify(f"[b red]ERROR[/]  {message}", severity="error", timeout=timeout)

notify_info ¤

notify_info(message: str, timeout: float = 3.0) -> None

Notify information.

Source code in src/devboard/_internal/notifications.py
28
29
30
def notify_info(self, message: str, timeout: float = 3.0) -> None:
    """Notify information."""
    self.app.notify(f"[b blue]INFO[/]  {message}", severity="information", timeout=timeout)

notify_success ¤

notify_success(message: str, timeout: float = 3.0) -> None

Notify success.

Source code in src/devboard/_internal/notifications.py
32
33
34
def notify_success(self, message: str, timeout: float = 3.0) -> None:
    """Notify success."""
    self.app.notify(f"[b green]SUCCESS[/]  {message}", severity="information", timeout=timeout)

notify_warning ¤

notify_warning(message: str, timeout: float = 3.0) -> None

Notify warning.

Source code in src/devboard/_internal/notifications.py
36
37
38
def notify_warning(self, message: str, timeout: float = 3.0) -> None:
    """Notify warning."""
    self.app.notify(f"[b yellow]WARNING[/]  {message}", severity="warning", timeout=timeout)

Project ¤

Project(path: Path)

A class representing development projects.

It is instantiated with a path, and then provides many utility properties and methods.

Methods:

  • __lt__ –

    Ordering is based on the project name.

  • checkout –

    Checkout branch, restore previous one when exiting.

  • delete –

    Delete branch.

  • fetch –

    Fetch.

  • fetch_locked –

    Fetch the project if no other path operation is running.

  • lock –

    Try to lock the project path without waiting.

  • locked –

    Try to lock the project path and release it when the context exits.

  • pull –

    Pull branch.

  • push –

    Push branch.

  • unlock –

    Unlock the project path.

  • unpulled –

    Number of unpulled commits (compared to the branch upstream), per branch.

  • unpushed –

    Number of unpushed commits (compared to the branch upstream), per branch.

  • unreleased –

    List unreleased commits (commits since the latest tag reachable from the branch).

Attributes:

  • DEFAULT_BRANCHES (tuple[str, ...]) –

    Name of common default branches. Mainly useful to compute unreleased commits.

  • LOCKS (dict[Path, Lock]) –

    Locks keyed by resolved project path, to avoid concurrent operations.

  • branch (Head) –

    Currently checked out branch.

  • default_branch (str) –

    Default branch (or main branch), as checked out when cloning.

  • devboard_key (Path) –

    Stable identity used to share this project between columns.

  • is_dirty (bool) –

    Whether the project is in a "dirty" state (uncommitted modifications).

  • latest_tag (TagReference) –

    Latest tag (by creation date).

  • name (str) –

    Name of the project.

  • path (Path) –

    Path of the project on the file-system.

  • repo (Repo) –

    GitPython's Repo object (cached per instance).

  • status (Status) –

    Status of the project.

  • status_line (str) –

    Status of the project, as a string.

Source code in src/devboard/_internal/projects.py
68
69
70
def __init__(self, path: Path) -> None:
    self.path: Path = path
    """Path of the project on the file-system."""

DEFAULT_BRANCHES class-attribute ¤

DEFAULT_BRANCHES: tuple[str, ...] = ('main', 'master')

Name of common default branches. Mainly useful to compute unreleased commits.

LOCKS class-attribute ¤

Locks keyed by resolved project path, to avoid concurrent operations.

branch property ¤

branch: Head

Currently checked out branch.

default_branch property ¤

default_branch: str

Default branch (or main branch), as checked out when cloning.

devboard_key property ¤

devboard_key: Path

Stable identity used to share this project between columns.

is_dirty property ¤

is_dirty: bool

Whether the project is in a "dirty" state (uncommitted modifications).

latest_tag property ¤

latest_tag: TagReference

Latest tag (by creation date).

Raises:

name property ¤

name: str

Name of the project.

path instance-attribute ¤

path: Path = path

Path of the project on the file-system.

repo cached property ¤

repo: Repo

GitPython's Repo object (cached per instance).

status property ¤

status: Status

Status of the project.

Computed from a single git status --porcelain call, which is much cheaper than diffing index and work tree separately. Each file is counted once, in the first matching category: untracked, renamed, added, deleted, type-changed, modified.

status_line property ¤

status_line: str

Status of the project, as a string.

__lt__ ¤

__lt__(other: object) -> bool

Ordering is based on the project name.

Total ordering is implemented on projects so they can be sorted in the application tables.

Source code in src/devboard/_internal/projects.py
75
76
77
78
79
80
81
82
def __lt__(self, other: object) -> bool:
    """Ordering is based on the project name.

    Total ordering is implemented on projects so they can be sorted in the application tables.
    """
    if not isinstance(other, Project):
        return NotImplemented
    return self.name < other.name

checkout ¤

checkout(branch: str | None) -> Iterator[None]

Checkout branch, restore previous one when exiting.

Source code in src/devboard/_internal/projects.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
@contextmanager
def checkout(self, branch: str | None) -> Iterator[None]:
    """Checkout branch, restore previous one when exiting."""
    if not branch:
        yield
        return
    current = self.branch
    if branch == current:
        yield
        return
    self.repo.branches[branch].checkout()
    try:
        yield
    finally:
        current.checkout()

delete ¤

delete(branch: str) -> None

Delete branch.

Source code in src/devboard/_internal/projects.py
228
229
230
def delete(self, branch: str) -> None:
    """Delete branch."""
    self.repo.delete_head(branch, force=True)

fetch ¤

fetch() -> None

Fetch.

Source code in src/devboard/_internal/projects.py
250
251
252
253
254
255
def fetch(self) -> None:
    """Fetch."""
    with suppress(AttributeError, GitCommandError):
        self.repo.remotes.origin.fetch()
    with suppress(AttributeError, GitCommandError):
        self.repo.remotes.upstream.fetch()

fetch_locked ¤

fetch_locked() -> bool

Fetch the project if no other path operation is running.

Source code in src/devboard/_internal/projects.py
257
258
259
260
261
262
263
264
265
def fetch_locked(self) -> bool:
    """Fetch the project if no other path operation is running."""
    if not self.lock():
        return False
    try:
        self.fetch()
    finally:
        self.unlock()
    return True

lock ¤

lock() -> bool

Try to lock the project path without waiting.

Source code in src/devboard/_internal/projects.py
279
280
281
def lock(self) -> bool:
    """Try to lock the project path without waiting."""
    return self._path_lock().acquire(blocking=False)

locked ¤

locked() -> Iterator[bool]

Try to lock the project path and release it when the context exits.

Source code in src/devboard/_internal/projects.py
287
288
289
290
291
292
293
294
295
@contextmanager
def locked(self) -> Iterator[bool]:
    """Try to lock the project path and release it when the context exits."""
    acquired = self.lock()
    try:
        yield acquired
    finally:
        if acquired:
            self.unlock()

pull ¤

pull(branch: str | None = None) -> None

Pull branch.

Source code in src/devboard/_internal/projects.py
218
219
220
221
def pull(self, branch: str | None = None) -> None:
    """Pull branch."""
    with self.checkout(branch):
        self.repo.remotes.origin.pull()

push ¤

push(branch: str | None = None) -> None

Push branch.

Source code in src/devboard/_internal/projects.py
223
224
225
226
def push(self, branch: str | None = None) -> None:
    """Push branch."""
    with self.checkout(branch):
        self.repo.remotes.origin.push()

unlock ¤

unlock() -> None

Unlock the project path.

Source code in src/devboard/_internal/projects.py
283
284
285
def unlock(self) -> None:
    """Unlock the project path."""
    self._path_lock().release()

unpulled ¤

unpulled() -> dict[str, int]

Number of unpulled commits (compared to the branch upstream), per branch.

Source code in src/devboard/_internal/projects.py
179
180
181
def unpulled(self) -> dict[str, int]:
    """Number of unpulled commits (compared to the branch upstream), per branch."""
    return {branch: behind for branch, (_, behind) in self._tracking.items()}

unpushed ¤

unpushed() -> dict[str, int]

Number of unpushed commits (compared to the branch upstream), per branch.

Source code in src/devboard/_internal/projects.py
175
176
177
def unpushed(self) -> dict[str, int]:
    """Number of unpushed commits (compared to the branch upstream), per branch."""
    return {branch: ahead for branch, (ahead, _) in self._tracking.items()}

unreleased ¤

unreleased(branch: str | None = None) -> list[Commit]

List unreleased commits (commits since the latest tag reachable from the branch).

Source code in src/devboard/_internal/projects.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def unreleased(self, branch: str | None = None) -> list[Commit]:
    """List unreleased commits (commits since the latest tag reachable from the branch)."""
    if branch is None:
        try:
            branch = self.default_branch
        except ValueError:
            return []
    try:
        latest_tag = self.repo.git.describe(branch, tags=True, abbrev=0)
    except GitCommandError:
        rev = branch  # No tag reachable: everything is unreleased.
    else:
        rev = f"{latest_tag}..{branch}"
    try:
        return list(self.repo.iter_commits(rev))
    except GitCommandError:
        return []

Row dataclass ¤

Row(
    table: SelectableRowsDataTable[_ItemT],
    key: RowKey,
    _snapshot: list | None = None,
    _item_snapshot: _ItemT | object = _MISSING_ITEM,
)

Bases: SelectableRow[_ItemT], Generic[_ItemT]

A Devboard row.

Methods:

  • refresh –

    Request a refresh of this row's item in the specified columns.

  • remove –

    Ask the table to remove this row on the UI thread.

  • select –

    Select this row.

  • toggle_select –

    Toggle-select this row.

  • unselect –

    Unselect this row.

Attributes:

app property ¤

app: App

Textual application.

checkbox property ¤

checkbox: Checkbox

Row checkbox.

data property ¤

data: list

Row data (without checkbox).

index property ¤

index: int

Row index.

item property ¤

item: _ItemT

Item that produced this row.

Raises:

  • ValueError –

    If the row was added without a source item.

key instance-attribute ¤

key: RowKey

The row key.

next property ¤

next: Row[_ItemT]

Next Devboard row.

previous property ¤

previous: Row[_ItemT]

Previous Devboard row.

selected property ¤

selected: bool

Whether this row is selected.

table instance-attribute ¤

table: SelectableRowsDataTable[_ItemT]

The data table containing this row.

refresh ¤

refresh(
    *,
    columns: Iterable[Column | type[Column]] | None = None,
    force: bool = False,
) -> None

Request a refresh of this row's item in the specified columns.

Pass column instances or classes. Omit columns to refresh every column that lists the item. This method is safe in background actions and after remove() on an action's row snapshot. Requests wait for any active scan. Set force=True to use the board's forced item hook.

Source code in src/devboard/_internal/board.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def refresh(self, *, columns: Iterable[Column | type[Column]] | None = None, force: bool = False) -> None:
    """Request a refresh of this row's item in the specified columns.

    Pass column instances or classes. Omit `columns` to refresh every column
    that lists the item. This method is safe in background actions and after
    `remove()` on an action's row snapshot. Requests wait for any active scan.
    Set `force=True` to use the board's forced item hook.
    """
    self.table.post_message(
        _RefreshItem(
            self.item,
            cast("Column", self.table.parent),
            tuple(columns) if columns is not None else None,
            force=force,
        ),
    )

remove ¤

remove() -> None

Ask the table to remove this row on the UI thread.

Source code in src/devboard/_internal/datatable.py
142
143
144
def remove(self) -> None:
    """Ask the table to remove this row on the UI thread."""
    self.table.post_message(_RemoveRow(self.key))

select ¤

select() -> None

Select this row.

Source code in src/devboard/_internal/datatable.py
125
126
127
def select(self) -> None:
    """Select this row."""
    self.checkbox.check()

toggle_select ¤

toggle_select() -> bool

Toggle-select this row.

Source code in src/devboard/_internal/datatable.py
133
134
135
def toggle_select(self) -> bool:
    """Toggle-select this row."""
    return self.checkbox.toggle()

unselect ¤

unselect() -> None

Unselect this row.

Source code in src/devboard/_internal/datatable.py
129
130
131
def unselect(self) -> None:
    """Unselect this row."""
    self.checkbox.uncheck()

SelectableRow dataclass ¤

SelectableRow(
    table: SelectableRowsDataTable[_ItemT],
    key: RowKey,
    _snapshot: list | None = None,
    _item_snapshot: _ItemT | object = _MISSING_ITEM,
)

Bases: Generic[_ItemT]

A selectable row.

Methods:

  • remove –

    Ask the table to remove this row on the UI thread.

  • select –

    Select this row.

  • toggle_select –

    Toggle-select this row.

  • unselect –

    Unselect this row.

Attributes:

app property ¤

app: App

Textual application.

checkbox property ¤

checkbox: Checkbox

Row checkbox.

data property ¤

data: list

Row data (without checkbox).

index property ¤

index: int

Row index.

item property ¤

item: _ItemT

Item that produced this row.

Raises:

  • ValueError –

    If the row was added without a source item.

key instance-attribute ¤

key: RowKey

The row key.

next property ¤

next: SelectableRow[_ItemT]

Next row (down).

previous property ¤

previous: SelectableRow[_ItemT]

Previous row (up).

selected property ¤

selected: bool

Whether this row is selected.

table instance-attribute ¤

table: SelectableRowsDataTable[_ItemT]

The data table containing this row.

remove ¤

remove() -> None

Ask the table to remove this row on the UI thread.

Source code in src/devboard/_internal/datatable.py
142
143
144
def remove(self) -> None:
    """Ask the table to remove this row on the UI thread."""
    self.table.post_message(_RemoveRow(self.key))

select ¤

select() -> None

Select this row.

Source code in src/devboard/_internal/datatable.py
125
126
127
def select(self) -> None:
    """Select this row."""
    self.checkbox.check()

toggle_select ¤

toggle_select() -> bool

Toggle-select this row.

Source code in src/devboard/_internal/datatable.py
133
134
135
def toggle_select(self) -> bool:
    """Toggle-select this row."""
    return self.checkbox.toggle()

unselect ¤

unselect() -> None

Unselect this row.

Source code in src/devboard/_internal/datatable.py
129
130
131
def unselect(self) -> None:
    """Unselect this row."""
    self.checkbox.uncheck()

SelectableRowsDataTable ¤

SelectableRowsDataTable(*args: Any, **kwargs: Any)

Bases: DataTable, Generic[_ItemT]

Data table with selectable rows.

Methods:

Attributes:

Source code in src/devboard/_internal/datatable.py
182
183
184
185
186
187
188
189
190
def __init__(self, *args: Any, **kwargs: Any) -> None:
    """Initialize the table and its row-to-item associations."""
    self._row_items: dict[RowKey, _ItemT] = {}
    self._associated_item: _ItemT | object = _MISSING_ITEM
    self._hidden_rows: dict[RowKey, tuple[list[Any], int | None, Any]] = {}
    self._row_order: list[RowKey] = []
    self._display_order: list[RowKey] = []
    self._row_filter: Callable[[SelectableRow[_ItemT]], bool] | None = None
    super().__init__(*args, **kwargs)

BINDINGS class-attribute instance-attribute ¤

BINDINGS: ClassVar = [
    Binding(
        "space",
        "toggle_select_row",
        "Toggle select",
        show=False,
    ),
    Binding(
        "ctrl+a, *",
        "toggle_select_all",
        "Toggle select all",
        show=False,
    ),
    Binding(
        "exclamation_mark",
        "reverse_select",
        "Reverse selection",
        show=False,
    ),
    Binding(
        "shift+up",
        "toggle_select_up",
        "Expand selection up",
        show=False,
    ),
    Binding(
        "shift+down",
        "toggle_select_down",
        "Expand selection down",
        show=False,
    ),
]

Key bindings for selecting rows.

ROW class-attribute instance-attribute ¤

The class to instantiate selectable rows.

all_rows property ¤

all_rows: Iterator[SelectableRow[_ItemT]]

All rows in insertion order, including hidden rows.

current_row property ¤

current_row: SelectableRow[_ItemT]

Currently selected row.

selectable_rows property ¤

selectable_rows: Iterator[SelectableRow[_ItemT]]

Rows, as selectable ones.

selected_rows property ¤

selected_rows: Iterator[SelectableRow[_ItemT]]

Selected rows.

action_reverse_select ¤

action_reverse_select() -> None

Reverse selection.

Source code in src/devboard/_internal/datatable.py
311
312
313
314
315
def action_reverse_select(self) -> None:
    """Reverse selection."""
    for row in self.selectable_rows:
        row.toggle_select()
    self.force_refresh()

action_toggle_select_all ¤

action_toggle_select_all() -> None

Toggle-select all rows.

Source code in src/devboard/_internal/datatable.py
300
301
302
303
304
305
306
307
308
309
def action_toggle_select_all(self) -> None:
    """Toggle-select all rows."""
    rows = list(self.selectable_rows)
    if all(row.selected for row in rows):
        for row in rows:
            row.unselect()
    else:
        for row in rows:
            row.select()
    self.force_refresh()

action_toggle_select_down ¤

action_toggle_select_down() -> None

Toggle selection down.

Source code in src/devboard/_internal/datatable.py
329
330
331
332
333
334
335
336
337
338
339
def action_toggle_select_down(self) -> None:
    """Toggle selection down."""
    try:
        row = self.current_row
        next_row = row.next
    except CellDoesNotExist:
        pass
    else:
        next_row.toggle_select()
        self.move_cursor(row=next_row.index)
        self.force_refresh()

action_toggle_select_row ¤

action_toggle_select_row() -> None

Toggle-select current row.

Source code in src/devboard/_internal/datatable.py
291
292
293
294
295
296
297
298
def action_toggle_select_row(self) -> None:
    """Toggle-select current row."""
    try:
        row = self.current_row
    except CellDoesNotExist:
        return
    row.toggle_select()
    self.force_refresh()

action_toggle_select_up ¤

action_toggle_select_up() -> None

Toggle selection up.

Source code in src/devboard/_internal/datatable.py
317
318
319
320
321
322
323
324
325
326
327
def action_toggle_select_up(self) -> None:
    """Toggle selection up."""
    try:
        row = self.current_row
        previous_row = row.previous
    except CellDoesNotExist:
        pass
    else:
        previous_row.toggle_select()
        self.move_cursor(row=previous_row.index)
        self.force_refresh()

add_row ¤

add_row(
    *cells: Any,
    height: int | None = 1,
    key: str | None = None,
    label: Any | None = None,
) -> RowKey

Add a row with a checkbox and associate its source item, if set.

Source code in src/devboard/_internal/datatable.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def add_row(
    self,
    *cells: Any,
    height: int | None = 1,
    key: str | None = None,
    label: Any | None = None,
) -> RowKey:
    """Add a row with a checkbox and associate its source item, if set."""
    if key is not None and RowKey(key) in self._hidden_rows:
        raise DuplicateKey(f"The row key {key!r} already exists.")
    row_key = super().add_row(
        Checkbox(),
        *cells,
        height=height,
        key=key if key is not None else uuid4().hex,
        label=label,
    )
    self._row_order.append(row_key)
    self._display_order.append(row_key)
    if self._associated_item is not _MISSING_ITEM:
        self._row_items[row_key] = cast("_ItemT", self._associated_item)
    if self._row_filter is not None and not self._row_filter(self.ROW(table=self, key=row_key)):
        self._hide_row(row_key)
    return row_key

add_rows ¤

add_rows(rows: Iterable[Iterable]) -> list[RowKey]

Add rows.

Automatically insert a column with checkboxes in position 0.

Source code in src/devboard/_internal/datatable.py
220
221
222
223
224
225
def add_rows(self, rows: Iterable[Iterable]) -> list[RowKey]:
    """Add rows.

    Automatically insert a column with checkboxes in position 0.
    """
    return [self.add_row(*row) for row in rows]

clear ¤

clear(
    columns: bool = True,
) -> SelectableRowsDataTable[_ItemT]

Clear rows and optionally columns.

When clearing columns, automatically re-add a column for checkboxes.

Source code in src/devboard/_internal/datatable.py
227
228
229
230
231
232
233
234
235
236
237
238
239
def clear(self, columns: bool = True) -> SelectableRowsDataTable[_ItemT]:  # noqa: FBT001,FBT002
    """Clear rows and optionally columns.

    When clearing columns, automatically re-add a column for checkboxes.
    """
    super().clear(columns)
    self._row_items.clear()
    self._hidden_rows.clear()
    self._row_order.clear()
    self._display_order.clear()
    if columns:
        self.add_column("", key="checkbox")
    return self

filter_rows ¤

filter_rows(
    predicate: Callable[[SelectableRow[_ItemT]], bool]
    | None,
) -> None

Show matching rows, or show every row when the predicate is None.

Each call replaces the previous filter. Hidden rows retain their keys, cells, source items, and selections, but do not participate in row actions. The filter also applies to rows added later.

Source code in src/devboard/_internal/datatable.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
def filter_rows(self, predicate: Callable[[SelectableRow[_ItemT]], bool] | None) -> None:
    """Show matching rows, or show every row when the predicate is `None`.

    Each call replaces the previous filter. Hidden rows retain their keys,
    cells, source items, and selections, but do not participate in row actions.
    The filter also applies to rows added later.
    """
    visible = {row.key for row in self.all_rows if predicate is None or predicate(row)}
    cursor_key = None
    with suppress(CellDoesNotExist):
        cursor_key = self.current_row.key
    self._show_hidden_rows(visible)
    self._row_filter = predicate
    for row_key in self._row_order:
        if row_key not in visible and row_key not in self._hidden_rows:
            self._hide_row(row_key)
    if self.row_count:
        self._sort_visible_rows()
        if cursor_key is not None and cursor_key in visible:
            self.move_cursor(row=self.get_row_index(cursor_key))
    self.force_refresh()

force_refresh ¤

force_refresh() -> None

Force refresh table.

Source code in src/devboard/_internal/datatable.py
412
413
414
415
def force_refresh(self) -> None:
    """Force refresh table."""
    for row in self.selectable_rows:
        self.update_cell(row.key, "checkbox", row.checkbox)

get_row ¤

get_row(row_key: RowKey | str) -> list[Any]

Return row cells, including cells retained while a row is hidden.

Source code in src/devboard/_internal/datatable.py
252
253
254
255
256
257
def get_row(self, row_key: RowKey | str) -> list[Any]:
    """Return row cells, including cells retained while a row is hidden."""
    key = RowKey(row_key) if isinstance(row_key, str) else row_key
    if key in self._hidden_rows:
        return list(self._hidden_rows[key][0])
    return super().get_row(row_key)

remove_row ¤

remove_row(row_key: RowKey | str) -> None

Remove a row and its source-item association.

Source code in src/devboard/_internal/datatable.py
241
242
243
244
245
246
247
248
249
250
def remove_row(self, row_key: RowKey | str) -> None:
    """Remove a row and its source-item association."""
    key = RowKey(row_key) if isinstance(row_key, str) else row_key
    if key in self._hidden_rows:
        del self._hidden_rows[key]
    else:
        super().remove_row(row_key)
    self._row_items.pop(key, None)
    self._row_order.remove(key)
    self._display_order.remove(key)

sort ¤

sort(
    *columns: Any,
    key: Callable[[Any], Any] | None = None,
    reverse: bool = False,
) -> SelectableRowsDataTable[_ItemT]

Sort all rows and retain the active filter.

Source code in src/devboard/_internal/datatable.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
def sort(
    self,
    *columns: Any,
    key: Callable[[Any], Any] | None = None,
    reverse: bool = False,
) -> SelectableRowsDataTable[_ItemT]:
    """Sort all rows and retain the active filter."""
    column_indices = [self.get_column_index(column) for column in columns]

    def sort_key(row_key: RowKey) -> Any:
        cells = self.get_row(row_key)
        values = tuple(cells[index] for index in column_indices) if columns else tuple(cells)
        value = values[0] if len(columns) == 1 else values
        return key(value) if key is not None else value

    self._display_order = sorted(self._row_order, key=sort_key, reverse=reverse)
    self._sort_visible_rows()
    return self

Status dataclass ¤

Status(
    added: list[Path],
    deleted: list[Path],
    modified: list[Path],
    renamed: list[Path],
    typechanged: list[Path],
    untracked: list[Path],
)

Git status data.

Attributes:

added instance-attribute ¤

added: list[Path]

Added files.

deleted instance-attribute ¤

deleted: list[Path]

Deleted files.

modified instance-attribute ¤

modified: list[Path]

Modified files.

renamed instance-attribute ¤

renamed: list[Path]

Renamed files.

typechanged instance-attribute ¤

typechanged: list[Path]

Type-changed files.

untracked instance-attribute ¤

untracked: list[Path]

Untracked files.

get_parser ¤

get_parser() -> ArgumentParser

Return the CLI argument parser.

Returns:

Source code in src/devboard/_internal/cli.py
51
52
53
54
55
56
57
58
59
60
61
62
def get_parser() -> argparse.ArgumentParser:
    """Return the CLI argument parser.

    Returns:
        An argparse parser.
    """
    parser = argparse.ArgumentParser(prog="devboard")
    parser.add_argument("--show-config-dir", action="store_true", help="Show Devboard's configuration directory.")
    parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}")
    parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
    parser.add_argument("board", nargs="?", default=None, help="Board name or path.")
    return parser

main ¤

main(args: list[str] | None = None) -> int

Run the main program.

This function is executed when you type devboard or python -m devboard.

Parameters:

  • args ¤

    (list[str] | None, default: None ) –

    Arguments passed from the command line.

Returns:

  • int –

    An exit code.

Source code in src/devboard/_internal/cli.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def main(args: list[str] | None = None) -> int:
    """Run the main program.

    This function is executed when you type `devboard` or `python -m devboard`.

    Parameters:
        args: Arguments passed from the command line.

    Returns:
        An exit code.
    """
    parser = get_parser()
    opts = parser.parse_args(args=args)
    if opts.show_config_dir:
        print(user_config_dir(appname="devboard"))
        return 0
    app = _make_app(opts.board)
    app.run()
    return 0

row_action ¤

row_action(
    method: Callable[
        Concatenate[
            _ActionColumnT, Row[Any], _ActionParamsT
        ],
        Awaitable[None] | None,
    ],
) -> Callable[
    Concatenate[_ActionColumnT, _ActionParamsT], None
]

Adapt a row callback into a Textual action.

Decorate an action_* method and use the suffix as the binding action. For example, bind open to a decorated action_open method.

Declare the row as the first argument after self and make it positional-only: action_label(self, row, /, label). Binding arguments follow the row. For example, label('feature') calls action_label(row, 'feature').

The decorated method receives each selected row, or the current row when no rows are selected. Devboard runs each synchronous call in a background thread unless the column sets THREADED to false. Async methods run in async workers. When caching is enabled, Devboard updates the cache once after the whole operation, including no-ops and failed calls.

Source code in src/devboard/_internal/board.py
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
def row_action(
    method: Callable[Concatenate[_ActionColumnT, Row[Any], _ActionParamsT], Awaitable[None] | None],
) -> Callable[Concatenate[_ActionColumnT, _ActionParamsT], None]:
    """Adapt a row callback into a Textual action.

    Decorate an `action_*` method and use the suffix as the binding action. For example, bind `open` to a decorated
    `action_open` method.

    Declare the row as the first argument after `self` and make it positional-only: `action_label(self, row, /, label)`.
    Binding arguments follow the row. For example, `label('feature')` calls `action_label(row, 'feature')`.

    The decorated method receives each selected row, or the current row when no rows are selected. Devboard runs each
    synchronous call in a background thread unless the column sets `THREADED` to false. Async methods run in async workers.
    When caching is enabled, Devboard updates the cache once after the whole operation, including no-ops and failed calls.
    """

    @wraps(method)
    def action(column: _ActionColumnT, /, *args: _ActionParamsT.args, **kwargs: _ActionParamsT.kwargs) -> None:
        column._apply_to_rows(_bind_row_action(method, column, *args, **kwargs))

    return action

rows_action ¤

rows_action(
    method: Callable[
        Concatenate[
            _ActionColumnT, list[Row[Any]], _ActionParamsT
        ],
        Awaitable[None] | None,
    ],
) -> Callable[
    Concatenate[_ActionColumnT, _ActionParamsT], None
]

Adapt a batch callback into a Textual action.

Decorate an action_* method and use its suffix in a binding. The method receives one list of selected visible rows, or a list containing the current row when none are selected. Empty tables do not call the method.

Declare the row list as the first argument after self and make it positional-only: action_label(self, rows, /, label). Binding arguments follow the row list. For example, label('feature') calls action_label(rows, 'feature').

Rows are stable snapshots, with the same data, item, remove(), and refresh() API as row_action callbacks. Synchronous methods run in one background thread unless the column sets THREADED to false. Async methods always run in an async worker, so they can await self.app.push_screen_wait() to ask for shared input. Use asyncio.to_thread() for blocking work inside an async method.

When caching is enabled, Devboard updates the cache once after the operation, including no-ops and failed calls.

Source code in src/devboard/_internal/board.py
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
def rows_action(
    method: Callable[Concatenate[_ActionColumnT, list[Row[Any]], _ActionParamsT], Awaitable[None] | None],
) -> Callable[Concatenate[_ActionColumnT, _ActionParamsT], None]:
    """Adapt a batch callback into a Textual action.

    Decorate an `action_*` method and use its suffix in a binding. The method receives one list of selected visible rows,
    or a list containing the current row when none are selected. Empty tables do not call the method.

    Declare the row list as the first argument after `self` and make it positional-only: `action_label(self, rows, /, label)`.
    Binding arguments follow the row list. For example, `label('feature')` calls `action_label(rows, 'feature')`.

    Rows are stable snapshots, with the same `data`, `item`, `remove()`, and `refresh()` API as `row_action` callbacks.
    Synchronous methods run in one background thread unless the column sets `THREADED` to false. Async methods always
    run in an async worker, so they can await `self.app.push_screen_wait()` to ask for shared input.
    Use `asyncio.to_thread()` for blocking work inside an async method.

    When caching is enabled, Devboard updates the cache once after the operation, including no-ops and failed calls.
    """

    @wraps(method)
    def action(column: _ActionColumnT, /, *args: _ActionParamsT.args, **kwargs: _ActionParamsT.kwargs) -> None:
        column._apply_to_row_batch(_bind_row_action(method, column, *args, **kwargs))

    return action