Add Python package entry point

#3
README.md CHANGED
@@ -27,6 +27,26 @@ The app includes two modes:
27
 
28
  The model never edits code directly. It only proposes comments, and the app rejects any annotated file whose semantic AST changes.
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  ## Hackathon Fit
31
 
32
  - **Track:** Backyard AI
 
27
 
28
  The model never edits code directly. It only proposes comments, and the app rejects any annotated file whose semantic AST changes.
29
 
30
+ ## CLI Usage
31
+
32
+ The same annotation pipeline is available from the command line:
33
+
34
+ ```bash
35
+ pip install -e .
36
+ between-the-lines path/to/file.py --model base --output annotated.py --summary
37
+ btl path/to/file.py --check --summary
38
+ ```
39
+
40
+ You can also run it without installing a console command:
41
+
42
+ ```bash
43
+ python -m btl.cli path/to/file.py --model base --output annotated.py --summary
44
+ python -m btl.cli path/to/file.py --model tuned --in-place
45
+ python -m btl.cli path/to/file.py --check --summary
46
+ ```
47
+
48
+ `--model base` uses the richer Mellum2 GGUF path. `--model tuned` uses the LoRA adapter for shorter comments. Both modes run the AST validation before writing output.
49
+
50
  ## Hackathon Fit
51
 
52
  - **Track:** Backyard AI
app.py CHANGED
@@ -1,12 +1,11 @@
1
- import ast
2
  import html
3
- from dataclasses import dataclass
4
  from pathlib import Path
5
  from typing import Any
6
 
7
  import gradio as gr
8
 
9
- from btl.model import ModelUnavailableError, generate_comment, generate_comment_with_llm, load_llm
 
10
 
11
 
12
  CSS = """
@@ -156,130 +155,18 @@ code {
156
  """
157
 
158
 
