capyy 0.3.2__tar.gz → 0.5.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: capyy
3
- Version: 0.3.2
3
+ Version: 0.5.0
4
4
  Summary: Tiny encrypted JSON-over-TCP APIs: call a function in another program, on your Wi-Fi or across the internet
5
5
  Author: Izac
6
6
  License-Expression: MIT
@@ -73,6 +73,30 @@ capy.to_int("42"), capy.to_bool("yes") # 42, True
73
73
  `to_str`, `to_int`, `to_float`, `to_bool`, `to_list` and `to_dict` raise a `ValueError` that says exactly
74
74
  what could not be converted.
75
75
 
76
+ ## Project file: capy.config.json
77
+
78
+ Describe the whole project once, then never repeat ports or hosts:
79
+
80
+ ```bash
81
+ python3 -m capy new my-game # folder + capy.config.json + server.py + client.py
82
+ cd my-game
83
+ cy run # starts the server, runs the client, stops the server
84
+ ```
85
+
86
+ ```json
87
+ {"name": "my-game", "version": "1.0", "mode": "local", "url": "127.0.0.1:2388", "tls": true,
88
+ "files": {"server": "server.py", "client": "client.py"}}
89
+ ```
90
+
91
+ ```python
92
+ app = capy.project() # finds capy.config.json next to the script, or in a parent folder
93
+ server = app.server() # server side
94
+ app.call("hello", nom="Izac") # client side
95
+ ```
96
+
97
+ `mode` can be `local`, `wifi` or `network` (with `relay`, `key`, `fingerprint`). Strings starting with `$` are read
98
+ 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 `python3 -m capy new`.
99
+
76
100
  ## Encryption (TLS)
77
101
 
78
102
  Everything is encrypted by default, with nothing to configure.
@@ -114,6 +138,27 @@ capy.network.Server("home").run() # at home, even behind a router
114
138
  capy.network.call("home", "temperature") # from anywhere
115
139
  ```
116
140
 
141
+ ## `capy.domo` — home automation
142
+
143
+ One way to drive lights and plugs from Philips Hue, WiZ, Shelly, Yeelight and Home Assistant (and everything
144
+ Home Assistant supports), on your local network, with zero dependencies:
145
+
146
+ ```python
147
+ import capy.domo
148
+
149
+ home = capy.domo.Home() # devices, groups and scenes live in capy.domo.json
150
+ home.scan() # find devices on the network
151
+ home["salon"].color("orange") # a device, a group, or "tout" (everything)
152
+ home.scene("film")
153
+ capy.domo.serve(home) # control it remotely through capy / capy.wifi / capy.network
154
+ ```
155
+
156
+ ```bash
157
+ python3 -m capy.domo scan
158
+ python3 -m capy.domo pair hue
159
+ python3 -m capy.domo salon on 30% orange
160
+ ```
161
+
117
162
  ## License
118
163
 
119
164
  MIT
@@ -51,6 +51,30 @@ capy.to_int("42"), capy.to_bool("yes") # 42, True
51
51
  `to_str`, `to_int`, `to_float`, `to_bool`, `to_list` and `to_dict` raise a `ValueError` that says exactly
52
52
  what could not be converted.
53
53
 
54
+ ## Project file: capy.config.json
55
+
56
+ Describe the whole project once, then never repeat ports or hosts:
57
+
58
+ ```bash
59
+ python3 -m capy new my-game # folder + capy.config.json + server.py + client.py
60
+ cd my-game
61
+ cy run # starts the server, runs the client, stops the server
62
+ ```
63
+
64
+ ```json
65
+ {"name": "my-game", "version": "1.0", "mode": "local", "url": "127.0.0.1:2388", "tls": true,
66
+ "files": {"server": "server.py", "client": "client.py"}}
67
+ ```
68
+
69
+ ```python
70
+ app = capy.project() # finds capy.config.json next to the script, or in a parent folder
71
+ server = app.server() # server side
72
+ app.call("hello", nom="Izac") # client side
73
+ ```
74
+
75
+ `mode` can be `local`, `wifi` or `network` (with `relay`, `key`, `fingerprint`). Strings starting with `$` are read
76
+ 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 `python3 -m capy new`.
77
+
54
78
  ## Encryption (TLS)
55
79
 
56
80
  Everything is encrypted by default, with nothing to configure.
@@ -92,6 +116,27 @@ capy.network.Server("home").run() # at home, even behind a router
92
116
  capy.network.call("home", "temperature") # from anywhere
93
117
  ```
