Skip to content

Textual Python : Guide Complet pour Applications Terminal Modernes

Textual Python Framework Framework moderne pour applications terminal interactives

Scénario réel : Vous gérez une infrastructure avec :

  • 🖥️ 10+ serveurs Linux
  • 📊 20+ services à surveiller
  • 🔍 Des logs à analyser en continu
  • ⚡ Des actions rapides à exécuter

Problème : Vous utilisez 15 scripts bash séparés, c’est :

  • ❌ Difficile à maintenir
  • ❌ Peu intuitif pour l’équipe
  • ❌ Pas de visualisation temps réel

Solution Textual : Une seule application terminal qui :

  • ✅ Centralise toutes les fonctionnalités
  • ✅ Offre une interface graphique intuitive
  • ✅ Affiche des graphiques temps réel
  • ✅ Permet des contrôles rapides

Dans ce guide complet, vous apprendrez à :

  1. 🏗️ Structurer des projets Textual professionnels
  2. 🎨 Créer des interfaces avec TCSS avancé
  3. ⚡ Gérer les événements asynchrones
  4. 📊 Traiter des données temps réel
  5. 🚀 Déployer en production

  1. 🚀 Installation & Premier Projet
  2. 🏗️ Structure de Projet Professionnelle
  3. 🧩 Widgets Essentiels
  4. 🎨 TCSS : Design dans le Terminal
  5. ⚡ Gestion d’Événements
  6. 📊 Projet Complet : Dashboard Monitoring
  7. 📋 Quiz : Testez Vos Connaissances
  8. 📝 Exercices Pratiques
  9. 🚀 Déploiement & Production

Terminal window
# 1. Créer et activer un environnement virtuel (ESSENTIEL)
python -m venv .venv
# 2. Activer selon votre OS
source .venv/bin/activate # Linux/Mac
# OU
.venv\Scripts\activate # Windows
# 3. Installer Textual avec outils de développement
pip install "textual[dev]"
# 4. Vérifier l'installation
python -c "import textual; print(f'✅ Textual {textual.__version__} installé')"
# 5. Voir les démos intégrées
python -m textual

⚠️ Pourquoi un environnement virtuel ?

  • Isolation des dépendances
  • Pas de conflits entre projets
  • Reproducibilité exacte
  • Sécurité (pas besoin de droits admin)
👁️ Voir le code (cliquez pour développer)
premier_app.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Label, Button
class PremiereApp(App):
"""Première application Textual - Simple et fonctionnelle."""
CSS = """
Screen {
background: #1a1a2e;
}
#titre {
text-align: center;
color: cyan;
margin: 2;
text-style: bold;
}
#bouton {
width: 30;
margin: 1 auto;
background: #3498db;
color: white;
}
#bouton:hover {
background: #2980b9;
}
"""
def compose(self) -> ComposeResult:
"""Construire l'interface."""
yield Header()
yield Label("🎉 Ma Première App Textual !", id="titre")
yield Button("Cliquez-moi", id="bouton")
yield Footer()
async def on_button_pressed(self, event):
"""Gestion du clic sur le bouton."""
self.query_one("#titre", Label).update("✅ Clic réussi ! Bienvenue dans Textual !")
if __name__ == "__main__":
app = PremiereApp()
app.run()

Exécution :

Terminal window
python premier_app.py

Résultat : Une application terminal complète avec :

  • En-tête et pied de page automatiques
  • Bouton interactif
  • Design professionnel
  • Quitter avec Ctrl+Q

🏗️ Structure de Projet Professionnelle

