This commit is contained in:
Rajeev Rajeshuni 2026-01-20 19:43:27 +01:00 committed by GitHub
commit 836476a492
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1710 additions and 25 deletions

View file

@ -92,6 +92,7 @@ def _discover_commands() -> List[Type[SupportsCliCommand]]:
("cognee.cli.commands.cognify_command", "CognifyCommand"),
("cognee.cli.commands.delete_command", "DeleteCommand"),
("cognee.cli.commands.config_command", "ConfigCommand"),
("cognee.cli.commands.tui_command", "TuiCommand"),
]
for module_path, class_name in command_modules:

View file

@ -0,0 +1,56 @@
import argparse
from cognee.cli import SupportsCliCommand
from cognee.cli.config import DEFAULT_DOCS_URL
import cognee.cli.echo as fmt
from cognee.cli.exceptions import CliCommandException
class TuiCommand(SupportsCliCommand):
@property
def command_string(self) -> str:
return "tui"
@property
def help_string(self) -> str:
return "Launch the interactive Textual TUI for cognee commands"
@property
def docs_url(self) -> str:
return f"{DEFAULT_DOCS_URL}/usage/tui"
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
# No additional arguments for now
pass
def execute(self, args: argparse.Namespace) -> None:
try:
from textual.app import App
from cognee.cli.tui.home_screen import HomeScreen
from cognee.shared.logging_utils import setup_logging
class CogneeTUI(App):
"""Main TUI application for cognee."""
CSS = """
Screen {
background: $surface;
}
"""
def on_mount(self) -> None:
"""Push the home screen on mount."""
self.push_screen(HomeScreen())
setup_logging(enable_console_logging=False)
app = CogneeTUI()
app.run()
except ImportError:
raise CliCommandException(
"Textual is not installed. Install with: pip install textual",
docs_url=self.docs_url,
)
except Exception as ex:
raise CliCommandException(
f"Failed to launch TUI: {str(ex)}",
docs_url=self.docs_url,
raiseable_exception=ex,
)

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,126 @@
import asyncio
from textual.app import ComposeResult
from textual.widgets import Input, Label, Static, TextArea
from textual.containers import Container, Vertical
from textual.binding import Binding
from cognee.cli.tui.base_screen import BaseTUIScreen
class AddTUIScreen(BaseTUIScreen):
"""TUI screen for adding data to cognee."""
BINDINGS = [
Binding("q", "quit_app", "Quit"),
Binding("escape", "back", "Back"),
Binding("ctrl+s", "submit", "Submit"),
Binding("ctrl+v", "paste", "Paste", show=False),
]
CSS = (
BaseTUIScreen.CSS
+ """
#data-input {
height: 8;
min-height: 8;
}
"""
)
def __init__(self):
super().__init__()
self.is_processing = False
def compose_content(self) -> ComposeResult:
with Container(classes="tui-main-container"):
with Container(classes="tui-title-wrapper"):
yield Static("📥 Add Data to Cognee", classes="tui-title-bordered")
with Vertical(classes="tui-form"):
yield Label(
"Data (text, file path (/path/to/doc), URL, or S3 path (s3://bucket)):",
classes="tui-label-spaced",
)
yield TextArea(
"",
id="data-input",
)
yield Label("Dataset Name:", classes="tui-label-spaced")
yield Input(placeholder="main_dataset", value="main_dataset", id="dataset-input")
yield Static("", classes="tui-status")
def compose_footer(self) -> ComposeResult:
yield Static("Ctrl+S: Add • Esc: Back • q: Quit", classes="tui-footer")
def on_mount(self) -> None:
"""Focus the data input on mount."""
data_input = self.query_one("#data-input", TextArea)
data_input.focus()
def action_back(self) -> None:
"""Go back to home screen."""
if not self.is_processing:
self.app.pop_screen()
def action_quit_app(self) -> None:
"""Quit the entire application."""
self.app.exit()
def action_paste(self) -> None:
"""Handle paste action - Textual handles this automatically."""
pass
def action_submit(self) -> None:
"""Submit the form."""
if not self.is_processing:
self._submit_data()
def _submit_data(self) -> None:
"""Process and submit the data."""
data_input = self.query_one("#data-input", TextArea)
dataset_input = self.query_one("#dataset-input", Input)
status = self.query_one(".tui-status", Static)
data = data_input.text.strip()
dataset_name = dataset_input.value.strip() or "main_dataset"
if not data:
status.update("[red]✗ Please enter data to add[/red]")
return
self.is_processing = True
status.update("[yellow]⏳ Processing...[/yellow]")
# Disable inputs during processing
data_input.disabled = True
dataset_input.disabled = True
# Run async add operation
asyncio.create_task(self._add_data_async(data, dataset_name))
async def _add_data_async(self, data: str, dataset_name: str) -> None:
"""Async function to add data to cognee."""
status = self.query_one(".tui-status", Static)
try:
import cognee
await cognee.add(data=data, dataset_name=dataset_name)
status.update(f"[green]✓ Successfully added data to dataset '{dataset_name}'[/green]")
# Clear the data input after successful add
data_input = self.query_one("#data-input", TextArea)
data_input.clear()
except Exception as e:
status.update(f"[red]✗ Failed to add data: {str(e)}[/red]")
finally:
# Re-enable inputs
self.is_processing = False
data_input = self.query_one("#data-input", TextArea)
dataset_input = self.query_one("#dataset-input", Input)
data_input.disabled = False
dataset_input.disabled = False
data_input.focus()

View file

@ -0,0 +1,45 @@
from textual.screen import Screen
from textual.app import ComposeResult
from textual.widgets import Static
from cognee.version import get_cognee_version
from cognee.cli.tui.common_styles import COMMON_STYLES
class BaseTUIScreen(Screen):
"""Base screen class with constant header for all TUI screens."""
# Subclasses should override this CSS and add their own styles
CSS = (
COMMON_STYLES
+ """
#header {
dock: top;
background: $boost;
color: $text;
content-align: center middle;
border: solid $primary;
text-style: bold;
padding: 1;
}
"""
)
def compose_header(self) -> ComposeResult:
"""Compose the constant header widget."""
version = get_cognee_version()
yield Static(f"🧠 cognee v{version}", id="header")
def compose_content(self) -> ComposeResult:
"""Override this method in subclasses to provide screen-specific content."""
yield from ()
def compose_footer(self) -> ComposeResult:
"""Override this method in subclasses to provide screen-specific footer."""
yield from ()
def compose(self) -> ComposeResult:
"""Compose the screen with header, content, and footer."""
yield from self.compose_header()
yield from self.compose_content()
yield from self.compose_footer()

View file

@ -0,0 +1,169 @@
import asyncio
from textual.app import ComposeResult
from textual.widgets import Input, Label, Static, Checkbox, RadioSet, RadioButton
from textual.containers import Container, Vertical
from textual.binding import Binding
from cognee.cli.tui.base_screen import BaseTUIScreen
from cognee.cli.config import CHUNKER_CHOICES
class CognifyTUIScreen(BaseTUIScreen):
"""TUI screen for cognifying data in cognee."""
BINDINGS = [
Binding("q", "quit_app", "Quit"),
Binding("escape", "back", "Back"),
Binding("ctrl+s", "submit", "Submit"),
]
CSS = (
BaseTUIScreen.CSS
+ """
Checkbox {
margin-top: 1;
margin-bottom: 1;
}
RadioSet {
margin-top: 0;
margin-bottom: 1;
height: auto;
}
RadioButton {
height: 1;
}
"""
)
def __init__(self):
super().__init__()
self.is_processing = False
def compose_content(self) -> ComposeResult:
with Container(classes="tui-main-container"):
with Container(classes="tui-title-wrapper"):
yield Static("⚡ Cognify Data", classes="tui-title-bordered")
with Vertical(classes="tui-form"):
yield Label(
"Dataset Name:", classes="tui-label-spaced"
)
yield Input(
placeholder="Enter the dataset name here.", value="", id="dataset-input"
)
yield Label("Chunker Type:", classes="tui-label-spaced")
with RadioSet(id="chunker-radio"):
for chunker in CHUNKER_CHOICES:
yield RadioButton(chunker, value=(chunker == "TextChunker"))
yield Checkbox("Run in background", id="background-checkbox")
yield Static("", classes="tui-status")
def compose_footer(self) -> ComposeResult:
yield Static("Ctrl+S: Start • Esc: Back • q: Quit", classes="tui-footer")
def on_mount(self) -> None:
"""Focus the dataset input on mount."""
dataset_input = self.query_one("#dataset-input", Input)
dataset_input.focus()
def action_back(self) -> None:
"""Go back to home screen."""
if not self.is_processing:
self.app.pop_screen()
def action_quit_app(self) -> None:
"""Quit the entire application."""
self.app.exit()
def action_submit(self) -> None:
"""Submit the form."""
if not self.is_processing:
self._submit_cognify()
def _submit_cognify(self) -> None:
"""Process and submit the cognify request."""
dataset_input = self.query_one("#dataset-input", Input)
chunker_radio = self.query_one("#chunker-radio", RadioSet)
background_checkbox = self.query_one("#background-checkbox", Checkbox)
status = self.query_one(".tui-status", Static)
dataset_name = dataset_input.value.strip() or None
chunker_type = (
str(chunker_radio.pressed_button.label)
if chunker_radio.pressed_button
else "TextChunker"
)
run_background = background_checkbox.value
self.is_processing = True
status.update("[yellow]⏳ Starting cognification...[/yellow]")
# Disable inputs during processing
dataset_input.disabled = True
chunker_radio.disabled = True
background_checkbox.disabled = True
# Run async cognify operation
asyncio.create_task(self._cognify_async(dataset_name, chunker_type, run_background))
async def _cognify_async(
self, dataset_name: str | None, chunker_type: str, run_background: bool
) -> None:
"""Async function to cognify data."""
status = self.query_one(".tui-status", Static)
from cognee.modules.chunking.TextChunker import TextChunker
try:
# Get chunker class
chunker_class = TextChunker
if chunker_type == "LangchainChunker":
try:
from cognee.modules.chunking.LangchainChunker import LangchainChunker
except ImportError:
LangchainChunker = None
if LangchainChunker is not None:
chunker_class = LangchainChunker
else:
status.update(
"[yellow]⚠ LangchainChunker not available, using TextChunker[/yellow]"
)
elif chunker_type == "CsvChunker":
try:
from cognee.modules.chunking.CsvChunker import CsvChunker
except ImportError:
CsvChunker = None
if CsvChunker is not None:
chunker_class = CsvChunker
else:
status.update("[yellow]⚠ CsvChunker not available, using TextChunker[/yellow]")
# Prepare datasets parameter
datasets = [dataset_name] if dataset_name else None
import cognee
await cognee.cognify(
datasets=datasets,
chunker=chunker_class,
run_in_background=run_background,
)
if run_background:
status.update("[green]✓ Cognification started in background![/green]")
else:
status.update("[green]✓ Cognification completed successfully![/green]")
except Exception as e:
status.update(f"[red]✗ Failed to cognify: {str(e)}[/red]")
finally:
# Re-enable inputs
self.is_processing = False
dataset_input = self.query_one("#dataset-input", Input)
chunker_radio = self.query_one("#chunker-radio", RadioSet)
background_checkbox = self.query_one("#background-checkbox", Checkbox)
dataset_input.disabled = False
chunker_radio.disabled = False
background_checkbox.disabled = False
dataset_input.focus()

View file

@ -0,0 +1,135 @@
"""Common CSS styles for TUI screens to reduce repetition."""
COMMON_STYLES = """
/* Common screen background */
Screen {
background: $surface;
}
/* Common container styles */
.tui-container {
height: 100%;
padding: 1;
}
.tui-bordered-wrapper {
border: solid $primary;
}
.tui-content-container {
height: auto;
padding: 1;
content-align: center middle;
}
/* Main container wrapper - used across all screens */
.tui-main-container {
height: 100%;
background: $surface;
}
/* Title wrapper - centers title elements */
.tui-title-wrapper {
width: 100%;
height: auto;
align: center middle;
content-align: center middle;
}
/* Styled title with border */
.tui-title-bordered {
text-align: center;
width: auto;
color: $accent;
text-style: bold;
padding: 0 10;
border: solid $accent;
}
.tui-form {
width: 100%;
height: auto;
border: solid $primary;
padding: 2;
background: $surface;
}
/* Common title styles */
.tui-title {
text-align: center;
text-style: bold;
color: $accent;
margin-bottom: 2;
width: 100%;
}
/* Common label styles */
.tui-label {
color: $text-muted;
margin-bottom: 1;
}
.tui-label-spaced {
color: $text-muted;
margin-top: 1;
margin-bottom: 1;
}
/* Common input styles */
Input {
width: 100%;
margin-bottom: 1;
}
/* Common button styles */
Button {
margin: 0 1;
}
/* Common status message styles */
.tui-status {
text-align: center;
margin-top: 2;
height: auto;
}
/* Common footer styles */
.tui-footer {
dock: bottom;
padding: 1 0;
background: $boost;
color: $text-muted;
content-align: center middle;
border: solid $primary;
}
/* Common dialog/modal styles */
.tui-dialog {
border: thick $warning;
background: $surface;
padding: 2;
}
.tui-dialog-title {
text-align: center;
text-style: bold;
color: $warning;
margin-bottom: 1;
}
.tui-dialog-message {
text-align: center;
margin-bottom: 1;
}
.tui-dialog-buttons {
align: center middle;
height: 3;
}
/* Common input group styles */
.tui-input-group {
height: auto;
margin-bottom: 2;
}
"""

View file