159
- @dataclass(frozen=True)
160
- class BlockInfo:
161
- kind: str
162
- name: str
163
- lineno: int
164
- source: str
165
-
166
-
167
- def _parse_python(source: str) -> ast.Module:
168
- return ast.parse(source)
169
-
170
-
171
- def _strip_docstrings(node: ast.AST) -> ast.AST:
172
- copied = ast.fix_missing_locations(ast.parse(ast.unparse(node)))
173
- for child in ast.walk(copied):
174
- if isinstance(child, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
175
- if (
176
- child.body
177
- and isinstance(child.body[0], ast.Expr)
178
- and isinstance(child.body[0].value, ast.Constant)
179
- and isinstance(child.body[0].value.value, str)
180
- ):
181
- child.body = child.body[1:]
182
- return copied
183
-
184
-
185
- def _semantic_ast_dump(source: str) -> str:
186
- tree = _parse_python(source)
187
- stripped = _strip_docstrings(tree)
188
- return ast.dump(stripped, include_attributes=False)
189
-
190
-
191
- def _collect_blocks(tree: ast.Module, source: str) -> list[BlockInfo]:
192
- blocks: list[BlockInfo] = []
193
- for node in ast.walk(tree):
194
- if isinstance(node, ast.ClassDef):
195
- block_source = ast.get_source_segment(source, node) or ast.unparse(node)
196
- blocks.append(BlockInfo("class", node.name, node.lineno, block_source))
197
- elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
198
- kind = "async function" if isinstance(node, ast.AsyncFunctionDef) else "function"
199
- block_source = ast.get_source_segment(source, node) or ast.unparse(node)
200
- blocks.append(BlockInfo(kind, node.name, node.lineno, block_source))
201
- return sorted(blocks, key=lambda item: item.lineno)
202
-
203
-
204
- def _placeholder_summary(blocks: list[BlockInfo]) -> str:
205
- if not blocks:
206
- return "This Python file has no classes or functions to annotate yet."
207
- names = ", ".join(f"{block.kind} `{block.name}`" for block in blocks[:8])
208
- overflow = "" if len(blocks) <= 8 else f", plus {len(blocks) - 8} more"
209
- return f"This file defines {names}{overflow}. Model-generated summaries will replace this deterministic placeholder."
210
-
211
-
212
- def _insert_comments(source: str, comments: dict[int, str]) -> str:
213
- lines = source.splitlines()
214
- inserts: dict[int, list[str]] = {}
215
- for lineno, comment_text in comments.items():
216
- indent = len(lines[lineno - 1]) - len(lines[lineno - 1].lstrip())
217
- inserts.setdefault(lineno - 1, []).append(" " * indent + comment_text)
218
-
219
- annotated: list[str] = []
220
- for index, line in enumerate(lines):
221
- annotated.extend(inserts.get(index, []))
222
- annotated.append(line)
223
- return "\n".join(annotated) + ("\n" if source.endswith("\n") else "")
224
-
225
-
226
  MODEL_LABELS = {
227
  "Base Mellum2 (richer)": "base",
228
  "Fine-tuned LoRA (concise)": "tuned",
229
  }
230
 
231
 
232
- def _generate_block_comments(blocks: list[BlockInfo], model_label: str) -> tuple[dict[int, str], list[str]]:
233
- comments: dict[int, str] = {}
234
- notes: list[str] = []
235
- model_variant = MODEL_LABELS.get(model_label, "base")
236
- llm = load_llm() if model_variant == "base" else None
237
- for block in blocks:
238
- try:
239
- if model_variant == "base":
240
- comments[block.lineno] = generate_comment_with_llm(llm, block.kind, block.name, block.source)
241
- else:
242
- comments[block.lineno] = generate_comment(block.kind, block.name, block.source, variant="tuned")
243
- except Exception as exc:
244
- comments[block.lineno] = f"# TODO(model): Could not explain {block.kind} `{block.name}`."
245
- notes.append(f"{block.name}: model generation failed ({type(exc).__name__}).")
246
- return comments, notes
247
-
248
-
249
  def annotate_code(source: str, model_label: str) -> tuple[str, str, str]:
250
- source = source.strip("\ufeff")
251
- if not source.strip():
252
- return "", "", "Paste a Python file to annotate."
253
-
254
- try:
255
- original_tree = _parse_python(source)
256
- except SyntaxError as exc:
257
- return "", "", f"Syntax error on line {exc.lineno}: {html.escape(exc.msg)}"
258
-
259
- blocks = _collect_blocks(original_tree, source)
260
- if not blocks:
261
- return _placeholder_summary(blocks), source, "Parsed successfully. No classes or functions to annotate."
262
-
263
  try:
264
- comments, generation_notes = _generate_block_comments(blocks, model_label)
265
  except ModelUnavailableError as exc:
266
- return _placeholder_summary(blocks), source, f"Model unavailable: {html.escape(str(exc))}"
267
-
268
- annotated = _insert_comments(source, comments)
269
-
270
- try:
271
- same_ast = _semantic_ast_dump(source) == _semantic_ast_dump(annotated)
272
- except SyntaxError as exc:
273
- return "", annotated, f"Generated annotation failed to parse on line {exc.lineno}: {html.escape(exc.msg)}"
274
-
275
- status = (
276
- f"Validated: parsed {len(blocks)} block(s), inserted {model_label} comments, semantic AST unchanged."
277
- if same_ast
278
- else "Rejected: annotation changed the semantic AST."
279
- )
280
- if generation_notes:
281
- status += "\n" + "\n".join(generation_notes)
282
- return _placeholder_summary(blocks), annotated if same_ast else source, status
283
 
284
 
285
  def load_uploaded_python(file_obj: Any) -> tuple[str, str]:
@@ -298,11 +185,11 @@ def load_uploaded_python(file_obj: Any) -> tuple[str, str]:
298
  return "", f"Could not read `{path.name}`: {exc}"
299
 
300
  try:
301
- tree = _parse_python(source)
302
  except SyntaxError as exc:
303
  return source, f"Loaded `{path.name}`, but parsing failed on line {exc.lineno}: {html.escape(exc.msg)}"
304
 
305
- blocks = _collect_blocks(tree, source)
306
  return source, f"Loaded `{path.name}`. Parsed {len(blocks)} class/function block(s)."
307
 
308
 
 
 
1
  import html
 
2
  from pathlib import Path
3
  from typing import Any
4
 
5
  import gradio as gr
6
 
7
+ from btl.annotate import annotate_python, collect_blocks, parse_python
8
+ from btl.model import ModelUnavailableError
9
 
10
 
11
  CSS = """
 
155
  """
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  MODEL_LABELS = {
159
  "Base Mellum2 (richer)": "base",
160
  "Fine-tuned LoRA (concise)": "tuned",
161
  }
162
 
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  def annotate_code(source: str, model_label: str) -> tuple[str, str, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  try:
166
+ result = annotate_python(source, MODEL_LABELS.get(model_label, "base"))
167
  except ModelUnavailableError as exc:
168
+ return "", source, f"Model unavailable: {html.escape(str(exc))}"
169
+ return result.summary, result.annotated_source, result.status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
 
172
  def load_uploaded_python(file_obj: Any) -> tuple[str, str]:
 
185
  return "", f"Could not read `{path.name}`: {exc}"
186
 
187
  try:
188
+ tree = parse_python(source)
189
  except SyntaxError as exc:
190
  return source, f"Loaded `{path.name}`, but parsing failed on line {exc.lineno}: {html.escape(exc.msg)}"
191
 
192
+ blocks = collect_blocks(tree, source)
193
  return source, f"Loaded `{path.name}`. Parsed {len(blocks)} class/function block(s)."
194
 
195
 
btl/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (210 Bytes). View file
 
btl/__pycache__/annotate.cpython-313.pyc ADDED
Binary file (9.68 kB). View file
 
btl/__pycache__/cli.cpython-313.pyc ADDED
Binary file (4.14 kB). View file
 
btl/__pycache__/model.cpython-313.pyc ADDED
Binary file (7.51 kB). View file
 
btl/__pycache__/prompts.cpython-313.pyc ADDED
Binary file (1.06 kB). View file
 
btl/annotate.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ from dataclasses import dataclass
3
+ from typing import Literal
4
+
5
+ from .model import ModelUnavailableError, generate_comment, generate_comment_with_llm, load_llm
6
+
7
+
8
+ ModelChoice = Literal["base", "tuned"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class BlockInfo:
13
+ kind: str
14
+ name: str
15
+ lineno: int
16
+ source: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class AnnotationResult:
21
+ summary: str
22
+ annotated_source: str
23
+ status: str
24
+ valid: bool
25
+ block_count: int
26
+ notes: tuple[str, ...] = ()
27
+
28
+
29
+ def parse_python(source: str) -> ast.Module:
30
+ return ast.parse(source)
31
+
32
+
33
+ def strip_docstrings(node: ast.AST) -> ast.AST:
34
+ copied = ast.fix_missing_locations(ast.parse(ast.unparse(node)))
35
+ for child in ast.walk(copied):
36
+ if isinstance(child, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
37
+ if (
38
+ child.body
39
+ and isinstance(child.body[0], ast.Expr)
40
+ and isinstance(child.body[0].value, ast.Constant)
41
+ and isinstance(child.body[0].value.value, str)
42
+ ):
43
+ child.body = child.body[1:]
44
+ return copied
45
+
46
+
47
+ def semantic_ast_dump(source: str) -> str:
48
+ tree = parse_python(source)
49
+ stripped = strip_docstrings(tree)
50
+ return ast.dump(stripped, include_attributes=False)
51
+
52
+
53
+ def collect_blocks(tree: ast.Module, source: str) -> list[BlockInfo]:
54
+ blocks: list[BlockInfo] = []
55
+ for node in ast.walk(tree):
56
+ if isinstance(node, ast.ClassDef):
57
+ block_source = ast.get_source_segment(source, node) or ast.unparse(node)
58
+ blocks.append(BlockInfo("class", node.name, node.lineno, block_source))
59
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
60
+ kind = "async function" if isinstance(node, ast.AsyncFunctionDef) else "function"
61
+ block_source = ast.get_source_segment(source, node) or ast.unparse(node)
62
+ blocks.append(BlockInfo(kind, node.name, node.lineno, block_source))
63
+ return sorted(blocks, key=lambda item: item.lineno)
64
+
65
+
66
+ def summarize_blocks(blocks: list[BlockInfo]) -> str:
67
+ if not blocks:
68
+ return "This Python file has no classes or functions to annotate yet."
69
+ names = ", ".join(f"{block.kind} `{block.name}`" for block in blocks[:8])
70
+ overflow = "" if len(blocks) <= 8 else f", plus {len(blocks) - 8} more"
71
+ return f"This file defines {names}{overflow}."
72
+
73
+
74
+ def insert_comments(source: str, comments: dict[int, str]) -> str:
75
+ lines = source.splitlines()
76
+ inserts: dict[int, list[str]] = {}
77
+ for lineno, comment_text in comments.items():
78
+ indent = len(lines[lineno - 1]) - len(lines[lineno - 1].lstrip())
79
+ inserts.setdefault(lineno - 1, []).append(" " * indent + comment_text)
80
+
81
+ annotated: list[str] = []
82
+ for index, line in enumerate(lines):
83
+ annotated.extend(inserts.get(index, []))
84
+ annotated.append(line)
85
+ return "\n".join(annotated) + ("\n" if source.endswith("\n") else "")
86
+
87
+
88
+ def generate_block_comments(blocks: list[BlockInfo], model_choice: ModelChoice = "base") -> tuple[dict[int, str], list[str]]:
89
+ comments: dict[int, str] = {}
90
+ notes: list[str] = []
91
+ llm = load_llm() if model_choice == "base" else None
92
+
93
+ for block in blocks:
94
+ try:
95
+ if model_choice == "base":
96
+ comments[block.lineno] = generate_comment_with_llm(llm, block.kind, block.name, block.source)
97
+ else:
98
+ comments[block.lineno] = generate_comment(block.kind, block.name, block.source, variant="tuned")
99
+ except ModelUnavailableError:
100
+ raise
101
+ except Exception as exc:
102
+ comments[block.lineno] = f"# TODO(model): Could not explain {block.kind} `{block.name}`."
103
+ notes.append(f"{block.name}: model generation failed ({type(exc).__name__}).")
104
+ return comments, notes
105
+
106
+
107
+ def annotate_python(source: str, model_choice: ModelChoice = "base") -> AnnotationResult:
108
+ source = source.strip("\ufeff")
109
+ if not source.strip():
110
+ return AnnotationResult("", "", "Paste a Python file to annotate.", False, 0)
111
+
112
+ try:
113
+ original_tree = parse_python(source)
114
+ except SyntaxError as exc:
115
+ return AnnotationResult("", "", f"Syntax error on line {exc.lineno}: {exc.msg}", False, 0)
116
+
117
+ blocks = collect_blocks(original_tree, source)
118
+ summary = summarize_blocks(blocks)
119
+ if not blocks:
120
+ return AnnotationResult(summary, source, "Parsed successfully. No classes or functions to annotate.", True, 0)
121
+
122
+ comments, generation_notes = generate_block_comments(blocks, model_choice)
123
+ annotated = insert_comments(source, comments)
124
+
125
+ try:
126
+ same_ast = semantic_ast_dump(source) == semantic_ast_dump(annotated)
127
+ except SyntaxError as exc:
128
+ return AnnotationResult(
129
+ "",
130
+ annotated,
131
+ f"Generated annotation failed to parse on line {exc.lineno}: {exc.msg}",
132
+ False,
133
+ len(blocks),
134
+ tuple(generation_notes),
135
+ )
136
+
137
+ status = (
138
+ f"Validated: parsed {len(blocks)} block(s), inserted {model_choice} model comments, semantic AST unchanged."
139
+ if same_ast
140
+ else "Rejected: annotation changed the semantic AST."
141
+ )
142
+ if generation_notes:
143
+ status += "\n" + "\n".join(generation_notes)
144
+
145
+ return AnnotationResult(
146
+ summary,
147
+ annotated if same_ast else source,
148
+ status,
149
+ same_ast,
150
+ len(blocks),
151
+ tuple(generation_notes),
152
+ )
btl/cli.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from .annotate import annotate_python
6
+ from .model import ModelUnavailableError
7
+
8
+
9
+ def parse_args() -> argparse.Namespace:
10
+ parser = argparse.ArgumentParser(
11
+ prog="between-the-lines",
12
+ description="Annotate a Python file with model-generated comments and verify the semantic AST is unchanged.",
13
+ )
14
+ parser.add_argument("input", type=Path, help="Python file to annotate.")
15
+ parser.add_argument(
16
+ "-o",
17
+ "--output",
18
+ type=Path,
19
+ help="Write annotated Python to this file. Defaults to stdout.",
20
+ )
21
+ parser.add_argument(
22
+ "--model",
23
+ choices=["base", "tuned"],
24
+ default="base",
25
+ help="Comment model to use. Defaults to base.",
26
+ )
27
+ parser.add_argument(
28
+ "--in-place",
29
+ action="store_true",
30
+ help="Overwrite the input file with the annotated version after validation.",
31
+ )
32
+ parser.add_argument(
33
+ "--check",
34
+ action="store_true",
35
+ help="Parse and validate only; do not write annotated code.",
36
+ )
37
+ parser.add_argument(
38
+ "--summary",
39
+ action="store_true",
40
+ help="Print the file summary and validation status to stderr.",
41
+ )
42
+ return parser.parse_args()
43
+
44
+
45
+ def read_source(path: Path) -> str:
46
+ try:
47
+ return path.read_text(encoding="utf-8")
48
+ except UnicodeDecodeError:
49
+ return path.read_text(encoding="utf-8-sig")
50
+
51
+
52
+ def main() -> int:
53
+ args = parse_args()
54
+ if args.output and args.in_place:
55
+ print("error: use either --output or --in-place, not both", file=sys.stderr)
56
+ return 2
57
+
58
+ try:
59
+ source = read_source(args.input)
60
+ except OSError as exc:
61
+ print(f"error: could not read {args.input}: {exc}", file=sys.stderr)
62
+ return 2
63
+ except UnicodeDecodeError as exc:
64
+ print(f"error: could not decode {args.input} as UTF-8 Python text: {exc}", file=sys.stderr)
65
+ return 2
66
+
67
+ try:
68
+ result = annotate_python(source, args.model)
69
+ except ModelUnavailableError as exc:
70
+ print(f"error: model unavailable: {exc}", file=sys.stderr)
71
+ return 1
72
+ if args.summary or args.check:
73
+ if result.summary:
74
+ print(result.summary, file=sys.stderr)
75
+ print(result.status, file=sys.stderr)
76
+
77
+ if not result.valid:
78
+ return 1
79
+
80
+ if args.check:
81
+ return 0
82
+
83
+ if args.in_place:
84
+ args.input.write_text(result.annotated_source, encoding="utf-8")
85
+ return 0
86
+
87
+ if args.output:
88
+ args.output.write_text(result.annotated_source, encoding="utf-8")
89
+ return 0
90
+
91
+ print(result.annotated_source, end="")
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ raise SystemExit(main())
pyproject.toml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "between-the-lines"
7
+ version = "0.1.0"
8
+ description = "Annotate Python files with small-model comments and AST validation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "accelerate",
13
+ "bitsandbytes",
14
+ "gradio==5.50.0",
15
+ "huggingface_hub",
16
+ "llama-cpp-python",
17
+ "peft",
18
+ "safetensors",
19
+ "torch",
20
+ "transformers @ git+https://github.com/huggingface/transformers.git",
21
+ "truststore",
22
+ ]
23
+
24
+ [project.scripts]
25
+ between-the-lines = "btl.cli:main"
26
+ btl = "btl.cli:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["btl*"]