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.
- pentool/__init__.py +4 -0
- pentool/__main__.py +17 -0
- pentool/api/__init__.py +45 -0
- pentool/api/base_api.py +52 -0
- pentool/api/comparer_api.py +23 -0
- pentool/api/decoder_api.py +28 -0
- pentool/api/intruder_api.py +209 -0
- pentool/api/proxy_api.py +319 -0
- pentool/api/repeater_api.py +118 -0
- pentool/api/scanner_api.py +336 -0
- pentool/api/sequencer_api.py +21 -0
- pentool/api/spider_api.py +126 -0
- pentool/api/target_api.py +121 -0
- pentool/cli/__init__.py +1 -0
- pentool/cli/main.py +100 -0
- pentool/cli/project.py +90 -0
- pentool/cli/proxy.py +185 -0
- pentool/cli/scan.py +118 -0
- pentool/core/__init__.py +1 -0
- pentool/core/config.py +184 -0
- pentool/core/crash_reporter.py +75 -0
- pentool/core/database.py +139 -0
- pentool/core/event_bus.py +232 -0
- pentool/core/events.py +164 -0
- pentool/core/features.py +410 -0
- pentool/core/license.py +294 -0
- pentool/core/logging.py +59 -0
- pentool/core/plugin_manager.py +332 -0
- pentool/core/project.py +37 -0
- pentool/core/storage_interface.py +311 -0
- pentool/core/updater.py +96 -0
- pentool/core/utils.py +61 -0
- pentool/modules/__init__.py +1 -0
- pentool/modules/comparer.py +177 -0
- pentool/modules/decoder.py +281 -0
- pentool/modules/intruder.py +432 -0
- pentool/modules/intruder_turbo.py +190 -0
- pentool/modules/match_replace.py +146 -0
- pentool/modules/proxy.py +803 -0
- pentool/modules/repeater.py +237 -0
- pentool/modules/scanner/__init__.py +7 -0
- pentool/modules/scanner/ai_analyzer.py +230 -0
- pentool/modules/scanner/base.py +173 -0
- pentool/modules/scanner/baseline.py +101 -0
- pentool/modules/scanner/checks/__init__.py +47 -0
- pentool/modules/scanner/checks/broken_auth.py +231 -0
- pentool/modules/scanner/checks/cors.py +209 -0
- pentool/modules/scanner/checks/dom_xss.py +114 -0
- pentool/modules/scanner/checks/graphql.py +110 -0
- pentool/modules/scanner/checks/header_injection.py +262 -0
- pentool/modules/scanner/checks/headers.py +75 -0
- pentool/modules/scanner/checks/helpers.py +358 -0
- pentool/modules/scanner/checks/info_leak.py +68 -0
- pentool/modules/scanner/checks/jwt_none.py +238 -0
- pentool/modules/scanner/checks/lfi.py +186 -0
- pentool/modules/scanner/checks/nosql_injection.py +92 -0
- pentool/modules/scanner/checks/oauth.py +123 -0
- pentool/modules/scanner/checks/open_redirect.py +168 -0
- pentool/modules/scanner/checks/path_traversal.py +229 -0
- pentool/modules/scanner/checks/prototype_pollution.py +88 -0
- pentool/modules/scanner/checks/rce.py +338 -0
- pentool/modules/scanner/checks/sensitive_data.py +74 -0
- pentool/modules/scanner/checks/sqli.py +546 -0
- pentool/modules/scanner/checks/ssrf.py +273 -0
- pentool/modules/scanner/checks/ssti.py +353 -0
- pentool/modules/scanner/checks/xss.py +622 -0
- pentool/modules/scanner/checks/xxe.py +240 -0
- pentool/modules/scanner/engine.py +407 -0
- pentool/modules/scanner/fingerprint.py +169 -0
- pentool/modules/scanner/helpers.py +94 -0
- pentool/modules/scanner/mutator.py +378 -0
- pentool/modules/scanner/oob.py +104 -0
- pentool/modules/scanner/passive.py +114 -0
- pentool/modules/scanner/payloads.py +224 -0
- pentool/modules/scanner/report.py +117 -0
- pentool/modules/sequencer.py +500 -0
- pentool/modules/spider.py +702 -0
- pentool/modules/target.py +190 -0
- pentool/modules/websocket_handler.py +259 -0
- pentool/plugins/__init__.py +7 -0
- pentool/plugins/builtin/__init__.py +1 -0
- pentool/plugins/builtin/payloads_pro.py +404 -0
- pentool/plugins/builtin/reports_pro.py +450 -0
- pentool/plugins/builtin/scanner_pro.py +308 -0
- pentool/plugins/example_plugin.py +75 -0
- pentool/project_to_txt.py +67 -0
- pentool/services/__init__.py +1 -0
- pentool/services/base_service.py +64 -0
- pentool/services/intruder_service.py +108 -0
- pentool/services/proxy_service.py +232 -0
- pentool/services/repeater_service.py +111 -0
- pentool/services/scan_service.py +340 -0
- pentool/storage/__init__.py +0 -0
- pentool/storage/http_storage.py +522 -0
- pentool/storage/large_body_handler.py +54 -0
- pentool/storage/lru_cache.py +49 -0
- pentool/t.py +213 -0
- pentool/tui/__init__.py +1 -0
- pentool/tui/app.py +1438 -0
- pentool/tui/constants.py +79 -0
- pentool/tui/dialogs/__init__.py +0 -0
- pentool/tui/dialogs/cert_dialog.py +81 -0
- pentool/tui/dialogs/file_selector.py +227 -0
- pentool/tui/dialogs/load_from_proxy.py +125 -0
- pentool/tui/dialogs/match_replace_dialog.py +229 -0
- pentool/tui/dialogs/project_dialog.py +82 -0
- pentool/tui/dialogs/scope_dialog.py +225 -0
- pentool/tui/messages.py +121 -0
- pentool/tui/mixins/__init__.py +0 -0
- pentool/tui/mixins/app_mixin.py +61 -0
- pentool/tui/mixins/dialog_cancel.py +25 -0
- pentool/tui/mixins/request_context_menu.py +285 -0
- pentool/tui/mixins/tab_rename.py +101 -0
- pentool/tui/screens/__init__.py +31 -0
- pentool/tui/screens/comparer/__init__.py +3 -0
- pentool/tui/screens/comparer/screen.py +233 -0
- pentool/tui/screens/dashboard/__init__.py +2 -0
- pentool/tui/screens/dashboard/live_dashboard.py +741 -0
- pentool/tui/screens/dashboard/screen.py +820 -0
- pentool/tui/screens/decoder/__init__.py +3 -0
- pentool/tui/screens/decoder/screen.py +311 -0
- pentool/tui/screens/extensions/__init__.py +3 -0
- pentool/tui/screens/extensions/screen.py +24 -0
- pentool/tui/screens/intruder/__init__.py +3 -0
- pentool/tui/screens/intruder/screen.py +1362 -0
- pentool/tui/screens/proxy/__init__.py +3 -0
- pentool/tui/screens/proxy/screen.py +1664 -0
- pentool/tui/screens/repeater/__init__.py +3 -0
- pentool/tui/screens/repeater/screen.py +646 -0
- pentool/tui/screens/scanner/__init__.py +3 -0
- pentool/tui/screens/scanner/screen.py +1984 -0
- pentool/tui/screens/sequencer/__init__.py +3 -0
- pentool/tui/screens/sequencer/screen.py +520 -0
- pentool/tui/screens/settings/__init__.py +5 -0
- pentool/tui/screens/settings/hotkeys.py +134 -0
- pentool/tui/screens/settings/screen.py +543 -0
- pentool/tui/screens/spider/__init__.py +3 -0
- pentool/tui/screens/spider/screen.py +385 -0
- pentool/tui/screens/target/__init__.py +4 -0
- pentool/tui/screens/target/screen.py +361 -0
- pentool/tui/screens/terminal/__init__.py +5 -0
- pentool/tui/screens/terminal/screen.py +181 -0
- pentool/tui/widgets/__init__.py +1 -0
- pentool/tui/widgets/context_menu.py +148 -0
- pentool/tui/widgets/filter_bar.py +206 -0
- pentool/tui/widgets/inspector_panel.py +112 -0
- pentool/tui/widgets/menu.py +86 -0
- pentool/tui/widgets/menu_bar.py +238 -0
- pentool/tui/widgets/module_tabs.py +73 -0
- pentool/tui/widgets/payload_drop_zone.py +66 -0
- pentool/tui/widgets/request_editor.py +504 -0
- pentool/tui/widgets/resize_handle.py +116 -0
- pentool/tui/widgets/search_bar.py +113 -0
- pentool/tui/widgets/statusbar.py +99 -0
- pentool/tui/widgets/toolbar_button.py +76 -0
- pentool/utils/__init__.py +1 -0
- pentool/utils/cert.py +361 -0
- pentool/utils/coder.py +170 -0
- pentool/utils/copy_as.py +251 -0
- pentool/utils/diff.py +91 -0
- pentool/utils/http_client.py +175 -0
- pentool/utils/parser.py +227 -0
- pentool/utils/terminal_check.py +32 -0
- pentool-1.0.0.dist-info/METADATA +155 -0
- pentool-1.0.0.dist-info/RECORD +169 -0
- pentool-1.0.0.dist-info/WHEEL +5 -0
- pentool-1.0.0.dist-info/entry_points.txt +2 -0
- pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
- 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
|
+
]
|
pentool/core/updater.py
ADDED
|
@@ -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
|
+
)
|