@ -0,0 +1,404 @@
import argparse
import json
from typing import Any, Optional
from cognee.cli.reference import SupportsCliCommand
from cognee.cli import DEFAULT_DOCS_URL
from cognee.cli.exceptions import CliCommandException
try:
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import DataTable, Input, Label, Button, Static
from textual.containers import Container, Horizontal
from textual.binding import Binding
from cognee.cli.tui.base_screen import BaseTUIScreen
except ImportError:
# Handle case where textual is not installed to prevent import errors at module level
BaseTUIScreen = object
class ConfirmModal(Screen):
"""Modal screen for confirming reset action."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
]
CSS = """
ConfirmModal {
align: center middle;
}
#confirm-dialog {
width: 60;
height: auto;
border: thick $warning;
background: $surface;
padding: 1 2;
}
#confirm-title {
text-align: center;
text-style: bold;
margin-bottom: 1;
}
#confirm-message {
text-align: center;
margin-bottom: 2;
}
.tui-dialog-buttons {
align: center middle;
height: auto;
}
Button {
margin: 0 1;
}
"""
def __init__(self, key: str, default_value: str):
super().__init__()
self.key = key
self.default_value = default_value
def compose(self) -> ComposeResult:
with Container(id="confirm-dialog"):
yield Label("⚠ Reset Configuration", id="confirm-title")
yield Label(f"Are you sure you want to reset '{self.key}'?", id="confirm-message")
yield Label(f"It will revert to: {self.default_value}", classes="dim-text")
with Horizontal(classes="tui-dialog-buttons"):
yield Button("Reset", variant="error", id="confirm-btn")
yield Button("Cancel", variant="default", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "confirm-btn":
self.dismiss(True)
else:
self.dismiss(False)
def action_cancel(self) -> None:
self.dismiss(False)
class ConfigTUIScreen(BaseTUIScreen):
"""Main config TUI screen with inline editing and live data fetching."""
BINDINGS = [
Binding("q", "quit_app", "Quit"),
Binding("escape", "cancel_or_back", "Back/Cancel"),
Binding("e", "edit", "Edit"),
Binding("enter", "confirm_edit", "Confirm", show=False),
Binding("r", "reset", "Reset"),
Binding("up", "cursor_up", "Up", show=False),
Binding("down", "cursor_down", "Down", show=False),
]
CSS = (
BaseTUIScreen.CSS
+ """
DataTable {
height: 1fr;
text-align: center;
}
#inline-edit-container {
display: none;
height: auto;
padding: 0 1;
margin-top: 1;
}
#inline-edit-container.visible {
display: block;
}
#edit-label {
color: $text-muted;
margin-bottom: 1;
}
#inline-input {
width: 100%;
}
.dim-text {
color: $text-muted;
text-align: center;
margin-bottom: 1;
}
"""
)
# Config key mappings: Key -> (Reset Method Name, Default Value)
CONFIG_MAP = {
"llm_provider": ("set_llm_provider", "openai"),
"llm_model": ("set_llm_model", "gpt-5-mini"),
"llm_api_key": ("set_llm_api_key", ""),
"llm_endpoint": ("set_llm_endpoint", ""),
"graph_database_provider": ("set_graph_database_provider", "kuzu"),
"vector_db_provider": ("set_vector_db_provider", "lancedb"),
"vector_db_url": ("set_vector_db_url", ""),
"vector_db_key": ("set_vector_db_key", ""),
"chunk_size": ("set_chunk_size", 1500),
"chunk_overlap": ("set_chunk_overlap", 10),
}
def __init__(self):
super().__init__()
self.editing_key = None # Track which key is being edited
def compose_content(self) -> ComposeResult:
with Container(classes="tui-main-container"):
with Container(classes="tui-title-wrapper"):
yield Static("⚙️ Configuration Manager", classes="tui-title-bordered")
with Container(classes="tui-bordered-wrapper"):
table = DataTable(id="config-table")
table.cursor_type = "row"
table.zebra_stripes = True
yield table
with Container(id="inline-edit-container"):
yield Label("", id="edit-label")
yield Input(placeholder="Enter new value", id="inline-input")
def compose_footer(self) -> ComposeResult:
yield Static(
"↑↓: Navigate • e: Edit • Enter: Save • r: Reset • Esc: Back • q: Quit",
classes="tui-footer",
)
def on_mount(self) -> None:
"""Initialize the table with columns and current data."""
table = self.query_one(DataTable)
table.add_columns("Configuration Key", "Current Value")
self._load_table_data()
table.focus()
def _load_table_data(self) -> None:
"""Fetch real config values and populate the table."""
table = self.query_one(DataTable)
table.clear()
try:
import cognee
# Check if get method exists, otherwise warn
has_get = hasattr(cognee.config, "get")
except ImportError:
has_get = False
self.notify("Could not import cognee config", severity="error")
for key, (_, default_val) in self.CONFIG_MAP.items():
value_display = "N/A"
if has_get:
try:
raw_val = cognee.config.get(key)
if raw_val is None:
raw_val = default_val
value_display = str(raw_val) if raw_val is not None else "(empty)"
except Exception:
value_display = "Error fetching value"
table.add_row(key, value_display, key=key)
def action_cursor_up(self) -> None:
if self.editing_key:
return
self.query_one(DataTable).action_cursor_up()
def action_cursor_down(self) -> None:
if self.editing_key:
return
self.query_one(DataTable).action_cursor_down()
def action_cancel_or_back(self) -> None:
if self.editing_key:
self._cancel_edit()
else:
self.app.pop_screen()
def action_quit_app(self) -> None:
self.app.exit()
def action_edit(self) -> None:
"""Start inline editing for the selected config value."""
if self.editing_key:
return
table = self.query_one(DataTable)
if table.cursor_row < 0:
return
# Get row data using the cursor
row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
current_val = table.get_cell(row_key, list(table.columns.keys())[1]) # Get value column
self.editing_key = str(row_key.value)
# Show edit container
edit_container = self.query_one("#inline-edit-container")
edit_container.add_class("visible")
# Update UI
label = self.query_one("#edit-label", Label)
label.update(f"Editing: [bold]{self.editing_key}[/bold]")
input_widget = self.query_one("#inline-input", Input)
input_widget.value = ""
# Don't put "empty" or "N/A" into the input box to save user deleting it
if current_val not in ["(empty)", "N/A", "Error fetching value"]:
input_widget.value = str(current_val)
input_widget.placeholder = f"Enter new value for {self.editing_key}"
input_widget.focus()
def action_confirm_edit(self) -> None:
"""Confirm the inline edit and save the value."""
if not self.editing_key:
return
input_widget = self.query_one("#inline-input", Input)
value = input_widget.value.strip()
# Allow saving even if empty (might mean unset/empty string)
self._save_config(self.editing_key, value)
self._cancel_edit()
def _cancel_edit(self) -> None:
self.editing_key = None
edit_container = self.query_one("#inline-edit-container")
edit_container.remove_class("visible")
self.query_one("#inline-input", Input).value = ""
self.query_one(DataTable).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "inline-input" and self.editing_key:
self.action_confirm_edit()
def action_reset(self) -> None:
"""Reset the selected config to default."""
table = self.query_one(DataTable)
if table.cursor_row < 0:
return
row_key_obj = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
key = str(row_key_obj.value)
if key not in self.CONFIG_MAP:
self.notify(f"Cannot reset '{key}'", severity="warning")
return
_, default_value = self.CONFIG_MAP[key]
display_default = "(empty)" if default_value == "" else str(default_value)
def handle_confirm_result(confirmed: bool) -> None:
if confirmed:
self._reset_config(key)
self.app.push_screen(ConfirmModal(key, display_default), handle_confirm_result)
def _save_config(self, key: str, value: str) -> None:
"""Save config value and update UI."""
try:
import cognee
# Parse value types (restore JSON behavior)
try:
parsed_value = json.loads(value)
except (json.JSONDecodeError, TypeError):
# If it looks like a boolean but json didn't catch it
if value.lower() == "true":
parsed_value = True
elif value.lower() == "false":
parsed_value = False
else:
parsed_value = value
cognee.config.set(key, parsed_value)
self._update_table_row(key, parsed_value)
self.notify(f"✓ Set {key}", severity="information")
except Exception as e:
self.notify(f"✗ Error setting {key}: {str(e)}", severity="error")
def _reset_config(self, key: str) -> None:
"""Reset config to default using mapped method and update UI."""
try:
import cognee
method_name, default_value = self.CONFIG_MAP[key]
if hasattr(cognee.config, method_name):
method = getattr(cognee.config, method_name)
method(default_value)
# IMPROVEMENT: Update table immediately
self._update_table_row(key, default_value)
self.notify(f"✓ Reset {key}", severity="information")
else:
self.notify(f"✗ Reset method '{method_name}' not found", severity="error")
except Exception as e:
self.notify(f"✗ Failed to reset {key}: {str(e)}", severity="error")
def _update_table_row(self, key: str, value: Any) -> None:
"""Helper to update a specific row's value column visually."""
table = self.query_one(DataTable)
display_val = str(value) if value != "" else "(empty)"
# 'key' was used as the row_key in add_row, so we can address it directly
# The value column is at index 1 (0 is key, 1 is value)
try:
col_key = list(table.columns.keys())[1]
table.update_cell(key, col_key, display_val)
except Exception:
# Fallback if key update fails, reload all
self._load_table_data()
class ConfigTUICommand(SupportsCliCommand):
"""TUI command for config management."""
command_string = "config-tui"
help_string = "Launch interactive TUI for managing cognee configuration"
docs_url = f"{DEFAULT_DOCS_URL}/usage/config-tui"
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
pass
def execute(self, args: argparse.Namespace) -> None:
try:
# Import here to check if Textual is actually installed
from textual.app import App
class ConfigTUIApp(App):
"""Simple app wrapper for config TUI."""
CSS = """
Screen { background: $surface; }
"""
def on_mount(self) -> None:
self.push_screen(ConfigTUIScreen())
app = ConfigTUIApp()
app.run()
except ImportError:
raise CliCommandException(
"Textual is not installed. Install with: pip install textual",
docs_url=self.docs_url,
)
except Exception as ex:
raise CliCommandException(
f"Failed to launch config TUI: {str(ex)}",
docs_url=self.docs_url,
raiseable_exception=ex,
)

View file

