vial-ml 0.1.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.
- vial_ml-0.1.0/PKG-INFO +58 -0
- vial_ml-0.1.0/README.md +48 -0
- vial_ml-0.1.0/pyproject.toml +39 -0
- vial_ml-0.1.0/src/vial/__init__.py +61 -0
- vial_ml-0.1.0/src/vial/cli.py +36 -0
- vial_ml-0.1.0/src/vial/context.py +18 -0
- vial_ml-0.1.0/src/vial/extractor.py +34 -0
- vial_ml-0.1.0/src/vial/merger.py +34 -0
- vial_ml-0.1.0/src/vial/workspace.py +31 -0
vial_ml-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: vial-ml
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Surgical code isolation for AI agents
|
|
5
|
+
Author: Momchil Georgiev
|
|
6
|
+
Author-email: Momchil Georgiev <40865235+momchilgeorgiev@users.noreply.github.com>
|
|
7
|
+
Requires-Dist: typer>=0.25.1
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Vial
|
|
12
|
+
|
|
13
|
+
Surgical code isolation for AI agents.
|
|
14
|
+
|
|
15
|
+
Vial extracts a single function or class from a large Python file into a clean, focused workspace file so an agent only sees — and edits — exactly what it needs to.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install vial
|
|
21
|
+
# or with uv:
|
|
22
|
+
uv add vial
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## CLI
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Isolate a function
|
|
29
|
+
vial extract billing.py process_payment
|
|
30
|
+
|
|
31
|
+
# Agent edits .vial_workspace/process_payment_isolated.py ...
|
|
32
|
+
|
|
33
|
+
# Stitch it back (validates syntax first)
|
|
34
|
+
vial merge
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Python API
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from vial import Vial
|
|
41
|
+
|
|
42
|
+
v = Vial()
|
|
43
|
+
isolated_path = v.extract("billing.py", "process_payment")
|
|
44
|
+
# agent edits isolated_path ...
|
|
45
|
+
v.merge()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Why
|
|
49
|
+
|
|
50
|
+
When an agent reads a 2,000-line file to fix a 20-line function it wastes tokens, risks editing the wrong code, and produces complex diffs that frequently fail. Vial reduces the context to only what matters, cutting token usage by 71–88% on typical files.
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
1. **Extract** — AST-based line-bound finder locates the exact start/end of the target. A protected context header (imports + module-level globals, commented out) is prepended so the agent has the context it needs without being able to accidentally modify it.
|
|
55
|
+
2. **Edit** — The agent modifies only the isolated file.
|
|
56
|
+
3. **Merge** — Vial validates the modified code's syntax, then splices it back into the original file at the exact line range. Surrounding code is never touched.
|
|
57
|
+
|
|
58
|
+
All session state lives in a `metadata.json` file inside the workspace directory.
|
vial_ml-0.1.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Vial
|
|
2
|
+
|
|
3
|
+
Surgical code isolation for AI agents.
|
|
4
|
+
|
|
5
|
+
Vial extracts a single function or class from a large Python file into a clean, focused workspace file so an agent only sees — and edits — exactly what it needs to.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install vial
|
|
11
|
+
# or with uv:
|
|
12
|
+
uv add vial
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## CLI
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Isolate a function
|
|
19
|
+
vial extract billing.py process_payment
|
|
20
|
+
|
|
21
|
+
# Agent edits .vial_workspace/process_payment_isolated.py ...
|
|
22
|
+
|
|
23
|
+
# Stitch it back (validates syntax first)
|
|
24
|
+
vial merge
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Python API
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from vial import Vial
|
|
31
|
+
|
|
32
|
+
v = Vial()
|
|
33
|
+
isolated_path = v.extract("billing.py", "process_payment")
|
|
34
|
+
# agent edits isolated_path ...
|
|
35
|
+
v.merge()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
When an agent reads a 2,000-line file to fix a 20-line function it wastes tokens, risks editing the wrong code, and produces complex diffs that frequently fail. Vial reduces the context to only what matters, cutting token usage by 71–88% on typical files.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
1. **Extract** — AST-based line-bound finder locates the exact start/end of the target. A protected context header (imports + module-level globals, commented out) is prepended so the agent has the context it needs without being able to accidentally modify it.
|
|
45
|
+
2. **Edit** — The agent modifies only the isolated file.
|
|
46
|
+
3. **Merge** — Vial validates the modified code's syntax, then splices it back into the original file at the exact line range. Surrounding code is never touched.
|
|
47
|
+
|
|
48
|
+
All session state lives in a `metadata.json` file inside the workspace directory.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vial-ml"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Surgical code isolation for AI agents"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Momchil Georgiev", email = "40865235+momchilgeorgiev@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer>=0.25.1",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
vial = "vial.cli:app"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.8.14,<0.9.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[tool.uv.build-backend]
|
|
22
|
+
module-name = "vial"
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
|
|
27
|
+
[tool.coverage.run]
|
|
28
|
+
source = ["src/vial"]
|
|
29
|
+
omit = ["*/cli.py"]
|
|
30
|
+
|
|
31
|
+
[tool.coverage.report]
|
|
32
|
+
show_missing = true
|
|
33
|
+
fail_under = 85
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=9.0.3",
|
|
38
|
+
"pytest-cov>=7.1.0",
|
|
39
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from vial.workspace import Workspace
|
|
3
|
+
from vial.extractor import find_target
|
|
4
|
+
from vial.context import extract_context
|
|
5
|
+
from vial.merger import validate_syntax, strip_header, splice_back
|
|
6
|
+
|
|
7
|
+
HEADER_DELIMITER = "#" + "=" * 40 + " END OF CONTEXT " + "=" * 40
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Vial:
|
|
11
|
+
def __init__(self, workspace_dir: str | Path = ".vial_workspace"):
|
|
12
|
+
self.workspace = Workspace(workspace_dir)
|
|
13
|
+
|
|
14
|
+
def extract(self, source_filepath: str | Path, target_name: str) -> str:
|
|
15
|
+
"""Extract target_name from source_filepath into an isolated workspace file."""
|
|
16
|
+
source = Path(source_filepath)
|
|
17
|
+
source_code = source.read_text()
|
|
18
|
+
|
|
19
|
+
result = find_target(source_code, target_name)
|
|
20
|
+
context = extract_context(source_code)
|
|
21
|
+
|
|
22
|
+
header = "# Context from original file (read-only — do not modify this block):\n"
|
|
23
|
+
if context:
|
|
24
|
+
header += "\n".join(f"# {line}" for line in context.splitlines()) + "\n"
|
|
25
|
+
header += HEADER_DELIMITER + "\n"
|
|
26
|
+
|
|
27
|
+
isolated_path = self.workspace.isolated_path(target_name)
|
|
28
|
+
isolated_path.write_text(header + result.code)
|
|
29
|
+
|
|
30
|
+
self.workspace.save_metadata({
|
|
31
|
+
"source_filepath": str(source.resolve()),
|
|
32
|
+
"target_name": target_name,
|
|
33
|
+
"start_line": result.start_line,
|
|
34
|
+
"end_line": result.end_line,
|
|
35
|
+
"isolated_filepath": str(isolated_path.resolve()),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return str(isolated_path)
|
|
39
|
+
|
|
40
|
+
def merge(self) -> None:
|
|
41
|
+
"""Validate and merge the modified isolated file back into the source."""
|
|
42
|
+
meta = self.workspace.load_metadata()
|
|
43
|
+
isolated = Path(meta["isolated_filepath"])
|
|
44
|
+
content = isolated.read_text()
|
|
45
|
+
|
|
46
|
+
modified_code = strip_header(content, HEADER_DELIMITER)
|
|
47
|
+
|
|
48
|
+
is_valid, error = validate_syntax(modified_code)
|
|
49
|
+
if not is_valid:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Merge aborted — agent produced invalid Python code:\n{error}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
splice_back(
|
|
55
|
+
source_filepath=Path(meta["source_filepath"]),
|
|
56
|
+
modified_code=modified_code,
|
|
57
|
+
start_line=meta["start_line"],
|
|
58
|
+
end_line=meta["end_line"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self.workspace.cleanup()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from vial import Vial
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(help="Vial — surgical code isolation for AI agents.")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@app.command()
|
|
9
|
+
def extract(
|
|
10
|
+
source: Path = typer.Argument(..., help="Path to the Python source file."),
|
|
11
|
+
target: str = typer.Argument(..., help="Name of the function or class to extract."),
|
|
12
|
+
workspace: Path = typer.Option(".vial_workspace", help="Workspace directory."),
|
|
13
|
+
):
|
|
14
|
+
"""Extract a named function or class into an isolated workspace file."""
|
|
15
|
+
try:
|
|
16
|
+
v = Vial(workspace_dir=workspace)
|
|
17
|
+
isolated = v.extract(source, target)
|
|
18
|
+
typer.echo(f"Isolated '{target}' → {isolated}")
|
|
19
|
+
except (ValueError, FileNotFoundError) as e:
|
|
20
|
+
typer.echo(f"Error: {e}", err=True)
|
|
21
|
+
raise typer.Exit(code=1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def merge(
|
|
26
|
+
workspace: Path = typer.Option(".vial_workspace", help="Workspace directory."),
|
|
27
|
+
):
|
|
28
|
+
"""Validate and merge the modified isolated file back into the source."""
|
|
29
|
+
try:
|
|
30
|
+
v = Vial(workspace_dir=workspace)
|
|
31
|
+
meta = v.workspace.load_metadata()
|
|
32
|
+
v.merge()
|
|
33
|
+
typer.echo(f"Merged '{meta['target_name']}' back into {meta['source_filepath']}")
|
|
34
|
+
except (ValueError, FileNotFoundError) as e:
|
|
35
|
+
typer.echo(f"Error: {e}", err=True)
|
|
36
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def extract_context(source_code: str) -> str:
|
|
5
|
+
"""Return import statements and top-level assignments as a single string."""
|
|
6
|
+
if not source_code.strip():
|
|
7
|
+
return ""
|
|
8
|
+
|
|
9
|
+
tree = ast.parse(source_code)
|
|
10
|
+
lines = source_code.splitlines()
|
|
11
|
+
collected: list[str] = []
|
|
12
|
+
|
|
13
|
+
for node in tree.body:
|
|
14
|
+
if isinstance(node, (ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign)):
|
|
15
|
+
chunk = lines[node.lineno - 1 : node.end_lineno]
|
|
16
|
+
collected.extend(chunk)
|
|
17
|
+
|
|
18
|
+
return "\n".join(collected)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ExtractionResult:
|
|
7
|
+
target_name: str
|
|
8
|
+
start_line: int # 0-indexed, inclusive
|
|
9
|
+
end_line: int # 0-indexed, exclusive
|
|
10
|
+
code: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_target(source_code: str, target_name: str) -> ExtractionResult:
|
|
14
|
+
"""Locate a named function or class and return its bounds and source."""
|
|
15
|
+
tree = ast.parse(source_code)
|
|
16
|
+
lines = source_code.splitlines()
|
|
17
|
+
|
|
18
|
+
for node in ast.walk(tree):
|
|
19
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
20
|
+
if node.name == target_name:
|
|
21
|
+
start = node.lineno - 1
|
|
22
|
+
end = node.end_lineno
|
|
23
|
+
code = "\n".join(lines[start:end])
|
|
24
|
+
return ExtractionResult(
|
|
25
|
+
target_name=target_name,
|
|
26
|
+
start_line=start,
|
|
27
|
+
end_line=end,
|
|
28
|
+
code=code,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"'{target_name}' not found in source. "
|
|
33
|
+
"Check the name and make sure it is a top-level function or class."
|
|
34
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def validate_syntax(code: str) -> tuple[bool, str]:
|
|
6
|
+
"""Returns (True, '') if code is valid Python, else (False, error_message)."""
|
|
7
|
+
try:
|
|
8
|
+
ast.parse(code)
|
|
9
|
+
return True, ""
|
|
10
|
+
except SyntaxError as e:
|
|
11
|
+
return False, f"SyntaxError on line {e.lineno}: {e.msg}\n{e.text or ''}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def strip_header(content: str, delimiter: str) -> str:
|
|
15
|
+
"""Remove the read-only context header block above the delimiter."""
|
|
16
|
+
if delimiter in content:
|
|
17
|
+
_, code = content.split(delimiter, 1)
|
|
18
|
+
return code.strip("\n")
|
|
19
|
+
return content.strip("\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def splice_back(
|
|
23
|
+
source_filepath: Path,
|
|
24
|
+
modified_code: str,
|
|
25
|
+
start_line: int,
|
|
26
|
+
end_line: int,
|
|
27
|
+
) -> Path:
|
|
28
|
+
"""Replace lines [start_line:end_line] in source_filepath with modified_code."""
|
|
29
|
+
original_lines = source_filepath.read_text().splitlines()
|
|
30
|
+
modified_lines = modified_code.splitlines()
|
|
31
|
+
|
|
32
|
+
new_lines = original_lines[:start_line] + modified_lines + original_lines[end_line:]
|
|
33
|
+
source_filepath.write_text("\n".join(new_lines) + "\n")
|
|
34
|
+
return source_filepath
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Workspace:
|
|
6
|
+
METADATA_FILENAME = "metadata.json"
|
|
7
|
+
|
|
8
|
+
def __init__(self, directory: Path | str = ".vial_workspace"):
|
|
9
|
+
self.dir = Path(directory)
|
|
10
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
self.metadata_path = self.dir / self.METADATA_FILENAME
|
|
12
|
+
|
|
13
|
+
def isolated_path(self, target_name: str) -> Path:
|
|
14
|
+
return self.dir / f"{target_name}_isolated.py"
|
|
15
|
+
|
|
16
|
+
def save_metadata(self, meta: dict) -> None:
|
|
17
|
+
self.metadata_path.write_text(json.dumps(meta, indent=2))
|
|
18
|
+
|
|
19
|
+
def load_metadata(self) -> dict:
|
|
20
|
+
if not self.metadata_path.exists():
|
|
21
|
+
raise FileNotFoundError(
|
|
22
|
+
f"No active session found in {self.dir}. Run `vial extract` first."
|
|
23
|
+
)
|
|
24
|
+
return json.loads(self.metadata_path.read_text())
|
|
25
|
+
|
|
26
|
+
def cleanup(self) -> None:
|
|
27
|
+
meta = self.load_metadata()
|
|
28
|
+
isolated = Path(meta["isolated_filepath"])
|
|
29
|
+
if isolated.exists():
|
|
30
|
+
isolated.unlink()
|
|
31
|
+
self.metadata_path.unlink()
|