Le réseau facile en Python, chiffré et sans dépendance. Tes programmes se parlent sur la même machine, sur le wifi ou à travers internet ; ta maison s'allume depuis ton code ; tes projets se lancent avec une commande.
Au cœur de capy, une idée simple : un serveur propose des actions (des fonctions Python), et des clients les appellent par leur nom, avec des paramètres. Pas de HTTP, pas d'URL, pas de framework : un décorateur côté serveur, une ligne côté client. Tout le reste est construit là-dessus.
décrire un projet une fois, et le lancer comme avec npm
cy start, cy test
Les trois façons de se parler (capy, capy.wifi, capy.network) marchent pareil : @api.action("nom") côté serveur, call(..., "nom", param=valeur) côté client. Avec un [fichier capy.config.json](#fichier-de-projet-capy-config-json), le choix entre les trois se fait dans le fichier, pas dans le code.
API en écoute sur le port 4000, chiffrée (TLS) · empreinte : 9dfa7b98c1e0…
client.py
import capy
try:
reponse = capy.call("localhost", 4000, "hello", nom="Izac")
exceptConnectionErroras e:
exit(str(e))
print(reponse) # tout le dictprint(capy.get(reponse, "message")) # juste le message
résultat
{'message': 'Bonjour Izac !'}
Bonjour Izac !
Et côté serveur, chaque requête s'affiche au passage :
résultat
127.0.0.1 → {"action": "hello", "nom": "Izac"}
💡Arrêter le serveur
Ctrl+C dans son terminal. Et **après chaque modification de serveur.py, relance-le** : Python ne relit pas le fichier tout seul, l'ancien code continue de tourner tant que tu ne l'arrêtes pas.
Côté serveur, chaque client est servi dans son propre thread : plusieurs clients peuvent appeler en même temps, une action lente ne bloque pas les autres.
📌Combien ça coûte ?
Environ 3,5 ms par appel en local avec le chiffrement, 0,4 ms sans (tls=False). Largement assez pour un jeu, un tableau de bord ou une petite appli : capy n'est pas fait pour des milliers d'appels par seconde.
Une action, c'est une fonction normale avec un décorateur. Ses paramètres sont ceux que le client envoie, et ce qu'elle renvoie part au client.
python
@server.action("addition")
defaddition(a, b):
return {"resultat": a + b}
@server.action("saluer")
defsaluer(nom, poli=True): # poli a une valeur par défaut : le client peut l'oublierreturn {"texte": f"Bonjour {nom}"if poli elsef"Salut {nom}"}
Tout ce qui existe en JSON : un dict (le plus pratique), une liste, un texte, un nombre, True / False, None.
python
return {"nom": "Zoe", "age": 14} # dictreturn [1, 2, 3] # listereturn"ok"# textereturn capy.json('{"text": "Hello"}') # du JSON déjà écrit en texte -> le client reçoit un dict
🪤print() n'envoie rien au client
print(...) dans une action affiche dans le terminal du serveur. Le client, lui, reçoit None. Pour envoyer quelque chose, c'est toujours return.
🪤Pas de set, de date ou d'objet
Un set, un datetime ou un objet à toi n'existent pas en JSON. Le client reçoit alors {"erreur": "réponse pas en JSON : Object of type set is not JSON serializable"}. Convertis avant : list(mon_set), ma_date.isoformat(), vars(mon_objet).
Si ton action lève une exception, capy l'attrape et le client reçoit {"erreur": "le message"} : le serveur ne plante pas. Tu peux donc t'en servir exprès pour refuser une demande :
python
@server.action("diviser")
defdiviser(a, b):
if b == 0:
raiseValueError("division par zéro impossible")
return {"resultat": a / b}
résultat
{'erreur': 'division par zéro impossible'}
Les autres erreurs arrivent pareil, toutes seules :
{"erreur": "addition() got an unexpected keyword argument 'c'"}
envoie autre chose que du JSON
{"erreur": "JSON invalide"}
⚠️Moins de 3 secondes
Le client attend la réponse 3 secondes au maximum. Au-delà, il abandonne avec ConnectionError: … n'a pas répondu à temps (3 s) : l'action est sans doute trop lente. Pour un long calcul, lance-le dans un thread et propose une 2e action qui donne le résultat quand il est prêt.
💡Plusieurs clients en même temps : protège ce qui est partagé
Chaque appel tourne dans son thread. Si deux appels modifient la même variable ou le même fichier en même temps, l'un peut écraser l'autre. Un threading.Lock() règle ça (voir l'exemple « Un compteur partagé »).
hote : "localhost" (la même machine), une IP ("192.168.1.42"), ou un nom de machine ("macbook.local").
port : un nombre, celui du serveur.
action : le nom donné à @server.action("...").
les paramètres : autant que tu veux, par leur nom.
La réponse est déjà un objet Python : reponse["message"] marche directement.
🪤L'ordre est hôte, port, action
capy.call(4000, "localhost", "hello") donne une TypeError qui rappelle le bon ordre. Et capy ne parle pas HTTP : pas de http:// devant l'hôte.
📌Des paramètres qui s'appellent name, host ou action
Pas de souci : capy.call("localhost", 4000, "creer", name="Zoe", action="sauter") marche, ces noms ne se mélangent pas avec ceux de call. Seul tls est réservé.
Pas besoin d'écrire un client pour essayer une action : capy call l'appelle et affiche la réponse en JSON.
terminal
capy call localhost:4000 hello nom=Izac
résultat
{
"message": "Bonjour Izac !"
}
Les paramètres s'écrivent nom=valeur. Les nombres, true / false, null et les listes sont compris : age=14 envoie le nombre 14, actif=true un booléen, ids=[1,2] une liste. Le reste est du texte.
Un serveur en clair : ajoute --no-tls.
Si le serveur répond {"erreur": …}, la commande se termine avec le code 1 : pratique dans un script.
Dans un projet, plus besoin d'adresse : cy call hello nom=Izac appelle le serveur du projet, qu'il soit en local, sur le wifi ou derrière un relais.
capy cli ouvre une invite où tu tapes les commandes à la suite, sans retaper capy à chaque fois :
résultat
◆ capy v0.5.4 · mode interactif
help pour l'aide · Tab pour compléter · ↑↓ l'historique · exit pour sortir
capy (mon-jeu) ❯ call hello nom=Izac
{
"message": "Bonjour Izac !"
}
4 ms
capy (mon-jeu) ❯ wifi scan
● maison 192.168.1.42:51234
capy (mon-jeu) ❯ exit
Tab complète les commandes, les scripts et les fichiers du projet, et les sous-commandes (wifi sc → wifi scan).
↑↓ retrouvent les commandes d'avant, même d'une fois sur l'autre (gardées dans ~/.capy/history).
cd dossier change de projet sans sortir : l'invite affiche le projet où tu es.
Une erreur s'affiche en rouge, sans faire sortir du mode. Ctrl+C arrête la commande en cours, exit ou Ctrl+D sort.
📌Les couleurs
Dans un terminal, capy et cy affichent tout en couleurs (thème Catppuccin), avec des icônes et des tableaux alignés. Dans un fichier ou un pipe (| jq, > sortie.txt), la sortie redevient du texte et du JSON purs. Pour couper les couleurs partout : export NO_COLOR=1.
💡Avec jq
La sortie est du JSON pur (les messages comme « empreinte retenue » vont sur la sortie d'erreur) : capy call localhost:4000 hello | jq -r .message affiche juste Bonjour monde !.
Plutôt que de répéter le port, l'adresse ou le relais dans chaque fichier, décris tout ton projet une fois dans un capy.config.json : son nom, sa version, comment le joindre, et ses fichiers. Le serveur et le client le lisent avec capy.project(), et la commande cy lance ses scripts et ses fichiers, comme npm avec package.json (cy ne marche que dans un projet ; pour en créer un : capy new).
Créé : /Users/izac/projets/mon-jeu
mon-jeu v1.0
mode local (localhost:4000)
tls oui (chiffré)
server server.py ✓
client client.py ✓
server │ API en écoute sur le port 4000, chiffrée (TLS) · empreinte : 9dfa7b98…
Bonjour toi !
server │ 127.0.0.1 → {"action": "hello", "nom": "toi"}
new crée le dossier avec capy.config.json, un server.py et un client.py qui marchent déjà. run lance le serveur en fond, attend qu'il soit prêt, lance le client, puis arrête le serveur.
le nom du projet, et le nom du serveur en wifi / network
le nom du dossier
description
une phrase sur le projet
—
version
la version du projet, ex : "1.0"
—
author
qui a fait le projet
—
mode
"local", "wifi" ou "network"
"network" s'il y a un relay, sinon "local"
url ou host
l'adresse du serveur pour le client (mode local) ; url peut contenir le port : "127.0.0.1:2388"
"localhost"
port
le port du serveur (obligatoire en local, choisi tout seul en wifi)
—
tls
le chiffrement
true
relay
le relais capy.network, ex : "mon-vps.fr:3879"
—
key
la clé du relais
—
fingerprint
l'empreinte du relais
—
files
les fichiers du projet, par nom : "server", "client", ou ceux que tu veux
—
📌Écris-le comme tu veux
capy convertit tout seul : "port": "2388" (en texte) devient 2388, et "tls" accepte true, "True", "oui", "non", "1", "0"… Une valeur vraiment impossible donne une erreur claire : 'tls' doit être true ou false dans capy.config.json (reçu 'peut-etre').
Le même code marche dans les trois modes : seul le fichier change.
montre le projet : nom, version, mode, adresse, fichiers (✓ s'ils existent), scripts
capy new NOM [mode]
crée un dossier NOM avec capy.config.json, server.py, client.py
capy init [nom] [mode]
crée juste capy.config.json dans le dossier actuel
cy server
lance le fichier "server" de files
cy client
lance le fichier "client" (les arguments en plus lui sont passés)
cy NOM
lance n'importe quel fichier de files, ex : cy tests
cy NOM [args…]
lance le script NOM de scripts, comme npm run (avant un fichier du même nom)
cy scripts
liste les scripts
cy run
serveur en fond + client, puis arrête le serveur
mode vaut local par défaut, ou wifi, ou network : capy new salon wifi.
💡Plus de fichiers dans files
"files": {"server": "server.py", "client": "client.py", "admin": "outils/admin.py", "tests": "tests.py"}, puis cy admin ou cy tests. Dans le code : app.file("admin") donne le chemin.
🔒Les secrets dans des variables d'environnement
Un texte qui commence par $ est lu dans les variables d'environnement : avec "key": "$CAPY_KEY", la vraie clé reste dans ton ~/.zshrc (export CAPY_KEY='…') et le fichier peut être partagé ou mis sur GitHub sans risque.
💡Trouvé même depuis un sous-dossier
capy.project() et cy cherchent capy.config.json dans le dossier, puis dans les dossiers au-dessus (comme git). Pour un autre fichier : capy.project("autre.json") ou capy.project("~/projets/jeu").
🪤Une faute de frappe dans le fichier
capy vérifie tout au chargement et dit quoi corriger : ValueError: capy.project : clé 'prot' inconnue dans capy.config.json (tu voulais dire 'port' ?). Pareil pour "url": "http://…" (capy ne parle pas HTTP) ou deux ports différents dans url et port.
capy.get(reponse, "chemin") sort une valeur d'une réponse (dict, liste ou texte JSON). Le chemin passe dans les dicts par leur clé, et dans les listes par leur numéro (0 = le 1er).
python
reponse = {"text": "Hello", "users": [{"name": "Izac"}, {"name": "Zoe"}]}
capy.get(reponse, "text") # "Hello"
capy.get(reponse, "users.0.name") # "Izac"
capy.get(reponse, "users.-1.name") # "Zoe" (-1 = le dernier)
capy.get(reponse, "age", default=0) # 0 (au lieu d'une erreur si la clé n'existe pas)
capy.get(reponse, "texte")
# ValueError: capy.get : clé 'texte' introuvable dans 'texte' (clés : text, users)
💡Vérifier une erreur en une ligne
capy.get(reponse, "erreur", default=None) vaut None quand tout va bien : if capy.get(reponse, "erreur", default=None): exit(reponse["erreur"])
Ce que tape un utilisateur avec input() est toujours du texte. Dans l'action, capy.to_int(age) transforme "14" en 14, ou renvoie une erreur claire au client si on a tapé "abc".
Tout est chiffré par défaut, sans rien configurer : capy, capy.wifi et capy.network.
Chaque machine qui fait tourner un serveur crée son certificat toute seule au 1er lancement (~/.capy/server-cert.pem, avec openssl). Le serveur affiche son empreinte en démarrant.
Comme SSH, un client retient l'empreinte d'un serveur à la 1re connexion, dans ~/.capy/known_hosts.json. Si elle change ensuite, capy refuse avec capy.SecurityError : quelqu'un se fait peut-être passer pour le serveur.
Un serveur sur ta propre machine (localhost) est reconnu tout seul, sans rien retenir.
capy.fingerprint() donne l'empreinte des serveurs de ta machine.
résultat
capy : nouveau serveur 192.168.1.42:4000, empreinte retenue 9dfa7b98c1e0…
« l'empreinte a CHANGÉ ! » : que faire ?
Si tu as réinstallé le serveur, changé de machine ou effacé ~/.capy sur le serveur, c'est normal : ouvre ~/.capy/known_hosts.jsonsur le client et supprime la ligne de ce serveur. Sinon, méfie-toi : c'est exactement le message qu'on verrait si quelqu'un se faisait passer pour ton serveur.
ConnectionError: capy : connexion chiffrée impossible avec localhost:4000 — le serveur n'est peut-être pas chiffré (tls=False des deux côtés pour s'en passer)
💡Tester un serveur sans Python
Un serveur tls=False se teste depuis le terminal avec nc : une ligne JSON envoyée, une ligne JSON reçue. printf '{"action": "hello", "nom": "Izac"}\n' | nc localhost 4000
Le serveur écoute sur toutes les adresses de la machine : n'importe quel appareil qui peut joindre ce port (le même wifi, voire internet si ta box redirige le port) peut appeler tes actions. Le chiffrement empêche d'espionner les échanges, pas d'appeler. Ne mets pas d'action dangereuse (supprimer des fichiers, lancer des commandes…) sans demander un mot de passe.
Les secrets ne s'affichent jamais dans le journal du serveur : les paramètres dont le nom contient password, pwd, mdp, secret ou token apparaissent comme "***".
Ne stocke jamais un mot de passe en clair : garde son hash (voir l'exemple « Connexion avec mot de passe »).
Côté client, simplesuit.sys.password() demande un mot de passe en affichant des *.
capy.Server(0) prend un port libre au hasard ; après run(), il est dans server.port.
💡Lancer un serveur dans le même script (pour tester)
run() ne rend jamais la main : lance-le dans un thread, et attends server.listening. Voir l'exemple « Serveur et client dans le même fichier ».
🪤OSError: Address already in use
Le port est déjà pris, souvent par ton propre serveur lancé dans un autre terminal (ou resté ouvert). Trouve qui l'utilise avec lsof -i :4000, puis arrête-le, ou choisis un autre port.
🪤« n'a rien répondu » ou None partout
Ton serveur tourne peut-être encore avec l'ancien code. Arrête-le (Ctrl+C) et relance-le.
💡Même code, trois réseaux
Une action écrite pour capy.Server marche telle quelle avec capy.wifi.Server("nom") ou capy.network.Server("nom") : seule la ligne qui crée le serveur change.
Pour la simplicité : pas d'URL, pas de méthode GET/POST, pas de serveur web à configurer, le chiffrement déjà fait, et le client tient en une ligne. Si tu as besoin d'un vrai site web ou d'une API publique utilisée par des navigateurs, prends plutôt un framework HTTP.
Un programme qui n'est pas en Python peut-il appeler capy ?
Oui, avec tls=False c'est juste du TCP avec une ligne JSON : n'importe quel langage sait faire ça (même nc). En chiffré, il faut parler TLS et accepter le certificat auto-signé du serveur.
Le serveur peut-il envoyer un message au client sans qu'on lui demande ?
Non : le client demande, le serveur répond. Pour l'inverse, le client peut lui aussi lancer un petit capy.Server et donner son adresse au serveur.
Où sont rangés les certificats et les empreintes ?
Dans le dossier ~/.capy : server-cert.pem (le certificat de tes serveurs), relay-cert.pem (celui d'un relais capy.network) et known_hosts.json (les empreintes retenues par tes clients).
Exemples
Premier serveur et son client
Un serveur avec deux actions, et un client qui les appelle. Lance serveur.py dans un terminal, puis client.py dans un autre.
serveur.py
import capy
api = capy.Server(4000)
# Les paramètres envoyés par le client arrivent comme arguments@api.action("bonjour")
defbonjour(nom="toi"):
return {"message": f"Bonjour {nom} !"}
@api.action("addition")
defaddition(a, b):
return {"resultat": a + b}
api.run()
Le client appelle les actions par leur nom, avec des paramètres
Extraire une valeur avec un chemin "a.b.0", et la convertir avec une erreur claire si c'est impossible. Pas besoin de serveur pour celui-ci : lance-le directement.
lire_une_reponse.py
import capy
reponse = {
"ville": "Paris",
"temperature": "18.5",
"pluie": "non",
"jours": [{"nom": "lundi", "max": 21}, {"nom": "mardi", "max": 19}],
}
print(capy.get(reponse, "ville")) # Parisprint(capy.get(reponse, "jours.1.nom")) # mardi (0 = le 1er)print(capy.get(reponse, "jours.-1.max")) # 19 (-1 = le dernier)print(capy.get(reponse, "vent", default=0)) # 0 (la clé n'existe pas)print(capy.to_float(reponse, "temperature")) # 18.5print(capy.to_bool(reponse, "pluie")) # Falseprint(capy.to_str({"ok": True})) # {"ok": true} (dict -> texte JSON)# Si c'est impossible : ValueError avec un message qui dit quoi corrigerfor essai in (lambda: capy.get(reponse, "jours.5"), lambda: capy.to_int(reponse, "ville")):
try:
essai()
exceptValueErroras e:
print("Erreur :", e)
Gérer les erreurs
Deux sortes d'erreurs : le serveur ne répond pas (ConnectionError), ou il répond {"erreur": "..."} (action inconnue, mauvais paramètre, exception dans l'action). Pas besoin de serveur pour celui-ci : lance-le directement.
gerer_les_erreurs.py
import capy
# 1. Personne n'écoute sur ce port -> ConnectionErrortry:
capy.call("localhost", 4999, "bonjour")
exceptConnectionErroras e:
print("Pas de réponse :", e)
# 2. Les erreurs côté serveur reviennent dans la réponse# (handle() traite une demande sans passer par le réseau : pratique pour tester)
api = capy.Server(4000)
@api.action("diviser")
defdiviser(a, b):
return {"resultat": a / b}
print(api.handle('{"action": "diviser", "a": 6, "b": 3}')) # {'resultat': 2.0}print(api.handle('{"action": "diviser", "a": 6, "b": 0}')) # {'erreur': 'division by zero'}print(api.handle('{"action": "multiplier"}')) # {'erreur': 'action inconnue'}print(api.handle("pas du json")) # {'erreur': 'JSON invalide'}
capy.wifi : se trouver par son nom sur le même wifi
Pas besoin de connaître l'IP ni le port : le serveur s'annonce sous un nom. Lance serveur.py sur un appareil, client.py sur un autre du même wifi (ou le même).
serveur.py
import capy.wifi
api = capy.wifi.Server("salon") # Port choisi tout seul@api.action("lumiere")
deflumiere(allumer=True):
etat = "allumée"if allumer else"éteinte"return {"lumiere": etat}
api.run()
Le client cherche "salon" sur le wifi puis l'appelle
Un relais tourne sur une machine joignable depuis internet (un VPS). Les serveurs s'y connectent même derrière une box, les clients les appellent par leur nom.
1. Sur le VPS : ouvrir le port, puis lancer le relais (il affiche son empreinte)
relais.sh
ufw allow 3879/tcp
capy relay 3879 motdepasse
# Relais capy.network en écoute sur le port 3879, chiffré (TLS), avec clé
# Empreinte : 9b734c758696262e758f8664db40fafe3601f3804ced5dc331527704319f3049
2. Le serveur : chez toi, derrière ta box, il se connecte au relais
serveur.py
import capy
import capy.network
capy.network.use("mon-vps.fr:3879", key="motdepasse")
api = capy.network.Server("maison")
@api.action("temperature")
deftemperature():
return {"salon": 20.5}
api.run() # Se reconnecte tout seul si le relais redémarre
3. Le client : n'importe où dans le monde
fingerprint = l'empreinte affichée par le relais : sûr dès la 1re connexion
Tout est chiffré par défaut. Au démarrage, le serveur affiche son empreinte (son « identité ») ; le client la retient à la 1re connexion et refuse ensuite de parler à un imposteur.
serveur.py
import capy
api = capy.Server(4000) # Chiffré (TLS)# api = capy.Server(4000, tls=False) # En clair : le client doit aussi mettre tls=False@api.action("secret")
defsecret():
return {"code": 1234}
api.run()
# API en écoute sur le port 4000, chiffrée (TLS) · empreinte : 9dfa7b98…
Le client vérifie qu'il parle au bon serveur
client.py
import capy
try:
print(capy.call("localhost", 4000, "secret")) # {'code': 1234}except capy.SecurityError as e:
print("Attention :", e) # L'empreinte a changé : capy refuse de parler à ce serveurexceptConnectionErroras e:
print("Pas de réponse :", e)
print("Empreinte de ma machine :", capy.fingerprint())
Connexion avec mot de passe
Un serveur qui crée des comptes et vérifie les mots de passe, sans jamais les enregistrer en clair. On garde seulement un « hash » (une empreinte impossible à inverser) mélangé à un « sel » aléatoire. Lance serveur.py dans un terminal, puis client.py dans un autre.
serveur.py
import hashlib
import secrets
import capy
api = capy.Server(4000)
comptes = {} # nom -> {"sel": ..., "hash": ...} (dans un vrai projet : un fichier JSON)defhacher(mot_de_passe, sel):
# 200 000 tours : très lent à deviner par force brute, mais instantané pour une seule vérificationreturn hashlib.pbkdf2_hmac("sha256", mot_de_passe.encode(), bytes.fromhex(sel), 200_000).hex()
@api.action("inscription")
definscription(nom, password):
if nom in comptes:
raiseValueError(f"le nom {nom!r} est déjà pris")
sel = secrets.token_hex(16)
comptes[nom] = {"sel": sel, "hash": hacher(password, sel)}
return {"ok": True}
@api.action("connexion")
defconnexion(nom, password):
compte = comptes.get(nom)
# Même message si le nom OU le mot de passe est faux : on ne révèle pas quels noms existentif compte isNoneornot secrets.compare_digest(hacher(password, compte["sel"]), compte["hash"]):
raiseValueError("nom ou mot de passe incorrect")
return {"ok": True, "message": f"Bienvenue {nom} !"}
api.run() # Dans le journal, les mots de passe s'affichent "***"
Le client demande le mot de passe sans l'afficher, puis se connecte
client.py
import getpass
import capy
nom = input("nom > ")
mdp = getpass.getpass("mot de passe > ") # Ou simplesuit.sys.password() pour afficher des *
capy.call("localhost", 4000, "inscription", nom=nom, password=mdp)
for essai in (mdp, "pas-le-bon"):
reponse = capy.call("localhost", 4000, "connexion", nom=nom, password=essai)
print(capy.get(reponse, "message", default=None) or capy.get(reponse, "erreur"))
# Bienvenue Zoe !# nom ou mot de passe incorrect
Un compteur partagé : plusieurs clients en même temps
Chaque appel tourne dans son propre thread. Sans verrou, deux appels simultanés peuvent lire la même valeur et l'un écrase l'autre : le compte final est faux. threading.Lock() règle ça. Tout est dans ce fichier (serveur dans un thread + 20 clients) : lance-le directement.
compteur_partage.py
import threading
import time
import capy
api = capy.Server(0) # 0 : un port libre au hasard
compteur = 0
verrou = threading.Lock()
@api.action("plus_un")
defplus_un():
global compteur
with verrou: # Un seul appel à la fois dans ce bloc
valeur = compteur
time.sleep(0.01) # Simule un petit travail (lire un fichier, calculer…)
compteur = valeur + 1return {"compteur": compteur}
threading.Thread(target=api.run, daemon=True).start()
whilenot api.listening: # Attendre que le serveur soit prêt
time.sleep(0.05)
clients = [threading.Thread(target=capy.call, args=("localhost", api.port, "plus_un")) for _ inrange(20)]
for client in clients:
client.start()
for client in clients:
client.join()
print("Compteur :", compteur) # Toujours 20 (sans le verrou : souvent moins !)
Serveur et client dans le même fichier
run() ne rend jamais la main : on le lance dans un thread, puis on attend server.listening. Pratique pour tester ses actions, ou pour un programme qui est à la fois serveur et client. Pour tester une action sans réseau du tout : api.handle('{"action": "..."}').
serveur_et_client_ensemble.py
import threading
import time
import capy
api = capy.Server(0)
@api.action("carre")
defcarre(x):
return {"resultat": capy.to_float(x) ** 2}
threading.Thread(target=api.run, daemon=True).start() # daemon : s'arrête avec le programmewhilenot api.listening:
time.sleep(0.05)
print("Port choisi :", api.port)
print(capy.call("localhost", api.port, "carre", x=7)) # {'resultat': 49.0}print(capy.call("localhost", api.port, "carre", x="3")) # {'resultat': 9.0} ("3" converti)print(capy.call("localhost", api.port, "carre", x="abc")) # {'erreur': "capy.to_float : impossible de transformer 'abc' (str) en float"}print(api.handle('{"action": "carre", "x": 5}')) # {'resultat': 25.0}, sans réseau
Parler à capy depuis le terminal
Sans chiffrement (tls=False), capy c'est juste une ligne JSON envoyée, une ligne JSON reçue : n'importe quel outil ou langage peut l'appeler. Ici avec nc (netcat), déjà installé sur Mac et Linux.
scan() demande à tout le wifi « il y a des serveurs capy ? » et attend les réponses pendant 1 seconde. Lance quelques capy.wifi.Server ailleurs (ou l'exemple 5) pour voir quelque chose.
scanner_le_wifi.py
import capy.wifi
serveurs = capy.wifi.scan(timeout=1)
ifnot serveurs:
print("Aucun serveur capy.wifi trouvé (même wifi ? wifi public qui isole les appareils ?)")
for nom, (ip, port) insorted(serveurs.items()):
ici = " (cet appareil)"if ip == "127.0.0.1"else""print(f"{nom:<15} {ip}:{port}{ici}")
# maison 192.168.1.42:51234# salon 127.0.0.1:50110 (cet appareil)
Garder le relais allumé (systemd)
Sur le VPS : le relais tourne en fond, redémarre s'il plante et se relance au démarrage du VPS. Remplace MA_CLE_SECRETE par ta clé, et /root si capy est installé pour un autre utilisateur.
relais_systemd.sh
sudo tee /etc/systemd/system/capy-relay.service > /dev/null <<'SERVICE'[Unit]
Description=Relais capy.network
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 -u -m capy.network 3879 MA_CLE_SECRETE
WorkingDirectory=/root
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
SERVICE
sudo systemctl daemon-reload
sudo systemctl enable --now capy-relay # Lancer maintenant + à chaque démarrage
systemctl status capy-relay # Il doit être "active (running)"journalctl -u capy-relay -f # Voir son journal en direct (Ctrl+C pour sortir)
Un projet complet : capy.config.json
On décrit le projet une fois (nom, version, port, adresse, chiffrement, fichiers…) ; serveur et client le lisent avec capy.project(), et cy lance ses scripts et ses fichiers (comme npm).
1. Créer le projet (modes : local, wifi ou network), puis tout lancer d'un coup
creer_le_projet.sh
capy new mon-jeu
cd mon-jeu
cy # Voir ce que capy a compris
# mon-jeu v1.0
# mode local (localhost:4000)
# tls oui (chiffré)
# server server.py ✓
# client client.py ✓
cy run # Serveur en fond + client, puis arrêt du serveur
# server │ API en écoute sur le port 4000, chiffrée (TLS) · empreinte : …
# Bonjour toi !
cy server # Ou chacun dans son terminal
cy client
2. Le serveur : pas de port écrit dans le code, il vient du fichier
serveur.py
import capy
app = capy.project() # Lit capy.config.json (à côté du script, ou dans un dossier au-dessus)
server = app.server() # capy.Server, capy.wifi.Server ou capy.network.Server selon "mode"@server.action("hello")
defhello(nom="monde"):
return {"message": f"Bonjour {nom} !"}
server.run()
3. Le client : pas d'IP, pas de port, pas de relais
Cherche sur le wifi les lampes et prises Hue, WiZ, Shelly, Yeelight et Home Assistant, en même temps. ⚠️ À lancer chez toi : sur un wifi public, ce ne sont pas tes lampes.
trouver_les_appareils.py
import capy.domo
home = capy.domo.Home() # capy.domo.json à côté du script (créé au save())
nouveaux = home.scan() # ~2 secondesfor appareil in nouveaux:
print(f"+ {appareil.name:<18} {appareil.brand:<14} {appareil.host or appareil.id}")
for conseil in home.hints: # Ex : « Pont Hue trouvé à 192.168.1.10 : appaire-le avec … »print("!", conseil)
home.save()
print(home) # <capy.domo.Home 4 appareils, 0 groupes, 0 scènes (…/capy.domo.json)>
capy.domo : piloter une lampe
Les mêmes méthodes pour toutes les marques. Régler une lampe éteinte l'allume.
piloter_une_lampe.py
import time
import capy.domo
home = capy.domo.Home()
lampe = home["bureau"] # Le nom donné dans capy.domo.json
lampe.on()
lampe.brightness(30) # %
time.sleep(1)
lampe.color("orange") # "orange", "#FF7800" ou (255, 120, 0)
time.sleep(1)
lampe.white("chaud") # Ou 2700 (kelvins)
time.sleep(1)
lampe.set(brightness=100, color="bleu")
print(lampe.state()) # {'on': True, 'brightness': 100, 'color': '#0000FF', 'kelvin': None, 'reachable': True}
lampe.off()
capy.domo : groupes et scènes
Un groupe se pilote comme une lampe (tous ensemble, en même temps). Une scène règle plusieurs groupes d'un coup. Groupes et scènes sont gardés dans capy.domo.json.
groupes_et_scenes.py
import capy.domo
home = capy.domo.Home()
home.set_group("salon", ["lampe-du-salon", "bureau"])
home.scenes["film"] = {
"salon": {"brightness": 15, "color": "orange"},
"cuisine": {"on": False},
}
home.save()
home["salon"].white("chaud") # Les deux lampes en même temps
home.scene("film") # Tout le monde prend son réglage
home["tout"].off() # « tout » : tous les appareils
home.save_scene("soiree") # L'état actuel devient une scène
home.save()
capy.domo à distance : la maison devient un serveur capy
Sur l'ordi qui reste à la maison. Avec un capy.config.json, c'est son mode (local, wifi ou network) qui est utilisé : avec "relay", la maison est pilotable depuis tout internet. Sans, un serveur capy sur le port 4040.
Ailleurs : la télécommande, avec le même capy.config.json
telecommande.py
import capy.domo
maison = capy.domo.Remote() # Lit capy.config.jsonprint(maison.list()["devices"]) # Ce qu'il y a à la maison
maison["salon"].color("orange") # Mêmes méthodes qu'en local
maison.scene("film")
maison["tout"].off()
capy.domo : un réveil lumineux
La lampe s'allume très doucement, d'un orange chaud vers un blanc de jour, comme un lever de soleil. Lance-le avec l'heure du réveil : python3 19_reveil_lumineux.py 7:30
reveil_lumineux.py
import datetime as dt
import sys
import time
import capy.domo
DUREE = 20 * 60# Le lever de soleil dure 20 minutes
lampe = capy.domo.Home()["chambre"]
heure, minute = (int(x) for x in (sys.argv[1] iflen(sys.argv) > 1else"7:30").split(":"))
reveil = dt.datetime.now().replace(hour=heure, minute=minute, second=0, microsecond=0)
if reveil < dt.datetime.now():
reveil += dt.timedelta(days=1)
debut = reveil - dt.timedelta(seconds=DUREE)
print(f"Lever de soleil de {debut:%H:%M} à {reveil:%H:%M} (Ctrl+C pour annuler)")
time.sleep(max((debut - dt.datetime.now()).total_seconds(), 0))
for etape inrange(1, 61): # 60 étapes : toutes les 20 secondes
avance = etape / 60if avance < 0.4:
lampe.set(brightness=1 + avance * 50, color=(255, round(80 + avance * 200), 0)) # Rouge-orangé qui monteelse:
lampe.set(brightness=avance * 100, kelvin=round(2200 + avance * 3800)) # Puis blanc de plus en plus froid
time.sleep(DUREE / 60)
print("Debout ! ☀️")
deffile(self, name):
if name notin self.files:
known = ", ".join(self.files) or"aucun"raiseKeyError(f'capy.project : pas de fichier {name!r} dans "files" de {self.path.name} (fichiers : {known})')
return self.files[name]
defrun_script(self, name, args=()):
if name notin self.scripts:
known = ", ".join(self.scripts) or"aucun"
close = difflib.get_close_matches(name, self.scripts, n=1)
hint = f" (tu voulais dire {close[0]!r} ?)"if close else""raiseKeyError(f'capy.project : pas de script {name!r} dans "scripts" de {self.path.name}{hint} (scripts : {known})')
command = self.scripts[name]
first, _, rest = command.partition(" ")
if first in ("python3", "python"):
command = shlex.quote(sys.executable) + (" " + rest if rest else"")
elif first == "cy": # cy au début = capy avec ce même Python (même si cy n'est pas dans le PATH)
command = shlex.quote(sys.executable) + " -m capy" + (" " + rest if rest else"")
if args:
command += " " + " ".join(shlex.quote(str(a)) for a in args)
env = {**os.environ, "CAPY_NAME": self.name, "CAPY_MODE": self.mode, "CAPY_PROJECT": str(self.folder)}
if self.mode == "local":
env.update(CAPY_HOST=self.host, CAPY_PORT=str(self.port))
return subprocess.call(command, shell=True, cwd=self.folder, env=env)
defdescribe(self):
where = {"local": f"{self.host}:{self.port}", "wifi": f"sur le wifi, sous le nom {self.name!r}",
"network": f"via le relais {self.relay}, sous le nom {self.name!r}"}[self.mode]
lines = [f"{self.name}" + (f" v{self.version}"if self.version else"") + (f" par {self.author}"if self.author else"")]
if self.description:
lines.append(f" {self.description}")
lines += [f" mode {self.mode} ({where})", f" tls {'oui (chiffré)' if self.tls else 'non (en clair)'}"]
for name, path in self.files.items():
shown = path.relative_to(self.folder) if path.is_relative_to(self.folder) else path
lines.append(f" {name:<8} {shown} {'✓' if path.is_file() else '✗ introuvable'}")
for name, command in self.scripts.items():
lines.append(f" script {name:<8} {command}")
lines.append(f" fichier {self.path}")
return"\n".join(lines)
defclient(self, conn, addr):
if self._context:
conn = _tls.accept(self._context, conn)
if conn isNone: # Le client ne parle pas en chiffré -> on raccrochereturnelse:
conn.settimeout(10)
with conn:
try:
line = conn.makefile(encoding="utf-8").readline()
ifnot line.strip(): # Connexion ouverte puis fermée sans rien demanderreturn
conn.sendall((_dumps(self.handle(line)) + "\n").encode())
except (OSError, ValueError):
returnprint(f"{addr[0]} → {_hide(line)}")
defrun(self, port=None):
self.port = self.port if port isNoneelse port
if self.port isNone:
raiseValueError("capy: aucun port, utilise Server(port) ou run(port)")
if self.tls:
self._context, empreinte = _tls.server_context("server")
with socket.create_server(("", self.port)) as server:
self.port = server.getsockname()[1]
self.listening = Trueif self.tls:
print(f"API en écoute sur le port {self.port}, chiffrée (TLS) · empreinte : {empreinte}")
else:
print(f"API en écoute sur le port {self.port} (pas chiffrée)")
whileTrue:
conn, addr = server.accept()
threading.Thread(target=self.client, args=(conn, addr), daemon=True).start()
defproject(path=None) -> Project:
if path isNone:
found = find()
if found isNone:
raiseFileNotFoundError(
f"capy.project : aucun {FILE_NAME} trouvé dans {_main_dir()} ni au-dessus.\n"f" Crée-le avec : capy init\n"f" ou écris-le toi-même, par exemple : {jsonlib.dumps(EXAMPLE, ensure_ascii=False)}"
)
returnProject(found)
path = pth.Path(path).expanduser()
ifnot path.is_absolute():
path = _main_dir() / path
returnProject(path / FILE_NAME if path.is_dir() else path)
Extraire une valeur d'un JSON (dict, liste, ou texte JSON) avec un chemin "a.b.0.c"
Exget('{"text": "Hello"}', "text") -> "Hello"
Exget(reponse, "users.0.name") -> "Izac" (0 = 1er élément d'une liste)
Exget(reponse, "age", default=0) -> 0 si "age" n'existe pas (au lieu d'une erreur)
Ex-> Any : la valeur peut être de n'importe quel type (sinon Pylance croit que c'est un object)
Voir le code
python
defget(data, path, default=_NOTHING) -> Any:
value = json(data) ifisinstance(data, str) else data
for key instr(path).split("."):
ifisinstance(value, dict) and key in value:
value = value[key]
elifisinstance(value, list) and key.lstrip("-").isdigit() and -len(value) <= int(key) < len(value):
value = value[int(key)]
elif default isnot _NOTHING:
return default
elifisinstance(value, dict):
raiseValueError(f"capy.get : clé {key!r} introuvable dans {path!r} (clés : {', '.join(map(str, value)) or 'aucune'})")
elifisinstance(value, list):
raiseValueError(f"capy.get : {key!r} pas valable dans {path!r} (liste de {len(value)} éléments, 0 à {len(value) - 1})")
else:
raiseValueError(f"capy.get : impossible d'aller dans {key!r} de {path!r}, la valeur est un {type(value).__name__}")
return value
defto_int(data, key=None) -> int:
if key isnotNone:
data = get(data, key)
ifisinstance(data, bool): # True est un int pour Python, mais pas pour nousraise_error("to_int", data, "int")
ifisinstance(data, int):
return data
ifisinstance(data, float) and data.is_integer():
returnint(data)
ifisinstance(data, str):
try:
returnto_int(float(data)) if"."in data elseint(data)
exceptValueError:
passraise_error("to_int", data, "int")
defto_bool(data, key=None) -> bool:
if key isnotNone:
data = get(data, key)
ifisinstance(data, bool):
return data
if data in (0, 1):
returnbool(data)
ifisinstance(data, str):
text = data.strip().lower()
if text in ("true", "oui", "yes", "1"):
returnTrueif text in ("false", "non", "no", "0"):
returnFalseraise_error("to_bool", data, "bool")