pentool 1.0.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.
Files changed (169) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +17 -0
  3. pentool/api/__init__.py +45 -0
  4. pentool/api/base_api.py +52 -0
  5. pentool/api/comparer_api.py +23 -0
  6. pentool/api/decoder_api.py +28 -0
  7. pentool/api/intruder_api.py +209 -0
  8. pentool/api/proxy_api.py +319 -0
  9. pentool/api/repeater_api.py +118 -0
  10. pentool/api/scanner_api.py +336 -0
  11. pentool/api/sequencer_api.py +21 -0
  12. pentool/api/spider_api.py +126 -0
  13. pentool/api/target_api.py +121 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +100 -0
  16. pentool/cli/project.py +90 -0
  17. pentool/cli/proxy.py +185 -0
  18. pentool/cli/scan.py +118 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +184 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +232 -0
  24. pentool/core/events.py +164 -0
  25. pentool/core/features.py +410 -0
  26. pentool/core/license.py +294 -0
  27. pentool/core/logging.py +59 -0
  28. pentool/core/plugin_manager.py +332 -0
  29. pentool/core/project.py +37 -0
  30. pentool/core/storage_interface.py +311 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +61 -0
  33. pentool/modules/__init__.py +1 -0
  34. pentool/modules/comparer.py +177 -0
  35. pentool/modules/decoder.py +281 -0
  36. pentool/modules/intruder.py +432 -0
  37. pentool/modules/intruder_turbo.py +190 -0
  38. pentool/modules/match_replace.py +146 -0
  39. pentool/modules/proxy.py +803 -0
  40. pentool/modules/repeater.py +237 -0
  41. pentool/modules/scanner/__init__.py +7 -0
  42. pentool/modules/scanner/ai_analyzer.py +230 -0
  43. pentool/modules/scanner/base.py +173 -0
  44. pentool/modules/scanner/baseline.py +101 -0
  45. pentool/modules/scanner/checks/__init__.py +47 -0
  46. pentool/modules/scanner/checks/broken_auth.py +231 -0
  47. pentool/modules/scanner/checks/cors.py +209 -0
  48. pentool/modules/scanner/checks/dom_xss.py +114 -0
  49. pentool/modules/scanner/checks/graphql.py +110 -0
  50. pentool/modules/scanner/checks/header_injection.py +262 -0
  51. pentool/modules/scanner/checks/headers.py +75 -0
  52. pentool/modules/scanner/checks/helpers.py +358 -0
  53. pentool/modules/scanner/checks/info_leak.py +68 -0
  54. pentool/modules/scanner/checks/jwt_none.py +238 -0
  55. pentool/modules/scanner/checks/lfi.py +186 -0
  56. pentool/modules/scanner/checks/nosql_injection.py +92 -0
  57. pentool/modules/scanner/checks/oauth.py +123 -0
  58. pentool/modules/scanner/checks/open_redirect.py +168 -0
  59. pentool/modules/scanner/checks/path_traversal.py +229 -0
  60. pentool/modules/scanner/checks/prototype_pollution.py +88 -0
  61. pentool/modules/scanner/checks/rce.py +338 -0
  62. pentool/modules/scanner/checks/sensitive_data.py +74 -0
  63. pentool/modules/scanner/checks/sqli.py +546 -0
  64. pentool/modules/scanner/checks/ssrf.py +273 -0
  65. pentool/modules/scanner/checks/ssti.py +353 -0
  66. pentool/modules/scanner/checks/xss.py +622 -0
  67. pentool/modules/scanner/checks/xxe.py +240 -0
  68. pentool/modules/scanner/engine.py +407 -0
  69. pentool/modules/scanner/fingerprint.py +169 -0
  70. pentool/modules/scanner/helpers.py +94 -0
  71. pentool/modules/scanner/mutator.py +378 -0
  72. pentool/modules/scanner/oob.py +104 -0
  73. pentool/modules/scanner/passive.py +114 -0
  74. pentool/modules/scanner/payloads.py +224 -0
  75. pentool/modules/scanner/report.py +117 -0
  76. pentool/modules/sequencer.py +500 -0
  77. pentool/modules/spider.py +702 -0
  78. pentool/modules/target.py +190 -0
  79. pentool/modules/websocket_handler.py +259 -0
  80. pentool/plugins/__init__.py +7 -0
  81. pentool/plugins/builtin/__init__.py +1 -0
  82. pentool/plugins/builtin/payloads_pro.py +404 -0
  83. pentool/plugins/builtin/reports_pro.py +450 -0
  84. pentool/plugins/builtin/scanner_pro.py +308 -0
  85. pentool/plugins/example_plugin.py +75 -0
  86. pentool/project_to_txt.py +67 -0
  87. pentool/services/__init__.py +1 -0
  88. pentool/services/base_service.py +64 -0
  89. pentool/services/intruder_service.py +108 -0
  90. pentool/services/proxy_service.py +232 -0
  91. pentool/services/repeater_service.py +111 -0
  92. pentool/services/scan_service.py +340 -0
  93. pentool/storage/__init__.py +0 -0
  94. pentool/storage/http_storage.py +522 -0
  95. pentool/storage/large_body_handler.py +54 -0
  96. pentool/storage/lru_cache.py +49 -0
  97. pentool/t.py +213 -0
  98. pentool/tui/__init__.py +1 -0
  99. pentool/tui/app.py +1438 -0
  100. pentool/tui/constants.py +79 -0
  101. pentool/tui/dialogs/__init__.py +0 -0
  102. pentool/tui/dialogs/cert_dialog.py +81 -0
  103. pentool/tui/dialogs/file_selector.py +227 -0
  104. pentool/tui/dialogs/load_from_proxy.py +125 -0
  105. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  106. pentool/tui/dialogs/project_dialog.py +82 -0
  107. pentool/tui/dialogs/scope_dialog.py +225 -0
  108. pentool/tui/messages.py +121 -0
  109. pentool/tui/mixins/__init__.py +0 -0
  110. pentool/tui/mixins/app_mixin.py +61 -0
  111. pentool/tui/mixins/dialog_cancel.py +25 -0
  112. pentool/tui/mixins/request_context_menu.py +285 -0
  113. pentool/tui/mixins/tab_rename.py +101 -0
  114. pentool/tui/screens/__init__.py +31 -0
  115. pentool/tui/screens/comparer/__init__.py +3 -0
  116. pentool/tui/screens/comparer/screen.py +233 -0
  117. pentool/tui/screens/dashboard/__init__.py +2 -0
  118. pentool/tui/screens/dashboard/live_dashboard.py +741 -0
  119. pentool/tui/screens/dashboard/screen.py +820 -0
  120. pentool/tui/screens/decoder/__init__.py +3 -0
  121. pentool/tui/screens/decoder/screen.py +311 -0
  122. pentool/tui/screens/extensions/__init__.py +3 -0
  123. pentool/tui/screens/extensions/screen.py +24 -0
  124. pentool/tui/screens/intruder/__init__.py +3 -0
  125. pentool/tui/screens/intruder/screen.py +1362 -0
  126. pentool/tui/screens/proxy/__init__.py +3 -0
  127. pentool/tui/screens/proxy/screen.py +1664 -0
  128. pentool/tui/screens/repeater/__init__.py +3 -0
  129. pentool/tui/screens/repeater/screen.py +646 -0
  130. pentool/tui/screens/scanner/__init__.py +3 -0
  131. pentool/tui/screens/scanner/screen.py +1984 -0
  132. pentool/tui/screens/sequencer/__init__.py +3 -0
  133. pentool/tui/screens/sequencer/screen.py +520 -0
  134. pentool/tui/screens/settings/__init__.py +5 -0
  135. pentool/tui/screens/settings/hotkeys.py +134 -0
  136. pentool/tui/screens/settings/screen.py +543 -0
  137. pentool/tui/screens/spider/__init__.py +3 -0
  138. pentool/tui/screens/spider/screen.py +385 -0
  139. pentool/tui/screens/target/__init__.py +4 -0
  140. pentool/tui/screens/target/screen.py +361 -0
  141. pentool/tui/screens/terminal/__init__.py +5 -0
  142. pentool/tui/screens/terminal/screen.py +181 -0
  143. pentool/tui/widgets/__init__.py +1 -0
  144. pentool/tui/widgets/context_menu.py +148 -0
  145. pentool/tui/widgets/filter_bar.py +206 -0
  146. pentool/tui/widgets/inspector_panel.py +112 -0
  147. pentool/tui/widgets/menu.py +86 -0
  148. pentool/tui/widgets/menu_bar.py +238 -0
  149. pentool/tui/widgets/module_tabs.py +73 -0
  150. pentool/tui/widgets/payload_drop_zone.py +66 -0
  151. pentool/tui/widgets/request_editor.py +504 -0
  152. pentool/tui/widgets/resize_handle.py +116 -0
  153. pentool/tui/widgets/search_bar.py +113 -0
  154. pentool/tui/widgets/statusbar.py +99 -0
  155. pentool/tui/widgets/toolbar_button.py +76 -0
  156. pentool/utils/__init__.py +1 -0
  157. pentool/utils/cert.py +361 -0
  158. pentool/utils/coder.py +170 -0
  159. pentool/utils/copy_as.py +251 -0
  160. pentool/utils/diff.py +91 -0
  161. pentool/utils/http_client.py +175 -0
  162. pentool/utils/parser.py +227 -0
  163. pentool/utils/terminal_check.py +32 -0
  164. pentool-1.0.0.dist-info/METADATA +155 -0
  165. pentool-1.0.0.dist-info/RECORD +169 -0
  166. pentool-1.0.0.dist-info/WHEEL +5 -0
  167. pentool-1.0.0.dist-info/entry_points.txt +2 -0
  168. pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
  169. pentool-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,311 @@
