kodit 0.1.4__tar.gz → 0.1.6__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.
Potentially problematic release.
This version of kodit might be problematic. Click here for more details.
- {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/pypi.yaml +0 -1
- {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/test.yaml +6 -0
- kodit-0.1.6/.vscode/launch.json +15 -0
- {kodit-0.1.4 → kodit-0.1.6}/.vscode/settings.json +1 -1
- {kodit-0.1.4 → kodit-0.1.6}/PKG-INFO +6 -2
- {kodit-0.1.4 → kodit-0.1.6}/docs/_index.md +2 -1
- {kodit-0.1.4 → kodit-0.1.6}/pyproject.toml +6 -2
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/_version.py +2 -2
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/env.py +5 -4
- kodit-0.1.6/src/kodit/app.py +29 -0
- kodit-0.1.6/src/kodit/bm25/__init__.py +1 -0
- kodit-0.1.6/src/kodit/bm25/bm25.py +71 -0
- kodit-0.1.6/src/kodit/cli.py +272 -0
- kodit-0.1.6/src/kodit/config.py +97 -0
- kodit-0.1.6/src/kodit/database.py +75 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/indexing/repository.py +11 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/indexing/service.py +28 -16
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/logging.py +20 -18
- kodit-0.1.6/src/kodit/mcp.py +160 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/middleware.py +16 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/retreival/repository.py +32 -0
- kodit-0.1.6/src/kodit/retreival/service.py +69 -0
- kodit-0.1.6/src/kodit/snippets/__init__.py +1 -0
- kodit-0.1.6/src/kodit/snippets/languages/__init__.py +53 -0
- kodit-0.1.6/src/kodit/snippets/languages/csharp.scm +12 -0
- kodit-0.1.6/src/kodit/snippets/languages/python.scm +22 -0
- kodit-0.1.6/src/kodit/snippets/method_snippets.py +120 -0
- kodit-0.1.6/src/kodit/snippets/snippets.py +48 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/sources/service.py +3 -5
- {kodit-0.1.4 → kodit-0.1.6}/tests/conftest.py +18 -0
- kodit-0.1.6/tests/kodit/cli_test.py +75 -0
- kodit-0.1.6/tests/kodit/e2e.py +145 -0
- {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/indexing/test_service.py +11 -6
- kodit-0.1.6/tests/kodit/mcp_test.py +41 -0
- {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/retreival/test_service.py +28 -6
- kodit-0.1.6/tests/kodit/snippets/__init__.py +0 -0
- kodit-0.1.6/tests/kodit/snippets/csharp.cs +44 -0
- kodit-0.1.6/tests/kodit/snippets/detect_language_test.py +87 -0
- kodit-0.1.6/tests/kodit/snippets/method_extraction_test.py +108 -0
- kodit-0.1.6/tests/kodit/snippets/python.py +24 -0
- {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/sources/test_service.py +2 -2
- kodit-0.1.6/tests/smoke.sh +40 -0
- {kodit-0.1.4 → kodit-0.1.6}/uv.lock +428 -92
- kodit-0.1.4/src/kodit/app.py +0 -25
- kodit-0.1.4/src/kodit/cli.py +0 -186
- kodit-0.1.4/src/kodit/config.py +0 -5
- kodit-0.1.4/src/kodit/database.py +0 -91
- kodit-0.1.4/src/kodit/mcp.py +0 -110
- kodit-0.1.4/src/kodit/retreival/service.py +0 -30
- kodit-0.1.4/src/kodit/sse.py +0 -61
- kodit-0.1.4/tests/kodit/cli_test.py +0 -19
- kodit-0.1.4/tests/kodit/mcp_test.py +0 -109
- kodit-0.1.4/tests/smoke.sh +0 -20
- {kodit-0.1.4 → kodit-0.1.6}/.cursor/rules/kodit.mdc +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.github/CODE_OF_CONDUCT.md +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.github/CONTRIBUTING.md +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/docker.yaml +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/docs.yaml +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/pypi-test.yaml +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.gitignore +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/.python-version +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/Dockerfile +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/LICENSE +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/README.md +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/alembic.ini +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/docs/developer/index.md +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/.gitignore +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/README +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/script.py.mako +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/versions/85155663351e_initial.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/versions/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/indexing/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/indexing/models.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/retreival/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/sources/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/sources/models.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/src/kodit/sources/repository.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/tests/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/indexing/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/retreival/__init__.py +0 -0
- {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/sources/__init__.py +0 -0
|
@@ -14,6 +14,7 @@ permissions:
|
|
|
14
14
|
jobs:
|
|
15
15
|
test:
|
|
16
16
|
runs-on: ubuntu-latest
|
|
17
|
+
timeout-minutes: 10
|
|
17
18
|
steps:
|
|
18
19
|
- name: Checkout code
|
|
19
20
|
uses: actions/checkout@v4
|
|
@@ -44,6 +45,7 @@ jobs:
|
|
|
44
45
|
|
|
45
46
|
build-package:
|
|
46
47
|
runs-on: ubuntu-latest
|
|
48
|
+
timeout-minutes: 10
|
|
47
49
|
steps:
|
|
48
50
|
- name: Checkout code
|
|
49
51
|
uses: actions/checkout@v4
|
|
@@ -67,6 +69,7 @@ jobs:
|
|
|
67
69
|
test-package:
|
|
68
70
|
needs: build-package
|
|
69
71
|
runs-on: ubuntu-latest
|
|
72
|
+
timeout-minutes: 10
|
|
70
73
|
steps:
|
|
71
74
|
- uses: actions/checkout@v4
|
|
72
75
|
with:
|
|
@@ -97,5 +100,8 @@ jobs:
|
|
|
97
100
|
- name: Run simple version command test
|
|
98
101
|
run: kodit version
|
|
99
102
|
|
|
103
|
+
- name: Delete kodit data_dir
|
|
104
|
+
run: rm -rf ${HOME}/.kodit
|
|
105
|
+
|
|
100
106
|
- name: Run smoke test
|
|
101
107
|
run: ./tests/smoke.sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kodit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Code indexing for better AI code generation
|
|
5
5
|
Project-URL: Homepage, https://docs.helixml.tech/kodit/
|
|
6
6
|
Project-URL: Documentation, https://docs.helixml.tech/kodit/
|
|
@@ -22,18 +22,22 @@ Requires-Dist: aiosqlite>=0.20.0
|
|
|
22
22
|
Requires-Dist: alembic>=1.15.2
|
|
23
23
|
Requires-Dist: asgi-correlation-id>=4.3.4
|
|
24
24
|
Requires-Dist: better-exceptions>=0.3.3
|
|
25
|
+
Requires-Dist: bm25s[core]>=0.2.12
|
|
25
26
|
Requires-Dist: click>=8.1.8
|
|
26
27
|
Requires-Dist: colorama>=0.4.6
|
|
27
28
|
Requires-Dist: dotenv>=0.9.9
|
|
28
29
|
Requires-Dist: fastapi[standard]>=0.115.12
|
|
30
|
+
Requires-Dist: fastmcp>=2.3.3
|
|
29
31
|
Requires-Dist: httpx-retries>=0.3.2
|
|
30
32
|
Requires-Dist: httpx>=0.28.1
|
|
31
|
-
Requires-Dist: mcp>=1.6.0
|
|
32
33
|
Requires-Dist: posthog>=4.0.1
|
|
34
|
+
Requires-Dist: pydantic-settings>=2.9.1
|
|
33
35
|
Requires-Dist: pytable-formatter>=0.1.1
|
|
34
36
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.40
|
|
35
37
|
Requires-Dist: structlog>=25.3.0
|
|
36
38
|
Requires-Dist: tdqm>=0.0.1
|
|
39
|
+
Requires-Dist: tree-sitter-language-pack>=0.7.3
|
|
40
|
+
Requires-Dist: tree-sitter>=0.24.0
|
|
37
41
|
Requires-Dist: uritools>=5.0.0
|
|
38
42
|
Description-Content-Type: text/markdown
|
|
39
43
|
|
|
@@ -87,6 +87,7 @@ description:
|
|
|
87
87
|
globs:
|
|
88
88
|
alwaysApply: true
|
|
89
89
|
---
|
|
90
|
-
You MUST use the code-search MCP tool and always include any file context the user has provided in the chat
|
|
90
|
+
You MUST use the code-search MCP tool and always include any file context the user has provided in the chat.
|
|
91
|
+
```
|
|
91
92
|
|
|
92
93
|
Alternatively, you can browse to the cursor settings and set this prompt globally.
|
|
@@ -29,7 +29,6 @@ dependencies = [
|
|
|
29
29
|
"fastapi[standard]>=0.115.12",
|
|
30
30
|
"httpx-retries>=0.3.2",
|
|
31
31
|
"httpx>=0.28.1",
|
|
32
|
-
"mcp>=1.6.0",
|
|
33
32
|
"structlog>=25.3.0",
|
|
34
33
|
"posthog>=4.0.1",
|
|
35
34
|
"sqlalchemy[asyncio]>=2.0.40",
|
|
@@ -39,6 +38,11 @@ dependencies = [
|
|
|
39
38
|
"aiofiles>=24.1.0",
|
|
40
39
|
"tdqm>=0.0.1",
|
|
41
40
|
"uritools>=5.0.0",
|
|
41
|
+
"tree-sitter-language-pack>=0.7.3",
|
|
42
|
+
"tree-sitter>=0.24.0",
|
|
43
|
+
"fastmcp>=2.3.3",
|
|
44
|
+
"pydantic-settings>=2.9.1",
|
|
45
|
+
"bm25s[core]>=0.2.12",
|
|
42
46
|
]
|
|
43
47
|
|
|
44
48
|
[dependency-groups]
|
|
@@ -104,7 +108,7 @@ ignore = [
|
|
|
104
108
|
"PGH004", # If I've disabled all, I mean disable all
|
|
105
109
|
]
|
|
106
110
|
select = ["ALL"]
|
|
107
|
-
exclude = []
|
|
111
|
+
exclude = ["./tests/*"]
|
|
108
112
|
|
|
109
113
|
[[tool.uv.index]]
|
|
110
114
|
name = "pypi"
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
5
|
|
|
6
|
-
import structlog
|
|
7
6
|
from alembic import context
|
|
8
7
|
from sqlalchemy import pool
|
|
9
8
|
from sqlalchemy.engine import Connection
|
|
@@ -66,8 +65,6 @@ async def run_async_migrations() -> None:
|
|
|
66
65
|
prefix="sqlalchemy.",
|
|
67
66
|
poolclass=pool.NullPool,
|
|
68
67
|
)
|
|
69
|
-
log = structlog.get_logger(__name__)
|
|
70
|
-
log.debug("Running migrations on %s", connectable.url)
|
|
71
68
|
|
|
72
69
|
async with connectable.connect() as connection:
|
|
73
70
|
await connection.run_sync(do_run_migrations)
|
|
@@ -77,7 +74,11 @@ async def run_async_migrations() -> None:
|
|
|
77
74
|
|
|
78
75
|
def run_migrations_online() -> None:
|
|
79
76
|
"""Run migrations in 'online' mode."""
|
|
80
|
-
|
|
77
|
+
connectable = config.attributes.get("connection", None)
|
|
78
|
+
if connectable is None:
|
|
79
|
+
asyncio.run(run_async_migrations())
|
|
80
|
+
else:
|
|
81
|
+
do_run_migrations(connectable)
|
|
81
82
|
|
|
82
83
|
|
|
83
84
|
if context.is_offline_mode():
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""FastAPI application for kodit API."""
|
|
2
|
+
|
|
3
|
+
from asgi_correlation_id import CorrelationIdMiddleware
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
|
|
6
|
+
from kodit.mcp import mcp
|
|
7
|
+
from kodit.middleware import ASGICancelledErrorMiddleware, logging_middleware
|
|
8
|
+
|
|
9
|
+
# See https://gofastmcp.com/deployment/asgi#fastapi-integration
|
|
10
|
+
mcp_app = mcp.sse_app()
|
|
11
|
+
app = FastAPI(title="kodit API", lifespan=mcp_app.router.lifespan_context)
|
|
12
|
+
|
|
13
|
+
# Add middleware
|
|
14
|
+
app.middleware("http")(logging_middleware)
|
|
15
|
+
app.add_middleware(CorrelationIdMiddleware)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.get("/")
|
|
19
|
+
async def root() -> dict[str, str]:
|
|
20
|
+
"""Return a welcome message for the kodit API."""
|
|
21
|
+
return {"message": "Hello, World!"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Add mcp routes last, otherwise previous routes aren't added
|
|
25
|
+
app.mount("", mcp_app)
|
|
26
|
+
|
|
27
|
+
# Wrap the entire app with ASGI middleware after all routes are added to suppress
|
|
28
|
+
# CancelledError at the ASGI level
|
|
29
|
+
app = ASGICancelledErrorMiddleware(app)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""BM25 module."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""BM25 service."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import bm25s
|
|
6
|
+
import Stemmer
|
|
7
|
+
import structlog
|
|
8
|
+
from bm25s.tokenization import Tokenized
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BM25Service:
|
|
12
|
+
"""Service for BM25."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, data_dir: Path) -> None:
|
|
15
|
+
"""Initialize the BM25 service."""
|
|
16
|
+
self.log = structlog.get_logger(__name__)
|
|
17
|
+
self.index_path = data_dir / "bm25s_index"
|
|
18
|
+
try:
|
|
19
|
+
self.log.debug("Loading BM25 index")
|
|
20
|
+
self.retriever = bm25s.BM25.load(self.index_path, mmap=True)
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
self.log.debug("BM25 index not found, creating new index")
|
|
23
|
+
self.retriever = bm25s.BM25()
|
|
24
|
+
|
|
25
|
+
self.stemmer = Stemmer.Stemmer("english")
|
|
26
|
+
|
|
27
|
+
def _tokenize(self, corpus: list[str]) -> list[list[str]] | Tokenized:
|
|
28
|
+
return bm25s.tokenize(
|
|
29
|
+
corpus,
|
|
30
|
+
stopwords="en",
|
|
31
|
+
stemmer=self.stemmer,
|
|
32
|
+
return_ids=False,
|
|
33
|
+
show_progress=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def index(self, corpus: list[str]) -> None:
|
|
37
|
+
"""Index a new corpus."""
|
|
38
|
+
self.log.debug("Indexing corpus")
|
|
39
|
+
vocab = self._tokenize(corpus)
|
|
40
|
+
self.retriever = bm25s.BM25()
|
|
41
|
+
self.retriever.index(vocab)
|
|
42
|
+
self.retriever.save(self.index_path)
|
|
43
|
+
|
|
44
|
+
def retrieve(
|
|
45
|
+
self, doc_ids: list[int], query: str, top_k: int = 2
|
|
46
|
+
) -> list[tuple[int, float]]:
|
|
47
|
+
"""Retrieve from the index."""
|
|
48
|
+
if top_k == 0:
|
|
49
|
+
self.log.warning("Top k is 0, returning empty list")
|
|
50
|
+
return []
|
|
51
|
+
if len(doc_ids) == 0:
|
|
52
|
+
self.log.warning("No documents to retrieve from, returning empty list")
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
top_k = min(top_k, len(doc_ids))
|
|
56
|
+
self.log.debug(
|
|
57
|
+
"Retrieving from index", query=query, top_k=top_k, num_docs=len(doc_ids)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
query_tokens = self._tokenize([query])
|
|
61
|
+
|
|
62
|
+
self.log.debug("Query tokens", query_tokens=query_tokens)
|
|
63
|
+
|
|
64
|
+
results, scores = self.retriever.retrieve(
|
|
65
|
+
query_tokens=query_tokens, corpus=doc_ids, k=top_k
|
|
66
|
+
)
|
|
67
|
+
self.log.debug("Raw results", results=results, scores=scores)
|
|
68
|
+
return [
|
|
69
|
+
(int(result), float(score))
|
|
70
|
+
for result, score in zip(results[0], scores[0], strict=False)
|
|
71
|
+
]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Command line interface for kodit."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import structlog
|
|
10
|
+
import uvicorn
|
|
11
|
+
from pytable_formatter import Table
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from kodit.config import (
|
|
15
|
+
DEFAULT_BASE_DIR,
|
|
16
|
+
DEFAULT_DB_URL,
|
|
17
|
+
DEFAULT_DISABLE_TELEMETRY,
|
|
18
|
+
DEFAULT_LOG_FORMAT,
|
|
19
|
+
DEFAULT_LOG_LEVEL,
|
|
20
|
+
AppContext,
|
|
21
|
+
with_app_context,
|
|
22
|
+
with_session,
|
|
23
|
+
)
|
|
24
|
+
from kodit.indexing.repository import IndexRepository
|
|
25
|
+
from kodit.indexing.service import IndexService
|
|
26
|
+
from kodit.logging import configure_logging, configure_telemetry, log_event
|
|
27
|
+
from kodit.retreival.repository import RetrievalRepository
|
|
28
|
+
from kodit.retreival.service import RetrievalRequest, RetrievalService
|
|
29
|
+
from kodit.sources.repository import SourceRepository
|
|
30
|
+
from kodit.sources.service import SourceService
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group(context_settings={"max_content_width": 100})
|
|
34
|
+
@click.option("--log-level", help=f"Log level [default: {DEFAULT_LOG_LEVEL}]")
|
|
35
|
+
@click.option("--log-format", help=f"Log format [default: {DEFAULT_LOG_FORMAT}]")
|
|
36
|
+
@click.option(
|
|
37
|
+
"--disable-telemetry",
|
|
38
|
+
is_flag=True,
|
|
39
|
+
help=f"Disable telemetry [default: {DEFAULT_DISABLE_TELEMETRY}]",
|
|
40
|
+
)
|
|
41
|
+
@click.option("--db-url", help=f"Database URL [default: {DEFAULT_DB_URL}]")
|
|
42
|
+
@click.option("--data-dir", help=f"Data directory [default: {DEFAULT_BASE_DIR}]")
|
|
43
|
+
@click.option(
|
|
44
|
+
"--env-file",
|
|
45
|
+
help="Path to a .env file [default: .env]",
|
|
46
|
+
type=click.Path(
|
|
47
|
+
exists=True,
|
|
48
|
+
dir_okay=False,
|
|
49
|
+
resolve_path=True,
|
|
50
|
+
path_type=Path,
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def cli( # noqa: PLR0913
|
|
55
|
+
ctx: click.Context,
|
|
56
|
+
log_level: str | None,
|
|
57
|
+
log_format: str | None,
|
|
58
|
+
disable_telemetry: bool | None,
|
|
59
|
+
db_url: str | None,
|
|
60
|
+
data_dir: str | None,
|
|
61
|
+
env_file: Path | None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""kodit CLI - Code indexing for better AI code generation.""" # noqa: D403
|
|
64
|
+
config = AppContext()
|
|
65
|
+
# First check if env-file is set and reload config if it is
|
|
66
|
+
if env_file:
|
|
67
|
+
config = AppContext(_env_file=env_file) # type: ignore[reportCallIssue]
|
|
68
|
+
|
|
69
|
+
# Now override with CLI arguments, if set
|
|
70
|
+
if data_dir:
|
|
71
|
+
config.data_dir = Path(data_dir)
|
|
72
|
+
if db_url:
|
|
73
|
+
config.db_url = db_url
|
|
74
|
+
if log_level:
|
|
75
|
+
config.log_level = log_level
|
|
76
|
+
if log_format:
|
|
77
|
+
config.log_format = log_format
|
|
78
|
+
if disable_telemetry:
|
|
79
|
+
config.disable_telemetry = disable_telemetry
|
|
80
|
+
configure_logging(config)
|
|
81
|
+
configure_telemetry(config)
|
|
82
|
+
|
|
83
|
+
# Set the app context in the click context for downstream cli
|
|
84
|
+
ctx.obj = config
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@cli.group()
|
|
88
|
+
def sources() -> None:
|
|
89
|
+
"""Manage code sources."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@sources.command(name="list")
|
|
93
|
+
@with_app_context
|
|
94
|
+
@with_session
|
|
95
|
+
async def list_sources(session: AsyncSession, app_context: AppContext) -> None:
|
|
96
|
+
"""List all code sources."""
|
|
97
|
+
repository = SourceRepository(session)
|
|
98
|
+
service = SourceService(app_context.get_clone_dir(), repository)
|
|
99
|
+
sources = await service.list_sources()
|
|
100
|
+
|
|
101
|
+
# Define headers and data
|
|
102
|
+
headers = ["ID", "Created At", "URI"]
|
|
103
|
+
data = [[source.id, source.created_at, source.uri] for source in sources]
|
|
104
|
+
|
|
105
|
+
# Create and display the table
|
|
106
|
+
table = Table(headers=headers, data=data)
|
|
107
|
+
click.echo(table)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@sources.command(name="create")
|
|
111
|
+
@click.argument("uri")
|
|
112
|
+
@with_app_context
|
|
113
|
+
@with_session
|
|
114
|
+
async def create_source(
|
|
115
|
+
session: AsyncSession, app_context: AppContext, uri: str
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Add a new code source."""
|
|
118
|
+
repository = SourceRepository(session)
|
|
119
|
+
service = SourceService(app_context.get_clone_dir(), repository)
|
|
120
|
+
source = await service.create(uri)
|
|
121
|
+
click.echo(f"Source created: {source.id}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@cli.group()
|
|
125
|
+
def indexes() -> None:
|
|
126
|
+
"""Manage indexes."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@indexes.command(name="create")
|
|
130
|
+
@click.argument("source_id")
|
|
131
|
+
@with_app_context
|
|
132
|
+
@with_session
|
|
133
|
+
async def create_index(
|
|
134
|
+
session: AsyncSession, app_context: AppContext, source_id: int
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Create an index for a source."""
|
|
137
|
+
source_repository = SourceRepository(session)
|
|
138
|
+
source_service = SourceService(app_context.get_clone_dir(), source_repository)
|
|
139
|
+
repository = IndexRepository(session)
|
|
140
|
+
service = IndexService(repository, source_service, app_context.get_data_dir())
|
|
141
|
+
index = await service.create(source_id)
|
|
142
|
+
click.echo(f"Index created: {index.id}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@indexes.command(name="list")
|
|
146
|
+
@with_app_context
|
|
147
|
+
@with_session
|
|
148
|
+
async def list_indexes(session: AsyncSession, app_context: AppContext) -> None:
|
|
149
|
+
"""List all indexes."""
|
|
150
|
+
source_repository = SourceRepository(session)
|
|
151
|
+
source_service = SourceService(app_context.get_clone_dir(), source_repository)
|
|
152
|
+
repository = IndexRepository(session)
|
|
153
|
+
service = IndexService(repository, source_service, app_context.get_data_dir())
|
|
154
|
+
indexes = await service.list_indexes()
|
|
155
|
+
|
|
156
|
+
# Define headers and data
|
|
157
|
+
headers = [
|
|
158
|
+
"ID",
|
|
159
|
+
"Created At",
|
|
160
|
+
"Updated At",
|
|
161
|
+
"Num Snippets",
|
|
162
|
+
]
|
|
163
|
+
data = [
|
|
164
|
+
[
|
|
165
|
+
index.id,
|
|
166
|
+
index.created_at,
|
|
167
|
+
index.updated_at,
|
|
168
|
+
index.num_snippets,
|
|
169
|
+
]
|
|
170
|
+
for index in indexes
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
# Create and display the table
|
|
174
|
+
table = Table(headers=headers, data=data)
|
|
175
|
+
click.echo(table)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@indexes.command(name="run")
|
|
179
|
+
@click.argument("index_id")
|
|
180
|
+
@with_app_context
|
|
181
|
+
@with_session
|
|
182
|
+
async def run_index(
|
|
183
|
+
session: AsyncSession, app_context: AppContext, index_id: int
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Run an index."""
|
|
186
|
+
source_repository = SourceRepository(session)
|
|
187
|
+
source_service = SourceService(app_context.get_clone_dir(), source_repository)
|
|
188
|
+
repository = IndexRepository(session)
|
|
189
|
+
service = IndexService(repository, source_service, app_context.get_data_dir())
|
|
190
|
+
await service.run(index_id)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@cli.command()
|
|
194
|
+
@click.argument("query")
|
|
195
|
+
@click.option("--top-k", default=10, help="Number of snippets to retrieve")
|
|
196
|
+
@with_app_context
|
|
197
|
+
@with_session
|
|
198
|
+
async def retrieve(
|
|
199
|
+
session: AsyncSession, app_context: AppContext, query: str, top_k: int
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Retrieve snippets from the database."""
|
|
202
|
+
repository = RetrievalRepository(session)
|
|
203
|
+
service = RetrievalService(repository, app_context.get_data_dir())
|
|
204
|
+
# Temporary request while we don't have all search capabilities
|
|
205
|
+
snippets = await service.retrieve(
|
|
206
|
+
RetrievalRequest(keywords=query.split(","), top_k=top_k)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if len(snippets) == 0:
|
|
210
|
+
click.echo("No snippets found")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
for snippet in snippets:
|
|
214
|
+
click.echo("-" * 80)
|
|
215
|
+
click.echo(f"{snippet.uri}")
|
|
216
|
+
click.echo(snippet.content)
|
|
217
|
+
click.echo("-" * 80)
|
|
218
|
+
click.echo()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@cli.command()
|
|
222
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind the server to")
|
|
223
|
+
@click.option("--port", default=8080, help="Port to bind the server to")
|
|
224
|
+
@with_app_context
|
|
225
|
+
def serve(
|
|
226
|
+
app_context: AppContext,
|
|
227
|
+
host: str,
|
|
228
|
+
port: int,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Start the kodit server, which hosts the MCP server and the kodit API."""
|
|
231
|
+
log = structlog.get_logger(__name__)
|
|
232
|
+
log.info("Starting kodit server", host=host, port=port)
|
|
233
|
+
log_event("kodit_server_started")
|
|
234
|
+
|
|
235
|
+
# Dump AppContext to a dictionary of strings, and set the env vars
|
|
236
|
+
app_context_dict = {k: str(v) for k, v in app_context.model_dump().items()}
|
|
237
|
+
os.environ.update(app_context_dict)
|
|
238
|
+
|
|
239
|
+
# Configure uvicorn with graceful shutdown
|
|
240
|
+
config = uvicorn.Config(
|
|
241
|
+
"kodit.app:app",
|
|
242
|
+
host=host,
|
|
243
|
+
port=port,
|
|
244
|
+
reload=False,
|
|
245
|
+
log_config=None, # Setting to None forces uvicorn to use our structlog setup
|
|
246
|
+
access_log=False, # Using own middleware for access logging
|
|
247
|
+
timeout_graceful_shutdown=0, # The mcp server does not shutdown cleanly, force
|
|
248
|
+
)
|
|
249
|
+
server = uvicorn.Server(config)
|
|
250
|
+
|
|
251
|
+
def handle_sigint(signum: int, frame: Any) -> None:
|
|
252
|
+
"""Handle SIGINT (Ctrl+C)."""
|
|
253
|
+
log.info("Received shutdown signal, force killing MCP connections")
|
|
254
|
+
server.handle_exit(signum, frame)
|
|
255
|
+
|
|
256
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
|
257
|
+
server.run()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@cli.command()
|
|
261
|
+
def version() -> None:
|
|
262
|
+
"""Show the version of kodit."""
|
|
263
|
+
try:
|
|
264
|
+
from kodit import _version
|
|
265
|
+
except ImportError:
|
|
266
|
+
print("unknown, try running `uv build`, which is what happens in ci") # noqa: T201
|
|
267
|
+
else:
|
|
268
|
+
print(_version.version) # noqa: T201
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
cli()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Global configuration for the kodit project."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable, Coroutine
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
|
+
|
|
13
|
+
from kodit.database import Database
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_DIR = Path.home() / ".kodit"
|
|
16
|
+
DEFAULT_DB_URL = f"sqlite+aiosqlite:///{DEFAULT_BASE_DIR}/kodit.db"
|
|
17
|
+
DEFAULT_LOG_LEVEL = "INFO"
|
|
18
|
+
DEFAULT_LOG_FORMAT = "pretty"
|
|
19
|
+
DEFAULT_DISABLE_TELEMETRY = False
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AppContext(BaseSettings):
|
|
24
|
+
"""Global context for the kodit project. Provides a shared state for the app."""
|
|
25
|
+
|
|
26
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
data_dir: Path = Field(default=DEFAULT_BASE_DIR)
|
|
29
|
+
db_url: str = Field(default=DEFAULT_DB_URL)
|
|
30
|
+
log_level: str = Field(default=DEFAULT_LOG_LEVEL)
|
|
31
|
+
log_format: str = Field(default=DEFAULT_LOG_FORMAT)
|
|
32
|
+
disable_telemetry: bool = Field(default=DEFAULT_DISABLE_TELEMETRY)
|
|
33
|
+
_db: Database | None = None
|
|
34
|
+
|
|
35
|
+
def model_post_init(self, _: Any) -> None:
|
|
36
|
+
"""Post-initialization hook."""
|
|
37
|
+
# Call this to ensure the data dir exists for the default db location
|
|
38
|
+
self.get_data_dir()
|
|
39
|
+
|
|
40
|
+
def get_data_dir(self) -> Path:
|
|
41
|
+
"""Get the data directory."""
|
|
42
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return self.data_dir
|
|
44
|
+
|
|
45
|
+
def get_clone_dir(self) -> Path:
|
|
46
|
+
"""Get the clone directory."""
|
|
47
|
+
clone_dir = self.get_data_dir() / "clones"
|
|
48
|
+
clone_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
return clone_dir
|
|
50
|
+
|
|
51
|
+
async def get_db(self, *, run_migrations: bool = True) -> Database:
|
|
52
|
+
"""Get the database."""
|
|
53
|
+
if self._db is None:
|
|
54
|
+
self._db = Database(self.db_url)
|
|
55
|
+
if run_migrations:
|
|
56
|
+
await self._db.run_migrations(self.db_url)
|
|
57
|
+
return self._db
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
with_app_context = click.make_pass_decorator(AppContext)
|
|
61
|
+
|
|
62
|
+
T = TypeVar("T")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def wrap_async(f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
|
|
66
|
+
"""Decorate async Click commands.
|
|
67
|
+
|
|
68
|
+
This decorator wraps an async function to run it with asyncio.run().
|
|
69
|
+
It should be used after the Click command decorator.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
@cli.command()
|
|
73
|
+
@wrap_async
|
|
74
|
+
async def my_command():
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
@wraps(f)
|
|
80
|
+
def wrapper(*args: Any, **kwargs: Any) -> T:
|
|
81
|
+
return asyncio.run(f(*args, **kwargs))
|
|
82
|
+
|
|
83
|
+
return wrapper
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def with_session(f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
|
|
87
|
+
"""Provide a database session to CLI commands."""
|
|
88
|
+
|
|
89
|
+
@wraps(f)
|
|
90
|
+
@with_app_context
|
|
91
|
+
@wrap_async
|
|
92
|
+
async def wrapper(app_context: AppContext, *args: Any, **kwargs: Any) -> T:
|
|
93
|
+
db = await app_context.get_db()
|
|
94
|
+
async with db.session_factory() as session:
|
|
95
|
+
return await f(session, *args, **kwargs)
|
|
96
|
+
|
|
97
|
+
return wrapper
|