94
118
 
119
+ ## `capy.domo` — home automation
120
+
121
+ One way to drive lights and plugs from Philips Hue, WiZ, Shelly, Yeelight and Home Assistant (and everything
122
+ Home Assistant supports), on your local network, with zero dependencies:
123
+
124
+ ```python
125
+ import capy.domo
126
+
127
+ home = capy.domo.Home() # devices, groups and scenes live in capy.domo.json
128
+ home.scan() # find devices on the network
129
+ home["salon"].color("orange") # a device, a group, or "tout" (everything)
130
+ home.scene("film")
131
+ capy.domo.serve(home) # control it remotely through capy / capy.wifi / capy.network
132
+ ```
133
+
134
+ ```bash
135
+ python3 -m capy.domo scan
136
+ python3 -m capy.domo pair hue
137
+ python3 -m capy.domo salon on 30% orange
138
+ ```
139
+
95
140
  ## License
96
141
 
97
142
  MIT
@@ -6,6 +6,7 @@ import threading
6
6
  from typing import Any
7
7
 
8
8
  from . import _tls
9
+ from ._project import Project, project # noqa: F401 (capy.project() : lire capy.config.json)
9
10
  from ._tls import SecurityError # noqa: F401 (capy.SecurityError)
10
11
 
11
12
 
@@ -51,7 +52,7 @@ class Server:
51
52
  line = conn.makefile(encoding="utf-8").readline()
52
53
  if not line.strip(): # Connexion ouverte puis fermée sans rien demander
53
54
  return
54
- conn.sendall((jsonlib.dumps(self.handle(line), ensure_ascii=False) + "\n").encode())
55
+ conn.sendall((_dumps(self.handle(line)) + "\n").encode())
55
56
  except (OSError, ValueError):
56
57
  return
57
58
  print(f"{addr[0]} → {_hide(line)}")
@@ -76,6 +77,15 @@ class Server:
76
77
 
77
78
 
78
79
  # Renvoyer du JSON écrit en texte depuis une action
80
+ # Réponse d'une action -> texte JSON (une réponse impossible à mettre en JSON devient une erreur claire)
81
+ # Ex : return {1, 2} (un set) -> {"erreur": "réponse pas en JSON : Object of type set is not JSON serializable"}
82
+ def _dumps(response):
83
+ try:
84
+ return jsonlib.dumps(response, ensure_ascii=False)
85
+ except (TypeError, ValueError) as e:
86
+ return jsonlib.dumps({"erreur": f"réponse pas en JSON : {e}"}, ensure_ascii=False)
87
+
88
+
79
89
  # Paramètres jamais affichés dans le journal du serveur (mots de passe, tokens…)
80
90
  SECRETS = ("password", "passwd", "pwd", "mdp", "motdepasse", "secret", "token")
81
91
 
@@ -284,6 +294,8 @@ def _call(host, port, action, params, tls, label, who="capy"):
284
294
  error = e
285
295
  except (OSError, ValueError) as e:
286
296
  error = e
297
+ if connected and isinstance(error, socket.timeout): # Connecté, mais l'action met trop longtemps
298
+ raise ConnectionError(f"{who} : le serveur {host}:{port} n'a pas répondu à temps (3 s) : l'action est sans doute trop lente") from None
287
299
  if connected: # Il a décroché puis coupé : souvent un serveur chiffré appelé en clair
