opsiom-cli 1.5.0__tar.gz → 1.5.2__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
  # Changelog
2
2
 
3
- ## 1.5.0
3
+ ## 1.5.2
4
4
 
5
5
  ### Ajouts
6
6
  - Sous-commandes `opsiom doctor` (diagnostic), `opsiom paths` (emplacements des fichiers) et `opsiom configure`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opsiom-cli
3
- Version: 1.5.0
3
+ Version: 1.5.2
4
4
  Summary: Interface en ligne de commande pour discuter avec Opsiom, l'assistant IA francophone.
5
5
  Keywords: chatbot,cli,francophone,ia,opsiom
6
6
  Classifier: Environment :: Console
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opsiom-cli"
7
- version = "1.5.0"
7
+ version = "1.5.2"
8
8
  description = "Interface en ligne de commande pour discuter avec Opsiom, l'assistant IA francophone."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -9,7 +9,9 @@ Les deux clients exposent la même interface :
9
9
 
10
10
  import html
11
11
  import json
12
+ import os
12
13
  import re
14
+ import sys
13
15
 
14
16
  import requests
15
17
 
@@ -62,6 +64,38 @@ def error_detail(exc):
62
64
  return None
63
65
 
64
66
 
67
+ # --- Formulaire de connexion -------------------------------------------------
68
+
69
+ _INPUT_RE = re.compile(r"<input\b[^>]*>", re.IGNORECASE)
70
+ _ATTR_RE = re.compile(r"""([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""")
71
+
72
+
73
+ def parse_login_form(page_html):
74
+ """Repère dans la page de connexion :
75
+ - les champs cachés (jeton CSRF, etc.), à renvoyer tels quels ;
76
+ - le nom du champ pseudo et celui du champ mot de passe.
77
+ Ne lit jamais de valeur saisie par l'utilisateur."""
78
+ hidden, user_field, pass_field = {}, None, None
79
+ for tag in _INPUT_RE.findall(page_html or ""):
80
+ attrs = {key.lower(): (dq or sq) for key, dq, sq in _ATTR_RE.findall(tag)}
81
+ name = attrs.get("name")
82
+ if not name:
83
+ continue
84
+ kind = attrs.get("type", "text").lower()
85
+ if kind == "hidden":
86
+ hidden[name] = attrs.get("value", "")
87
+ elif kind == "password":
88
+ pass_field = pass_field or name
89
+ elif kind in ("text", "email") and user_field is None:
90
+ user_field = name
91
+ return {"hidden": hidden, "username": user_field, "password": pass_field}
92
+
93
+
94
+ def _debug(message):
95
+ if os.environ.get("OPSIOM_DEBUG"):
96
+ print(f"[debug] {message}", file=sys.stderr)
97
+
98
+
65
99
  class OpsiomClient:
66
100
  """Serveur d'inférence classique : routes /api/*, clé API (X-API-Key)."""
67
101
 
@@ -104,8 +138,9 @@ class OpsiomClient:
104
138
 
105
139
 
106
140
  class HFSpaceClient:
107
- """Space Hugging Face qui héberge Opsiom. Authentification par compte Octix
108
- (formulaire /login, session par cookie). Historique et quota vivent côté Space."""
141
+ """Space Hugging Face qui héberge Opsiom. Authentification par compte Octix :
142
+ le CLI se comporte comme un navigateur (page /login, puis formulaire).
143
+ Historique et quota vivent côté Space."""
109
144
 
110
145
  backend = "hf"
111
146
  tracks_quota = True
@@ -117,7 +152,7 @@ class HFSpaceClient:
117
152
  self.username = username
118
153
  self.identity = account_identity(self.base_url, username)
119
154
  self.conversation_id = None
120
- self.session = requests.Session()
155
+ self.session = requests.Session() # garde le cookie de session entre les requêtes
121
156
  self.session.headers["User-Agent"] = "opsiom-cli"
122
157
  if hf_token: # Space privé
123
158
  self.session.headers["Authorization"] = f"Bearer {hf_token}"
@@ -132,30 +167,54 @@ class HFSpaceClient:
132
167
 
133
168
  def login(self, username, password):
134
169
  """Ouvre la session du Space. Renvoie (True, None) ou (False, message)."""
