capyy 0.5.3__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.3 → capyy-0.5.4}/PKG-INFO +4 -1
  2. {capyy-0.5.3 → capyy-0.5.4}/README.md +3 -0
  3. capyy-0.5.4/capy/__main__.py +541 -0
  4. {capyy-0.5.3 → capyy-0.5.4}/capy/_project.py +1 -1
  5. capyy-0.5.4/capy/_style.py +112 -0
  6. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/__main__.py +26 -23
  7. {capyy-0.5.3 → capyy-0.5.4}/capyy.egg-info/PKG-INFO +4 -1
  8. {capyy-0.5.3 → capyy-0.5.4}/capyy.egg-info/SOURCES.txt +1 -0
  9. {capyy-0.5.3 → capyy-0.5.4}/pyproject.toml +1 -1
  10. {capyy-0.5.3 → capyy-0.5.4}/tests/test_capy.py +19 -1
  11. capyy-0.5.3/capy/__main__.py +0 -324
  12. {capyy-0.5.3 → capyy-0.5.4}/LICENSE +0 -0
  13. {capyy-0.5.3 → capyy-0.5.4}/capy/__init__.py +0 -0
  14. {capyy-0.5.3 → capyy-0.5.4}/capy/_tls.py +0 -0
  15. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/__init__.py +0 -0
  16. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/_base.py +0 -0
  17. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/_homeassistant.py +0 -0
  18. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/_hue.py +0 -0
  19. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/_net.py +0 -0
  20. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/_shelly.py +0 -0
  21. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/_wiz.py +0 -0
  22. {capyy-0.5.3 → capyy-0.5.4}/capy/domo/_yeelight.py +0 -0
  23. {capyy-0.5.3 → capyy-0.5.4}/capy/network.py +0 -0
  24. {capyy-0.5.3 → capyy-0.5.4}/capy/wifi.py +0 -0
  25. {capyy-0.5.3 → capyy-0.5.4}/capyy.egg-info/dependency_links.txt +0 -0
  26. {capyy-0.5.3 → capyy-0.5.4}/capyy.egg-info/entry_points.txt +0 -0
  27. {capyy-0.5.3 → capyy-0.5.4}/capyy.egg-info/top_level.txt +0 -0
  28. {capyy-0.5.3 → capyy-0.5.4}/setup.cfg +0 -0
  29. {capyy-0.5.3 → capyy-0.5.4}/tests/test_domo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capyy
3
- Version: 0.5.3
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
@@ -111,6 +111,9 @@ capy network call home test --relay my-vps.example:3879 --key "$CAPY_KEY"
111
111
  cy call hello nom=Izac # inside a project: uses capy.config.json
112
112
  ```
113
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
+
114
117
  Parameters are `name=value`; numbers, `true`/`false` and lists are parsed (`age=14` sends the number 14).
115
118
  The answer is printed as JSON; the exit code is 1 when the server answers `{"erreur": ...}`.
116
119
 
@@ -89,6 +89,9 @@ capy network call home test --relay my-vps.example:3879 --key "$CAPY_KEY"
89
89
  cy call hello nom=Izac # inside a project: uses capy.config.json
90
90
  ```
91
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
+
92
95
  Parameters are `name=value`; numbers, `true`/`false` and lists are parsed (`age=14` sends the number 14).
93
96
  The answer is printed as JSON; the exit code is 1 when the server answers `{"erreur": ...}`.
94
97
 
@@ -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", "call", "domo", "relay", "wifi", "network") # 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")
@@ -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.3
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
@@ -111,6 +111,9 @@ capy network call home test --relay my-vps.example:3879 --key "$CAPY_KEY"
111
111
  cy call hello nom=Izac # inside a project: uses capy.config.json
112
112
  ```
113
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
+
114
117
  Parameters are `name=value`; numbers, `true`/`false` and lists are parsed (`age=14` sends the number 14).
115
118
  The answer is printed as JSON; the exit code is 1 when the server answers `{"erreur": ...}`.
116
119
 
@@ -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.3"
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"
@@ -403,8 +403,26 @@ class TestCommandLine(unittest.TestCase):
403
403
  self.assertEqual(json.loads(result.stdout), {"message": "Bonjour Léa"})
404
404
  self.assertIn("quel relais", self.run_capy("network", "call", "cli", "hello").stderr)
405
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
+
406
424
  def test_reserved_script_names(self):
407
- for name in ("call", "wifi", "network"):
425
+ for name in ("call", "wifi", "network", "cli", "version"):
408
426
  with self.assertRaisesRegex(ValueError, "nom d'une commande"):
409
427
  path = pathlib.Path(self.home.name) / "capy.config.json"
410
428
  path.write_text(json.dumps({"port": 1, "scripts": {name: "echo"}}))
@@ -1,324 +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
- # 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 new NOM [mode] -> crée un dossier NOM avec capy.config.json, server.py, client.py
13
- # capy init [nom] [mode] -> crée juste capy.config.json ici
14
- # capy domo … -> la domotique (capy domo help)
15
- # capy relay PORT [CLÉ] -> lance un relais capy.network (sur le VPS)
16
- # capy call HÔTE:PORT ACTION [nom=valeur…] -> appelle une action d'un serveur capy
17
- # capy wifi scan / capy wifi call NOM ACTION … -> les serveurs du wifi
18
- # capy network call NOM ACTION … [--relay R --key K] / capy network relay PORT [CLÉ]
19
- # mode : local (par défaut), wifi ou network
20
- # python3 -m capy = capy (si la commande n'est pas dans le PATH)
21
-
22
- import difflib
23
- import json
24
- import socket
25
- import subprocess
26
- import sys
27
- import threading
28
- import time
29
-
30
- from ._project import COMMANDS, FILE_NAME, MODES, find, init, new, project
31
-
32
- USAGE = f"""Dans un projet capy ({FILE_NAME}) :
33
- cy montre le projet
34
- cy NOM [args…] lance le script NOM de "scripts" (comme npm run)
35
- cy scripts liste les scripts
36
- cy server / client lance un fichier de "files"
37
- cy run serveur en fond + client, puis arrête le serveur
38
- cy call ACTION [nom=valeur…] appelle une action du serveur du projet
39
- Partout :
40
- capy new NOM [mode] crée un projet complet (config, server.py, client.py)
41
- capy init [nom] [mode] crée juste {FILE_NAME} ici
42
- capy domo … la domotique (capy domo help)
43
- capy relay PORT [CLÉ] lance un relais capy.network
44
- capy call HÔTE:PORT ACTION [nom=valeur…] [--no-tls]
45
- capy wifi scan les serveurs capy du wifi
46
- capy wifi call NOM ACTION [nom=valeur…] [--no-tls]
47
- capy network call NOM ACTION [nom=valeur…] [--relay HÔTE:PORT] [--key CLÉ] [--fingerprint …]
48
- capy network relay PORT [CLÉ] (= capy relay)
49
- Paramètres : nom=Izac age=14 actif=true liste=[1,2] (les nombres, true/false et listes sont compris)
50
- modes : {", ".join(MODES)}"""
51
-
52
-
53
- # Le projet du dossier actuel (ou au-dessus), sinon on explique quoi faire
54
- def _project():
55
- path = find(".")
56
- if path is None:
57
- sys.exit(f"Pas de {FILE_NAME} ici ni au-dessus : ce dossier n'est pas un projet capy.\n"
58
- f" Crée un projet : capy new mon-projet\n"
59
- f" ou juste le fichier : capy init")
60
- try:
61
- return project(path)
62
- except ValueError as e:
63
- sys.exit(str(e))
64
-
65
-
66
- # Sortir les options "--nom valeur" (et "--drapeau") d'une liste d'arguments -> (options, reste)
67
- def _options(args, flags=(), values=()):
68
- options, rest, i = {}, [], 0
69
- while i < len(args):
70
- word = args[i]
71
- if word in flags:
72
- options[word] = True
73
- elif word in values:
74
- if i + 1 >= len(args):
75
- sys.exit(f"capy : il manque une valeur après {word}")
76
- options[word] = args[i + 1]
77
- i += 1
78
- elif word.startswith("--"):
79
- sys.exit(f"capy : option {word} inconnue (possibles : {', '.join((*flags, *values)) or 'aucune'})")
80
- else:
81
- rest.append(word)
82
- i += 1
83
- return options, rest
84
-
85
-
86
- # "nom=Izac" "age=14" -> {"nom": "Izac", "age": 14} : une valeur qui est du JSON (14, true, [1, 2]) est convertie
87
- def _params(words):
88
- params = {}
89
- for word in words:
90
- key, sep, value = word.partition("=")
91
- if not sep or not key:
92
- sys.exit(f"capy : paramètre {word!r} invalide, écris nom=valeur (ex : nom=Izac age=14)")
93
- try:
94
- params[key] = json.loads(value)
95
- except ValueError:
96
- params[key] = value
97
- return params
98
-
99
-
100
- # Faire l'appel et afficher la réponse en JSON ; code de sortie 1 si erreur (pratique dans un script)
101
- def _call(function):
102
- import capy
103
- try:
104
- answer = function()
105
- except (ConnectionError, ValueError, KeyError, capy.SecurityError) as e:
106
- sys.exit(str(e).strip("'\""))
107
- print(json.dumps(answer, indent=2, ensure_ascii=False))
108
- if isinstance(answer, dict) and "erreur" in answer:
109
- sys.exit(1)
110
-
111
-
112
- USAGE_CALL = "Usage : capy call HÔTE:PORT ACTION [nom=valeur…] [--no-tls] (ex : capy call localhost:4000 hello nom=Izac)"
113
-
114
-
115
- # capy call localhost:4000 hello nom=Izac
116
- def call_direct(args):
117
- import capy
118
- options, rest = _options(args, flags=("--no-tls",))
119
- if len(rest) < 2:
120
- sys.exit(USAGE_CALL)
121
- host, _, port = rest[0].rpartition(":")
122
- if not host or not port.isdigit():
123
- sys.exit(f"capy : adresse {rest[0]!r} invalide, écris HÔTE:PORT (ex : localhost:4000)\n{USAGE_CALL}")
124
- _call(lambda: capy.call(host, int(port), rest[1], tls="--no-tls" not in options, **_params(rest[2:])))
125
-
126
-
127
- # capy wifi scan / capy wifi call maison hello nom=Izac
128
- def wifi(args):
129
- import capy.wifi
130
- sub = args[0] if args else None
131
- if sub == "scan":
132
- options, _ = _options(args[1:], values=("--timeout",))
133
- found = capy.wifi.scan(timeout=float(options.get("--timeout", 1)))
134
- if not found:
135
- sys.exit("Aucun serveur capy.wifi trouvé (même wifi ? wifi public qui isole les appareils ?)")
136
- for name, (ip, port) in sorted(found.items()):
137
- print(f" {name:<20} {ip}:{port}{' (cet appareil)' if ip == '127.0.0.1' else ''}")
138
- elif sub == "call":
139
- options, rest = _options(args[1:], flags=("--no-tls",))
140
- if len(rest) < 2:
141
- sys.exit("Usage : capy wifi call NOM ACTION [nom=valeur…] [--no-tls] (ex : capy wifi call maison hello)")
142
- _call(lambda: capy.wifi.call(rest[0], rest[1], tls="--no-tls" not in options, **_params(rest[2:])))
143
- else:
144
- sys.exit("Usage : capy wifi scan [--timeout S] ou capy wifi call NOM ACTION [nom=valeur…] [--no-tls]")
145
-
146
-
147
- # capy network call maison hello --relay mon-vps.fr:3879 --key … / capy network relay 3879 CLÉ
148
- # Relais et clé : options, sinon le capy.config.json du dossier (mode network), sinon $CAPY_RELAY / $CAPY_KEY
149
- def network(args):
150
- import os
151
-
152
- import capy.network
153
- sub = args[0] if args else None
154
- if sub == "relay" or (sub or "").isdigit(): # capy network relay 3879 CLÉ (ou l'ancien capy network 3879 CLÉ)
155
- capy.network.cli(args[1:] if sub == "relay" else args)
156
- elif sub == "call":
157
- options, rest = _options(args[1:], values=("--relay", "--key", "--fingerprint"))
158
- if len(rest) < 2:
159
- sys.exit("Usage : capy network call NOM ACTION [nom=valeur…] [--relay HÔTE:PORT] [--key CLÉ] [--fingerprint …]")
160
- relay, key, fingerprint = options.get("--relay"), options.get("--key"), options.get("--fingerprint")
161
- path = find(".")
162
- if path and not relay:
163
- try:
164
- app = project(path)
165
- except ValueError:
166
- app = None
167
- if app and app.mode == "network":
168
- relay, key, fingerprint = app.relay, key or app.key, fingerprint or app.fingerprint
169
- relay, key = relay or os.environ.get("CAPY_RELAY"), key or os.environ.get("CAPY_KEY")
170
- if not relay:
171
- sys.exit("capy : quel relais ? Ajoute --relay mon-vps.fr:3879 (ou un capy.config.json en mode network, ou $CAPY_RELAY)")
172
- capy.network.use(relay, key=key, fingerprint=fingerprint)
173
- _call(lambda: capy.network.call(rest[0], rest[1], **_params(rest[2:])))
174
- else:
175
- sys.exit("Usage : capy network call NOM ACTION [nom=valeur…] [--relay …] [--key …] ou capy network relay PORT [CLÉ]")
176
-
177
-
178
- # Lancer un fichier du projet avec le même Python, depuis le dossier du projet
179
- def _start(app, name, args=(), **options):
180
- try:
181
- path = app.file(name)
182
- except KeyError as e:
183
- sys.exit(e.args[0])
184
- if not path.is_file():
185
- sys.exit(f"capy : le fichier {name!r} ({path}) n'existe pas")
186
- return subprocess.Popen([sys.executable, "-u", str(path), *args], cwd=app.folder, **options)
187
-
188
-
189
- # Attendre que le serveur réponde (10 s max) ; False s'il s'est arrêté avant
190
- def _wait_ready(app, process):
191
- end = time.monotonic() + 10
192
- while time.monotonic() < end:
193
- if process.poll() is not None:
194
- return False
195
- try:
196
- if app.mode == "local" and app.port:
197
- socket.create_connection((app.host, app.port), timeout=0.3).close()
198
- return True
199
- if app.mode == "wifi":
200
- import capy.wifi
201
- capy.wifi._found.pop(app.name, None)
202
- capy.wifi.find(app.name, timeout=0.3)
203
- return True
204
- if app.mode == "network" or not app.port:
205
- time.sleep(2) # Pas de moyen simple de vérifier : on laisse 2 s au serveur
206
- return process.poll() is None
207
- except (OSError, ConnectionError):
208
- time.sleep(0.2)
209
- return process.poll() is None
210
-
211
-
212
- # Recopier la sortie du serveur, avec "server │" devant chaque ligne
213
- def _relay_output(process):
214
- for line in process.stdout:
215
- print(f"\033[2mserver │\033[0m {line}", end="", flush=True)
216
-
217
-
218
- def run(app, args):
219
- server = _start(app, "server", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
220
- threading.Thread(target=_relay_output, args=(server,), daemon=True).start()
221
- try:
222
- if not _wait_ready(app, server):
223
- time.sleep(0.2) # Laisser s'afficher l'erreur du serveur
224
- sys.exit(f"capy : le serveur s'est arrêté (ou ne répond pas après 10 s)")
225
- client = _start(app, "client", args)
226
- code = client.wait()
227
- except KeyboardInterrupt:
228
- code = 130
229
- finally:
230
- server.terminate()
231
- try:
232
- server.wait(timeout=3)
233
- except subprocess.TimeoutExpired:
234
- server.kill()
235
- sys.exit(code)
236
-
237
-
238
- def main(args):
239
- command = args[0] if args else None
240
- if command in ("-h", "--help", "help"):
241
- print(USAGE)
242
- return
243
- if command == "domo": # capy domo salon on
244
- from .domo.__main__ import cli as domo_cli
245
- domo_cli(args[1:])
246
- return
247
- if command == "relay": # capy relay 3879 CLÉ
248
- from .network import cli as relay_cli
249
- relay_cli(args[1:])
250
- return
251
- if command == "network":
252
- network(args[1:])
253
- return
254
- if command == "wifi":
255
- wifi(args[1:])
256
- return
257
- if command == "call" and len(args) > 1 and ":" in args[1]: # capy call localhost:4000 hello
258
- call_direct(args[1:])
259
- return
260
- if command in ("init", "new"):
261
- rest = args[1:]
262
- mode = next((a for a in rest if a in MODES), "local")
263
- name = next((a for a in rest if a not in MODES), None)
264
- if command == "new" and not name:
265
- sys.exit("Usage : capy new NOM [mode]")
266
- try:
267
- path = new(name, mode) / FILE_NAME if command == "new" else init(name=name, mode=mode)
268
- except (FileExistsError, ValueError) as e:
269
- sys.exit(str(e))
270
- print(f"Créé : {path.parent if command == 'new' else path}")
271
- print(project(path).describe())
272
- if command == "new":
273
- print(f"\nPour l'essayer :\n cd {name}\n cy run")
274
- if mode == "network":
275
- print("Pense à : export CAPY_KEY='la clé du relais' (dans ~/.zshrc), et à mettre l'adresse de ton relais")
276
- return
277
- app = _project()
278
- if command is None:
279
- print(app.describe())
280
- elif command == "run":
281
- run(app, args[1:])
282
- elif command == "call": # cy call hello nom=Izac : le serveur du projet, quel que soit son mode
283
- if len(args) < 2:
284
- sys.exit("Usage : cy call ACTION [nom=valeur…] (ex : cy call hello nom=Izac)")
285
- _call(lambda: app.call(args[1], **_params(args[2:])))
286
- elif command == "scripts":
287
- if not app.scripts:
288
- print('Pas de "scripts" dans ' + FILE_NAME + ', ex : "scripts": {"test": "python3 -m unittest"}')
289
- for name, line in app.scripts.items():
290
- print(f" {name:<12} {line}")
291
- elif command in app.scripts:
292
- print(f"\033[2m> {app.scripts[command]}\033[0m", flush=True) # Comme npm : on montre la commande lancée
293
- try:
294
- sys.exit(app.run_script(command, args[1:]))
295
- except KeyboardInterrupt:
296
- sys.exit(130)
297
- elif command in app.files:
298
- try:
299
- sys.exit(_start(app, command, args[1:]).wait())
300
- except KeyboardInterrupt:
301
- sys.exit(130)
302
- else:
303
- names = list(app.scripts) + list(app.files) + list(COMMANDS)
304
- close = difflib.get_close_matches(command, names, n=1)
305
- if close:
306
- sys.exit(f"capy : {command!r} inconnu, tu voulais dire {close[0]!r} ? (cy {close[0]})")
307
- sys.exit(f"capy : commande, script ou fichier {command!r} inconnu (possibles : {', '.join(names)})\n\n{USAGE}")
308
-
309
-
310
- # La commande cy : seulement les commandes d'un projet
311
- def cli():
312
- args = sys.argv[1:]
313
- if args[:1] in (["new"], ["init"], ["domo"], ["relay"], ["network"], ["wifi"]):
314
- sys.exit(f"cy sert dans un projet capy ; pour ça, c'est la commande capy : capy {' '.join(args)}")
315
- main(args)
316
-
317
-
318
- # La commande capy (et python3 -m capy) : tout
319
- def capy_cli():
320
- main(sys.argv[1:])
321
-
322
-
323
- if __name__ == "__main__":
324
- 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
File without changes
File without changes