1
+ """Storage Interface — abstraction layer for database operations.
2
+
3
+ This interface allows switching between SQLite (desktop) and PostgreSQL (SaaS)
4
+ without changing business logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from typing import Any
11
+
12
+
13
+ class StorageInterface(ABC):
14
+ """Abstract base class for storage implementations.
15
+
16
+ Implementations:
17
+ - SQLiteStorage (current, for desktop)
18
+ - PostgreSQLStorage (future, for SaaS)
19
+ """
20
+
21
+ @abstractmethod
22
+ async def init_db(self, path_or_conn_string: str) -> None:
23
+ """Initialize database connection.
24
+
25
+ Args:
26
+ path_or_conn_string: File path for SQLite, connection string for PostgreSQL
27
+ """
28
+ pass
29
+
30
+ @abstractmethod
31
+ async def close(self) -> None:
32
+ """Close database connection."""
33
+ pass
34
+
35
+ # ── HTTP History operations ────────────────────────────────────────────────
36
+
37
+ @abstractmethod
38
+ async def add_request(
39
+ self,
40
+ method: str,
41
+ url: str,
42
+ status_code: int | None,
43
+ request_headers: dict[str, str] | None,
44
+ response_headers: dict[str, str] | None,
45
+ request_body: str | None,
46
+ response_body: str | None,
47
+ **kwargs,
48
+ ) -> int:
49
+ """Add HTTP request/response pair to storage.
50
+
51
+ Returns:
52
+ Row ID of inserted record
53
+ """
54
+ pass
55
+
56
+ @abstractmethod
57
+ async def get_request(self, row_id: int) -> dict[str, Any] | None:
58
+ """Get HTTP request/response by ID."""
59
+ pass
60
+
61
+ @abstractmethod
62
+ async def get_requests(
63
+ self,
64
+ limit: int = 100,
65
+ offset: int = 0,
66
+ filters: dict[str, Any] | None = None,
67
+ order_by: str = "timestamp DESC",
68
+ ) -> list[dict[str, Any]]:
69
+ """Get list of HTTP requests with filtering and pagination."""
70
+ pass
71
+
72
+ @abstractmethod
73
+ async def update_response(
74
+ self,
75
+ row_id: int,
76
+ status_code: int,
77
+ response_headers: dict[str, str],
78
+ response_body: str,
79
+ ) -> None:
80
+ """Update response data for existing request."""
81
+ pass
82
+
83
+ @abstractmethod
84
+ async def delete_request(self, row_id: int) -> None:
85
+ """Delete HTTP request by ID."""
86
+ pass
87
+
88
+ @abstractmethod
89
+ async def clear_all_requests(self) -> None:
90
+ """Delete all HTTP requests."""
91
+ pass
92
+
93
+ @abstractmethod
94
+ async def search_requests(self, query: str, limit: int = 100) -> list[dict[str, Any]]:
95
+ """Full-text search in requests (FTS5 for SQLite, tsvector for PostgreSQL)."""
96
+ pass
97
+
98
+ # ── Scanner findings operations ────────────────────────────────────────────
99
+
100
+ @abstractmethod
101
+ async def add_finding(
102
+ self,
103
+ severity: str,
104
+ title: str,
105
+ url: str,
106
+ description: str,
107
+ evidence: str | None,
108
+ **kwargs,
109
+ ) -> int:
110
+ """Add vulnerability finding to storage."""
111
+ pass
112
+
113
+ @abstractmethod
114
+ async def get_findings(
115
+ self,
116
+ limit: int = 100,
117
+ filters: dict[str, Any] | None = None,
118
+ ) -> list[dict[str, Any]]:
119
+ """Get list of findings with filtering."""
120
+ pass
121
+
122
+ @abstractmethod
123
+ async def update_finding(self, finding_id: int, **kwargs) -> None:
124
+ """Update finding (e.g., mark as false positive)."""
125
+ pass
126
+
127
+ @abstractmethod
128
+ async def delete_finding(self, finding_id: int) -> None:
129
+ """Delete finding."""
130
+ pass
131
+
132
+ @abstractmethod
133
+ async def clear_all_findings(self) -> None:
134
+ """Delete all findings."""
135
+ pass
136
+
137
+ # ── Project/session metadata ───────────────────────────────────────────────
138
+
139
+ @abstractmethod
140
+ async def get_metadata(self, key: str) -> Any | None:
141
+ """Get project metadata by key."""
142
+ pass
143
+
144
+ @abstractmethod
145
+ async def set_metadata(self, key: str, value: Any) -> None:
146
+ """Set project metadata."""
147
+ pass
148
+
149
+ # ── Statistics ─────────────────────────────────────────────────────────────
150
+
151
+ @abstractmethod
152
+ async def get_stats(self) -> dict[str, Any]:
153
+ """Get storage statistics (counts, sizes, etc.)."""
154
+ pass
155
+
156
+
157
+ class SQLiteStorage(StorageInterface):
158
+ """SQLite implementation (current desktop version).
159
+
160
+ This is a thin wrapper around existing HttpStorage for compatibility.
161
+ """
162
+
163
+ def __init__(self) -> None:
164
+ from pentool.storage.http_storage import HttpStorage
165
+ self._storage = HttpStorage()
166
+
167
+ async def init_db(self, path_or_conn_string: str) -> None:
168
+ await self._storage.init_db(path_or_conn_string)
169
+
170
+ async def close(self) -> None:
171
+ await self._storage.close()
172
+
173
+ async def add_request(
174
+ self,
175
+ method: str,
176
+ url: str,
177
+ status_code: int | None,
178
+ request_headers: dict[str, str] | None,
179
+ response_headers: dict[str, str] | None,
180
+ request_body: str | None,
181
+ response_body: str | None,
182
+ **kwargs,
183
+ ) -> int:
184
+ # Delegate to existing HttpStorage implementation
185
+ from pentool.utils.parser import ParsedRequest, ParsedResponse
186
+
187
+ req = ParsedRequest(
188
+ method=method,
189
+ url=url,
190
+ headers=request_headers or {},
191
+ body=request_body or "",
192
+ )
193
+
194
+ resp = ParsedResponse(
195
+ status=status_code or 0,
196
+ headers=response_headers or {},
197
+ body=response_body or "",
198
+ ) if status_code else None
199
+
200
+ return await self._storage.add_request(req, resp)
201
+
202
+ async def get_request(self, row_id: int) -> dict[str, Any] | None:
203
+ return await self._storage.get_request_by_id(row_id)
204
+
205
+ async def get_requests(
206
+ self,
207
+ limit: int = 100,
208
+ offset: int = 0,
209
+ filters: dict[str, Any] | None = None,
210
+ order_by: str = "timestamp DESC",
211
+ ) -> list[dict[str, Any]]:
212
+ return await self._storage.get_requests_metadata(
213
+ limit=limit,
214
+ offset=offset,
215
+ order_by=order_by,
216
+ )
217
+
218
+ async def update_response(
219
+ self,
220
+ row_id: int,
221
+ status_code: int,
222
+ response_headers: dict[str, str],
223
+ response_body: str,
224
+ ) -> None:
225
+ await self._storage.update_response(
226
+ row_id,
227
+ status_code,
228
+ response_headers,
229
+ response_body,
230
+ )
231
+
232
+ async def delete_request(self, row_id: int) -> None:
233
+ await self._storage.delete_request(row_id)
234
+
235
+ async def clear_all_requests(self) -> None:
236
+ await self._storage.clear_all()
237
+
238
+ async def search_requests(self, query: str, limit: int = 100) -> list[dict[str, Any]]:
239
+ return await self._storage.search(query, limit=limit)
240
+
241
+ # Scanner findings (delegating to database.py)
242
+ async def add_finding(
243
+ self,
244
+ severity: str,
245
+ title: str,
246
+ url: str,
247
+ description: str,
248
+ evidence: str | None,
249
+ **kwargs,
250
+ ) -> int:
251
+ # TODO: implement via core/database.py
252
+ raise NotImplementedError("Findings storage not yet migrated to interface")
253
+
254
+ async def get_findings(
255
+ self,
256
+ limit: int = 100,
257
+ filters: dict[str, Any] | None = None,
258
+ ) -> list[dict[str, Any]]:
259
+ raise NotImplementedError("Findings storage not yet migrated to interface")
260
+
261
+ async def update_finding(self, finding_id: int, **kwargs) -> None:
262
+ raise NotImplementedError("Findings storage not yet migrated to interface")
263
+
264
+ async def delete_finding(self, finding_id: int) -> None:
265
+ raise NotImplementedError("Findings storage not yet migrated to interface")
266
+
267
+ async def clear_all_findings(self) -> None:
268
+ raise NotImplementedError("Findings storage not yet migrated to interface")
269
+
270
+ async def get_metadata(self, key: str) -> Any | None:
271
+ raise NotImplementedError("Metadata storage not yet implemented")
272
+
273
+ async def set_metadata(self, key: str, value: Any) -> None:
274
+ raise NotImplementedError("Metadata storage not yet implemented")
275
+
276
+ async def get_stats(self) -> dict[str, Any]:
277
+ # Return basic stats from HttpStorage
278
+ return {
279
+ "total_requests": await self._storage.count(),
280
+ "total_findings": 0, # TODO
281
+ }
282
+
283
+
284
+ # Future: PostgreSQLStorage for SaaS
285
+ # class PostgreSQLStorage(StorageInterface):
286
+ # """PostgreSQL implementation for SaaS version."""
287
+ # pass
288
+
289
+
290
+ def create_storage(backend: str = "sqlite") -> StorageInterface:
291
+ """Factory function to create storage instance.
292
+
293
+ Args:
294
+ backend: "sqlite" or "postgresql"
295
+
296
+ Returns:
297
+ StorageInterface implementation
298
+ """
299
+ if backend == "sqlite":
300
+ return SQLiteStorage()
301
+ elif backend == "postgresql":
302
+ raise NotImplementedError("PostgreSQL storage not yet implemented")
303
+ else:
304
+ raise ValueError(f"Unknown storage backend: {backend}")
305
+
306
+
307
+ __all__ = [
308
+ "StorageInterface",
309
+ "SQLiteStorage",
310
+ "create_storage",
311
+ ]
@@ -0,0 +1,96 @@
1
+ """Update checker — checks GitHub releases for a newer version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from typing import Tuple
8
+
9
+
10
+ @dataclass
11
+ class UpdateInfo:
12
+ has_update: bool
13
+ latest_version: str
14
+ url: str
15
+ error: str = ""
16
+
17
+
18
+ def _parse_version(v: str) -> tuple[int, ...]:
19
+ """Parse 'v1.2.3' or '1.2.3' into (1, 2, 3)."""
20
+ v = v.lstrip("v").strip()
21
+ try:
22
+ return tuple(int(x) for x in v.split(".")[:3])
23
+ except Exception:
24
+ return (0,)
25
+
26
+
27
+ async def check_update_async(
28
+ owner: str = "sudores",
29
+ repo: str = "pentool",
30
+ timeout: float = 6.0,
31
+ ) -> UpdateInfo:
32
+ """Query GitHub releases API and compare with installed version."""
33
+ try:
34
+ from pentool import __version__ as current_version
35
+ except Exception:
36
+ current_version = "0.0.0"
37
+
38
+ url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
39
+
40
+ try:
41
+ import aiohttp # type: ignore[import]
42
+ except ImportError:
43
+ return UpdateInfo(has_update=False, latest_version=current_version, url="",
44
+ error="aiohttp not installed")
45
+
46
+ try:
47
+ aio_timeout = aiohttp.ClientTimeout(total=timeout)
48
+ async with aiohttp.ClientSession(timeout=aio_timeout) as session:
49
+ async with session.get(
50
+ url,
51
+ headers={"Accept": "application/vnd.github+json"},
52
+ ssl=False,
53
+ ) as resp:
54
+ if resp.status != 200:
55
+ return UpdateInfo(has_update=False, latest_version=current_version,
56
+ url="", error=f"HTTP {resp.status}")
57
+ data = await resp.json()
58
+ except asyncio.TimeoutError:
59
+ return UpdateInfo(has_update=False, latest_version=current_version,
60
+ url="", error="timeout")
61
+ except Exception as exc:
62
+ return UpdateInfo(has_update=False, latest_version=current_version,
63
+ url="", error=str(exc))
64
+
65
+ latest_tag = data.get("tag_name", "")
66
+ html_url = data.get("html_url", "")
67
+ if not latest_tag:
68
+ return UpdateInfo(has_update=False, latest_version=current_version,
69
+ url="", error="no tag_name in response")
70
+
71
+ has_update = _parse_version(latest_tag) > _parse_version(current_version)
72
+ return UpdateInfo(has_update=has_update, latest_version=latest_tag, url=html_url)
73
+
74
+
75
+ def check_update_sync() -> UpdateInfo:
76
+ """Synchronous wrapper for use outside an async context."""
77
+ try:
78
+ loop = asyncio.new_event_loop()
79
+ result = loop.run_until_complete(check_update_async())
80
+ loop.close()
81
+ return result
82
+ except Exception as exc:
83
+ return UpdateInfo(has_update=False, latest_version="", url="", error=str(exc))
84
+
85
+
86
+ def do_pip_upgrade() -> bool:
87
+ """Attempt to upgrade via pip. Returns True on success."""
88
+ import subprocess, sys
89
+ try:
90
+ result = subprocess.run(
91
+ [sys.executable, "-m", "pip", "install", "--upgrade", "pentool"],
92
+ capture_output=True, text=True, timeout=120,
93
+ )
94
+ return result.returncode == 0
95
+ except Exception:
96
+ return False
pentool/core/utils.py ADDED
@@ -0,0 +1,61 @@
1
+ """core/utils.py — утилиты общего назначения.
2
+
3
+ Содержит функции, которые нужны нескольким слоям приложения,
4
+ но не принадлежат ни одному конкретному модулю.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import threading
11
+ from typing import Any, Coroutine, TypeVar
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ def run_async_sync(coro: Coroutine[Any, Any, T], timeout: float = 15.0) -> T:
17
+ """Запустить async-корутину из синхронного контекста.
18
+
19
+ Создаёт новый event loop в отдельном daemon-треде, дожидается
20
+ результата с таймаутом и возвращает его.
21
+
22
+ Используется когда нужно вызвать async-функцию из sync-кода
23
+ (например, из export_project_data, import_project_data и т.п.)
24
+ и при этом нельзя использовать asyncio.run() (т.к. внешний loop
25
+ уже работает — Textual TUI loop).
26
+
27
+ Args:
28
+ coro: Async-корутина для выполнения.
29
+ timeout: Максимальное время ожидания в секундах (default=15).
30
+
31
+ Returns:
32
+ Результат корутины.
33
+
34
+ Raises:
35
+ TimeoutError: Если корутина не завершилась за timeout секунд.
36
+ Exception: Любое исключение из корутины пробрасывается наружу.
37
+ """
38
+ result: list[Any] = []
39
+ exc_box: list[BaseException] = []
40
+ done_event = threading.Event()
41
+
42
+ def _run() -> None:
43
+ loop = asyncio.new_event_loop()
44
+ try:
45
+ result.append(loop.run_until_complete(coro))
46
+ except Exception as e:
47
+ exc_box.append(e)
48
+ finally:
49
+ loop.close()
50
+ done_event.set()
51
+
52
+ threading.Thread(target=_run, daemon=True).start()
53
+ finished = done_event.wait(timeout=timeout)
54
+ if not finished:
55
+ raise TimeoutError(f"run_async_sync: timed out after {timeout}s")
56
+ if exc_box:
57
+ raise exc_box[0]
58
+ return result[0] if result else None # type: ignore[return-value]
59
+
60
+
61
+ __all__ = ["run_async_sync"]
@@ -0,0 +1 @@
1
+ """Пакет modules: логика модулей, независимая от интерфейса."""
@@ -0,0 +1,177 @@
1
+ """Comparer — side-by-side diff двух текстов с подсветкой различий."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from dataclasses import dataclass
7
+
8
+ __all__ = ["compare", "compare_lines", "CompareStats", "DiffLine", "DiffResult"]
9
+
10
+
11
+ @dataclass
12
+ class DiffLine:
13
+ """Одна строка diff-результата."""
14
+
15
+ tag: str # "equal" | "replace" | "insert" | "delete"
16
+ left: str # текст левой стороны (пустая если insert)
17
+ right: str # текст правой стороны (пустая если delete)
18
+ line_left: int # номер строки слева (0 если нет)
19
+ line_right: int # номер строки справа (0 если нет)
20
+
21
+
22
+ @dataclass
23
+ class CompareStats:
24
+ """Статистика сравнения."""
25
+
26
+ total_left: int
27
+ total_right: int
28
+ equal_lines: int
29
+ added_lines: int
30
+ removed_lines: int
31
+ changed_lines: int
32
+ similarity: float # 0.0–1.0
33
+
34
+ @property
35
+ def similarity_pct(self) -> int:
36
+ return int(self.similarity * 100)
37
+
38
+
39
+ @dataclass
40
+ class DiffResult:
41
+ """Результат сравнения: строки diff + статистика."""
42
+
43
+ lines: list[DiffLine]
44
+ stats: CompareStats
45
+
46
+ def rich_text(self) -> str:
47
+ """Собрать Rich-markup строку для RichLog."""
48
+ parts: list[str] = []
49
+ for dl in self.lines:
50
+ if dl.tag == "equal":
51
+ parts.append(f"[dim] {dl.left}[/dim]")
52
+ elif dl.tag == "insert":
53
+ parts.append(f"[green]+ {dl.right}[/green]")
54
+ elif dl.tag == "delete":
55
+ parts.append(f"[red]- {dl.left}[/red]")
56
+ elif dl.tag == "replace":
57
+ parts.append(f"[red]- {dl.left}[/red]")
58
+ parts.append(f"[green]+ {dl.right}[/green]")
59
+ return "\n".join(parts)
60
+
61
+
62
+ def compare(left: str, right: str) -> DiffResult:
63
+ """Сравнить два текста построчно.
64
+
65
+ Args:
66
+ left: Левый текст.
67
+ right: Правый текст.
68
+
69
+ Returns:
70
+ DiffResult с построчными различиями и статистикой.
71
+ """
72
+ left_lines = left.splitlines()
73
+ right_lines = right.splitlines()
74
+ return compare_lines(left_lines, right_lines)
75
+
76
+
77
+ def compare_lines(left_lines: list[str], right_lines: list[str]) -> DiffResult:
78
+ """Сравнить два списка строк.
79
+
80
+ Args:
81
+ left_lines: Строки левой стороны.
82
+ right_lines: Строки правой стороны.
83
+
84
+ Returns:
85
+ DiffResult с построчными различиями и статистикой.
86
+ """
87
+ matcher = difflib.SequenceMatcher(None, left_lines, right_lines, autojunk=False)
88
+ opcodes = matcher.get_opcodes()
89
+
90
+ diff_lines: list[DiffLine] = []
91
+ equal = added = removed = changed = 0
92
+
93
+ ln_left = 1
94
+ ln_right = 1
95
+
96
+ for tag, i1, i2, j1, j2 in opcodes:
97
+ if tag == "equal":
98
+ for k in range(i2 - i1):
99
+ diff_lines.append(DiffLine(
100
+ tag="equal",
101
+ left=left_lines[i1 + k],
102
+ right=right_lines[j1 + k],
103
+ line_left=ln_left + k,
104
+ line_right=ln_right + k,
105
+ ))
106
+ equal += i2 - i1
107
+ ln_left += i2 - i1
108
+ ln_right += j2 - j1
109
+
110
+ elif tag == "replace":
111
+ # Показываем удалённые строки слева, добавленные справа
112
+ left_chunk = left_lines[i1:i2]
113
+ right_chunk = right_lines[j1:j2]
114
+ max_len = max(len(left_chunk), len(right_chunk))
115
+ for k in range(max_len):
116
+ l_text = left_chunk[k] if k < len(left_chunk) else ""
117
+ r_text = right_chunk[k] if k < len(right_chunk) else ""
118
+ if l_text and r_text:
119
+ diff_lines.append(DiffLine(
120
+ tag="replace",
121
+ left=l_text, right=r_text,
122
+ line_left=ln_left + k if k < len(left_chunk) else 0,
123
+ line_right=ln_right + k if k < len(right_chunk) else 0,
124
+ ))
125
+ elif l_text:
126
+ diff_lines.append(DiffLine(
127
+ tag="delete", left=l_text, right="",
128
+ line_left=ln_left + k, line_right=0,
129
+ ))
130
+ else:
131
+ diff_lines.append(DiffLine(
132
+ tag="insert", left="", right=r_text,
133
+ line_left=0, line_right=ln_right + k,
134
+ ))
135
+ changed += max(len(left_chunk), len(right_chunk))
136
+ ln_left += i2 - i1
137
+ ln_right += j2 - j1
138
+
139
+ elif tag == "delete":
140
+ for k in range(i2 - i1):
141
+ diff_lines.append(DiffLine(
142
+ tag="delete",
143
+ left=left_lines[i1 + k], right="",
144
+ line_left=ln_left + k, line_right=0,
145
+ ))
146
+ removed += i2 - i1
147
+ ln_left += i2 - i1
148
+
149
+ elif tag == "insert":
150
+ for k in range(j2 - j1):
151
+ diff_lines.append(DiffLine(
152
+ tag="insert",
153
+ left="", right=right_lines[j1 + k],
154
+ line_left=0, line_right=ln_right + k,
155
+ ))
156
+ added += j2 - j1
157
+ ln_right += j2 - j1
158
+
159
+ similarity = matcher.ratio()
160
+ stats = CompareStats(
161
+ total_left=len(left_lines),
162
+ total_right=len(right_lines),
163
+ equal_lines=equal,
164
+ added_lines=added,
165
+ removed_lines=removed,
166
+ changed_lines=changed,
167
+ similarity=similarity,
168
+ )
169
+ return DiffResult(lines=diff_lines, stats=stats)
170
+
171
+
172
+ def compare_bytes(left: bytes, right: bytes) -> DiffResult:
173
+ """Сравнить два байтовых потока (декодируются как UTF-8 с заменой)."""
174
+ return compare(
175
+ left.decode("utf-8", errors="replace"),
176
+ right.decode("utf-8", errors="replace"),
177
+ )