capyy 0.3.2__tar.gz → 0.4.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.4.0}/PKG-INFO +18 -1
- {capyy-0.3.2 → capyy-0.4.0}/README.md +17 -0
- {capyy-0.3.2 → capyy-0.4.0}/capy/__init__.py +13 -1
- capyy-0.4.0/capy/__main__.py +36 -0
- capyy-0.4.0/capy/_project.py +188 -0
- {capyy-0.3.2 → capyy-0.4.0}/capy/network.py +1 -1
- {capyy-0.3.2 → capyy-0.4.0/capyy.egg-info}/PKG-INFO +18 -1
- {capyy-0.3.2 → capyy-0.4.0}/capyy.egg-info/SOURCES.txt +2 -0
- {capyy-0.3.2 → capyy-0.4.0}/pyproject.toml +1 -1
- {capyy-0.3.2 → capyy-0.4.0}/tests/test_capy.py +96 -0
- {capyy-0.3.2 → capyy-0.4.0}/LICENSE +0 -0
- {capyy-0.3.2 → capyy-0.4.0}/capy/_tls.py +0 -0
- {capyy-0.3.2 → capyy-0.4.0}/capy/wifi.py +0 -0
- {capyy-0.3.2 → capyy-0.4.0}/capyy.egg-info/dependency_links.txt +0 -0
- {capyy-0.3.2 → capyy-0.4.0}/capyy.egg-info/top_level.txt +0 -0
- {capyy-0.3.2 → capyy-0.4.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.4.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,23 @@ 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 project once (`python3 -m capy init my-game` creates it), then never repeat ports or hosts:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{"name": "my-game", "mode": "local", "host": "localhost", "port": 4000, "tls": true}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
app = capy.project() # finds capy.config.json next to the script, or in a parent folder
|
|
86
|
+
server = app.server() # server side
|
|
87
|
+
app.call("hello", nom="Izac") # client side
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`mode` can be `local`, `wifi` or `network` (with `relay`, `key`, `fingerprint`). Strings starting with `$` are read
|
|
91
|
+
from environment variables, e.g. `"key": "$CAPY_KEY"`.
|
|
92
|
+
|
|
76
93
|
## Encryption (TLS)
|
|
77
94
|
|
|
78
95
|
Everything is encrypted by default, with nothing to configure.
|
|
@@ -51,6 +51,23 @@ 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 project once (`python3 -m capy init my-game` creates it), then never repeat ports or hosts:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{"name": "my-game", "mode": "local", "host": "localhost", "port": 4000, "tls": true}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
app = capy.project() # finds capy.config.json next to the script, or in a parent folder
|
|
64
|
+
server = app.server() # server side
|
|
65
|
+
app.call("hello", nom="Izac") # client side
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`mode` can be `local`, `wifi` or `network` (with `relay`, `key`, `fingerprint`). Strings starting with `$` are read
|
|
69
|
+
from environment variables, e.g. `"key": "$CAPY_KEY"`.
|
|
70
|
+
|
|
54
71
|
## Encryption (TLS)
|
|
55
72
|
|
|
56
73
|
Everything is encrypted by default, with nothing to configure.
|
|
@@ -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,36 @@
|
|
|
1
|
+
# python3 -m capy : petites commandes pour un projet capy
|
|
2
|
+
#
|
|
3
|
+
# python3 -m capy init [nom] [local|wifi|network] -> crée capy.config.json ici
|
|
4
|
+
# python3 -m capy -> montre le projet trouvé (capy.config.json)
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ._project import FILE_NAME, MODES, find, init, project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(args):
|
|
12
|
+
if args[:1] == ["init"]:
|
|
13
|
+
rest = args[1:]
|
|
14
|
+
mode = next((a for a in rest if a in MODES), "local")
|
|
15
|
+
name = next((a for a in rest if a not in MODES), None)
|
|
16
|
+
try:
|
|
17
|
+
path = init(name=name, mode=mode)
|
|
18
|
+
except (FileExistsError, ValueError) as e:
|
|
19
|
+
sys.exit(str(e))
|
|
20
|
+
print(f"Créé : {path}")
|
|
21
|
+
print(path.read_text(encoding="utf-8"), end="")
|
|
22
|
+
if mode == "network":
|
|
23
|
+
print("Pense à : export CAPY_KEY='la clé du relais' (dans ~/.zshrc), et à mettre l'adresse de ton relais")
|
|
24
|
+
return
|
|
25
|
+
if args:
|
|
26
|
+
sys.exit(f"Usage : python3 -m capy init [nom] [{'|'.join(MODES)}] ou python3 -m capy")
|
|
27
|
+
path = find(".")
|
|
28
|
+
if path is None:
|
|
29
|
+
sys.exit(f"Pas de {FILE_NAME} ici ni au-dessus. Crée-le avec : python3 -m capy init")
|
|
30
|
+
try:
|
|
31
|
+
print(project(path), "\n ", path)
|
|
32
|
+
except ValueError as e:
|
|
33
|
+
sys.exit(str(e))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
main(sys.argv[1:])
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# capy.project() : décrire un projet une fois dans capy.config.json, puis ne plus répéter port, IP, relais...
|
|
2
|
+
#
|
|
3
|
+
# capy.config.json {"name": "maison", "mode": "local", "host": "localhost", "port": 4000}
|
|
4
|
+
# serveur.py app = capy.project(); server = app.server(); ...; server.run()
|
|
5
|
+
# client.py app = capy.project(); app.call("hello", nom="Izac")
|
|
6
|
+
#
|
|
7
|
+
# mode "local" -> capy.Server(port) / capy.call(host, port, ...)
|
|
8
|
+
# mode "wifi" -> capy.wifi.Server(name) / capy.wifi.call(name, ...)
|
|
9
|
+
# mode "network" -> capy.network.Server(name) / capy.network.call(name, ...) via "relay"
|
|
10
|
+
# Un texte qui commence par $ est lu dans les variables d'environnement : "key": "$CAPY_KEY"
|
|
11
|
+
|
|
12
|
+
import difflib
|
|
13
|
+
import json as jsonlib
|
|
14
|
+
import os
|
|
15
|
+
import pathlib as pth
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
FILE_NAME = "capy.config.json"
|
|
20
|
+
MODES = ("local", "wifi", "network")
|
|
21
|
+
|
|
22
|
+
# Ce qu'on peut mettre dans le fichier : clé -> (types acceptés, explication)
|
|
23
|
+
FIELDS = {
|
|
24
|
+
"name": ((str,), "nom du projet (le nom du serveur en wifi et en network)"),
|
|
25
|
+
"mode": ((str,), "local, wifi ou network"),
|
|
26
|
+
"host": ((str,), "adresse du serveur pour le client, en local (ex : localhost, 192.168.1.42)"),
|
|
27
|
+
"port": ((int,), "port du serveur (0 = un port libre au hasard)"),
|
|
28
|
+
"tls": ((bool,), "chiffrement, true par défaut"),
|
|
29
|
+
"relay": ((str,), "relais capy.network, ex : mon-vps.fr:3879"),
|
|
30
|
+
"key": ((str,), "clé du relais (mieux : \"$CAPY_KEY\", lue dans les variables d'environnement)"),
|
|
31
|
+
"fingerprint": ((str,), "empreinte du relais, affichée à son démarrage"),
|
|
32
|
+
"description": ((str,), "une phrase pour toi, capy ne s'en sert pas"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
EXAMPLE = {"name": "mon-projet", "mode": "local", "host": "localhost", "port": 4000, "tls": True}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Dossier du script lancé (comme simplesuit.json) ; sans script (python3 -c, REPL) -> dossier actuel
|
|
39
|
+
def _main_dir():
|
|
40
|
+
file = getattr(sys.modules.get("__main__"), "__file__", None)
|
|
41
|
+
return pth.Path(file).resolve().parent if file else pth.Path.cwd()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Chercher capy.config.json dans le dossier du script, puis dans les dossiers au-dessus (comme git)
|
|
45
|
+
def find(start=None):
|
|
46
|
+
folder = pth.Path(start or _main_dir()).resolve()
|
|
47
|
+
for place in (folder, *folder.parents):
|
|
48
|
+
if (place / FILE_NAME).is_file():
|
|
49
|
+
return place / FILE_NAME
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Un projet capy, lu depuis capy.config.json
|
|
54
|
+
# Ex : app = capy.project() puis app.server() côté serveur, app.call("hello") côté client
|
|
55
|
+
class Project:
|
|
56
|
+
def __init__(self, path):
|
|
57
|
+
self.path = pth.Path(path)
|
|
58
|
+
try:
|
|
59
|
+
data = jsonlib.loads(self.path.read_text(encoding="utf-8"))
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
raise FileNotFoundError(f"capy.project : pas de fichier {self.path}") from None
|
|
62
|
+
except ValueError as e:
|
|
63
|
+
raise ValueError(f"capy.project : {self.path.name} n'est pas du JSON valide ({e})") from None
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
raise ValueError(f"capy.project : {self.path.name} doit contenir un objet {{...}}")
|
|
66
|
+
self.config = _check(data, self.path.name)
|
|
67
|
+
get = self.config.get
|
|
68
|
+
self.name = get("name") or self.path.parent.name
|
|
69
|
+
self.mode = get("mode") or ("network" if get("relay") else "local")
|
|
70
|
+
self.host = get("host", "localhost")
|
|
71
|
+
self.port = get("port")
|
|
72
|
+
self.tls = get("tls", True)
|
|
73
|
+
self.relay, self.key, self.fingerprint = get("relay"), get("key"), get("fingerprint")
|
|
74
|
+
self._require()
|
|
75
|
+
|
|
76
|
+
def __repr__(self):
|
|
77
|
+
where = {"local": f"local, {self.host}:{self.port}", "wifi": "sur le wifi", "network": f"via le relais {self.relay}"}[self.mode]
|
|
78
|
+
return f"<capy.Project {self.name!r} ({where}, {'chiffré' if self.tls else 'en clair'})>"
|
|
79
|
+
|
|
80
|
+
# Vérifier qu'il ne manque rien pour ce mode
|
|
81
|
+
def _require(self):
|
|
82
|
+
if self.mode not in MODES:
|
|
83
|
+
raise ValueError(f"capy.project : mode {self.mode!r} inconnu dans {self.path.name}, choisis parmi : {', '.join(MODES)}")
|
|
84
|
+
if self.mode == "local" and self.port is None:
|
|
85
|
+
raise ValueError(f'capy.project : il manque "port" dans {self.path.name} (ex : "port": 4000)')
|
|
86
|
+
if self.mode == "network" and not self.relay:
|
|
87
|
+
raise ValueError(f'capy.project : il manque "relay" dans {self.path.name} (ex : "relay": "mon-vps.fr:3879")')
|
|
88
|
+
if self.mode == "network" and not self.tls:
|
|
89
|
+
raise ValueError("capy.project : capy.network est toujours chiffré, enlève \"tls\": false")
|
|
90
|
+
|
|
91
|
+
# Le serveur de ce projet, déjà réglé (port, nom, chiffrement, relais)
|
|
92
|
+
# Ex : server = app.server() ; @server.action("hello") ... ; server.run()
|
|
93
|
+
def server(self):
|
|
94
|
+
import capy
|
|
95
|
+
if self.mode == "local":
|
|
96
|
+
return capy.Server(self.port, tls=self.tls)
|
|
97
|
+
if self.mode == "wifi":
|
|
98
|
+
import capy.wifi
|
|
99
|
+
return capy.wifi.Server(self.name, port=self.port or 0, tls=self.tls)
|
|
100
|
+
self._use_relay()
|
|
101
|
+
import capy.network
|
|
102
|
+
return capy.network.Server(self.name)
|
|
103
|
+
|
|
104
|
+
# Appeler une action du serveur de ce projet (sans redonner l'IP, le port ni le relais)
|
|
105
|
+
# Ex : app.call("hello", nom="Izac") -> {"message": "Bonjour Izac !"}
|
|
106
|
+
def call(self, action, /, **params) -> Any:
|
|
107
|
+
import capy
|
|
108
|
+
if self.mode == "local":
|
|
109
|
+
return capy.call(self.host, self.port, action, tls=self.tls, **params)
|
|
110
|
+
if self.mode == "wifi":
|
|
111
|
+
import capy.wifi
|
|
112
|
+
return capy.wifi.call(self.name, action, tls=self.tls, **params)
|
|
113
|
+
self._use_relay()
|
|
114
|
+
import capy.network
|
|
115
|
+
return capy.network.call(self.name, action, **params)
|
|
116
|
+
|
|
117
|
+
# Régler capy.network sur le relais du projet (seulement s'il a changé : use() ferme les connexions ouvertes)
|
|
118
|
+
def _use_relay(self):
|
|
119
|
+
import capy.network
|
|
120
|
+
wanted = {"relay": self.relay, "key": self.key, "fingerprint": self.fingerprint}
|
|
121
|
+
if capy.network._config != wanted:
|
|
122
|
+
capy.network.use(self.relay, key=self.key, fingerprint=self.fingerprint)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Vérifier le contenu du fichier : clés connues, bons types, variables d'environnement
|
|
126
|
+
def _check(data, file):
|
|
127
|
+
result = {}
|
|
128
|
+
for key, value in data.items():
|
|
129
|
+
if key not in FIELDS:
|
|
130
|
+
close = difflib.get_close_matches(key, FIELDS, n=1)
|
|
131
|
+
hint = f" (tu voulais dire {close[0]!r} ?)" if close else f" (clés possibles : {', '.join(FIELDS)})"
|
|
132
|
+
raise ValueError(f"capy.project : clé {key!r} inconnue dans {file}{hint}")
|
|
133
|
+
types, help_text = FIELDS[key]
|
|
134
|
+
if isinstance(value, str) and value.startswith("$") and len(value) > 1:
|
|
135
|
+
env = value[1:]
|
|
136
|
+
if env not in os.environ:
|
|
137
|
+
raise ValueError(f"capy.project : {key!r} vaut {value!r} mais la variable d'environnement {env} n'existe pas"
|
|
138
|
+
f" (ajoute export {env}='...' dans ~/.zshrc)")
|
|
139
|
+
value = os.environ[env]
|
|
140
|
+
if int in types and value.isdigit():
|
|
141
|
+
value = int(value)
|
|
142
|
+
if value is None:
|
|
143
|
+
continue
|
|
144
|
+
if not isinstance(value, types) or (bool not in types and isinstance(value, bool)):
|
|
145
|
+
raise ValueError(f"capy.project : {key!r} doit être {_type_name(types)} dans {file} : {help_text}")
|
|
146
|
+
result[key] = value
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _type_name(types):
|
|
151
|
+
return {str: "un texte", int: "un nombre", bool: "true ou false"}[types[0]]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Lire le capy.config.json du projet -> Project
|
|
155
|
+
# Sans chemin : cherché à côté du script lancé, puis dans les dossiers au-dessus
|
|
156
|
+
# Ex : app = capy.project() / capy.project("autre.json") / capy.project("~/projets/jeu")
|
|
157
|
+
def project(path=None) -> Project:
|
|
158
|
+
if path is None:
|
|
159
|
+
found = find()
|
|
160
|
+
if found is None:
|
|
161
|
+
raise FileNotFoundError(
|
|
162
|
+
f"capy.project : aucun {FILE_NAME} trouvé dans {_main_dir()} ni au-dessus.\n"
|
|
163
|
+
f" Crée-le avec : python3 -m capy init\n"
|
|
164
|
+
f" ou écris-le toi-même, par exemple : {jsonlib.dumps(EXAMPLE, ensure_ascii=False)}"
|
|
165
|
+
)
|
|
166
|
+
return Project(found)
|
|
167
|
+
path = pth.Path(path).expanduser()
|
|
168
|
+
if not path.is_absolute():
|
|
169
|
+
path = _main_dir() / path
|
|
170
|
+
return Project(path / FILE_NAME if path.is_dir() else path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Écrire un capy.config.json de départ (python3 -m capy init [nom] [mode])
|
|
174
|
+
def init(folder=None, name=None, mode="local"):
|
|
175
|
+
if mode not in MODES:
|
|
176
|
+
raise ValueError(f"capy init : mode {mode!r} inconnu, choisis parmi : {', '.join(MODES)}")
|
|
177
|
+
folder = pth.Path(folder or pth.Path.cwd())
|
|
178
|
+
path = folder / FILE_NAME
|
|
179
|
+
if path.exists():
|
|
180
|
+
raise FileExistsError(f"capy init : {path} existe déjà (supprime-le d'abord pour en refaire un)")
|
|
181
|
+
data = {"name": name or folder.resolve().name, "mode": mode}
|
|
182
|
+
if mode == "local":
|
|
183
|
+
data.update(host="localhost", port=4000)
|
|
184
|
+
elif mode == "network":
|
|
185
|
+
data.update(relay="mon-vps.fr:3879", key="$CAPY_KEY")
|
|
186
|
+
data["tls"] = True
|
|
187
|
+
path.write_text(jsonlib.dumps(data, indent=4, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
188
|
+
return path
|
|
@@ -296,7 +296,7 @@ class Relay:
|
|
|
296
296
|
except ConnectionError:
|
|
297
297
|
return
|
|
298
298
|
if reply["capy"] == "ok":
|
|
299
|
-
_log(f" {name} ← {
|
|
299
|
+
_log(f" {name} ← {capy._hide(request)}")
|
|
300
300
|
|
|
301
301
|
def run(self):
|
|
302
302
|
self.context, fingerprint = _relay_context()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capyy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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,23 @@ 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 project once (`python3 -m capy init my-game` creates it), then never repeat ports or hosts:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{"name": "my-game", "mode": "local", "host": "localhost", "port": 4000, "tls": true}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
app = capy.project() # finds capy.config.json next to the script, or in a parent folder
|
|
86
|
+
server = app.server() # server side
|
|
87
|
+
app.call("hello", nom="Izac") # client side
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`mode` can be `local`, `wifi` or `network` (with `relay`, `key`, `fingerprint`). Strings starting with `$` are read
|
|
91
|
+
from environment variables, e.g. `"key": "$CAPY_KEY"`.
|
|
92
|
+
|
|
76
93
|
## Encryption (TLS)
|
|
77
94
|
|
|
78
95
|
Everything is encrypted by default, with nothing to configure.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "capyy"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Tiny encrypted JSON-over-TCP APIs: call a function in another program, on your Wi-Fi or across the internet"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Tests de capy : python3 -m unittest discover -s tests
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
3
5
|
import pathlib
|
|
4
6
|
import socket
|
|
5
7
|
import tempfile
|
|
@@ -48,6 +50,15 @@ def demo_server(server):
|
|
|
48
50
|
def text():
|
|
49
51
|
return capy.json('{"text": "Hello"}')
|
|
50
52
|
|
|
53
|
+
@server.action("not_json")
|
|
54
|
+
def not_json():
|
|
55
|
+
return {1, 2} # Un set : impossible en JSON
|
|
56
|
+
|
|
57
|
+
@server.action("slow")
|
|
58
|
+
def slow():
|
|
59
|
+
time.sleep(3.5)
|
|
60
|
+
return {"ok": True}
|
|
61
|
+
|
|
51
62
|
return server
|
|
52
63
|
|
|
53
64
|
|
|
@@ -127,6 +138,14 @@ class TestCall(unittest.TestCase):
|
|
|
127
138
|
with self.assertRaises(ConnectionError):
|
|
128
139
|
capy.call("localhost", free_port(), "hello")
|
|
129
140
|
|
|
141
|
+
def test_not_json_reply(self):
|
|
142
|
+
reply = capy.call("localhost", self.tls.port, "not_json")
|
|
143
|
+
self.assertIn("réponse pas en JSON", reply["erreur"])
|
|
144
|
+
|
|
145
|
+
def test_too_slow(self):
|
|
146
|
+
with self.assertRaisesRegex(ConnectionError, "pas répondu à temps"):
|
|
147
|
+
capy.call("localhost", self.plain.port, "slow", tls=False)
|
|
148
|
+
|
|
130
149
|
def test_bad_arguments(self):
|
|
131
150
|
with self.assertRaises(TypeError):
|
|
132
151
|
capy.call(4000, "hello", "localhost")
|
|
@@ -203,5 +222,82 @@ class TestNetwork(unittest.TestCase):
|
|
|
203
222
|
capy.network.call("maison", "hello")
|
|
204
223
|
|
|
205
224
|
|
|
225
|
+
class TestProject(unittest.TestCase):
|
|
226
|
+
def setUp(self):
|
|
227
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
228
|
+
self.dir = pathlib.Path(self.tmp.name)
|
|
229
|
+
|
|
230
|
+
def tearDown(self):
|
|
231
|
+
self.tmp.cleanup()
|
|
232
|
+
|
|
233
|
+
def write(self, data, folder=None):
|
|
234
|
+
path = (folder or self.dir) / "capy.config.json"
|
|
235
|
+
path.write_text(json.dumps(data))
|
|
236
|
+
return path
|
|
237
|
+
|
|
238
|
+
def test_local_server_and_call(self):
|
|
239
|
+
port = free_port()
|
|
240
|
+
self.write({"name": "demo", "mode": "local", "host": "localhost", "port": port})
|
|
241
|
+
app = capy.project(self.dir)
|
|
242
|
+
self.assertEqual((app.name, app.mode, app.port, app.tls), ("demo", "local", port, True))
|
|
243
|
+
start(demo_server(app.server()))
|
|
244
|
+
self.assertEqual(app.call("hello", name="Zoe"), {"message": "Bonjour Zoe"})
|
|
245
|
+
|
|
246
|
+
def test_find_in_parent_folder(self):
|
|
247
|
+
self.write({"port": 4000})
|
|
248
|
+
sub = self.dir / "client" / "src"
|
|
249
|
+
sub.mkdir(parents=True)
|
|
250
|
+
self.assertEqual(capy._project.find(sub), (self.dir / "capy.config.json").resolve())
|
|
251
|
+
app = capy.project(self.dir / "capy.config.json")
|
|
252
|
+
self.assertEqual((app.name, app.mode, app.host), (self.dir.name, "local", "localhost")) # Valeurs par défaut
|
|
253
|
+
|
|
254
|
+
def test_env_variables(self):
|
|
255
|
+
os.environ["CAPY_TEST_KEY"], os.environ["CAPY_TEST_PORT"] = "s3cret", "4100"
|
|
256
|
+
self.write({"relay": "mon-vps.fr:3879", "key": "$CAPY_TEST_KEY", "port": "$CAPY_TEST_PORT"})
|
|
257
|
+
app = capy.project(self.dir)
|
|
258
|
+
self.assertEqual((app.mode, app.key, app.port), ("network", "s3cret", 4100)) # relay -> mode network
|
|
259
|
+
self.write({"relay": "mon-vps.fr:3879", "key": "$CAPY_PAS_LA"})
|
|
260
|
+
with self.assertRaisesRegex(ValueError, "CAPY_PAS_LA n'existe pas"):
|
|
261
|
+
capy.project(self.dir)
|
|
262
|
+
|
|
263
|
+
def test_errors(self):
|
|
264
|
+
with self.assertRaisesRegex(FileNotFoundError, "pas de fichier"):
|
|
265
|
+
capy.project(self.dir / "absent.json")
|
|
266
|
+
for data, message in (
|
|
267
|
+
({"prot": 4000}, "tu voulais dire 'port'"),
|
|
268
|
+
({"port": "4000"}, "doit être un nombre"),
|
|
269
|
+
({"port": True}, "doit être un nombre"),
|
|
270
|
+
({"mode": "local"}, 'il manque "port"'),
|
|
271
|
+
({"mode": "bluetooth"}, "mode 'bluetooth' inconnu"),
|
|
272
|
+
({"mode": "network"}, 'il manque "relay"'),
|
|
273
|
+
({"tls": "oui", "port": 1}, "true ou false"),
|
|
274
|
+
):
|
|
275
|
+
self.write(data)
|
|
276
|
+
with self.assertRaisesRegex(ValueError, message):
|
|
277
|
+
capy.project(self.dir)
|
|
278
|
+
(self.dir / "capy.config.json").write_text("{pas du json")
|
|
279
|
+
with self.assertRaisesRegex(ValueError, "pas du JSON valide"):
|
|
280
|
+
capy.project(self.dir)
|
|
281
|
+
|
|
282
|
+
def test_wifi_and_network_servers(self):
|
|
283
|
+
self.write({"name": "salon", "mode": "wifi", "tls": False})
|
|
284
|
+
server = capy.project(self.dir).server()
|
|
285
|
+
self.assertIsInstance(server, capy.wifi.Server)
|
|
286
|
+
self.assertEqual((server.name, server.port, server.tls), ("salon", 0, False))
|
|
287
|
+
self.write({"name": "garage", "relay": "127.0.0.1:1", "key": "k"})
|
|
288
|
+
self.assertIsInstance(capy.project(self.dir).server(), capy.network.Server)
|
|
289
|
+
self.assertEqual(capy.network._config["relay"], "127.0.0.1:1")
|
|
290
|
+
|
|
291
|
+
def test_init(self):
|
|
292
|
+
path = capy._project.init(self.dir, name="jeu")
|
|
293
|
+
self.assertEqual(json.loads(path.read_text())["name"], "jeu")
|
|
294
|
+
self.assertEqual(capy.project(path).port, 4000)
|
|
295
|
+
with self.assertRaises(FileExistsError):
|
|
296
|
+
capy._project.init(self.dir)
|
|
297
|
+
(self.dir / "net").mkdir()
|
|
298
|
+
path = capy._project.init(self.dir / "net", mode="network")
|
|
299
|
+
self.assertEqual(json.loads(path.read_text())["key"], "$CAPY_KEY")
|
|
300
|
+
|
|
301
|
+
|
|
206
302
|
if __name__ == "__main__":
|
|
207
303
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|