Textual Python : Guide Complet pour Applications Terminal Modernes
🖥️ Textual Python : Le Guide Ultime
Section titled “🖥️ Textual Python : Le Guide Ultime”
Framework moderne pour applications terminal interactives
🎯 Pourquoi Apprendre Textual ?
Section titled “🎯 Pourquoi Apprendre Textual ?”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 à :
- 🏗️ Structurer des projets Textual professionnels
- 🎨 Créer des interfaces avec TCSS avancé
- ⚡ Gérer les événements asynchrones
- 📊 Traiter des données temps réel
- 🚀 Déployer en production
📚 Table des Matières
Section titled “📚 Table des Matières”- 🚀 Installation & Premier Projet
- 🏗️ Structure de Projet Professionnelle
- 🧩 Widgets Essentiels
- 🎨 TCSS : Design dans le Terminal
- ⚡ Gestion d’Événements
- 📊 Projet Complet : Dashboard Monitoring
- 📋 Quiz : Testez Vos Connaissances
- 📝 Exercices Pratiques
- 🚀 Déploiement & Production
🚀 Installation & Premier Projet
Section titled “🚀 Installation & Premier Projet”📦 Installation Professionnelle
Section titled “📦 Installation Professionnelle”# 1. Créer et activer un environnement virtuel (ESSENTIEL)python -m venv .venv
# 2. Activer selon votre OSsource .venv/bin/activate # Linux/Mac# OU.venv\Scripts\activate # Windows
# 3. Installer Textual avec outils de développementpip install "textual[dev]"
# 4. Vérifier l'installationpython -c "import textual; print(f'✅ Textual {textual.__version__} installé')"
# 5. Voir les démos intégréespython -m textual⚠️ Pourquoi un environnement virtuel ?
- Isolation des dépendances
- Pas de conflits entre projets
- Reproducibilité exacte
- Sécurité (pas besoin de droits admin)
🎮 Première Application en 2 Minutes
Section titled “🎮 Première Application en 2 Minutes”👁️ Voir le code (cliquez pour développer)
from textual.app import App, ComposeResultfrom 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 :
python premier_app.pyRé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
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🛠️ Script de Création Automatique
Section titled “🛠️ Script de Création Automatique”📜 create_project.sh (cliquez pour voir)
#!/bin/bashset -e
echo "🚀 Création d'un projet Textual professionnel"read -p "📝 Nom du projet: " PROJECT_NAME
mkdir -p "$PROJECT_NAME"cd "$PROJECT_NAME"
# Structure principalemkdir -p src/"$PROJECT_NAME"/{widgets,screens,models,styles/themes,utils}mkdir -p {tests,docs,assets/{images,data},scripts}
# README.mdcat > README.md << EOF# $PROJECT_NAMEApplication Textual pour monitoring/management.
## Installation\`\`\`bashpip install -r requirements.txt\`\`\`
## Lancement\`\`\`bashpython -m $PROJECT_NAME\`\`\`EOF
# requirements.txtcat > requirements.txt << EOFtextual>=0.55.0rich>=13.0.0EOF
# Fichier principalcat > src/"$PROJECT_NAME"/app.py << 'EOF'from textual.app import App, ComposeResultfrom 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 basecat > src/"$PROJECT_NAME"/styles/base.tcss << 'EOF'Screen { background: #1a1a2e; color: #ecf0f1;}
#welcome { text-align: center; margin: 2; color: cyan;}EOF
# Scripts utilitairescat > scripts/run_dev.sh << 'EOF'#!/bin/bashsource .venv/bin/activate 2>/dev/null || truepython -m src.monitor_appEOF
chmod +x scripts/*.shecho "✅ 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 :
chmod +x create_project.sh./create_project.sh# Suivez les instructions🧩 Widgets Essentiels
Section titled “🧩 Widgets Essentiels”📋 Tableau des Widgets Natifs
Section titled “📋 Tableau des Widgets Natifs”| Widget | Description | Usage | Exemple |
|---|---|---|---|
| Label | Texte simple | Titres, messages | Label("Bonjour") |
| Button | Bouton cliquable | Actions, validation | Button("OK", id="ok") |
| Input | Champ de saisie | Formulaire, recherche | Input(placeholder="Nom") |
| TextArea | Zone texte multiligne | Éditeur, logs | TextArea.code_editor() |
| DataTable | Tableau de données | Données tabulaires | DataTable() |
| Select | Liste déroulante | Choix, filtres | Select([("Oui","yes")]) |
| Switch | Interrupteur ON/OFF | Options booléennes | Switch() |
| ProgressBar | Barre de progression | Tâches longues | ProgressBar() |
| Sparkline | Graphique minimal | Tendances | Sparkline([1,2,3]) |
| ListView | Liste avec scroll | Navigation | ListView() |
| Tree | Arborescence | Fichiers, données hiérarchiques | Tree("Racine") |
| Markdown | Contenu Markdown | Documentation | Markdown("# Titre") |
🎯 DataTable Avancé
Section titled “🎯 DataTable Avancé”📊 Exemple Complet DataTable
from textual.app import App, ComposeResultfrom 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
🎨 TCSS : Design dans le Terminal
Section titled “🎨 TCSS : Design dans le Terminal”🎯 Variables CSS Prédéfinies
Section titled “🎯 Variables CSS Prédéfinies”/* 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 */🎨 Exemple de Thème Complet
Section titled “🎨 Exemple de Thème Complet”🎨 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; }🎭 Application avec Thème Dynamique
Section titled “🎭 Application avec Thème Dynamique”🎭 app_theme_dynamique.py (cliquez pour voir)
from textual.app import App, ComposeResultfrom textual.widgets import Header, Footer, Label, Button, Selectfrom textual.containers import Container, Verticalfrom 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
⚡ Gestion d’Événements
Section titled “⚡ Gestion d’Événements”📋 Tableau des Événements
Section titled “📋 Tableau des Événements”| Événement | Widget | Déclencheur | Méthode de gestion |
|---|---|---|---|
| Button.Pressed | Button | Clic bouton | on_button_pressed() |
| Input.Changed | Input | Changement texte | on_input_changed() |
| Input.Submitted | Input | Touche Entrée | on_input_submitted() |
| Select.Changed | Select | Changement sélection | on_select_changed() |
| DataTable.RowSelected | DataTable | Sélection ligne | on_data_table_row_selected() |
| DataTable.CellSelected | DataTable | Sélection cellule | on_data_table_cell_selected() |
| Switch.Changed | Switch | Changement état | on_switch_changed() |
| ListView.Selected | ListView | Sélection item | on_list_view_selected() |
🎮 Exemple Complet d’Événements
Section titled “🎮 Exemple Complet d’Événements”⚡ app_evenements.py (cliquez pour voir)
import asynciofrom datetime import datetimefrom textual.app import App, ComposeResultfrom textual.widgets import ( Header, Footer, Label, Button, Input, Select, DataTable, Switch)from textual.containers import Container, Gridfrom 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”🎯 Cas Réel : Dashboard DevOps
Section titled “🎯 Cas Réel : Dashboard DevOps”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)
import asyncioimport randomimport psutilfrom datetime import datetimefrom textual.app import App, ComposeResultfrom textual.widgets import ( Header, Footer, Label, Button, DataTable, TextArea, Sparkline)from textual.containers import Container, Grid, Vertical, Horizontalfrom textual.reactive import reactivefrom 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)
/* ===== 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
📋 Quiz : Testez Vos Connaissances
Section titled “📋 Quiz : Testez Vos Connaissances”🎯 10 Questions sur Textual
Section titled “🎯 10 Questions sur Textual”📝 Exercices Pratiques
Section titled “📝 Exercices Pratiques”🎯 Niveau 1 : Calculateur Terminal
Section titled “🎯 Niveau 1 : Calculateur Terminal”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, ComposeResultfrom textual.widgets import Header, Footer, Label, Button, DataTablefrom 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
from textual.app import App, ComposeResultfrom textual.widgets import Header, Footer, Label, Button, DataTablefrom 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()🎯 Niveau 2 : Gestionnaire de Tâches
Section titled “🎯 Niveau 2 : Gestionnaire de Tâches”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
import jsonfrom datetime import datetime, timedeltafrom pathlib import Pathfrom textual.app import App, ComposeResultfrom textual.widgets import ( Header, Footer, Label, Button, Input, Select, DataTable)from textual.containers import Container, Grid, Vertical, Horizontalfrom textual.screen import ModalScreenfrom 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()🎯 Niveau 3 : Client API REST
Section titled “🎯 Niveau 3 : Client API REST”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
import asyncioimport jsonfrom datetime import datetimefrom typing import Optional, Dict, Anyimport httpxfrom textual.app import App, ComposeResultfrom textual.widgets import ( Header, Footer, Label, Button, Input, TextArea, DataTable, TabbedContent, TabPane)from textual.containers import Container, Vertical, Horizontalfrom textual.reactive import reactivefrom 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()🚀 Déploiement & Production
Section titled “🚀 Déploiement & Production”📦 Packaging avec PyInstaller
Section titled “📦 Packaging avec PyInstaller”# 1. Installer PyInstallerpip install pyinstaller
# 2. Créer un fichier specpyi-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. Buildpyinstaller monapp.spec
# 5. Tester./dist/monapp🐳 Dockerisation
Section titled “🐳 Dockerisation”# DockerfileFROM python:3.11-slim
WORKDIR /app
# Installer les dépendances systèmeRUN apt-get update && apt-get install -y \ curl \ && rm -rf /var/lib/apt/lists/*
# Copier les dépendancesCOPY requirements.txt .
# Installer les dépendances PythonRUN pip install --no-cache-dir -r requirements.txt
# Copier le code sourceCOPY src/ ./src/COPY assets/ ./assets/
# Variable d'environnement pour le terminalENV TERM=xterm-256color
# Commande par défautCMD ["python", "-m", "src.monapp"]📱 Distribution Multi-Platforme
Section titled “📱 Distribution Multi-Platforme”#!/bin/bashVERSION="1.0.0"APP_NAME="monapp"
# Créer les dossiers de sortiemkdir -p dist/{linux,windows,macos}
# Build Linuxecho "🔨 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 macOSecho "🔨 Building for macOS..."pyinstaller \ --onefile \ --name "${APP_NAME}-macos" \ --distpath "dist/macos" \ src/monapp/__main__.py
echo "✅ Builds completed!"📚 Ressources & Next Steps
Section titled “📚 Ressources & Next Steps”🎯 Pour Aller Plus Loin
Section titled “🎯 Pour Aller Plus Loin”- Documentation Officielle : textual.textualize.io
- GitHub : github.com/Textualize/textual
- Exemples :
python -m textual(démos intégrées) - Discord : Communauté active pour questions
💼 Projets pour Votre Portfolio
Section titled “💼 Projets pour Votre Portfolio”- Terminal File Manager : Explorer de fichiers avancé
- Git Client TUI : Interface Git complète
- Database Admin Tool : Gestionnaire de bases de données
- System Monitor : Dashboard monitoring avancé
- Chat Application : Client chat terminal
- Game : Jeu terminal (échecs, sudoku, etc.)
📈 Métriques de Succès
Section titled “📈 Métriques de Succès”| Métrique | Objectif | Comment mesurer |
|---|---|---|
| Temps de réponse | < 100ms | time.perf_counter() |
| Mémoire utilisée | < 50MB | psutil.Process().memory_info() |
| Taille du binaire | < 20MB | os.path.getsize() |
| Compatibilité | Linux/Mac/Windows | Tests multi-plateformes |
| Code coverage | > 80% | pytest --cov |
📢 Partagez cet article
Section titled “📢 Partagez cet article”Dernière mise à jour : 08 Janvier 2026