capyy 0.5.2__tar.gz → 0.5.4__tar.gz

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.
Files changed (29) hide show
  1. {capyy-0.5.2 → capyy-0.5.4}/PKG-INFO +17 -1
  2. {capyy-0.5.2 → capyy-0.5.4}/README.md +16 -0
  3. capyy-0.5.4/capy/__main__.py +541 -0
  4. {capyy-0.5.2 → capyy-0.5.4}/capy/_project.py +1 -1
  5. capyy-0.5.4/capy/_style.py +112 -0
  6. {capyy-0.5.2 → capyy-0.5.4}/capy/_tls.py +3 -1
  7. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/__main__.py +26 -23
  8. {capyy-0.5.2 → capyy-0.5.4}/capyy.egg-info/PKG-INFO +17 -1
  9. {capyy-0.5.2 → capyy-0.5.4}/capyy.egg-info/SOURCES.txt +1 -0
  10. {capyy-0.5.2 → capyy-0.5.4}/pyproject.toml +1 -1
  11. {capyy-0.5.2 → capyy-0.5.4}/tests/test_capy.py +72 -0
  12. {capyy-0.5.2 → capyy-0.5.4}/tests/test_domo.py +4 -1
  13. capyy-0.5.2/capy/__main__.py +0 -187
  14. {capyy-0.5.2 → capyy-0.5.4}/LICENSE +0 -0
  15. {capyy-0.5.2 → capyy-0.5.4}/capy/__init__.py +0 -0
  16. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/__init__.py +0 -0
  17. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/_base.py +0 -0
  18. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/_homeassistant.py +0 -0
  19. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/_hue.py +0 -0
  20. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/_net.py +0 -0
  21. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/_shelly.py +0 -0
  22. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/_wiz.py +0 -0
  23. {capyy-0.5.2 → capyy-0.5.4}/capy/domo/_yeelight.py +0 -0
  24. {capyy-0.5.2 → capyy-0.5.4}/capy/network.py +0 -0
  25. {capyy-0.5.2 → capyy-0.5.4}/capy/wifi.py +0 -0
  26. {capyy-0.5.2 → capyy-0.5.4}/capyy.egg-info/dependency_links.txt +0 -0
  27. {capyy-0.5.2 → capyy-0.5.4}/capyy.egg-info/entry_points.txt +0 -0
  28. {capyy-0.5.2 → capyy-0.5.4}/capyy.egg-info/top_level.txt +0 -0
  29. {capyy-0.5.2 → capyy-0.5.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capyy
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: Easy, encrypted networking for Python: programs that talk (local, Wi-Fi, internet), home automation, and projects run with cy
5
5
  Author: Izac
6
6
  License-Expression: MIT
@@ -101,6 +101,22 @@ app.call("hello", nom="Izac") # client side
101
101
  `mode` can be `local`, `wifi` or `network` (with `relay`, `key`, `fingerprint`). Strings starting with `$` are read
102
102
  from environment variables, e.g. `"key": "$CAPY_KEY"`. Inside a project, the `cy` command runs its `scripts` like `npm run` (`cy test`, `cy start`) and its files (`cy server`); create a project with `capy new`.
103
103
 
104
+ ## From the terminal
105
+
106
+ ```bash
107
+ capy call localhost:4000 hello nom=Izac # any capy server (add --no-tls for a plain one)
108
+ capy wifi scan # capy.wifi servers on your network
109
+ capy wifi call home temperature
110
+ capy network call home test --relay my-vps.example:3879 --key "$CAPY_KEY"
111
+ cy call hello nom=Izac # inside a project: uses capy.config.json
112
+ ```
113
+
114
+ `capy cli` opens an interactive prompt (history, Tab completion, `cd` between projects). Output is colored in a
115
+ terminal and plain text / JSON in a pipe (set `NO_COLOR=1` to disable colors).
116
+
117
+ Parameters are `name=value`; numbers, `true`/`false` and lists are parsed (`age=14` sends the number 14).
118
+ The answer is printed as JSON; the exit code is 1 when the server answers `{"erreur": ...}`.
119
+
104
120
  ## Encryption (TLS)
105
121
 
106
122
  Everything is encrypted by default, with nothing to configure.
@@ -79,6 +79,22 @@ app.call("hello", nom="Izac") # client side
79
79
  `mode` can be `local`, `wifi` or `network` (with `relay`, `key`, `fingerprint`). Strings starting with `$` are read
80
80
  from environment variables, e.g. `"key": "$CAPY_KEY"`. Inside a project, the `cy` command runs its `scripts` like `npm run` (`cy test`, `cy start`) and its files (`cy server`); create a project with `capy new`.
81
81
 
82
+ ## From the terminal
83
+
84
+ ```bash
85
+ capy call localhost:4000 hello nom=Izac # any capy server (add --no-tls for a plain one)
86
+ capy wifi scan # capy.wifi servers on your network
87
+ capy wifi call home temperature
88
+ capy network call home test --relay my-vps.example:3879 --key "$CAPY_KEY"
89
+ cy call hello nom=Izac # inside a project: uses capy.config.json
90
+ ```
91
+
92
+ `capy cli` opens an interactive prompt (history, Tab completion, `cd` between projects). Output is colored in a
93
+ terminal and plain text / JSON in a pipe (set `NO_COLOR=1` to disable colors).
94
+
95
+ Parameters are `name=value`; numbers, `true`/`false` and lists are parsed (`age=14` sends the number 14).
96
+ The answer is printed as JSON; the exit code is 1 when the server answers `{"erreur": ...}`.
97
+
82
98
  ## Encryption (TLS)
83
99
 
84
100
  Everything is encrypted by default, with nothing to configure.
@@ -0,0 +1,541 @@
1
+ # Deux commandes, installées avec capy :
2
+ #
3
+ # cy : DANS un projet capy (un dossier avec capy.config.json, ou en dessous)
4
+ # cy -> montre le projet (nom, mode, adresse, fichiers, scripts…)
5
+ # cy NOM [args…] -> lance le script NOM de "scripts" (comme npm run)
6
+ # cy scripts -> liste les scripts
7
+ # cy server / cy client -> lance un fichier de "files"
8
+ # cy run -> lance le serveur en fond, puis le client, puis arrête le serveur
9
+ # cy call ACTION [nom=valeur…] -> appelle une action du serveur du projet (local, wifi ou network)
10
+ #
11
+ # capy : tout le reste (et tout ce que fait cy)
12
+ # capy cli -> le mode interactif : on tape les commandes à la suite (historique, Tab)
13
+ # capy new NOM [mode] -> crée un dossier NOM avec capy.config.json, server.py, client.py
14
+ # capy init [nom] [mode] -> crée juste capy.config.json ici
15
+ # capy domo … -> la domotique (capy domo help)
16
+ # capy relay PORT [CLÉ] -> lance un relais capy.network (sur le VPS)
17
+ # capy call HÔTE:PORT ACTION [nom=valeur…] -> appelle une action d'un serveur capy
18
+ # capy wifi scan / capy wifi call NOM ACTION … -> les serveurs du wifi
19
+ # capy network call NOM ACTION … [--relay R --key K] / capy network relay PORT [CLÉ]
20
+ # capy version -> la version de capy
21
+ # mode : local (par défaut), wifi ou network
22
+ # python3 -m capy = capy (si la commande n'est pas dans le PATH)
23
+
24
+ import difflib
25
+ import json
26
+ import os
27
+ import pathlib as pth
28
+ import re
29
+ import shlex
30
+ import socket
31
+ import subprocess
32
+ import sys
33
+ import threading
34
+ import time
35
+
36
+ from . import _style as st
37
+ from ._project import COMMANDS, FILE_NAME, MODES, find, init, new, project
38
+
39
+ OUTSIDE = ("init", "new", "domo", "relay", "wifi", "network") # Commandes de capy, pas de cy
40
+
41
+ # L'aide : (section, [(commande, explication)])
42
+ HELP = [
43
+ ("Dans un projet capy (" + FILE_NAME + ")", [
44
+ ("cy", "montre le projet"),
45
+ ("cy NOM [args…]", "lance le script NOM de \"scripts\" (comme npm run)"),
46
+ ("cy scripts", "liste les scripts"),
47
+ ("cy server / client", "lance un fichier de \"files\""),
48
+ ("cy run", "serveur en fond + client, puis arrête le serveur"),
49
+ ("cy call ACTION [nom=valeur…]", "appelle une action du serveur du projet"),
50
+ ]),
51
+ ("Partout", [
52
+ ("capy cli", "le mode interactif (historique ↑↓, complétion Tab)"),
53
+ ("capy new NOM [mode]", "crée un projet complet (config, server.py, client.py)"),
54
+ ("capy init [nom] [mode]", f"crée juste {FILE_NAME} ici"),
55
+ ("capy call HÔTE:PORT ACTION [nom=valeur…]", "appelle une action (--no-tls pour un serveur en clair)"),
56
+ ("capy wifi scan", "les serveurs capy du wifi"),
57
+ ("capy wifi call NOM ACTION [nom=valeur…]", "appelle un serveur du wifi"),
58
+ ("capy network call NOM ACTION [nom=valeur…]", "par internet (--relay HÔTE:PORT --key CLÉ)"),
59
+ ("capy relay PORT [CLÉ]", "lance un relais capy.network"),
60
+ ("capy domo …", "la domotique (capy domo help)"),
61
+ ("capy version", "la version de capy"),
62
+ ]),
63
+ ]
64
+
65
+
66
+ def usage():
67
+ lines = [st.title("capy", f"v{version()} · le réseau facile en Python"), ""]
68
+ width = max(len(c) for _, commands in HELP for c, _ in commands) # Les deux sections alignées ensemble
69
+ for name, commands in HELP:
70
+ lines.append(st.section(name))
71
+ lines += [f" {st.code(c)}{' ' * (width - len(c) + 3)}{st.paint(d, 'dim')}" for c, d in commands]
72
+ lines.append("")
73
+ lines.append(st.paint(f" Paramètres : nom=Izac age=14 actif=true liste=[1,2] · modes : {', '.join(MODES)}", "dim"))
74
+ return "\n".join(lines)
75
+
76
+
77
+ # La version de capy : celle du pyproject.toml si on tourne depuis le dossier du code (installation éditable,
78
+ # où pip garde la version du jour de l'installation), sinon celle installée par pip
79
+ def version():
80
+ pyproject = pth.Path(__file__).resolve().parent.parent / "pyproject.toml"
81
+ if pyproject.is_file():
82
+ match = re.search(r'^version = "(.+)"', pyproject.read_text(encoding="utf-8"), re.M)
83
+ if match and 'name = "capyy"' in pyproject.read_text(encoding="utf-8"):
84
+ return match.group(1)
85
+ try:
86
+ from importlib.metadata import version as package_version
87
+ return package_version("capyy")
88
+ except Exception:
89
+ return "?"
90
+
91
+
92
+ # Le projet du dossier actuel (ou au-dessus), sinon on explique quoi faire
93
+ def _project():
94
+ path = find(".")
95
+ if path is None:
96
+ sys.exit(f"Pas de {FILE_NAME} ici ni au-dessus : ce dossier n'est pas un projet capy.\n"
97
+ f" Crée un projet : capy new mon-projet\n"
98
+ f" ou juste le fichier : capy init")
99
+ try:
100
+ return project(path)
101
+ except ValueError as e:
102
+ sys.exit(str(e))
103
+
104
+
105
+ # Le projet, joliment (cy sans rien)
106
+ def describe(app):
107
+ head = st.paint(app.name, bold=True) + (st.paint(f" v{app.version}", "dim") if app.version else "") \
108
+ + (st.paint(f" par {app.author}", "dim") if app.author else "")
109
+ lines = [f"{st.paint('◆', 'mauve', bold=True)} {head}"]
110
+ if app.description:
111
+ lines.append(f" {app.description}")
112
+ where = {"local": f"{app.host}:{app.port}", "wifi": f"sur le wifi, nom {app.name!r}",
113
+ "network": f"relais {app.relay}, nom {app.name!r}"}[app.mode]
114
+ lock = st.paint("🔒 chiffré", "green") if app.tls else st.paint("en clair", "yellow")
115
+ lines += ["", st.table([(st.paint("Mode", "dim"), f"{st.paint(app.mode, 'mauve')} · {where} · {lock}"),
116
+ (st.paint("Fichier", "dim"), st.paint(str(app.path).replace(str(pth.Path.home()), "~"), "dim"))])]
117
+ if app.files:
118
+ lines += ["", st.section(" Fichiers")]
119
+ rows = []
120
+ for name, path in app.files.items():
121
+ shown = path.relative_to(app.folder) if str(path).startswith(str(app.folder)) else path
122
+ mark = st.paint("✓", "green") if path.is_file() else st.paint("✗", "red")
123
+ rows.append((mark, st.code(name), str(shown) + ("" if path.is_file() else st.paint(" introuvable", "red"))))
124
+ lines.append(st.table(rows, indent=4, gap=2))
125
+ if app.scripts:
126
+ lines += ["", st.section(" Scripts") + st.paint(" cy NOM pour en lancer un", "dim")]
127
+ lines.append(st.table([(st.code(name), command) for name, command in app.scripts.items()], indent=4))
128
+ return "\n".join(lines)
129
+
130
+
131
+ # Sortir les options "--nom valeur" (et "--drapeau") d'une liste d'arguments -> (options, reste)
132
+ def _options(args, flags=(), values=()):
133
+ options, rest, i = {}, [], 0
134
+ while i < len(args):
135
+ word = args[i]
136
+ if word in flags:
137
+ options[word] = True
138
+ elif word in values:
139
+ if i + 1 >= len(args):
140
+ sys.exit(f"capy : il manque une valeur après {word}")
141
+ options[word] = args[i + 1]
142
+ i += 1
143
+ elif word.startswith("--"):
144
+ sys.exit(f"capy : option {word} inconnue (possibles : {', '.join((*flags, *values)) or 'aucune'})")
145
+ else:
146
+ rest.append(word)
147
+ i += 1
148
+ return options, rest
149
+
150
+
151
+ # "nom=Izac" "age=14" -> {"nom": "Izac", "age": 14} : une valeur qui est du JSON (14, true, [1, 2]) est convertie
152
+ def _params(words):
153
+ params = {}
154
+ for word in words:
155
+ key, sep, value = word.partition("=")
156
+ if not sep or not key:
157
+ sys.exit(f"capy : paramètre {word!r} invalide, écris nom=valeur (ex : nom=Izac age=14)")
158
+ try:
159
+ params[key] = json.loads(value)
160
+ except ValueError:
161
+ params[key] = value
162
+ return params
163
+
164
+
165
+ # Faire l'appel et afficher la réponse en JSON (coloré dans un terminal) ; code 1 si erreur (pratique dans un script)
166
+ def _call(function):
167
+ import capy
168
+ started = time.monotonic()
169
+ try:
170
+ answer = function()
171
+ except (ConnectionError, ValueError, KeyError, capy.SecurityError) as e:
172
+ sys.exit(str(e).strip("'\""))
173
+ print(st.pretty(answer))
174
+ if st.enabled():
175
+ print(st.paint(f" {(time.monotonic() - started) * 1000:.0f} ms", "dim"))
176
+ if isinstance(answer, dict) and "erreur" in answer:
177
+ sys.exit(1)
178
+
179
+
180
+ USAGE_CALL = "Usage : capy call HÔTE:PORT ACTION [nom=valeur…] [--no-tls] (ex : capy call localhost:4000 hello nom=Izac)"
181
+
182
+
183
+ # capy call localhost:4000 hello nom=Izac
184
+ def call_direct(args):
185
+ import capy
186
+ options, rest = _options(args, flags=("--no-tls",))
187
+ if len(rest) < 2:
188
+ sys.exit(USAGE_CALL)
189
+ host, _, port = rest[0].rpartition(":")
190
+ if not host or not port.isdigit():
191
+ sys.exit(f"capy : adresse {rest[0]!r} invalide, écris HÔTE:PORT (ex : localhost:4000)\n{USAGE_CALL}")
192
+ _call(lambda: capy.call(host, int(port), rest[1], tls="--no-tls" not in options, **_params(rest[2:])))
193
+
194
+
195
+ # capy wifi scan / capy wifi call maison hello nom=Izac
196
+ def wifi(args):
197
+ import capy.wifi
198
+ sub = args[0] if args else None
199
+ if sub == "scan":
200
+ options, _ = _options(args[1:], values=("--timeout",))
201
+ if st.enabled():
202
+ print(st.paint(" Recherche sur le wifi…", "dim"), flush=True)
203
+ found = capy.wifi.scan(timeout=float(options.get("--timeout", 1)))
204
+ if not found:
205
+ sys.exit("Aucun serveur capy.wifi trouvé (même wifi ? wifi public qui isole les appareils ?)")
206
+ print(st.table([(st.paint("●", "green"), st.code(name), f"{ip}:{port}",
207
+ st.paint("cet appareil", "dim") if ip == "127.0.0.1" else "")
208
+ for name, (ip, port) in sorted(found.items())]))
209
+ elif sub == "call":
210
+ options, rest = _options(args[1:], flags=("--no-tls",))
211
+ if len(rest) < 2:
212
+ sys.exit("Usage : capy wifi call NOM ACTION [nom=valeur…] [--no-tls] (ex : capy wifi call maison hello)")
213
+ _call(lambda: capy.wifi.call(rest[0], rest[1], tls="--no-tls" not in options, **_params(rest[2:])))
214
+ else:
215
+ sys.exit("Usage : capy wifi scan [--timeout S] ou capy wifi call NOM ACTION [nom=valeur…] [--no-tls]")
216
+
217
+
218
+ # capy network call maison hello --relay mon-vps.fr:3879 --key … / capy network relay 3879 CLÉ
219
+ # Relais et clé : options, sinon le capy.config.json du dossier (mode network), sinon $CAPY_RELAY / $CAPY_KEY
220
+ def network(args):
221
+ import capy.network
222
+ sub = args[0] if args else None
223
+ if sub == "relay" or (sub or "").isdigit(): # capy network relay 3879 CLÉ (ou l'ancien capy network 3879 CLÉ)
224
+ capy.network.cli(args[1:] if sub == "relay" else args)
225
+ elif sub == "call":
226
+ options, rest = _options(args[1:], values=("--relay", "--key", "--fingerprint"))
227
+ if len(rest) < 2:
228
+ sys.exit("Usage : capy network call NOM ACTION [nom=valeur…] [--relay HÔTE:PORT] [--key CLÉ] [--fingerprint …]")
229
+ relay, key, fingerprint = options.get("--relay"), options.get("--key"), options.get("--fingerprint")
230
+ path = find(".")
231
+ if path and not relay:
232
+ try:
233
+ app = project(path)
234
+ except ValueError:
235
+ app = None
236
+ if app and app.mode == "network":
237
+ relay, key, fingerprint = app.relay, key or app.key, fingerprint or app.fingerprint
238
+ relay, key = relay or os.environ.get("CAPY_RELAY"), key or os.environ.get("CAPY_KEY")
239
+ if not relay:
240
+ sys.exit("capy : quel relais ? Ajoute --relay mon-vps.fr:3879 (ou un capy.config.json en mode network, ou $CAPY_RELAY)")
241
+ capy.network.use(relay, key=key, fingerprint=fingerprint)
242
+ _call(lambda: capy.network.call(rest[0], rest[1], **_params(rest[2:])))
243
+ else:
244
+ sys.exit("Usage : capy network call NOM ACTION [nom=valeur…] [--relay …] [--key …] ou capy network relay PORT [CLÉ]")
245
+
246
+
247
+ # Lancer un fichier du projet avec le même Python, depuis le dossier du projet
248
+ def _start(app, name, args=(), **options):
249
+ try:
250
+ path = app.file(name)
251
+ except KeyError as e:
252
+ sys.exit(e.args[0])
253
+ if not path.is_file():
254
+ sys.exit(f"capy : le fichier {name!r} ({path}) n'existe pas")
255
+ return subprocess.Popen([sys.executable, "-u", str(path), *args], cwd=app.folder, **options)
256
+
257
+
258
+ # Attendre que le serveur réponde (10 s max) ; False s'il s'est arrêté avant
259
+ def _wait_ready(app, process):
260
+ end = time.monotonic() + 10
261
+ while time.monotonic() < end:
262
+ if process.poll() is not None:
263
+ return False
264
+ try:
265
+ if app.mode == "local" and app.port:
266
+ socket.create_connection((app.host, app.port), timeout=0.3).close()
267
+ return True
268
+ if app.mode == "wifi":
269
+ import capy.wifi
270
+ capy.wifi._found.pop(app.name, None)
271
+ capy.wifi.find(app.name, timeout=0.3)
272
+ return True
273
+ if app.mode == "network" or not app.port:
274
+ time.sleep(2) # Pas de moyen simple de vérifier : on laisse 2 s au serveur
275
+ return process.poll() is None
276
+ except (OSError, ConnectionError):
277
+ time.sleep(0.2)
278
+ return process.poll() is None
279
+
280
+
281
+ # Recopier la sortie du serveur, avec "server │" devant chaque ligne
282
+ def _relay_output(process):
283
+ for line in process.stdout:
284
+ print(f"{st.paint('server │', 'dim')} {line}", end="", flush=True)
285
+
286
+
287
+ def run(app, args):
288
+ server = _start(app, "server", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
289
+ threading.Thread(target=_relay_output, args=(server,), daemon=True).start()
290
+ try:
291
+ if not _wait_ready(app, server):
292
+ time.sleep(0.2) # Laisser s'afficher l'erreur du serveur
293
+ sys.exit("capy : le serveur s'est arrêté (ou ne répond pas après 10 s)")
294
+ client = _start(app, "client", args)
295
+ code = client.wait()
296
+ except KeyboardInterrupt:
297
+ code = 130
298
+ finally:
299
+ server.terminate()
300
+ try:
301
+ server.wait(timeout=3)
302
+ except subprocess.TimeoutExpired:
303
+ server.kill()
304
+ sys.exit(code)
305
+
306
+
307
+ # prog : la commande tapée (capy ou cy), pour que les suggestions la reprennent
308
+ def main(args, prog="capy"):
309
+ command = args[0] if args else None
310
+ if command in ("-h", "--help", "help"):
311
+ print(usage())
312
+ return
313
+ if command in ("version", "--version", "-V"):
314
+ print(f"capy {version()}")
315
+ return
316
+ if command == "cli":
317
+ interactive()
318
+ return
319
+ if command == "domo": # capy domo salon on
320
+ from .domo.__main__ import cli as domo_cli
321
+ domo_cli(args[1:])
322
+ return
323
+ if command == "relay": # capy relay 3879 CLÉ
324
+ from .network import cli as relay_cli
325
+ relay_cli(args[1:])
326
+ return
327
+ if command == "network":
328
+ network(args[1:])
329
+ return
330
+ if command == "wifi":
331
+ wifi(args[1:])
332
+ return
333
+ if command == "call" and len(args) > 1 and ":" in args[1]: # capy call localhost:4000 hello
334
+ call_direct(args[1:])
335
+ return
336
+ if command in ("init", "new"):
337
+ rest = args[1:]
338
+ mode = next((a for a in rest if a in MODES), "local")
339
+ name = next((a for a in rest if a not in MODES), None)
340
+ if command == "new" and not name:
341
+ sys.exit("Usage : capy new NOM [mode]")
342
+ try:
343
+ path = new(name, mode) / FILE_NAME if command == "new" else init(name=name, mode=mode)
344
+ except (FileExistsError, ValueError) as e:
345
+ sys.exit(str(e))
346
+ print(st.ok(f"Créé : {path.parent if command == 'new' else path}"), "\n")
347
+ print(describe(project(path)))
348
+ if command == "new":
349
+ print(f"\n{st.section('Pour l’essayer')}\n {st.code(f'cd {name}')}\n {st.code('cy run')}")
350
+ if mode == "network":
351
+ print(st.paint("\nPense à : export CAPY_KEY='la clé du relais' (dans ~/.zshrc), et à mettre l'adresse de ton relais", "yellow"))
352
+ return
353
+ if command is None and prog == "capy" and find(".") is None: # capy tout seul, hors d'un projet : l'aide
354
+ print(usage())
355
+ return
356
+ app = _project()
357
+ if command is None:
358
+ print(describe(app))
359
+ elif command == "run":
360
+ run(app, args[1:])
361
+ elif command == "call": # cy call hello nom=Izac : le serveur du projet, quel que soit son mode
362
+ if len(args) < 2:
363
+ sys.exit("Usage : cy call ACTION [nom=valeur…] (ex : cy call hello nom=Izac)")
364
+ _call(lambda: app.call(args[1], **_params(args[2:])))
365
+ elif command == "scripts":
366
+ if not app.scripts:
367
+ print('Pas de "scripts" dans ' + FILE_NAME + ', ex : "scripts": {"test": "python3 -m unittest"}')
368
+ print(st.table([(st.code(name), line) for name, line in app.scripts.items()]))
369
+ elif command in app.scripts:
370
+ print(st.paint(f"> {app.scripts[command]}", "dim"), flush=True) # Comme npm : on montre la commande lancée
371
+ try:
372
+ sys.exit(app.run_script(command, args[1:]))
373
+ except KeyboardInterrupt:
374
+ sys.exit(130)
375
+ elif command in app.files:
376
+ try:
377
+ sys.exit(_start(app, command, args[1:]).wait())
378
+ except KeyboardInterrupt:
379
+ sys.exit(130)
380
+ else:
381
+ names = list(app.scripts) + list(app.files) + [c for c in COMMANDS if prog == "capy" or c not in OUTSIDE]
382
+ close = difflib.get_close_matches(command, names, n=1)
383
+ if close:
384
+ sys.exit(f"{prog} : {command!r} inconnu, tu voulais dire {close[0]!r} ? ({prog} {close[0]})")
385
+ sys.exit(f"{prog} : commande, script ou fichier {command!r} inconnu (possibles : {', '.join(names)})")
386
+
387
+
388
+ # ============================================================
389
+ # MODE INTERACTIF (capy cli)
390
+ # ============================================================
391
+
392
+ HISTORY = pth.Path.home() / ".capy" / "history"
393
+ SUBCOMMANDS = {"wifi": ["scan", "call"], "network": ["call", "relay"], "domo": ["scan", "pair", "list", "scene", "serve", "help"]}
394
+ BUILTINS = ["help", "exit", "clear", "cd", "version"]
395
+
396
+
397
+ # Les mots proposés par Tab : commandes, scripts et fichiers du projet, sous-commandes
398
+ def _words(before):
399
+ words = before.split()
400
+ if not words or (len(words) == 1 and not before.endswith(" ")):
401
+ names = set(COMMANDS) | set(BUILTINS)
402
+ path = find(".")
403
+ if path:
404
+ try:
405
+ app = project(path)
406
+ names |= set(app.scripts) | set(app.files)
407
+ except ValueError:
408
+ pass
409
+ return sorted(names - {"cli"})
410
+ if words[0] in SUBCOMMANDS and (len(words) == 1 or (len(words) == 2 and not before.endswith(" "))):
411
+ return SUBCOMMANDS[words[0]]
412
+ if words[0] == "cd":
413
+ return sorted(p.name + "/" for p in pth.Path(".").iterdir() if p.is_dir() and not p.name.startswith("."))
414
+ return []
415
+
416
+
417
+ def _setup_readline():
418
+ try:
419
+ import readline
420
+ except ImportError: # Windows sans pyreadline : pas d'historique, tout le reste marche
421
+ return None
422
+
423
+ def complete(text, state):
424
+ before = readline.get_line_buffer()[:readline.get_begidx()]
425
+ options = [w for w in _words(before + text) if w.startswith(text)]
426
+ return options[state] + ("" if options[state].endswith("/") else " ") if state < len(options) else None
427
+
428
+ readline.set_completer(complete)
429
+ readline.set_completer_delims(" ")
430
+ if "libedit" in (readline.__doc__ or ""): # macOS : libedit au lieu de GNU readline
431
+ readline.parse_and_bind("bind ^I rl_complete")
432
+ else:
433
+ readline.parse_and_bind("tab: complete")
434
+ try:
435
+ readline.read_history_file(HISTORY)
436
+ except OSError:
437
+ pass
438
+ readline.set_history_length(1000)
439
+ return readline
440
+
441
+
442
+ # L'invite : "capy (mon-projet) ❯" ; les couleurs entourées de \001 \002 pour que readline compte bien la largeur
443
+ def _prompt():
444
+ path = find(".")
445
+ name = None
446
+ if path:
447
+ try:
448
+ name = project(path).name
449
+ except ValueError:
450
+ name = path.parent.name
451
+ if not st.enabled() or not sys.stdin.isatty():
452
+ return ""
453
+ text = st.paint("capy", "mauve", bold=True) + (st.paint(f" ({name})", "dim") if name else "") + st.paint(" ❯ ", "pink", bold=True)
454
+ return "".join(f"\001{part}\002" if part.startswith("\033") else part for part in re.split(r"(\033\[[0-9;]*m)", text))
455
+
456
+
457
+ # capy cli : on tape les commandes à la suite, sans retaper capy (exit ou Ctrl+D pour sortir)
458
+ def interactive():
459
+ readline = _setup_readline() if sys.stdin.isatty() else None
460
+ if st.enabled() and sys.stdin.isatty():
461
+ print(st.title("capy", f"v{version()} · mode interactif"))
462
+ print(st.paint(" help pour l'aide · Tab pour compléter · ↑↓ l'historique · exit pour sortir\n", "dim"))
463
+ while True:
464
+ try:
465
+ line = input(_prompt())
466
+ except EOFError:
467
+ print()
468
+ break
469
+ except KeyboardInterrupt:
470
+ print()
471
+ continue
472
+ try:
473
+ words = shlex.split(line)
474
+ except ValueError as e:
475
+ print(st.bad(f"ligne illisible : {e}", sys.stderr), file=sys.stderr)
476
+ continue
477
+ if words[:1] in (["capy"], ["cy"]): # L'habitude de taper capy devant
478
+ words = words[1:]
479
+ if not words:
480
+ continue
481
+ if words[0] in ("exit", "quit", "q"):
482
+ break
483
+ if words[0] == "clear":
484
+ print("\033[2J\033[H", end="")
485
+ continue
486
+ if words[0] == "cd":
487
+ try:
488
+ os.chdir(os.path.expanduser(words[1] if len(words) > 1 else "~"))
489
+ except OSError as e:
490
+ print(st.bad(str(e), sys.stderr), file=sys.stderr)
491
+ continue
492
+ if words[0] == "cli":
493
+ continue
494
+ try:
495
+ main(words)
496
+ except SystemExit as e:
497
+ _show_error(e)
498
+ except KeyboardInterrupt:
499
+ print()
500
+ if readline:
501
+ try:
502
+ HISTORY.parent.mkdir(exist_ok=True)
503
+ readline.write_history_file(HISTORY)
504
+ except OSError:
505
+ pass
506
+
507
+
508
+ # Une erreur (sys.exit("message")) : ✗ rouge devant, sur la sortie d'erreur
509
+ def _show_error(error):
510
+ if isinstance(error.code, str):
511
+ first, *rest = error.code.split("\n")
512
+ print("\n".join([st.bad(first, sys.stderr), *rest]), file=sys.stderr)
513
+
514
+
515
+ def _run(args, prog):
516
+ try:
517
+ main(args, prog)
518
+ except SystemExit as e:
519
+ _show_error(e)
520
+ raise SystemExit(1 if isinstance(e.code, str) else e.code) from None
521
+ except KeyboardInterrupt:
522
+ print()
523
+ raise SystemExit(130) from None
524
+
525
+
526
+ # La commande cy : seulement les commandes d'un projet (et cy cli)
527
+ def cli():
528
+ args = sys.argv[1:]
529
+ if args[:1] in [[c] for c in OUTSIDE]:
530
+ _show_error(SystemExit(f"cy sert dans un projet capy ; pour ça, c'est la commande capy : capy {' '.join(args)}"))
531
+ raise SystemExit(1)
532
+ _run(args, "cy")
533
+
534
+
535
+ # La commande capy (et python3 -m capy) : tout
536
+ def capy_cli():
537
+ _run(sys.argv[1:], "capy")
538
+
539
+
540
+ if __name__ == "__main__":
541
+ capy_cli()
@@ -46,7 +46,7 @@ FIELDS = {
46
46
 
47
47
  EXAMPLE = {"name": "mon-projet", "mode": "local", "host": "localhost", "port": 4000, "tls": True,
48
48
  "files": {"server": "server.py", "client": "client.py"}}
49
- COMMANDS = ("init", "new", "run", "scripts", "help", "domo", "relay") # Mots réservés de cy / capy (pas des noms de fichiers ni de scripts)
49
+ COMMANDS = ("init", "new", "run", "scripts", "help", "call", "cli", "version", "domo", "relay", "wifi", "network") # Mots réservés de cy / capy (pas des noms de fichiers ni de scripts)
50
50
 
51
51
 
52
52
  # Dossier du script lancé (comme simplesuit.json) ; sans script (python3 -c, REPL) -> dossier actuel
@@ -0,0 +1,112 @@
1
+ # capy : le style des commandes capy et cy (couleurs, icônes, JSON coloré), sans dépendance
2
+ # Les couleurs se coupent toutes seules si la sortie n'est pas un terminal (fichier, | jq…) ou avec NO_COLOR=1
3
+
4
+ import json as jsonlib
5
+ import os
6
+ import re
7
+ import sys
8
+
9
+ # Couleurs Catppuccin Mocha (comme le thème du terminal), en RGB 24 bits
10
+ PALETTE = {
11
+ "text": (205, 214, 244), "dim": (127, 132, 156), "blue": (137, 180, 250), "green": (166, 227, 161),
12
+ "red": (243, 139, 168), "peach": (250, 179, 135), "yellow": (249, 226, 175), "mauve": (203, 166, 247),
13
+ "teal": (148, 226, 213), "pink": (245, 194, 231),
14
+ }
15
+ RESET = "\033[0m"
16
+
17
+
18
+ # Couleurs sur cette sortie ? (terminal, et pas de NO_COLOR)
19
+ def enabled(stream=None):
20
+ stream = stream or sys.stdout
21
+ return hasattr(stream, "isatty") and stream.isatty() and not os.environ.get("NO_COLOR")
22
+
23
+
24
+ # Colorer un texte : paint("ok", "green", bold=True)
25
+ def paint(text, color=None, bold=False, dim=False, stream=None):
26
+ if not enabled(stream):
27
+ return str(text)
28
+ codes = ""
29
+ if color:
30
+ codes += "\033[38;2;{};{};{}m".format(*PALETTE[color])
31
+ if bold:
32
+ codes += "\033[1m"
33
+ if dim:
34
+ codes += "\033[2m"
35
+ return f"{codes}{text}{RESET}"
36
+
37
+
38
+ # Enlever les couleurs (pour mesurer la vraie largeur d'un texte)
39
+ def strip(text):
40
+ return re.sub(r"\033\[[0-9;]*m", "", text)
41
+
42
+
43
+ # Raccourcis
44
+ def ok(text):
45
+ return f"{paint('✓', 'green', bold=True)} {text}"
46
+
47
+
48
+ def bad(text, stream=None):
49
+ return f"{paint('✗', 'red', bold=True, stream=stream)} {text}"
50
+
51
+
52
+ def title(text, extra=""):
53
+ return f"{paint('◆', 'mauve', bold=True)} {paint(text, bold=True)}" + (f" {paint(extra, 'dim')}" if extra else "")
54
+
55
+
56
+ def section(text):
57
+ return paint(text, "blue", bold=True)
58
+
59
+
60
+ def code(text):
61
+ return paint(text, "teal")
62
+
63
+
64
+ # Un petit carré de la couleur donnée ("#FF7800") : l'aperçu d'une lampe
65
+ def swatch(hexa, stream=None):
66
+ if not enabled(stream) or not hexa:
67
+ return ""
68
+ r, g, b = (int(hexa.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
69
+ return f"\033[38;2;{r};{g};{b}m██{RESET}"
70
+
71
+
72
+ # Un tableau aligné : [(colonne1, colonne2, …)] ; les couleurs ne gênent pas l'alignement
73
+ def table(rows, indent=2, gap=3):
74
+ rows = [[str(c) for c in row] for row in rows]
75
+ if not rows:
76
+ return ""
77
+ widths = [max(len(strip(row[i])) for row in rows if i < len(row)) for i in range(max(len(r) for r in rows))]
78
+ lines = []
79
+ for row in rows:
80
+ cells = [cell + " " * (widths[i] - len(strip(cell))) if i < len(row) - 1 else cell for i, cell in enumerate(row)]
81
+ lines.append(" " * indent + (" " * gap).join(cells).rstrip())
82
+ return "\n".join(lines)
83
+
84
+
85
+ # Du JSON indenté et coloré (clés en bleu, textes en vert, nombres en orange…), JSON pur sans couleurs
86
+ def pretty(data, stream=None):
87
+ if not enabled(stream):
88
+ return jsonlib.dumps(data, indent=2, ensure_ascii=False)
89
+ return _pretty(data, 0)
90
+
91
+
92
+ def _pretty(value, level):
93
+ pad, end = " " * (level + 1), " " * level
94
+ punct = lambda t: paint(t, "dim")
95
+ if isinstance(value, dict):
96
+ if not value:
97
+ return punct("{}")
98
+ items = [f"{pad}{paint(jsonlib.dumps(str(k), ensure_ascii=False), 'blue')}{punct(':')} {_pretty(v, level + 1)}"
99
+ for k, v in value.items()]
100
+ return punct("{") + "\n" + (punct(",") + "\n").join(items) + "\n" + end + punct("}")
101
+ if isinstance(value, list):
102
+ if not value:
103
+ return punct("[]")
104
+ items = [pad + _pretty(v, level + 1) for v in value]
105
+ return punct("[") + "\n" + (punct(",") + "\n").join(items) + "\n" + end + punct("]")
106
+ if value is None:
107
+ return paint("null", "red")
108
+ if isinstance(value, bool):
109
+ return paint("true" if value else "false", "mauve")
110
+ if isinstance(value, (int, float)):
111
+ return paint(jsonlib.dumps(value), "peach")
112
+ return paint(jsonlib.dumps(str(value), ensure_ascii=False), "green")
@@ -12,6 +12,7 @@ import pathlib as pth
12
12
  import socket
13
13
  import ssl
14
14
  import subprocess
15
+ import sys
15
16
  import threading
16
17
 
17
18
  CAPY_DIR = pth.Path.home() / ".capy"
@@ -119,7 +120,8 @@ def check(label, seen, pinned=None, who="capy", kind="serveur", local=False):
119
120
  CAPY_DIR.mkdir(exist_ok=True)
120
121
  known[label] = seen
121
122
  KNOWN_FILE.write_text(json.dumps(known, indent=4))
122
- print(f"{who} : nouveau {kind} {label}, empreinte retenue {seen}", flush=True)
123
+ # Sur stderr : la sortie normale reste propre (ex : capy call … | jq)
124
+ print(f"{who} : nouveau {kind} {label}, empreinte retenue {seen}", file=sys.stderr, flush=True)
123
125
  return
124
126
  if not hmac.compare_digest(known[label], seen):
125
127
  raise SecurityError(
@@ -16,6 +16,7 @@ import sys
16
16
  import time
17
17
 
18
18
  import capy
19
+ from capy import _style as st
19
20
 
20
21
  from . import FILE_NAME, WHITES, Home, Remote, _hue, find, serve, to_kelvin, to_rgb
21
22
 
@@ -51,18 +52,21 @@ def parse(words):
51
52
  return changes
52
53
 
53
54
 
55
+ # Une ligne d'état : "● salon allumé · 30 % · ██ #FF7800" (le carré est de la couleur de la lampe)
54
56
  def show_state(name, state):
57
+ label = st.code(f"{name:<18}")
55
58
  if not state.get("reachable", True):
56
- return f" {name:<18} injoignable"
57
- parts = ["allumé" if state["on"] else "éteint"]
58
- if state["on"]:
59
- if state.get("brightness") is not None:
60
- parts.append(f"{state['brightness']} %")
61
- if state.get("color"):
62
- parts.append(state["color"])
63
- if state.get("kelvin"):
64
- parts.append(f"{state['kelvin']} K")
65
- return f" {name:<18} {' · '.join(parts)}"
59
+ return f" {st.paint('✗', 'red')} {label} {st.paint('injoignable', 'red')}"
60
+ if not state["on"]:
61
+ return f" {st.paint('○', 'dim')} {label} {st.paint('éteint', 'dim')}"
62
+ parts = ["allumé"]
63
+ if state.get("brightness") is not None:
64
+ parts.append(f"{state['brightness']} %")
65
+ if state.get("color"):
66
+ parts.append(f"{st.swatch(state['color'])} {state['color']}".strip())
67
+ if state.get("kelvin"):
68
+ parts.append(f"{state['kelvin']} K")
69
+ return f" {st.paint('●', 'yellow')} {label} {' · '.join(parts)}"
66
70
 
67
71
 
68
72
  def main(args):
@@ -81,14 +85,14 @@ def main(args):
81
85
  command, rest = args[0], args[1:]
82
86
 
83
87
  if command == "scan" and not remote:
84
- print("Recherche sur le réseau…")
88
+ print(st.paint(" Recherche sur le réseau…", "dim"), flush=True)
85
89
  found = home.scan()
86
90
  for device in found:
87
- print(f" + {device.name:<18} {device.brand:<14} {device.host or device.id}")
91
+ print(f" {st.paint('+', 'green', bold=True)} {st.code(f'{device.name:<18}')} {device.brand:<14} {st.paint(device.host or device.id, 'dim')}")
88
92
  for hint in home.hints:
89
- print(" !", hint)
93
+ print(f" {st.paint('!', 'yellow', bold=True)} {hint}")
90
94
  if found:
91
- print(f"{len(found)} appareil(s) ajouté(s) dans {home.save()} (renomme-les dans ce fichier si tu veux)")
95
+ print(st.ok(f"{len(found)} appareil(s) ajouté(s) dans {home.save()}") + st.paint(" (renomme-les dans ce fichier si tu veux)", "dim"))
92
96
  elif not home.hints:
93
97
  print("Rien de nouveau trouvé (même réseau ? appareils allumés ?)")
94
98
 
@@ -120,8 +124,8 @@ def main(args):
120
124
  else:
121
125
  sys.exit("Usage : capy domo pair hue [IP] ou pair homeassistant URL")
122
126
  for device in added:
123
- print(f" + {device.name:<18} {device.brand}")
124
- print(f"{len(added)} appareil(s) ajouté(s) dans {home.save()}")
127
+ print(f" {st.paint('+', 'green', bold=True)} {st.code(f'{device.name:<18}')} {device.brand}")
128
+ print(st.ok(f"{len(added)} appareil(s) ajouté(s) dans {home.save()}"))
125
129
 
126
130
  elif command == "list":
127
131
  if remote:
@@ -131,21 +135,20 @@ def main(args):
131
135
  else:
132
136
  names, groups, scenes = list(home.devices), home.groups, list(home.scenes)
133
137
  states = home.all().state() if home.devices else {}
134
- print("Appareils :" if names else "Aucun appareil : lance capy domo scan")
138
+ print(st.section("Appareils") if names else "Aucun appareil : lance " + st.code("capy domo scan"))
135
139
  for name in names:
136
140
  print(show_state(name, states.get(name, {"reachable": False})))
137
141
  if groups:
138
- print("Groupes :")
139
- for name, members in groups.items():
140
- print(f" {name:<18} {', '.join(members)}")
142
+ print("\n" + st.section("Groupes"))
143
+ print(st.table([(st.code(name), st.paint(", ".join(members), "dim")) for name, members in groups.items()]))
141
144
  if scenes:
142
- print("Scènes :", ", ".join(scenes))
145
+ print("\n" + st.section("Scènes") + " " + " ".join(st.code(s) for s in scenes))
143
146
 
144
147
  elif command == "scene":
145
148
  if not rest:
146
149
  sys.exit("Usage : capy domo scene NOM")
147
150
  home.scene(rest[0])
148
- print(f"Scène {rest[0]!r} lancée")
151
+ print(st.ok(f"Scène {rest[0]!r} lancée"))
149
152
 
150
153
  elif command == "serve" and not remote:
151
154
  print("Maison pilotable à distance (actions domo.*), Ctrl+C pour arrêter")
@@ -167,7 +170,7 @@ def main(args):
167
170
  target.toggle()
168
171
  if changes:
169
172
  target.set(**changes)
170
- print(f"{command} : ok")
173
+ print(st.ok(f"{command} : {' · '.join(rest)}"))
171
174
 
172
175
 
173
176
  # Point d'entrée de capy domo
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capyy
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: Easy, encrypted networking for Python: programs that talk (local, Wi-Fi, internet), home automation, and projects run with cy
5
5
  Author: Izac
6
6
  License-Expression: MIT
@@ -101,6 +101,22 @@ app.call("hello", nom="Izac") # client side
101
101
  `mode` can be `local`, `wifi` or `network` (with `relay`, `key`, `fingerprint`). Strings starting with `$` are read
102
102
  from environment variables, e.g. `"key": "$CAPY_KEY"`. Inside a project, the `cy` command runs its `scripts` like `npm run` (`cy test`, `cy start`) and its files (`cy server`); create a project with `capy new`.
103
103
 
104
+ ## From the terminal
105
+
106
+ ```bash
107
+ capy call localhost:4000 hello nom=Izac # any capy server (add --no-tls for a plain one)
108
+ capy wifi scan # capy.wifi servers on your network
109
+ capy wifi call home temperature
110
+ capy network call home test --relay my-vps.example:3879 --key "$CAPY_KEY"
111
+ cy call hello nom=Izac # inside a project: uses capy.config.json
112
+ ```
113
+
114
+ `capy cli` opens an interactive prompt (history, Tab completion, `cd` between projects). Output is colored in a
115
+ terminal and plain text / JSON in a pipe (set `NO_COLOR=1` to disable colors).
116
+
117
+ Parameters are `name=value`; numbers, `true`/`false` and lists are parsed (`age=14` sends the number 14).
118
+ The answer is printed as JSON; the exit code is 1 when the server answers `{"erreur": ...}`.
119
+
104
120
  ## Encryption (TLS)
105
121
 
106
122
  Everything is encrypted by default, with nothing to configure.
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  capy/__init__.py
5
5
  capy/__main__.py
6
6
  capy/_project.py
7
+ capy/_style.py
7
8
  capy/_tls.py
8
9
  capy/network.py
9
10
  capy/wifi.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "capyy"
7
- version = "0.5.2"
7
+ version = "0.5.4"
8
8
  description = "Easy, encrypted networking for Python: programs that talk (local, Wi-Fi, internet), home automation, and projects run with cy"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -357,5 +357,77 @@ class TestProject(unittest.TestCase):
357
357
  self.assertEqual(json.loads(path.read_text())["key"], "$CAPY_KEY")
358
358
 
359
359
 
360
+ # Les commandes capy / cy, lancées pour de vrai (dossier personnel temporaire : ~/.capy n'est pas touché)
361
+ class TestCommandLine(unittest.TestCase):
362
+ @classmethod
363
+ def setUpClass(cls):
364
+ cls.home = tempfile.TemporaryDirectory()
365
+ cls.server = start(demo_server(capy.Server(0, tls=False)))
366
+ cls.relay_port = free_port()
367
+ threading.Thread(target=capy.network.Relay(cls.relay_port, key="cle").run, daemon=True).start()
368
+ time.sleep(0.5)
369
+ capy.network.use(f"127.0.0.1:{cls.relay_port}", key="cle")
370
+ start(demo_server(capy.network.Server("cli")), wait=lambda s: True)
371
+ time.sleep(1)
372
+
373
+ @classmethod
374
+ def tearDownClass(cls):
375
+ cls.home.cleanup()
376
+
377
+ def run_capy(self, *args, cwd=None):
378
+ import subprocess
379
+ import sys
380
+ # Autre HOME : Python ne voit plus les paquets installés pour l'utilisateur, on lui montre capy
381
+ env = {**os.environ, "HOME": self.home.name, "PYTHONPATH": str(pathlib.Path(__file__).resolve().parent.parent)}
382
+ return subprocess.run([sys.executable, "-m", "capy", *args], cwd=cwd or self.home.name, env=env,
383
+ capture_output=True, text=True, timeout=30)
384
+
385
+ def test_call(self):
386
+ result = self.run_capy("call", f"localhost:{self.server.port}", "hello", "name=Zoé", "--no-tls")
387
+ self.assertEqual((result.returncode, json.loads(result.stdout)), (0, {"message": "Bonjour Zoé"}))
388
+ result = self.run_capy("call", f"localhost:{self.server.port}", "divide", "a=6", "b=3", "--no-tls")
389
+ self.assertEqual(json.loads(result.stdout), {"result": 2.0}) # "6" et "3" compris comme des nombres
390
+ result = self.run_capy("call", f"localhost:{self.server.port}", "nope", "--no-tls")
391
+ self.assertEqual(result.returncode, 1) # Réponse {"erreur": ...} -> code 1
392
+ self.assertIn("nom=valeur", self.run_capy("call", f"localhost:{self.server.port}", "hello", "name", "--no-tls").stderr)
393
+
394
+ def test_project_call(self):
395
+ folder = pathlib.Path(self.home.name) / "projet"
396
+ folder.mkdir(exist_ok=True)
397
+ (folder / "capy.config.json").write_text(json.dumps({"port": self.server.port, "tls": False}))
398
+ result = self.run_capy("call", "hello", "name=Projet", cwd=folder)
399
+ self.assertEqual(json.loads(result.stdout), {"message": "Bonjour Projet"})
400
+
401
+ def test_network_call(self):
402
+ result = self.run_capy("network", "call", "cli", "hello", "name=Léa", "--relay", f"127.0.0.1:{self.relay_port}", "--key", "cle")
403
+ self.assertEqual(json.loads(result.stdout), {"message": "Bonjour Léa"})
404
+ self.assertIn("quel relais", self.run_capy("network", "call", "cli", "hello").stderr)
405
+
406
+ def test_interactive(self):
407
+ import subprocess
408
+ import sys
409
+ env = {**os.environ, "HOME": self.home.name, "PYTHONPATH": str(pathlib.Path(__file__).resolve().parent.parent)}
410
+ lines = f"call localhost:{self.server.port} hello name=Cli --no-tls\ncapy version\nfaute\ncd /\nexit\n"
411
+ result = subprocess.run([sys.executable, "-m", "capy", "cli"], input=lines, cwd=self.home.name, env=env,
412
+ capture_output=True, text=True, timeout=30)
413
+ self.assertIn('"message": "Bonjour Cli"', result.stdout) # La commande a marché
414
+ self.assertIn("capy ", result.stdout) # "capy version" (capy devant est accepté)
415
+ self.assertIn("pas un projet capy", result.stderr) # Une erreur n'arrête pas le mode interactif
416
+ self.assertEqual(result.returncode, 0)
417
+ self.assertFalse((pathlib.Path(self.home.name) / ".capy" / "history").exists()) # Pas un vrai terminal : pas d'historique
418
+
419
+ def test_plain_output_when_piped(self):
420
+ result = self.run_capy("help")
421
+ self.assertNotIn("\033[", result.stdout) # Pas de couleurs hors d'un terminal (| jq, fichiers…)
422
+ self.assertIn("capy cli", result.stdout)
423
+
424
+ def test_reserved_script_names(self):
425
+ for name in ("call", "wifi", "network", "cli", "version"):
426
+ with self.assertRaisesRegex(ValueError, "nom d'une commande"):
427
+ path = pathlib.Path(self.home.name) / "capy.config.json"
428
+ path.write_text(json.dumps({"port": 1, "scripts": {name: "echo"}}))
429
+ capy.project(path)
430
+
431
+
360
432
  if __name__ == "__main__":
361
433
  unittest.main()
@@ -345,7 +345,10 @@ class TestBrands(unittest.TestCase):
345
345
  self.assertFalse(found["switch.radiateur"].state()["on"])
346
346
 
347
347
  def test_unreachable(self):
348
- lamp = domo._wiz.WizLight("fantome", "127.0.0.1", port=free_port(socket.SOCK_DGRAM))
348
+ silent = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Un port gardé, qui ne répond jamais
349
+ silent.bind(("127.0.0.1", 0))
350
+ self.addCleanup(silent.close)
351
+ lamp = domo._wiz.WizLight("fantome", "127.0.0.1", port=silent.getsockname()[1])
349
352
  self.assertEqual(lamp.state()["reachable"], False)
350
353
  with self.assertRaisesRegex(ConnectionError, "'fantome' \\(wiz\\) ne répond pas"):
351
354
  lamp.on()
@@ -1,187 +0,0 @@
1
- # Deux commandes, installées avec capy :
2
- #
3
- # cy : DANS un projet capy (un dossier avec capy.config.json, ou en dessous)
4
- # cy -> montre le projet (nom, mode, adresse, fichiers, scripts…)
5
- # cy NOM [args…] -> lance le script NOM de "scripts" (comme npm run)
6
- # cy scripts -> liste les scripts
7
- # cy server / cy client -> lance un fichier de "files"
8
- # cy run -> lance le serveur en fond, puis le client, puis arrête le serveur
9
- #
10
- # capy : tout le reste (et tout ce que fait cy)
11
- # capy new NOM [mode] -> crée un dossier NOM avec capy.config.json, server.py, client.py
12
- # capy init [nom] [mode] -> crée juste capy.config.json ici
13
- # capy domo … -> la domotique (capy domo help)
14
- # capy relay PORT [CLÉ] -> lance un relais capy.network (sur le VPS)
15
- # mode : local (par défaut), wifi ou network
16
- # python3 -m capy = capy (si la commande n'est pas dans le PATH)
17
-
18
- import difflib
19
- import socket
20
- import subprocess
21
- import sys
22
- import threading
23
- import time
24
-
25
- from ._project import COMMANDS, FILE_NAME, MODES, find, init, new, project
26
-
27
- USAGE = f"""Dans un projet capy ({FILE_NAME}) :
28
- cy montre le projet
29
- cy NOM [args…] lance le script NOM de "scripts" (comme npm run)
30
- cy scripts liste les scripts
31
- cy server / client lance un fichier de "files"
32
- cy run serveur en fond + client, puis arrête le serveur
33
- Partout :
34
- capy new NOM [mode] crée un projet complet (config, server.py, client.py)
35
- capy init [nom] [mode] crée juste {FILE_NAME} ici
36
- capy domo … la domotique (capy domo help)
37
- capy relay PORT [CLÉ] lance un relais capy.network
38
- modes : {", ".join(MODES)}"""
39
-
40
-
41
- # Le projet du dossier actuel (ou au-dessus), sinon on explique quoi faire
42
- def _project():
43
- path = find(".")
44
- if path is None:
45
- sys.exit(f"Pas de {FILE_NAME} ici ni au-dessus : ce dossier n'est pas un projet capy.\n"
46
- f" Crée un projet : capy new mon-projet\n"
47
- f" ou juste le fichier : capy init")
48
- try:
49
- return project(path)
50
- except ValueError as e:
51
- sys.exit(str(e))
52
-
53
-
54
- # Lancer un fichier du projet avec le même Python, depuis le dossier du projet
55
- def _start(app, name, args=(), **options):
56
- try:
57
- path = app.file(name)
58
- except KeyError as e:
59
- sys.exit(e.args[0])
60
- if not path.is_file():
61
- sys.exit(f"capy : le fichier {name!r} ({path}) n'existe pas")
62
- return subprocess.Popen([sys.executable, "-u", str(path), *args], cwd=app.folder, **options)
63
-
64
-
65
- # Attendre que le serveur réponde (10 s max) ; False s'il s'est arrêté avant
66
- def _wait_ready(app, process):
67
- end = time.monotonic() + 10
68
- while time.monotonic() < end:
69
- if process.poll() is not None:
70
- return False
71
- try:
72
- if app.mode == "local" and app.port:
73
- socket.create_connection((app.host, app.port), timeout=0.3).close()
74
- return True
75
- if app.mode == "wifi":
76
- import capy.wifi
77
- capy.wifi._found.pop(app.name, None)
78
- capy.wifi.find(app.name, timeout=0.3)
79
- return True
80
- if app.mode == "network" or not app.port:
81
- time.sleep(2) # Pas de moyen simple de vérifier : on laisse 2 s au serveur
82
- return process.poll() is None
83
- except (OSError, ConnectionError):
84
- time.sleep(0.2)
85
- return process.poll() is None
86
-
87
-
88
- # Recopier la sortie du serveur, avec "server │" devant chaque ligne
89
- def _relay_output(process):
90
- for line in process.stdout:
91
- print(f"\033[2mserver │\033[0m {line}", end="", flush=True)
92
-
93
-
94
- def run(app, args):
95
- server = _start(app, "server", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
96
- threading.Thread(target=_relay_output, args=(server,), daemon=True).start()
97
- try:
98
- if not _wait_ready(app, server):
99
- time.sleep(0.2) # Laisser s'afficher l'erreur du serveur
100
- sys.exit(f"capy : le serveur s'est arrêté (ou ne répond pas après 10 s)")
101
- client = _start(app, "client", args)
102
- code = client.wait()
103
- except KeyboardInterrupt:
104
- code = 130
105
- finally:
106
- server.terminate()
107
- try:
108
- server.wait(timeout=3)
109
- except subprocess.TimeoutExpired:
110
- server.kill()
111
- sys.exit(code)
112
-
113
-
114
- def main(args):
115
- command = args[0] if args else None
116
- if command in ("-h", "--help", "help"):
117
- print(USAGE)
118
- return
119
- if command == "domo": # capy domo salon on
120
- from .domo.__main__ import cli as domo_cli
121
- domo_cli(args[1:])
122
- return
123
- if command in ("relay", "network"): # capy relay 3879 CLÉ
124
- from .network import cli as relay_cli
125
- relay_cli(args[1:])
126
- return
127
- if command in ("init", "new"):
128
- rest = args[1:]
129
- mode = next((a for a in rest if a in MODES), "local")
130
- name = next((a for a in rest if a not in MODES), None)
131
- if command == "new" and not name:
132
- sys.exit("Usage : capy new NOM [mode]")
133
- try:
134
- path = new(name, mode) / FILE_NAME if command == "new" else init(name=name, mode=mode)
135
- except (FileExistsError, ValueError) as e:
136
- sys.exit(str(e))
137
- print(f"Créé : {path.parent if command == 'new' else path}")
138
- print(project(path).describe())
139
- if command == "new":
140
- print(f"\nPour l'essayer :\n cd {name}\n cy run")
141
- if mode == "network":
142
- print("Pense à : export CAPY_KEY='la clé du relais' (dans ~/.zshrc), et à mettre l'adresse de ton relais")
143
- return
144
- app = _project()
145
- if command is None:
146
- print(app.describe())
147
- elif command == "run":
148
- run(app, args[1:])
149
- elif command == "scripts":
150
- if not app.scripts:
151
- print('Pas de "scripts" dans ' + FILE_NAME + ', ex : "scripts": {"test": "python3 -m unittest"}')
152
- for name, line in app.scripts.items():
153
- print(f" {name:<12} {line}")
154
- elif command in app.scripts:
155
- print(f"\033[2m> {app.scripts[command]}\033[0m", flush=True) # Comme npm : on montre la commande lancée
156
- try:
157
- sys.exit(app.run_script(command, args[1:]))
158
- except KeyboardInterrupt:
159
- sys.exit(130)
160
- elif command in app.files:
161
- try:
162
- sys.exit(_start(app, command, args[1:]).wait())
163
- except KeyboardInterrupt:
164
- sys.exit(130)
165
- else:
166
- names = list(app.scripts) + list(app.files) + list(COMMANDS)
167
- close = difflib.get_close_matches(command, names, n=1)
168
- if close:
169
- sys.exit(f"capy : {command!r} inconnu, tu voulais dire {close[0]!r} ? (cy {close[0]})")
170
- sys.exit(f"capy : commande, script ou fichier {command!r} inconnu (possibles : {', '.join(names)})\n\n{USAGE}")
171
-
172
-
173
- # La commande cy : seulement les commandes d'un projet
174
- def cli():
175
- args = sys.argv[1:]
176
- if args[:1] in (["new"], ["init"], ["domo"], ["relay"], ["network"]):
177
- sys.exit(f"cy sert dans un projet capy ; pour ça, c'est la commande capy : capy {' '.join(args)}")
178
- main(args)
179
-
180
-
181
- # La commande capy (et python3 -m capy) : tout
182
- def capy_cli():
183
- main(sys.argv[1:])
184
-
185
-
186
- if __name__ == "__main__":
187
- capy_cli()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes