portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +16 -3
- portacode/cli.py +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
- portacode/connection/handlers/__init__.py +28 -1
- portacode/connection/handlers/base.py +78 -16
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -2185
- portacode/connection/handlers/proxmox_infra.py +361 -0
- portacode/connection/handlers/registry.py +15 -4
- portacode/connection/handlers/session.py +483 -32
- portacode/connection/handlers/system_handlers.py +147 -8
- portacode/connection/handlers/tab_factory.py +53 -46
- portacode/connection/handlers/terminal_handlers.py +21 -8
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +214 -24
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev1.dist-info/METADATA +298 -0
- portacode-1.4.11.dev1.dist-info/RECORD +97 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.19.dev4.dist-info/METADATA +0 -241
- portacode-0.3.19.dev4.dist-info/RECORD +0 -30
- portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Stateless utilities for rendering unified diffs as HTML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from pygments import highlight
|
|
13
|
+
from pygments.formatters import HtmlFormatter
|
|
14
|
+
from pygments.lexers import get_lexer_for_filename
|
|
15
|
+
from pygments.util import ClassNotFound
|
|
16
|
+
|
|
17
|
+
PYGMENTS_AVAILABLE = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
PYGMENTS_AVAILABLE = False
|
|
20
|
+
highlight = None # type: ignore
|
|
21
|
+
HtmlFormatter = None # type: ignore
|
|
22
|
+
get_lexer_for_filename = None # type: ignore
|
|
23
|
+
ClassNotFound = Exception # type: ignore
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_html_diff(
|
|
29
|
+
original_content: str,
|
|
30
|
+
modified_content: str,
|
|
31
|
+
file_path: str,
|
|
32
|
+
) -> Optional[Dict[str, str]]:
|
|
33
|
+
"""Generate unified HTML diff (minimal + full context) for two strings."""
|
|
34
|
+
# If pygments isn't available we fall back to the simplified renderer
|
|
35
|
+
if not PYGMENTS_AVAILABLE:
|
|
36
|
+
return _generate_simple_diff_html(original_content, modified_content, file_path)
|
|
37
|
+
|
|
38
|
+
# Basic safety limits to keep rendering responsive
|
|
39
|
+
max_content_size = 500000 # 500KB
|
|
40
|
+
max_lines = 5000
|
|
41
|
+
original_line_count = original_content.count("\n")
|
|
42
|
+
modified_line_count = modified_content.count("\n")
|
|
43
|
+
if (
|
|
44
|
+
len(original_content) > max_content_size
|
|
45
|
+
or len(modified_content) > max_content_size
|
|
46
|
+
or max(original_line_count, modified_line_count) > max_lines
|
|
47
|
+
):
|
|
48
|
+
logger.warning("Large file detected for diff generation: %s", file_path)
|
|
49
|
+
return _generate_simple_diff_html(original_content, modified_content, file_path)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
original_lines = original_content.splitlines(keepends=True)
|
|
53
|
+
modified_lines = modified_content.splitlines(keepends=True)
|
|
54
|
+
|
|
55
|
+
start_time = time.time()
|
|
56
|
+
timeout_seconds = 5
|
|
57
|
+
|
|
58
|
+
minimal_diff_lines = list(
|
|
59
|
+
difflib.unified_diff(
|
|
60
|
+
original_lines,
|
|
61
|
+
modified_lines,
|
|
62
|
+
fromfile=f"a/{os.path.basename(file_path)}",
|
|
63
|
+
tofile=f"b/{os.path.basename(file_path)}",
|
|
64
|
+
lineterm="",
|
|
65
|
+
n=3,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
if time.time() - start_time > timeout_seconds:
|
|
69
|
+
logger.warning("Diff generation timeout for %s", file_path)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
if len(original_lines) + len(modified_lines) < 2000:
|
|
73
|
+
context_span = len(original_lines) + len(modified_lines)
|
|
74
|
+
full_diff_lines = list(
|
|
75
|
+
difflib.unified_diff(
|
|
76
|
+
original_lines,
|
|
77
|
+
modified_lines,
|
|
78
|
+
fromfile=f"a/{os.path.basename(file_path)}",
|
|
79
|
+
tofile=f"b/{os.path.basename(file_path)}",
|
|
80
|
+
lineterm="",
|
|
81
|
+
n=context_span,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
full_diff_lines = minimal_diff_lines
|
|
86
|
+
|
|
87
|
+
parsed_minimal = parse_unified_diff_simple(minimal_diff_lines)
|
|
88
|
+
parsed_full = parse_unified_diff_simple(full_diff_lines)
|
|
89
|
+
|
|
90
|
+
if time.time() - start_time > timeout_seconds:
|
|
91
|
+
logger.warning("Diff generation timeout for %s", file_path)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
minimal_html = render_diff_html(parsed_minimal, file_path, "minimal")
|
|
95
|
+
full_html = render_diff_html(parsed_full, file_path, "full")
|
|
96
|
+
return {"minimal": minimal_html, "full": full_html}
|
|
97
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
98
|
+
logger.error("Error generating HTML diff for %s: %s", file_path, exc)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_unified_diff_simple(diff_lines: List[str]) -> List[Dict]:
|
|
103
|
+
"""Parse unified diff lines into structured rows (no intraline highlighting)."""
|
|
104
|
+
parsed: List[Dict] = []
|
|
105
|
+
old_line_num = 0
|
|
106
|
+
new_line_num = 0
|
|
107
|
+
|
|
108
|
+
for line in diff_lines:
|
|
109
|
+
if line.startswith("@@"):
|
|
110
|
+
import re
|
|
111
|
+
|
|
112
|
+
match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
|
|
113
|
+
if match:
|
|
114
|
+
old_line_num = int(match.group(1)) - 1
|
|
115
|
+
new_line_num = int(match.group(2)) - 1
|
|
116
|
+
parsed.append(
|
|
117
|
+
{
|
|
118
|
+
"type": "header",
|
|
119
|
+
"content": line,
|
|
120
|
+
"old_line_num": "",
|
|
121
|
+
"new_line_num": "",
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
elif line.startswith("---") or line.startswith("+++"):
|
|
125
|
+
parsed.append(
|
|
126
|
+
{
|
|
127
|
+
"type": "header",
|
|
128
|
+
"content": line,
|
|
129
|
+
"old_line_num": "",
|
|
130
|
+
"new_line_num": "",
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
elif line.startswith("-"):
|
|
134
|
+
old_line_num += 1
|
|
135
|
+
parsed.append(
|
|
136
|
+
{
|
|
137
|
+
"type": "delete",
|
|
138
|
+
"old_line_num": old_line_num,
|
|
139
|
+
"new_line_num": "",
|
|
140
|
+
"content": line,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
elif line.startswith("+"):
|
|
144
|
+
new_line_num += 1
|
|
145
|
+
parsed.append(
|
|
146
|
+
{
|
|
147
|
+
"type": "add",
|
|
148
|
+
"old_line_num": "",
|
|
149
|
+
"new_line_num": new_line_num,
|
|
150
|
+
"content": line,
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
elif line.startswith(" "):
|
|
154
|
+
old_line_num += 1
|
|
155
|
+
new_line_num += 1
|
|
156
|
+
parsed.append(
|
|
157
|
+
{
|
|
158
|
+
"type": "context",
|
|
159
|
+
"old_line_num": old_line_num,
|
|
160
|
+
"new_line_num": new_line_num,
|
|
161
|
+
"content": line,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
return parsed
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def render_diff_html(parsed_diff: List[Dict], file_path: str, view_mode: str) -> str:
|
|
168
|
+
"""Convert parsed diff entries into styled HTML."""
|
|
169
|
+
if len(parsed_diff) > 1000:
|
|
170
|
+
logger.warning("Diff too large, truncating: %s (%s lines)", file_path, len(parsed_diff))
|
|
171
|
+
parsed_diff = parsed_diff[:1000]
|
|
172
|
+
|
|
173
|
+
lexer = _get_pygments_lexer(file_path)
|
|
174
|
+
highlighted_cache = {}
|
|
175
|
+
if lexer and PYGMENTS_AVAILABLE:
|
|
176
|
+
unique_lines = {
|
|
177
|
+
line_info["content"][1:].rstrip("\n")
|
|
178
|
+
for line_info in parsed_diff
|
|
179
|
+
if line_info.get("content") and line_info["content"][0] in "+- "
|
|
180
|
+
}
|
|
181
|
+
unique_lines = {line for line in unique_lines if line.strip()}
|
|
182
|
+
if unique_lines:
|
|
183
|
+
try:
|
|
184
|
+
combined = "\n".join(unique_lines)
|
|
185
|
+
highlighted = highlight(
|
|
186
|
+
combined, lexer, HtmlFormatter(nowrap=True, noclasses=False, style="monokai")
|
|
187
|
+
)
|
|
188
|
+
split_highlighted = highlighted.split("\n")
|
|
189
|
+
unique_list = list(unique_lines)
|
|
190
|
+
for idx, content in enumerate(unique_list):
|
|
191
|
+
if idx < len(split_highlighted):
|
|
192
|
+
highlighted_cache[content] = split_highlighted[idx]
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
logger.debug("Error in batch syntax highlighting: %s", exc)
|
|
195
|
+
highlighted_cache = {}
|
|
196
|
+
|
|
197
|
+
html_parts: List[str] = []
|
|
198
|
+
html_parts.append(f'<div class="unified-diff-container" data-view-mode="{view_mode}">')
|
|
199
|
+
|
|
200
|
+
line_additions = sum(1 for line in parsed_diff if line["type"] == "add")
|
|
201
|
+
line_deletions = sum(1 for line in parsed_diff if line["type"] == "delete")
|
|
202
|
+
|
|
203
|
+
html_parts.append(
|
|
204
|
+
f"""
|
|
205
|
+
<div class="diff-stats">
|
|
206
|
+
<div class="diff-stats-left">
|
|
207
|
+
<span class="additions">+{line_additions}</span>
|
|
208
|
+
<span class="deletions">-{line_deletions}</span>
|
|
209
|
+
<span class="file-path">{os.path.basename(file_path)}</span>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="diff-stats-right">
|
|
212
|
+
<button class="diff-toggle-btn" data-current-mode="{view_mode}">
|
|
213
|
+
<i class="fas fa-eye"></i>
|
|
214
|
+
<span class="toggle-text"></span>
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
"""
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
html_parts.append('<div class="diff-content">')
|
|
222
|
+
html_parts.append('<table class="diff-table">')
|
|
223
|
+
|
|
224
|
+
for line_info in parsed_diff:
|
|
225
|
+
if line_info["type"] == "header":
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
line_type = line_info["type"]
|
|
229
|
+
old_line_num = line_info.get("old_line_num", "")
|
|
230
|
+
new_line_num = line_info.get("new_line_num", "")
|
|
231
|
+
content = line_info.get("content", "")
|
|
232
|
+
|
|
233
|
+
final_content = _escape_html(content)
|
|
234
|
+
if content and content[0] in "+- ":
|
|
235
|
+
prefix = content[0] if content[0] in "+-" else " "
|
|
236
|
+
clean_content = content[1:].rstrip("\n")
|
|
237
|
+
if clean_content.strip():
|
|
238
|
+
cached = highlighted_cache.get(clean_content)
|
|
239
|
+
if cached:
|
|
240
|
+
final_content = prefix + cached
|
|
241
|
+
elif lexer and PYGMENTS_AVAILABLE:
|
|
242
|
+
try:
|
|
243
|
+
highlighted_line = highlight(
|
|
244
|
+
clean_content, lexer, HtmlFormatter(nowrap=True, noclasses=False, style="monokai")
|
|
245
|
+
)
|
|
246
|
+
final_content = prefix + highlighted_line
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
logger.debug("Error applying syntax highlighting: %s", exc)
|
|
249
|
+
final_content = _escape_html(content)
|
|
250
|
+
|
|
251
|
+
row_class = f"diff-line diff-{line_type}"
|
|
252
|
+
html_parts.append(
|
|
253
|
+
f"""
|
|
254
|
+
<tr class="{row_class}">
|
|
255
|
+
<td class="line-num old-line-num">{old_line_num}</td>
|
|
256
|
+
<td class="line-num new-line-num">{new_line_num}</td>
|
|
257
|
+
<td class="line-content">{final_content}</td>
|
|
258
|
+
</tr>
|
|
259
|
+
"""
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
html_parts.append("</table>")
|
|
263
|
+
html_parts.append("</div>")
|
|
264
|
+
html_parts.append("</div>")
|
|
265
|
+
return "".join(html_parts)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def render_simple_diff_html(parsed_diff: List[Dict], file_path: str) -> str:
|
|
269
|
+
"""Generate simplified diff HTML tables (no syntax highlighting)."""
|
|
270
|
+
html_parts = []
|
|
271
|
+
html_parts.append('<div class="unified-diff-container" data-view-mode="minimal">')
|
|
272
|
+
|
|
273
|
+
line_additions = sum(1 for line in parsed_diff if line["type"] == "add")
|
|
274
|
+
line_deletions = sum(1 for line in parsed_diff if line["type"] == "delete")
|
|
275
|
+
html_parts.append(
|
|
276
|
+
f"""
|
|
277
|
+
<div class="diff-stats">
|
|
278
|
+
<div class="diff-stats-left">
|
|
279
|
+
<span class="additions">+{line_additions}</span>
|
|
280
|
+
<span class="deletions">-{line_deletions}</span>
|
|
281
|
+
<span class="file-path">{os.path.basename(file_path)} (Large file - simplified view)</span>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
"""
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
html_parts.append('<div class="diff-content">')
|
|
288
|
+
html_parts.append('<table class="diff-table">')
|
|
289
|
+
|
|
290
|
+
for line_info in parsed_diff:
|
|
291
|
+
if line_info["type"] == "header":
|
|
292
|
+
continue
|
|
293
|
+
row_class = f'diff-line diff-{line_info["type"]}'
|
|
294
|
+
html_parts.append(
|
|
295
|
+
f"""
|
|
296
|
+
<tr class="{row_class}">
|
|
297
|
+
<td class="line-num old-line-num">{line_info.get("old_line_num", "")}</td>
|
|
298
|
+
<td class="line-num new-line-num">{line_info.get("new_line_num", "")}</td>
|
|
299
|
+
<td class="line-content">{_escape_html(line_info.get("content", ""))}</td>
|
|
300
|
+
</tr>
|
|
301
|
+
"""
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
html_parts.append("</table>")
|
|
305
|
+
html_parts.append("</div>")
|
|
306
|
+
html_parts.append("</div>")
|
|
307
|
+
return "".join(html_parts)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def generate_fallback_diff_html(file_path: str) -> str:
|
|
311
|
+
"""Fallback view when diff can't be rendered."""
|
|
312
|
+
return f"""
|
|
313
|
+
<div class="unified-diff-container" data-view-mode="minimal">
|
|
314
|
+
<div class="diff-stats">
|
|
315
|
+
<div class="diff-stats-left">
|
|
316
|
+
<span class="file-path">{os.path.basename(file_path)} (Diff unavailable)</span>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
<div class="diff-content">
|
|
320
|
+
<div style="padding: 2rem; text-align: center; color: var(--text-secondary);">
|
|
321
|
+
<i class="fas fa-exclamation-triangle" style="font-size: 2rem; margin-bottom: 1rem;"></i>
|
|
322
|
+
<p>Diff view unavailable for this file</p>
|
|
323
|
+
<p style="font-size: 0.9rem;">File may be too large or binary</p>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _generate_simple_diff_html(original_content: str, modified_content: str, file_path: str) -> Dict[str, str]:
|
|
331
|
+
"""Render simplified HTML diff for large files or when syntax highlighting is unavailable."""
|
|
332
|
+
diff_lines = list(
|
|
333
|
+
difflib.unified_diff(
|
|
334
|
+
original_content.splitlines(keepends=True),
|
|
335
|
+
modified_content.splitlines(keepends=True),
|
|
336
|
+
fromfile=f"a/{os.path.basename(file_path)}",
|
|
337
|
+
tofile=f"b/{os.path.basename(file_path)}",
|
|
338
|
+
lineterm="",
|
|
339
|
+
n=3,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
parsed = parse_unified_diff_simple(diff_lines)
|
|
343
|
+
if len(parsed) > 500:
|
|
344
|
+
parsed = parsed[:500]
|
|
345
|
+
logger.info("Truncated large diff to 500 lines for %s", file_path)
|
|
346
|
+
html = render_simple_diff_html(parsed, file_path)
|
|
347
|
+
return {"minimal": html, "full": html}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _get_pygments_lexer(file_path: str):
|
|
351
|
+
if not PYGMENTS_AVAILABLE or not get_lexer_for_filename: # type: ignore
|
|
352
|
+
return None
|
|
353
|
+
try:
|
|
354
|
+
return get_lexer_for_filename(file_path) # type: ignore
|
|
355
|
+
except ClassNotFound:
|
|
356
|
+
logger.debug("No Pygments lexer found for file: %s", file_path)
|
|
357
|
+
return None
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
logger.debug("Error getting Pygments lexer for %s: %s", file_path, exc)
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _escape_html(text: str) -> str:
|
|
364
|
+
return (
|
|
365
|
+
text.replace("&", "&")
|
|
366
|
+
.replace("<", "<")
|
|
367
|
+
.replace(">", ">")
|
|
368
|
+
.replace('"', """)
|
|
369
|
+
.replace("'", "'")
|
|
370
|
+
)
|
|
371
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple clock offset helper that tracks the difference between the local clock and
|
|
3
|
+
the server time. The device obtains the server time over the existing gateway
|
|
4
|
+
WebSocket and updates the offset based on measured round-trip time.
|
|
5
|
+
"""
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NTPClock:
|
|
12
|
+
"""Clock helper that stores a millisecond offset from the local system clock."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._offset_ms = 0.0
|
|
16
|
+
self._last_sync = None
|
|
17
|
+
self._last_latency_ms = None
|
|
18
|
+
self._smoothing_weight = 0.2
|
|
19
|
+
|
|
20
|
+
def now(self) -> float:
|
|
21
|
+
"""Return the current timestamp (seconds since epoch) adjusted by the offset."""
|
|
22
|
+
return time.time() + (self._offset_ms / 1000.0)
|
|
23
|
+
|
|
24
|
+
def now_ms(self) -> int:
|
|
25
|
+
"""Return the current timestamp in milliseconds adjusted by the offset."""
|
|
26
|
+
return int(time.time() * 1000 + self._offset_ms)
|
|
27
|
+
|
|
28
|
+
def now_iso(self) -> str:
|
|
29
|
+
"""Return the current ISO timestamp adjusted by the offset."""
|
|
30
|
+
return datetime.fromtimestamp(self.now(), tz=timezone.utc).isoformat()
|
|
31
|
+
|
|
32
|
+
def get_status(self) -> dict:
|
|
33
|
+
"""Return metadata about the last synchronization."""
|
|
34
|
+
return {
|
|
35
|
+
'server': 'gateway',
|
|
36
|
+
'offset_ms': self._offset_ms,
|
|
37
|
+
'last_sync': datetime.fromtimestamp(self._last_sync, tz=timezone.utc).isoformat() if self._last_sync else None,
|
|
38
|
+
'last_latency_ms': self._last_latency_ms,
|
|
39
|
+
'is_synced': self._last_sync is not None,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def update_from_server(self, server_time_ms: float, latency_ms: float) -> None:
|
|
43
|
+
"""Update the clock offset using the server timestamp and measured latency."""
|
|
44
|
+
client_receive_ms = time.time() * 1000
|
|
45
|
+
half_latency = latency_ms / 2
|
|
46
|
+
estimated_server_received = client_receive_ms - half_latency
|
|
47
|
+
new_offset = server_time_ms - estimated_server_received
|
|
48
|
+
if self._last_sync is not None:
|
|
49
|
+
self._offset_ms += self._smoothing_weight * (new_offset - self._offset_ms)
|
|
50
|
+
else:
|
|
51
|
+
self._offset_ms = new_offset
|
|
52
|
+
self._last_latency_ms = latency_ms
|
|
53
|
+
self._last_sync = time.time()
|
|
54
|
+
|
|
55
|
+
def sync(self) -> bool:
|
|
56
|
+
"""Legacy sync stub kept for compatibility; no-op so callers continue to work."""
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def start_auto_sync(self):
|
|
60
|
+
"""Legacy stub that does nothing now that sync happens via the gateway."""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Global instance
|
|
65
|
+
ntp_clock = NTPClock()
|