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.
- {capyy-0.3.2/capyy.egg-info → capyy-0.5.0}/PKG-INFO +46 -1
- {capyy-0.3.2 → capyy-0.5.0}/README.md +45 -0
- {capyy-0.3.2 → capyy-0.5.0}/capy/__init__.py +13 -1
- capyy-0.5.0/capy/__main__.py +169 -0
- capyy-0.5.0/capy/_project.py +343 -0
- capyy-0.5.0/capy/domo/__init__.py +403 -0
- capyy-0.5.0/capy/domo/__main__.py +184 -0
- capyy-0.5.0/capy/domo/_base.py +257 -0
- capyy-0.5.0/capy/domo/_homeassistant.py +93 -0
- capyy-0.5.0/capy/domo/_hue.py +149 -0
- capyy-0.5.0/capy/domo/_net.py +176 -0
- capyy-0.5.0/capy/domo/_shelly.py +86 -0
- capyy-0.5.0/capy/domo/_wiz.py +84 -0
- capyy-0.5.0/capy/domo/_yeelight.py +79 -0
- {capyy-0.3.2 → capyy-0.5.0}/capy/network.py +1 -1
- {capyy-0.3.2 → capyy-0.5.0/capyy.egg-info}/PKG-INFO +46 -1
- capyy-0.5.0/capyy.egg-info/SOURCES.txt +25 -0
- capyy-0.5.0/capyy.egg-info/entry_points.txt +2 -0
- {capyy-0.3.2 → capyy-0.5.0}/pyproject.toml +5 -2
- {capyy-0.3.2 → capyy-0.5.0}/tests/test_capy.py +154 -0
- capyy-0.5.0/tests/test_domo.py +448 -0
- capyy-0.3.2/capyy.egg-info/SOURCES.txt +0 -12
- {capyy-0.3.2 → capyy-0.5.0}/LICENSE +0 -0
- {capyy-0.3.2 → capyy-0.5.0}/capy/_tls.py +0 -0
- {capyy-0.3.2 → capyy-0.5.0}/capy/wifi.py +0 -0
- {capyy-0.3.2 → capyy-0.5.0}/capyy.egg-info/dependency_links.txt +0 -0
- {capyy-0.3.2 → capyy-0.5.0}/capyy.egg-info/top_level.txt +0 -0
- {capyy-0.3.2 → capyy-0.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capyy
|
|
3
|
-
Version: 0.
|
|
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((
|
|
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:])
|