Section titled “🏗️ Structure de Projet Professionnelle”
📁 Arborescence Optimisée
Terminal window
monapp-textual/
├── 📄 README.md # Documentation
├── 📄 pyproject.toml # Dépendances (Poetry)
├── 📄 requirements.txt # Dépendances (pip)
├── 📄 setup.py # Installation package
├── 📁 src/ # Code source
└── 📁 monapp/
├── 📄 __init__.py
├── 📄 __main__.py # Point d'entrée
├── 📄 app.py # Application principale
├── 📁 widgets/ # Widgets personnalisés
├── 📄 __init__.py
├── 📄 charts.py # Graphiques
└── 📄 forms.py # Formulaires
├── 📁 screens/ # Écrans/Views
├── 📄 __init__.py
├── 📄 dashboard.py
└── 📄 settings.py
├── 📁 styles/ # Fichiers TCSS
├── 📄 base.tcss
├── 📄 widgets.tcss
└── 📁 themes/
├── 📄 dark.tcss
└── 📄 light.tcss
└── 📁 utils/ # Utilitaires
├── 📄 __init__.py
├── 📄 validators.py
└── 📄 helpers.py
├── 📁 tests/ # Tests
├── 📄 __init__.py
├── 📄 test_app.py
└── 📄 test_widgets.py
├── 📁 assets/ # Ressources
├── 📁 images/
└── 📁 data/
└── 📁 scripts/ # Scripts utilitaires
├── 📄 create_project.sh # Création automatique
└── 📄 run_dev.sh # Lancement dev
📜 create_project.sh (cliquez pour voir)
scripts/create_project.sh
#!/bin/bash
set -e
echo "🚀 Création d'un projet Textual professionnel"
read -p "📝 Nom du projet: " PROJECT_NAME
mkdir -p "$PROJECT_NAME"
cd "$PROJECT_NAME"
# Structure principale
mkdir -p src/"$PROJECT_NAME"/{widgets,screens,models,styles/themes,utils}
mkdir -p {tests,docs,assets/{images,data},scripts}
# README.md
cat > README.md << EOF
# $PROJECT_NAME
Application Textual pour monitoring/management.
## Installation
\`\`\`bash
pip install -r requirements.txt
\`\`\`
## Lancement
\`\`\`bash
python -m $PROJECT_NAME
\`\`\`
EOF
# requirements.txt
cat > requirements.txt << EOF
textual>=0.55.0
rich>=13.0.0
EOF
# Fichier principal
cat > src/"$PROJECT_NAME"/app.py << 'EOF'
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Label
class MainApp(App):
CSS_PATH = "styles/base.tcss"
def compose(self) -> ComposeResult:
yield Header()
yield Label("Bienvenue !", id="welcome")
yield Footer()
async def on_mount(self):
self.title = "Mon App"
if __name__ == "__main__":
MainApp().run()
EOF
# Styles de base
cat > src/"$PROJECT_NAME"/styles/base.tcss << 'EOF'
Screen {
background: #1a1a2e;
color: #ecf0f1;
}
#welcome {
text-align: center;
margin: 2;
color: cyan;
}
EOF
# Scripts utilitaires
cat > scripts/run_dev.sh << 'EOF'
#!/bin/bash
source .venv/bin/activate 2>/dev/null || true
python -m src.monitor_app
EOF
chmod +x scripts/*.sh
echo "✅ Projet '$PROJECT_NAME' créé !"
echo "📋 Commandes:"
echo "1. cd $PROJECT_NAME"
echo "2. python -m venv .venv"
echo "3. source .venv/bin/activate"
echo "4. pip install -r requirements.txt"
echo "5. ./scripts/run_dev.sh"

Utilisation :

Terminal window
chmod +x create_project.sh
./create_project.sh
# Suivez les instructions

WidgetDescriptionUsageExemple
LabelTexte simpleTitres, messagesLabel("Bonjour")
ButtonBouton cliquableActions, validationButton("OK", id="ok")
InputChamp de saisieFormulaire, rechercheInput(placeholder="Nom")
TextAreaZone texte multiligneÉditeur, logsTextArea.code_editor()
DataTableTableau de donnéesDonnées tabulairesDataTable()
SelectListe déroulanteChoix, filtresSelect([("Oui","yes")])
SwitchInterrupteur ON/OFFOptions booléennesSwitch()
ProgressBarBarre de progressionTâches longuesProgressBar()
SparklineGraphique minimalTendancesSparkline([1,2,3])
ListViewListe avec scrollNavigationListView()
TreeArborescenceFichiers, données hiérarchiquesTree("Racine")
MarkdownContenu MarkdownDocumentationMarkdown("# Titre")
📊 Exemple Complet DataTable
datatable_complet.py
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Header, Footer
class TableApp(App):
"""Application avec DataTable avancé."""
CSS = """
DataTable {
height: 100%;
border: solid #3498db;
}
DataTable > .datatable--header {
background: #3498db;
color: white;
text-style: bold;
}
DataTable > .datatable--cursor {
background: #2980b9;
color: white;
}
"""
def compose(self) -> ComposeResult:
yield Header()
table = DataTable()
table.add_columns("ID", "Nom", "Rôle", "Status", "CPU %", "RAM %")
# Données de test
serveurs = [
(1, "web-01", "Serveur Web", "🟢", "45%", "67%"),
(2, "db-01", "Base de données", "🟡", "78%", "89%"),
(3, "cache-01", "Cache Redis", "🟢", "23%", "45%"),
(4, "backup-01", "Backup", "🔴", "12%", "34%"),
(5, "monitor-01", "Monitoring", "🟢", "34%", "56%"),
]
for row in serveurs:
table.add_row(*row)
yield table
yield Footer()
async def on_mount(self):
"""Configuration après montage."""
table = self.query_one(DataTable)
table.cursor_type = "row"
table.zebra_stripes = True
table.fixed_columns = 1
self.title = "Tableau des Serveurs"
self.set_interval(2, self.update_metrics)
def update_metrics(self):
"""Mettre à jour les métriques périodiquement."""
import random
table = self.query_one(DataTable)
for row_key in range(table.row_count):
# Simuler des changements réalistes
cpu = random.randint(10, 90)
ram = random.randint(20, 95)
status = random.choice(["🟢", "🟡", "🔴"])
table.update_cell(row_key, "CPU %", f"{cpu}%")
table.update_cell(row_key, "RAM %", f"{ram}%")
table.update_cell(row_key, "Status", status)
# Colorer les cellules selon les valeurs
if cpu > 80:
table.cell_styling.update_cell(row_key, "CPU %", "color", "#e74c3c")
elif cpu > 60:
table.cell_styling.update_cell(row_key, "CPU %", "color", "#f39c12")
else:
table.cell_styling.update_cell(row_key, "CPU %", "color", "#27ae60")
if __name__ == "__main__":
app = TableApp()
app.run()

Fonctionnalités implémentées :

  • ✅ Tableau avec colonnes fixes
  • ✅ Mise à jour temps réel
  • ✅ Colorisation conditionnelle
  • ✅ Sélection de ligne
  • ✅ Design professionnel

/* COULEURS DE BASE (prédéfinies par Textual) */
$primary: #3498db; /* Bleu principal */
$secondary: #2ecc71; /* Vert */
$accent: #e74c3c; /* Rouge */
$success: #27ae60; /* Succès */
$warning: #f39c12; /* Avertissement */
$error: #e74c3c; /* Erreur */
/* THÈME CLAIR/SOMBRE */
$surface: #1a1a2e; /* Fond (dark) */
$panel: #2d2d44; /* Panneaux */
$boost: #3d3d5a; /* Élévation */
$divider: #34495e; /* Séparateurs */
/* TEXTE */
$text: #ecf0f1; /* Texte principal */
$text-muted: #95a5a6; /* Texte secondaire */
/* BORDURES */
$border: #34495e; /* Bordures normales */
🎨 styles/themes/dark.tcss (cliquez pour voir)
/* styles/themes/dark.tcss - Thème sombre professionnel */
/* ===== VARIABLES DU THÈME ===== */
Screen {
/* Palette sombre */
--primary: #3498db;
--primary-dark: #2980b9;
--secondary: #2ecc71;
--accent: #e74c3c;
--success: #27ae60;
--warning: #f39c12;
--error: #e74c3c;
--info: #3498db;
/* Surfaces */
--surface: #1a1a2e;
--surface-light: #2d2d44;
--surface-lighter: #3d3d5a;
/* Texte */
--text: #ecf0f1;
--text-muted: #95a5a6;
--text-disabled: #7f8c8d;
/* Bordures */
--border: #34495e;
--border-light: #4a5f7a;
/* Ombres */
--shadow: rgba(0, 0, 0, 0.5);
}
/* ===== COMPOSANTS GÉNÉRAUX ===== */
Screen {
background: $surface;
color: $text;
}
Header {
background: $primary;
color: white;
text-style: bold;
border-bottom: solid $border;
}
Footer {
background: $surface-light;
color: $text-muted;
border-top: solid $border;
height: 1;
}
/* ===== BOUTONS ===== */
Button {
background: $surface-light;
color: $text;
border: solid $border;
padding: 0 2;
}
Button:hover {
background: $surface-lighter;
}
Button:focus {
border: double $primary;
}
Button.primary {
background: $primary;
color: white;
border: none;
}
Button.primary:hover {
background: $primary-dark;
}
Button.success {
background: $success;
color: white;
}
Button.warning {
background: $warning;
color: #2c3e50;
}
Button.error {
background: $error;
color: white;
}
/* ===== FORMULAIRES ===== */
Input, Select, TextArea {
background: $surface-light;
color: $text;
border: solid $border;
padding: 0 1;
}
Input:focus, Select:focus, TextArea:focus {
border: double $primary;
}
Input::placeholder {
color: $text-muted;
}
Input.invalid {
border: tall $error;
}
Input.valid {
border: tall $success;
}
/* ===== TABLEAUX ===== */
DataTable {
border: solid $border;
scrollbar-color: $primary;
}
DataTable > .datatable--header {
background: $surface-lighter;
color: $text;
text-style: bold;
border-bottom: solid $border;
}
DataTable > .datatable--cursor {
background: $primary;
color: white;
}
DataTable > .datatable--odd-row {
background: $surface-light;
}
DataTable > .datatable--even-row {
background: $surface;
}
/* ===== CARTES/PANNEAUX ===== */
.card {
border: solid $border;
background: $surface-light;
padding: 1;
margin: 1;
}
.card-title {
color: $primary;
text-style: bold;
border-bottom: solid $border-light;
padding-bottom: 1;
margin-bottom: 1;
}
.card:hover {
border: solid $primary;
background: $surface-lighter;
}
/* ===== NOTIFICATIONS & ALERTES ===== */
.notification-info {
background: $info;
color: white;
}
.notification-success {
background: $success;
color: white;
}
.notification-warning {
background: $warning;
color: #2c3e50;
}
.notification-error {
background: $error;
color: white;
}
/* ===== ANIMATIONS ===== */
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
@keyframes slide-in {
from { offset: 100% 0%; }
to { offset: 0% 0%; }
}
.alert-pulse {
animation: pulse 2s infinite;
}
.slide-in {
animation: slide-in 300ms in_out_cubic;
}
/* ===== UTILITAIRES ===== */
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-bold { text-style: bold; }
.text-italic { text-style: italic; }
.text-muted { color: $text-muted; }
.m-1 { margin: 1; }
.m-2 { margin: 2; }
.mt-1 { margin-top: 1; }
.mb-1 { margin-bottom: 1; }
.p-1 { padding: 1; }
.p-2 { padding: 2; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.hidden { display: none; }
🎭 app_theme_dynamique.py (cliquez pour voir)
app_theme_dynamique.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Label, Button, Select
from textual.containers import Container, Vertical
from textual.css.query import NoMatches
class ThemeApp(App):
"""Application avec changement de thème dynamique."""
# Thèmes disponibles
THEMES = {
"dark": {
"name": "Sombre",
"css": """
Screen { background: #1a1a2e; color: #ecf0f1; }
.card { background: #2d2d44; border: solid #34495e; }
Button { background: #3498db; color: white; }
"""
},
"light": {
"name": "Clair",
"css": """
Screen { background: #f8f9fa; color: #2c3e50; }
.card { background: white; border: solid #bdc3c7; }
Button { background: #2980b9; color: white; }
"""
},
"terminal": {
"name": "Terminal",
"css": """
Screen { background: black; color: #00ff00; }
.card { background: #002200; border: solid #008800; }
Button { background: #008800; color: #00ff00; }
"""
}
}
def compose(self) -> ComposeResult:
yield Header()
with Container(id="main"):
with Vertical(classes="card"):
yield Label("🎨 Choisissez votre thème", classes="card-title")
# Sélecteur de thème
theme_options = [(theme["name"], key) for key, theme in self.THEMES.items()]
yield Select(theme_options, prompt="Sélectionner...", id="theme-select")
yield Button("Appliquer", id="apply-theme", variant="primary")
# Zone de démonstration
yield Label("Démonstration du thème:", classes="mt-1")
with Vertical(classes="demo-area"):
yield Button("Bouton Primaire", classes="primary")
yield Button("Bouton Normal")
yield Label("Texte de démonstration")
yield Footer()
async def on_mount(self):
"""Initialisation."""
self.title = "Démonstration Thèmes"
self.current_theme = "dark"
self.apply_theme("dark")
async def on_button_pressed(self, event):
if event.button.id == "apply-theme":
select = self.query_one("#theme-select", Select)
if select.value:
await self.apply_theme(select.value)
self.notify(f"✅ Thème '{self.THEMES[select.value]['name']}' appliqué", timeout=2)
async def apply_theme(self, theme_key):
"""Appliquer un thème dynamiquement."""
if theme_key not in self.THEMES:
return
self.current_theme = theme_key
# Supprimer l'ancien CSS dynamique s'il existe
try:
old_dynamic = self.query_one("#dynamic-theme")
await old_dynamic.remove()
except NoMatches:
pass
# Ajouter le nouveau CSS
from textual.dom import DOMNode
from textual.css.stylesheet import Ruleset
# Créer un nœud style avec le CSS du thème
style_node = DOMNode()
style_node.id = "dynamic-theme"
# Extraire le CSS du thème
theme_css = self.THEMES[theme_key]["css"]
# Méthode simple : utiliser un widget caché avec le CSS
from textual.widget import Widget
class ThemeStyle(Widget):
"""Widget qui contient juste le CSS du thème."""
DEFAULT_CSS = theme_css
# Ajouter le widget de style
await self.mount(ThemeStyle(), after=self.query_one("#main"))
# Rafraîchir l'affichage
self.refresh()
if __name__ == "__main__":
app = ThemeApp()
app.run()

Fonctionnalités :

  • ✅ 3 thèmes prédéfinis (sombre, clair, terminal)
  • ✅ Changement dynamique en temps réel
  • ✅ Application de CSS à la volée
  • ✅ Interface de démonstration

ÉvénementWidgetDéclencheurMéthode de gestion
Button.PressedButtonClic boutonon_button_pressed()
Input.ChangedInputChangement texteon_input_changed()
Input.SubmittedInputTouche Entréeon_input_submitted()
Select.ChangedSelectChangement sélectionon_select_changed()
DataTable.RowSelectedDataTableSélection ligneon_data_table_row_selected()
DataTable.CellSelectedDataTableSélection celluleon_data_table_cell_selected()
Switch.ChangedSwitchChangement étaton_switch_changed()
ListView.SelectedListViewSélection itemon_list_view_selected()
⚡ app_evenements.py (cliquez pour voir)
app_evenements.py
import asyncio
from datetime import datetime
from textual.app import App, ComposeResult
from textual.widgets import (
Header, Footer, Label, Button,
Input, Select, DataTable, Switch
)
from textual.containers import Container, Grid
from textual.events import Key
class EvenementsApp(App):
"""Démonstration complète des événements Textual."""
CSS = """
Screen {
background: #1a1a2e;
}
.grid-container {
grid-size: 2 3;
grid-gutter: 1;
padding: 1;
}
.demo-panel {
border: solid #34495e;
padding: 1;
height: 100%;
}
.panel-title {
color: #3498db;
text-style: bold;
margin-bottom: 1;
border-bottom: solid #34495e;
}
#log-area {
height: 10;
border: solid #34495e;
background: #2d2d44;
padding: 1;
margin-top: 1;
}
"""
def __init__(self):
super().__init__()
self.log_entries = []
def compose(self) -> ComposeResult:
yield Header()
with Grid(classes="grid-container"):
# Panel 1: Boutons
with Container(classes="demo-panel"):
yield Label("🎮 Boutons", classes="panel-title")
yield Button("Bouton Primaire", id="btn-primary", variant="primary")
yield Button("Bouton Normal", id="btn-normal")
yield Button("Bouton Danger", id="btn-danger", variant="error")
yield Switch(id="toggle-switch")
# Panel 2: Formulaire
with Container(classes="demo-panel"):
yield Label("📝 Formulaire", classes="panel-title")
yield Label("Nom:")
yield Input(placeholder="Votre nom", id="input-nom")
yield Label("Email:")
yield Input(placeholder="email@exemple.com", id="input-email")
yield Label("Rôle:")
yield Select(
[("Développeur", "dev"), ("Admin", "admin"), ("Utilisateur", "user")],
id="select-role"
)
# Panel 3: Tableau
with Container(classes="demo-panel"):
yield Label("📊 Tableau", classes="panel-title")
self.table = DataTable()
self.table.add_columns("ID", "Nom", "Actions")
self.table.add_row("1", "Serveur Web", "🟢")
self.table.add_row("2", "Base de données", "🟡")
self.table.add_row("3", "Cache", "🔴")
yield self.table
# Panel 4: Logs
with Container(classes="demo-panel"):
yield Label("📋 Logs d'Événements", classes="panel-title")
self.log_label = Label("", id="log-area")
yield self.log_label
# Panel 5: Raccourcis clavier
with Container(classes="demo-panel"):
yield Label("⌨️ Raccourcis", classes="panel-title")
yield Label("Ctrl+S: Sauvegarder", classes="text-muted")
yield Label("Ctrl+Q: Quitter", classes="text-muted")
yield Label("F1: Aide", classes="text-muted")
yield Label("Esc: Retour", classes="text-muted")
# Panel 6: Statut
with Container(classes="demo-panel"):
yield Label("📈 Statut", classes="panel-title")
self.status_label = Label("Prêt", id="status")
yield self.status_label
yield Label("Clics: 0", id="click-count")
self.click_count = 0
yield Footer()
def log_event(self, event_name: str, details: str = ""):
"""Ajouter un événement au log."""
timestamp = datetime.now().strftime("%H:%M:%S")
entry = f"[{timestamp}] {event_name}"
if details:
entry += f": {details}"
self.log_entries.append(entry)
# Garder seulement les 8 dernières entrées
if len(self.log_entries) > 8:
self.log_entries.pop(0)
# Mettre à jour l'affichage
self.log_label.update("\n".join(self.log_entries))
# ===== GESTION DES ÉVÉNEMENTS =====
async def on_button_pressed(self, event: Button.Pressed):
"""Clic sur un bouton."""
button_id = event.button.id
self.click_count += 1
if button_id == "btn-primary":
self.log_event("Bouton Primaire", "Clic sur bouton primaire")
self.query_one("#status", Label).update("Action primaire exécutée")
elif button_id == "btn-normal":
self.log_event("Bouton Normal", "Clic sur bouton normal")
self.query_one("#status", Label).update("Action normale exécutée")
elif button_id == "btn-danger":
self.log_event("Bouton Danger", "Clic sur bouton danger")
self.query_one("#status", Label).update("⚠️ Action dangereuse !")
# Mettre à jour le compteur
self.query_one("#click-count", Label).update(f"Clics: {self.click_count}")
async def on_input_changed(self, event: Input.Changed):
"""Changement dans un champ Input."""
input_id = event.input.id
value = event.value
if input_id == "input-nom":
self.log_event("Champ Nom modifié", f"Valeur: {value}")
elif input_id == "input-email":
self.log_event("Champ Email modifié", f"Valeur: {value}")
async def on_input_submitted(self, event: Input.Submitted):
"""Validation avec Entrée dans un Input."""
input_id = event.input.id
self.log_event("Input validé", f"Champ: {input_id}")
if input_id == "input-nom" and event.value:
self.query_one("#status", Label).update(f"Bonjour {event.value} !")
async def on_select_changed(self, event: Select.Changed):
"""Changement de sélection dans un Select."""
self.log_event("Sélection modifiée", f"Rôle: {event.value}")
self.query_one("#status", Label).update(f"Rôle sélectionné: {event.value}")
async def on_switch_changed(self, event: Switch.Changed):
"""Changement d'état d'un Switch."""
state = "ON" if event.value else "OFF"
self.log_event("Switch modifié", f"État: {state}")
self.query_one("#status", Label).update(f"Toggle: {state}")
async def on_data_table_row_selected(self, event):
"""Sélection d'une ligne dans DataTable."""
row_index = event.cursor_row
if row_index is not None:
serveur = self.table.get_cell(row_index, "Nom")
self.log_event("Ligne sélectionnée", f"Serveur: {serveur}")
self.query_one("#status", Label).update(f"Sélection: {serveur}")
async def on_data_table_cell_selected(self, event):
"""Sélection d'une cellule dans DataTable."""
if event.cursor_row is not None and event.cursor_column is not None:
valeur = self.table.get_cell_at((event.cursor_row, event.cursor_column))
self.log_event("Cellule sélectionnée", f"Valeur: {valeur}")
async def on_key(self, event: Key):
"""Gestion des raccourcis clavier."""
if event.key == "ctrl+s":
self.log_event("Raccourci clavier", "Ctrl+S: Sauvegarde")
self.query_one("#status", Label).update("💾 Données sauvegardées")
event.stop()
elif event.key == "f1":
self.log_event("Raccourci clavier", "F1: Aide")
self.query_one("#status", Label).update("📚 Affichage de l'aide")
event.stop()
elif event.key == "escape":
self.log_event("Raccourci clavier", "Escape: Retour")
self.query_one("#status", Label).update("Retour à l'écran précédent")
event.stop()
elif event.key == "up":
self.log_event("Raccourci clavier", "Flèche Haut")
elif event.key == "down":
self.log_event("Raccourci clavier", "Flèche Bas")
async def on_mount(self):
"""Initialisation."""
self.title = "Démonstration Événements"
self.sub_title = "Interagissez pour voir les logs"
# Focus sur le premier champ
self.query_one("#input-nom", Input).focus()
if __name__ == "__main__":
app = EvenementsApp()
app.run()

Fonctionnalités démontrées :

  • ✅ Tous les types d’événements majeurs
  • ✅ Logging des événements en temps réel
  • ✅ Raccourcis clavier personnalisés
  • ✅ Interface de démonstration interactive

📊 Projet Complet : Dashboard Monitoring

Section titled “📊 Projet Complet : Dashboard Monitoring”

Objectif : Créer un dashboard pour :

  • Surveiller 5 serveurs en temps réel
  • Voir l’utilisation CPU/RAM/Disque
  • Gérer les services (start/stop/restart)
  • Consulter les logs système
  • Exécuter des commandes rapides
🚀 dashboard_monitoring.py (cliquez pour voir)
dashboard_monitoring.py
import asyncio
import random
import psutil
from datetime import datetime
from textual.app import App, ComposeResult
from textual.widgets import (
Header, Footer, Label, Button,
DataTable, TextArea, Sparkline
)
from textual.containers import Container, Grid, Vertical, Horizontal
from textual.reactive import reactive
from textual.worker import Worker, WorkerState
class DashboardMonitoring(App):
"""Dashboard de monitoring système complet."""
CSS_PATH = "styles/dashboard.tcss"
# Données réactives
cpu_history = reactive([])
mem_history = reactive([])
selected_server = reactive(None)
def __init__(self):
super().__init__()
self.servers = [
{"id": 1, "name": "web-01", "ip": "192.168.1.10", "status": "online"},
{"id": 2, "name": "db-01", "ip": "192.168.1.11", "status": "online"},
{"id": 3, "name": "cache-01", "ip": "192.168.1.12", "status": "offline"},
{"id": 4, "name": "backup-01", "ip": "192.168.1.13", "status": "online"},
{"id": 5, "name": "monitor-01", "ip": "192.168.1.14", "status": "online"},
]
self.log_entries = []
self.max_history = 30
def compose(self) -> ComposeResult:
yield Header()
with Grid(id="main-grid"):
# Colonne 1: Liste des serveurs
with Vertical(id="servers-panel", classes="panel"):
yield Label("🖥️ Serveurs", classes="panel-title")
self.servers_table = DataTable(id="servers-table")
self.servers_table.add_columns("Nom", "IP", "Status")
yield self.servers_table
with Horizontal(classes="button-group"):
yield Button("🔄 Rafraîchir", id="refresh-servers")
yield Button("▶️ Démarrer", id="start-server")
yield Button("⏹️ Arrêter", id="stop-server")
# Colonne 2: Métriques du serveur sélectionné
with Vertical(id="metrics-panel", classes="panel"):
yield Label("📊 Métriques", classes="panel-title")
# CPU
yield Label("💻 CPU", classes="metric-label")
self.cpu_sparkline = Sparkline([], id="cpu-sparkline")
yield self.cpu_sparkline
yield Label("45%", id="cpu-percent")
# Mémoire
yield Label("🧠 Mémoire", classes="metric-label")
self.mem_sparkline = Sparkline([], id="mem-sparkline")
yield self.mem_sparkline
yield Label("67%", id="mem-percent")
# Disque
yield Label("💾 Disque", classes="metric-label")
yield Label("Utilisation: 120GB / 500GB", id="disk-usage")
yield Label("IO: 45 MB/s", id="disk-io")
# Réseau
yield Label("🌐 Réseau", classes="metric-label")
yield Label("In: 1.2 MB/s", id="net-in")
yield Label("Out: 0.8 MB/s", id="net-out")
# Colonne 3: Logs système
with Vertical(id="logs-panel", classes="panel"):
yield Label("📋 Logs Système", classes="panel-title")
with Horizontal(classes="log-controls"):
yield Button("🔍 Filtre", id="filter-logs")
yield Button("🗑️ Effacer", id="clear-logs")
yield Button("💾 Exporter", id="export-logs")
self.log_area = TextArea(
id="log-area",
read_only=True,
language="text",
show_line_numbers=True
)
yield self.log_area
# Colonne 4: Actions rapides
with Vertical(id="actions-panel", classes="panel"):
yield Label("⚡ Actions Rapides", classes="panel-title")
yield Button("🔄 Redémarrer services", id="restart-services", classes="action-btn")
yield Button("🧹 Nettoyer logs", id="clean-logs", classes="action-btn")
yield Button("📊 Générer rapport", id="generate-report", classes="action-btn")
yield Button("🔒 Mettre à jour", id="update-system", classes="action-btn")
yield Button("🚨 Alerte admin", id="alert-admin", classes="action-btn error")
# Info système local
yield Label("💻 Système Local", classes="metric-label mt-2")
yield Label(f"OS: {psutil.os.name}", id="local-os")
yield Label(f"CPU Cores: {psutil.cpu_count()}", id="local-cores")
yield Label(f"RAM Total: {psutil.virtual_memory().total // (1024**3)}GB", id="local-ram")
yield Footer()
async def on_mount(self):
"""Initialisation du dashboard."""
self.title = "Dashboard Monitoring"
self.sub_title = "Surveillance système en temps réel"
# Initialiser la table des serveurs
self.update_servers_table()
# Configurer la table
self.servers_table.cursor_type = "row"
self.servers_table.zebra_stripes = True
# Démarrer les mises à jour périodiques
self.set_interval(1, self.update_local_metrics)
self.set_interval(2, self.update_server_metrics)
self.set_interval(5, self.add_log_entry)
# Sélectionner le premier serveur par défaut
if self.servers:
self.selected_server = self.servers[0]
def update_servers_table(self):
"""Mettre à jour la table des serveurs."""
self.servers_table.clear()
for server in self.servers:
# Icône de statut
status_icon = "🟢" if server["status"] == "online" else "🔴"
self.servers_table.add_row(
server["name"],
server["ip"],
f"{status_icon} {server['status']}"
)
def update_local_metrics(self):
"""Mettre à jour les métriques du système local."""
# CPU
cpu_percent = psutil.cpu_percent()
self.cpu_history.append(cpu_percent)
if len(self.cpu_history) > self.max_history:
self.cpu_history.pop(0)
self.cpu_sparkline.data = self.cpu_history
self.query_one("#cpu-percent", Label).update(f"{cpu_percent:.1f}%")
# Mémoire
mem = psutil.virtual_memory()
mem_percent = mem.percent
self.mem_history.append(mem_percent)
if len(self.mem_history) > self.max_history:
self.mem_history.pop(0)
self.mem_sparkline.data = self.mem_history
self.query_one("#mem-percent", Label).update(f"{mem_percent:.1f}%")
# Mettre à jour les autres métriques
disk = psutil.disk_usage('/')
self.query_one("#disk-usage", Label).update(
f"Utilisation: {disk.used // (1024**3)}GB / {disk.total // (1024**3)}GB"
)
def update_server_metrics(self):
"""Mettre à jour les métriques des serveurs (simulées)."""
for i, server in enumerate(self.servers):
if server["status"] == "online":
# Simuler des changements de statut aléatoires
if random.random() < 0.01: # 1% de chance de changer
server["status"] = "offline"
elif random.random() < 0.02: # 2% de chance de revenir
server["status"] = "online"
self.update_servers_table()
def add_log_entry(self):
"""Ajouter une entrée de log simulée."""
log_types = [
("INFO", "Synchronisation des données terminée"),
("WARN", "Utilisation CPU élevée sur web-01"),
("INFO", "Backup quotidien démarré"),
("ERROR", "Connexion échouée à db-01"),
("INFO", "Mise à jour de sécurité appliquée"),
]
log_type, message = random.choice(log_types)
timestamp = datetime.now().strftime("%H:%M:%S")
# Icônes selon le type
icons = {
"INFO": "ℹ️",
"WARN": "⚠️",
"ERROR": "",
"SUCCESS": ""
}
icon = icons.get(log_type, "📝")
entry = f"[{timestamp}] {icon} {log_type}: {message}"
self.log_entries.append(entry)
# Garder seulement les 20 dernières entrées
if len(self.log_entries) > 20:
self.log_entries.pop(0)
# Mettre à jour la zone de logs
self.log_area.text = "\n".join(self.log_entries)
self.log_area.scroll_end()
async def on_button_pressed(self, event):
"""Gestion des boutons du dashboard."""
button_id = event.button.id
if button_id == "refresh-servers":
self.log_entries.append(f"[{datetime.now().strftime('%H:%M:%S')}] 🔄 Rafraîchissement des serveurs")
self.update_servers_table()
self.notify("Serveurs rafraîchis", severity="information")
elif button_id == "start-server":
if self.selected_server:
self.selected_server["status"] = "online"
self.update_servers_table()
self.log_entries.append(
f"[{datetime.now().strftime('%H:%M:%S')}] ✅ Démarrage de {self.selected_server['name']}"
)
self.notify(f"{self.selected_server['name']} démarré", severity="success")
elif button_id == "stop-server":
if self.selected_server:
self.selected_server["status"] = "offline"
self.update_servers_table()
self.log_entries.append(
f"[{datetime.now().strftime('%H:%M:%S')}] ⏹️ Arrêt de {self.selected_server['name']}"
)
self.notify(f"{self.selected_server['name']} arrêté", severity="warning")
elif button_id == "clear-logs":
self.log_entries.clear()
self.log_area.text = ""
self.notify("Logs effacés", severity="information")
elif button_id == "restart-services":
# Simulation de redémarrage
self.log_entries.append(
f"[{datetime.now().strftime('%H:%M:%S')}] 🔄 Redémarrage des services"
)
self.notify("Redémarrage des services en cours...", timeout=3)
# Simuler un délai
await asyncio.sleep(2)
self.notify("Services redémarrés avec succès", severity="success")
elif button_id == "alert-admin":
self.log_entries.append(
f"[{datetime.now().strftime('%H:%M:%S')}] 🚨 Alerte administrateur envoyée"
)
self.notify("Alerte envoyée à l'administrateur", severity="error")
# Mettre à jour les logs
self.log_area.text = "\n".join(self.log_entries)
self.log_area.scroll_end()
async def on_data_table_row_selected(self, event):
"""Sélection d'un serveur dans la table."""
if event.cursor_row is not None and event.cursor_row < len(self.servers):
self.selected_server = self.servers[event.cursor_row]
# Mettre à jour l'affichage du serveur sélectionné
if self.selected_server:
self.query_one("#metrics-panel .panel-title", Label).update(
f"📊 Métriques - {self.selected_server['name']}"
)
# Simuler des métriques spécifiques au serveur
cpu = random.randint(10, 90)
mem = random.randint(20, 95)
self.query_one("#cpu-percent", Label).update(f"{cpu}%")
self.query_one("#mem-percent", Label).update(f"{mem}%")
if __name__ == "__main__":
app = DashboardMonitoring()
app.run()
🎨 styles/dashboard.tcss (cliquez pour voir)
styles/dashboard.tcss
/* ===== LAYOUT PRINCIPAL ===== */
Screen {
background: #1a1a2e;
color: #ecf0f1;
}
#main-grid {
grid-size: 2 2;
grid-gutter: 1;
padding: 1;
height: 100%;
}
/* ===== PANELS ===== */
.panel {
border: solid #34495e;
background: #2d2d44;
padding: 1;
height: 100%;
overflow: hidden;
}
.panel-title {
color: #3498db;
text-style: bold;
margin-bottom: 1;
border-bottom: solid #34495e;
padding-bottom: 1;
}
/* ===== TABLEAU DES SERVEURS ===== */
#servers-table {
height: 10;
margin-bottom: 1;
}
#servers-table > .datatable--header {
background: #2c3e50;
color: #ecf0f1;
}
#servers-table > .datatable--cursor {
background: #3498db;
color: white;
}
/* ===== BOUTONS ===== */
.button-group {
margin-top: 1;
}
.button-group > Button {
margin-right: 1;
padding: 0 1;
}
.action-btn {
width: 100%;
margin-bottom: 1;
text-align: left;
}
.action-btn.error {
background: #e74c3c;
color: white;
}
.action-btn.error:hover {
background: #c0392b;
}
/* ===== MÉTRIQUES ===== */
.metric-label {
color: #95a5a6;
margin-top: 1;
margin-bottom: 0;
}
#cpu-sparkline, #mem-sparkline {
height: 3;
margin: 0.5 0;
}
/* ===== LOGS ===== */
#log-area {
height: 20;
border: solid #34495e;
background: #1a1a2e;
margin-top: 1;
}
.log-controls {
margin-bottom: 1;
}
.log-controls > Button {
margin-right: 1;
padding: 0 1;
}
/* ===== UTILITAIRES ===== */
.mt-1 { margin-top: 1; }
.mt-2 { margin-top: 2; }
.mb-1 { margin-bottom: 1; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.text-center { text-align: center; }
.text-right { text-align: right; }
/* ===== ANIMATIONS ===== */
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
.alert-pulse {
animation: pulse 2s infinite;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 100) {
#main-grid {
grid-size: 1 4;
}
.panel {
height: auto;
min-height: 15;
}
}

Fonctionnalités du Dashboard :

  • ✅ Surveillance en temps réel CPU/RAM/Disque
  • ✅ Gestion des serveurs (start/stop/status)
  • ✅ Logs système avec filtrage
  • ✅ Actions rapides administrateur
  • ✅ Design responsive
  • ✅ Graphiques Sparkline
  • ✅ Notifications intelligentes


Exercice 1 : Calculateur avec Historique

Objectif : Créer une calculatrice avec historique des calculs.

Fonctionnalités requises :

  • Opérations de base (+, -, ×, ÷)
  • Historique des 5 derniers calculs
  • Bouton pour effacer l’historique
  • Design avec TCSS
  • Gestion des erreurs (division par zéro)

🎁 Structure de base fournie :

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Label, Button, DataTable
from textual.containers import Grid, Container
class Calculateur(App):
"""Calculateur avec historique."""
def compose(self):
yield Header()
# À compléter...
yield Footer()
async def on_button_pressed(self, event):
# À compléter...
pass
if __name__ == "__main__":
Calculateur().run()
✅ Solution Complète
calculateur_solution.py
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Label, Button, DataTable
from textual.containers import Grid, Container, Vertical, Horizontal
class Calculateur(App):
"""Calculateur avec historique des calculs."""
CSS = """
Screen {
background: #1a1a2e;
}
#ecran {
text-align: right;
padding: 1;
border: solid #34495e;
margin: 1 2;
background: #2d2d44;
color: #ecf0f1;
height: 3;
}
#clavier {
grid-size: 4 5;
grid-gutter: 1;
margin: 1 2;
}
#historique {
height: 8;
border: solid #34495e;
margin: 1 2;
background: #2d2d44;
}
Button {
width: 100%;
}
.chiffre {
background: #2d2d44;
}
.operation {
background: #3498db;
color: white;
}
#egal {
background: #27ae60;
color: white;
}
#clear {
background: #e74c3c;
color: white;
}
#clear-history {
width: 100%;
margin: 1 2;
background: #95a5a6;
}
"""
def __init__(self):
super().__init__()
self.ecran = "0"
self.premier_nombre = None
self.operation = None
self.attente_nouveau = False
self.historique = []
def compose(self) -> ComposeResult:
yield Header()
# Écran d'affichage principal
yield Label(self.ecran, id="ecran")
# Clavier
with Grid(id="clavier"):
# Ligne 1
yield Button("C", id="clear", classes="operation")
yield Button("CE", id="clear_entry")
yield Button("", id="backspace")
yield Button("÷", id="divide", classes="operation")
# Ligne 2
yield Button("7", id="7", classes="chiffre")
yield Button("8", id="8", classes="chiffre")
yield Button("9", id="9", classes="chiffre")
yield Button("×", id="multiply", classes="operation")
# Ligne 3
yield Button("4", id="4", classes="chiffre")
yield Button("5", id="5", classes="chiffre")
yield Button("6", id="6", classes="chiffre")
yield Button("-", id="subtract", classes="operation")
# Ligne 4
yield Button("1", id="1", classes="chiffre")
yield Button("2", id="2", classes="chiffre")
yield Button("3", id="3", classes="chiffre")
yield Button("+", id="add", classes="operation")
# Ligne 5
yield Button("0", id="0", classes="chiffre")
yield Button(".", id="decimal")
yield Button("=", id="egal")
yield Button("²", id="carre", classes="operation")
# Historique
yield Label("📋 Historique (5 derniers)", classes="panel-title")
self.historique_table = DataTable(id="historique")
self.historique_table.add_columns("Calcul", "Résultat")
yield self.historique_table
yield Button("🗑️ Effacer l'historique", id="clear-history")
yield Footer()
async def on_button_pressed(self, event):
bouton = event.button.id
# Chiffres 0-9
if bouton in "0123456789":
if self.ecran == "0" or self.attente_nouveau:
self.ecran = bouton
self.attente_nouveau = False
else:
self.ecran += bouton
# Point décimal
elif bouton == "decimal":
if "." not in self.ecran:
self.ecran += "."
# Opérations
elif bouton in ["add", "subtract", "multiply", "divide"]:
if self.premier_nombre is None:
self.premier_nombre = float(self.ecran)
elif not self.attente_nouveau:
await self.calculer()
self.operation = bouton
self.attente_nouveau = True
# Carré
elif bouton == "carre":
try:
nombre = float(self.ecran)
resultat = nombre ** 2
self.ajouter_historique(f"{nombre}²", resultat)
if resultat == int(resultat):
self.ecran = str(int(resultat))
else:
self.ecran = str(round(resultat, 8))
self.attente_nouveau = True
except Exception:
self.ecran = "Erreur"
# Égal
elif bouton == "egal":
if self.premier_nombre is not None and self.operation:
await self.calculer()
self.operation = None
# Clear
elif bouton == "clear":
self.ecran = "0"
self.premier_nombre = None
self.operation = None
self.attente_nouveau = False
# Backspace
elif bouton == "backspace":
if len(self.ecran) > 1:
self.ecran = self.ecran[:-1]
else:
self.ecran = "0"
# Effacer historique
elif bouton == "clear-history":
self.historique.clear()
self.historique_table.clear()
self.notify("Historique effacé", severity="information")
# Mettre à jour l'écran
self.query_one("#ecran", Label).update(self.ecran)
async def calculer(self):
"""Effectuer un calcul et l'ajouter à l'historique."""
try:
deuxieme = float(self.ecran)
operation_symboles = {
"add": "+",
"subtract": "-",
"multiply": "×",
"divide": "÷"
}
symbole = operation_symboles.get(self.operation, "?")
expression = f"{self.premier_nombre} {symbole} {deuxieme}"
if self.operation == "add":
resultat = self.premier_nombre + deuxieme
elif self.operation == "subtract":
resultat = self.premier_nombre - deuxieme
elif self.operation == "multiply":
resultat = self.premier_nombre * deuxieme
elif self.operation == "divide":
if deuxieme != 0:
resultat = self.premier_nombre / deuxieme
else:
self.ecran = "Erreur"
self.ajouter_historique(expression, "Division par 0")
return
# Formater le résultat
if resultat == int(resultat):
self.ecran = str(int(resultat))
else:
self.ecran = str(round(resultat, 8))
# Ajouter à l'historique
self.ajouter_historique(expression, resultat)
self.premier_nombre = resultat
except Exception:
self.ecran = "Erreur"
self.attente_nouveau = True
def ajouter_historique(self, expression, resultat):
"""Ajouter un calcul à l'historique."""
# Formater le résultat
if isinstance(resultat, (int, float)):
if resultat == int(resultat):
resultat_str = str(int(resultat))
else:
resultat_str = str(round(resultat, 4))
else:
resultat_str = str(resultat)
# Ajouter au début de la liste
self.historique.insert(0, (expression, resultat_str))
# Garder seulement 5 éléments
if len(self.historique) > 5:
self.historique.pop()
# Mettre à jour la table
self.historique_table.clear()
for expr, res in self.historique:
self.historique_table.add_row(expr, res)
if __name__ == "__main__":
app = Calculateur()
app.run()

Exercice 2 : Gestionnaire de Tâches avec Équipes

Objectif : Créer un gestionnaire de tâches pour équipe.

Fonctionnalités requises :

  • Créer/modifier/supprimer des tâches
  • Assigner des tâches à des membres d’équipe
  • Filtrage par statut (todo, in progress, done)
  • Priorité (low, medium, high)
  • Échéances avec dates
  • Persistance JSON

Bonus :

  • Notifications pour échéances proches
  • Statistiques par équipe
  • Export CSV
✅ Solution Complète
task_manager_solution.py
import json
from datetime import datetime, timedelta
from pathlib import Path
from textual.app import App, ComposeResult
from textual.widgets import (
Header, Footer, Label, Button,
Input, Select, DataTable
)
from textual.containers import Container, Grid, Vertical, Horizontal
from textual.screen import ModalScreen
from textual.validation import Function
class AddTaskScreen(ModalScreen):
"""Écran modal pour ajouter une tâche."""
CSS = """
.modal {
width: 60;
height: auto;
border: solid #3498db;
background: #2d2d44;
padding: 2;
}
.modal-title {
color: #3498db;
text-style: bold;
margin-bottom: 2;
text-align: center;
}
Input, Select {
margin-bottom: 1;
width: 100%;
}
.modal-buttons {
margin-top: 2;
}
"""
def __init__(self, members):
super().__init__()
self.members = members
def compose(self) -> ComposeResult:
with Container(classes="modal"):
yield Label("➕ Nouvelle Tâche", classes="modal-title")
yield Label("Titre:")
yield Input(placeholder="Titre de la tâche", id="title")
yield Label("Description:")
yield Input(placeholder="Description détaillée", id="description")
yield Label("Assignée à:")
member_options = [(m, m) for m in self.members]
yield Select(member_options, prompt="Sélectionner...", id="assignee")
yield Label("Priorité:")
yield Select(
[("Basse", "low"), ("Moyenne", "medium"), ("Haute", "high")],
value="medium",
id="priority"
)
yield Label("Échéance (jours):")
yield Input(
placeholder="7 (pour 7 jours)",
id="deadline",
validators=[
Function(lambda x: x.isdigit() and int(x) > 0, "Doit être un nombre positif")
]
)
with Horizontal(classes="modal-buttons"):
yield Button("Annuler", id="cancel", variant="default")
yield Button("Créer", id="create", variant="primary")
async def on_button_pressed(self, event):
if event.button.id == "create":
# Valider les entrées
title = self.query_one("#title", Input).value.strip()
deadline_input = self.query_one("#deadline", Input)
if not title:
self.notify("Le titre est requis", severity="error")
return
if not deadline_input.is_valid:
self.notify("Échéance invalide", severity="error")
return
# Créer l'objet tâche
task = {
"id": datetime.now().timestamp(),
"title": title,
"description": self.query_one("#description", Input).value,
"assignee": self.query_one("#assignee", Select).value,
"priority": self.query_one("#priority", Select).value,
"deadline_days": int(deadline_input.value),
"created_at": datetime.now().isoformat(),
"status": "todo"
}
self.dismiss(task)
elif event.button.id == "cancel":
self.dismiss(None)
class TaskManager(App):
"""Gestionnaire de tâches pour équipe."""
CSS_PATH = "styles/tasks.tcss"
def __init__(self):
super().__init__()
self.data_file = Path("tasks.json")
self.tasks = self.load_tasks()
self.members = ["Alice", "Bob", "Charlie", "Diana", "Eve"]
# Icônes pour les priorités
self.priority_icons = {
"low": "🟢",
"medium": "🟡",
"high": "🔴"
}
# Icônes pour les statuts
self.status_icons = {
"todo": "📝",
"in_progress": "",
"done": ""
}
def compose(self) -> ComposeResult:
yield Header()
with Container(id="main"):
# Barre d'outils
with Horizontal(id="toolbar"):
yield Button("➕ Ajouter", id="add-task")
yield Button("✏️ Modifier", id="edit-task")
yield Button("🗑️ Supprimer", id="delete-task")
yield Select(
[("Toutes", "all"), ("À faire", "todo"),
("En cours", "in_progress"), ("Terminées", "done")],
prompt="Filtrer par statut",
id="filter-status"
)
yield Select(
[("Toutes", "all")] + [(m, m) for m in self.members],
prompt="Filtrer par membre",
id="filter-member"
)
# Table des tâches
self.table = DataTable(id="tasks-table")
self.table.add_columns(
"ID", "Titre", "Assignée à", "Priorité",
"Statut", "Échéance", "Jours restants"
)
yield self.table
# Statistiques
with Horizontal(id="stats"):
yield Label("Total: 0", id="total-tasks")
yield Label("À faire: 0", id="todo-tasks")
yield Label("En cours: 0", id="inprogress-tasks")
yield Label("Terminées: 0", id="done-tasks")
yield Footer()
async def on_mount(self):
"""Initialisation."""
self.title = "Gestionnaire de Tâches"
self.update_table()
self.update_stats()
# Mettre à jour les jours restants périodiquement
self.set_interval(60, self.update_deadlines)
def load_tasks(self):
"""Charger les tâches depuis le fichier JSON."""
if self.data_file.exists():
try:
with open(self.data_file, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
return []
return []
def save_tasks(self):
"""Sauvegarder les tâches dans le fichier JSON."""
with open(self.data_file, 'w', encoding='utf-8') as f:
json.dump(self.tasks, f, indent=2, ensure_ascii=False)
def update_table(self):
"""Mettre à jour l'affichage du tableau."""
self.table.clear()
# Appliquer les filtres
filtered_tasks = self.tasks
# Filtre par statut
status_filter = self.query_one("#filter-status", Select).value
if status_filter != "all":
filtered_tasks = [t for t in filtered_tasks if t["status"] == status_filter]
# Filtre par membre
member_filter = self.query_one("#filter-member", Select).value
if member_filter != "all":
filtered_tasks = [t for t in filtered_tasks if t["assignee"] == member_filter]
# Afficher les tâches filtrées
for task in filtered_tasks:
# Calculer les jours restants
created = datetime.fromisoformat(task["created_at"])
deadline = created + timedelta(days=task["deadline_days"])
days_left = (deadline - datetime.now()).days
# Colorer selon l'urgence
days_color = ""
if days_left < 0:
days_text = f"⚠️ En retard ({abs(days_left)}j)"
days_color = "#e74c3c"
elif days_left == 0:
days_text = "⚠️ Aujourd'hui"
days_color = "#f39c12"
elif days_left <= 2:
days_text = f"⚠️ {days_left}j"
days_color = "#f39c12"
else:
days_text = f"{days_left}j"
days_color = "#27ae60"
# Formater la date d'échéance
deadline_str = deadline.strftime("%d/%m/%Y")
self.table.add_row(
str(task["id"])[:8],
task["title"],
task["assignee"],
f"{self.priority_icons[task['priority']]} {task['priority']}",
f"{self.status_icons[task['status']]} {task['status']}",
deadline_str,
days_text
)
# Appliquer la couleur aux jours restants
if days_color:
row_index = self.table.row_count - 1
self.table.cell_styling.update_cell(row_index, "Jours restants", "color", days_color)
def update_stats(self):
"""Mettre à jour les statistiques."""
total = len(self.tasks)
todo = len([t for t in self.tasks if t["status"] == "todo"])
in_progress = len([t for t in self.tasks if t["status"] == "in_progress"])
done = len([t for t in self.tasks if t["status"] == "done"])
self.query_one("#total-tasks", Label).update(f"Total: {total}")
self.query_one("#todo-tasks", Label).update(f"À faire: {todo}")
self.query_one("#inprogress-tasks", Label).update(f"En cours: {in_progress}")
self.query_one("#done-tasks", Label).update(f"Terminées: {done}")
def update_deadlines(self):
"""Mettre à jour les jours restants et vérifier les échéances."""
now = datetime.now()
for task in self.tasks:
created = datetime.fromisoformat(task["created_at"])
deadline = created + timedelta(days=task["deadline_days"])
days_left = (deadline - now).days
# Notifier si échéance imminente
if 0 <= days_left <= 1 and task["status"] != "done":
self.notify(
f"⚠️ Échéance proche: {task['title']} ({'aujourd\'hui' if days_left == 0 else 'demain'})",
severity="warning"
)
self.update_table()
async def on_button_pressed(self, event):
button_id = event.button.id
if button_id == "add-task":
# Ouvrir l'écran modal d'ajout
task = await self.push_screen_wait(AddTaskScreen(self.members))
if task:
self.tasks.append(task)
self.save_tasks()
self.update_table()
self.update_stats()
self.notify("✅ Tâche ajoutée", severity="success")
elif button_id == "delete-task":
# Supprimer la tâche sélectionnée
if self.table.cursor_row is not None:
row_index = self.table.cursor_row
# Trouver la tâche correspondante
filtered_tasks = self.get_filtered_tasks()
if row_index < len(filtered_tasks):
task_id = filtered_tasks[row_index]["id"]
# Supprimer de la liste
self.tasks = [t for t in self.tasks if t["id"] != task_id]
self.save_tasks()
self.update_table()
self.update_stats()
self.notify("🗑️ Tâche supprimée", severity="warning")
elif button_id == "edit-task":
# Modifier la tâche sélectionnée
if self.table.cursor_row is not None:
row_index = self.table.cursor_row
filtered_tasks = self.get_filtered_tasks()
if row_index < len(filtered_tasks):
task = filtered_tasks[row_index]
# Changer le statut (cycle: todo → in_progress → done → todo)
status_order = ["todo", "in_progress", "done"]
current_index = status_order.index(task["status"])
next_index = (current_index + 1) % len(status_order)
task["status"] = status_order[next_index]
self.save_tasks()
self.update_table()
status_text = {"todo": "À faire", "in_progress": "En cours", "done": "Terminée"}
self.notify(
f"📝 Statut changé: {status_text[task['status']]}",
severity="information"
)
def get_filtered_tasks(self):
"""Obtenir la liste des tâches filtrées."""
filtered_tasks = self.tasks
status_filter = self.query_one("#filter-status", Select).value
if status_filter != "all":
filtered_tasks = [t for t in filtered_tasks if t["status"] == status_filter]
member_filter = self.query_one("#filter-member", Select).value
if member_filter != "all":
filtered_tasks = [t for t in filtered_tasks if t["assignee"] == member_filter]
return filtered_tasks
if __name__ == "__main__":
app = TaskManager()
app.run()

Exercice 3 : Client API REST avec Authentification

Objectif : Créer un client pour interagir avec une API REST.

Fonctionnalités requises :

  • Authentification avec token JWT
  • CRUD complet (GET, POST, PUT, DELETE)
  • Gestion des erreurs HTTP
  • Cache des réponses
  • Interface avec onglets pour différentes ressources

API à utiliser : JSONPlaceholder (fake API) ou votre propre API

✅ Solution Complète
api_client_solution.py
import asyncio
import json
from datetime import datetime
from typing import Optional, Dict, Any
import httpx
from textual.app import App, ComposeResult
from textual.widgets import (
Header, Footer, Label, Button,
Input, TextArea, DataTable, TabbedContent, TabPane
)
from textual.containers import Container, Vertical, Horizontal
from textual.reactive import reactive
from textual.worker import Worker
class APIClient(App):
"""Client API REST avec authentification."""
CSS_PATH = "styles/api_client.tcss"
# Données réactives
auth_token = reactive("")
is_authenticated = reactive(False)
last_response = reactive({})
def __init__(self):
super().__init__()
self.base_url = "https://jsonplaceholder.typicode.com" # API de test
self.client = httpx.AsyncClient(timeout=30.0)
self.cache = {} # Cache simple
# Ressources disponibles
self.resources = {
"posts": "/posts",
"comments": "/comments",
"albums": "/albums",
"photos": "/photos",
"todos": "/todos",
"users": "/users"
}
def compose(self) -> ComposeResult:
yield Header()
with TabbedContent():
# Onglet 1: Authentification
with TabPane("🔐 Auth", id="tab-auth"):
yield Label("Authentification API", classes="tab-title")
with Vertical(classes="auth-form"):
yield Label("URL de l'API:")
yield Input(
value=self.base_url,
placeholder="https://api.example.com",
id="api-url"
)
yield Label("Token JWT (optionnel):")
yield Input(
type="text",
placeholder="Votre token JWT...",
id="auth-token",
password=True
)
with Horizontal(classes="button-group"):
yield Button("🔗 Se connecter", id="connect", variant="primary")
yield Button("🔓 Déconnexion", id="disconnect", variant="default")
yield Label("Statut: Non connecté", id="auth-status")
# Onglet 2: Ressources
with TabPane("📦 Ressources", id="tab-resources"):
yield Label("Ressources disponibles", classes="tab-title")
# Sélecteur de ressource
with Horizontal():
yield Label("Ressource:")
resource_options = [(name.capitalize(), name) for name in self.resources.keys()]
yield Select(resource_options, prompt="Choisir...", id="resource-select")
yield Button("📥 Charger", id="load-resource")
yield Button("🔄 Rafraîchir", id="refresh-resource")
# Tableau des données
self.data_table = DataTable(id="resource-table")
yield self.data_table
# Détails de l'élément sélectionné
yield Label("📄 Détails:", classes="section-title")
self.details_area = TextArea(id="details-area", read_only=True, language="json")
yield self.details_area
# Onglet 3: Requêtes personnalisées
with TabPane("🔧 Requêtes", id="tab-requests"):
yield Label("Requêtes HTTP personnalisées", classes="tab-title")
with Vertical(classes="request-form"):
# Méthode HTTP
with Horizontal():
yield Label("Méthode:")
yield Select(
[("GET", "GET"), ("POST", "POST"), ("PUT", "PUT"), ("DELETE", "DELETE")],
value="GET",
id="http-method"
)
# Endpoint
yield Label("Endpoint:")
yield Input(placeholder="/posts/1", id="endpoint")
# Corps de la requête (pour POST/PUT)
yield Label("Corps (JSON):")
self.request_body = TextArea(id="request-body", language="json")
yield self.request_body
# Headers
yield Label("Headers:")
self.headers_input = Input(
placeholder='{"Content-Type": "application/json"}',
id="headers"
)
yield self.headers_input
# Boutons d'action
with Horizontal(classes="button-group"):
yield Button("🚀 Envoyer", id="send-request", variant="primary")
yield Button("💾 Enregistrer", id="save-request")
yield Button("📋 Copier", id="copy-response")
# Onglet 4: Historique & Logs
with TabPane("📋 Historique", id="tab-history"):
yield Label("Historique des requêtes", classes="tab-title")
self.history_table = DataTable(id="history-table")
self.history_table.add_columns("Time", "Method", "Endpoint", "Status", "Duration")
yield self.history_table
with Horizontal(classes="button-group"):
yield Button("🗑️ Effacer", id="clear-history")
yield Button("💾 Exporter", id="export-history")
# Zone de réponse
yield Label("📤 Réponse:", classes="section-title")
self.response_area = TextArea(id="response-area", read_only=True, language="json")
yield self.response_area
# Barre de statut
with Horizontal(id="status-bar"):
yield Label("Prêt", id="status")
yield Label("", id="request-info")
yield Footer()
async def on_mount(self):
"""Initialisation."""
self.title = "Client API REST"
self.update_auth_status()
# Initialiser la table d'historique
self.history = []
def update_auth_status(self):
"""Mettre à jour le statut d'authentification."""
status_text = "✅ Connecté" if self.is_authenticated else "❌ Non connecté"
self.query_one("#auth-status", Label).update(f"Statut: {status_text}")
async def on_button_pressed(self, event):
button_id = event.button.id
if button_id == "connect":
await self.connect_api()
elif button_id == "disconnect":
self.disconnect_api()
elif button_id == "load-resource":
await self.load_selected_resource()
elif button_id == "refresh-resource":
await self.load_selected_resource(force_refresh=True)
elif button_id == "send-request":
await self.send_custom_request()
elif button_id == "clear-history":
self.history.clear()
self.history_table.clear()
self.notify("Historique effacé", severity="information")
async def connect_api(self):
"""Se connecter à l'API."""
url = self.query_one("#api-url", Input).value
token = self.query_one("#auth-token", Input).value
if not url:
self.notify("URL de l'API requise", severity="error")
return
try:
self.base_url = url.rstrip('/')
self.auth_token = token
self.is_authenticated = True
# Tester la connexion avec une requête simple
response = await self.make_request("GET", "/posts/1")
if response:
self.notify("✅ Connecté avec succès", severity="success")
self.update_auth_status()
else:
self.is_authenticated = False
self.notify("❌ Échec de connexion", severity="error")
except Exception as e:
self.is_authenticated = False
self.notify(f"Erreur: {str(e)}", severity="error")
def disconnect_api(self):
"""Se déconnecter de l'API."""
self.is_authenticated = False
self.auth_token = ""
self.notify("Déconnecté", severity="information")
self.update_auth_status()
async def load_selected_resource(self, force_refresh=False):
"""Charger la ressource sélectionnée."""
select = self.query_one("#resource-select", Select)
resource_name = select.value
if not resource_name:
self.notify("Sélectionnez une ressource", severity="warning")
return
endpoint = self.resources.get(resource_name)
if not endpoint:
self.notify("Ressource non trouvée", severity="error")
return
# Vérifier le cache
cache_key = f"GET:{endpoint}"
if not force_refresh and cache_key in self.cache:
data = self.cache[cache_key]
self.display_resource_data(resource_name, data)
self.notify("Données chargées depuis le cache", severity="information")
return
# Faire la requête
self.query_one("#status", Label).update(f"Chargement {resource_name}...")
data = await self.make_request("GET", endpoint)
if data:
# Mettre en cache
self.cache[cache_key] = data
self.display_resource_data(resource_name, data)
# Ajouter à l'historique
self.add_to_history("GET", endpoint, 200, "0.5s")
def display_resource_data(self, resource_name: str, data: list):
"""Afficher les données d'une ressource dans la table."""
self.data_table.clear()
if not data:
self.notify("Aucune donnée", severity="warning")
return
# Déterminer les colonnes basées sur le premier élément
if data and isinstance(data, list) and len(data) > 0:
first_item = data[0]
if isinstance(first_item, dict):
columns = list(first_item.keys())[:6] # Limiter à 6 colonnes
self.data_table.add_columns(*columns)
# Ajouter les données (limiter à 50 lignes)
for item in data[:50]:
row = [str(item.get(col, ""))[:30] for col in columns]
self.data_table.add_row(*row)
self.query_one("#status", Label).update(f"{resource_name} chargées: {len(data)} éléments")
async def send_custom_request(self):
"""Envoyer une requête personnalisée."""
method = self.query_one("#http-method", Select).value
endpoint = self.query_one("#endpoint", Input).value
if not endpoint:
self.notify("Endpoint requis", severity="error")
return
# Préparer les headers
headers = {}
headers_text = self.headers_input.value.strip()
if headers_text:
try:
headers = json.loads(headers_text)
except json.JSONDecodeError:
self.notify("Headers JSON invalides", severity="error")
return
# Préparer le corps
body = None
if method in ["POST", "PUT"]:
body_text = self.request_body.text.strip()
if body_text:
try:
body = json.loads(body_text)
except json.JSONDecodeError:
self.notify("Corps JSON invalide", severity="error")
return
# Faire la requête
self.query_one("#status", Label).update(f"Envoi {method} {endpoint}...")
start_time = datetime.now()
try:
response = await self.make_request(method, endpoint, json=body, headers=headers)
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
if response is not None:
# Afficher la réponse
response_text = json.dumps(response, indent=2)
self.response_area.text = response_text
# Mettre à jour les infos
self.query_one("#request-info", Label).update(
f"{method} {endpoint} - {duration:.2f}s"
)
# Ajouter à l'historique
self.add_to_history(method, endpoint, 200, f"{duration:.2f}s")
self.notify("✅ Requête réussie", severity="success")
except Exception as e:
self.notify(f"Erreur: {str(e)}", severity="error")
self.query_one("#status", Label).update("Erreur")
async def make_request(self, method: str, endpoint: str, **kwargs) -> Optional[Any]:
"""Faire une requête HTTP avec gestion d'erreurs."""
url = f"{self.base_url}{endpoint}"
# Ajouter le token d'authentification si disponible
headers = kwargs.get("headers", {})
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
kwargs["headers"] = headers
try:
response = await self.client.request(method, url, **kwargs)
response.raise_for_status()
# Essayer de parser le JSON
try:
return response.json()
except:
return response.text
except httpx.HTTPStatusError as e:
self.notify(f"Erreur HTTP {e.response.status_code}: {e.response.text[:100]}", severity="error")
return None
except httpx.RequestError as e:
self.notify(f"Erreur réseau: {str(e)}", severity="error")
return None
except Exception as e:
self.notify(f"Erreur inattendue: {str(e)}", severity="error")
return None
def add_to_history(self, method: str, endpoint: str, status: int, duration: str):
"""Ajouter une requête à l'historique."""
timestamp = datetime.now().strftime("%H:%M:%S")
# Icône selon la méthode
method_icons = {
"GET": "📥",
"POST": "📤",
"PUT": "✏️",
"DELETE": "🗑️",
"PATCH": "🔄"
}
icon = method_icons.get(method, "📡")
method_display = f"{icon} {method}"
# Statut coloré
if 200 <= status < 300:
status_display = f"[green]{status}[/]"
elif 400 <= status < 500:
status_display = f"[yellow]{status}[/]"
else:
status_display = f"[red]{status}[/]"
# Ajouter à la table
self.history_table.add_row(
timestamp,
method_display,
endpoint[:40],
status_display,
duration
)
# Garder seulement 50 entrées
if self.history_table.row_count > 50:
self.history_table.remove_row(self.history_table.rows[0])
if __name__ == "__main__":
app = APIClient()
app.run()

Terminal window
# 1. Installer PyInstaller
pip install pyinstaller
# 2. Créer un fichier spec
pyi-makespec --onefile --name monapp --add-data "src/monapp/styles:styles" src/monapp/__main__.py
# 3. Modifier le fichier spec pour inclure les données
# Ajouter dans a.datas:
# [('src/monapp/styles/*.tcss', 'styles')]
# 4. Build
pyinstaller monapp.spec
# 5. Tester
./dist/monapp
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Installer les dépendances système
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copier les dépendances
COPY requirements.txt .
# Installer les dépendances Python
RUN pip install --no-cache-dir -r requirements.txt
# Copier le code source
COPY src/ ./src/
COPY assets/ ./assets/
# Variable d'environnement pour le terminal
ENV TERM=xterm-256color
# Commande par défaut
CMD ["python", "-m", "src.monapp"]
scripts/build_all.sh
#!/bin/bash
VERSION="1.0.0"
APP_NAME="monapp"
# Créer les dossiers de sortie
mkdir -p dist/{linux,windows,macos}
# Build Linux
echo "🔨 Building for Linux..."
pyinstaller \
--onefile \
--name "${APP_NAME}-linux" \
--distpath "dist/linux" \
src/monapp/__main__.py
# Build Windows (sur Windows ou avec wine)
echo "🔨 Building for Windows..."
pyinstaller \
--onefile \
--name "${APP_NAME}.exe" \
--distpath "dist/windows" \
--console \
src/monapp/__main__.py
# Build macOS
echo "🔨 Building for macOS..."
pyinstaller \
--onefile \
--name "${APP_NAME}-macos" \
--distpath "dist/macos" \
src/monapp/__main__.py
echo "✅ Builds completed!"

  1. Documentation Officielle : textual.textualize.io
  2. GitHub : github.com/Textualize/textual
  3. Exemples : python -m textual (démos intégrées)
  4. Discord : Communauté active pour questions
  1. Terminal File Manager : Explorer de fichiers avancé
  2. Git Client TUI : Interface Git complète
  3. Database Admin Tool : Gestionnaire de bases de données
  4. System Monitor : Dashboard monitoring avancé
  5. Chat Application : Client chat terminal
  6. Game : Jeu terminal (échecs, sudoku, etc.)
MétriqueObjectifComment mesurer
Temps de réponse< 100mstime.perf_counter()
Mémoire utilisée< 50MBpsutil.Process().memory_info()
Taille du binaire< 20MBos.path.getsize()
CompatibilitéLinux/Mac/WindowsTests multi-plateformes
Code coverage> 80%pytest --cov


À propos de l'auteur

Riyad ODJOUADEExpert en cybersécurité & infrastructure

Je partage des guides techniques pratiques sur la cybersécurité, l'administration système et le développement web. Tous les contenus sont basés sur des expérimentations réelles.

Dernière mise à jour : 08 Janvier 2026