gyntoolkit 2.1.1__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.
gyntoolkit/recon.py ADDED
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env python3
2
+ """Recon passivo e ativo: WHOIS, DNS, geo, TLS, fingerprint, breaches, traceroute."""
3
+
4
+ import ipaddress
5
+ import re
6
+ import socket
7
+ import ssl
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+ import dns.resolver
12
+ import requests
13
+ import whois
14
+ from colorama import Fore, Style
15
+ from scapy.all import IP, TCP, sr1
16
+
17
+ from . import i18n
18
+ from .core import PROMPT, log, sanitize_input
19
+
20
+
21
+ def whois_lookup(domain: str):
22
+ """Consulta informações WHOIS"""
23
+ try:
24
+ domain = sanitize_input(domain, r"[A-Za-z0-9.-]")
25
+ return whois.whois(domain)
26
+ except Exception as e:
27
+ return i18n.t("recon.whois_err", err=e)
28
+
29
+ def dns_lookup(domain: str,
30
+ record_type: str = "A",
31
+ nameserver: str = None,
32
+ timeout: int = 5) -> list[str]:
33
+ """
34
+ Consulta registros DNS de um domínio.
35
+ :param domain: domínio a ser consultado
36
+ :param record_type: tipo de registro (A, AAAA, MX, TXT, CNAME, NS, SOA, etc.)
37
+ :param nameserver: servidor DNS (ex: "8.8.8.8"); None para usar o default
38
+ :param timeout: tempo máximo de espera, em segundos
39
+ :return: lista de strings com os dados retornados
40
+ """
41
+ resolver = dns.resolver.Resolver()
42
+ resolver.lifetime = timeout
43
+ if nameserver:
44
+ resolver.nameservers = [nameserver]
45
+ try:
46
+ answers = resolver.resolve(domain, record_type)
47
+ return [rdata.to_text() for rdata in answers]
48
+ except dns.resolver.NoAnswer:
49
+ return [i18n.t("dns.no_record", t=record_type, d=domain)]
50
+ except dns.resolver.NXDOMAIN:
51
+ return [i18n.t("dns.nxdomain", d=domain)]
52
+ except Exception as e:
53
+ return [i18n.t("dns.error", err=e)]
54
+
55
+ def geo_ip(target: str) -> dict[str, str]:
56
+ """Geolocalização de IP/host via ip-api.com (free tier, sem key)."""
57
+ try:
58
+ ip = socket.gethostbyname(target)
59
+ except socket.gaierror as e:
60
+ return {"erro": i18n.t("recon.resolve_fail", t=target, err=e)}
61
+
62
+ fields = "status,message,continent,country,regionName,city,zip,lat,lon,timezone,isp,org,as,reverse,mobile,proxy,hosting,query"
63
+ try:
64
+ response = requests.get(
65
+ f"http://ip-api.com/json/{ip}",
66
+ params={"fields": fields},
67
+ headers={"User-Agent": "gyntoolkit/2.0"},
68
+ timeout=10,
69
+ )
70
+ response.raise_for_status()
71
+ data = response.json()
72
+ if data.get("status") != "success":
73
+ return {"erro": data.get("message", i18n.t("recon.query_failed"))}
74
+ return data
75
+ except (requests.RequestException, ValueError) as e:
76
+ log.warning("geo_ip %s falhou: %s", target, e)
77
+ return {"erro": str(e)}
78
+
79
+
80
+ def reverse_dns(ip: str) -> dict[str, Any]:
81
+ """PTR lookup via socket + dnspython fallback."""
82
+ try:
83
+ ipaddress.ip_address(ip)
84
+ except ValueError:
85
+ try:
86
+ ip = socket.gethostbyname(ip)
87
+ except socket.gaierror as e:
88
+ return {"erro": i18n.t("recon.resolve_fail", t=ip, err=e)}
89
+ try:
90
+ hostname, aliases, addrs = socket.gethostbyaddr(ip)
91
+ return {"ip": ip, "hostname": hostname, "aliases": aliases, "addrs": addrs}
92
+ except socket.herror as e:
93
+ log.debug("reverse_dns %s falhou: %s", ip, e)
94
+ return {"ip": ip, "erro": i18n.t("recon.no_ptr", err=e)}
95
+
96
+
97
+ def subdomain_enum(domain: str, timeout: int = 30) -> list[str]:
98
+ """Enumeração de subdomínios via crt.sh Certificate Transparency."""
99
+ try:
100
+ response = requests.get(
101
+ f"https://crt.sh/?q=%.{domain}&output=json",
102
+ headers={"User-Agent": "gyntoolkit/2.0"},
103
+ timeout=timeout,
104
+ )
105
+ response.raise_for_status()
106
+ entries = response.json()
107
+ except (requests.RequestException, ValueError) as e:
108
+ log.warning("crt.sh falhou para %s: %s", domain, e)
109
+ return []
110
+
111
+ subs = set()
112
+ for entry in entries:
113
+ name_value = entry.get("name_value", "")
114
+ for name in name_value.split("\n"):
115
+ name = name.strip().lstrip("*.").lower()
116
+ if name and name.endswith(domain.lower()):
117
+ subs.add(name)
118
+ return sorted(subs)
119
+
120
+
121
+ def _parse_cert_der(der: bytes) -> dict[str, Any]:
122
+ """Parse cert DER usando cryptography (parseamento robusto sem depender de validação TLS)."""
123
+ from cryptography import x509
124
+ from cryptography.hazmat.primitives import hashes as crypto_hashes
125
+
126
+ cert = x509.load_der_x509_certificate(der)
127
+
128
+ def _name_attr(name_obj, oid):
129
+ try:
130
+ attrs = name_obj.get_attributes_for_oid(oid)
131
+ return attrs[0].value if attrs else None
132
+ except Exception:
133
+ return None
134
+
135
+ subject_cn = _name_attr(cert.subject, x509.NameOID.COMMON_NAME)
136
+ subject_org = _name_attr(cert.subject, x509.NameOID.ORGANIZATION_NAME)
137
+ issuer_cn = _name_attr(cert.issuer, x509.NameOID.COMMON_NAME)
138
+
139
+ sans: list[str] = []
140
+ try:
141
+ san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
142
+ sans = [str(n.value) for n in san_ext.value]
143
+ except x509.ExtensionNotFound:
144
+ pass
145
+
146
+ not_before = cert.not_valid_before_utc
147
+ not_after = cert.not_valid_after_utc
148
+ days_left = (not_after - datetime.now(timezone.utc)).days
149
+
150
+ fingerprint = cert.fingerprint(crypto_hashes.SHA256()).hex()
151
+
152
+ return {
153
+ "subject_cn": subject_cn,
154
+ "issuer_cn": issuer_cn,
155
+ "org": subject_org,
156
+ "not_before": not_before.strftime("%Y-%m-%d %H:%M:%S UTC"),
157
+ "not_after": not_after.strftime("%Y-%m-%d %H:%M:%S UTC"),
158
+ "dias_para_expirar": days_left,
159
+ "sans": sans,
160
+ "sha256": fingerprint,
161
+ "serial": format(cert.serial_number, "x"),
162
+ "signature_algo": cert.signature_algorithm_oid._name,
163
+ }
164
+
165
+
166
+ def ssl_inspect(host: str, port: int = 443, timeout: int = 8) -> dict[str, Any]:
167
+ """Inspeciona certificado SSL/TLS do alvo (aceita cert inválido, apenas inspeciona)."""
168
+ ctx = ssl.create_default_context()
169
+ ctx.check_hostname = False
170
+ ctx.verify_mode = ssl.CERT_NONE
171
+ try:
172
+ with (
173
+ socket.create_connection((host, port), timeout=timeout) as sock,
174
+ ctx.wrap_socket(sock, server_hostname=host) as tls,
175
+ ):
176
+ der = tls.getpeercert(binary_form=True)
177
+ cipher = tls.cipher()
178
+ version = tls.version()
179
+ except (TimeoutError, socket.gaierror, ConnectionRefusedError, OSError, ssl.SSLError) as e:
180
+ return {"erro": i18n.t("recon.ssl_fail", h=host, p=port, err=e)}
181
+
182
+ if not der:
183
+ return {"erro": i18n.t("recon.no_cert")}
184
+
185
+ try:
186
+ parsed = _parse_cert_der(der)
187
+ except Exception as e:
188
+ log.warning("Falha ao parsear cert DER: %s", e)
189
+ return {"erro": i18n.t("recon.cert_parse_fail", err=e)}
190
+
191
+ parsed.update({
192
+ "host": host,
193
+ "porta": port,
194
+ "tls_version": version,
195
+ "cipher": cipher[0] if cipher else None,
196
+ })
197
+ return parsed
198
+
199
+
200
+ TECH_SIGNATURES = [
201
+ ("nginx", r"nginx"),
202
+ ("Apache", r"Apache"),
203
+ ("IIS", r"Microsoft-IIS"),
204
+ ("Cloudflare", r"cloudflare"),
205
+ ("PHP", r"PHP/"),
206
+ ("Express", r"Express"),
207
+ ("Django", r"csrftoken|Django"),
208
+ ("Rails", r"_rails_|Phusion"),
209
+ ("WordPress", r"wp-content|wp-includes"),
210
+ ("Laravel", r"laravel_session"),
211
+ ("ASP.NET", r"ASP\.NET|AspNet"),
212
+ ("Node.js", r"X-Powered-By.*Express|Node"),
213
+ ]
214
+
215
+
216
+ def http_fingerprint(url: str, timeout: int = 10) -> dict[str, Any]:
217
+ """Detecta tecnologias via headers HTTP + cookies + body snippet."""
218
+ try:
219
+ response = requests.get(
220
+ url,
221
+ headers={"User-Agent": "Mozilla/5.0 gyntoolkit/2.0"},
222
+ timeout=timeout,
223
+ allow_redirects=True,
224
+ verify=False,
225
+ )
226
+ except requests.RequestException as e:
227
+ return {"erro": i18n.t("recon.http_fail", err=e)}
228
+
229
+ headers = dict(response.headers)
230
+ body_snippet = response.text[:8192]
231
+ haystack = "\n".join([f"{k}: {v}" for k, v in headers.items()]) + "\n" + body_snippet
232
+
233
+ techs = [name for name, pattern in TECH_SIGNATURES if re.search(pattern, haystack, re.IGNORECASE)]
234
+
235
+ return {
236
+ "url": response.url,
237
+ "status": response.status_code,
238
+ "redirects": len(response.history),
239
+ "server": headers.get("Server"),
240
+ "x_powered_by": headers.get("X-Powered-By"),
241
+ "content_type": headers.get("Content-Type"),
242
+ "set_cookie": headers.get("Set-Cookie"),
243
+ "hsts": headers.get("Strict-Transport-Security"),
244
+ "csp": headers.get("Content-Security-Policy"),
245
+ "xfo": headers.get("X-Frame-Options"),
246
+ "tech": techs,
247
+ }
248
+
249
+
250
+ def internetdb_lookup(ip: str, timeout: int = 10) -> dict[str, Any]:
251
+ """Consulta InternetDB (Shodan free tier, sem key)."""
252
+ try:
253
+ socket.inet_aton(ip)
254
+ except OSError:
255
+ try:
256
+ ip = socket.gethostbyname(ip)
257
+ except socket.gaierror as e:
258
+ return {"erro": i18n.t("recon.resolve_fail_simple", err=e)}
259
+ try:
260
+ response = requests.get(
261
+ f"https://internetdb.shodan.io/{ip}",
262
+ headers={"User-Agent": "gyntoolkit/2.0"},
263
+ timeout=timeout,
264
+ )
265
+ if response.status_code == 404:
266
+ return {"ip": ip, "info": i18n.t("recon.internetdb_nodata")}
267
+ response.raise_for_status()
268
+ return response.json()
269
+ except (requests.RequestException, ValueError) as e:
270
+ log.warning("InternetDB %s falhou: %s", ip, e)
271
+ return {"erro": str(e)}
272
+
273
+
274
+ def hibp_breaches(domain: str, timeout: int = 10) -> list[dict[str, Any]]:
275
+ """Consulta HIBP breaches por domínio (endpoint público)."""
276
+ try:
277
+ response = requests.get(
278
+ "https://haveibeenpwned.com/api/v3/breaches",
279
+ params={"domain": domain},
280
+ headers={"User-Agent": "gyntoolkit-recon"},
281
+ timeout=timeout,
282
+ )
283
+ if response.status_code == 404:
284
+ return []
285
+ response.raise_for_status()
286
+ return response.json()
287
+ except (requests.RequestException, ValueError) as e:
288
+ log.warning("HIBP falhou para %s: %s", domain, e)
289
+ return [{"erro": str(e)}]
290
+
291
+
292
+ def mac_vendor(mac: str, timeout: int = 6) -> str:
293
+ """Lookup fabricante via api.macvendors.com (free, no key)."""
294
+ mac = mac.strip().replace("-", ":").upper()
295
+ if not re.fullmatch(r"([0-9A-F]{2}:){5}[0-9A-F]{2}", mac):
296
+ return i18n.t("recon.mac_invalid", mac=mac)
297
+ try:
298
+ response = requests.get(f"https://api.macvendors.com/{mac}", timeout=timeout)
299
+ if response.status_code == 200:
300
+ return response.text.strip()
301
+ return i18n.t("recon.not_found_http", code=response.status_code)
302
+ except requests.RequestException as e:
303
+ return i18n.t("recon.error", err=e)
304
+
305
+
306
+ def traceroute(target: str, max_hops: int = 20, timeout: int = 3, dport: int = 80) -> list[dict[str, Any]]:
307
+ """Traceroute TCP via scapy (requer privilégio)."""
308
+ try:
309
+ target_ip = socket.gethostbyname(target)
310
+ except socket.gaierror as e:
311
+ return [{"erro": i18n.t("recon.resolve_fail_simple", err=e)}]
312
+
313
+ hops = []
314
+ for ttl in range(1, max_hops + 1):
315
+ pkt = IP(dst=target_ip, ttl=ttl) / TCP(dport=dport, flags="S")
316
+ start = datetime.now(timezone.utc)
317
+ try:
318
+ reply = sr1(pkt, timeout=timeout, verbose=0)
319
+ except PermissionError:
320
+ return [{"erro": i18n.t("recon.traceroute_priv")}]
321
+ except OSError as e:
322
+ return [{"erro": i18n.t("recon.scapy_err", err=e)}]
323
+ rtt_ms = (datetime.now(timezone.utc) - start).total_seconds() * 1000
324
+
325
+ if reply is None:
326
+ hops.append({"ttl": ttl, "ip": "*", "rtt_ms": None})
327
+ continue
328
+
329
+ hops.append({"ttl": ttl, "ip": reply.src, "rtt_ms": round(rtt_ms, 2)})
330
+ if reply.haslayer(TCP) and reply.getlayer(TCP).flags & 0x12:
331
+ break
332
+ if reply.haslayer("ICMP") and reply.getlayer("ICMP").type == 3:
333
+ break
334
+ return hops
335
+
336
+
337
+ # Tipos DNS oferecidos no menu (ordem fixa). As descrições são localizadas
338
+ # em runtime via i18n.t('dns.desc.<tipo>'), não armazenadas aqui.
339
+ DNS_TYPES = ["A", "AAAA", "MX", "NS", "CNAME", "TXT", "SOA"]
340
+
341
+ def escolher_tipo_dns() -> str:
342
+ """Mostra menu e retorna o tipo DNS escolhido"""
343
+ print(f"\n{Fore.CYAN}{i18n.t('dns.select')}{Style.RESET_ALL}")
344
+ for idx, tipo in enumerate(DNS_TYPES, 1):
345
+ print(f" {Fore.YELLOW}[{idx}]{Style.RESET_ALL} {tipo} → {i18n.t(f'dns.desc.{tipo}')}")
346
+ print(f" {Fore.YELLOW}[0]{Style.RESET_ALL} {i18n.t('dns.back')}")
347
+
348
+ while True:
349
+ try:
350
+ choice = int(input(f"\n{PROMPT}"))
351
+ if choice == 0:
352
+ return None
353
+ elif 1 <= choice <= len(DNS_TYPES):
354
+ return DNS_TYPES[choice - 1]
355
+ else:
356
+ raise ValueError
357
+ except ValueError:
358
+ print(f"{Fore.RED}{i18n.t('dns.invalid')}{Style.RESET_ALL}")
gyntoolkit/scan.py ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """Varredura avançada: port scan (SYN/connect), banner, CVE lookup, host discovery."""
3
+
4
+ import asyncio
5
+ import contextlib
6
+ import ipaddress
7
+ import os
8
+
9
+ import requests
10
+ from colorama import Fore, Style
11
+ from scapy.all import ARP, IP, TCP, Ether, sr1, srp
12
+
13
+ from . import i18n
14
+ from .core import TOP_PORTS, is_admin_windows, log
15
+
16
+
17
+ async def syn_scan(target: str, port: int) -> tuple[int, bool]:
18
+ """Varredura stealth SYN (requer admin/root).
19
+
20
+ Se o scapy não conseguir enviar (sem Npcap/interface indisponível), degrada
21
+ para ``connect_scan`` em vez de derrubar toda a varredura.
22
+ """
23
+ try:
24
+ pkt = IP(dst=target)/TCP(dport=port, flags="S")
25
+ response = sr1(pkt, timeout=2, verbose=0)
26
+ if response is None:
27
+ return (port, False)
28
+ return (port, response.haslayer(TCP) and response.getlayer(TCP).flags == 0x12)
29
+ except (OSError, PermissionError) as e:
30
+ log.warning("syn_scan %s:%s falhou: %s", target, port, e)
31
+ return (port, False)
32
+ except Exception as e:
33
+ # scapy indisponível (ex.: interface/Npcap ausente): usa connect scan.
34
+ log.warning("syn_scan indisponível em %s:%s (%s); fallback connect_scan", target, port, e)
35
+ return await connect_scan(target, port)
36
+
37
+ async def connect_scan(target: str, port: int) -> tuple[int, bool]:
38
+ """Varredura TCP completa"""
39
+ try:
40
+ reader, writer = await asyncio.wait_for(
41
+ asyncio.open_connection(target, port),
42
+ timeout=2
43
+ )
44
+ writer.close()
45
+ await writer.wait_closed()
46
+ return (port, True)
47
+ except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
48
+ return (port, False)
49
+
50
+ async def get_banner(target: str, port: int) -> tuple[int, str]:
51
+ """Obtém banner do serviço"""
52
+ try:
53
+ reader, writer = await asyncio.wait_for(
54
+ asyncio.open_connection(target, port),
55
+ timeout=3
56
+ )
57
+ writer.write(b"GET / HTTP/1.1\r\n\r\n")
58
+ banner = await asyncio.wait_for(reader.read(512), timeout=2)
59
+ writer.close()
60
+ with contextlib.suppress(OSError):
61
+ await writer.wait_closed()
62
+ return (port, banner.decode(errors='ignore').strip())
63
+ except (asyncio.TimeoutError, ConnectionRefusedError, OSError) as e:
64
+ log.debug("banner %s:%s vazio: %s", target, port, e)
65
+ return (port, "")
66
+
67
+ def check_vulnerabilities(service: str) -> list[str]:
68
+ """Consulta NVD API 2.0 por CVEs associados ao serviço."""
69
+ if not service or service.lower() in {"desconhecido", "unknown"}:
70
+ return []
71
+ try:
72
+ response = requests.get(
73
+ "https://services.nvd.nist.gov/rest/json/cves/2.0",
74
+ params={"keywordSearch": service, "resultsPerPage": 5},
75
+ headers={"User-Agent": "gyntoolkit/2.0"},
76
+ timeout=15,
77
+ )
78
+ response.raise_for_status()
79
+ data = response.json()
80
+ return [item["cve"]["id"] for item in data.get("vulnerabilities", [])[:5]]
81
+ except (requests.RequestException, ValueError, KeyError) as e:
82
+ log.warning("NVD lookup falhou para '%s': %s", service, e)
83
+ return []
84
+
85
+ def network_discovery(cidr: str, timeout: int = 2) -> list[str]:
86
+ """Descobre hosts ativos em rede via ARP scan (requer privilégio)."""
87
+ try:
88
+ ipaddress.ip_network(cidr, strict=False)
89
+ except ValueError as e:
90
+ log.error("CIDR inválido '%s': %s", cidr, e)
91
+ print(f"{Fore.RED}{i18n.t('scan.cidr_invalid', err=e)}{Style.RESET_ALL}")
92
+ return []
93
+
94
+ try:
95
+ arp = ARP(pdst=cidr)
96
+ ether = Ether(dst="ff:ff:ff:ff:ff:ff")
97
+ answered, _ = srp(ether / arp, timeout=timeout, verbose=0)
98
+ hosts = sorted({r.psrc for _, r in answered}, key=lambda ip: ipaddress.ip_address(ip))
99
+ return hosts
100
+ except PermissionError:
101
+ print(f"{Fore.RED}{i18n.t('scan.arp_priv')}{Style.RESET_ALL}")
102
+ return []
103
+ except OSError as e:
104
+ log.error("ARP scan falhou: %s", e)
105
+ print(f"{Fore.RED}{i18n.t('scan.arp_fail', err=e)}{Style.RESET_ALL}")
106
+ return []
107
+
108
+ async def perform_scan(target: str, scan_type: str) -> dict[int, dict]:
109
+ """Executa varredura completa com análise de vulnerabilidades"""
110
+ # aceita canônico "fast" e legados pt ("rápido"/"rapido"); resto = full range
111
+ ports = TOP_PORTS if scan_type in ("fast", "rápido", "rapido") else range(1, 65536)
112
+ open_ports = []
113
+ results = {}
114
+
115
+ # Fase 1: Varredura de portas.
116
+ # Privilégio é constante durante o scan: decide SYN vs connect uma vez.
117
+ use_syn = os.geteuid() == 0 if os.name == 'posix' else is_admin_windows()
118
+ tasks = [
119
+ syn_scan(target, port) if use_syn else connect_scan(target, port)
120
+ for port in ports
121
+ ]
122
+
123
+ # Processar resultados
124
+ for future in asyncio.as_completed(tasks):
125
+ port, is_open = await future
126
+ if is_open:
127
+ open_ports.append(port)
128
+
129
+ # Fase 2: Obter banners
130
+ banner_tasks = [get_banner(target, port) for port in open_ports]
131
+ banners = {}
132
+ for future in asyncio.as_completed(banner_tasks):
133
+ port, banner = await future
134
+ banners[port] = banner
135
+
136
+ # Fase 3: Analisar vulnerabilidades
137
+ for port in open_ports:
138
+ service = banners[port].split()[0] if banners[port] else "unknown"
139
+ vulns = check_vulnerabilities(service)
140
+
141
+ # 'service' e 'risco' são tokens neutros (DATA); o display os localiza
142
+ # via i18n (scan.service_unknown / scan.risk_high / scan.risk_low).
143
+ results[port] = {
144
+ 'service': service,
145
+ 'banner': banners[port],
146
+ 'vulnerabilidades': vulns,
147
+ 'risco': "high" if vulns else "low"
148
+ }
149
+
150
+ return results
gyntoolkit/ui.py ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """Camada de apresentação com ``rich`` (tabelas, painéis, spinners).
3
+
4
+ Degrada graciosamente para ``colorama`` puro se ``rich`` não estiver instalado,
5
+ então a CLI funciona igual sem a dependência.
6
+ """
7
+
8
+ from collections.abc import Iterable, Sequence
9
+ from contextlib import contextmanager
10
+ from typing import Any
11
+
12
+ from colorama import Fore, Style
13
+
14
+ try:
15
+ from rich import box
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ RICH = True
20
+ console = Console()
21
+ except ImportError: # pragma: no cover - fallback sem rich
22
+ RICH = False
23
+ console = None
24
+
25
+
26
+ def _fmt(value: Any) -> str:
27
+ if value is None:
28
+ return "—"
29
+ if isinstance(value, (list, tuple)):
30
+ return ", ".join(str(v) for v in value) if value else "—"
31
+ return str(value)
32
+
33
+
34
+ def success(msg: str) -> None:
35
+ if RICH:
36
+ console.print(f"[bold green]{msg}[/]")
37
+ else:
38
+ print(f"{Fore.GREEN}{msg}{Style.RESET_ALL}")
39
+
40
+
41
+ def error(msg: str) -> None:
42
+ if RICH:
43
+ console.print(f"[bold red]{msg}[/]")
44
+ else:
45
+ print(f"{Fore.RED}{msg}{Style.RESET_ALL}")
46
+
47
+
48
+ def notice(msg: str) -> None:
49
+ if RICH:
50
+ console.print(f"[cyan]{msg}[/]")
51
+ else:
52
+ print(f"{Fore.CYAN}{msg}{Style.RESET_ALL}")
53
+
54
+
55
+ def print_kv(
56
+ title: str,
57
+ mapping: dict[str, Any],
58
+ warn_keys: dict[str, Any] | None = None,
59
+ ) -> None:
60
+ """Renderiza um mapa chave/valor. ``warn_keys`` marca chaves cujo valor,
61
+ se satisfizer o predicado, deve aparecer em vermelho.
62
+
63
+ ``warn_keys`` mapeia chave -> callable(value) -> bool.
64
+ """
65
+ warn_keys = warn_keys or {}
66
+ if RICH:
67
+ table = Table(title=title, box=box.SIMPLE_HEAVY, title_style="bold green",
68
+ show_header=False, expand=False)
69
+ table.add_column("campo", style="yellow", no_wrap=True)
70
+ table.add_column("valor", style="white")
71
+ for k, v in mapping.items():
72
+ predicate = warn_keys.get(k)
73
+ style = "bold red" if predicate and predicate(v) else "white"
74
+ table.add_row(str(k), f"[{style}]{_fmt(v)}[/]")
75
+ console.print(table)
76
+ else:
77
+ print(f"\n{Fore.GREEN}{title}{Style.RESET_ALL}")
78
+ for k, v in mapping.items():
79
+ predicate = warn_keys.get(k)
80
+ color = Fore.RED if predicate and predicate(v) else Fore.YELLOW
81
+ print(f" {color}{k}:{Style.RESET_ALL} {_fmt(v)}")
82
+
83
+
84
+ def print_table(
85
+ title: str,
86
+ columns: Sequence[str],
87
+ rows: Iterable[Sequence[Any]],
88
+ row_styles: list[str | None] | None = None,
89
+ ) -> None:
90
+ """Renderiza uma tabela. ``row_styles`` opcional colore cada linha (rich)."""
91
+ rows = list(rows)
92
+ if RICH:
93
+ table = Table(title=title, box=box.ROUNDED, title_style="bold green",
94
+ header_style="bold yellow")
95
+ for col in columns:
96
+ table.add_column(str(col))
97
+ for idx, row in enumerate(rows):
98
+ style = row_styles[idx] if row_styles and idx < len(row_styles) else None
99
+ table.add_row(*[_fmt(c) for c in row], style=style)
100
+ console.print(table)
101
+ else:
102
+ print(f"\n{Fore.GREEN}{title}{Style.RESET_ALL}")
103
+ print(f" {Fore.YELLOW}{' '.join(str(c) for c in columns)}{Style.RESET_ALL}")
104
+ for row in rows:
105
+ print(" " + " ".join(_fmt(c) for c in row))
106
+
107
+
108
+ def print_list(title: str, items: Sequence[Any]) -> None:
109
+ if RICH:
110
+ console.print(f"[bold green]{title}[/]")
111
+ for it in items:
112
+ console.print(f" • {_fmt(it)}")
113
+ else:
114
+ print(f"\n{Fore.GREEN}{title}{Style.RESET_ALL}")
115
+ for it in items:
116
+ print(f" - {_fmt(it)}")
117
+
118
+
119
+ @contextmanager
120
+ def status(message: str):
121
+ """Spinner enquanto um bloco roda (rich). Fallback imprime a mensagem."""
122
+ if RICH:
123
+ with console.status(f"[cyan]{message}[/]", spinner="dots"):
124
+ yield
125
+ else:
126
+ print(f"{Fore.CYAN}{message}{Style.RESET_ALL}")
127
+ yield
128
+
129
+
130
+ def kv_pairs(mapping: dict[str, Any], labels: dict[str, str]) -> list[tuple[str, Any]]:
131
+ """Filtra/relabela um dict preservando a ordem de ``labels``."""
132
+ return [(labels[k], mapping[k]) for k in labels if k in mapping]