Metadata-Version: 2.4
Name: pulse-python
Version: 0.0.1
Summary: Surveillance Pulse pour Python : signal de vie, base de données, Redis, files et tâches planifiées détectés tout seuls.
License-Expression: MIT
Project-URL: Homepage, https://pulse-app.si
Keywords: pulse,monitoring,django,fastapi,flask,celery
Classifier: Framework :: Django
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: secure
Requires-Dist: cryptography>=41; extra == "secure"
Dynamic: license-file

# pulse-python

Ton app Python surveillée par [Pulse](https://pulse-app.si), sur ton iPhone et ton Mac. Django, FastAPI, Flask ou simple script : tu branches Pulse, il trouve ta base, ton Redis, tes files Celery ou RQ et tes tâches planifiées, et te prévient dès que quelque chose cloche.

```
  ton app Python                                          ton iPhone, ton Mac
 ┌─────────────────────────┐        HTTPS        ┌──────────┐       ┌──────────────┐
 │ pulse-python            │ ──────────────────▶ │  Pulse   │ ────▶ │  app Pulse   │
 │  ├ PostgreSQL  SELECT 1 │   état, tâches,     │  (API)   │ push  │  alertes,    │
 │  ├ Redis       PING     │   journaux          │          │ ◀──── │  journaux,   │
 │  ├ Celery, RQ  courtier │   (chiffrés si tu   │          │  SSE  │  en direct   │
 │  └ tâches      passages │    le veux)         └──────────┘       └──────────────┘
 └─────────────────────────┘
```

## En deux minutes

```bash
pip install pulse-python
```

Mets la clé que l'app Pulse t'affiche quand tu ajoutes une app dans l'environnement du processus (fichier `.env` chargé par ton framework, variable du service systemd, `environment:` de Docker Compose) :

```bash
PULSE_KEY=pk_live_…
```

Puis branche Pulse selon ton framework (ci-dessous). Relance ton app : le premier signal arrive dans Pulse dans les secondes qui suivent. Aucune dépendance n'est installée avec le paquet : il n'utilise que la bibliothèque standard de Python (3.9 ou plus).

### Django

```python
# settings.py
INSTALLED_APPS = [
    # …
    "pulse_python.django",
]
```

C'est tout. Au démarrage, Pulse lit `DATABASES` (une sonde par base), les `CACHES` Redis et l'app Celery du projet si elle existe (`proj/celery.py` importé dans `proj/__init__.py`, comme le recommande Celery), avec son `beat_schedule`. Avec `runserver`, seul le processus qui sert l'app démarre Pulse, pas celui qui surveille les fichiers. Les commandes courtes (`migrate`, `shell`, `collectstatic`, `test`…) ne le démarrent pas.

Les réglages peuvent aussi vivre dans `settings.py`, ils passent avant les variables :

```python
PULSE = {"exclude": ["File emails"], "log_level": "WARN"}
```

### FastAPI

```python
from fastapi import FastAPI
from pulse_python.fastapi import instrument

app = FastAPI(lifespan=lifespan)  # ton lifespan reste en place
instrument(app, watch=[engine, redis_client, celery_app])
```

Pulse démarre et s'arrête avec le cycle de vie de l'app, autour de ton `lifespan` sans le remplacer. Les clients asynchrones (`AsyncEngine` de SQLAlchemy, `redis.asyncio`) sont reconnus : Pulse ouvre sa propre connexion pour chaque contrôle, sans toucher au pool de ton app.

### Flask

```python
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from pulse_python.flask import Pulse

app = Flask(__name__)
db = SQLAlchemy(app)
Pulse(app, watch=[redis_client, queue, scheduler])
```

Ou en usine d'app : `pulse = Pulse(watch=[…])` puis `pulse.init_app(app)`. Flask-SQLAlchemy est trouvé tout seul dans `app.extensions`, même s'il est branché après Pulse. Avec `flask run --debug`, seul le processus relancé démarre Pulse.

### Script, worker, autre framework

```python
from pulse_python import start_pulse

start_pulse(watch=[engine, redis_client, queue])
```

`start_pulse` renvoie le rapporteur (ou `None` si Pulse est coupé ou mal réglé) et n'en crée qu'un par processus : un second appel ajoute seulement ce que tu lui donnes à surveiller. `pulse()` le retrouve n'importe où, `stop_pulse()` l'arrête. Le rapporteur tourne dans un thread à lui, qui ne retient jamais l'arrêt de ton processus.

### Gunicorn, Uvicorn et plusieurs workers

Chaque processus qui démarre Pulse envoie son propre rapport avec la même clé, et Pulse garde le plus récent. Avec `gunicorn -w 4` ou `uvicorn --workers 4`, tu as donc quatre rapports équivalents par intervalle : c'est sans danger, les journaux de chaque worker arrivent bien, seul le trafic est multiplié. Pour n'en garder qu'un avec Django ou Flask, lance Gunicorn avec `--preload` : Pulse tourne alors dans le processus maître (qui ne sert pas de requêtes, mais voit les mêmes dépendances) et les workers n'en lancent pas, au prix de leurs journaux, qui ne sont alors plus recopiés. Avec FastAPI, le cycle de vie tourne dans chaque worker : chacun envoie le sien.

Même logique pour un worker Celery à part : s'il charge Django, il démarre lui aussi Pulse. C'est le processus qui exécute les tâches qui voit leurs réussites et leurs échecs. Choisis celui qui envoie (`PULSE_DISABLED=true` dans l'autre), ou lance le worker avec `-B` et laisse-lui le rapport.

## Ce que Pulse surveille

| Dépendance trouvée | Ce que Pulse fait | Nom affiché |
|---|---|---|
| SQLAlchemy `Engine` et `AsyncEngine`, Flask-SQLAlchemy | `SELECT 1` | PostgreSQL, MySQL, SQLite… |
| Connexions Django (`DATABASES`) | `SELECT 1` | selon le moteur |
| PyMongo `MongoClient` | `ping` | MongoDB |
| `redis.Redis`, `redis.asyncio.Redis`, caches Redis de Django | `PING` | Redis |
| Celery | se connecte au courtier, sans rien publier | File celery, File emails… |
| RQ `Queue` | lit le nombre de tâches en attente | File emails, File factures… |
| `beat_schedule` de Celery | planning, prochain passage, réussite ou échec des passages | le nom de l'entrée |
| APScheduler | planning, dernier et prochain passage, réussite ou échec | l'identifiant de la tâche |

Chaque contrôle a 5 secondes pour répondre et ne fait que lire. Une dépendance en échec, une tâche qui a planté ou qui a sauté son passage de plus de 5 minutes, et l'app passe en « dégradée » dans Pulse avec une notification qui dit quoi.

Pour une tâche lancée par cron, systemd ou ton propre planificateur, enveloppe-la : son échec remonte dans Pulse puis à l'appelant, comme avant.

```python
from pulse_python import track, track_cron

@track_cron("relance-abonnements")
def relance():
    abonnements.relancer()

@track_cron("export-nocturne")
async def export():
    await exports.lancer()

track("nettoyage", lambda: sessions.nettoyer())
```

Tu peux aussi ajouter tes propres contrôles, synchrones ou asynchrones :

```python
from pulse_python import Probe, http_probe, start_pulse

start_pulse(probes=[
    http_probe("Route /health", "http://localhost:8000/health"),
    Probe(name="Stockage", kind="custom", run=lambda: stockage.lister(limite=1)),
])
```

## Choisir ce qui part

Tu décides de ce que ton app raconte à Pulse. Le signal de vie part toujours, le reste se règle :

```bash
# Seulement l'état des dépendances et les journaux, pas les tâches ni les versions
PULSE_SEND=checks,logs

# Ignorer certaines instances ou certaines tâches (* sert de joker)
PULSE_EXCLUDE=File emails,nettoyage-*

# Ou au contraire, ne suivre que celles-ci
PULSE_INCLUDE=PostgreSQL,Redis,relance-abonnements

# Journaux : seulement les avertissements et les erreurs
PULSE_LOG_LEVEL=WARN
```

Les mêmes réglages existent dans le code, ils passent avant les variables :

```python
start_pulse(
    watch=[engine],
    send=["checks", "crons", "logs"],
    exclude=["File emails"],
    log_level="WARN",
)
```

## Journaux et mode direct (Pro et Team)

Pulse recopie ce qui passe par le module `logging` de Python, sans rien changer à ce que tu vois dans ton terminal : il s'ajoute au logger racine à côté de tes handlers. Le niveau et le nom du logger sont repris (`orbit.paiements`), la trace d'une exception reste entière, et les secrets sont masqués avant de partir (`token=***`, `Bearer ***`, identifiants dans les URL de connexion).

```
ERROR:orbit.paiements:Paiement refusé token=abc123
                                   │
                                   ▼  dans l'app Pulse
ERROR  orbit.paiements  Paiement refusé token=***
```

Seuls les messages qui remontent au logger racine sont vus : un logger réglé avec `propagate = False` (celui des accès d'Uvicorn, par exemple) garde ses lignes pour lui. Les niveaux suivent la configuration de ton app : Pulse ne les change pas.

Quand tu ouvres l'app dans Pulse, le serveur prévient le paquet qu'on le regarde : l'état part toutes les 5 secondes et les journaux chaque seconde, puis tout revient au calme 30 secondes après que tu as quitté l'écran. Les journaux sont gardés 3 jours en Pro et 7 jours en Team. Sans journaux dans ton offre, rien n'est capturé : ton app ne paie aucun surcoût.

## Mode sécurisé : Pulse ne peut rien lire

C'est l'option pour qui ne veut confier ses données à personne, nous compris. Ton iPhone ou ton Mac fabrique une paire de clés. La clé publique va dans ton environnement, la clé privée ne quitte jamais tes appareils. Le chiffrement demande le paquet `cryptography` :

```bash
pip install "pulse-python[secure]"
PULSE_PUBLIC_KEY=pulse_pub_…   # copiée depuis l'app, Réglages de l'app > Mode sécurisé
```

```
 ton app ──── rapport chiffré (HPKE, X25519 + AES-256-GCM) ────▶ Pulse ──▶ ton iPhone
                                                                 │          │
                                                    garde un paquet         ouvre avec
                                                    qu'il ne sait pas       la clé privée
                                                    ouvrir                  du trousseau
```

Sans `cryptography`, Pulse refuse de démarrer plutôt que d'envoyer en clair.

Un serveur qui ne lit rien ne peut pas alerter. C'est pour ça qu'un petit résumé reste en clair par défaut : « tout va bien » ou « 2 soucis », sans nom ni message. La notification dit alors simplement « 2 soucis signalés, le détail est chiffré ». Tu peux en laisser plus, ou rien du tout :

```bash
PULSE_CLEAR=status            # par défaut : un booléen et un nombre
PULSE_CLEAR=status,checks     # Pulse peut dire « Redis ne répond plus »
PULSE_CLEAR=none              # silence complet : Pulse ne sait plus que si l'app est en vie
```

Une fois le mode actif, Pulse refuse tout rapport en clair : un `.env` oublié ne fuit pas.

## Sécurité, en clair

- **Ton app d'abord.** Le paquet ne lève jamais d'erreur (sauf `track`, qui te rend l'erreur de ta propre tâche). Pulse injoignable : un seul avertissement, ton app continue. Clé refusée : les envois s'arrêtent.
- **Rien n'est écrit chez toi.** Les contrôles ne font que lire (`SELECT 1`, `PING`), avec 5 secondes au plus. Une sonde bloquée ne retient ni ton app ni son arrêt.
- **Rien de superflu ne part.** Ni requête, ni donnée métier, ni variable d'environnement. Les messages d'erreur sont nettoyés et coupés à 300 caractères.
- **La clé ne voyage qu'en HTTPS**, dans un en-tête, jamais dans une URL, sans suivre de redirection. Elle ne sert qu'à écrire l'état de cette app : elle n'ouvre pas ton compte. Tu peux la remplacer depuis l'app à tout moment.
- **Relais sans domaine** : avec `PULSE_RELAY_FINGERPRINT`, l'empreinte SHA-256 du certificat est comparée avant d'envoyer le moindre octet.
- **Aucune dépendance obligatoire** : la bibliothèque standard de Python suffit, `cryptography` n'est utile qu'au mode sécurisé.

## Variables

| Variable | Défaut | Rôle |
|---|---|---|
| `PULSE_KEY` | (obligatoire) | clé de l'app : `pk_live_…` en production, `pk_test_…` en staging |
| `PULSE_SEND` | `checks,crons,logs,versions` | ce qui part en plus du signal de vie |
| `PULSE_INCLUDE` | (tout) | seuls ces contrôles et tâches, noms séparés par des virgules, `*` permis |
| `PULSE_EXCLUDE` | (rien) | contrôles et tâches ignorés |
| `PULSE_LOG_LEVEL` | `DEBUG` | niveau minimal des journaux envoyés |
| `PULSE_LOGS` | `true` | `false` coupe les journaux |
| `PULSE_PUBLIC_KEY` | (aucune) | active le mode sécurisé |
| `PULSE_CLEAR` | `status` | en mode sécurisé, ce qui reste lisible par Pulse |
| `PULSE_ENDPOINT` | `https://api.pulse-app.si` | ton Pulse Relay en mode VPS perso (Team), par exemple `https://relais.orbit.dev` |
| `PULSE_RELAY_FINGERPRINT` | (aucune) | empreinte du certificat d'un relais sans domaine : rien ne part si elle ne correspond pas |
| `PULSE_APP_VERSION` | (aucune) | version affichée dans Pulse |
| `PULSE_DISABLED` | `false` | `true` coupe tout (tests, CI) |

Dans le code, les mêmes réglages s'écrivent en minuscules : `key`, `send`, `include`, `exclude`, `log_level`, `logs`, `public_key`, `clear`, `endpoint`, `relay_fingerprint`, `app_version`, `disabled`.

## Si rien n'arrive

Les messages de Pulse passent par le logger `pulse_python` : ils s'affichent avec ta configuration de `logging`, ou sur la sortie d'erreur si tu n'en as pas.

| Message | Ce qu'il faut faire |
|---|---|
| `PULSE_KEY absente` | la variable n'est pas chargée dans ce processus : vérifie ton `.env` ou ton service |
| `Clé Pulse refusée` | la clé a été remplacée dans l'app : recopie la nouvelle |
| `Mode sécurisé actif pour cette app` | ajoute `PULSE_PUBLIC_KEY` |
| `PULSE_PUBLIC_KEY ne correspond plus` | la clé a changé sur ton téléphone : recopie la clé publique |
| `le paquet cryptography manque` | installe `pulse-python[secure]` |
| `Pulse injoignable` | réseau ou pare-feu : l'app continue, les envois reprennent seuls |

Le rythme des rapports suit celui de l'app dans Pulse (30 secondes en Pro, 5 minutes en Gratuit).

## Licence

MIT