@ -0,0 +1,247 @@
import asyncio
from uuid import UUID
from textual.app import ComposeResult
from textual.widgets import Input, Button, Static, Label
from textual.containers import Container, Vertical, Horizontal
from textual.binding import Binding
from cognee.cli.tui.base_screen import BaseTUIScreen
from cognee.modules.data.methods.delete_dataset_by_name import delete_dataset_by_name
from cognee.modules.data.methods.delete_data_by_user import delete_data_by_user
from cognee.modules.users.methods import get_default_user
class DeleteTUIScreen(BaseTUIScreen):
"""Simple delete screen with input fields for dataset name, user ID, or delete all."""
BINDINGS = [
Binding("q", "quit_app", "Quit"),
Binding("escape", "back", "Back"),
Binding("ctrl+s", "delete", "Delete"),
Binding("ctrl+a", "delete_all", "Delete All", priority=True),
]
CSS = (
BaseTUIScreen.CSS
+ """
#button-group {
height: auto;
align: center middle;
margin-top: 2;
}
"""
)
def __init__(self):
super().__init__()
self.is_processing = False
def compose_content(self) -> ComposeResult:
with Container(classes="tui-main-container"):
with Container(classes="tui-title-wrapper"):
yield Static("🗑 Delete Data", classes="tui-title-bordered")
with Vertical(id="delete-form", classes="tui-form"):
with Vertical(classes="tui-input-group"):
yield Label("Dataset Name (optional):", classes="tui-label")
yield Input(
placeholder="Enter dataset name to delete specific dataset",
id="dataset-input",
)
with Vertical(classes="tui-input-group"):
yield Label("User ID (optional):", classes="tui-label")
yield Input(
placeholder="Enter user ID to delete user's data or leave empty for default user.",
id="user-input",
)
with Horizontal(id="button-group"):
yield Button("Delete", variant="error", id="delete-btn")
yield Button("Delete All", variant="error", id="delete-all-btn")
yield Static("", classes="tui-status")
def compose_footer(self) -> ComposeResult:
yield Static(
"Ctrl+s: Delete • Ctrl+a: Delete All • Esc: Back • q: Quit", classes="tui-footer"
)
def on_mount(self) -> None:
"""Focus the dataset input on mount."""
dataset_input = self.query_one("#dataset-input", Input)
dataset_input.focus()
def action_back(self) -> None:
"""Go back to home screen."""
if not self.is_processing:
self.app.pop_screen()
def action_quit_app(self) -> None:
"""Quit the entire application."""
self.app.exit()
def action_delete(self) -> None:
"""Delete the dataset."""
if not self.is_processing:
self._handle_delete()
def action_delete_all(self) -> None:
"""Delete all data."""
if not self.is_processing:
self._handle_delete_all()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if self.is_processing:
return
if event.button.id == "delete-btn":
self._handle_delete()
elif event.button.id == "delete-all-btn":
self._handle_delete_all()
elif event.button.id == "cancel-btn":
self.app.pop_screen()
def _handle_delete(self) -> None:
status = self.query_one(".tui-status", Static)
status.update("🔍 Starting the deletion process...")
"""Handle delete operation for dataset or user."""
if self.is_processing:
return
dataset_input = self.query_one("#dataset-input", Input)
user_input = self.query_one("#user-input", Input)
dataset_name = dataset_input.value.strip() or None
user_id = user_input.value.strip() or None
if not dataset_name and not user_id:
status.update("⚠️ Please enter a dataset name or user ID")
return
self.is_processing = True
status.update("🔍 Checking data to delete...")
# Run async delete operation
asyncio.create_task(self._delete_async(dataset_name, user_id))
async def _delete_async(self, dataset_name: str | None, user_id: str | None) -> None:
"""Async function to delete data."""
status = self.query_one(".tui-status", Static)
try:
if user_id is None:
user = await get_default_user()
resolved_user_id = user.id
else:
resolved_user_id = UUID(user_id)
if dataset_name:
await delete_dataset_by_name(dataset_name, resolved_user_id)
status.update(f"✓ Successfully deleted dataset '{dataset_name}'.")
else:
await delete_data_by_user(resolved_user_id)
status.update(f"✓ Successfully deleted all data for user {resolved_user_id}.")
except Exception as e:
status.update(f"✗ Error: {str(e)}")
finally:
self.is_processing = False
self.clear_input()
def _handle_delete_all(self) -> None:
"""Handle delete all operation with confirmation."""
if self.is_processing:
return
user_input = self.query_one("#user-input", Input)
user_id = user_input.value.strip() or None
def handle_confirm(confirmed: bool) -> None:
if confirmed:
asyncio.create_task(self._perform_delete_all(user_id))
self.app.push_screen(DeleteAllConfirmModal(), handle_confirm)
async def _perform_delete_all(self, user_id: str | None) -> None:
"""Perform the actual delete all operation."""
status = self.query_one(".tui-status", Static)
self.is_processing = True
try:
status.update("🔍 Deleting all data...")
if user_id is None:
user = await get_default_user()
resolved_user_id = user.id
else:
resolved_user_id = UUID(user_id)
await delete_data_by_user(resolved_user_id)
status.update(f"✓ Successfully deleted all data by user {resolved_user_id}")
# Clear inputs
dataset_input = self.query_one("#dataset-input", Input)
user_input = self.query_one("#user-input", Input)
dataset_input.value = ""
user_input.value = ""
except Exception as e:
status.update(f"✗ Error: {str(e)}")
finally:
self.is_processing = False
def clear_input(self) -> None:
dataset_input = self.query_one("#dataset-input", Input)
user_input = self.query_one("#user-input", Input)
dataset_input.value = ""
user_input.value = ""
class DeleteAllConfirmModal(BaseTUIScreen):
"""Modal screen for confirming delete all action."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
]
CSS = (
BaseTUIScreen.CSS
+ """
DeleteAllConfirmModal {
align: center middle;
}
#confirm-dialog {
width: 60;
height: 20;
border: thick $error;
background: $surface;
padding: 2;
}
#confirm-title {
text-align: center;
text-style: bold;
color: $error;
margin-bottom: 1;
}
#confirm-warning {
text-align: center;
color: $warning;
text-style: bold;
margin-bottom: 2;
}
"""
)
def compose_content(self) -> ComposeResult:
with Container(id="confirm-dialog"):
yield Label("⚠️ DELETE ALL DATA", id="confirm-title")
yield Label("This will delete ALL data from cognee", classes="tui-dialog-message")
yield Label("This operation is IRREVERSIBLE!", id="confirm-warning")
with Horizontal(classes="tui-dialog-buttons"):
yield Button("Delete All", variant="error", id="confirm-btn")
yield Button("Cancel", variant="default", id="cancel-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "confirm-btn":
self.dismiss(True)
else:
self.dismiss(False)
def action_cancel(self) -> None:
self.dismiss(False)

View file

@ -0,0 +1,188 @@
from textual.app import ComposeResult
from textual.widgets import ListView, ListItem, Static
from textual.containers import Container, Horizontal
from textual.binding import Binding
from cognee.cli.tui.base_screen import BaseTUIScreen
from cognee.cli.tui.config_screen import ConfigTUIScreen
from cognee.cli.tui.add_screen import AddTUIScreen
from cognee.cli.tui.cognify_screen import CognifyTUIScreen
from cognee.cli.tui.search_screen import SearchTUIScreen
from cognee.cli.tui.delete_screen import DeleteTUIScreen
def make_item(icon: str, command: str, description: str) -> ListItem:
"""Compose a ListItem that contains a Horizontal container with 3 children."""
return ListItem(
Horizontal(
Static(icon, classes="cmd-icon"),
Static(command, classes="cmd-name"),
Static(description, classes="cmd-desc"),
classes="cmd-row",
)
)
class HomeScreen(BaseTUIScreen):
"""Home screen with command selection menu."""
BINDINGS = [
Binding("q", "quit_app", "Quit"),
Binding("escape", "quit_app", "Quit"),
Binding("enter", "select", "Select"),
Binding("up", "nav_up", "Up", priority=True),
Binding("down", "nav_down", "Down", priority=True),
]
CSS = (
BaseTUIScreen.CSS
+ """
ListView > ListItem {
width: 100%;
padding: 0;
margin: 0;
}
.menu-list > ListItem {
width: 100%;
padding: 0;
margin: 0;
}
.menu-list {
height: auto;
background: $surface;
border: none;
padding: 0 0;
}
ListView {
height: auto;
background: $surface;
border: none;
padding: 0 0;
}
ListItem {
background: $surface;
color: $text;
width: 100%;
height: 3;
}
ListItem:focus {
outline: none;
}
ListItem.highlighted {
background: $primary-darken-3;
color: $text;
}
ListItem.highlighted .cmd-name {
text-style: bold;
color: $accent;
}
.cmd-row {
width: 100%;
height: auto;
align-horizontal: left;
align-vertical: middle;
height: 1fr;
}
.cmd-icon {
width: 4;
text-align: center;
color: $text-muted;
}
.cmd-name {
width: 14;
padding-left: 1;
text-style: bold;
}
.cmd-desc {
width: 1fr;
overflow: auto;
padding-left: 1;
color: $text-muted;
}
"""
)
def __init__(self):
super().__init__()
self.lv = None
self.current_index = 0
def compose_content(self) -> ComposeResult:
with Container(classes="tui-main-container"):
with Container(classes="tui-title-wrapper"):
yield Static("Select Command", classes="tui-title-bordered")
with Container(classes="tui-bordered-wrapper"):
yield ListView(
make_item("📥", "add", "Add data to cognee"),
make_item("🔍", "search", "Search data in cognee"),
make_item("", "cognify", "Process data in cognee"),
make_item("🗑️", "delete", "Delete data from cognee"),
make_item("⚙️", "config", "Configure cognee settings"),
id="menu-list",
classes="menu-list",
)
def compose_footer(self) -> ComposeResult:
yield Static("↑↓: Navigate • Enter: Select • q/Esc: Quit", classes="tui-footer")
def on_mount(self) -> None:
"""Focus the list view on mount."""
self.lv = self.query_one(ListView)
self.current_index = 0
self.set_focus(self.lv)
self._apply_highlight()
def _apply_highlight(self) -> None:
lv = self.lv
children = list(lv.children)
self.lv.index = self.current_index
for idx, item in enumerate(children):
if idx == self.current_index:
item.add_class("highlighted")
else:
item.remove_class("highlighted")
def action_nav_up(self) -> None:
self.current_index = max(0, self.current_index - 1)
self._apply_highlight()
def action_nav_down(self) -> None:
children = list(self.lv.children)
self.current_index = min(len(children) - 1, self.current_index + 1)
self._apply_highlight()
def on_list_view_selected(self, event: ListView.Selected) -> None:
selected_index = event.index
self.current_index = selected_index
self._apply_highlight()
if selected_index == 0: # add
self.app.push_screen(AddTUIScreen())
elif selected_index == 1: # search
self.app.push_screen(SearchTUIScreen())
elif selected_index == 2: # cognify
self.app.push_screen(CognifyTUIScreen())
elif selected_index == 3: # delete
self.app.push_screen(DeleteTUIScreen())
elif selected_index == 4: # config
self.app.push_screen(ConfigTUIScreen())
else:
self.app.exit()
def action_select(self) -> None:
"""Select the current item."""
list_view = self.query_one(ListView)
list_view.action_select_cursor()
def action_quit_app(self) -> None:
"""Quit the entire application."""
self.app.exit()

View file

@ -0,0 +1,179 @@
import asyncio
from textual.app import ComposeResult
from textual.widgets import Input, Label, Static, Select
from textual.containers import Container, Vertical, ScrollableContainer
from textual.binding import Binding
from cognee.cli.tui.base_screen import BaseTUIScreen
class SearchTUIScreen(BaseTUIScreen):
"""Simple search screen with query input and results display."""
BINDINGS = [
Binding("q", "quit_app", "Quit"),
Binding("escape", "back", "Back"),
Binding("ctrl+s", "search", "Search"),
]
CSS = (
BaseTUIScreen.CSS
+ """
#search-form {
height: auto;
border: solid $primary;
padding: 1;
margin-bottom: 1;
}
#search-form Label {
margin-bottom: 0;
color: $text-muted;
}
#search-form Input, #search-form Select {
margin-bottom: 1;
}
#results-container {
height: 1fr;
border: solid $primary;
padding: 1;
}
#results-title {
text-style: bold;
color: $accent;
margin-bottom: 1;
}
#results-content {
height: 1fr;
overflow-y: auto;
}
"""
)
def __init__(self):
super().__init__()
self.is_searching = False
def compose_content(self) -> ComposeResult:
with Container(classes="tui-main-container"):
with Container(classes="tui-title-wrapper"):
yield Static("🔍 Search Data", classes="tui-title-bordered")
with Vertical(id="search-form"):
yield Label("Query:", classes="tui-label-spaced")
yield Input(placeholder="Enter your search query...", id="query-input")
yield Label("Search Type:", classes="tui-label-spaced")
yield Select(
[
("Graph Completion (Recommended)", "GRAPH_COMPLETION"),
("RAG Completion", "RAG_COMPLETION"),
("Chunks", "CHUNKS"),
("Summaries", "SUMMARIES"),
("Coding Rules", "CODING_RULES"),
],
value="GRAPH_COMPLETION",
id="query-type-select",
)
with Container(id="results-container"):
yield Static("Results", id="results-title")
with ScrollableContainer(id="results-content"):
yield Static(
"Enter a query and click Search to see results.", id="results-text"
)
def compose_footer(self) -> ComposeResult:
yield Static("Ctrl+S: Search • Esc: Back • q: Quit", classes="tui-footer")
def on_mount(self) -> None:
"""Focus the query input on mount."""
query_input = self.query_one("#query-input", Input)
query_input.focus()
def action_back(self) -> None:
"""Go back to home screen."""
self.app.pop_screen()
def action_quit_app(self) -> None:
"""Quit the entire application."""
self.app.exit()
def action_search(self) -> None:
"""Trigger search action."""
if not self.is_searching:
self._perform_search()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle Enter key in query input."""
if event.input.id == "query-input":
self._perform_search()
def _perform_search(self) -> None:
"""Perform the search operation."""
if self.is_searching:
return
query_input = self.query_one("#query-input", Input)
query_text = query_input.value.strip()
if not query_text:
self.notify("Please enter a search query", severity="warning")
return
query_type_select = self.query_one("#query-type-select", Select)
query_type = str(query_type_select.value)
self.is_searching = True
self.notify(f"Searching for: {query_text}", severity="information")
# Update results to show loading
results_text = self.query_one("#results-text", Static)
results_text.update("🔍 Searching...")
# Run async search
asyncio.create_task(self._async_search(query_text, query_type))
async def _async_search(self, query_text: str, query_type: str) -> None:
"""Async search operation."""
try:
import cognee
from cognee.modules.search.types import SearchType
# Convert string to SearchType enum
search_type = SearchType[query_type]
# Perform search
results = await cognee.search(
query_text=query_text,
query_type=search_type,
system_prompt_path="answer_simple_question.txt",
top_k=10,
)
# Update results display
results_text = self.query_one("#results-text", Static)
if not results:
results_text.update("No results found for your query.")
else:
# Format results based on type
if query_type in ["GRAPH_COMPLETION", "RAG_COMPLETION"]:
formatted = "\n\n".join([f"📝 {result}" for result in results])
elif query_type == "CHUNKS":
formatted = "\n\n".join(
[f"📄 Chunk {i + 1}:\n{result}" for i, result in enumerate(results)]
)
else:
formatted = "\n\n".join([f"{result}" for result in results])
results_text.update(formatted)
self.notify(f"✓ Found {len(results)} result(s)", severity="information")
except Exception as e:
results_text = self.query_one("#results-text", Static)
results_text.update(f"❌ Error: {str(e)}")
self.notify(f"Search failed: {str(e)}", severity="error")
finally:
self.is_searching = False

