routeros-client 0.6.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.
@@ -0,0 +1,17 @@
1
+ # Configuration de la campagne de tests réels (test_live_router.py).
2
+ # Copiez ce fichier en .env et adaptez-le, ou exportez ces variables.
3
+ # .env est ignoré par git : n'y mettez que des identifiants de LABORATOIRE.
4
+
5
+ ROS_HOST=192.168.88.1
6
+ ROS_USER=admin
7
+ ROS_PASSWORD=change-me
8
+
9
+ # Ports RouterOS — à ne surcharger que si vos services n'écoutent pas
10
+ # sur les ports standards.
11
+ ROS_PORT_API=8728
12
+ ROS_PORT_API_SSL=8729
13
+ ROS_PORT_SSH=22
14
+ ROS_PORT_REST=80
15
+
16
+ # Nom de la liste d'adresses jetable créée puis supprimée par les tests.
17
+ ROS_TEST_LIST=rosapi-selftest
@@ -0,0 +1,437 @@
1
+ # Changelog — routeros_api
2
+
3
+ ## v0.6.0
4
+
5
+ Trois axes : **23 bugs corrigés**, **9 optimisations de performance**, **transport SSH ajouté**.
6
+ La compatibilité ascendante avec la v0.5.0 est préservée (voir la section dédiée en fin de document).
7
+
8
+ L'ancienne version est conservée telle quelle dans `routeros_api_v0.5.0.bak.py`.
9
+
10
+ ### Validation
11
+
12
+ | Campagne | Commande | Résultat |
13
+ | --- | --- | --- |
14
+ | Hors-ligne (aucun routeur requis) | `python -m unittest test_routeros_api -v` | **90/90** |
15
+ | Hors-ligne, contre la **wheel installée** | idem depuis un répertoire neutre | **90/90** |
16
+ | Routeur réel | `python test_live_router.py` | **62/62** |
17
+ | Analyse statique | `ruff check routeros_api.py` | **0 finding** |
18
+ | Version Python minimale du code | `vermin routeros_api.py` | **3.7** (installation déclarée 3.9+, voir §5) |
19
+ | Métadonnées de distribution | `twine check --strict dist/*` | **PASSED** |
20
+
21
+ La campagne hors-ligne passe **avec et sans `paramiko` installé**, aussi bien depuis les sources que
22
+ depuis la wheel installée en site-packages.
23
+
24
+ Les tests concernés par un bug sont nommés `test_bugN_…`.
25
+
26
+ La campagne réelle a été exécutée contre un **RouterOS 7.23.3 (stable), CHR** sur
27
+ `192.168.117.132`, aux ports par défaut — API 8728, API-SSL 8729, SSH 22, REST 80 — et couvre les
28
+ quatre transports, les écritures (`add`/`set`/`remove`/`ensure`/`bulk_*`/pipelining), `listen()`,
29
+ `export()`, `ping_host()`, les bascules de transport, et la cohérence des données entre API et SSH.
30
+ Tous les objets créés pendant les tests sont supprimés en fin de campagne (vérifié).
31
+ **Les bugs 20, 21 et 22 ci-dessous n'ont été révélés que par cette campagne réelle** — aucun test
32
+ hors-ligne ne pouvait les détecter.
33
+
34
+ ---
35
+
36
+ ## 1. Bugs corrigés
37
+
38
+ ### BUG-1 — 🔴 Critique : désynchronisation permanente du flux après une erreur
39
+
40
+ `_read_response()` levait `RouterOSCommandError` **dès la réception du `!trap`** quand aucun tag
41
+ n'était attendu — c'est-à-dire pour tous les appels de `cmd()`, `talk()`, `print()`, `add()`, `set()`…
42
+ puisque `use_tag` vaut `False` par défaut.
43
+
44
+ Le protocole termine **toujours** une commande par un `!done`, y compris après un `!trap`. Ce `!done`
45
+ restait donc dans le tampon et était lu par la commande **suivante** comme si c'était sa réponse.
46
+
47
+ Conséquence : après la moindre erreur de syntaxe ou de permission, toutes les commandes suivantes
48
+ étaient décalées d'une réponse — `print()` renvoyait des listes vides, `add()` semblait réussir sans
49
+ rien créer. Le tout sans aucune erreur visible.
50
+
51
+ **Correctif** : le `!trap` est mémorisé, la lecture continue jusqu'au `!done`, puis l'exception est levée.
52
+ Un délai de garde (`trap_drain_timeout`, 5 s) évite un blocage si un routeur n'envoie pas le `!done`.
53
+ *(test `test_bug1_trap_keeps_stream_in_sync`)*
54
+
55
+ ### BUG-2 — 🔴 `cmd_timeout` laissait le socket définitivement en mode non bloquant
56
+
57
+ La restauration était conditionnée par `if prev_timeout is not None`. Or `gettimeout()` renvoie
58
+ précisément `None` pour un socket bloquant — le cas par défaut. Après un seul appel avec
59
+ `cmd_timeout=…`, le socket gardait ce timeout **pour toute la session** : les commandes longues
60
+ (`/export`, gros `print`) échouaient ensuite en timeout sans raison apparente.
61
+
62
+ **Correctif** : sentinelle `_UNSET` pour distinguer « non modifié » de « était None ».
63
+ *(test `test_bug2_timeout_restored_on_blocking_socket`)*
64
+
65
+ ### BUG-3 — 🔴 La reconnexion automatique détruisait la file de tâches
66
+
67
+ `_ensure_connected()` appelait `self.close()`, qui exécute `queue.shutdown()` puis `self.queue = None`.
68
+ Après une reconnexion réussie : `api.queue.submit(...)` → `AttributeError: 'NoneType'`.
69
+ Pire, si la reconnexion partait d'un worker de la file, `close()` faisait joindre le thread courant
70
+ à lui-même → `RuntimeError: cannot join current thread`.
71
+
72
+ **Correctif** : nouveau `_reset_connection()` qui ne ferme que le socket ; `ApiQueue.shutdown()`
73
+ ignore le thread courant.
74
+
75
+ ### BUG-4 — 🔴 Réponses taguées d'une autre commande interprétées comme siennes
76
+
77
+ Le filtre `if expected_tag and phrase_tag and phrase_tag != expected_tag` ne s'appliquait que
78
+ lorsqu'un tag était attendu. Sans tag, une phrase taguée (reliquat d'un `listen()` annulé, d'un
79
+ `/cancel` tardif) était intégrée aux enregistrements de la commande en cours.
80
+
81
+ **Correctif** : toute phrase portant un tag différent de celui attendu est écartée vers
82
+ `_ResponseRouter`. `!fatal` reste traité en priorité, quel que soit son tag.
83
+ *(test `test_bug4_foreign_tag_is_not_consumed_as_own`)*
84
+
85
+ ### BUG-4b — 🔴 `/cancel` hors verrou et jamais consommé
86
+
87
+ `cancel()` écrivait sur le socket **sans** `_socket_lock` : la trame pouvait s'entrelacer avec celle
88
+ d'un autre thread. De plus, `/cancel` n'était pas tagué : le `!done` qu'il provoque était attribué à
89
+ la commande suivante (même symptôme que BUG-1).
90
+
91
+ **Correctif** : envoi sous verrou, `/cancel` porte son propre tag, sa réponse est identifiée et écartée.
92
+
93
+ ### BUG-5 — 🔴 `ApiQueue.shutdown()` abandonnait les futures en attente
94
+
95
+ Les tâches encore en file n'étaient ni exécutées, ni annulées, ni marquées `task_done()` :
96
+ un `future.result()` bloquait **indéfiniment**, et `ApiQueue.join()` ne revenait jamais.
97
+
98
+ **Correctif** : purge de la file au shutdown, chaque future est annulée et chaque tâche marquée terminée.
99
+ *(test `test_bug5_shutdown_resolves_pending_futures`)*
100
+
101
+ ### BUG-6 — 🟠 Fuite mémoire du routeur de réponses
102
+
103
+ `_ResponseRouter.dispatch()` empilait les phrases dans des `Queue` que rien ne consommait, et
104
+ `release()` n'était jamais appelé. Sur un service de longue durée utilisant des tags, la mémoire
105
+ croissait sans borne.
106
+
107
+ **Correctif** : routeur borné (1000 phrases, éviction des plus anciennes avec log), `release()` appelé
108
+ en fin de chaque commande taguée, `clear()` à la déconnexion.
109
+
110
+ ### BUG-7 — 🟠 `talk()` modifiait la liste de l'appelant
111
+
112
+ `_execute_talk()` faisait `words = msg` (sans copie) puis `words.append(".tag=…")`. La liste passée par
113
+ l'appelant était donc modifiée, et un retry (`enable_resilience`) ajoutait un **second** `.tag=` à la
114
+ même commande — que RouterOS rejette.
115
+
116
+ **Correctif** : la liste est copiée dès qu'un tag doit être ajouté.
117
+ *(test `test_bug7_caller_list_is_not_mutated`)*
118
+
119
+ ### BUG-8 — 🟠 Régression : les guillemets étaient stockés dans les valeurs
120
+
121
+ Le « BUG-9 FIX » de la v0.5.0 conservait les guillemets englobants dans le mot encodé.
122
+ Le protocole binaire délimitant les mots par **longueur**, aucun quoting n'est nécessaire :
123
+ `=comment="hello world"` créait un commentaire contenant littéralement les guillemets.
124
+
125
+ **Correctif** : retour au comportement correct (guillemets retirés), plus le support de `\"`.
126
+ Ancien comportement disponible via `RouterOSProtocol.KEEP_QUOTES = True`.
127
+ *(tests `test_split_command_*`)*
128
+
129
+ ### BUG-9 — 🟡 `APIResponse.error_message` documenté mais jamais renseigné
130
+
131
+ Le champ était annoncé dans le code comme « peuplé par `_read_response()` » ; il valait toujours `None`.
132
+
133
+ **Correctif** : nouveau paramètre `raise_on_trap=False` sur `talk()` / `_read_response()` qui renvoie
134
+ la réponse avec `error_message` et `error_category` renseignés. Ajout de `.ok` et `.raise_for_error()`.
135
+
136
+ ### BUG-10 — 🟠 `listen(timeout=…)` sans effet
137
+
138
+ L'échéance était vérifiée **avant** un `read_sentence()` bloquant. Sur une connexion sans timeout socket
139
+ (le défaut), l'appel restait bloqué pour toujours et le paramètre `timeout` n'avait aucun effet.
140
+
141
+ **Correctif** : lecture par tranches d'une seconde, échéance réévaluée à chaque tranche.
142
+ *(test `test_bug10_listen_timeout_is_enforced`)*
143
+
144
+ ### BUG-11 — 🟡 `ApiQueue.submit(timeout=…)` silencieusement ignoré
145
+
146
+ Le paramètre était accepté puis jamais utilisé.
147
+ **Correctif** : transmis comme `cmd_timeout` à `talk()`. *(test `test_bug11_submit_timeout_is_applied`)*
148
+
149
+ ### BUG-12 — 🟠 Filtres `id=` sans effet dans `get_filtered()` et `ResourceProxy.get()`
150
+
151
+ Seul `get_proplist()` normalisait les clés système. `proxy.find(id="*1")` envoyait `?id=*1`, que
152
+ RouterOS ignore : la requête retournait **toute la table** au lieu d'une entrée — silencieusement.
153
+ Une valeur `None` produisait par ailleurs le filtre absurde `?key=None`.
154
+
155
+ **Correctif** : normalisation centralisée dans `_query_words()` ; `None` produit `?key` (test
156
+ d'existence) ; support des opérateurs `?>`, `?<`. *(test `test_bug12_query_words_normalize_id`)*
157
+
158
+ ### BUG-13 — 🟡 `ping_host()` : intervalle tronqué et latences non analysées
159
+
160
+ * `interval=1.5` produisait `interval=1s` (`ms // 1000`) ;
161
+ * `float("12ms490us".replace("ms",""))` lève `ValueError`, silencieusement avalée : `min/avg/max_ms`
162
+ restaient à 0 sur RouterOS v7, qui renvoie systématiquement ce format.
163
+
164
+ **Correctif** : `parse_duration_ms()` gère `12ms490us`, `2s500ms`, `1w2d3h`, `00:00:01.5` ;
165
+ l'intervalle sub-seconde est émis en millisecondes. *(test `test_bug13_ping_parses_sub_ms_and_interval`)*
166
+
167
+ ### BUG-14 — 🟡 `get_multi()` fragile
168
+
169
+ Indexation `responses[i]` sans garde (IndexError possible) et test `if responses[i]` faux pour une
170
+ table vide, `APIResponse.__len__` valant alors 0.
171
+ **Correctif** : correspondance par index sécurisée et test de type explicite.
172
+
173
+ ### BUG-15 — 🟡 Paramètre `encoding` accepté puis ignoré
174
+
175
+ `Api(encoding="latin-1")` n'avait aucun effet : `encode_word()` codait en dur `utf-8`.
176
+ **Correctif** : l'encodage est propagé jusqu'à l'encodage/décodage des mots.
177
+
178
+ ### BUG-16 — 🟡 Octet d'en-tête réservé traité comme une fin de phrase
179
+
180
+ `decode_word_length()` renvoyait `(0, 1)` pour `0xF1..0xFF`, ce qui simulait un mot vide — donc une
181
+ fin de phrase — et masquait une désynchronisation du flux.
182
+ **Correctif** : `RouterOSProtocolError` explicite.
183
+
184
+ ### BUG-17 — 🟡 `AttributeError` au lieu d'une erreur typée
185
+
186
+ `_read_response()` et `export()` déréférençaient `self.sock` sans garde : après un `!fatal` ou un
187
+ `close()` concurrent, l'appelant recevait `AttributeError: 'NoneType' object has no attribute
188
+ 'read_sentence'` au lieu de `RouterOSConnectionError`.
189
+
190
+ ### BUG-18 — 🟡 Erreur d'authentification masquée en mode AUTO
191
+
192
+ Sur RouterOS ≥ 6.45 (authentification legacy supprimée), un mot de passe erroné produisait
193
+ « Pas de challenge reçu » — message trompeur, la vraie cause étant perdue.
194
+ **Correctif** : les deux erreurs (plaintext + legacy) sont rapportées et chaînées.
195
+
196
+ ### BUG-19 — 🟡 `RestApi` : authentification non préemptive
197
+
198
+ Avec le seul `HTTPBasicAuthHandler`, **chaque** requête coûtait deux allers-retours (401 puis rejeu),
199
+ et échouait si le routeur ne renvoyait pas un `WWW-Authenticate` exploitable.
200
+ **Correctif** : en-tête `Authorization: Basic` envoyé d'emblée (`preemptive_auth=True`), 401/403
201
+ remontés en `RouterOSAuthError`, respect de `Retry-After`, `close()` robuste.
202
+
203
+ ### BUG-20 — 🔴 `api-ssl` (8729) inutilisable : handshake TLS systématiquement refusé
204
+
205
+ *Découvert en conditions réelles.* Sans certificat configuré — la situation par défaut — le service
206
+ `api-ssl` de RouterOS ne propose **que des suites anonymes** (`ADH-AES256-SHA256`). Le contexte SSL par
207
+ défaut de Python les refuse : toute connexion `use_ssl=True` échouait sur
208
+ `SSLV3_ALERT_HANDSHAKE_FAILURE`, y compris avec `ssl_verify=False`. Le transport TLS de la v0.5.0 ne
209
+ pouvait donc pas fonctionner contre une configuration RouterOS standard.
210
+
211
+ **Correctif** : quand `ssl_verify=False` (l'appelant a déjà renoncé à authentifier le pair), le
212
+ contexte réactive les suites anonymes en essayant `ALL:@SECLEVEL=0`, puis `ADH:@SECLEVEL=0`, puis
213
+ `ALL:aNULL`. Désactivable par `ssl_allow_anonymous=False`.
214
+ Vérifié sur ROS 7.23.3 : négocie `ADH-AES256-SHA256 / TLSv1.2`.
215
+
216
+ ### BUG-21 — 🟠 `RestApi.add()` : mauvais verbe HTTP (régression v0.5.0)
217
+
218
+ *Découvert en conditions réelles.* Le « BUG-10 FIX » de la v0.5.0 avait remplacé `PUT` par `POST` au
219
+ nom de la sémantique REST habituelle. L'API REST de RouterOS ne suit pas cette convention :
220
+
221
+ | Verbe | Effet réel côté RouterOS |
222
+ | --- | --- |
223
+ | `PUT /rest/<menu>` | **crée** — HTTP 201 + objet créé |
224
+ | `POST /rest/<menu>` | HTTP 400 `{"detail":"no such command"}` |
225
+ | `POST /rest/<menu>/<commande>` | invoque une commande (`/print`, `/monitor`…) |
226
+ | `PATCH /rest/<menu>/<id>` | modifie |
227
+ | `DELETE /rest/<menu>/<id>` | supprime |
228
+
229
+ `add()` était donc **cassé** en v0.5.0 : toute création via REST échouait en HTTP 400.
230
+
231
+ **Correctif** : retour à `PUT`, plus une méthode `command()` pour l'invocation de commandes.
232
+
233
+ ### BUG-22 — 🟠 SSH : options placées avant les paramètres
234
+
235
+ *Découvert en conditions réelles.* `_CliCommand.render()` émettait les options avant les paramètres,
236
+ produisant `/ping as-value address=127.0.0.1`. Or `/ping` prend l'hôte en **argument positionnel** :
237
+ RouterOS tentait de joindre un hôte nommé « as-value », le mode `as-value` échouait, et `ping_host()`
238
+ retombait silencieusement sur l'analyse du tableau texte — d'où des latences perdues (`avg_ms = 0.0`
239
+ alors que le routeur répondait en 0,077 ms).
240
+
241
+ **Correctif** : ordre `commande → positionnels → paramètres → options → where`, valide pour les deux
242
+ familles de commandes. *(test `test_bug22_options_come_after_parameters`)*
243
+
244
+ ### BUG-23 — 🟡 SSH/OpenSSH : `timeout` ignoré sur les flux
245
+
246
+ *Découvert par l'analyse statique finale (`ruff ARG002`).* `_OpenSshBackend.stream()` acceptait un
247
+ paramètre `timeout` puis ne s'en servait jamais : un `print follow` lancé via le client OpenSSH
248
+ tournait indéfiniment même lorsque l'appelant avait fixé une échéance. Même classe de défaut que
249
+ BUG-11.
250
+
251
+ **Correctif** : le flux s'interrompt une fois l'échéance atteinte, et le processus est terminé dans
252
+ le `finally`.
253
+
254
+ ### Limite RouterOS documentée (et non un bug de la bibliothèque)
255
+
256
+ Sur RouterOS 7.23.3, **`/export` ne renvoie rien via le protocole API binaire** : ni `!re`, ni `=ret=`,
257
+ ni erreur. `Api.export()` retourne donc une chaîne vide quoi que fasse la bibliothèque — un
258
+ avertissement est désormais journalisé pour éviter le diagnostic à l'aveugle. `SshApi.export()`
259
+ fonctionne pleinement : c'est la voie à utiliser pour sauvegarder une configuration.
260
+
261
+ ### Corrections mineures complémentaires
262
+
263
+ * `export()` : test `"=message=" in word` remplacé par une analyse correcte ; l'export passe désormais
264
+ par le chemin de lecture commun (tag, verrou, gestion du `!trap`).
265
+ * `api_session()` : `retry` négatif ne peut plus produire `yield None`.
266
+ * `__del__` : ne relance plus de threads pendant l'arrêt de l'interpréteur.
267
+ * `close()` : idempotent, remet `connected`/`logged_in` à `False` même sans socket.
268
+ * `_send_sentence()` : masque aussi `=response=` (empreinte MD5) dans les logs `verbose`.
269
+ * `cmd()` / `print()` : `cmd_timeout` (et `on_record`) sont devenus des arguments **nommés
270
+ exclusifs**. Ils partaient auparavant dans `**kwargs` et étaient envoyés au routeur comme des
271
+ propriétés — `api.print("/x", cmd_timeout=5)` provoquait `unknown parameter cmd_timeout`.
272
+ `add()`, `set()`, `remove()`, `enable()` et `disable()` en héritent.
273
+ * `auto_connect()` : nouveau `transport_kwargs={"api": {...}, "ssh": {...}}`. Un `port=` global
274
+ s'appliquait à tous les transports, ce qui rendait la bascule API→SSH impossible dès qu'un port
275
+ non standard était précisé.
276
+
277
+ ---
278
+
279
+ ## 2. Performances
280
+
281
+ | # | Optimisation | Gain |
282
+ | --- | --- | --- |
283
+ | P1 | Tampon de réception à curseur + compactage amorti (`del buffer[:n]` était exécuté à **chaque mot**) | O(n²) → O(n) : ~40× plus rapide sur une réponse de 10 000 enregistrements |
284
+ | P2 | `TCP_NODELAY` activé | supprime jusqu'à ~40 ms de latence Nagle/ACK différé **par commande** |
285
+ | P3 | Décodage de l'en-tête sur place (plus de double analyse ni de slice intermédiaire) | ~25 % sur la boucle de lecture |
286
+ | P4 | `partition()` au lieu de `split("=", 1)` + liaisons locales dans les boucles chaudes | ~10 % sur le parsing des enregistrements |
287
+ | P5 | **Pipelining** : `batch(pipeline=True)` envoie N commandes en une trame et lit les réponses par tag | N allers-retours → 1 (200 commandes à 20 ms de latence : ~4 s → ~0,1 s) |
288
+ | P6 | `ApiQueue` créée à la demande (`lazy_queue=True`) | plus de thread inutile par instance `Api` |
289
+ | P7 | `SO_KEEPALIVE` + `set_keepalive` SSH | détection des connexions mortes |
290
+ | P8 | REST : authentification préemptive | 2 requêtes HTTP → 1 |
291
+ | P9 | `paramiko` importé **paresseusement** (à la première connexion SSH) | `import routeros_api` : **982 ms → 475 ms** quand paramiko est installé |
292
+
293
+ À cela s'ajoutent `iter_print()` (lecture au fil de l'eau, mémoire constante) et le multiplexage
294
+ d'une seule connexion SSH pour toutes les commandes (backend paramiko).
295
+
296
+ ---
297
+
298
+ ## 3. Transport SSH (nouveau)
299
+
300
+ `SshApi` expose **exactement la même surface** que `Api` — `talk / cmd / print / add / set / remove /
301
+ enable / disable / batch / bulk_* / get_resource / ping_host / export / listen` — si bien que le code
302
+ métier existant fonctionne sans modification :
303
+
304
+ ```python
305
+ from routeros_api import connect, ssh_session
306
+
307
+ # API binaire (inchangé)
308
+ api = connect("192.168.88.1", "admin", "secret")
309
+
310
+ # SSH — même code métier ensuite
311
+ ros = connect("192.168.88.1", "admin", transport="ssh",
312
+ key_filename="~/.ssh/id_ed25519")
313
+
314
+ with ssh_session("192.168.88.1", "admin", password="secret") as ros:
315
+ for addr in ros.get_resource("/ip/address").find(disabled=False):
316
+ print(addr["address"])
317
+
318
+ # Bascule automatique API → SSH selon ce qui répond
319
+ ros = auto_connect(ip, "admin", pwd, order=("api", "ssh"))
320
+ ```
321
+
322
+ **Fonctionnement.** Les commandes protocolaires (`"/ip/address/print"`, `"=address=…"`,
323
+ `"?disabled=false"`) sont traduites en ligne de commande RouterOS par `RouterOSCli`, exécutées dans un
324
+ canal `exec` (aucune analyse d'invite, code de retour disponible), puis la sortie est re-parsée en
325
+ enregistrements identiques à ceux de l'API. Trois formats sont essayés, du plus fidèle au plus tolérant :
326
+
327
+ 1. `:put [:serialize to=json [<cmd> as-value]]` — RouterOS v7, restitue les mêmes champs que l'API, `.id` compris ;
328
+ 2. `:put [<cmd> as-value]` — RouterOS v6 et v7 ;
329
+ 3. `<cmd> without-paging terse` — repli universel.
330
+
331
+ Le mode retenu est mémorisé après la première commande.
332
+
333
+ **Dépendances.** `paramiko` est **optionnel** : s'il est installé, mot de passe / clé / agent sont
334
+ supportés. Sinon, le module bascule sur le client `ssh` du système (clé ou agent uniquement ; un mot de
335
+ passe lève une erreur explicite indiquant les options).
336
+
337
+ > **À installer si vous utilisez SSH avec un mot de passe** : `pip install paramiko`.
338
+ > Sans lui, `SshApi(..., password="…")` lève `RouterOSSSHError` avec le message d'aide correspondant.
339
+ > La campagne réelle a été menée avec paramiko 5.0.0.
340
+
341
+ **Sécurité.** Toute valeur sortant d'un jeu de caractères sûr est quotée et échappée (`\`, `"`, `$`,
342
+ sauts de ligne) et les segments de chemin sont validés — sans quoi une valeur contenant `;` ou un saut
343
+ de ligne permettrait d'injecter une commande dans la console. Vérification de la clé d'hôte activée par
344
+ défaut (`strict_host_key=True`).
345
+
346
+ **Limites.** Pas de multiplexage par tag : `batch(pipeline=True)` retombe en séquentiel, `cancel()` est
347
+ sans objet, `listen()` est émulé via `print follow`. Compter ~2 à 5 ms de surcoût par commande.
348
+
349
+ Extras : `run()` / `run_script()` (passe-plat CLI brut, pour ce que l'API ne sait pas faire),
350
+ `sftp_get()` / `sftp_put()` (backend paramiko).
351
+
352
+ ---
353
+
354
+ ## 4. Autres nouveautés
355
+
356
+ * `ensure(path, match, values)` — création/mise à jour idempotente, retourne `(.id, created)`.
357
+ * `find_id()`, `find_ids()`, `remove_where()`, `is_alive()`.
358
+ * `iter_print()` — générateur à mémoire constante sur les grosses tables.
359
+ * `Api.pipeline()` — gestionnaire de contexte autour du pipelining.
360
+ * `APIResponse` : `.ok`, `.first()`, `.values()`, `.ids()`, `.typed()`, `.raise_for_error()`.
361
+ * `to_python()`, `parse_duration_ms()` — conversion de valeurs RouterOS (opt-in).
362
+ * `_format_value()` : `True` → `yes`, `None` → `""`, `["a","b"]` → `"a,b"` (la v0.5.0 envoyait `True`).
363
+ * `cmd(..., _params={...})` — échappatoire pour les propriétés dont le nom entre en collision avec un
364
+ paramètre Python (`path`, `proplist`).
365
+ * `auto_connect()`, `ssh_connect()`, `rest_connect()`, `ssh_session()`, enums `Transport` / `SshBackend`.
366
+ * `RestApi.query()` — filtrage côté serveur.
367
+ * Retry avec jitter, `Retry-After` respecté côté REST.
368
+
369
+ ---
370
+
371
+ ## 5. Distribution / PyPI
372
+
373
+ Le projet est désormais publiable. Point à connaître avant toute installation :
374
+
375
+ > **Le nom de distribution diffère du nom d'import.**
376
+ > `pip install routeros-client` → `import routeros_api`
377
+
378
+ `routeros-api` est déjà occupé sur PyPI par un autre projet (v0.21.0, « Python API to RouterBoard
379
+ devices produced by MikroTik »). PyPI normalisant les noms, ses variantes `routeros_api` et
380
+ `RouterOS-api` désignent ce même projet : aucune n'était disponible. Le **nom d'import reste
381
+ `routeros_api`** afin que les applications existantes continuent de fonctionner sans modification.
382
+
383
+ *Conséquence à surveiller* : ce paquet et le `routeros-api` de PyPI installent tous deux un
384
+ `routeros_api` importable. Ne les installez pas dans le même environnement.
385
+
386
+ Fichiers ajoutés :
387
+
388
+ | Fichier | Rôle |
389
+ | --- | --- |
390
+ | `pyproject.toml` | Métadonnées PEP 621, `dependencies = []`, extras `[ssh]` et `[dev]`, config ruff |
391
+ | `LICENSE` | MIT |
392
+ | `MANIFEST.in` | Contenu de l'archive source |
393
+ | `.github/workflows/ci.yml` | Tests sur 6 combinaisons OS × Python (3.9→3.13), **avec et sans paramiko**, ruff, vermin, construction et validation des artefacts |
394
+ | `.github/workflows/publish.yml` | Publication PyPI sur étiquette `v*` via Trusted Publishing (OIDC, aucun token stocké), avec garde-fou de cohérence étiquette ↔ `__version__` |
395
+
396
+ Dépôt cible : <https://github.com/jackarten/routeros-client> — renseigné dans `[project.urls]` et
397
+ dans le workflow de publication.
398
+
399
+ ### Plancher Python : 3.9
400
+
401
+ Les métadonnées de licence suivent la **PEP 639** (`license = "MIT"` en expression SPDX +
402
+ `license-files`), qui exige **setuptools ≥ 77**. Or setuptools a abandonné Python 3.8 en v76 : sur
403
+ 3.8, la version la plus récente disponible est 75.x, qui ne connaît que l'ancienne forme
404
+ `license = { text = "MIT" }` et rejette la chaîne SPDX avec
405
+ « `project.license` must be valid exactly by one definition ». La construction du paquet y est donc
406
+ impossible.
407
+
408
+ Python 3.8 étant en fin de vie depuis octobre 2024, `requires-python` est fixé à **`>=3.9`** plutôt
409
+ que de revenir à des métadonnées dépréciées. Le **code**, lui, reste compatible 3.7+ (vérifié par
410
+ vermin) : pour couvrir 3.8, il suffit de repasser `license` sous la forme `{ text = "MIT" }`,
411
+ d'ajouter le classifieur `License :: OSI Approved :: MIT License` et d'abaisser
412
+ `requires = ["setuptools>=61"]`. La marche à suivre est consignée dans `pyproject.toml`.
413
+
414
+ Reste à faire avant la première publication : créer le dépôt distant et y pousser le code, puis
415
+ déclarer l'éditeur de confiance sur <https://pypi.org/manage/account/publishing/>
416
+ (*Add a new pending publisher* : projet `routeros-client`, propriétaire `jackarten`, dépôt
417
+ `routeros-client`, workflow `publish.yml`, environment `pypi`). Aucun token n'est à stocker.
418
+
419
+ ---
420
+
421
+ ## 6. Compatibilité ascendante
422
+
423
+ Vérifiée par les tests `TestFactories` (surface publique, signature de `Api.__init__`) et par
424
+ `smoke.py` : tous les symboles, méthodes et paramètres nommés de la v0.5.0 sont présents et
425
+ conservent leur sémantique. `api.queue` reste un attribut assignable (`api.queue = None` fonctionne),
426
+ bien qu'il soit devenu une propriété à création paresseuse.
427
+
428
+ **Deux changements de comportement volontaires**, tous deux des corrections de bug :
429
+
430
+ 1. **BUG-8** — `split_command()` retire de nouveau les guillemets englobants.
431
+ Ancien comportement : `RouterOSProtocol.KEEP_QUOTES = True`.
432
+ 2. **BUG-1** — un `!trap` est signalé après lecture du `!done` (quelques millisecondes plus tard).
433
+ L'exception levée est identique ; seul le flux reste désormais synchronisé.
434
+
435
+ À noter également : `_format_value()` corrige l'envoi des booléens Python (`disabled=True` produisait
436
+ `=disabled=True`, refusé par RouterOS ; il produit désormais `=disabled=yes`). Le code qui passait déjà
437
+ des chaînes `"yes"` / `"true"` n'est pas affecté.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Karten
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ # Contenu de l'archive source (sdist).
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include LICENSE
5
+ include requirements.txt
6
+ include requirements-core.txt
7
+ include .env.example
8
+ include test_routeros_api.py
9
+ include test_live_router.py
10
+
11
+ # Exclusions
12
+ exclude *.bak.py
13
+ prune .venv
14
+ prune .github
15
+ global-exclude __pycache__
16
+ global-exclude *.py[cod]
17
+ global-exclude .env