capyy 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
capy/__init__.py ADDED
@@ -0,0 +1,275 @@
1
+ # capy : mini-API en TCP, une ligne JSON par requête / réponse, chiffrée (TLS)
2
+
3
+ import json as jsonlib # "json" est pris par capy.json()
4
+ import socket
5
+ import threading
6
+
7
+ from . import _tls
8
+ from ._tls import SecurityError # noqa: F401 (capy.SecurityError)
9
+
10
+
11
+ # Serveur : @server.action("nom") pour déclarer une action, server.run() pour lancer
12
+ # Le port se donne à Server(port) ou à run(port), au choix
13
+ # Chiffré (TLS) par défaut ; tls=False pour parler en clair (le client aussi doit mettre tls=False)
14
+ class Server:
15
+ def __init__(self, port=None, tls=True):
16
+ self.port, self.tls = port, tls
17
+ self.actions = {}
18
+ self.listening = False # True une fois le serveur prêt
19
+ self._context = None
20
+
21
+ def action(self, name):
22
+ def decorator(function):
23
+ self.actions[name] = function
24
+ return function
25
+ return decorator
26
+
27
+ # {"action": "get", "id": 2} -> get(id=2)
28
+ def handle(self, line):
29
+ try:
30
+ request = jsonlib.loads(line)
31
+ function = self.actions[request.pop("action")]
32
+ except (ValueError, AttributeError, TypeError): # pas du JSON, ou pas un objet {...}
33
+ return {"erreur": "JSON invalide"}
34
+ except KeyError:
35
+ return {"erreur": "action inconnue"}
36
+ try:
37
+ return function(**request)
38
+ except Exception as e:
39
+ return {"erreur": str(e)}
40
+
41
+ def client(self, conn, addr):
42
+ if self._context:
43
+ conn = _tls.accept(self._context, conn)
44
+ if conn is None: # Le client ne parle pas en chiffré -> on raccroche
45
+ return
46
+ else:
47
+ conn.settimeout(10)
48
+ with conn:
49
+ try:
50
+ line = conn.makefile(encoding="utf-8").readline()
51
+ if not line.strip(): # Connexion ouverte puis fermée sans rien demander
52
+ return
53
+ conn.sendall((jsonlib.dumps(self.handle(line), ensure_ascii=False) + "\n").encode())
54
+ except (OSError, ValueError):
55
+ return
56
+ print(f"{addr[0]} → {line.strip()}")
57
+
58
+ # port 0 -> le système choisit un port libre
59
+ def run(self, port=None):
60
+ self.port = self.port if port is None else port
61
+ if self.port is None:
62
+ raise ValueError("capy: aucun port, utilise Server(port) ou run(port)")
63
+ if self.tls:
64
+ self._context, empreinte = _tls.server_context("server")
65
+ with socket.create_server(("", self.port)) as server:
66
+ self.port = server.getsockname()[1]
67
+ self.listening = True
68
+ if self.tls:
69
+ print(f"API en écoute sur le port {self.port}, chiffrée (TLS) · empreinte : {empreinte}")
70
+ else:
71
+ print(f"API en écoute sur le port {self.port} (pas chiffrée)")
72
+ while True:
73
+ conn, addr = server.accept()
74
+ threading.Thread(target=self.client, args=(conn, addr), daemon=True).start()
75
+
76
+
77
+ # Renvoyer du JSON écrit en texte depuis une action
78
+ # Ex : return capy.json('{"text": "Hello"}')
79
+ def json(text):
80
+ try:
81
+ return jsonlib.loads(text)
82
+ except ValueError as e:
83
+ raise ValueError(f"capy.json : JSON invalide ({e})") from None
84
+
85
+
86
+ # ============================================================
87
+ # EXTRAIRE UNE VALEUR
88
+ # ============================================================
89
+
90
+ _NOTHING = object() # Pour savoir si default a été donné (None est une vraie valeur)
91
+
92
+
93
+ # Extraire une valeur d'un JSON (dict, liste, ou texte JSON) avec un chemin "a.b.0.c"
94
+ # Ex : get('{"text": "Hello"}', "text") -> "Hello"
95
+ # get(reponse, "users.0.name") -> "Izac" (0 = 1er élément d'une liste)
96
+ # get(reponse, "age", default=0) -> 0 si "age" n'existe pas (au lieu d'une erreur)
97
+ def get(data, path, default=_NOTHING):
98
+ value = json(data) if isinstance(data, str) else data
99
+ for key in str(path).split("."):
100
+ if isinstance(value, dict) and key in value:
101
+ value = value[key]
102
+ elif isinstance(value, list) and key.lstrip("-").isdigit() and -len(value) <= int(key) < len(value):
103
+ value = value[int(key)]
104
+ elif default is not _NOTHING:
105
+ return default
106
+ elif isinstance(value, dict):
107
+ raise ValueError(f"capy.get : clé {key!r} introuvable dans {path!r} (clés : {', '.join(map(str, value)) or 'aucune'})")
108
+ elif isinstance(value, list):
109
+ raise ValueError(f"capy.get : {key!r} pas valable dans {path!r} (liste de {len(value)} éléments, 0 à {len(value) - 1})")
110
+ else:
111
+ raise ValueError(f"capy.get : impossible d'aller dans {key!r} de {path!r}, la valeur est un {type(value).__name__}")
112
+ return value
113
+
114
+
115
+ # ============================================================
116
+ # CONVERSIONS
117
+ # ============================================================
118
+ # Pour transformer une valeur reçue (réponse de capy.call, paramètre d'une action...)
119
+ # Si ça ne marche pas -> ValueError avec un message clair
120
+ # (dans une action, le client reçoit alors {"erreur": "..."})
121
+ # key (optionnel) : extraire d'abord une valeur, comme get() -> to_int(reponse, "age")
122
+
123
+ # Erreur commune : "capy.to_int : impossible de transformer 'abc' (str) en int"
124
+ def _error(name, data, target):
125
+ text = repr(data)
126
+ if len(text) > 60:
127
+ text = text[:60] + "…"
128
+ return ValueError(f"capy.{name} : impossible de transformer {text} ({type(data).__name__}) en {target}")
129
+
130
+
131
+ # Du JSON (dict, liste...) -> texte JSON sur une ligne (un texte reste tel quel)
132
+ # Ex : to_str({"text": "Hello"}) -> '{"text": "Hello"}'
133
+ # to_str('{"text": "Hello"}', "text") -> "Hello"
134
+ def to_str(data, key=None):
135
+ if key is not None:
136
+ data = get(data, key)
137
+ if isinstance(data, str):
138
+ return data
139
+ try:
140
+ return jsonlib.dumps(data, ensure_ascii=False)
141
+ except (TypeError, ValueError):
142
+ raise _error("to_str", data, "texte JSON") from None
143
+
144
+
145
+ # -> entier. Accepte 42, 42.0, "42", " 42 "
146
+ # Ex : to_int("42") -> 42 / to_int("abc") -> ValueError
147
+ def to_int(data, key=None):
148
+ if key is not None:
149
+ data = get(data, key)
150
+ if isinstance(data, bool): # True est un int pour Python, mais pas pour nous
151
+ raise _error("to_int", data, "int")
152
+ if isinstance(data, int):
153
+ return data
154
+ if isinstance(data, float) and data.is_integer():
155
+ return int(data)
156
+ if isinstance(data, str):
157
+ try:
158
+ return to_int(float(data)) if "." in data else int(data)
159
+ except ValueError:
160
+ pass
161
+ raise _error("to_int", data, "int")
162
+
163
+
164
+ # -> nombre à virgule. Accepte 4.5, 4, "4.5"
165
+ # Ex : to_float("4.5") -> 4.5
166
+ def to_float(data, key=None):
167
+ if key is not None:
168
+ data = get(data, key)
169
+ if not isinstance(data, bool) and isinstance(data, (int, float, str)):
170
+ try:
171
+ return float(data)
172
+ except ValueError:
173
+ pass
174
+ raise _error("to_float", data, "float")
175
+
176
+
177
+ # -> True / False. Accepte true/false, 1/0, "true"/"false", "oui"/"non", "1"/"0"
178
+ # Ex : to_bool("oui") -> True
179
+ def to_bool(data, key=None):
180
+ if key is not None:
181
+ data = get(data, key)
182
+ if isinstance(data, bool):
183
+ return data
184
+ if data in (0, 1):
185
+ return bool(data)
186
+ if isinstance(data, str):
187
+ text = data.strip().lower()
188
+ if text in ("true", "oui", "yes", "1"):
189
+ return True
190
+ if text in ("false", "non", "no", "0"):
191
+ return False
192
+ raise _error("to_bool", data, "bool")
193
+
194
+
195
+ # -> liste. Accepte une liste, un tuple, ou du texte JSON '[1, 2]'
196
+ # Ex : to_list("[1, 2]") -> [1, 2]
197
+ def to_list(data, key=None):
198
+ if key is not None:
199
+ data = get(data, key)
200
+ if isinstance(data, (list, tuple)):
201
+ return list(data)
202
+ if isinstance(data, str):
203
+ try:
204
+ value = jsonlib.loads(data)
205
+ except ValueError:
206
+ value = None
207
+ if isinstance(value, list):
208
+ return value
209
+ raise _error("to_list", data, "list")
210
+
211
+
212
+ # -> dict. Accepte un dict, ou du texte JSON '{"a": 1}'
213
+ # Ex : to_dict('{"a": 1}') -> {"a": 1}
214
+ def to_dict(data, key=None):
215
+ if key is not None:
216
+ data = get(data, key)
217
+ if isinstance(data, dict):
218
+ return data
219
+ if isinstance(data, str):
220
+ try:
221
+ value = jsonlib.loads(data)
222
+ except ValueError:
223
+ value = None
224
+ if isinstance(value, dict):
225
+ return value
226
+ raise _error("to_dict", data, "dict")
227
+
228
+
229
+ # Client : capy.call("127.0.0.1", 4000, "get", id=2) -> dict
230
+ # Lève ConnectionError si le serveur ne répond pas, SecurityError si ce n'est pas le bon serveur
231
+ # tls=False : pour un serveur pas chiffré (Server(..., tls=False))
232
+ def call(host, port, action, /, tls=True, **params):
233
+ return _call(host, port, action, params, tls, label=f"{host}:{port}")
234
+
235
+
236
+ # Envoyer une demande et lire la réponse
237
+ # label : sous quel nom retenir l'empreinte du serveur ("ip:port", "wifi:salon"...)
238
+ def _call(host, port, action, params, tls, label, who="capy"):
239
+ if not isinstance(host, str) or isinstance(port, bool) or not isinstance(port, int):
240
+ raise TypeError(
241
+ f"capy.call : l'ordre est call(host, port, action), ex : call(\"localhost\", 4000, \"get\")"
242
+ f" -> reçu call({host!r}, {port!r}, {action!r})"
243
+ )
244
+ if "://" in host: # capy parle en TCP, pas en HTTP
245
+ raise ValueError(f"capy.call : host est un nom ou une IP, pas une URL -> {host.split('://', 1)[1].strip('/')!r}")
246
+ connected = False
247
+ hint = " — il est peut-être chiffré : enlève tls=False" if not tls else ""
248
+ try:
249
+ if tls:
250
+ conn = _tls.connect(host, port, label, timeout=3, who=who)
251
+ else:
252
+ conn = socket.create_connection((host, port), timeout=3)
253
+ connected = True
254
+ with conn:
255
+ conn.sendall((jsonlib.dumps({"action": action, **params}) + "\n").encode())
256
+ line = conn.makefile(encoding="utf-8").readline()
257
+ if not line:
258
+ raise ConnectionError(f"{who} : le serveur {host}:{port} n'a rien répondu{hint}")
259
+ return jsonlib.loads(line)
260
+ except ConnectionError as e:
261
+ if str(e).startswith(who): # Message déjà clair (ex : chiffrement impossible)
262
+ raise
263
+ error = e
264
+ except (OSError, ValueError) as e:
265
+ error = e
266
+ if connected: # Il a décroché puis coupé : souvent un serveur chiffré appelé en clair
267
+ raise ConnectionError(f"{who} : le serveur {host}:{port} a coupé la connexion sans répondre{hint}") from None
268
+ raise ConnectionError(f"{who} : le serveur {host}:{port} ne répond pas ({type(error).__name__})") from None
269
+
270
+
271
+ # Empreinte du certificat des serveurs capy de cette machine (à donner aux clients qui veulent vérifier)
272
+ # Ex : capy.fingerprint() -> "9dfa7b98…"
273
+ def fingerprint():
274
+ _tls.server_context("server") # Crée le certificat s'il n'existe pas encore
275
+ return _tls.my_fingerprint("server")
capy/_tls.py ADDED
@@ -0,0 +1,139 @@
1
+ # capy._tls : le chiffrement (TLS) commun à capy, capy.wifi et capy.network
2
+ #
3
+ # Chaque machine qui fait tourner un serveur crée son certificat tout seul (dans ~/.capy, avec openssl).
4
+ # Comme SSH, un client retient l'empreinte d'un serveur à la 1re connexion (~/.capy/known_hosts.json) ;
5
+ # si elle change ensuite -> SecurityError (quelqu'un se fait peut-être passer pour le serveur).
6
+
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import os
11
+ import pathlib as pth
12
+ import socket
13
+ import ssl
14
+ import subprocess
15
+ import threading
16
+
17
+ CAPY_DIR = pth.Path.home() / ".capy"
18
+ KNOWN_FILE = CAPY_DIR / "known_hosts.json"
19
+ LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
20
+ _lock = threading.Lock()
21
+
22
+
23
+ # L'empreinte d'un serveur ne correspond pas : on refuse de lui parler
24
+ class SecurityError(Exception):
25
+ pass
26
+
27
+
28
+ # Empreinte d'un certificat (64 caractères)
29
+ def fingerprint(der):
30
+ return hashlib.sha256(der).hexdigest()
31
+
32
+
33
+ # Certificat de cette machine : créé une fois avec openssl, puis réutilisé
34
+ # kind : "server" (capy, capy.wifi) ou "relay" (capy.network)
35
+ def _certificate(kind):
36
+ cert, key = CAPY_DIR / f"{kind}-cert.pem", CAPY_DIR / f"{kind}-key.pem"
37
+ with _lock:
38
+ if not (cert.exists() and key.exists()):
39
+ CAPY_DIR.mkdir(exist_ok=True)
40
+ try:
41
+ subprocess.run(
42
+ ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650",
43
+ "-subj", f"/CN=capy-{kind}", "-keyout", str(key), "-out", str(cert)],
44
+ check=True, capture_output=True,
45
+ )
46
+ except (OSError, subprocess.CalledProcessError) as e:
47
+ raise RuntimeError(f"capy : impossible de créer le certificat avec openssl ({e})") from None
48
+ os.chmod(key, 0o600) # Clé privée lisible par toi seul
49
+ return cert, key
50
+
51
+
52
+ # Empreinte du certificat de cette machine (None s'il n'existe pas encore)
53
+ def my_fingerprint(kind="server"):
54
+ cert = CAPY_DIR / f"{kind}-cert.pem"
55
+ try:
56
+ return fingerprint(ssl.PEM_cert_to_DER_cert(cert.read_text()))
57
+ except (OSError, ValueError):
58
+ return None
59
+
60
+
61
+ # Contexte TLS côté serveur -> (contexte, empreinte)
62
+ def server_context(kind="server"):
63
+ cert, key = _certificate(kind)
64
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
65
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
66
+ context.load_cert_chain(cert, key)
67
+ return context, my_fingerprint(kind)
68
+
69
+
70
+ # Chiffrer une connexion qui arrive sur un serveur (None si le client ne parle pas en TLS)
71
+ def accept(context, raw, timeout=10):
72
+ try:
73
+ raw.settimeout(timeout)
74
+ conn = context.wrap_socket(raw, server_side=True)
75
+ conn.settimeout(timeout)
76
+ return conn
77
+ except (OSError, ValueError):
78
+ raw.close()
79
+ return None
80
+
81
+
82
+ # Se connecter en chiffré, et vérifier que c'est bien le bon serveur (empreinte)
83
+ # label : sous quel nom retenir l'empreinte ("ip:port", "wifi:salon"...)
84
+ # pinned : empreinte attendue (sinon : celle retenue la 1re fois)
85
+ def connect(host, port, label, timeout=10, pinned=None, who="capy", kind="serveur"):
86
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
87
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
88
+ context.check_hostname = False # Certificat fait maison :
89
+ context.verify_mode = ssl.CERT_NONE # on le vérifie nous-mêmes avec l'empreinte juste après
90
+ raw = socket.create_connection((host, port), timeout=timeout)
91
+ try:
92
+ conn = context.wrap_socket(raw)
93
+ except (ssl.SSLError, OSError):
94
+ raw.close()
95
+ raise ConnectionError(
96
+ f"{who} : connexion chiffrée impossible avec {host}:{port}"
97
+ f" — le {kind} n'est peut-être pas chiffré (tls=False des deux côtés pour s'en passer)"
98
+ ) from None
99
+ try:
100
+ check(label, fingerprint(conn.getpeercert(binary_form=True)), pinned, who, kind, local=host in LOCAL_HOSTS)
101
+ except SecurityError:
102
+ conn.close()
103
+ raise
104
+ return conn
105
+
106
+
107
+ # Comparer une empreinte avec celle attendue, ou celle retenue la 1re fois
108
+ def check(label, seen, pinned=None, who="capy", kind="serveur", local=False):
109
+ wanted = (pinned or "").replace(":", "").lower()
110
+ if wanted:
111
+ if not hmac.compare_digest(wanted, seen):
112
+ raise SecurityError(f"{who} : l'empreinte du {kind} {label} n'est pas celle attendue (reçu {seen})")
113
+ return
114
+ if local and seen == my_fingerprint("server"): # Ce Mac lui-même : rien à vérifier
115
+ return
116
+ with _lock:
117
+ known = _known()
118
+ if label not in known:
119
+ CAPY_DIR.mkdir(exist_ok=True)
120
+ known[label] = seen
121
+ KNOWN_FILE.write_text(json.dumps(known, indent=4))
122
+ print(f"{who} : nouveau {kind} {label}, empreinte retenue {seen}", flush=True)
123
+ return
124
+ if not hmac.compare_digest(known[label], seen):
125
+ raise SecurityError(
126
+ f"{who} : l'empreinte du {kind} {label} a CHANGÉ ! Quelqu'un se fait peut-être passer pour lui.\n"
127
+ f" Si tu as réinstallé le {kind} (normal), supprime sa ligne dans {KNOWN_FILE}"
128
+ )
129
+
130
+
131
+ # Les empreintes retenues (reprend l'ancien fichier known_relays.json s'il existe)
132
+ def _known():
133
+ old = CAPY_DIR / "known_relays.json"
134
+ if not KNOWN_FILE.exists() and old.exists():
135
+ old.rename(KNOWN_FILE)
136
+ try:
137
+ return json.loads(KNOWN_FILE.read_text())
138
+ except (OSError, ValueError):
139
+ return {}
capy/network.py ADDED
@@ -0,0 +1,502 @@
1
+ # capy.network : capy à travers tout internet, grâce à un relais, le tout chiffré (TLS)
2
+ # Le relais tourne sur une machine avec une IP publique (un VPS).
3
+ # Les serveurs s'y connectent (même derrière une box) et s'enregistrent sous un nom,
4
+ # les clients appellent un nom : le relais fait passer la demande et la réponse.
5
+ #
6
+ # Sur le VPS : python3 -m capy.network 3879 motdepasse
7
+ # Partout ailleurs :
8
+ # capy.network.use("mon-vps.fr:3879", key="motdepasse")
9
+ # capy.network.Server("maison").run() / capy.network.call("maison", "test")
10
+ #
11
+ # Chiffrement : le relais crée son certificat tout seul (dans ~/.capy).
12
+ # Comme SSH, la 1re connexion retient son empreinte (~/.capy/known_hosts.json) ;
13
+ # si elle change ensuite -> SecurityError (quelqu'un se fait peut-être passer pour le relais).
14
+
15
+ import hmac
16
+ import json as jsonlib
17
+ import queue
18
+ import select
19
+ import socket
20
+ import sys
21
+ import threading
22
+ import time
23
+
24
+ import capy
25
+ from capy import _tls
26
+ from capy._tls import SecurityError # noqa: F401 (capy.network.SecurityError)
27
+
28
+ PING_EVERY = 25 # Secondes sans rien avant un "je suis toujours là" (sinon la box coupe la connexion)
29
+ _config = {"relay": None, "key": None, "fingerprint": None}
30
+ _print_lock = threading.Lock()
31
+
32
+
33
+ # print() qui ne se mélange pas entre threads
34
+ def _log(text):
35
+ with _print_lock:
36
+ print(text, flush=True)
37
+
38
+
39
+ # Choisir le relais une fois pour toutes (pour Server et call)
40
+ # fingerprint (optionnel) : l'empreinte affichée par le relais au démarrage, pour être sûr dès la 1re fois
41
+ # Ex : use("mon-vps.fr:3879", key="motdepasse")
42
+ def use(relay, key=None, fingerprint=None):
43
+ _config.update(relay=relay, key=key, fingerprint=fingerprint)
44
+ _close_pipes()
45
+
46
+
47
+ # "mon-vps.fr:3879" -> ("mon-vps.fr", 3879)
48
+ def _address(relay):
49
+ relay = relay or _config["relay"]
50
+ if not relay:
51
+ raise ValueError('capy.network : aucun relais, utilise capy.network.use("ip:port")')
52
+ host, _, port = str(relay).rpartition(":")
53
+ if not host or not port.isdigit():
54
+ raise ValueError(f'capy.network : relais invalide {relay!r}, attendu "ip:port" (ex : "mon-vps.fr:3879")')
55
+ return host, int(port)
56
+
57
+
58
+ # ============================================================
59
+ # ENVOYER / RECEVOIR
60
+ # ============================================================
61
+
62
+ def _encode(message):
63
+ return (jsonlib.dumps(message, ensure_ascii=False) + "\n").encode()
64
+
65
+
66
+ # Envoyer un dict sur une ligne JSON
67
+ def _send(sock, message):
68
+ sock.sendall(_encode(message))
69
+
70
+
71
+ # Lire une ligne -> (ligne, ce qui a été reçu après)
72
+ def _recv_line(sock, buffer=b""):
73
+ while b"\n" not in buffer:
74
+ data = sock.recv(65536)
75
+ if not data:
76
+ raise ConnectionError("connexion fermée")
77
+ buffer += data
78
+ line, rest = buffer.split(b"\n", 1)
79
+ return line, rest
80
+
81
+
82
+ # Connexion qui reste ouverte (serveur <-> relais)
83
+ # OpenSSL ne supporte pas 2 threads sur la même connexion chiffrée :
84
+ # un seul thread lit ET écrit, les autres déposent leurs messages dans une file
85
+ class _Channel:
86
+ def __init__(self, sock, on_message, buffer=b""):
87
+ self.sock, self.on_message, self.buffer = sock, on_message, buffer
88
+ self.outbox = queue.Queue()
89
+ self.wake_r, self.wake_w = socket.socketpair() # Pour réveiller le thread quand il y a un message à envoyer
90
+ self.closing = False
91
+
92
+ # Envoyer un message (depuis n'importe quel thread)
93
+ def send(self, message):
94
+ if self.closing:
95
+ raise ConnectionError("connexion fermée")
96
+ self.outbox.put(_encode(message))
97
+ self._wake()
98
+
99
+ # Fermer (depuis n'importe quel thread)
100
+ def close(self):
101
+ self.closing = True
102
+ self._wake()
103
+
104
+ def _wake(self):
105
+ try:
106
+ self.wake_w.send(b"!")
107
+ except OSError:
108
+ pass
109
+
110
+ # Boucle du thread qui lit et écrit (rend la main quand la connexion se ferme)
111
+ def run(self, on_idle=None):
112
+ try:
113
+ self._lines() # Ce qui était déjà arrivé avec le "bonjour"
114
+ while not self.closing:
115
+ ready = [self.sock] if self.sock.pending() else select.select([self.sock, self.wake_r], [], [], PING_EVERY)[0]
116
+ if not ready and on_idle:
117
+ on_idle()
118
+ if self.wake_r in ready:
119
+ self.wake_r.recv(4096)
120
+ while not self.outbox.empty():
121
+ self.sock.sendall(self.outbox.get_nowait())
122
+ if self.sock in ready:
123
+ data = self.sock.recv(65536)
124
+ if not data:
125
+ break
126
+ self.buffer += data
127
+ self._lines()
128
+ except (OSError, ValueError):
129
+ pass
130
+ finally:
131
+ self.closing = True
132
+ for s in (self.sock, self.wake_r, self.wake_w):
133
+ s.close()
134
+
135
+ def _lines(self):
136
+ while b"\n" in self.buffer:
137
+ line, self.buffer = self.buffer.split(b"\n", 1)
138
+ try:
139
+ self.on_message(jsonlib.loads(line))
140
+ except (ValueError, AttributeError):
141
+ pass
142
+
143
+
144
+ # ============================================================
145
+ # CHIFFREMENT (code commun dans capy/_tls.py)
146
+ # ============================================================
147
+
148
+ # Certificat du relais (le sien, différent de celui des serveurs capy de la même machine)
149
+ def _relay_context():
150
+ return _tls.server_context("relay")
151
+
152
+
153
+ # Se connecter au relais en chiffré, et vérifier que c'est bien lui (empreinte)
154
+ def _connect(host, port, timeout=10):
155
+ return _tls.connect(host, port, f"{host}:{port}", timeout, pinned=_config["fingerprint"], who="capy.network", kind="relais")
156
+
157
+
158
+ # ============================================================
159
+ # RELAIS (sur le VPS)
160
+ # ============================================================
161
+
162
+ # Un serveur connecté au relais
163
+ class _Link:
164
+ def __init__(self, name):
165
+ self.name = name
166
+ self.channel = None
167
+ self.lock = threading.Lock()
168
+ self.waiting = {} # id de la demande -> [Event, réponse]
169
+ self.next_id = 0
170
+
171
+ # Faire passer une demande au serveur et attendre sa réponse
172
+ def ask(self, request, timeout):
173
+ done = threading.Event()
174
+ with self.lock:
175
+ self.next_id += 1
176
+ ask_id = self.next_id
177
+ self.waiting[ask_id] = [done, None]
178
+ self.channel.send({"id": ask_id, "request": request})
179
+ if not done.wait(timeout):
180
+ self.waiting.pop(ask_id, None)
181
+ raise TimeoutError(f"{self.name!r} n'a pas répondu en {timeout} s")
182
+ return self.waiting.pop(ask_id)[1]
183
+
184
+ # Une réponse du serveur arrive
185
+ def receive(self, message):
186
+ slot = self.waiting.get(message.get("id"))
187
+ if slot:
188
+ slot[1] = message.get("response")
189
+ slot[0].set()
190
+
191
+
192
+ # Relais : à lancer sur une machine joignable depuis internet
193
+ # Ex : capy.network.Relay(3879, key="motdepasse").run()
194
+ class Relay:
195
+ def __init__(self, port, key=None):
196
+ self.port, self.key = port, key
197
+ self.links = {} # nom -> _Link
198
+
199
+ def _key_ok(self, key):
200
+ return self.key is None or hmac.compare_digest(str(key or ""), self.key)
201
+
202
+ def _client(self, raw, addr):
203
+ try:
204
+ raw.settimeout(10)
205
+ conn = self.context.wrap_socket(raw, server_side=True)
206
+ line, rest = _recv_line(conn)
207
+ hello = jsonlib.loads(line)
208
+ kind = hello.get("capy")
209
+ except (OSError, ValueError, AttributeError):
210
+ raw.close()
211
+ return
212
+ with conn:
213
+ if kind == "call": # La clé est vérifiée à chaque demande
214
+ self._calls(hello, conn, rest)
215
+ elif not self._key_ok(hello.get("key")):
216
+ _send(conn, {"capy": "erreur", "message": "capy.network : mauvaise clé pour ce relais"})
217
+ elif kind == "register":
218
+ self._register(hello.get("name"), conn, rest, addr)
219
+
220
+ # Un serveur s'enregistre : on garde sa connexion ouverte
221
+ def _register(self, name, conn, rest, addr):
222
+ if not isinstance(name, str) or not name:
223
+ _send(conn, {"capy": "erreur", "message": "capy.network : nom invalide"})
224
+ return
225
+ old = self.links.get(name)
226
+ if old and self._alive(old): # Nom pris par un serveur qui répond encore -> refusé
227
+ _send(conn, {"capy": "erreur", "message": f"capy.network : nom déjà utilisé, un autre serveur est connecté au relais sous le nom {name!r}"})
228
+ _log(f"! {name} refusé ({addr[0]}) : nom déjà utilisé")
229
+ return
230
+ _send(conn, {"capy": "ok"})
231
+ conn.settimeout(None)
232
+ link = _Link(name)
233
+ link.channel = _Channel(conn, link.receive, rest)
234
+ self.links[name] = link # L'ancien ne répondait plus (connexion morte) -> le nouveau le remplace
235
+ if old:
236
+ old.channel.close()
237
+ _log(f"+ {name} connecté ({addr[0]})")
238
+ link.channel.run()
239
+ if self.links.get(name) is link:
240
+ del self.links[name]
241
+ for slot in list(link.waiting.values()): # Demandes en cours -> erreur
242
+ slot[1] = {"erreur": f"capy.network : {name!r} s'est déconnecté"}
243
+ slot[0].set()
244
+ _log(f"- {name} déconnecté")
245
+
246
+ # Le serveur connecté sous ce nom répond-il encore ? (3 s max, sinon sa connexion est morte)
247
+ def _alive(self, link):
248
+ try:
249
+ link.ask({"action": "__capy_ping__"}, timeout=3)
250
+ except (TimeoutError, OSError):
251
+ return False
252
+ return not link.channel.closing
253
+
254
+ # Un client appelle des serveurs par leur nom
255
+ # La connexion reste ouverte : il peut envoyer d'autres demandes (en parallèle, chacune avec son id)
256
+ def _calls(self, hello, conn, rest):
257
+ conn.settimeout(None)
258
+ idle = []
259
+
260
+ def on_message(message):
261
+ idle.clear()
262
+ self._call_async(channel, message)
263
+
264
+ def on_idle(): # Client parti sans prévenir (plus de "je suis toujours là") -> on ferme
265
+ idle.append(1)
266
+ if len(idle) >= 3:
267
+ channel.close()
268
+
269
+ channel = _Channel(conn, on_message, rest)
270
+ self._call_async(channel, hello)
271
+ channel.run(on_idle=on_idle)
272
+
273
+ # Chaque demande dans un thread à part (pour ne pas bloquer les autres)
274
+ def _call_async(self, channel, message):
275
+ if message.get("capy") == "call":
276
+ threading.Thread(target=self._call, args=(channel, message), daemon=True).start()
277
+
278
+ # Faire passer une demande au serveur, et renvoyer sa réponse au client (avec l'id de la demande)
279
+ def _call(self, channel, message):
280
+ name, request = message.get("to"), message.get("request")
281
+ reply = {"capy": "erreur", "id": message.get("id")}
282
+ link = self.links.get(name)
283
+ if not self._key_ok(message.get("key")):
284
+ reply["message"] = "capy.network : mauvaise clé pour ce relais"
285
+ elif link is None:
286
+ connected = ", ".join(self.links) or "aucun"
287
+ reply["message"] = f"capy.network : aucun serveur {name!r} sur le relais (connectés : {connected})"
288
+ else:
289
+ try:
290
+ reply.update(capy="ok", response=link.ask(request, timeout=10))
291
+ except (TimeoutError, OSError) as e:
292
+ reply["message"] = f"capy.network : {e}"
293
+ try:
294
+ channel.send(reply)
295
+ except ConnectionError:
296
+ return
297
+ if reply["capy"] == "ok":
298
+ _log(f" {name} ← {jsonlib.dumps(request, ensure_ascii=False)}")
299
+
300
+ def run(self):
301
+ self.context, fingerprint = _relay_context()
302
+ with socket.create_server(("", self.port)) as server:
303
+ _log(f"Relais capy.network en écoute sur le port {self.port}, chiffré (TLS)"
304
+ + (", avec clé" if self.key else ", SANS clé : ouvert à tous"))
305
+ _log(f"Empreinte : {fingerprint}")
306
+ try:
307
+ while True:
308
+ conn, addr = server.accept()
309
+ threading.Thread(target=self._client, args=(conn, addr), daemon=True).start()
310
+ except KeyboardInterrupt:
311
+ _log("\nRelais arrêté")
312
+
313
+
314
+ # ============================================================
315
+ # SERVEUR (n'importe où, même derrière une box)
316
+ # ============================================================
317
+
318
+ # Serveur joignable depuis tout internet sous un nom, via le relais
319
+ # Ex : api = capy.network.Server("maison") puis api.run()
320
+ class Server(capy.Server):
321
+ def __init__(self, name, relay=None, key=None):
322
+ super().__init__()
323
+ self.name, self.relay, self.key = name, relay, key
324
+
325
+ # Se connecter au relais et répondre aux demandes (se reconnecte tout seul si ça coupe)
326
+ def run(self):
327
+ host, port = _address(self.relay)
328
+ key = self.key if self.key is not None else _config["key"]
329
+ while True:
330
+ try:
331
+ conn = _connect(host, port)
332
+ _send(conn, {"capy": "register", "name": self.name, "key": key})
333
+ line, rest = _recv_line(conn)
334
+ hello = jsonlib.loads(line)
335
+ if hello.get("capy") != "ok":
336
+ conn.close()
337
+ raise PermissionError(hello.get("message", "capy.network : le relais a refusé la connexion"))
338
+ conn.settimeout(None)
339
+ _log(f"Connecté au relais {host}:{port} sous le nom {self.name!r} (chiffré)")
340
+ channel = _Channel(conn, lambda message: self._request(channel, message), rest)
341
+ channel.run(on_idle=lambda: channel.send({"ping": True}))
342
+ _log("Connexion au relais perdue, reconnexion dans 3 s")
343
+ except PermissionError:
344
+ raise
345
+ except (OSError, ValueError) as e:
346
+ _log(f"Relais {host}:{port} injoignable ({e}), nouvel essai dans 3 s")
347
+ except KeyboardInterrupt:
348
+ _log("\nServeur arrêté")
349
+ return
350
+ time.sleep(3)
351
+
352
+ # Une demande arrive : on y répond dans un thread à part (pour ne pas bloquer les autres)
353
+ def _request(self, channel, message):
354
+ if (message.get("request") or {}).get("action") == "__capy_ping__": # Le relais vérifie qu'on est vivant
355
+ channel.send({"id": message.get("id"), "response": {"pong": True}})
356
+ elif "request" in message:
357
+ threading.Thread(target=self._answer, args=(channel, message), daemon=True).start()
358
+
359
+ def _answer(self, channel, message):
360
+ response = self.handle(jsonlib.dumps(message["request"]))
361
+ try:
362
+ jsonlib.dumps(response)
363
+ except (TypeError, ValueError) as e:
364
+ response = {"erreur": f"réponse pas en JSON : {e}"}
365
+ try:
366
+ channel.send({"id": message.get("id"), "response": response})
367
+ except ConnectionError:
368
+ return
369
+ _log(f"réseau → {jsonlib.dumps(message['request'], ensure_ascii=False)}")
370
+
371
+
372
+ # ============================================================
373
+ # CLIENT (n'importe où)
374
+ # ============================================================
375
+
376
+ _pipes = {} # (host, port) -> _Pipe : une connexion gardée ouverte par relais
377
+ _pipes_lock = threading.Lock()
378
+ _old_relays = set() # Relais d'une ancienne version : une seule demande par connexion
379
+
380
+
381
+ # "le relais ne répond pas", sauf si l'erreur a déjà un message clair (ex : TLS impossible)
382
+ def _unreachable(host, port, error):
383
+ if isinstance(error, ConnectionError) and str(error).startswith("capy.network"):
384
+ return error
385
+ return ConnectionError(f"capy.network : le relais {host}:{port} ne répond pas")
386
+
387
+
388
+ # Connexion client <-> relais gardée ouverte entre les appels (évite TCP + TLS à chaque fois)
389
+ # Chaque demande porte un id : plusieurs appels en parallèle passent par la même connexion
390
+ class _Pipe:
391
+ def __init__(self, host, port):
392
+ conn = _connect(host, port, timeout=12)
393
+ conn.settimeout(None)
394
+ self.address = (host, port)
395
+ self.lock = threading.Lock()
396
+ self.waiting = {} # id de la demande -> [Event, réponse]
397
+ self.next_id = 0
398
+ self.closed = False
399
+ self.channel = _Channel(conn, self._receive)
400
+ threading.Thread(target=self._run, daemon=True).start()
401
+
402
+ def _run(self):
403
+ self.channel.run(on_idle=lambda: self.channel.send({"ping": True}))
404
+ with self.lock:
405
+ self.closed = True
406
+ slots = list(self.waiting.values())
407
+ for slot in slots: # Demandes en cours -> réponse None (connexion perdue)
408
+ slot[0].set()
409
+
410
+ # Envoyer une demande et attendre la réponse du relais (None si la connexion a coupé avant)
411
+ def ask(self, message, timeout):
412
+ done = threading.Event()
413
+ with self.lock:
414
+ if self.closed:
415
+ return None
416
+ self.next_id += 1
417
+ ask_id = self.next_id
418
+ self.waiting[ask_id] = [done, None]
419
+ try: # Sous le verrou : les demandes partent dans l'ordre de leurs id
420
+ self.channel.send({**message, "id": ask_id})
421
+ except ConnectionError:
422
+ pass # _run va libérer la demande
423
+ answered = done.wait(timeout)
424
+ with self.lock:
425
+ slot = self.waiting.pop(ask_id, [done, None])
426
+ if not answered:
427
+ self.channel.close() # Le relais ne répond plus : la prochaine fois on se reconnecte
428
+ raise _unreachable(*self.address, None)
429
+ return slot[1]
430
+
431
+ # Une réponse du relais arrive
432
+ def _receive(self, message):
433
+ with self.lock:
434
+ if "id" in message:
435
+ slot = self.waiting.get(message["id"])
436
+ else: # Ancien relais : il répond sans id, à la 1re demande seulement, puis ferme
437
+ _old_relays.add(self.address)
438
+ slot = self.waiting.get(min(self.waiting, default=None))
439
+ if slot:
440
+ slot[1] = message
441
+ slot[0].set()
442
+
443
+
444
+ # La connexion gardée ouverte vers ce relais (on la crée si besoin)
445
+ def _pipe(host, port):
446
+ with _pipes_lock:
447
+ pipe = _pipes.get((host, port))
448
+ if pipe is None or pipe.channel.closing:
449
+ try:
450
+ pipe = _pipes[(host, port)] = _Pipe(host, port)
451
+ except (OSError, ValueError) as e:
452
+ raise _unreachable(host, port, e) from None
453
+ return pipe
454
+
455
+
456
+ # Fermer les connexions gardées ouvertes (use() a changé de relais, de clé ou d'empreinte)
457
+ def _close_pipes():
458
+ with _pipes_lock:
459
+ for pipe in _pipes.values():
460
+ pipe.channel.close()
461
+ _pipes.clear()
462
+ _old_relays.clear()
463
+
464
+
465
+ # Une demande sur une connexion neuve, fermée juste après (pour les anciens relais)
466
+ def _call_once(host, port, message):
467
+ try:
468
+ with _connect(host, port, timeout=12) as conn:
469
+ _send(conn, message)
470
+ line, _ = _recv_line(conn)
471
+ return jsonlib.loads(line)
472
+ except (OSError, ValueError) as e:
473
+ raise _unreachable(host, port, e) from None
474
+
475
+
476
+ # Appeler une action d'un serveur par son nom, via le relais (comme capy.call)
477
+ # La connexion chiffrée reste ouverte : seul le 1er appel paie la connexion TLS
478
+ # Ex : capy.network.call("maison", "test") -> {"text": "Hello"}
479
+ def call(name, action, /, **params):
480
+ host, port = _address(None)
481
+ message = {"capy": "call", "to": name, "key": _config["key"], "request": {"action": action, **params}}
482
+ reply = None
483
+ for _ in range(2): # Connexion morte (relais relancé) -> on se reconnecte une fois
484
+ if (host, port) in _old_relays:
485
+ reply = _call_once(host, port, message)
486
+ else:
487
+ reply = _pipe(host, port).ask(message, timeout=15)
488
+ if reply is not None:
489
+ break
490
+ if reply is None:
491
+ raise _unreachable(host, port, None)
492
+ if reply.get("capy") != "ok":
493
+ raise ConnectionError(reply.get("message", "capy.network : erreur du relais"))
494
+ return reply["response"]
495
+
496
+
497
+ # Lancer un relais depuis le terminal : python3 -m capy.network 3879 motdepasse
498
+ if __name__ == "__main__":
499
+ if len(sys.argv) < 2 or not sys.argv[1].isdigit():
500
+ print("Usage : python3 -m capy.network PORT [CLÉ]")
501
+ sys.exit(1)
502
+ Relay(int(sys.argv[1]), key=sys.argv[2] if len(sys.argv) > 2 else None).run()
capy/wifi.py ADDED
@@ -0,0 +1,112 @@
1
+ # capy.wifi : capy entre appareils du même wifi, sans connaître les IP
2
+ # Le serveur s'annonce sous un nom, le client l'appelle par ce nom.
3
+ # Chiffré (TLS) comme capy : l'empreinte de chaque serveur est retenue sous "wifi:<nom>".
4
+
5
+ import json as jsonlib
6
+ import socket
7
+ import threading
8
+ import time
9
+
10
+ import capy
11
+
12
+ DISCOVERY_PORT = 38790 # Port UDP où les serveurs répondent "je suis là"
13
+ _found = {} # Serveurs déjà trouvés : nom -> (ip, port)
14
+ _secure = {} # Serveurs trouvés : nom -> chiffré ou pas
15
+
16
+
17
+ # Serveur visible sur le wifi sous un nom (port choisi tout seul si on n'en donne pas)
18
+ # Ex : api = capy.wifi.Server("maison") puis api.run()
19
+ class Server(capy.Server):
20
+ def __init__(self, name, port=0, tls=True):
21
+ super().__init__(port, tls)
22
+ self.name = name
23
+
24
+ # Lancer le serveur, et répondre aux scans du wifi pour être trouvé
25
+ def run(self, port=None):
26
+ threading.Thread(target=self._announce, daemon=True).start()
27
+ print(f"Visible sur le wifi sous le nom {self.name!r} ({_local_ip()})")
28
+ super().run(port)
29
+
30
+ # Répondre "je suis là" aux scans des clients
31
+ def _announce(self):
32
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
33
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
34
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # Plusieurs serveurs sur le même appareil
35
+ s.bind(("", DISCOVERY_PORT))
36
+ while True:
37
+ data, addr = s.recvfrom(1024)
38
+ if data == b"capy?" and self.listening:
39
+ s.sendto(jsonlib.dumps({"name": self.name, "port": self.port, "tls": self.tls}).encode(), addr)
40
+
41
+
42
+ # Tous les serveurs capy.wifi du réseau
43
+ # Ex : scan() -> {"maison": ("192.168.1.42", 51234), "salon": ("192.168.1.50", 3879)}
44
+ def scan(timeout=1, _wanted=None):
45
+ found, me = {}, _local_ip()
46
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
47
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
48
+ end = time.monotonic() + timeout
49
+ next_ask = 0
50
+ while time.monotonic() < end:
51
+ # On redemande 3 fois : en wifi, un message UDP peut se perdre
52
+ if time.monotonic() >= next_ask:
53
+ for target in ("255.255.255.255", "127.0.0.1"): # Tout le wifi + cet appareil (même sans wifi)
54
+ try:
55
+ s.sendto(b"capy?", (target, DISCOVERY_PORT))
56
+ except OSError:
57
+ pass
58
+ next_ask = time.monotonic() + timeout / 3
59
+ s.settimeout(max(min(end, next_ask) - time.monotonic(), 0.001))
60
+ try:
61
+ data, (ip, _) = s.recvfrom(1024)
62
+ info = jsonlib.loads(data)
63
+ name, port = info["name"], int(info["port"])
64
+ except socket.timeout:
65
+ continue
66
+ except (OSError, ValueError, KeyError, TypeError):
67
+ continue
68
+ # Serveur sur cet appareil -> 127.0.0.1 (20x plus rapide que passer par l'IP du wifi)
69
+ found.setdefault(name, ("127.0.0.1" if ip == me else ip, port))
70
+ _secure.setdefault(name, info.get("tls") is True)
71
+ if name == _wanted: # find() : on a ce qu'on cherche, inutile d'attendre
72
+ break
73
+ _found.update(found)
74
+ return found
75
+
76
+
77
+ # Trouver un serveur par son nom -> (ip, port)
78
+ # Ex : find("maison") -> ("192.168.1.42", 51234)
79
+ def find(name, timeout=1):
80
+ if name not in _found:
81
+ found = scan(timeout, _wanted=name)
82
+ if name not in found:
83
+ raise ConnectionError(f"capy.wifi : aucun serveur {name!r} sur le wifi (trouvés : {', '.join(found) or 'aucun'})")
84
+ return _found[name]
85
+
86
+
87
+ # Appeler une action d'un serveur par son nom (comme capy.call, sans IP ni port)
88
+ # tls=False : pour un serveur pas chiffré (Server(..., tls=False))
89
+ # Ex : capy.wifi.call("maison", "test") -> {"text": "Hello"}
90
+ def call(name, action, /, tls=True, **params):
91
+ for attempt in range(2):
92
+ host, port = find(name)
93
+ if _secure.get(name, True) != tls:
94
+ state = "n'est pas chiffré : mets tls=False" if tls else "est chiffré : enlève tls=False"
95
+ raise ConnectionError(f"capy.wifi : le serveur {name!r} {state} dans capy.wifi.call()")
96
+ try:
97
+ return capy._call(host, port, action, params, tls, label=f"wifi:{name}", who="capy.wifi")
98
+ except ConnectionError as e:
99
+ if attempt or "chiffr" in str(e):
100
+ raise
101
+ _found.pop(name, None) # Serveur relancé (autre port, autre IP) -> on le recherche une fois
102
+ _secure.pop(name, None)
103
+
104
+
105
+ # IP de cet appareil sur le wifi (ex : "192.168.1.42")
106
+ def _local_ip():
107
+ try:
108
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
109
+ s.connect(("1.1.1.1", 80)) # Rien n'est envoyé, ça sert juste à savoir quelle IP on utilise
110
+ return s.getsockname()[0]
111
+ except OSError:
112
+ return "127.0.0.1"
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: capyy
3
+ Version: 0.3.0
4
+ Summary: Tiny encrypted JSON-over-TCP APIs: call a function in another program, on your Wi-Fi or across the internet
5
+ Author: Izac
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://libs.zazac.link/capy/
8
+ Project-URL: Documentation, https://libs.zazac.link/capy/
9
+ Keywords: rpc,api,tcp,json,tls,lan,discovery,relay,nat
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Natural Language :: French
15
+ Classifier: Topic :: Internet
16
+ Classifier: Topic :: System :: Networking
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # capyy — tiny encrypted JSON-over-TCP APIs
24
+
25
+ Call a function in another Python program — on the same machine, on your Wi-Fi, or across the internet —
26
+ with one line of code. Zero dependencies, Python 3.9+, encrypted with TLS by default.
27
+
28
+ ```bash
29
+ pip install capyy
30
+ ```
31
+
32
+ The package is published as **capyy**, but you import it as **`capy`**.
33
+ Error messages are in French (the library was written for French speakers), and the full documentation,
34
+ with examples, is at **[libs.zazac.link/capy](https://libs.zazac.link/capy/)**.
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ # server.py
40
+ import capy
41
+
42
+ api = capy.Server(4000)
43
+
44
+ @api.action("hello")
45
+ def hello(name="you"):
46
+ return {"message": f"Hello {name}!"}
47
+
48
+ api.run()
49
+ ```
50
+
51
+ ```python
52
+ # client.py
53
+ import capy
54
+
55
+ print(capy.call("localhost", 4000, "hello", name="Alice")) # {'message': 'Hello Alice!'}
56
+ ```
57
+
58
+ - An action returns a `dict`, or JSON written as text with `capy.json('{"text": "Hello"}')`.
59
+ - Errors raised on the server come back as `{"erreur": "..."}` (unknown action, bad parameters, exceptions).
60
+ - `capy.call` raises `ConnectionError` when the server does not answer.
61
+
62
+ ## Reading a response
63
+
64
+ ```python
65
+ reply = {"city": "Paris", "temp": "18.5", "days": [{"name": "monday"}, {"name": "tuesday"}]}
66
+
67
+ capy.get(reply, "days.1.name") # "tuesday" (paths go through dicts and lists)
68
+ capy.get(reply, "wind", default=0) # 0
69
+ capy.to_float(reply, "temp") # 18.5
70
+ capy.to_int("42"), capy.to_bool("yes") # 42, True
71
+ ```
72
+
73
+ `to_str`, `to_int`, `to_float`, `to_bool`, `to_list` and `to_dict` raise a `ValueError` that says exactly
74
+ what could not be converted.
75
+
76
+ ## Encryption (TLS)
77
+
78
+ Everything is encrypted by default, with nothing to configure.
79
+
80
+ - Each machine running a server creates its own certificate on first launch (`~/.capy`, using `openssl`)
81
+ and prints its **fingerprint**.
82
+ - Like SSH, a client **remembers a server's fingerprint** the first time (`~/.capy/known_hosts.json`)
83
+ and raises `capy.SecurityError` if it ever changes.
84
+ - Use `tls=False` on **both** sides to talk in clear (faster on localhost).
85
+
86
+ ## `capy.wifi` — find each other on the same network
87
+
88
+ ```python
89
+ import capy.wifi
90
+
91
+ api = capy.wifi.Server("living-room") # announces itself on the LAN, port chosen automatically
92
+ ...
93
+ capy.wifi.call("living-room", "light", on=False) # from any device on the same Wi-Fi
94
+ capy.wifi.scan() # {'living-room': ('192.168.1.42', 51234)}
95
+ ```
96
+
97
+ ## `capy.network` — across the internet, through a relay
98
+
99
+ A machine behind a home router cannot be reached from the internet, so `capy.network` goes through a
100
+ **relay** running on a machine with a public IP (a VPS). Servers connect to it and register a name;
101
+ clients call that name. Everything is encrypted, and the connection is kept open between calls.
102
+
103
+ ```bash
104
+ python3 -m capy.network 3879 my-secret-key # on the VPS (open the port in its firewall)
105
+ ```
106
+
107
+ ```python
108
+ import capy.network
109
+
110
+ capy.network.use("my-vps.example:3879", key="my-secret-key")
111
+ capy.network.Server("home").run() # at home, even behind a router
112
+ capy.network.call("home", "temperature") # from anywhere
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,9 @@
1
+ capy/__init__.py,sha256=_8GyTkQ-1v0sZNuDMxOsIFczgdr6lB1V9V8CnWMZ-Z4,10964
2
+ capy/_tls.py,sha256=LGLxUSUkQ2GkVIgxu0j0KHl_9eC8CsaDG_0Pmpt6W4U,5427
3
+ capy/network.py,sha256=llT4BiZ3s2TWkYly47mn4kDklLh2Z9zkzPUFNOsuaRY,20364
4
+ capy/wifi.py,sha256=5xVv69F02M47j1dsE9CFA5VMq5UXebJ8Y-fWzaahGHc,5006
5
+ capyy-0.3.0.dist-info/licenses/LICENSE,sha256=tGjTKp9sFPwxUZXQBjbeBRrFZtWmGiPz6RXCfVV9NIg,1061
6
+ capyy-0.3.0.dist-info/METADATA,sha256=anKV38ur_jGG9LA-3ivdmOWjWFEqkK_eoCAyVxC4EOk,4004
7
+ capyy-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ capyy-0.3.0.dist-info/top_level.txt,sha256=ORaAIIZzMv8hrF-iSAV250cVyIpfuaDS8JPYnJw8BU4,5
9
+ capyy-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Izac
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ capy