View file

@ -3,8 +3,7 @@ import asyncio
from os import path
import tempfile
from uuid import UUID
from typing import Optional
from typing import AsyncGenerator, List
from typing import Optional, AsyncGenerator, List, Union
from contextlib import asynccontextmanager
from sqlalchemy.orm import joinedload
from sqlalchemy.exc import NoResultFound
@ -257,35 +256,46 @@ class SQLAlchemyAdapter:
return [schema[0] for schema in result.fetchall()]
return []
async def delete_entity_by_id(
self, table_name: str, data_id: UUID, schema_name: Optional[str] = "public"
async def delete_entities_by_id(
self,
table_name: str,
data_id: Union[UUID, List[UUID]], # Supports a single UUID or a List of UUIDs
schema_name: Optional[str] = "public",
):
"""
Delete an entity from the specified table based on its unique ID.
Delete one or more entities from the specified table based on their ID(s).
Parameters:
-----------
- table_name (str): The name of the table from which to delete the entity.
- data_id (UUID): The unique identifier of the entity to be deleted.
- schema_name (Optional[str]): The name of the schema where the table resides,
defaults to 'public'. (default 'public')
- table_name (str): The name of the table from which to delete the entities.
- data_id (Union[UUID, List[UUID]]): The unique identifier(s) to be deleted.
- schema_name (Optional[str]): The name of the schema where the table resides.
"""
if self.engine.dialect.name == "sqlite":
async with self.get_async_session() as session:
TableModel = await self.get_table(table_name, schema_name)
# Foreign key constraints are disabled by default in SQLite (for backwards compatibility),
# so must be enabled for each database connection/session separately.
# Ensure data_ids is a list for the WHERE clause logic
if isinstance(data_id, list):
data_ids_to_delete = data_id
else:
data_ids_to_delete = [data_id]
if not data_ids_to_delete:
return
async with self.get_async_session() as session:
TableModel = await self.get_table(table_name, schema_name)
# Handle SQLite's foreign key requirement
if self.engine.dialect.name == "sqlite":
from sqlalchemy import text
await session.execute(text("PRAGMA foreign_keys = ON;"))
await session.execute(TableModel.delete().where(TableModel.c.id == data_id))
await session.commit()
else:
async with self.get_async_session() as session:
TableModel = await self.get_table(table_name, schema_name)
await session.execute(TableModel.delete().where(TableModel.c.id == data_id))
await session.commit()
# Construct the DELETE statement using the 'in_()' operator
stmt = TableModel.delete().where(TableModel.c.id.in_(data_ids_to_delete))
# Execute and commit
await session.execute(stmt)
await session.commit()
async def delete_data_entity(self, data_id: UUID):
"""

View file

@ -16,6 +16,8 @@ from .get_dataset_ids import get_dataset_ids
# Delete
from .delete_dataset import delete_dataset
from .delete_dataset_by_name import delete_dataset_by_name
from .delete_data_by_user import delete_data_by_user
from .delete_data import delete_data
# Create

View file

@ -0,0 +1,33 @@
from uuid import UUID
from sqlalchemy import select
from cognee.infrastructure.databases.relational import get_relational_engine
from cognee.modules.data.models import Dataset
from cognee.modules.users.methods import get_user
from cognee.shared.logging_utils import get_logger
logger = get_logger()
async def delete_data_by_user(user_id: UUID):
"""
Delete all datasets and their associated data for a specific user.
This function performs a comprehensive deletion of all data owned by a user,
including datasets, data entries, and all related records in the database.
Args:
user_id: UUID of the user whose data should be deleted
Raises:
EntityNotFoundError: If user is not found
"""
db_engine = get_relational_engine()
async with db_engine.get_async_session() as session:
# Verify user exists
await get_user(user_id)
# Get all datasets owned by this user
datasets_query = select(Dataset.id).where(Dataset.owner_id == user_id)
user_datasets_ids = (await session.execute(datasets_query)).scalars().all()
if user_datasets_ids:
await db_engine.delete_entities_by_id(Dataset.__table__.name, user_datasets_ids)

View file

@ -0,0 +1,28 @@
from uuid import UUID
from sqlalchemy import select
from cognee.infrastructure.databases.relational import get_relational_engine
from ..models import Dataset
async def delete_dataset_by_name(dataset_name: str, user_id: UUID):
"""
Delete a single dataset by name for a specific user.
Args:
dataset_name: The name of the dataset to delete (must be a single string).
user_id: UUID of the dataset owner.
"""
db_engine = get_relational_engine()
async with db_engine.get_async_session() as session:
dataset_id = (
await session.scalars(
select(Dataset.id)
.filter(Dataset.owner_id == user_id)
.filter(Dataset.name == dataset_name)
)
).first()
# Keeping this out of the first session, since delete_entities_by_id creates another session.
if dataset_id:
await db_engine.delete_entities_by_id(Dataset.__table__.name, dataset_id)

View file

@ -285,7 +285,7 @@ def cleanup_old_logs(logs_dir, max_files):
return False
def setup_logging(log_level=None, name=None):
def setup_logging(log_level=None, name=None, enable_console_logging=True):
"""Sets up the logging configuration with structlog integration.
Args:
@ -465,7 +465,8 @@ def setup_logging(log_level=None, name=None):
root_logger = logging.getLogger()
if root_logger.hasHandlers():
root_logger.handlers.clear()
root_logger.addHandler(stream_handler)
if enable_console_logging:
root_logger.addHandler(stream_handler)
# Note: root logger needs to be set at NOTSET to allow all messages through and specific stream and file handlers
# can define their own levels.

View file

@ -161,6 +161,7 @@ redis = ["redis>=5.0.3,<6.0.0"]
monitoring = ["sentry-sdk[fastapi]>=2.9.0,<3", "langfuse>=2.32.0,<3"]
docling = ["docling>=2.54", "transformers>=4.55"]
textual = ["textual>=6.6.0"]
[project.urls]
Homepage = "https://www.cognee.ai"

61
uv.lock generated
View file

@ -1093,6 +1093,9 @@ scraping = [
{ name = "protego" },
{ name = "tavily-python" },
]
textual = [
{ name = "textual" },
]
[package.metadata]
requires-dist = [
@ -1193,6 +1196,7 @@ requires-dist = [
{ name = "structlog", specifier = ">=25.2.0,<26" },
{ name = "tavily-python", marker = "extra == 'scraping'", specifier = ">=0.7.12" },
{ name = "tenacity", specifier = ">=9.0.0" },
{ name = "textual", marker = "extra == 'textual'", specifier = ">=6.6.0" },
{ name = "tiktoken", specifier = ">=0.8.0,<1.0.0" },
{ name = "transformers", marker = "extra == 'codegraph'", specifier = ">=4.46.3,<5" },
{ name = "transformers", marker = "extra == 'docling'", specifier = ">=4.55" },
@ -1207,7 +1211,7 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.34.0,<1.0.0" },
{ name = "websockets", specifier = ">=15.0.1,<16.0.0" },
]
provides-extras = ["api", "distributed", "scraping", "neo4j", "neptune", "postgres", "postgres-binary", "notebook", "langchain", "llama-index", "huggingface", "ollama", "mistral", "anthropic", "deepeval", "posthog", "groq", "llama-cpp", "chromadb", "docs", "codegraph", "evals", "graphiti", "aws", "dlt", "baml", "dev", "debug", "redis", "monitoring", "docling"]
provides-extras = ["api", "distributed", "scraping", "neo4j", "neptune", "postgres", "postgres-binary", "notebook", "langchain", "llama-index", "huggingface", "ollama", "mistral", "anthropic", "deepeval", "posthog", "groq","llama-cpp", "chromadb", "docs", "codegraph", "evals", "graphiti", "aws", "dlt", "baml", "dev", "debug", "redis", "monitoring", "docling", "textual"]
[[package]]
name = "colorama"
@ -3802,6 +3806,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/c9/556846b9d112a3387397850d5560f5ec63464508c6aa068257f0516159d0/limits-4.8.0-py3-none-any.whl", hash = "sha256:de43d24969a0050b859dd29bbd61bd807a5de3ed9255f666aec1ea3dd3fc407e", size = 62028, upload-time = "2025-04-23T21:00:26.017Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
]
[[package]]
name = "litellm"
version = "1.80.0"
@ -4157,6 +4173,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "marko"
version = "2.2.2"
@ -4323,6 +4344,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@ -8622,6 +8655,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" },
]
[[package]]
name = "textual"
version = "6.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/2f/f0b408f227edca21d1996c1cd0b65309f0cbff44264aa40aded3ff9ce2e1/textual-6.6.0.tar.gz", hash = "sha256:53345166d6b0f9fd028ed0217d73b8f47c3a26679a18ba3b67616dcacb470eec", size = 1579327, upload-time = "2025-11-10T17:50:00.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/b3/95ab646b0c908823d71e49ab8b5949ec9f33346cee3897d1af6be28a8d91/textual-6.6.0-py3-none-any.whl", hash = "sha256:5a9484bd15ee8a6fd8ac4ed4849fb25ee56bed2cecc7b8a83c4cd7d5f19515e5", size = 712606, upload-time = "2025-11-10T17:49:58.391Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
@ -9149,6 +9199,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "uc-micro-py"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
]
[[package]]
name = "unstructured"
version = "0.18.27"