instructvault 0.2.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.
@@ -0,0 +1,2 @@
1
+ from .sdk import InstructVault
2
+ __all__ = ["InstructVault"]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Iterable, List, Optional
7
+
8
+ from .io import load_prompt_spec
9
+ from .spec import PromptSpec
10
+ from .store import PromptStore
11
+
12
+ @dataclass(frozen=True)
13
+ class BundlePrompt:
14
+ path: str
15
+ spec: PromptSpec
16
+
17
+ def _list_files_at_ref(repo_root: Path, ref: str, rel_dir: str) -> List[str]:
18
+ cmd = ["git", "-C", str(repo_root), "ls-tree", "-r", "--name-only", ref, rel_dir]
19
+ res = subprocess.run(cmd, capture_output=True, text=True)
20
+ if res.returncode != 0:
21
+ raise ValueError(res.stderr.strip() or f"Could not list files at ref {ref}")
22
+ return [line.strip() for line in res.stdout.splitlines() if line.strip()]
23
+
24
+ def _is_prompt_file(path: str) -> bool:
25
+ lower = path.lower()
26
+ return lower.endswith(".prompt.yml") or lower.endswith(".prompt.yaml") or lower.endswith(".prompt.json")
27
+
28
+ def collect_prompts(repo_root: Path, prompts_dir: Path, ref: Optional[str]) -> List[BundlePrompt]:
29
+ store = PromptStore(repo_root)
30
+ prompts: List[BundlePrompt] = []
31
+ if ref is None:
32
+ for p in sorted(prompts_dir.rglob("*.prompt.y*ml")):
33
+ rel_path = p.relative_to(repo_root).as_posix()
34
+ spec = load_prompt_spec(p.read_text(encoding="utf-8"))
35
+ prompts.append(BundlePrompt(rel_path, spec))
36
+ for p in sorted(prompts_dir.rglob("*.prompt.json")):
37
+ rel_path = p.relative_to(repo_root).as_posix()
38
+ spec = load_prompt_spec(p.read_text(encoding="utf-8"))
39
+ prompts.append(BundlePrompt(rel_path, spec))
40
+ return prompts
41
+
42
+ rel_dir = prompts_dir.relative_to(repo_root).as_posix()
43
+ for rel_path in _list_files_at_ref(repo_root, ref, rel_dir):
44
+ if not _is_prompt_file(rel_path):
45
+ continue
46
+ spec = load_prompt_spec(store.read_text(rel_path, ref=ref))
47
+ prompts.append(BundlePrompt(rel_path, spec))
48
+ return prompts
49
+
50
+ def write_bundle(out_path: Path, *, repo_root: Path, prompts_dir: Path, ref: Optional[str]) -> None:
51
+ prompts = collect_prompts(repo_root, prompts_dir, ref)
52
+ payload = {
53
+ "bundle_version": "1.0",
54
+ "ref": ref or "WORKTREE",
55
+ "prompts": [
56
+ {"path": p.path, "spec": p.spec.model_dump(by_alias=True)}
57
+ for p in prompts
58
+ ],
59
+ }
60
+ out_path.parent.mkdir(parents=True, exist_ok=True)
61
+ out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
62
+
instructvault/cli.py ADDED
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import typer
6
+ from rich import print as rprint
7
+
8
+ from .bundle import write_bundle
9
+ from .diff import unified_diff
10
+ from .eval import run_dataset, run_inline_tests
11
+ from .io import load_dataset_jsonl, load_prompt_spec
12
+ from .junit import write_junit_xml
13
+ from .render import check_required_vars, render_messages
14
+ from .scaffold import init_repo
15
+ from .store import PromptStore
16
+
17
+ app = typer.Typer(help="InstructVault: git-first prompt registry + CI evals + runtime SDK")
18
+
19
+ def _gather_prompt_files(base: Path) -> list[Path]:
20
+ if base.is_file():
21
+ return [base]
22
+ files = sorted(base.rglob("*.prompt.y*ml")) + sorted(base.rglob("*.prompt.json"))
23
+ return files
24
+
25
+ @app.command()
26
+ def init(repo: Path = typer.Option(Path("."), "--repo")):
27
+ init_repo(repo)
28
+ rprint("[green]Initialized prompts/, datasets/, and .github/workflows/ivault.yml[/green]")
29
+
30
+ @app.command()
31
+ def validate(path: Path = typer.Argument(...),
32
+ repo: Path = typer.Option(Path("."), "--repo"),
33
+ json_out: bool = typer.Option(False, "--json")):
34
+ base = path if path.is_absolute() else repo / path
35
+ files = _gather_prompt_files(base)
36
+ if not files:
37
+ raise typer.BadParameter("No prompt files found")
38
+ ok = True
39
+ results = []
40
+ for f in files:
41
+ try:
42
+ spec = load_prompt_spec(f.read_text(encoding="utf-8"))
43
+ try:
44
+ rel_path = f.relative_to(repo).as_posix()
45
+ except ValueError:
46
+ rel_path = str(f)
47
+ results.append({"path": rel_path, "ok": True, "name": spec.name})
48
+ if not json_out:
49
+ rprint(f"[green]OK[/green] {rel_path} ({spec.name})")
50
+ except Exception as e:
51
+ ok = False
52
+ try:
53
+ rel_path = f.relative_to(repo).as_posix()
54
+ except ValueError:
55
+ rel_path = str(f)
56
+ results.append({"path": rel_path, "ok": False, "error": str(e)})
57
+ if not json_out:
58
+ rprint(f"[red]FAIL[/red] {rel_path} {e}")
59
+ if json_out:
60
+ rprint(json.dumps({"ok": ok, "results": results}))
61
+ raise typer.Exit(code=0 if ok else 1)
62
+
63
+ @app.command()
64
+ def render(prompt_path: str = typer.Argument(...),
65
+ vars_json: str = typer.Option("{}", "--vars"),
66
+ ref: Optional[str] = typer.Option(None, "--ref"),
67
+ repo: Path = typer.Option(Path("."), "--repo"),
68
+ json_out: bool = typer.Option(False, "--json")):
69
+ store = PromptStore(repo_root=repo)
70
+ spec = load_prompt_spec(store.read_text(prompt_path, ref=ref))
71
+ vars_dict = json.loads(vars_json)
72
+ check_required_vars(spec, vars_dict)
73
+ msgs = render_messages(spec, vars_dict)
74
+ if json_out:
75
+ rprint(json.dumps([{"role": m.role, "content": m.content} for m in msgs]))
76
+ else:
77
+ for m in msgs:
78
+ rprint(f"[bold]{m.role}[/bold]\n{m.content}\n")
79
+
80
+ @app.command()
81
+ def diff(prompt_path: str = typer.Argument(...),
82
+ ref1: str = typer.Option(..., "--ref1"),
83
+ ref2: str = typer.Option(..., "--ref2"),
84
+ repo: Path = typer.Option(Path("."), "--repo"),
85
+ json_out: bool = typer.Option(False, "--json")):
86
+ store = PromptStore(repo_root=repo)
87
+ a = store.read_text(prompt_path, ref=ref1)
88
+ b = store.read_text(prompt_path, ref=ref2)
89
+ d = unified_diff(a, b, f"{ref1}:{prompt_path}", f"{ref2}:{prompt_path}")
90
+ if json_out:
91
+ rprint(json.dumps({"same": not d.strip(), "diff": d}))
92
+ else:
93
+ rprint(d if d.strip() else "[yellow]No differences[/yellow]")
94
+
95
+ @app.command()
96
+ def resolve(ref: str = typer.Argument(...),
97
+ repo: Path = typer.Option(Path("."), "--repo"),
98
+ json_out: bool = typer.Option(False, "--json")):
99
+ store = PromptStore(repo_root=repo)
100
+ sha = store.resolve_ref(ref)
101
+ if json_out:
102
+ rprint(json.dumps({"ref": ref, "sha": sha}))
103
+ else:
104
+ rprint(sha)
105
+
106
+ @app.command()
107
+ def bundle(prompts: Path = typer.Option(Path("prompts"), "--prompts"),
108
+ out: Path = typer.Option(Path("out/ivault.bundle.json"), "--out"),
109
+ ref: Optional[str] = typer.Option(None, "--ref"),
110
+ repo: Path = typer.Option(Path("."), "--repo")):
111
+ prompts_dir = prompts if prompts.is_absolute() else repo / prompts
112
+ write_bundle(out, repo_root=repo, prompts_dir=prompts_dir, ref=ref)
113
+ rprint(f"[green]Wrote bundle[/green] {out}")
114
+
115
+ @app.command()
116
+ def eval(prompt_path: str = typer.Argument(...),
117
+ ref: Optional[str] = typer.Option(None, "--ref"),
118
+ dataset: Optional[Path] = typer.Option(None, "--dataset"),
119
+ report: Optional[Path] = typer.Option(None, "--report"),
120
+ junit: Optional[Path] = typer.Option(None, "--junit"),
121
+ repo: Path = typer.Option(Path("."), "--repo"),
122
+ json_out: bool = typer.Option(False, "--json")):
123
+ store = PromptStore(repo_root=repo)
124
+ spec = load_prompt_spec(store.read_text(prompt_path, ref=ref))
125
+
126
+ ok1, r1 = run_inline_tests(spec)
127
+ results = list(r1)
128
+ ok = ok1
129
+
130
+ if dataset is not None:
131
+ rows = load_dataset_jsonl(dataset.read_text(encoding="utf-8"))
132
+ ok2, r2 = run_dataset(spec, rows)
133
+ ok = ok and ok2
134
+ results.extend(r2)
135
+
136
+ payload = {
137
+ "prompt": spec.name,
138
+ "ref": ref or "WORKTREE",
139
+ "pass": ok,
140
+ "results": [{"test": r.name, "pass": r.passed, "error": r.error} for r in results],
141
+ }
142
+ if report:
143
+ report.parent.mkdir(parents=True, exist_ok=True)
144
+ report.write_text(json.dumps(payload, indent=2), encoding="utf-8")
145
+ if junit:
146
+ junit.parent.mkdir(parents=True, exist_ok=True)
147
+ write_junit_xml(suite_name=f"ivault:{spec.name}", results=results, out_path=str(junit))
148
+
149
+ if json_out:
150
+ rprint(json.dumps(payload))
151
+ else:
152
+ for r in results:
153
+ if r.passed:
154
+ rprint(f"[green]PASS[/green] {r.name}")
155
+ else:
156
+ rprint(f"[red]FAIL[/red] {r.name} {r.error or ''}")
157
+ raise typer.Exit(code=0 if ok else 1)
instructvault/diff.py ADDED
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+ import difflib
3
+ def unified_diff(a: str, b: str, fromfile: str, tofile: str) -> str:
4
+ return "".join(difflib.unified_diff(a.splitlines(keepends=True), b.splitlines(keepends=True), fromfile=fromfile, tofile=tofile))
instructvault/eval.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import List, Optional, Tuple
4
+ from .spec import AssertSpec, DatasetRow, PromptSpec
5
+ from .render import check_required_vars, render_joined_text
6
+
7
+ @dataclass(frozen=True)
8
+ class TestResult:
9
+ name: str
10
+ passed: bool
11
+ error: Optional[str] = None
12
+
13
+ def _match_assert(assert_spec: AssertSpec, text: str) -> bool:
14
+ t = text.lower()
15
+ ok = True
16
+ if assert_spec.contains_any:
17
+ ok = ok and any(s.lower() in t for s in assert_spec.contains_any)
18
+ if assert_spec.contains_all:
19
+ ok = ok and all(s.lower() in t for s in assert_spec.contains_all)
20
+ if assert_spec.not_contains:
21
+ ok = ok and all(s.lower() not in t for s in assert_spec.not_contains)
22
+ return ok
23
+
24
+ def run_inline_tests(spec: PromptSpec) -> Tuple[bool, List[TestResult]]:
25
+ results: List[TestResult] = []
26
+ all_ok = True
27
+ for t in spec.tests:
28
+ try:
29
+ check_required_vars(spec, t.vars)
30
+ out = render_joined_text(spec, t.vars)
31
+ passed = _match_assert(t.assert_, out)
32
+ results.append(TestResult(t.name, passed))
33
+ all_ok = all_ok and passed
34
+ except Exception as e:
35
+ results.append(TestResult(t.name, False, str(e)))
36
+ all_ok = False
37
+ return all_ok, results
38
+
39
+ def run_dataset(spec: PromptSpec, rows: List[DatasetRow]) -> Tuple[bool, List[TestResult]]:
40
+ results: List[TestResult] = []
41
+ all_ok = True
42
+ for i, row in enumerate(rows, start=1):
43
+ name = f"dataset_row_{i}"
44
+ try:
45
+ check_required_vars(spec, row.vars)
46
+ out = render_joined_text(spec, row.vars)
47
+ passed = _match_assert(row.assert_, out)
48
+ results.append(TestResult(name, passed))
49
+ all_ok = all_ok and passed
50
+ except Exception as e:
51
+ results.append(TestResult(name, False, str(e)))
52
+ all_ok = False
53
+ return all_ok, results
instructvault/io.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List
3
+ import json
4
+ import yaml
5
+ from .spec import DatasetRow, PromptSpec
6
+
7
+ def load_prompt_spec(yaml_text: str) -> PromptSpec:
8
+ text = yaml_text.strip()
9
+ if text.startswith("{") or text.startswith("["):
10
+ data: Dict[str, Any] = json.loads(text) if text else {}
11
+ else:
12
+ data = yaml.safe_load(yaml_text) or {}
13
+ return PromptSpec.model_validate(data)
14
+
15
+ def load_dataset_jsonl(text: str) -> List[DatasetRow]:
16
+ rows: List[DatasetRow] = []
17
+ for i, line in enumerate(text.splitlines(), start=1):
18
+ line = line.strip()
19
+ if not line:
20
+ continue
21
+ try:
22
+ obj = json.loads(line)
23
+ except Exception as e:
24
+ raise ValueError(f"Invalid JSON on line {i}: {e}") from e
25
+ rows.append(DatasetRow.model_validate(obj))
26
+ return rows
instructvault/junit.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+ import xml.etree.ElementTree as ET
3
+ from datetime import datetime, timezone
4
+ from typing import Iterable, Optional
5
+ from .eval import TestResult
6
+
7
+ def write_junit_xml(*, suite_name: str, results: Iterable[TestResult], out_path: str, timestamp: Optional[str] = None) -> None:
8
+ results = list(results)
9
+ tests = len(results)
10
+ failures = sum(1 for r in results if not r.passed)
11
+ ts = timestamp or datetime.now(timezone.utc).isoformat()
12
+
13
+ suite = ET.Element("testsuite", {
14
+ "name": suite_name,
15
+ "tests": str(tests),
16
+ "failures": str(failures),
17
+ "errors": "0",
18
+ "time": "0",
19
+ "timestamp": ts,
20
+ })
21
+
22
+ for r in results:
23
+ case = ET.SubElement(suite, "testcase", {"name": r.name, "classname": suite_name, "time": "0"})
24
+ if not r.passed:
25
+ f = ET.SubElement(case, "failure", {"message": r.error or "assertion failed", "type": "AssertionError"})
26
+ f.text = r.error or "assertion failed"
27
+
28
+ tree = ET.ElementTree(suite)
29
+ ET.indent(tree, space=" ")
30
+ tree.write(out_path, encoding="utf-8", xml_declaration=True)
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List
3
+ from jinja2 import Environment, StrictUndefined
4
+ from .spec import PromptMessage, PromptSpec
5
+
6
+ _env = Environment(undefined=StrictUndefined, autoescape=False)
7
+
8
+ def check_required_vars(spec: PromptSpec, vars: Dict[str, Any]) -> None:
9
+ missing = [k for k in spec.variables.required if k not in vars]
10
+ if missing:
11
+ raise ValueError(f"Missing required vars: {missing}")
12
+
13
+ def render_messages(spec: PromptSpec, vars: Dict[str, Any]) -> List[PromptMessage]:
14
+ rendered: List[PromptMessage] = []
15
+ for m in spec.messages:
16
+ tmpl = _env.from_string(m.content)
17
+ rendered.append(PromptMessage(role=m.role, content=tmpl.render(**vars)))
18
+ return rendered
19
+
20
+ def render_joined_text(spec: PromptSpec, vars: Dict[str, Any]) -> str:
21
+ msgs = render_messages(spec, vars)
22
+ return "\n\n".join([f"{m.role}: {m.content}" for m in msgs])
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+
4
+ DEFAULT_PROMPT = '''spec_version: "1.0"
5
+ name: hello_world
6
+ description: Minimal example prompt.
7
+ variables:
8
+ required: [name]
9
+ messages:
10
+ - role: system
11
+ content: "You are a helpful assistant."
12
+ - role: user
13
+ content: "Say hello to {{ name }}."
14
+ tests:
15
+ - name: includes_name
16
+ vars: { name: "Ava" }
17
+ assert: { contains_any: ["Ava"] }
18
+ '''
19
+
20
+ DEFAULT_WORKFLOW = '''name: ivault (prompts)
21
+ on:
22
+ pull_request:
23
+ paths:
24
+ - "prompts/**"
25
+ - "datasets/**"
26
+ push:
27
+ branches: [ main ]
28
+ paths:
29
+ - "prompts/**"
30
+ - "datasets/**"
31
+
32
+ jobs:
33
+ prompts:
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: actions/setup-python@v5
38
+ with:
39
+ python-version: "3.11"
40
+ - run: pip install instructvault
41
+ - run: ivault validate prompts
42
+ - run: ivault eval prompts/hello_world.prompt.yml --report out/report.json --junit out/junit.xml
43
+ - uses: actions/upload-artifact@v4
44
+ if: always()
45
+ with:
46
+ name: ivault-reports
47
+ path: out/
48
+ '''
49
+
50
+ def init_repo(repo_root: Path) -> None:
51
+ prompts = repo_root / "prompts"
52
+ datasets = repo_root / "datasets"
53
+ workflow = repo_root / ".github" / "workflows" / "ivault.yml"
54
+ prompts.mkdir(parents=True, exist_ok=True)
55
+ datasets.mkdir(parents=True, exist_ok=True)
56
+ workflow.parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ sample_prompt = prompts / "hello_world.prompt.yml"
59
+ if not sample_prompt.exists():
60
+ sample_prompt.write_text(DEFAULT_PROMPT, encoding="utf-8")
61
+ if not workflow.exists():
62
+ workflow.write_text(DEFAULT_WORKFLOW, encoding="utf-8")
instructvault/sdk.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Optional, Union
4
+ import json
5
+ from .io import load_prompt_spec
6
+ from .render import check_required_vars, render_messages
7
+ from .spec import PromptMessage, PromptSpec
8
+ from .store import PromptStore
9
+
10
+ class InstructVault:
11
+ def __init__(self, repo_root: Optional[Union[str, Path]] = None, bundle_path: Optional[Union[str, Path]] = None):
12
+ if repo_root is None and bundle_path is None:
13
+ raise ValueError("Provide repo_root or bundle_path")
14
+ self.store = PromptStore(Path(repo_root)) if repo_root is not None else None
15
+ self.bundle = None
16
+ if bundle_path is not None:
17
+ data = json.loads(Path(bundle_path).read_text(encoding="utf-8"))
18
+ self.bundle = {p["path"]: PromptSpec.model_validate(p["spec"]) for p in data.get("prompts", [])}
19
+
20
+ def load_prompt(self, prompt_path: str, ref: Optional[str] = None) -> PromptSpec:
21
+ if self.bundle is not None:
22
+ if prompt_path not in self.bundle:
23
+ raise FileNotFoundError(f"Prompt not found in bundle: {prompt_path}")
24
+ return self.bundle[prompt_path]
25
+ if self.store is None:
26
+ raise ValueError("No repo_root configured")
27
+ return load_prompt_spec(self.store.read_text(prompt_path, ref=ref))
28
+ def render(self, prompt_path: str, vars: Dict[str, Any], ref: Optional[str] = None) -> List[PromptMessage]:
29
+ spec = self.load_prompt(prompt_path, ref=ref)
30
+ check_required_vars(spec, vars)
31
+ return render_messages(spec, vars)
instructvault/spec.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Literal, Optional
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ Role = Literal["system", "user", "assistant", "tool"]
6
+
7
+ class PromptMessage(BaseModel):
8
+ model_config = ConfigDict(extra="forbid")
9
+ role: Role
10
+ content: str
11
+
12
+ class VariableSpec(BaseModel):
13
+ model_config = ConfigDict(extra="forbid")
14
+ required: List[str] = Field(default_factory=list)
15
+ optional: List[str] = Field(default_factory=list)
16
+
17
+ class ModelDefaults(BaseModel):
18
+ model_config = ConfigDict(extra="allow")
19
+ temperature: Optional[float] = None
20
+ top_p: Optional[float] = None
21
+ max_tokens: Optional[int] = None
22
+
23
+ class AssertSpec(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+ contains_any: Optional[List[str]] = None
26
+ contains_all: Optional[List[str]] = None
27
+ not_contains: Optional[List[str]] = None
28
+
29
+ class PromptTest(BaseModel):
30
+ model_config = ConfigDict(extra="forbid")
31
+ name: str
32
+ vars: Dict[str, Any] = Field(default_factory=dict)
33
+ assert_: AssertSpec = Field(alias="assert")
34
+
35
+ class PromptSpec(BaseModel):
36
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
37
+ spec_version: str = Field(default="1.0", alias="spec_version")
38
+ name: str
39
+ description: Optional[str] = None
40
+ model_defaults: ModelDefaults = Field(default_factory=ModelDefaults, alias="modelParameters")
41
+ variables: VariableSpec = Field(default_factory=VariableSpec)
42
+ messages: List[PromptMessage]
43
+ tests: List[PromptTest] = Field(default_factory=list)
44
+
45
+ class DatasetRow(BaseModel):
46
+ model_config = ConfigDict(extra="forbid")
47
+ vars: Dict[str, Any] = Field(default_factory=dict)
48
+ assert_: AssertSpec = Field(alias="assert")
instructvault/store.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+ import subprocess
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ class PromptStore:
7
+ def __init__(self, repo_root: Path):
8
+ self.repo_root = repo_root.resolve()
9
+
10
+ def read_text(self, rel_path: str, ref: Optional[str] = None) -> str:
11
+ rel_path = rel_path.lstrip("/")
12
+ if ref is None:
13
+ return (self.repo_root / rel_path).read_text(encoding="utf-8")
14
+ cmd = ["git", "-C", str(self.repo_root), "show", f"{ref}:{rel_path}"]
15
+ res = subprocess.run(cmd, capture_output=True, text=True)
16
+ if res.returncode != 0:
17
+ raise FileNotFoundError(res.stderr.strip() or f"Could not read {rel_path} at ref {ref}")
18
+ return res.stdout
19
+
20
+ def resolve_ref(self, ref: str) -> str:
21
+ cmd = ["git", "-C", str(self.repo_root), "rev-parse", ref]
22
+ res = subprocess.run(cmd, capture_output=True, text=True)
23
+ if res.returncode != 0:
24
+ raise ValueError(res.stderr.strip() or f"Could not resolve ref {ref}")
25
+ return res.stdout.strip()
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: instructvault
3
+ Version: 0.2.0
4
+ Summary: Git-first prompt registry + CI evals + lightweight runtime SDK (ivault).
5
+ Project-URL: Homepage, https://github.com/05satyam/instruct_vault
6
+ Project-URL: Repository, https://github.com/05satyam/instruct_vault
7
+ Author: InstructVault OSS Maintainers
8
+ License: Apache-2.0
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: jinja2>=3.1
12
+ Requires-Dist: pydantic>=2.7
13
+ Requires-Dist: pyyaml>=6.0
14
+ Requires-Dist: rich>=13.7
15
+ Requires-Dist: typer>=0.12
16
+ Provides-Extra: dev
17
+ Requires-Dist: mypy>=1.10; extra == 'dev'
18
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.6; extra == 'dev'
21
+ Requires-Dist: types-pyyaml; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # InstructVault (`ivault`)
25
+ **Git‑first prompt hub for teams and individual developers.**
26
+
27
+ InstructVault makes prompts **first‑class, governed, testable, versioned artifacts** — just like code — while keeping runtime **fast and local**.
28
+
29
+ ## What this does (at a glance)
30
+ - **Prompts live in Git** as YAML/JSON files
31
+ - **CI validates + evaluates** prompts on every change
32
+ - **Releases are tags/SHAs**, reproducible by design
33
+ - **Runtime stays lightweight** (local read or bundle artifact)
34
+
35
+ ## System flow (Mermaid)
36
+ ```mermaid
37
+ flowchart LR
38
+ A[Prompt files<br/>YAML/JSON] --> B[PR Review]
39
+ B --> C[CI: validate + eval]
40
+ C --> D{Release?}
41
+ D -- tag/SHA --> E[Bundle artifact]
42
+ D -- tag/SHA --> F[Deploy app]
43
+ E --> F
44
+ F --> G[Runtime render<br/>local or bundle]
45
+ ```
46
+
47
+ ## Why this exists
48
+ Enterprises already have Git + PR reviews + CI/CD. Prompts usually don’t.
49
+ InstructVault brings **prompt‑as‑code** without requiring a server, database, or platform.
50
+
51
+ ## Features
52
+ - ✅ Git‑native versioning (tags/SHAs = releases)
53
+ - ✅ CLI‑first (`init`, `validate`, `render`, `eval`, `diff`, `resolve`, `bundle`)
54
+ - ✅ LLM‑framework agnostic (returns standard `{role, content}` messages)
55
+ - ✅ CI‑friendly reports (JSON + optional JUnit XML)
56
+ - ✅ No runtime latency tax (local read or bundle)
57
+ - ✅ Optional playground (separate package)
58
+
59
+ ## Install
60
+ ### Users
61
+ ```bash
62
+ pip install instructvault
63
+ ```
64
+
65
+ ### Contributors
66
+ ```bash
67
+ git clone <your-repo>
68
+ cd instructvault
69
+ python -m venv .venv
70
+ source .venv/bin/activate
71
+ pip install -e ".[dev]"
72
+ pytest
73
+ ```
74
+
75
+ ## Quickstart (end‑to‑end)
76
+
77
+ ### 1) Initialize a repo
78
+ ```bash
79
+ ivault init
80
+ ```
81
+
82
+ ### 2) Create a prompt
83
+ `prompts/support_reply.prompt.yml` (YAML or JSON)
84
+ ```yaml
85
+ spec_version: "1.0"
86
+ name: support_reply
87
+ description: Respond to a support ticket with empathy and clear steps.
88
+ model_defaults:
89
+ temperature: 0.2
90
+
91
+ variables:
92
+ required: [ticket_text]
93
+ optional: [customer_name]
94
+
95
+ messages:
96
+ - role: system
97
+ content: |
98
+ You are a support engineer. Be concise, empathetic, and action-oriented.
99
+ - role: user
100
+ content: |
101
+ Customer: {{ customer_name | default("there") }}
102
+ Ticket:
103
+ {{ ticket_text }}
104
+
105
+ tests:
106
+ - name: must_contain_customer_and_ticket
107
+ vars:
108
+ ticket_text: "My order arrived damaged."
109
+ customer_name: "Alex"
110
+ assert:
111
+ contains_all: ["Customer:", "Ticket:"]
112
+ ```
113
+
114
+ ### 3) Validate + render locally
115
+ ```bash
116
+ ivault validate prompts
117
+ ivault render prompts/support_reply.prompt.yml --vars '{"ticket_text":"My app crashed.","customer_name":"Sam"}'
118
+ ```
119
+
120
+ ### 4) Add dataset‑driven eval
121
+ `datasets/support_cases.jsonl`
122
+ ```jsonl
123
+ {"vars":{"ticket_text":"Order arrived damaged","customer_name":"Alex"},"assert":{"contains_any":["Ticket:"]}}
124
+ {"vars":{"ticket_text":"Need refund"},"assert":{"contains_all":["Ticket:"]}}
125
+ ```
126
+
127
+ ```bash
128
+ ivault eval prompts/support_reply.prompt.yml --dataset datasets/support_cases.jsonl --report out/report.json --junit out/junit.xml
129
+ ```
130
+
131
+ ### 5) Version prompts with tags
132
+ ```bash
133
+ git add prompts datasets
134
+ git commit -m "Add support prompts + eval dataset"
135
+ git tag prompts/v1.0.0
136
+ ```
137
+
138
+ ### 6) Load by Git ref at runtime
139
+ ```python
140
+ from instructvault import InstructVault
141
+
142
+ vault = InstructVault(repo_root=".")
143
+ msgs = vault.render(
144
+ "prompts/support_reply.prompt.yml",
145
+ vars={"ticket_text":"My order is delayed", "customer_name":"Ava"},
146
+ ref="prompts/v1.0.0",
147
+ )
148
+ ```
149
+
150
+ ### 7) Bundle prompts at build time (optional)
151
+ ```bash
152
+ ivault bundle --prompts prompts --out out/ivault.bundle.json --ref prompts/v1.0.0
153
+ ```
154
+
155
+ ```python
156
+ from instructvault import InstructVault
157
+ vault = InstructVault(bundle_path="out/ivault.bundle.json")
158
+ ```
159
+
160
+ ## How teams use this in production
161
+ 1) Prompt changes go through PRs
162
+ 2) CI runs `validate` + `eval`
163
+ 3) Tags or bundles become the deployable artifact
164
+ 4) Apps load by tag or bundle (no runtime network calls)
165
+
166
+ ## Datasets (why JSONL)
167
+ Datasets are **deterministic eval inputs** checked into Git. This makes CI reproducible and audit‑friendly.
168
+ For cloud datasets, use a CI pre‑step (e.g., download from S3) and then run `ivault eval` on the local file.
169
+
170
+ ## Playground (optional)
171
+ A minimal playground exists under `playground/` for local or org‑hosted use.
172
+ It lists prompts, renders with variables, and runs evals — without touching production prompts directly.
173
+ For local dev, run from the repo root:
174
+ ```bash
175
+ export IVAULT_REPO_ROOT=/path/to/your/repo
176
+ PYTHONPATH=. uvicorn ivault_playground.app:app --reload
177
+ ```
178
+
179
+ ## Docs
180
+ - `docs/governance.md`
181
+ - `docs/ci.md`
182
+ - `docs/playground.md`
183
+ - `docs/cookbooks.md`
184
+ - `docs/dropin_guide.md`
185
+ - `docs/release_checklist.md`
186
+ - `docs/ci_templates/gitlab-ci.yml`
187
+ - `docs/ci_templates/Jenkinsfile`
188
+ - `CHANGELOG.md`
189
+ - `CODE_OF_CONDUCT.md`
190
+
191
+ ## License
192
+ Apache‑2.0
@@ -0,0 +1,17 @@
1
+ instructvault/__init__.py,sha256=cg7j0qh6W84D-K0uSOLKKAP2JquW4NRXwZRDDLk5E18,59
2
+ instructvault/bundle.py,sha256=gXOqjpza_8MrYJjJVCzBJKHbO78bXkAbqPUjKnS_6Nk,2478
3
+ instructvault/cli.py,sha256=2ylpvw-GHmZdIUVS3AoepWcQy6RG9QAwqVEQaCFG60o,6065
4
+ instructvault/diff.py,sha256=vz_vmKDXasNFoVKHCk2u_TsboHk1BdwvX0wCnJI1ATQ,252
5
+ instructvault/eval.py,sha256=-yrFHCEUrONvzfKLP8s_RktFU74Ergp9tQJvzfrMR9s,1949
6
+ instructvault/io.py,sha256=mxoNp6SXbFoty22fzrbf7z-B5Nlw0XjgWphK0awA1S8,867
7
+ instructvault/junit.py,sha256=sIEcIiGD3Xk6uCYjnE5p_07j8dPoS_RAc2eoy3BIBeQ,1133
8
+ instructvault/render.py,sha256=vcVnqIXGytskZEKbUofoKgIVflQSYhsmdpEtZs1X19A,919
9
+ instructvault/scaffold.py,sha256=f5gwXE3dUPuJYTedZRqBs8w5SQEgt1dgDSuqW2dxrMg,1685
10
+ instructvault/sdk.py,sha256=JpEik6AZof2cIG-4GO-mdWwZe7w2rwq45jKawwfstsQ,1594
11
+ instructvault/spec.py,sha256=xRkcVjMnoKECLaAfjQHiGgQVMdZ_KvNzf-K-j6DQ6k0,1737
12
+ instructvault/store.py,sha256=NhN49w7xrkeij0lQDr-CEdANYLpNVBXumv_cKqLmiYY,1056
13
+ instructvault-0.2.0.dist-info/METADATA,sha256=pU68kaOH7DoqVGn9iGzq0eIl06dH92JIGx3dnZMsjlA,5536
14
+ instructvault-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
15
+ instructvault-0.2.0.dist-info/entry_points.txt,sha256=cdcMJQwBk9c95LwfN2W6x2xO43FwPjhfV3jHE7TTuHg,49
16
+ instructvault-0.2.0.dist-info/licenses/LICENSE,sha256=VFbCvIsyizmkz4NrZPMdcPhyRK5uM0HhAjv3GBUbb7Y,135
17
+ instructvault-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ivault = instructvault.cli:app
@@ -0,0 +1,5 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION