healspace 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {healspace-0.2.0 → healspace-0.3.0}/PKG-INFO +1 -1
- healspace-0.3.0/healspace/__init__.py +43 -0
- healspace-0.3.0/healspace/brain.py +692 -0
- {healspace-0.2.0 → healspace-0.3.0}/healspace/core.py +70 -3
- {healspace-0.2.0 → healspace-0.3.0}/healspace.egg-info/PKG-INFO +1 -1
- {healspace-0.2.0 → healspace-0.3.0}/healspace.egg-info/SOURCES.txt +1 -0
- {healspace-0.2.0 → healspace-0.3.0}/pyproject.toml +1 -1
- healspace-0.2.0/healspace/__init__.py +0 -34
- {healspace-0.2.0 → healspace-0.3.0}/README.md +0 -0
- {healspace-0.2.0 → healspace-0.3.0}/healspace/errors.py +0 -0
- {healspace-0.2.0 → healspace-0.3.0}/healspace.egg-info/dependency_links.txt +0 -0
- {healspace-0.2.0 → healspace-0.3.0}/healspace.egg-info/requires.txt +0 -0
- {healspace-0.2.0 → healspace-0.3.0}/healspace.egg-info/top_level.txt +0 -0
- {healspace-0.2.0 → healspace-0.3.0}/setup.cfg +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HealSpace — Guardian AI for Hugging Face Spaces
|
|
3
|
+
================================================
|
|
4
|
+
Automatically detects backend errors, heals common issues,
|
|
5
|
+
and protects your Space with a branded lineage seal.
|
|
6
|
+
|
|
7
|
+
Quick start — one line in app.py
|
|
8
|
+
---------------------------------
|
|
9
|
+
import gradio as gr
|
|
10
|
+
from healspace import heal
|
|
11
|
+
|
|
12
|
+
with gr.Blocks() as demo:
|
|
13
|
+
gr.Markdown("# My Space")
|
|
14
|
+
# ... your UI ...
|
|
15
|
+
|
|
16
|
+
heal(demo) # wraps, protects, and launches
|
|
17
|
+
demo.launch()
|
|
18
|
+
|
|
19
|
+
Mini AI code healer (context-aware)
|
|
20
|
+
-------------------------------------
|
|
21
|
+
from healspace.brain import CodeHealer
|
|
22
|
+
|
|
23
|
+
healer = CodeHealer()
|
|
24
|
+
patch = healer.heal_traceback(traceback_text, source_file="app.py")
|
|
25
|
+
print(patch) # shows a diff
|
|
26
|
+
patch.apply() # writes fix back to file
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .core import HealSpace, heal, diagnose, fix
|
|
30
|
+
from .errors import KnownError, ErrorReport
|
|
31
|
+
from .brain import CodeHealer, CodePatch, StyleProfile
|
|
32
|
+
|
|
33
|
+
__version__ = "0.3.0"
|
|
34
|
+
__author__ = "Onyxl · TeraBites"
|
|
35
|
+
__license__ = "Apache 2.0"
|
|
36
|
+
__all__ = [
|
|
37
|
+
# guardian
|
|
38
|
+
"HealSpace", "heal", "diagnose", "fix",
|
|
39
|
+
# error types
|
|
40
|
+
"KnownError", "ErrorReport",
|
|
41
|
+
# mini AI
|
|
42
|
+
"CodeHealer", "CodePatch", "StyleProfile",
|
|
43
|
+
]
|
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"""
|
|
2
|
+
healspace.brain — HealSpace Mini AI
|
|
3
|
+
=====================================
|
|
4
|
+
A lightweight, context-aware code healer.
|
|
5
|
+
|
|
6
|
+
How it works
|
|
7
|
+
------------
|
|
8
|
+
1. **Understand** — reads the lines BEFORE and AFTER the error to learn:
|
|
9
|
+
- indentation style (tabs vs spaces, width)
|
|
10
|
+
- naming convention (snake_case, camelCase, etc.)
|
|
11
|
+
- quote style (' vs ")
|
|
12
|
+
- whether type hints are used
|
|
13
|
+
- comment style (# inline, docstrings, etc.)
|
|
14
|
+
|
|
15
|
+
2. **Diagnose** — matches the error against known patterns (from errors.py)
|
|
16
|
+
and extracts the exact line + file from the traceback.
|
|
17
|
+
|
|
18
|
+
3. **Fix** — generates a drop-in replacement that:
|
|
19
|
+
- uses the SAME style as surrounding code
|
|
20
|
+
- is minimal (touches only what's broken)
|
|
21
|
+
- includes a short inline comment explaining the fix
|
|
22
|
+
|
|
23
|
+
4. **Apply** (optional) — writes the fix back to the source file in-place.
|
|
24
|
+
|
|
25
|
+
Public API
|
|
26
|
+
----------
|
|
27
|
+
from healspace.brain import CodeHealer
|
|
28
|
+
|
|
29
|
+
healer = CodeHealer()
|
|
30
|
+
patch = healer.heal_traceback(traceback_text, source_file="app.py")
|
|
31
|
+
print(patch.fixed_line)
|
|
32
|
+
patch.apply() # writes fix to file
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
import ast
|
|
38
|
+
import textwrap
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Optional, List, Dict, Tuple
|
|
42
|
+
|
|
43
|
+
from .errors import KnownError, ErrorReport
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
# Style fingerprint
|
|
48
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class StyleProfile:
|
|
52
|
+
"""Describes the coding style of a block of source code."""
|
|
53
|
+
indent_char: str = " " # " " or "\t"
|
|
54
|
+
indent_width: int = 4 # spaces per level (irrelevant if tabs)
|
|
55
|
+
quote_char: str = '"' # dominant quote character
|
|
56
|
+
uses_type_hints:bool = False
|
|
57
|
+
naming: str = "snake" # "snake" | "camel" | "pascal" | "mixed"
|
|
58
|
+
trailing_comma: bool = False
|
|
59
|
+
max_line_len: int = 88
|
|
60
|
+
|
|
61
|
+
def indent(self, level: int = 1) -> str:
|
|
62
|
+
if self.indent_char == "\t":
|
|
63
|
+
return "\t" * level
|
|
64
|
+
return " " * (self.indent_width * level)
|
|
65
|
+
|
|
66
|
+
def q(self, s: str) -> str:
|
|
67
|
+
"""Wrap string literal in the dominant quote style."""
|
|
68
|
+
return f"{self.quote_char}{s}{self.quote_char}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _detect_style(lines: List[str]) -> StyleProfile:
|
|
72
|
+
"""Infer StyleProfile from a list of source lines."""
|
|
73
|
+
profile = StyleProfile()
|
|
74
|
+
|
|
75
|
+
# ── Indentation ──────────────────────────────────────────────────────────
|
|
76
|
+
tab_lines = sum(1 for l in lines if l.startswith("\t"))
|
|
77
|
+
space_lines = sum(1 for l in lines if l.startswith(" "))
|
|
78
|
+
if tab_lines > space_lines:
|
|
79
|
+
profile.indent_char = "\t"
|
|
80
|
+
profile.indent_width = 1
|
|
81
|
+
else:
|
|
82
|
+
profile.indent_char = " "
|
|
83
|
+
# detect width from the smallest non-zero indent
|
|
84
|
+
widths = []
|
|
85
|
+
for l in lines:
|
|
86
|
+
stripped = l.lstrip(" ")
|
|
87
|
+
w = len(l) - len(stripped)
|
|
88
|
+
if w > 0:
|
|
89
|
+
widths.append(w)
|
|
90
|
+
if widths:
|
|
91
|
+
profile.indent_width = min(widths)
|
|
92
|
+
|
|
93
|
+
# ── Quote style ──────────────────────────────────────────────────────────
|
|
94
|
+
singles = sum(l.count("'") for l in lines)
|
|
95
|
+
doubles = sum(l.count('"') for l in lines)
|
|
96
|
+
profile.quote_char = "'" if singles > doubles else '"'
|
|
97
|
+
|
|
98
|
+
# ── Type hints ───────────────────────────────────────────────────────────
|
|
99
|
+
profile.uses_type_hints = any(
|
|
100
|
+
re.search(r"def \w+\(.*:.*\) ->|: int|: str|: bool|: float|: list|: dict",
|
|
101
|
+
l) for l in lines
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# ── Naming convention ────────────────────────────────────────────────────
|
|
105
|
+
names = re.findall(r"\b([a-zA-Z_]\w*)\b", " ".join(lines))
|
|
106
|
+
snake = sum(1 for n in names if "_" in n and n == n.lower())
|
|
107
|
+
camel = sum(1 for n in names if re.match(r"[a-z][a-zA-Z0-9]*[A-Z]", n))
|
|
108
|
+
pascal = sum(1 for n in names if re.match(r"[A-Z][a-z]", n))
|
|
109
|
+
if snake >= camel and snake >= pascal:
|
|
110
|
+
profile.naming = "snake"
|
|
111
|
+
elif camel > pascal:
|
|
112
|
+
profile.naming = "camel"
|
|
113
|
+
else:
|
|
114
|
+
profile.naming = "pascal"
|
|
115
|
+
|
|
116
|
+
# ── Trailing commas ──────────────────────────────────────────────────────
|
|
117
|
+
profile.trailing_comma = any(re.search(r",\s*[\)\]\}]", l) for l in lines)
|
|
118
|
+
|
|
119
|
+
# ── Max line length ──────────────────────────────────────────────────────
|
|
120
|
+
lens = [len(l.rstrip()) for l in lines if l.strip()]
|
|
121
|
+
if lens:
|
|
122
|
+
profile.max_line_len = max(79, min(120, max(lens)))
|
|
123
|
+
|
|
124
|
+
return profile
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
# Fix generators — one per error type
|
|
129
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def _fix_missing_dep(
|
|
132
|
+
report: ErrorReport,
|
|
133
|
+
broken_line: str,
|
|
134
|
+
style: StyleProfile,
|
|
135
|
+
context_before: List[str],
|
|
136
|
+
) -> str:
|
|
137
|
+
"""
|
|
138
|
+
ModuleNotFoundError → add a try/except import that falls back gracefully,
|
|
139
|
+
matching the surrounding import block style.
|
|
140
|
+
"""
|
|
141
|
+
pkg = report.title.split(": ", 1)[-1].strip()
|
|
142
|
+
q = style.quote_char
|
|
143
|
+
|
|
144
|
+
# Is the broken line a plain `import X` or `from X import Y`?
|
|
145
|
+
m_from = re.match(r"^(\s*)(from\s+\S+\s+import\s+.+)$", broken_line)
|
|
146
|
+
m_import = re.match(r"^(\s*)(import\s+\S+.*)$", broken_line)
|
|
147
|
+
|
|
148
|
+
if m_from:
|
|
149
|
+
ind, stmt = m_from.group(1), m_from.group(2)
|
|
150
|
+
return (
|
|
151
|
+
f"{ind}try:\n"
|
|
152
|
+
f"{ind} {stmt}\n"
|
|
153
|
+
f"{ind}except ImportError:\n"
|
|
154
|
+
f"{ind} raise ImportError(\n"
|
|
155
|
+
f"{ind} {q}HealSpace: {pkg!r} is missing. \"\n"
|
|
156
|
+
f"{ind} \"Add {q}{pkg}{q} to requirements.txt and restart the Space.{q}\n"
|
|
157
|
+
f"{ind} )\n"
|
|
158
|
+
)
|
|
159
|
+
elif m_import:
|
|
160
|
+
ind, stmt = m_import.group(1), m_import.group(2)
|
|
161
|
+
alias = re.sub(r"import\s+(\S+).*", r"\1", stmt).split(".")[0]
|
|
162
|
+
return (
|
|
163
|
+
f"{ind}try:\n"
|
|
164
|
+
f"{ind} {stmt}\n"
|
|
165
|
+
f"{ind}except ImportError:\n"
|
|
166
|
+
f"{ind} {alias} = None "
|
|
167
|
+
f"# HealSpace: install {q}{pkg}{q} to enable this feature\n"
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
ind = re.match(r"^(\s*)", broken_line).group(1)
|
|
171
|
+
return (
|
|
172
|
+
f"{ind}# HealSpace fix: '{pkg}' not found — "
|
|
173
|
+
f"add it to requirements.txt\n"
|
|
174
|
+
f"{broken_line}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _fix_port_conflict(
|
|
179
|
+
report: ErrorReport,
|
|
180
|
+
broken_line: str,
|
|
181
|
+
style: StyleProfile,
|
|
182
|
+
context_before: List[str],
|
|
183
|
+
) -> str:
|
|
184
|
+
"""
|
|
185
|
+
Port already in use → add server_port with a fallback scan,
|
|
186
|
+
or wrap launch() with a retry loop matching existing style.
|
|
187
|
+
"""
|
|
188
|
+
ind = re.match(r"^(\s*)", broken_line).group(1)
|
|
189
|
+
q = style.quote_char
|
|
190
|
+
|
|
191
|
+
# If the broken line is a .launch() call, patch it
|
|
192
|
+
if "launch" in broken_line:
|
|
193
|
+
# Extract existing kwargs
|
|
194
|
+
has_share = "share" in broken_line
|
|
195
|
+
share_kw = ", share=True" if has_share else ""
|
|
196
|
+
return (
|
|
197
|
+
f"{ind}# HealSpace fix: auto-find a free port if 7860 is taken\n"
|
|
198
|
+
f"{ind}import socket as _hs_sock\n"
|
|
199
|
+
f"{ind}def _hs_free_port(start=7860):\n"
|
|
200
|
+
f"{ind} for p in range(start, start + 20):\n"
|
|
201
|
+
f"{ind} try:\n"
|
|
202
|
+
f"{ind} _hs_sock.socket().bind(({q}0.0.0.0{q}, p)); return p\n"
|
|
203
|
+
f"{ind} except OSError:\n"
|
|
204
|
+
f"{ind} continue\n"
|
|
205
|
+
f"{ind} return start\n"
|
|
206
|
+
f"{ind}demo.launch(server_port=_hs_free_port(){share_kw})\n"
|
|
207
|
+
)
|
|
208
|
+
return (
|
|
209
|
+
f"{ind}# HealSpace fix: port conflict — restart Space or change server_port\n"
|
|
210
|
+
f"{broken_line}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _fix_gpu_oom(
|
|
215
|
+
report: ErrorReport,
|
|
216
|
+
broken_line: str,
|
|
217
|
+
style: StyleProfile,
|
|
218
|
+
context_before: List[str],
|
|
219
|
+
) -> str:
|
|
220
|
+
"""CUDA OOM → wrap the offending call with empty_cache + half-precision hint."""
|
|
221
|
+
ind = re.match(r"^(\s*)", broken_line).group(1)
|
|
222
|
+
return (
|
|
223
|
+
f"{ind}# HealSpace fix: clear CUDA cache before this call\n"
|
|
224
|
+
f"{ind}try:\n"
|
|
225
|
+
f"{ind} import torch as _hs_torch; _hs_torch.cuda.empty_cache()\n"
|
|
226
|
+
f"{ind}except Exception:\n"
|
|
227
|
+
f"{ind} pass\n"
|
|
228
|
+
f"{broken_line}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _fix_syntax_error(
|
|
233
|
+
report: ErrorReport,
|
|
234
|
+
broken_line: str,
|
|
235
|
+
style: StyleProfile,
|
|
236
|
+
context_before: List[str],
|
|
237
|
+
) -> str:
|
|
238
|
+
"""
|
|
239
|
+
SyntaxError → attempt common automatic repairs:
|
|
240
|
+
- unmatched brackets
|
|
241
|
+
- missing colon after def/class/if/for/while/with/else/elif/except/finally
|
|
242
|
+
- obvious string not closed
|
|
243
|
+
"""
|
|
244
|
+
line = broken_line.rstrip()
|
|
245
|
+
ind = re.match(r"^(\s*)", line).group(1)
|
|
246
|
+
fixed = line
|
|
247
|
+
|
|
248
|
+
# Missing colon
|
|
249
|
+
if re.match(
|
|
250
|
+
r"^\s*(def |class |if |elif |else|for |while |with |try|except|finally)",
|
|
251
|
+
line
|
|
252
|
+
) and not line.rstrip().endswith(":"):
|
|
253
|
+
fixed = line.rstrip() + ":"
|
|
254
|
+
|
|
255
|
+
# Unbalanced brackets — add missing closers
|
|
256
|
+
opens = {"(": ")", "[": "]", "{": "}"}
|
|
257
|
+
stack = []
|
|
258
|
+
in_str = False
|
|
259
|
+
str_ch = None
|
|
260
|
+
for ch in fixed:
|
|
261
|
+
if in_str:
|
|
262
|
+
if ch == str_ch:
|
|
263
|
+
in_str = False
|
|
264
|
+
else:
|
|
265
|
+
if ch in ('"', "'"):
|
|
266
|
+
in_str = True; str_ch = ch
|
|
267
|
+
elif ch in opens:
|
|
268
|
+
stack.append(opens[ch])
|
|
269
|
+
elif ch in opens.values() and stack and stack[-1] == ch:
|
|
270
|
+
stack.pop()
|
|
271
|
+
if stack:
|
|
272
|
+
fixed = fixed.rstrip() + "".join(reversed(stack))
|
|
273
|
+
|
|
274
|
+
if fixed != line:
|
|
275
|
+
return (
|
|
276
|
+
f"{ind}# HealSpace fix: auto-corrected syntax\n"
|
|
277
|
+
f"{fixed}\n"
|
|
278
|
+
)
|
|
279
|
+
# Can't auto-fix — leave with comment
|
|
280
|
+
return (
|
|
281
|
+
f"{ind}# HealSpace: syntax error here — check brackets, colons, and quotes\n"
|
|
282
|
+
f"{broken_line}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _fix_auth_error(
|
|
287
|
+
report: ErrorReport,
|
|
288
|
+
broken_line: str,
|
|
289
|
+
style: StyleProfile,
|
|
290
|
+
context_before: List[str],
|
|
291
|
+
) -> str:
|
|
292
|
+
"""Auth error → inject HF_TOKEN env lookup before the offending call."""
|
|
293
|
+
ind = re.match(r"^(\s*)", broken_line).group(1)
|
|
294
|
+
q = style.quote_char
|
|
295
|
+
return (
|
|
296
|
+
f"{ind}# HealSpace fix: ensure HF_TOKEN is set as a Space secret\n"
|
|
297
|
+
f"{ind}import os as _hs_os\n"
|
|
298
|
+
f"{ind}if not _hs_os.environ.get({q}HF_TOKEN{q}):\n"
|
|
299
|
+
f"{ind} raise EnvironmentError(\n"
|
|
300
|
+
f"{ind} {q}HealSpace: Add HF_TOKEN as a secret in Space Settings.{q}\n"
|
|
301
|
+
f"{ind} )\n"
|
|
302
|
+
f"{broken_line}"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _fix_runtime_error(
|
|
307
|
+
report: ErrorReport,
|
|
308
|
+
broken_line: str,
|
|
309
|
+
style: StyleProfile,
|
|
310
|
+
context_before: List[str],
|
|
311
|
+
) -> str:
|
|
312
|
+
"""Generic RuntimeError → wrap in try/except with descriptive re-raise."""
|
|
313
|
+
ind = re.match(r"^(\s*)", broken_line).group(1)
|
|
314
|
+
q = style.quote_char
|
|
315
|
+
desc = report.description[:80].replace(q, "\\" + q)
|
|
316
|
+
return (
|
|
317
|
+
f"{ind}# HealSpace fix: guarded against RuntimeError\n"
|
|
318
|
+
f"{ind}try:\n"
|
|
319
|
+
f"{ind} {broken_line.strip()}\n"
|
|
320
|
+
f"{ind}except RuntimeError as _hs_e:\n"
|
|
321
|
+
f"{ind} print(f{q}[HealSpace] RuntimeError caught: {{_hs_e}}{q})\n"
|
|
322
|
+
f"{ind} raise\n"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_FIX_GENERATORS: Dict[str, callable] = {
|
|
327
|
+
"missing_dep": _fix_missing_dep,
|
|
328
|
+
"port_conflict": _fix_port_conflict,
|
|
329
|
+
"gpu_oom": _fix_gpu_oom,
|
|
330
|
+
"syntax_error": _fix_syntax_error,
|
|
331
|
+
"auth_error": _fix_auth_error,
|
|
332
|
+
"runtime_error": _fix_runtime_error,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
# fallback for error types without a dedicated generator
|
|
336
|
+
def _fix_generic(
|
|
337
|
+
report: ErrorReport,
|
|
338
|
+
broken_line: str,
|
|
339
|
+
style: StyleProfile,
|
|
340
|
+
context_before: List[str],
|
|
341
|
+
) -> str:
|
|
342
|
+
ind = re.match(r"^(\s*)", broken_line).group(1)
|
|
343
|
+
return (
|
|
344
|
+
f"{ind}# HealSpace: {report.title}\n"
|
|
345
|
+
f"{ind}# Suggested: {report.fixes[0] if report.fixes else 'see traceback'}\n"
|
|
346
|
+
f"{broken_line}"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
351
|
+
# Patch dataclass
|
|
352
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
@dataclass
|
|
355
|
+
class CodePatch:
|
|
356
|
+
"""
|
|
357
|
+
A ready-to-apply code fix produced by CodeHealer.
|
|
358
|
+
|
|
359
|
+
Attributes
|
|
360
|
+
----------
|
|
361
|
+
source_file : path to the file that was healed (may be None)
|
|
362
|
+
error_line : 1-based line number of the broken line
|
|
363
|
+
original : the original broken line(s)
|
|
364
|
+
fixed : the replacement code (may span multiple lines)
|
|
365
|
+
report : the ErrorReport that triggered this fix
|
|
366
|
+
style : the StyleProfile inferred from surrounding code
|
|
367
|
+
context_before : lines above the error (for display)
|
|
368
|
+
context_after : lines below the error (for display)
|
|
369
|
+
"""
|
|
370
|
+
source_file: Optional[str]
|
|
371
|
+
error_line: int
|
|
372
|
+
original: str
|
|
373
|
+
fixed: str
|
|
374
|
+
report: ErrorReport
|
|
375
|
+
style: StyleProfile
|
|
376
|
+
context_before: List[str] = field(default_factory=list)
|
|
377
|
+
context_after: List[str] = field(default_factory=list)
|
|
378
|
+
|
|
379
|
+
# ── Display ───────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
def diff(self) -> str:
|
|
382
|
+
"""Return a human-readable unified-diff-style patch."""
|
|
383
|
+
sep = "─" * 60
|
|
384
|
+
lines = [
|
|
385
|
+
sep,
|
|
386
|
+
f" HealSpace AI — {self.report.title}",
|
|
387
|
+
f" File: {self.source_file or '(unknown)'} line {self.error_line}",
|
|
388
|
+
sep,
|
|
389
|
+
"",
|
|
390
|
+
]
|
|
391
|
+
for l in self.context_before[-3:]:
|
|
392
|
+
lines.append(f" {l.rstrip()}")
|
|
393
|
+
for l in self.original.splitlines():
|
|
394
|
+
lines.append(f"- {l.rstrip()}")
|
|
395
|
+
for l in self.fixed.splitlines():
|
|
396
|
+
lines.append(f"+ {l.rstrip()}")
|
|
397
|
+
for l in self.context_after[:3]:
|
|
398
|
+
lines.append(f" {l.rstrip()}")
|
|
399
|
+
lines += ["", f" Style: indent={self.style.indent_width}sp "
|
|
400
|
+
f"quotes={self.style.quote_char} "
|
|
401
|
+
f"hints={self.style.uses_type_hints} "
|
|
402
|
+
f"naming={self.style.naming}", sep]
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
def apply(self, dry_run: bool = False) -> bool:
|
|
406
|
+
"""
|
|
407
|
+
Write the fix back to *source_file* in-place.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
dry_run : bool
|
|
412
|
+
If True, print what would change but don't write.
|
|
413
|
+
|
|
414
|
+
Returns
|
|
415
|
+
-------
|
|
416
|
+
True if the file was (or would be) modified, False otherwise.
|
|
417
|
+
"""
|
|
418
|
+
if not self.source_file:
|
|
419
|
+
print("[HealSpace Brain] No source file — cannot apply patch.")
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
path = Path(self.source_file)
|
|
423
|
+
if not path.exists():
|
|
424
|
+
print(f"[HealSpace Brain] File not found: {path}")
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
428
|
+
idx = self.error_line - 1 # 0-based
|
|
429
|
+
if idx < 0 or idx >= len(lines):
|
|
430
|
+
print(f"[HealSpace Brain] Line {self.error_line} out of range.")
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
fixed_lines = [
|
|
434
|
+
l if l.endswith("\n") else l + "\n"
|
|
435
|
+
for l in self.fixed.splitlines()
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
if dry_run:
|
|
439
|
+
print(self.diff())
|
|
440
|
+
print("[HealSpace Brain] (dry run — file not modified)")
|
|
441
|
+
return True
|
|
442
|
+
|
|
443
|
+
lines[idx : idx + 1] = fixed_lines
|
|
444
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
445
|
+
print(f"[HealSpace Brain] ✅ Patch applied → {path} (line {self.error_line})")
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
def __str__(self) -> str:
|
|
449
|
+
return self.diff()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
453
|
+
# Traceback parser
|
|
454
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
455
|
+
|
|
456
|
+
def _parse_traceback(tb: str) -> Tuple[Optional[str], Optional[int], Optional[str]]:
|
|
457
|
+
"""
|
|
458
|
+
Extract (filename, lineno, line_text) from a Python traceback string.
|
|
459
|
+
Returns the INNERMOST (deepest) frame, which is the root cause.
|
|
460
|
+
"""
|
|
461
|
+
# Matches: File "app.py", line 42, in some_func
|
|
462
|
+
frame_re = re.compile(r'File "([^"]+)", line (\d+)')
|
|
463
|
+
line_re = re.compile(r'^\s{4}(.+)$') # the code line after the frame
|
|
464
|
+
|
|
465
|
+
matches = list(frame_re.finditer(tb))
|
|
466
|
+
if not matches:
|
|
467
|
+
return None, None, None
|
|
468
|
+
|
|
469
|
+
# Take the last frame
|
|
470
|
+
m = matches[-1]
|
|
471
|
+
filename = m.group(1)
|
|
472
|
+
lineno = int(m.group(2))
|
|
473
|
+
|
|
474
|
+
# The line of code is the next non-empty line after the frame header
|
|
475
|
+
after = tb[m.end():]
|
|
476
|
+
code_line = None
|
|
477
|
+
for raw in after.splitlines():
|
|
478
|
+
stripped = raw.strip()
|
|
479
|
+
if stripped and not stripped.startswith("^") and not stripped.startswith("~"):
|
|
480
|
+
code_line = raw
|
|
481
|
+
break
|
|
482
|
+
|
|
483
|
+
return filename, lineno, code_line
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
487
|
+
# Main healer
|
|
488
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
class CodeHealer:
|
|
491
|
+
"""
|
|
492
|
+
HealSpace Mini AI — context-aware code fixer.
|
|
493
|
+
|
|
494
|
+
Parameters
|
|
495
|
+
----------
|
|
496
|
+
context_window : int
|
|
497
|
+
How many lines above and below the error to read for style analysis
|
|
498
|
+
and context display (default 15).
|
|
499
|
+
auto_apply : bool
|
|
500
|
+
If True, automatically write fixes back to source files (default False).
|
|
501
|
+
verbose : bool
|
|
502
|
+
Print detailed logs (default True).
|
|
503
|
+
|
|
504
|
+
Example
|
|
505
|
+
-------
|
|
506
|
+
>>> from healspace.brain import CodeHealer
|
|
507
|
+
>>> healer = CodeHealer()
|
|
508
|
+
>>> patch = healer.heal_traceback(traceback_text, source_file="app.py")
|
|
509
|
+
>>> print(patch) # diff view
|
|
510
|
+
>>> patch.apply() # write fix to file
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
def __init__(
|
|
514
|
+
self,
|
|
515
|
+
context_window: int = 15,
|
|
516
|
+
auto_apply: bool = False,
|
|
517
|
+
verbose: bool = True,
|
|
518
|
+
):
|
|
519
|
+
self.context_window = context_window
|
|
520
|
+
self.auto_apply = auto_apply
|
|
521
|
+
self.verbose = verbose
|
|
522
|
+
|
|
523
|
+
# ── Internal helpers ──────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
def _read_context(
|
|
526
|
+
self, filepath: str, lineno: int
|
|
527
|
+
) -> Tuple[List[str], str, List[str]]:
|
|
528
|
+
"""
|
|
529
|
+
Read (lines_before, broken_line, lines_after) from *filepath*.
|
|
530
|
+
Falls back gracefully if the file can't be read.
|
|
531
|
+
"""
|
|
532
|
+
try:
|
|
533
|
+
all_lines = Path(filepath).read_text(encoding="utf-8").splitlines()
|
|
534
|
+
except Exception:
|
|
535
|
+
return [], "", []
|
|
536
|
+
|
|
537
|
+
idx = lineno - 1 # 0-based
|
|
538
|
+
before = all_lines[max(0, idx - self.context_window) : idx]
|
|
539
|
+
broken = all_lines[idx] if idx < len(all_lines) else ""
|
|
540
|
+
after = all_lines[idx + 1 : idx + 1 + self.context_window]
|
|
541
|
+
return before, broken, after
|
|
542
|
+
|
|
543
|
+
def _generate_fix(
|
|
544
|
+
self,
|
|
545
|
+
report: ErrorReport,
|
|
546
|
+
broken_line: str,
|
|
547
|
+
style: StyleProfile,
|
|
548
|
+
context_before: List[str],
|
|
549
|
+
) -> str:
|
|
550
|
+
"""Dispatch to the appropriate fix generator."""
|
|
551
|
+
generator = _FIX_GENERATORS.get(report.error_type, _fix_generic)
|
|
552
|
+
return generator(report, broken_line, style, context_before)
|
|
553
|
+
|
|
554
|
+
# ── Public API ────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
def heal_traceback(
|
|
557
|
+
self,
|
|
558
|
+
traceback_text: str,
|
|
559
|
+
source_file: Optional[str] = None,
|
|
560
|
+
) -> Optional[CodePatch]:
|
|
561
|
+
"""
|
|
562
|
+
Full healing pipeline:
|
|
563
|
+
parse traceback → diagnose → read context → detect style → generate fix.
|
|
564
|
+
|
|
565
|
+
Parameters
|
|
566
|
+
----------
|
|
567
|
+
traceback_text : str
|
|
568
|
+
The full Python traceback (as printed to stderr).
|
|
569
|
+
source_file : str, optional
|
|
570
|
+
Override the filename from the traceback (useful in Gradio callbacks
|
|
571
|
+
where the path is /tmp/...).
|
|
572
|
+
|
|
573
|
+
Returns
|
|
574
|
+
-------
|
|
575
|
+
CodePatch if a fix was generated, None if the error was unrecognised.
|
|
576
|
+
"""
|
|
577
|
+
if self.verbose:
|
|
578
|
+
print("[HealSpace Brain] 🧠 Analysing traceback…")
|
|
579
|
+
|
|
580
|
+
# 1. Diagnose
|
|
581
|
+
report = KnownError.match(traceback_text)
|
|
582
|
+
if report is None:
|
|
583
|
+
if self.verbose:
|
|
584
|
+
print("[HealSpace Brain] No known error pattern matched.")
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
if self.verbose:
|
|
588
|
+
print(f"[HealSpace Brain] 🔍 Detected: {report.title}")
|
|
589
|
+
|
|
590
|
+
# 2. Parse traceback for file + line
|
|
591
|
+
tb_file, lineno, tb_code_line = _parse_traceback(traceback_text)
|
|
592
|
+
filepath = source_file or tb_file
|
|
593
|
+
lineno = lineno or 1
|
|
594
|
+
|
|
595
|
+
# 3. Read surrounding code from file
|
|
596
|
+
context_before, broken_line, context_after = [], "", []
|
|
597
|
+
if filepath:
|
|
598
|
+
context_before, broken_line, context_after = self._read_context(
|
|
599
|
+
filepath, lineno
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# If we couldn't read the file, use the line from the traceback itself
|
|
603
|
+
if not broken_line and tb_code_line:
|
|
604
|
+
broken_line = tb_code_line.strip()
|
|
605
|
+
|
|
606
|
+
if not broken_line:
|
|
607
|
+
broken_line = f"# (line {lineno} — source unavailable)"
|
|
608
|
+
|
|
609
|
+
# 4. Detect style from surrounding code
|
|
610
|
+
all_context = context_before + [broken_line] + context_after
|
|
611
|
+
style = _detect_style(all_context)
|
|
612
|
+
|
|
613
|
+
if self.verbose:
|
|
614
|
+
print(
|
|
615
|
+
f"[HealSpace Brain] 📐 Style: "
|
|
616
|
+
f"indent={style.indent_width}{'tab' if style.indent_char == chr(9) else 'sp'} "
|
|
617
|
+
f"quotes={style.quote_char} "
|
|
618
|
+
f"type_hints={style.uses_type_hints} "
|
|
619
|
+
f"naming={style.naming}"
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# 5. Generate fix
|
|
623
|
+
fixed = self._generate_fix(report, broken_line, style, context_before)
|
|
624
|
+
|
|
625
|
+
if self.verbose:
|
|
626
|
+
print(f"[HealSpace Brain] 🔧 Fix generated for line {lineno}")
|
|
627
|
+
|
|
628
|
+
patch = CodePatch(
|
|
629
|
+
source_file = filepath,
|
|
630
|
+
error_line = lineno,
|
|
631
|
+
original = broken_line,
|
|
632
|
+
fixed = fixed,
|
|
633
|
+
report = report,
|
|
634
|
+
style = style,
|
|
635
|
+
context_before = context_before,
|
|
636
|
+
context_after = context_after,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
if self.auto_apply:
|
|
640
|
+
patch.apply()
|
|
641
|
+
|
|
642
|
+
return patch
|
|
643
|
+
|
|
644
|
+
def heal_file(
|
|
645
|
+
self,
|
|
646
|
+
source_file: str,
|
|
647
|
+
traceback_text: str,
|
|
648
|
+
) -> Optional[CodePatch]:
|
|
649
|
+
"""Convenience wrapper — same as heal_traceback with source_file forced."""
|
|
650
|
+
return self.heal_traceback(traceback_text, source_file=source_file)
|
|
651
|
+
|
|
652
|
+
def heal_code(
|
|
653
|
+
self,
|
|
654
|
+
code: str,
|
|
655
|
+
error_text: str,
|
|
656
|
+
lineno: int = 1,
|
|
657
|
+
) -> Optional[CodePatch]:
|
|
658
|
+
"""
|
|
659
|
+
Heal an in-memory code string without a file on disk.
|
|
660
|
+
|
|
661
|
+
Parameters
|
|
662
|
+
----------
|
|
663
|
+
code : full source code as a string
|
|
664
|
+
error_text : error message or traceback
|
|
665
|
+
lineno : 1-based line number of the broken line
|
|
666
|
+
|
|
667
|
+
Returns
|
|
668
|
+
-------
|
|
669
|
+
CodePatch (source_file=None, apply() will no-op)
|
|
670
|
+
"""
|
|
671
|
+
report = KnownError.match(error_text)
|
|
672
|
+
if report is None:
|
|
673
|
+
return None
|
|
674
|
+
|
|
675
|
+
lines = code.splitlines()
|
|
676
|
+
idx = lineno - 1
|
|
677
|
+
broken_line = lines[idx] if 0 <= idx < len(lines) else ""
|
|
678
|
+
before = lines[max(0, idx - self.context_window) : idx]
|
|
679
|
+
after = lines[idx + 1 : idx + 1 + self.context_window]
|
|
680
|
+
style = _detect_style(before + [broken_line] + after)
|
|
681
|
+
fixed = self._generate_fix(report, broken_line, style, before)
|
|
682
|
+
|
|
683
|
+
return CodePatch(
|
|
684
|
+
source_file = None,
|
|
685
|
+
error_line = lineno,
|
|
686
|
+
original = broken_line,
|
|
687
|
+
fixed = fixed,
|
|
688
|
+
report = report,
|
|
689
|
+
style = style,
|
|
690
|
+
context_before = before,
|
|
691
|
+
context_after = after,
|
|
692
|
+
)
|
|
@@ -12,9 +12,12 @@ import subprocess
|
|
|
12
12
|
from typing import Optional, List, Callable
|
|
13
13
|
|
|
14
14
|
from .errors import KnownError, ErrorReport
|
|
15
|
+
from .brain import CodeHealer, CodePatch
|
|
15
16
|
|
|
16
17
|
# ── Lineage seal ──────────────────────────────────────────────────────────────
|
|
17
|
-
SEAL
|
|
18
|
+
SEAL = "🛡️ Protected by HealSpace"
|
|
19
|
+
SEAL_URL = "https://pypi.org/project/healspace/"
|
|
20
|
+
SEAL_MD = "🛡️ Protected by HealSpace — [click here to learn more](https://pypi.org/project/healspace/)"
|
|
18
21
|
|
|
19
22
|
# ── Auto-fix actions ──────────────────────────────────────────────────────────
|
|
20
23
|
|
|
@@ -221,7 +224,7 @@ class HealSpace:
|
|
|
221
224
|
for r in reports:
|
|
222
225
|
lines.append(str(r))
|
|
223
226
|
lines.append("")
|
|
224
|
-
lines.append(SEAL)
|
|
227
|
+
lines.append(f"{SEAL} — learn more: {SEAL_URL}")
|
|
225
228
|
return "\n".join(lines)
|
|
226
229
|
|
|
227
230
|
def _trigger_error() -> str:
|
|
@@ -236,7 +239,71 @@ class HealSpace:
|
|
|
236
239
|
heal_btn.click (_run_heal, inputs=log_input, outputs=output_box)
|
|
237
240
|
error_btn.click(_trigger_error, inputs=None, outputs=output_box)
|
|
238
241
|
|
|
239
|
-
|
|
242
|
+
# ── Mini AI Code Healer panel ──────────────────────────────────
|
|
243
|
+
with gr.Accordion("🧠 HealSpace AI — Code Healer", open=False):
|
|
244
|
+
gr.Markdown(
|
|
245
|
+
"Paste a **traceback** and the **broken source code** below.\n\n"
|
|
246
|
+
"The AI reads the style of your code (indentation, quotes, naming, "
|
|
247
|
+
"type hints) and writes a drop-in fix that matches it perfectly."
|
|
248
|
+
)
|
|
249
|
+
with gr.Row():
|
|
250
|
+
ai_tb_input = gr.Textbox(
|
|
251
|
+
label = "Traceback / Error",
|
|
252
|
+
placeholder = "Paste the full traceback here…",
|
|
253
|
+
lines = 6,
|
|
254
|
+
)
|
|
255
|
+
ai_code_input = gr.Textbox(
|
|
256
|
+
label = "Source Code (optional — paste the full file)",
|
|
257
|
+
placeholder = "Paste your app.py or the relevant module here…",
|
|
258
|
+
lines = 6,
|
|
259
|
+
)
|
|
260
|
+
ai_lineno = gr.Number(
|
|
261
|
+
label = "Error line number (from traceback)",
|
|
262
|
+
value = 1,
|
|
263
|
+
minimum = 1,
|
|
264
|
+
step = 1,
|
|
265
|
+
precision = 0,
|
|
266
|
+
)
|
|
267
|
+
ai_btn = gr.Button("🧠 Analyse & Generate Fix", variant="primary")
|
|
268
|
+
ai_output = gr.Code(
|
|
269
|
+
label = "HealSpace AI — Generated Fix",
|
|
270
|
+
language = "python",
|
|
271
|
+
lines = 14,
|
|
272
|
+
interactive = True,
|
|
273
|
+
)
|
|
274
|
+
ai_diff = gr.Textbox(
|
|
275
|
+
label = "Diff View",
|
|
276
|
+
lines = 10,
|
|
277
|
+
interactive = False,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _run_ai_heal(tb_text: str, code_text: str, lineno: int) -> tuple:
|
|
281
|
+
healer = CodeHealer(verbose=False)
|
|
282
|
+
if code_text.strip():
|
|
283
|
+
patch = healer.heal_code(
|
|
284
|
+
code = code_text,
|
|
285
|
+
error_text = tb_text,
|
|
286
|
+
lineno = max(1, int(lineno)),
|
|
287
|
+
)
|
|
288
|
+
elif tb_text.strip():
|
|
289
|
+
patch = healer.heal_traceback(tb_text)
|
|
290
|
+
else:
|
|
291
|
+
return ("# Paste a traceback above first.", "")
|
|
292
|
+
if patch is None:
|
|
293
|
+
return (
|
|
294
|
+
"# HealSpace AI: no known error pattern matched.\n"
|
|
295
|
+
"# Try pasting more of the traceback.",
|
|
296
|
+
"",
|
|
297
|
+
)
|
|
298
|
+
return patch.fixed, patch.diff()
|
|
299
|
+
|
|
300
|
+
ai_btn.click(
|
|
301
|
+
_run_ai_heal,
|
|
302
|
+
inputs = [ai_tb_input, ai_code_input, ai_lineno],
|
|
303
|
+
outputs = [ai_output, ai_diff],
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
gr.Markdown(f"<center><sub>{SEAL_MD}</sub></center>")
|
|
240
307
|
|
|
241
308
|
return self
|
|
242
309
|
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
HealSpace — Guardian AI for Hugging Face Spaces
|
|
3
|
-
================================================
|
|
4
|
-
Automatically detects backend errors, heals common issues,
|
|
5
|
-
and protects your Space with a branded lineage seal.
|
|
6
|
-
|
|
7
|
-
Quick start
|
|
8
|
-
-----------
|
|
9
|
-
In your Space's app.py, wrap your Gradio app:
|
|
10
|
-
|
|
11
|
-
import gradio as gr
|
|
12
|
-
from healspace import heal
|
|
13
|
-
|
|
14
|
-
with gr.Blocks() as demo:
|
|
15
|
-
gr.Markdown("# My Space")
|
|
16
|
-
# ... your UI ...
|
|
17
|
-
|
|
18
|
-
heal(demo) # wraps, protects, and launches
|
|
19
|
-
demo.launch()
|
|
20
|
-
|
|
21
|
-
Or use the standalone guardian (non-Gradio):
|
|
22
|
-
|
|
23
|
-
from healspace import HealSpace
|
|
24
|
-
hs = HealSpace()
|
|
25
|
-
hs.watch() # starts background health monitor
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from .core import HealSpace, heal, diagnose, fix
|
|
29
|
-
from .errors import KnownError, ErrorReport
|
|
30
|
-
|
|
31
|
-
__version__ = "0.2.0"
|
|
32
|
-
__author__ = "Onyxl · TeraBites"
|
|
33
|
-
__license__ = "Apache 2.0"
|
|
34
|
-
__all__ = ["HealSpace", "heal", "diagnose", "fix", "KnownError", "ErrorReport"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|