170
+ # Les caractères de contrôle ne peuvent pas faire partie d'un mot de passe saisi
171
+ # au clavier ; sous Windows, un Ctrl+V dans getpass les insère pourtant tels quels.
172
+ removed = len(re.findall(r"[\x00-\x1f\x7f]", password))
173
+ password = re.sub(r"[\x00-\x1f\x7f]", "", password)
174
+ _debug(f"pseudo envoyé : {username!r} ; mot de passe : {len(password)} caractère(s)"
175
+ + (f", {removed} caractère(s) de contrôle supprimé(s)" if removed else ""))
176
+
177
+ # 1. Charger la page de connexion : cookie de session + champs du formulaire.
178
+ try:
179
+ page = self.session.get(f"{self.base_url}/login", timeout=90)
180
+ except requests.RequestException as exc:
181
+ return False, f"Space injoignable ({exc.__class__.__name__})."
182
+ form = parse_login_form(page.text if page.ok else "")
183
+ _debug(f"GET /login -> HTTP {page.status_code}, champs cachés : {sorted(form['hidden'])}, "
184
+ f"champ pseudo : {form['username']!r}, champ mot de passe : {form['password']!r}")
185
+
186
+ # 2. Soumettre le formulaire avec les champs cachés (jeton CSRF).
187
+ data = dict(form["hidden"])
188
+ data[form["username"] or "username"] = username
189
+ data[form["password"] or "password"] = password
135
190
  try:
136
- r = self.session.post(
137
- f"{self.base_url}/login",
138
- data={"username": username, "password": password},
139
- timeout=90, # un Space endormi met un moment à se réveiller
140
- allow_redirects=False,
141
- )
191
+ r = self.session.post(f"{self.base_url}/login", data=data, timeout=90,
192
+ allow_redirects=False)
142
193
  except requests.RequestException as exc:
143
194
  return False, f"Space injoignable ({exc.__class__.__name__})."
144
195
  location = r.headers.get("Location", "")
145
- # Succès = redirection hors de /login. Un échec qui redirige vers /login
146
- # n'est donc pas pris pour une réussite.
196
+ _debug(f"POST /login -> HTTP {r.status_code}, Location : {location or '—'}")
197
+
198
+ # 3. Interpréter la réponse.
147
199
  if r.status_code in (301, 302, 303, 307, 308) and "/login" not in location:
148
- self.username = username
149
- self.identity = account_identity(self.base_url, username)
150
- return True, None
200
+ return self._accept(username)
151
201
  flash = re.search(r'<li class="flash[^"]*">(.*?)</li>', r.text or "", re.S)
152
202
  if flash:
153
203
  return False, html.unescape(re.sub(r"<[^>]+>", "", flash.group(1))).strip()
204
+ if r.status_code == 200 and parse_login_form(r.text)["password"] is None:
205
+ return self._accept(username) # connecté sans redirection
154
206
  if r.status_code in (400, 401, 403):
155
- return False, "pseudo ou mot de passe refusé."
207
+ return False, (f"pseudo ou mot de passe refusé (HTTP {r.status_code}). "
208
+ "Si le mot de passe est bon, lancez « OPSIOM_DEBUG=1 opsiom » "
209
+ "pour voir ce que demande le formulaire.")
156
210
  return False, (f"réponse inattendue (HTTP {r.status_code}). Le Space démarre peut-être : "
157
211
  "réessayez dans une minute.")
158
212
 
213
+ def _accept(self, username):
214
+ self.username = username
215
+ self.identity = account_identity(self.base_url, username)
216
+ return True, None
217
+
159
218
  def health(self):
160
219
  r = self.session.get(f"{self.base_url}/status", timeout=30)
161
220
  r.raise_for_status()
@@ -164,3 +164,15 @@ def test_stream_printer_keeps_indentation(capsys):
164
164
  assert "\n" + " " * 6 + "def f():" in out
165
165
  assert "\n" + " " * 10 + "pass" in out
166
166
  assert text == "Voici :\n def f():\n pass"