288
300
  raise ConnectionError(f"{who} : le serveur {host}:{port} a coupé la connexion sans répondre{hint}") from None
289
301
  raise ConnectionError(f"{who} : le serveur {host}:{port} ne répond pas ({type(error).__name__})") from None
@@ -0,0 +1,169 @@
1
+ # Les commandes d'un projet capy (tout vient de capy.config.json)
2
+ #
3
+ # cy : seulement 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
+ # python3 -m capy : tout ce que fait cy, plus créer un projet
11
+ # python3 -m capy new NOM [mode] -> crée un dossier NOM avec capy.config.json, server.py, client.py
12
+ # python3 -m capy init [nom] [mode] -> crée juste capy.config.json ici
13
+ # mode : local (par défaut), wifi ou network
14
+
15
+ import difflib
16
+ import socket
17
+ import subprocess
18
+ import sys
19
+ import threading
20
+ import time
21
+
22
+ from ._project import COMMANDS, FILE_NAME, MODES, find, init, new, project
23
+
24
+ USAGE = f"""Dans un projet capy ({FILE_NAME}) :
25
+ cy montre le projet
26
+ cy NOM [args…] lance le script NOM de "scripts" (comme npm run)
27
+ cy scripts liste les scripts
28
+ cy server / client lance un fichier de "files"
29
+ cy run serveur en fond + client, puis arrête le serveur
30
+ Créer un projet :
31
+ python3 -m capy new NOM [mode] un dossier complet (config, server.py, client.py)
32
+ python3 -m capy init [nom] [mode] juste {FILE_NAME} ici
33
+ modes : {", ".join(MODES)}"""
34
+
35
+
36
+ # Le projet du dossier actuel (ou au-dessus), sinon on explique quoi faire
37
+ def _project():
38
+ path = find(".")
39
+ if path is None:
40
+ sys.exit(f"cy marche dans un projet capy, et il n'y a pas de {FILE_NAME} ici ni au-dessus.\n"
41
+ f" Crée un projet : python3 -m capy new mon-projet\n"
42
+ f" ou juste le fichier : python3 -m capy init")
43
+ try:
44
+ return project(path)
45
+ except ValueError as e:
46
+ sys.exit(str(e))
47
+
48
+
49
+ # Lancer un fichier du projet avec le même Python, depuis le dossier du projet
50
+ def _start(app, name, args=(), **options):
51
+ try:
52
+ path = app.file(name)
53
+ except KeyError as e:
54
+ sys.exit(e.args[0])
55
+ if not path.is_file():
56
+ sys.exit(f"capy : le fichier {name!r} ({path}) n'existe pas")
57
+ return subprocess.Popen([sys.executable, "-u", str(path), *args], cwd=app.folder, **options)
58
+
59
+
60
+ # Attendre que le serveur réponde (10 s max) ; False s'il s'est arrêté avant
61
+ def _wait_ready(app, process):
62
+ end = time.monotonic() + 10
63
+ while time.monotonic() < end:
64
+ if process.poll() is not None:
65
+ return False
66
+ try:
67
+ if app.mode == "local" and app.port:
68
+ socket.create_connection((app.host, app.port), timeout=0.3).close()
69
+ return True
70
+ if app.mode == "wifi":
71
+ import capy.wifi
72
+ capy.wifi._found.pop(app.name, None)
73
+ capy.wifi.find(app.name, timeout=0.3)
74
+ return True
75
+ if app.mode == "network" or not app.port:
76
+ time.sleep(2) # Pas de moyen simple de vérifier : on laisse 2 s au serveur
77
+ return process.poll() is None
78
+ except (OSError, ConnectionError):
79
+ time.sleep(0.2)
80
+ return process.poll() is None
81
+
82
+
83
+ # Recopier la sortie du serveur, avec "server │" devant chaque ligne
84
+ def _relay_output(process):
85
+ for line in process.stdout:
86
+ print(f"\033[2mserver │\033[0m {line}", end="", flush=True)
87
+
88
+
89
+ def run(app, args):
90
+ server = _start(app, "server", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
91
+ threading.Thread(target=_relay_output, args=(server,), daemon=True).start()
92
+ try:
93
+ if not _wait_ready(app, server):
94
+ time.sleep(0.2) # Laisser s'afficher l'erreur du serveur
95
+ sys.exit(f"capy : le serveur s'est arrêté (ou ne répond pas après 10 s)")
96
+ client = _start(app, "client", args)
97
+ code = client.wait()
98
+ except KeyboardInterrupt:
99
+ code = 130
100
+ finally:
101
+ server.terminate()
102
+ try:
103
+ server.wait(timeout=3)
104
+ except subprocess.TimeoutExpired:
105
+ server.kill()
106
+ sys.exit(code)
107
+
108
+
109
+ def main(args):
110
+ command = args[0] if args else None
111
+ if command in ("-h", "--help", "help"):
112
+ print(USAGE)
113
+ return
114
+ if command in ("init", "new"):
115
+ rest = args[1:]
116
+ mode = next((a for a in rest if a in MODES), "local")
117
+ name = next((a for a in rest if a not in MODES), None)
118
+ if command == "new" and not name:
119
+ sys.exit("Usage : python3 -m capy new NOM [mode]")
120
+ try:
121
+ path = new(name, mode) / FILE_NAME if command == "new" else init(name=name, mode=mode)
122
+ except (FileExistsError, ValueError) as e:
123
+ sys.exit(str(e))
124
+ print(f"Créé : {path.parent if command == 'new' else path}")
125
+ print(project(path).describe())
126
+ if command == "new":
127
+ print(f"\nPour l'essayer :\n cd {name}\n cy run")
128
+ if mode == "network":
129
+ print("Pense à : export CAPY_KEY='la clé du relais' (dans ~/.zshrc), et à mettre l'adresse de ton relais")
130
+ return
131
+ app = _project()
132
+ if command is None:
133
+ print(app.describe())
134
+ elif command == "run":
135
+ run(app, args[1:])
136
+ elif command == "scripts":
137
+ if not app.scripts:
138
+ print('Pas de "scripts" dans ' + FILE_NAME + ', ex : "scripts": {"test": "python3 -m unittest"}')
139
+ for name, line in app.scripts.items():
140
+ print(f" {name:<12} {line}")
141
+ elif command in app.scripts:
142
+ print(f"\033[2m> {app.scripts[command]}\033[0m", flush=True) # Comme npm : on montre la commande lancée
143
+ try:
144
+ sys.exit(app.run_script(command, args[1:]))
145
+ except KeyboardInterrupt:
146
+ sys.exit(130)
147
+ elif command in app.files:
148
+ try:
149
+ sys.exit(_start(app, command, args[1:]).wait())
150
+ except KeyboardInterrupt:
151
+ sys.exit(130)
152
+ else:
153
+ names = list(app.scripts) + list(app.files) + list(COMMANDS)
154
+ close = difflib.get_close_matches(command, names, n=1)
155
+ if close:
156
+ sys.exit(f"capy : {command!r} inconnu, tu voulais dire {close[0]!r} ? (cy {close[0]})")
157
+ sys.exit(f"capy : commande, script ou fichier {command!r} inconnu (possibles : {', '.join(names)})\n\n{USAGE}")
158
+
159
+
160
+ # La commande cy : seulement les commandes d'un projet (pas new ni init, qui créent un projet)
161
+ def cli():
162
+ args = sys.argv[1:]
163
+ if args[:1] in (["new"], ["init"]):
164
+ sys.exit(f"cy sert dans un projet existant ; pour en créer un : python3 -m capy {' '.join(args)}")
165
+ main(args)
166
+
167
+
168
+ if __name__ == "__main__": # python3 -m capy : tout, y compris new et init
169
+ main(sys.argv[1:])