toolbox-gateway 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.
- toolbox_gateway-0.1.0/.gitignore +35 -0
- toolbox_gateway-0.1.0/PKG-INFO +35 -0
- toolbox_gateway-0.1.0/README.md +8 -0
- toolbox_gateway-0.1.0/pyproject.toml +46 -0
- toolbox_gateway-0.1.0/src/toolbox/__init__.py +20 -0
- toolbox_gateway-0.1.0/src/toolbox/adapters/__init__.py +1 -0
- toolbox_gateway-0.1.0/src/toolbox/adapters/langchain.py +98 -0
- toolbox_gateway-0.1.0/src/toolbox/adapters/openai.py +63 -0
- toolbox_gateway-0.1.0/src/toolbox/backends/__init__.py +5 -0
- toolbox_gateway-0.1.0/src/toolbox/backends/redis_store.py +131 -0
- toolbox_gateway-0.1.0/src/toolbox/backends/sqlite_store.py +133 -0
- toolbox_gateway-0.1.0/src/toolbox/core.py +438 -0
- toolbox_gateway-0.1.0/src/toolbox/hints.py +89 -0
- toolbox_gateway-0.1.0/src/toolbox/mcp.py +72 -0
- toolbox_gateway-0.1.0/src/toolbox/schema.py +308 -0
- toolbox_gateway-0.1.0/tests/test_core.py +283 -0
- toolbox_gateway-0.1.0/tests/test_hints.py +88 -0
- toolbox_gateway-0.1.0/tests/test_mcp.py +99 -0
- toolbox_gateway-0.1.0/tests/test_schema.py +198 -0
- toolbox_gateway-0.1.0/tests/test_sqlite_store.py +104 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ── Python ─────────────────────────────────────────────────────────────
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.eggs/
|
|
8
|
+
*.egg
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
*.db
|
|
16
|
+
.idea/
|
|
17
|
+
.vscode/
|
|
18
|
+
|
|
19
|
+
# ── JavaScript ─────────────────────────────────────────────────────────
|
|
20
|
+
node_modules/
|
|
21
|
+
dist/
|
|
22
|
+
*.d.ts
|
|
23
|
+
*.js.map
|
|
24
|
+
*.tsbuildinfo
|
|
25
|
+
.next/
|
|
26
|
+
coverage/
|
|
27
|
+
.yalc/
|
|
28
|
+
yalc.lock
|
|
29
|
+
|
|
30
|
+
# ── OS / Editor ────────────────────────────────────────────────────────
|
|
31
|
+
.DS_Store
|
|
32
|
+
Thumbs.db
|
|
33
|
+
*.swp
|
|
34
|
+
*.swo
|
|
35
|
+
*~
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolbox-gateway
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Single-tool gateway pattern for LLM agents — discover, inspect, and execute tools on demand
|
|
5
|
+
Author: Jason McNeal
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: agent,ai,llm,mcp,token-optimization,tools
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
21
|
+
Provides-Extra: langchain
|
|
22
|
+
Requires-Dist: langchain-core>=0.1; extra == 'langchain'
|
|
23
|
+
Requires-Dist: pydantic>=2.0; extra == 'langchain'
|
|
24
|
+
Provides-Extra: redis
|
|
25
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# toolbox-gateway
|
|
29
|
+
|
|
30
|
+
Python implementation of the single-tool gateway pattern for LLM agents.
|
|
31
|
+
|
|
32
|
+
See the [top-level README](../../README.md) for full documentation.
|
|
33
|
+
|
|
34
|
+
Install: `pip install toolbox-gateway`
|
|
35
|
+
Import: `from toolbox import Toolbox, Tool`
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "toolbox-gateway"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Single-tool gateway pattern for LLM agents — discover, inspect, and execute tools on demand"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jason McNeal" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["llm", "ai", "agent", "tools", "mcp", "token-optimization"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
redis = ["redis>=5.0"]
|
|
31
|
+
langchain = ["langchain-core>=0.1", "pydantic>=2.0"]
|
|
32
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff>=0.4"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
target-version = "py310"
|
|
36
|
+
line-length = 100
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "N", "UP", "B"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/toolbox"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Toolbox — a single-tool gateway pattern for LLM agents."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .core import Toolbox, Tool
|
|
6
|
+
from .hints import HintStore, Hint, MemoryHintStore
|
|
7
|
+
from .backends.sqlite_store import SQLiteHintStore
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Toolbox",
|
|
11
|
+
"Tool",
|
|
12
|
+
"HintStore",
|
|
13
|
+
"Hint",
|
|
14
|
+
"MemoryHintStore",
|
|
15
|
+
"SQLiteHintStore",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Opt-in: schema formatting utilities
|
|
19
|
+
# Usage: from toolbox.schema import schema_to_csv, schema_to_markdown, data_to_csv
|
|
20
|
+
# Requires: pip install toolbox-gateway[schema] (future: may add dependencies)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""LangChain adapter for Toolbox.
|
|
2
|
+
|
|
3
|
+
Converts the toolbox into a single LangChain BaseTool instance.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from ..core import Toolbox
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LangChainAdapter:
|
|
15
|
+
"""Adapt Toolbox as a single LangChain tool.
|
|
16
|
+
|
|
17
|
+
Requires langchain-core to be installed.
|
|
18
|
+
|
|
19
|
+
Usage::
|
|
20
|
+
|
|
21
|
+
adapter = LangChainAdapter(toolbox)
|
|
22
|
+
tool = adapter.as_tool()
|
|
23
|
+
|
|
24
|
+
# Use in a LangChain agent
|
|
25
|
+
agent = create_react_agent(llm, [tool], prompt)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, toolbox: Toolbox) -> None:
|
|
29
|
+
self.toolbox = toolbox
|
|
30
|
+
|
|
31
|
+
def as_tool(self) -> Any:
|
|
32
|
+
"""Return a LangChain BaseTool wrapping the toolbox.
|
|
33
|
+
|
|
34
|
+
Raises ImportError if langchain-core is not installed.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
from langchain_core.tools import StructuredTool
|
|
38
|
+
except ImportError as e:
|
|
39
|
+
raise ImportError(
|
|
40
|
+
"langchain-core is required for the LangChain adapter. "
|
|
41
|
+
"Install it with: pip install langchain-core"
|
|
42
|
+
) from e
|
|
43
|
+
|
|
44
|
+
def _run(command: str, **kwargs: Any) -> str:
|
|
45
|
+
result = self.toolbox.handle(command=command, **kwargs)
|
|
46
|
+
return json.dumps(result.to_dict(), default=str)
|
|
47
|
+
|
|
48
|
+
async def _arun(command: str, **kwargs: Any) -> str:
|
|
49
|
+
# Synchronous fallback — async execute is not yet supported
|
|
50
|
+
return _run(command, **kwargs)
|
|
51
|
+
|
|
52
|
+
defn = self.toolbox.get_tool_definition()
|
|
53
|
+
params = defn["parameters"]
|
|
54
|
+
|
|
55
|
+
return StructuredTool.from_function(
|
|
56
|
+
func=_run,
|
|
57
|
+
coroutine=_arun,
|
|
58
|
+
name=defn["name"],
|
|
59
|
+
description=defn["description"],
|
|
60
|
+
args_schema=_build_pydantic_schema(params),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_pydantic_schema(json_schema: dict[str, Any]) -> Any:
|
|
65
|
+
"""Convert a JSON Schema to a Pydantic model for LangChain."""
|
|
66
|
+
try:
|
|
67
|
+
from pydantic import BaseModel, Field, create_model
|
|
68
|
+
except ImportError:
|
|
69
|
+
raise ImportError("pydantic is required for the LangChain adapter")
|
|
70
|
+
|
|
71
|
+
properties = json_schema.get("properties", {})
|
|
72
|
+
required = set(json_schema.get("required", []))
|
|
73
|
+
|
|
74
|
+
fields: dict[str, Any] = {}
|
|
75
|
+
for name, prop in properties.items():
|
|
76
|
+
field_type = _json_type_to_python(prop)
|
|
77
|
+
is_required = name in required
|
|
78
|
+
|
|
79
|
+
if is_required:
|
|
80
|
+
fields[name] = (field_type, Field(description=prop.get("description", "")))
|
|
81
|
+
else:
|
|
82
|
+
fields[name] = (Optional[field_type], Field(default=None, description=prop.get("description", "")))
|
|
83
|
+
|
|
84
|
+
return create_model("ToolboxInput", **fields)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _json_type_to_python(prop: dict[str, Any]) -> type:
|
|
88
|
+
"""Map JSON Schema types to Python types."""
|
|
89
|
+
type_map = {
|
|
90
|
+
"string": str,
|
|
91
|
+
"integer": int,
|
|
92
|
+
"number": float,
|
|
93
|
+
"boolean": bool,
|
|
94
|
+
"array": list,
|
|
95
|
+
"object": dict,
|
|
96
|
+
}
|
|
97
|
+
json_type = prop.get("type", "string")
|
|
98
|
+
return type_map.get(json_type, str)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""OpenAI function calling adapter for Toolbox.
|
|
2
|
+
|
|
3
|
+
Converts the toolbox into a single OpenAI function definition and
|
|
4
|
+
provides a handler for tool call responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..core import Toolbox, ToolboxCommand
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OpenAIAdapter:
|
|
16
|
+
"""Adapt Toolbox for OpenAI's function calling API.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
adapter = OpenAIAdapter(toolbox)
|
|
21
|
+
|
|
22
|
+
# Get the function definition for your API call
|
|
23
|
+
functions = [adapter.get_function_schema()]
|
|
24
|
+
|
|
25
|
+
# When OpenAI returns a tool call, handle it:
|
|
26
|
+
result = adapter.handle_tool_call(tool_call)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, toolbox: Toolbox) -> None:
|
|
30
|
+
self.toolbox = toolbox
|
|
31
|
+
|
|
32
|
+
def get_function_schema(self) -> dict[str, Any]:
|
|
33
|
+
"""Return the OpenAI function calling schema for toolbox."""
|
|
34
|
+
defn = self.toolbox.get_tool_definition()
|
|
35
|
+
return {
|
|
36
|
+
"type": "function",
|
|
37
|
+
"function": {
|
|
38
|
+
"name": defn["name"],
|
|
39
|
+
"description": defn["description"],
|
|
40
|
+
"parameters": defn["parameters"],
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def handle_tool_call(self, tool_call: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
"""Handle an OpenAI tool call response.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
tool_call: The tool call object from OpenAI's response,
|
|
49
|
+
with 'function' containing 'arguments' as a JSON string.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A dict suitable for sending back as a tool message.
|
|
53
|
+
"""
|
|
54
|
+
arguments = json.loads(tool_call["function"]["arguments"])
|
|
55
|
+
command = arguments.get("command", "")
|
|
56
|
+
result = self.toolbox.handle(command=command, **arguments)
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"tool_call_id": tool_call.get("id", ""),
|
|
60
|
+
"role": "tool",
|
|
61
|
+
"name": "toolbox",
|
|
62
|
+
"content": json.dumps(result.to_dict()),
|
|
63
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Redis hint store — production-grade persistent hints with TTL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..hints import Hint, HintStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RedisHintStore:
|
|
13
|
+
"""Redis-backed hint store for production use.
|
|
14
|
+
|
|
15
|
+
Hints are stored with a configurable TTL (default 30 days).
|
|
16
|
+
Uses SCAN for key discovery to avoid blocking Redis.
|
|
17
|
+
|
|
18
|
+
Requires the ``redis`` package: pip install redis
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
import redis
|
|
23
|
+
client = redis.Redis(host="localhost", port=6379, db=0)
|
|
24
|
+
store = RedisHintStore(client)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
KEY_PREFIX = "tool-hints"
|
|
28
|
+
DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60 # 30 days
|
|
29
|
+
|
|
30
|
+
def __init__(self, client: Any, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> None:
|
|
31
|
+
self._redis = client
|
|
32
|
+
self._ttl = ttl_seconds
|
|
33
|
+
|
|
34
|
+
# ── Key helpers ────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _id_key(self, hint_id: str) -> str:
|
|
37
|
+
return f"{self.KEY_PREFIX}:id:{hint_id}"
|
|
38
|
+
|
|
39
|
+
def _index_key(self, category: str, key: str) -> str:
|
|
40
|
+
return f"{self.KEY_PREFIX}:index:{category}:{key}"
|
|
41
|
+
|
|
42
|
+
# ── Interface ──────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def read(self, *, category: str | None = None, key: str | None = None) -> list[Hint]:
|
|
45
|
+
all_hints = self._get_all_hints()
|
|
46
|
+
if category:
|
|
47
|
+
all_hints = [h for h in all_hints if h.category == category]
|
|
48
|
+
if key:
|
|
49
|
+
all_hints = [h for h in all_hints if h.key == key]
|
|
50
|
+
return all_hints
|
|
51
|
+
|
|
52
|
+
def get_by_id(self, hint_id: str) -> Optional[Hint]:
|
|
53
|
+
data = self._redis.get(self._id_key(hint_id))
|
|
54
|
+
if data:
|
|
55
|
+
return Hint(**json.loads(data))
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def create(self, *, category: str, key: str, hint: str) -> Hint:
|
|
59
|
+
# Idempotent: check for existing hint with same category+key
|
|
60
|
+
existing_id = self._redis.get(self._index_key(category, key))
|
|
61
|
+
if existing_id:
|
|
62
|
+
existing_data = self._redis.get(self._id_key(existing_id.decode() if isinstance(existing_id, bytes) else existing_id))
|
|
63
|
+
if existing_data:
|
|
64
|
+
return Hint(**json.loads(existing_data))
|
|
65
|
+
|
|
66
|
+
from uuid import uuid4
|
|
67
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
68
|
+
hint_obj = Hint(id=str(uuid4()), category=category, key=key, hint=hint, created_at=now, updated_at=now)
|
|
69
|
+
|
|
70
|
+
id_key = self._id_key(hint_obj.id)
|
|
71
|
+
idx_key = self._index_key(category, key)
|
|
72
|
+
|
|
73
|
+
self._redis.setex(id_key, self._ttl, json.dumps({
|
|
74
|
+
"id": hint_obj.id, "category": hint_obj.category,
|
|
75
|
+
"key": hint_obj.key, "hint": hint_obj.hint,
|
|
76
|
+
"created_at": hint_obj.created_at, "updated_at": hint_obj.updated_at,
|
|
77
|
+
}))
|
|
78
|
+
self._redis.setex(idx_key, self._ttl, hint_obj.id)
|
|
79
|
+
|
|
80
|
+
return hint_obj
|
|
81
|
+
|
|
82
|
+
def update(self, *, hint_id: str, hint: str) -> Optional[Hint]:
|
|
83
|
+
data = self._redis.get(self._id_key(hint_id))
|
|
84
|
+
if not data:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
existing = Hint(**json.loads(data))
|
|
88
|
+
updated = Hint(
|
|
89
|
+
id=existing.id, category=existing.category, key=existing.key,
|
|
90
|
+
hint=hint, created_at=existing.created_at,
|
|
91
|
+
updated_at=datetime.now(timezone.utc).isoformat(),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
id_key = self._id_key(updated.id)
|
|
95
|
+
self._redis.setex(id_key, self._ttl, json.dumps({
|
|
96
|
+
"id": updated.id, "category": updated.category,
|
|
97
|
+
"key": updated.key, "hint": updated.hint,
|
|
98
|
+
"created_at": updated.created_at, "updated_at": updated.updated_at,
|
|
99
|
+
}))
|
|
100
|
+
|
|
101
|
+
return updated
|
|
102
|
+
|
|
103
|
+
def delete(self, *, hint_id: str) -> bool:
|
|
104
|
+
data = self._redis.get(self._id_key(hint_id))
|
|
105
|
+
if not data:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
existing = Hint(**json.loads(data))
|
|
109
|
+
self._redis.delete(self._id_key(hint_id))
|
|
110
|
+
self._redis.delete(self._index_key(existing.category, existing.key))
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
# ── Internals ──────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def _get_all_hints(self) -> list[Hint]:
|
|
116
|
+
"""Use SCAN to discover all hint keys without blocking."""
|
|
117
|
+
hints: list[Hint] = []
|
|
118
|
+
pattern = f"{self.KEY_PREFIX}:id:*"
|
|
119
|
+
cursor = 0
|
|
120
|
+
|
|
121
|
+
while True:
|
|
122
|
+
cursor, keys = self._redis.scan(cursor, match=pattern, count=100)
|
|
123
|
+
for key in keys:
|
|
124
|
+
key_str = key.decode() if isinstance(key, bytes) else key
|
|
125
|
+
data = self._redis.get(key_str)
|
|
126
|
+
if data:
|
|
127
|
+
hints.append(Hint(**json.loads(data)))
|
|
128
|
+
if cursor == 0:
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
return hints
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""SQLite hint store — lightweight persistent hints with no external deps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from ..hints import Hint, HintStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_DB_PATH = ".toolbox/hints.db"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SQLiteHintStore:
|
|
18
|
+
"""SQLite-backed hint store for production use.
|
|
19
|
+
|
|
20
|
+
Zero external dependencies — uses Python's built-in sqlite3 module.
|
|
21
|
+
Persists hints across restarts with no infrastructure requirements.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
store = SQLiteHintStore() # .toolbox/hints.db
|
|
26
|
+
store = SQLiteHintStore(path="data/my_hints.db") # custom path
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: str = DEFAULT_DB_PATH) -> None:
|
|
30
|
+
self._path = path
|
|
31
|
+
self._ensure_db()
|
|
32
|
+
|
|
33
|
+
def _ensure_db(self) -> None:
|
|
34
|
+
"""Create the database and table if they don't exist."""
|
|
35
|
+
Path(self._path).parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
with self._connect() as conn:
|
|
37
|
+
conn.execute("""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS hints (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
category TEXT NOT NULL,
|
|
41
|
+
key TEXT NOT NULL,
|
|
42
|
+
hint TEXT NOT NULL,
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
updated_at TEXT NOT NULL
|
|
45
|
+
)
|
|
46
|
+
""")
|
|
47
|
+
conn.execute("""
|
|
48
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hints_category_key
|
|
49
|
+
ON hints (category, key)
|
|
50
|
+
""")
|
|
51
|
+
|
|
52
|
+
def _connect(self) -> sqlite3.Connection:
|
|
53
|
+
conn = sqlite3.connect(self._path)
|
|
54
|
+
conn.row_factory = sqlite3.Row
|
|
55
|
+
return conn
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _row_to_hint(row: sqlite3.Row) -> Hint:
|
|
59
|
+
return Hint(
|
|
60
|
+
id=row["id"],
|
|
61
|
+
category=row["category"],
|
|
62
|
+
key=row["key"],
|
|
63
|
+
hint=row["hint"],
|
|
64
|
+
created_at=row["created_at"],
|
|
65
|
+
updated_at=row["updated_at"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# ── Interface ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def read(self, *, category: str | None = None, key: str | None = None) -> list[Hint]:
|
|
71
|
+
with self._connect() as conn:
|
|
72
|
+
query = "SELECT * FROM hints"
|
|
73
|
+
conditions: list[str] = []
|
|
74
|
+
params: list[str] = []
|
|
75
|
+
|
|
76
|
+
if category:
|
|
77
|
+
conditions.append("category = ?")
|
|
78
|
+
params.append(category)
|
|
79
|
+
if key:
|
|
80
|
+
conditions.append("key = ?")
|
|
81
|
+
params.append(key)
|
|
82
|
+
|
|
83
|
+
if conditions:
|
|
84
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
85
|
+
|
|
86
|
+
rows = conn.execute(query, params).fetchall()
|
|
87
|
+
return [self._row_to_hint(row) for row in rows]
|
|
88
|
+
|
|
89
|
+
def get_by_id(self, hint_id: str) -> Optional[Hint]:
|
|
90
|
+
with self._connect() as conn:
|
|
91
|
+
row = conn.execute("SELECT * FROM hints WHERE id = ?", (hint_id,)).fetchone()
|
|
92
|
+
return self._row_to_hint(row) if row else None
|
|
93
|
+
|
|
94
|
+
def create(self, *, category: str, key: str, hint: str) -> Hint:
|
|
95
|
+
from uuid import uuid4
|
|
96
|
+
|
|
97
|
+
with self._connect() as conn:
|
|
98
|
+
# Check for existing (idempotent)
|
|
99
|
+
existing = conn.execute(
|
|
100
|
+
"SELECT * FROM hints WHERE category = ? AND key = ?",
|
|
101
|
+
(category, key),
|
|
102
|
+
).fetchone()
|
|
103
|
+
|
|
104
|
+
if existing:
|
|
105
|
+
return self._row_to_hint(existing)
|
|
106
|
+
|
|
107
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
108
|
+
hint_obj = Hint(id=str(uuid4()), category=category, key=key, hint=hint, created_at=now, updated_at=now)
|
|
109
|
+
|
|
110
|
+
conn.execute(
|
|
111
|
+
"INSERT INTO hints (id, category, key, hint, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
112
|
+
(hint_obj.id, hint_obj.category, hint_obj.key, hint_obj.hint, hint_obj.created_at, hint_obj.updated_at),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return hint_obj
|
|
116
|
+
|
|
117
|
+
def update(self, *, hint_id: str, hint: str) -> Optional[Hint]:
|
|
118
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
119
|
+
with self._connect() as conn:
|
|
120
|
+
cursor = conn.execute(
|
|
121
|
+
"UPDATE hints SET hint = ?, updated_at = ? WHERE id = ?",
|
|
122
|
+
(hint, now, hint_id),
|
|
123
|
+
)
|
|
124
|
+
if cursor.rowcount == 0:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
row = conn.execute("SELECT * FROM hints WHERE id = ?", (hint_id,)).fetchone()
|
|
128
|
+
return self._row_to_hint(row) if row else None
|
|
129
|
+
|
|
130
|
+
def delete(self, *, hint_id: str) -> bool:
|
|
131
|
+
with self._connect() as conn:
|
|
132
|
+
cursor = conn.execute("DELETE FROM hints WHERE id = ?", (hint_id,))
|
|
133
|
+
return cursor.rowcount > 0
|