167
+
168
+
169
+ def test_parse_login_form_finds_hidden_fields_and_names():
170
+ from opsiom_cli.clients import parse_login_form
171
+
172
+ page = (
173
+ '<form><input type="hidden" name="csrf_token" value="abc123">'
174
+ "<input name='pseudo' type='text'>"
175
+ '<input type="password" name="mdp"></form>'
176
+ )
177
+ form = parse_login_form(page)
178
+ assert form == {"hidden": {"csrf_token": "abc123"}, "username": "pseudo", "password": "mdp"}
@@ -0,0 +1,176 @@
1
+ """Tests de bout en bout des clients contre des faux serveurs HTTP locaux."""
2
+
3
+ import json
4
+ import threading
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from urllib.parse import parse_qs
7
+
8
+ import pytest
9
+ import requests
10
+
11
+ from opsiom_cli.chat import explain_error
12
+ from opsiom_cli.clients import HFSpaceClient, OpsiomClient
13
+
14
+ API_KEY = "secret"
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def no_proxy(monkeypatch):
19
+ monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
20
+ monkeypatch.setenv("no_proxy", "127.0.0.1,localhost")
21
+
22
+
23
+ def _serve(handler):
24
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
25
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
26
+ return httpd
27
+
28
+
29
+ # --- Faux serveur Opsiom (API) ----------------------------------------------
30
+
31
+ class FakeOpsiom(BaseHTTPRequestHandler):
32
+ def log_message(self, *args):
33
+ pass
34
+
35
+ def _json(self, status, body):
36
+ data = json.dumps(body).encode("utf-8")
37
+ self.send_response(status)
38
+ self.send_header("Content-Type", "application/json")
39
+ self.send_header("Content-Length", str(len(data)))
40
+ self.end_headers()
41
+ self.wfile.write(data)
42
+
43
+ def do_GET(self):
44
+ if self.path == "/api/health":
45
+ self._json(200, {"status": "ok", "models_loaded": ["m1"], "quota": {"remaining": 490}})
46
+ elif self.path == "/api/models":
47
+ self._json(200, {"models": [{"id": "m1", "label": "Modèle 1", "params": "7B"}],
48
+ "default": "m1"})
49
+ else:
50
+ self._json(404, {"error": "introuvable"})
51
+
52
+ def do_POST(self):
53
+ length = int(self.headers.get("Content-Length", 0))
54
+ self.rfile.read(length)
55
+ if self.headers.get("X-API-Key") != API_KEY:
56
+ self._json(401, {"error": "clé invalide"})
57
+ return
58
+ if self.path != "/api/chat/stream":
59
+ self._json(404, {"error": "introuvable"})
60
+ return
61
+ self.send_response(200)
62
+ self.send_header("Content-Type", "text/event-stream")
63
+ self.end_headers()
64
+ events = [{"token": "Bon"}, {"token": "jour"},
65
+ {"done": True, "model": "m1", "remaining_quota": 489}]
66
+ for event in events:
67
+ self.wfile.write(f"data: {json.dumps(event)}\n\n".encode("utf-8"))
68
+ self.wfile.flush()
69
+
70
+
71
+ @pytest.fixture
72
+ def opsiom_server():
73
+ httpd = _serve(FakeOpsiom)
74
+ yield f"http://127.0.0.1:{httpd.server_address[1]}"
75
+ httpd.shutdown()
76
+ httpd.server_close()
77
+
78
+
79
+ def test_health_models_and_stream(opsiom_server):
80
+ client = OpsiomClient(opsiom_server, api_key=API_KEY)
81
+ assert client.health()["status"] == "ok"
82
+ assert client.list_models()["default"] == "m1"
83
+ pieces = list(client.chat_stream("Bonjour ?", model="m1"))
84
+ assert "".join(p for p in pieces if isinstance(p, str)) == "Bonjour"
85
+ assert pieces[-1]["remaining_quota"] == 489
86
+
87
+
88
+ def test_wrong_key_is_explained(opsiom_server):
89
+ client = OpsiomClient(opsiom_server, api_key="mauvaise")
90
+ with pytest.raises(requests.HTTPError) as info:
91
+ list(client.chat_stream("x"))
92
+ msg, _hint = explain_error(info.value, client)
93
+ assert "clé API" in msg
94
+
95
+
96
+ # --- Faux Space Hugging Face : formulaire avec cookie + jeton CSRF -----------
97
+
98
+ LOGIN_PAGE = (
99
+ '<html><body><form method="post" action="/login">'
100
+ '<input type="hidden" name="csrf_token" value="tok123">'
101
+ '<input type="text" name="identifiant">'
102
+ '<input type="password" name="secret">'
103
+ '<button>Connexion</button></form></body></html>'
104
+ ).encode("utf-8")
105
+
106
+
107
+ class FakeSpace(BaseHTTPRequestHandler):
108
+ """Comme un Flask-WTF : le POST n'est accepté qu'avec le cookie de session
109
+ obtenu sur GET /login et le bon jeton CSRF. Champs : identifiant / secret."""
110
+
111
+ def log_message(self, *args):
112
+ pass
113
+
114
+ def _send(self, status, body=b"", headers=()):
115
+ self.send_response(status)
116
+ for key, value in headers:
117
+ self.send_header(key, value)
118
+ self.send_header("Content-Length", str(len(body)))
119
+ self.end_headers()
120
+ self.wfile.write(body)
121
+
122
+ def do_GET(self):
123
+ if self.path == "/login":
124
+ self._send(200, LOGIN_PAGE, [("Content-Type", "text/html; charset=utf-8"),
125
+ ("Set-Cookie", "session=abc; Path=/")])
126
+ else:
127
+ self._send(404)
128
+
129
+ def do_POST(self):
130
+ length = int(self.headers.get("Content-Length", 0))
131
+ form = parse_qs(self.rfile.read(length).decode("utf-8"))
132
+ if "session=abc" not in self.headers.get("Cookie", "") or form.get("csrf_token") != ["tok123"]:
133
+ self._send(400, b"The CSRF token is missing.")
134
+ return
135
+ if form.get("identifiant") == ["alice"] and form.get("secret") == ["bon"]:
136
+ self._send(302, headers=[("Location", "/")])
137
+ else:
138
+ page = LOGIN_PAGE.replace(b"<button>", b'<ul><li class="flash error">Mot de passe incorrect</li></ul><button>')
139
+ self._send(200, page, [("Content-Type", "text/html; charset=utf-8")])
140
+
141
+
142
+ @pytest.fixture
143
+ def space_server():
144
+ httpd = _serve(FakeSpace)
145
+ yield f"http://127.0.0.1:{httpd.server_address[1]}"
146
+ httpd.shutdown()
147
+ httpd.server_close()
148
+
149
+
150
+ def test_direct_post_without_page_is_refused(space_server):
151
+ """Reproduit l'ancien comportement : POST sans passer par /login."""
152
+ r = requests.post(f"{space_server}/login", data={"identifiant": "alice", "secret": "bon"},
153
+ allow_redirects=False)
154
+ assert r.status_code == 400
155
+
156
+
157
+ def test_octix_login_with_csrf_and_custom_field_names(space_server):
158
+ client = HFSpaceClient(space_server)
159
+ ok, message = client.login("alice", "bon")
160
+ assert ok is True and message is None
161
+ assert client.username == "alice"
162
+ assert client.identity.startswith("hf-")
163
+
164
+
165
+ def test_octix_wrong_password_shows_site_message(space_server):
166
+ client = HFSpaceClient(space_server)
167
+ ok, message = client.login("alice", "mauvais")
168
+ assert ok is False
169
+ assert "incorrect" in message
170
+
171
+
172
+ def test_control_characters_are_removed_from_password(space_server):
173
+ """Un Ctrl+V sous Windows peut insérer \x16 dans le mot de passe."""
174
+ client = HFSpaceClient(space_server)
175
+ ok, _ = client.login("alice", "bon\x16")
176
+ assert ok is True
opsiom_cli-1.5.0/.env DELETED
@@ -1 +0,0 @@
1
- TOKEN_PYPI=pypi-AgEIcHlwaS5vcmcCJDI3YTY0YTk5LTExZDktNDFlOS04ZGE4LTU1MWFmNDcwZTg1MAACKlszLCJjYzExY2QyYy00ODg4LTRlOTItOTNmYy00NTdhMDhlMWQ5NjQiXQAABiDBNDAJrhWz9Rt11yQPXO1klHHZCcHTEsImiD4G6FOTWA
@@ -1,130 +0,0 @@
1
- """Tests de bout en bout des clients contre un faux serveur HTTP local."""
2
-
3
- import json
4
- import threading
5
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
-
7
- import pytest
8
- import requests
9
-
10
- from opsiom_cli.chat import explain_error
11
- from opsiom_cli.clients import OpsiomClient
12
-
13
- API_KEY = "secret"
14
-
15
-
16
- class FakeOpsiom(BaseHTTPRequestHandler):
17
- def log_message(self, *args): # silence les logs du serveur de test
18
- pass
19
-
20
- def _json(self, status, body):
21
- data = json.dumps(body).encode("utf-8")
22
- self.send_response(status)
23
- self.send_header("Content-Type", "application/json")
24
- self.send_header("Content-Length", str(len(data)))
25
- self.end_headers()
26
- self.wfile.write(data)
27
-
28
- def do_GET(self):
29
- if self.path == "/api/health":
30
- self._json(200, {"status": "ok", "models_loaded": ["m1"],
31
- "quota": {"remaining": 490}})
32
- elif self.path == "/api/models":
33
- self._json(200, {"models": [{"id": "m1", "label": "Modèle 1", "params": "7B"}],
34
- "default": "m1"})
35
- else:
36
- self._json(404, {"error": "introuvable"})
37
-
38
- def do_POST(self):
39
- length = int(self.headers.get("Content-Length", 0))
40
- self.rfile.read(length)
41
- if self.headers.get("X-API-Key") != API_KEY:
42
- self._json(401, {"error": "clé invalide"})
43
- return
44
- if self.path != "/api/chat/stream":
45
- self._json(404, {"error": "introuvable"})
46
- return
47
- self.send_response(200)
48
- self.send_header("Content-Type", "text/event-stream")
49
- self.end_headers()
50
- events = [
51
- {"token": "Bon"},
52
- {"token": "jour"},
53
- {"done": True, "model": "m1", "remaining_quota": 489},
54
- ]
55
- for event in events:
56
- self.wfile.write(f"data: {json.dumps(event)}\n\n".encode("utf-8"))
57
- self.wfile.flush()
58
-
59
-
60
- @pytest.fixture
61
- def server(monkeypatch):
62
- monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
63
- monkeypatch.setenv("no_proxy", "127.0.0.1,localhost")
64
- httpd = ThreadingHTTPServer(("127.0.0.1", 0), FakeOpsiom)
65
- thread = threading.Thread(target=httpd.serve_forever, daemon=True)
66
- thread.start()
67
- yield f"http://127.0.0.1:{httpd.server_address[1]}"
68
- httpd.shutdown()
69
- httpd.server_close()
70
-
71
-
72
- def test_health_models_and_stream(server):
73
- client = OpsiomClient(server, api_key=API_KEY)
74
- assert client.health()["status"] == "ok"
75
- assert client.list_models()["default"] == "m1"
76
- pieces = list(client.chat_stream("Bonjour ?", model="m1"))
77
- text = "".join(p for p in pieces if isinstance(p, str))
78
- assert text == "Bonjour"
79
- assert pieces[-1]["remaining_quota"] == 489
80
-
81
-
82
- def test_wrong_key_is_explained(server):
83
- client = OpsiomClient(server, api_key="mauvaise")
84
- with pytest.raises(requests.HTTPError) as info:
85
- list(client.chat_stream("x"))
86
- msg, _hint = explain_error(info.value, client)
87
- assert "clé API" in msg
88
-
89
-
90
- class FakeSpace(BaseHTTPRequestHandler):
91
- """Space Hugging Face minimal : /login redirige vers / si le mot de passe est bon,
92
- sinon vers /login (comportement courant d'un formulaire Flask/Gradio)."""
93
-
94
- def log_message(self, *args):
95
- pass
96
-
97
- def do_POST(self):
98
- length = int(self.headers.get("Content-Length", 0))
99
- body = self.rfile.read(length).decode("utf-8")
100
- if self.path == "/login" and "password=bon" in body:
101
- self.send_response(302)
102
- self.send_header("Location", "/")
103
- else:
104
- self.send_response(302)
105
- self.send_header("Location", "/login")
106
- self.send_header("Content-Length", "0")
107
- self.end_headers()
108
-
109
-
110
- @pytest.fixture
111
- def space(monkeypatch):
112
- monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
113
- monkeypatch.setenv("no_proxy", "127.0.0.1,localhost")
114
- httpd = ThreadingHTTPServer(("127.0.0.1", 0), FakeSpace)
115
- threading.Thread(target=httpd.serve_forever, daemon=True).start()
116
- yield f"http://127.0.0.1:{httpd.server_address[1]}"
117
- httpd.shutdown()
118
- httpd.server_close()
119
-
120
-
121
- def test_octix_login_success_and_failure(space):
122
- from opsiom_cli.clients import HFSpaceClient
123
-
124
- client = HFSpaceClient(space, username=None)
125
- ok, _ = client.login("alice", "mauvais")
126
- assert ok is False # une redirection vers /login n'est pas une réussite
127
- ok, _ = client.login("alice", "bon")
128
- assert ok is True
129
- assert client.username == "alice"
130
- assert client.identity.startswith("hf-")
File without changes