youcaport 0.1.0__py3-none-any.whl

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.
youcaport/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """YoucaPort — gestionnaire de ports en ligne de commande."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ from pathlib import Path
8
+
9
+
10
+ def _version() -> str:
11
+ """Renvoie la version du paquet (source de vérité : pyproject.toml)."""
12
+ try:
13
+ return version("youcaport")
14
+ except PackageNotFoundError:
15
+ chemin_pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
16
+ with chemin_pyproject.open("rb") as fichier:
17
+ projet = tomllib.load(fichier)
18
+ return str(projet["project"]["version"])
19
+
20
+
21
+ __version__ = _version()
22
+
23
+ __all__ = ["__version__"]
youcaport/cli.py ADDED
@@ -0,0 +1,415 @@
1
+ """Points d'entrée CLI de YoucaPort (Typer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import NoReturn
6
+
7
+ import typer
8
+
9
+ from youcaport import __version__
10
+ from youcaport import dashboard as module_dashboard
11
+ from youcaport.core import (
12
+ docker_manager,
13
+ port_manager,
14
+ privileges,
15
+ process_manager,
16
+ profiles,
17
+ project_manager,
18
+ suggester,
19
+ )
20
+ from youcaport.core.port_manager import LIBRE, OCCUPE, InfoPort, PortSansProcessusError
21
+ from youcaport.core.process_manager import Processus, ProcessusIntrouvableError
22
+ from youcaport.core.profiles import ProfilInexistantError
23
+ from youcaport.core.validator import valider_port
24
+ from youcaport.menu import lancer_menu
25
+ from youcaport.utils import terminal
26
+
27
+ app = typer.Typer(
28
+ add_completion=False,
29
+ rich_markup_mode="rich",
30
+ help="**YoucaPort** : lister, vérifier et libérer les ports utilisés localement.",
31
+ no_args_is_help=False,
32
+ epilog=(
33
+ "Documentation : https://github.com/Fitiafenohaja/YoucaPort\n"
34
+ "Astuce : sous Linux, ajoutez `--sudo` pour identifier puis arrêter "
35
+ "aussi les ports protégés (root/docker)."
36
+ ),
37
+ )
38
+
39
+ app_profile = typer.Typer(help="Gère les profils de projets (association ports ↔ projets).")
40
+ app.add_typer(app_profile, name="profile")
41
+
42
+ app_docker = typer.Typer(help="Gère les conteneurs Docker qui publient des ports (V6).")
43
+ app.add_typer(app_docker, name="docker")
44
+
45
+
46
+ def _callback_version(valeur: bool) -> None:
47
+ """Affiche la version puis quitte lorsque --version est fourni."""
48
+ if valeur:
49
+ typer.echo(f"youcaport {__version__}")
50
+ raise typer.Exit()
51
+
52
+
53
+ @app.callback(invoke_without_command=True)
54
+ def principal(
55
+ ctx: typer.Context,
56
+ version: bool = typer.Option(
57
+ False,
58
+ "--version",
59
+ "-V",
60
+ help="Affiche la version et quitte.",
61
+ is_eager=True,
62
+ callback=_callback_version,
63
+ ),
64
+ ) -> None:
65
+ """YoucaPort : gestion simple des ports réseau locaux."""
66
+ if ctx.invoked_subcommand is None:
67
+ try:
68
+ lancer_menu()
69
+ except KeyboardInterrupt:
70
+ terminal.afficher_information("À bientôt !")
71
+
72
+
73
+ @app.command()
74
+ def status(
75
+ sudo: bool = typer.Option(
76
+ False,
77
+ "--sudo",
78
+ help="Identifie aussi les ports protégés (root/docker). Mot de passe demandé si besoin.",
79
+ ),
80
+ ) -> None:
81
+ """Affiche les ports actuellement utilisés."""
82
+ try:
83
+ infos = port_manager.lister_ports_utilises()
84
+ if sudo:
85
+ infos = _enrichir_sudo(infos)
86
+ except Exception as exc:
87
+ _sortie_erreur(exc)
88
+
89
+ if not infos:
90
+ terminal.afficher_information("Aucun port en écoute actuellement.")
91
+ return
92
+
93
+ terminal.afficher_ports(infos)
94
+ terminal.afficher_resume_ports(infos)
95
+
96
+
97
+ @app.command()
98
+ def check(
99
+ port: str,
100
+ sudo: bool = typer.Option(
101
+ False,
102
+ "--sudo",
103
+ help="Identifie le processus même s'il est protégé (root/docker).",
104
+ ),
105
+ ) -> None:
106
+ """Vérifie si un port est libre ou occupé."""
107
+ try:
108
+ info = port_manager.verifier_port(port)
109
+ if sudo:
110
+ infos = _enrichir_sudo([info])
111
+ info = infos[0]
112
+ except Exception as exc:
113
+ _sortie_erreur(exc)
114
+
115
+ if info.etat == LIBRE:
116
+ terminal.afficher_port_libre(info)
117
+ else:
118
+ terminal.afficher_port_occupe(info)
119
+ if info.processus is None and not sudo and privileges.sudo_disponible():
120
+ terminal.afficher_information(
121
+ f"Astuce : `youcaport check {info.port} --sudo` identifie ce processus protégé."
122
+ )
123
+ terminal.afficher_suggestions(suggester.suggerer_ports_libres(info.port))
124
+
125
+
126
+ @app.command()
127
+ def free(
128
+ port: str,
129
+ sudo: bool = typer.Option(
130
+ False,
131
+ "--sudo",
132
+ help="Autorise l'arrêt des processus protégés (root/docker) via sudo.",
133
+ ),
134
+ ) -> None:
135
+ """Libère un port en arrêtant le processus qui l'occupe."""
136
+ try:
137
+ info = port_manager.verifier_port(port)
138
+ except Exception as exc:
139
+ _sortie_erreur(exc)
140
+
141
+ if info.etat == LIBRE:
142
+ terminal.afficher_erreur(f"Aucun processus trouvé sur le port {info.port}.")
143
+ raise typer.Exit(code=1)
144
+
145
+ mapping: dict[int, dict] = {}
146
+ if info.processus is None:
147
+ if not sudo:
148
+ if privileges.sudo_disponible():
149
+ terminal.afficher_information(
150
+ f"Astuce : `youcaport free --sudo {info.port}` arrête aussi "
151
+ "les processus protégés (root/docker)."
152
+ )
153
+ _sortie_erreur(
154
+ PortSansProcessusError(
155
+ f"Le port {info.port} est occupé mais aucun processus n'y est associé."
156
+ )
157
+ )
158
+ try:
159
+ mapping = privileges.connexions_privilegiees(interactif=True)
160
+ except privileges.SudoNonDisponibleError as exc:
161
+ _sortie_erreur(exc)
162
+ donnees = mapping.get(info.port)
163
+ if donnees is None:
164
+ _sortie_erreur(
165
+ PortSansProcessusError(
166
+ f"Le port {info.port} est occupé mais aucun processus n'y est associé."
167
+ )
168
+ )
169
+ info = InfoPort(
170
+ port=info.port,
171
+ processus=Processus(
172
+ pid=donnees["pid"],
173
+ nom=donnees["nom"],
174
+ executable="",
175
+ commande=donnees["nom"],
176
+ etat=donnees["etat"],
177
+ ),
178
+ etat=OCCUPE,
179
+ )
180
+
181
+ terminal.afficher_resume_processus(info)
182
+ if mapping:
183
+ terminal.afficher_information(
184
+ "Ce processus est protégé (root/docker) : il sera arrêté via sudo."
185
+ )
186
+ _prevenir_arret_immediat()
187
+ if not terminal.demander_confirmation():
188
+ terminal.afficher_information("Arrêt annulé, aucun processus arrêté.")
189
+ return
190
+
191
+ terminal.afficher_information("Arrêt du processus...")
192
+ try:
193
+ if mapping:
194
+ port_manager.liberer_port_privilegie(info.port, mapping)
195
+ else:
196
+ port_manager.liberer_port(info.port)
197
+ except Exception as exc:
198
+ _sortie_erreur(exc)
199
+
200
+ terminal.afficher_succes("Processus arrêté.")
201
+ terminal.afficher_succes(f"Port {info.port} libéré.")
202
+
203
+
204
+ def _prevenir_arret_immediat() -> None:
205
+ """Prévient que l'arrêt est immédiat sur Windows (pas de fermeture gracieuse)."""
206
+ if not process_manager.ARRET_DOUX:
207
+ terminal.afficher_information(
208
+ "Remarque : sur Windows, l'arrêt du processus est immédiat "
209
+ "(aucune fermeture gracieuse n'est possible)."
210
+ )
211
+
212
+
213
+ def _enrichir_sudo(infos: list[InfoPort]) -> list[InfoPort]:
214
+ """Tente d'enrichir les ports non identifiés avec les données privilégiées."""
215
+ try:
216
+ mapping = privileges.connexions_privilegiees(interactif=True)
217
+ except privileges.SudoNonDisponibleError as exc:
218
+ terminal.afficher_information(str(exc))
219
+ return infos
220
+ return port_manager.enrichir_privilegies(infos, mapping)
221
+
222
+
223
+ @app.command()
224
+ def suggest(
225
+ port: str,
226
+ count: int = typer.Option(
227
+ 3,
228
+ "--count",
229
+ min=1,
230
+ max=10,
231
+ help="Nombre de ports libres à suggérer (1-10).",
232
+ ),
233
+ ) -> None:
234
+ """Suggère des ports libres proches d'un port donné (Auto Port)."""
235
+ try:
236
+ candidats = suggester.suggerer_ports_libres(port, count)
237
+ except Exception as exc:
238
+ _sortie_erreur(exc)
239
+
240
+ if not candidats:
241
+ terminal.afficher_information("Aucun port libre trouvé après ce port.")
242
+ return
243
+
244
+ terminal.afficher_suggestions(candidats)
245
+
246
+
247
+ @app.command()
248
+ def project(
249
+ chemin: str = typer.Argument(".", help="Dossier du projet à analyser."),
250
+ ) -> None:
251
+ """Affiche les ports utilisés par un projet."""
252
+ try:
253
+ infos = project_manager.lister_ports_projet(chemin)
254
+ except Exception as exc:
255
+ _sortie_erreur(exc)
256
+
257
+ if not infos:
258
+ terminal.afficher_information("Aucun port en écoute détecté pour ce projet.")
259
+ return
260
+
261
+ terminal.afficher_ports(infos)
262
+
263
+
264
+ @app.command()
265
+ def dashboard(
266
+ port: int = typer.Option(
267
+ 8421,
268
+ "--port",
269
+ help="Port sur lequel écoute l'interface web locale.",
270
+ ),
271
+ no_browser: bool = typer.Option(
272
+ False,
273
+ "--no-browser",
274
+ help="N'ouvre pas automatiquement le navigateur.",
275
+ ),
276
+ sudo: bool = typer.Option(
277
+ False,
278
+ "--sudo",
279
+ help="Identifie les ports protégés (uniquement si sudo est déjà authentifié).",
280
+ ),
281
+ ) -> None:
282
+ """Démarre une interface web locale pour visualiser les ports."""
283
+ try:
284
+ valider_port(port)
285
+ module_dashboard.lancer_dashboard(port, ouvrir_navigateur=not no_browser, avec_sudo=sudo)
286
+ except Exception as exc:
287
+ _sortie_erreur(exc)
288
+
289
+
290
+ @app_docker.command("list")
291
+ def docker_liste() -> None:
292
+ """Liste les conteneurs Docker en cours d'exécution et leurs ports hôtes."""
293
+ try:
294
+ conteneurs = docker_manager.lister_conteneurs()
295
+ except Exception as exc:
296
+ _sortie_erreur(exc)
297
+
298
+ if not conteneurs:
299
+ terminal.afficher_information("Aucun conteneur Docker en cours d'exécution.")
300
+ return
301
+
302
+ terminal.afficher_conteneurs(conteneurs)
303
+
304
+
305
+ @app_docker.command("show")
306
+ def docker_affichage(port: str) -> None:
307
+ """Affiche le conteneur Docker qui publie un port hôte donné."""
308
+ try:
309
+ numero = valider_port(port)
310
+ conteneur = docker_manager.conteneur_pour_port(numero)
311
+ except Exception as exc:
312
+ _sortie_erreur(exc)
313
+
314
+ if conteneur is None:
315
+ _sortie_erreur(
316
+ docker_manager.ConteneurIntrouvableError(f"Aucun conteneur ne publie le port {numero}.")
317
+ )
318
+
319
+ terminal.afficher_conteneur(conteneur, numero)
320
+
321
+
322
+ @app_docker.command("stop")
323
+ def docker_stop(port: str) -> None:
324
+ """Arrête le conteneur Docker qui publie un port (confirmation obligatoire)."""
325
+ try:
326
+ numero = valider_port(port)
327
+ conteneur = docker_manager.conteneur_pour_port(numero)
328
+ except Exception as exc:
329
+ _sortie_erreur(exc)
330
+
331
+ if conteneur is None:
332
+ _sortie_erreur(
333
+ docker_manager.ConteneurIntrouvableError(f"Aucun conteneur ne publie le port {numero}.")
334
+ )
335
+
336
+ terminal.afficher_conteneur(conteneur, numero)
337
+ if not terminal.demander_confirmation(f"Arrêter le conteneur '{conteneur.nom}' ?"):
338
+ terminal.afficher_information("Arrêt annulé, aucun conteneur arrêté.")
339
+ return
340
+
341
+ terminal.afficher_information("Arrêt du conteneur...")
342
+ try:
343
+ docker_manager.arreter_conteneur(conteneur)
344
+ except Exception as exc:
345
+ _sortie_erreur(exc)
346
+
347
+ terminal.afficher_succes(f"Conteneur '{conteneur.nom}' arrêté.")
348
+
349
+
350
+ @app_profile.command("list")
351
+ def profile_liste() -> None:
352
+ """Liste les profils enregistrés."""
353
+ try:
354
+ profils = profiles.lister_profils()
355
+ except Exception as exc:
356
+ _sortie_erreur(exc)
357
+
358
+ if not profils:
359
+ terminal.afficher_information("Aucun profil enregistré.")
360
+ return
361
+
362
+ terminal.afficher_profils(profils)
363
+
364
+
365
+ @app_profile.command("add")
366
+ def profile_ajout(nom: str, port: str) -> None:
367
+ """Associe un port à un profil de projet."""
368
+ try:
369
+ profiles.ajouter_profil(nom, port)
370
+ profils = profiles.lister_profils()
371
+ except Exception as exc:
372
+ _sortie_erreur(exc)
373
+
374
+ terminal.afficher_succes(f"Profil '{nom}' : ports {', '.join(map(str, profils[nom]))}.")
375
+
376
+
377
+ @app_profile.command("show")
378
+ def profile_affichage(nom: str) -> None:
379
+ """Affiche l'état des ports d'un profil."""
380
+ try:
381
+ profils = profiles.lister_profils()
382
+ except Exception as exc:
383
+ _sortie_erreur(exc)
384
+
385
+ if nom not in profils:
386
+ _sortie_erreur(ProfilInexistantError(f"Le profil '{nom}' n'existe pas."))
387
+
388
+ try:
389
+ infos = [port_manager.verifier_port(numero) for numero in profils[nom]]
390
+ except Exception as exc:
391
+ _sortie_erreur(exc)
392
+
393
+ terminal.afficher_ports(infos)
394
+
395
+
396
+ @app_profile.command("remove")
397
+ def profile_suppression(nom: str) -> None:
398
+ """Supprime un profil complet."""
399
+ try:
400
+ profiles.supprimer_profil(nom)
401
+ except Exception as exc:
402
+ _sortie_erreur(exc)
403
+
404
+ terminal.afficher_succes(f"Profil '{nom}' supprimé.")
405
+
406
+
407
+ def _sortie_erreur(exc: Exception) -> NoReturn:
408
+ """Affiche une erreur compréhensible puis quitte proprement (code 1)."""
409
+ terminal.afficher_exception(exc)
410
+ code = 0 if isinstance(exc, ProcessusIntrouvableError) else 1
411
+ raise typer.Exit(code=code)
412
+
413
+
414
+ if __name__ == "__main__":
415
+ app()
@@ -0,0 +1,124 @@
1
+ """Gestion des conteneurs Docker qui publient des ports (V6).
2
+
3
+ Interagit avec le CLI `docker ps` / `docker stop` ; aucun accès direct à
4
+ l'API Docker ni à la socket.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+
13
+ from youcaport.core.validator import YoucaPortError
14
+
15
+ _FORMAT_PS = "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
16
+
17
+
18
+ class DockerNonDisponibleError(YoucaPortError):
19
+ """docker est introuvable ou n'a pas pu s'exécuter."""
20
+
21
+
22
+ class ConteneurIntrouvableError(YoucaPortError):
23
+ """Aucun conteneur ne publie le port demandé."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Conteneur:
28
+ """Description d'un conteneur Docker et de ses ports publics publiés."""
29
+
30
+ identifiant: str
31
+ nom: str
32
+ image: str
33
+ statut: str
34
+ ports: frozenset[int]
35
+
36
+
37
+ def docker_disponible() -> bool:
38
+ """Indique si le CLI docker est exécutable sur le système."""
39
+ return shutil.which("docker") is not None
40
+
41
+
42
+ def lister_conteneurs() -> list[Conteneur]:
43
+ """Liste les conteneurs en cours d'exécution et leurs ports publics.
44
+
45
+ Lève DockerNonDisponibleError si docker est absent ou a échoué.
46
+ """
47
+ if not docker_disponible():
48
+ raise DockerNonDisponibleError("Docker n'est pas installé ou absent du PATH.")
49
+
50
+ try:
51
+ resultat = subprocess.run(
52
+ ["docker", "ps", "--format", _FORMAT_PS],
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=15,
56
+ )
57
+ except subprocess.TimeoutExpired as exc:
58
+ raise DockerNonDisponibleError("Le délai de réponse de Docker est dépassé.") from exc
59
+
60
+ if resultat.returncode != 0:
61
+ message = (resultat.stderr or "Docker n'a pas pu être interrogé.").strip()
62
+ raise DockerNonDisponibleError(message)
63
+
64
+ return _parser_ps(resultat.stdout)
65
+
66
+
67
+ def conteneur_pour_port(port: int) -> Conteneur | None:
68
+ """Retourne le conteneur publiant un port hôte donné, sinon None."""
69
+ for conteneur in lister_conteneurs():
70
+ if port in conteneur.ports:
71
+ return conteneur
72
+ return None
73
+
74
+
75
+ def arreter_conteneur(conteneur: Conteneur) -> None:
76
+ """Arrête proprement un conteneur (`docker stop`).
77
+
78
+ Lève DockerNonDisponibleError en cas d'échec.
79
+ """
80
+ if not docker_disponible():
81
+ raise DockerNonDisponibleError("Docker n'est pas installé ou absent du PATH.")
82
+
83
+ resultat = subprocess.run(
84
+ ["docker", "stop", conteneur.nom],
85
+ capture_output=True,
86
+ text=True,
87
+ timeout=30,
88
+ )
89
+ if resultat.returncode != 0:
90
+ message = (resultat.stderr or "docker stop a échoué.").strip()
91
+ raise DockerNonDisponibleError(message)
92
+
93
+
94
+ def _parser_ps(texte: str) -> list[Conteneur]:
95
+ """Convertit la sortie `docker ps` (format tabulé) en Conteneur."""
96
+ conteneurs: list[Conteneur] = []
97
+ for ligne in texte.splitlines():
98
+ champs = ligne.split("\t")
99
+ if len(champs) != 5:
100
+ continue
101
+ identifiant, nom, image, statut, ports = champs
102
+ conteneurs.append(
103
+ Conteneur(
104
+ identifiant=identifiant,
105
+ nom=nom,
106
+ image=image,
107
+ statut=statut,
108
+ ports=_parser_ports(ports),
109
+ )
110
+ )
111
+ return conteneurs
112
+
113
+
114
+ def _parser_ports(texte: str) -> frozenset[int]:
115
+ """Extrait les ports publics publiés d'un champ « Ports » de `docker ps`."""
116
+ ports: set[int] = set()
117
+ for liaison in texte.split(","):
118
+ liaison = liaison.strip()
119
+ if "->" not in liaison:
120
+ continue
121
+ publique = liaison.split("->", 1)[0].rsplit(":", 1)[-1]
122
+ if publique.isdigit() and 1 <= int(publique) <= 65535:
123
+ ports.add(int(publique))
124
+ return frozenset(ports)
@@ -0,0 +1,129 @@
1
+ """Coordination de la gestion des ports et de leurs processus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from youcaport.core import privileges, process_manager
8
+ from youcaport.core.process_manager import Processus, ProcessusArretImpossibleError
9
+ from youcaport.core.validator import YoucaPortError, valider_port
10
+
11
+ OCCUPE = "OCCUPE"
12
+ LIBRE = "LIBRE"
13
+
14
+
15
+ class PortNonOccupeError(YoucaPortError):
16
+ """Le port demandé n'est utilisé par aucun processus."""
17
+
18
+
19
+ class PortSansProcessusError(YoucaPortError):
20
+ """Le port est occupé mais aucun processus n'a pu être identifié."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class InfoPort:
25
+ """État d'un port et, le cas échéant, le processus qui l'occupe."""
26
+
27
+ port: int
28
+ processus: Processus | None
29
+ etat: str
30
+
31
+
32
+ def lister_ports_utilises() -> list[InfoPort]:
33
+ """Liste les ports actuellement en écoute, triés par numéro.
34
+
35
+ Lève PermissionSystemeError si les connexions réseau sont inaccessibles.
36
+ """
37
+ infos: dict[tuple[int, int], InfoPort] = {}
38
+ for port, pid in process_manager.lister_connexions_ecoute():
39
+ processus = process_manager.construire_processus(pid) if pid != -1 else None
40
+ infos[(port, pid)] = InfoPort(port=port, processus=processus, etat=OCCUPE)
41
+
42
+ return [infos[cle] for cle in sorted(infos)]
43
+
44
+
45
+ def verifier_port(port: int | str) -> InfoPort:
46
+ """Retourne l'état courant d'un port et son processus éventuel."""
47
+ numero = valider_port(port)
48
+ processus = process_manager.rechercher_premier_processus(numero)
49
+
50
+ if processus is not None:
51
+ return InfoPort(port=numero, processus=processus, etat=OCCUPE)
52
+
53
+ if process_manager.port_en_ecoute(numero):
54
+ return InfoPort(port=numero, processus=None, etat=OCCUPE)
55
+
56
+ return InfoPort(port=numero, processus=None, etat=LIBRE)
57
+
58
+
59
+ def liberer_port(port: int | str) -> InfoPort:
60
+ """Arrête le processus occupant le port puis retourne le nouvel état.
61
+
62
+ Lève PortNonOccupeError si le port est déjà libre,
63
+ ProcessusIntrouvableError si le processus est déjà arrêté,
64
+ PermissionSystemeError ou ProcessusArretImpossibleError selon l'échec.
65
+ """
66
+ numero = valider_port(port)
67
+ processus = process_manager.rechercher_premier_processus(numero)
68
+
69
+ if processus is None:
70
+ if process_manager.port_en_ecoute(numero):
71
+ raise PortSansProcessusError(
72
+ f"Le port {numero} est occupé mais aucun processus n'y est associé."
73
+ )
74
+ raise PortNonOccupeError(f"Aucun processus trouvé sur le port {numero}.")
75
+
76
+ process_manager.arreter_processus(processus.pid)
77
+
78
+ apres = verifier_port(numero)
79
+ if apres.etat != LIBRE:
80
+ raise ProcessusArretImpossibleError(
81
+ f"Le processus a été arrêté mais le port {numero} est toujours utilisé."
82
+ )
83
+
84
+ return apres
85
+
86
+
87
+ def enrichir_privilegies(infos: list[InfoPort], mapping: dict[int, dict]) -> list[InfoPort]:
88
+ """Remplace les ports sans processus par les données privilégiées (sudo)."""
89
+ retour: list[InfoPort] = []
90
+ for info in infos:
91
+ donnees = mapping.get(info.port) if info.processus is None else None
92
+ if donnees is None:
93
+ retour.append(info)
94
+ continue
95
+
96
+ retour.append(
97
+ InfoPort(
98
+ port=info.port,
99
+ processus=Processus(
100
+ pid=donnees["pid"],
101
+ nom=donnees["nom"],
102
+ executable="",
103
+ commande=donnees["nom"],
104
+ etat=donnees["etat"],
105
+ ),
106
+ etat=info.etat,
107
+ )
108
+ )
109
+ return retour
110
+
111
+
112
+ def liberer_port_privilegie(port: int | str, mapping: dict[int, dict]) -> InfoPort:
113
+ """Libère via sudo un port protégé dont le processus vient du mapping privilégié."""
114
+ numero = valider_port(port)
115
+ donnees = mapping.get(numero)
116
+ if donnees is None:
117
+ raise PortSansProcessusError(
118
+ f"Le port {numero} est occupé mais aucun processus n'y est associé."
119
+ )
120
+
121
+ privileges.arreter_privilegie(donnees["pid"])
122
+
123
+ apres = verifier_port(numero)
124
+ if apres.etat != LIBRE:
125
+ raise ProcessusArretImpossibleError(
126
+ f"Le processus a été arrêté mais le port {numero} est toujours utilisé."
127
+ )
128
+
129
+ return apres