eulerian 0.1.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.
- eulerian/__init__.py +1 -0
- eulerian/cli.py +197 -0
- eulerian/index.py +127 -0
- eulerian/mcp/__init__.py +1 -0
- eulerian/mcp/_instance.py +14 -0
- eulerian/mcp/server.py +7 -0
- eulerian/mcp/tools.py +372 -0
- eulerian/parser.py +593 -0
- eulerian/sources/__init__.py +1 -0
- eulerian/sources/local.py +115 -0
- eulerian/sources/repo.py +890 -0
- eulerian/state.py +11 -0
- eulerian-0.1.0.dist-info/METADATA +116 -0
- eulerian-0.1.0.dist-info/RECORD +17 -0
- eulerian-0.1.0.dist-info/WHEEL +4 -0
- eulerian-0.1.0.dist-info/entry_points.txt +3 -0
- eulerian-0.1.0.dist-info/licenses/LICENSE +21 -0
eulerian/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""eulerian — standalone real-time code index for local directories and git repos."""
|
eulerian/cli.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""eulerian CLI — watch, outline, find, status, repo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="eulerian",
|
|
13
|
+
help="Real-time code index for local directories and git repos.",
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
)
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def watch(
|
|
21
|
+
path: Path = typer.Argument(
|
|
22
|
+
Path("."), help="Directory to watch (default: current directory)"
|
|
23
|
+
),
|
|
24
|
+
http: bool = typer.Option(
|
|
25
|
+
False, "--http", help="Serve MCP over HTTP instead of stdio"
|
|
26
|
+
),
|
|
27
|
+
host: str = typer.Option("0.0.0.0", "--host", help="Bind host (http mode only)"),
|
|
28
|
+
port: int = typer.Option(8090, "--port", help="Bind port (http mode only)"),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Start watching a directory and serve the MCP server (stdio or http).
|
|
31
|
+
|
|
32
|
+
Performs a full scan immediately, then keeps the index live via a
|
|
33
|
+
filesystem watcher for the lifetime of this process.
|
|
34
|
+
"""
|
|
35
|
+
import asyncio
|
|
36
|
+
|
|
37
|
+
from .sources import local as _local
|
|
38
|
+
from .state import index
|
|
39
|
+
from .mcp.server import server as mcp_server
|
|
40
|
+
|
|
41
|
+
root = path.expanduser().resolve()
|
|
42
|
+
if not root.is_dir():
|
|
43
|
+
console.print(f"[red]Not a directory:[/red] {root}")
|
|
44
|
+
raise typer.Exit(1)
|
|
45
|
+
|
|
46
|
+
watcher = _local.watch_root(index, root)
|
|
47
|
+
console.print(
|
|
48
|
+
f"[green]eulerian[/green] watching {watcher.root} ({watcher.symbol_count} symbols)"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if http:
|
|
52
|
+
console.print(f" MCP (http) listening on {host}:{port}")
|
|
53
|
+
asyncio.run(mcp_server.run_http_async(host=host, port=port))
|
|
54
|
+
else:
|
|
55
|
+
asyncio.run(mcp_server.run_stdio_async())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def outline(
|
|
60
|
+
query: str = typer.Argument(..., help="Symbol name or file path fragment"),
|
|
61
|
+
path: Path = typer.Argument(
|
|
62
|
+
Path("."), help="Directory to search (default: current directory)"
|
|
63
|
+
),
|
|
64
|
+
k: int = typer.Option(20, help="Max results"),
|
|
65
|
+
) -> None:
|
|
66
|
+
"""One-shot fuzzy symbol search — parses path, searches, exits. No persistent watcher."""
|
|
67
|
+
from .index import Index
|
|
68
|
+
|
|
69
|
+
root = path.expanduser().resolve()
|
|
70
|
+
idx = Index()
|
|
71
|
+
idx.full_scan(root, root)
|
|
72
|
+
symbols = idx.outline(root, query, k)
|
|
73
|
+
if not symbols:
|
|
74
|
+
console.print(f"[dim]No symbols found for[/dim] {query!r}")
|
|
75
|
+
return
|
|
76
|
+
for s in symbols:
|
|
77
|
+
console.print(
|
|
78
|
+
f" [cyan]{s.name}[/cyan] [L{s.start}-{s.end}] ({s.kind}) [dim]{s.file}[/dim]"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def find(
|
|
84
|
+
symbol: str = typer.Argument(..., help="Symbol name or substring"),
|
|
85
|
+
path: Path = typer.Argument(
|
|
86
|
+
Path("."), help="Directory to search (default: current directory)"
|
|
87
|
+
),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""One-shot symbol lookup by name — parses path, searches, exits."""
|
|
90
|
+
from .index import Index
|
|
91
|
+
|
|
92
|
+
root = path.expanduser().resolve()
|
|
93
|
+
idx = Index()
|
|
94
|
+
idx.full_scan(root, root)
|
|
95
|
+
symbols = idx.find(root, symbol)
|
|
96
|
+
if not symbols:
|
|
97
|
+
console.print(f"[dim]No symbol found matching[/dim] {symbol!r}")
|
|
98
|
+
return
|
|
99
|
+
for s in symbols:
|
|
100
|
+
console.print(
|
|
101
|
+
f" [cyan]{s.name}[/cyan] [L{s.start}-{s.end}] [dim]{s.file}[/dim]"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def status(
|
|
107
|
+
path: Path = typer.Argument(
|
|
108
|
+
Path("."), help="Directory to scan (default: current directory)"
|
|
109
|
+
),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""One-shot scan summary — files indexed, symbol count, by kind."""
|
|
112
|
+
from .parser import parse_directory
|
|
113
|
+
|
|
114
|
+
root = path.expanduser().resolve()
|
|
115
|
+
result = parse_directory(root)
|
|
116
|
+
by_kind: dict[str, int] = {}
|
|
117
|
+
for s in result.symbols:
|
|
118
|
+
by_kind[s.kind] = by_kind.get(s.kind, 0) + 1
|
|
119
|
+
console.print(f"[bold]{root}[/bold]")
|
|
120
|
+
console.print(
|
|
121
|
+
f" files indexed: {result.files_indexed} skipped: {result.files_skipped}"
|
|
122
|
+
)
|
|
123
|
+
for kind, count in sorted(by_kind.items()):
|
|
124
|
+
console.print(f" {kind}: {count}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# repo subcommands — operate on the persistent registry/worktree pool
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
repo_app = typer.Typer(
|
|
132
|
+
name="repo", help="Registered-git-repo worktree management.", no_args_is_help=True
|
|
133
|
+
)
|
|
134
|
+
app.add_typer(repo_app)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@repo_app.command("add")
|
|
138
|
+
def repo_add_cmd(url: str = typer.Argument(..., help="Git remote URL")) -> None:
|
|
139
|
+
"""Clone url as a bare clone and register it."""
|
|
140
|
+
from .sources import repo as _repo
|
|
141
|
+
|
|
142
|
+
result = _repo.repo_add(url)
|
|
143
|
+
console.print(json.dumps(result, indent=2))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@repo_app.command("list")
|
|
147
|
+
def repo_list_cmd() -> None:
|
|
148
|
+
"""List all registered repos."""
|
|
149
|
+
from .sources import repo as _repo
|
|
150
|
+
|
|
151
|
+
console.print(json.dumps(_repo.repo_list(), indent=2))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@repo_app.command("fetch")
|
|
155
|
+
def repo_fetch_cmd(
|
|
156
|
+
repo: str = typer.Argument(..., help="Registered repo name or URL"),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Fetch the latest refs for a registered repo."""
|
|
159
|
+
from .sources import repo as _repo
|
|
160
|
+
|
|
161
|
+
console.print(json.dumps(_repo.repo_fetch(repo), indent=2))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@repo_app.command("checkout")
|
|
165
|
+
def repo_checkout_cmd(
|
|
166
|
+
repo: str = typer.Argument(..., help="Registered repo name or URL"),
|
|
167
|
+
ref: str = typer.Argument(..., help="Branch, tag, or commit hash"),
|
|
168
|
+
user: str = typer.Option("default", "--user", "-u"),
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Create or reuse a worktree for repo+ref."""
|
|
171
|
+
from .index import Index
|
|
172
|
+
from .sources import repo as _repo
|
|
173
|
+
|
|
174
|
+
idx = Index()
|
|
175
|
+
console.print(json.dumps(_repo.code_checkout(idx, repo, ref, user), indent=2))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@repo_app.command("outline")
|
|
179
|
+
def repo_outline_cmd(
|
|
180
|
+
repo: str = typer.Argument(...),
|
|
181
|
+
ref: str = typer.Argument(...),
|
|
182
|
+
query: str = typer.Argument(...),
|
|
183
|
+
user: str = typer.Option("default", "--user", "-u"),
|
|
184
|
+
k: int = typer.Option(20),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Fuzzy symbol search over a checked-out worktree (checkout first)."""
|
|
187
|
+
from .index import Index
|
|
188
|
+
from .sources import repo as _repo
|
|
189
|
+
|
|
190
|
+
idx = Index()
|
|
191
|
+
symbols, warnings = _repo.query_outline(idx, user, repo, ref, query, k)
|
|
192
|
+
for s in symbols:
|
|
193
|
+
console.print(
|
|
194
|
+
f" [cyan]{s.name}[/cyan] [L{s.start}-{s.end}] ({s.kind}) [dim]{s.file}[/dim]"
|
|
195
|
+
)
|
|
196
|
+
for w in warnings:
|
|
197
|
+
console.print(f"[yellow]WARNING:[/yellow] {w}")
|
eulerian/index.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""In-memory symbol index, keyed by an opaque handle.
|
|
2
|
+
|
|
3
|
+
A handle is whatever the caller wants — a resolved local root path, or a
|
|
4
|
+
(user, repo, ref) tuple for repo-mode. The index itself doesn't care; it
|
|
5
|
+
just holds a symbol list per handle and answers outline/find/read-adjacent
|
|
6
|
+
queries against it. Local-mode and repo-mode sources both populate the same
|
|
7
|
+
index through full_scan/update_file/remove_file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Hashable
|
|
15
|
+
|
|
16
|
+
from rapidfuzz import fuzz, process
|
|
17
|
+
|
|
18
|
+
from .parser import (
|
|
19
|
+
Symbol,
|
|
20
|
+
build_module_map,
|
|
21
|
+
parse_directory,
|
|
22
|
+
parse_file,
|
|
23
|
+
walk_python_files,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
Handle = Hashable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Index:
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
self._symbols: dict[Handle, list[Symbol]] = {}
|
|
33
|
+
self._roots: dict[Handle, Path] = {}
|
|
34
|
+
self._module_maps: dict[Handle, dict[str, str]] = {}
|
|
35
|
+
|
|
36
|
+
def full_scan(self, handle: Handle, root: Path) -> int:
|
|
37
|
+
"""Walk + parse everything under root, replacing any existing index for handle."""
|
|
38
|
+
result = parse_directory(root)
|
|
39
|
+
module_map = build_module_map(root, walk_python_files(root))
|
|
40
|
+
with self._lock:
|
|
41
|
+
self._symbols[handle] = result.symbols
|
|
42
|
+
self._roots[handle] = root
|
|
43
|
+
self._module_maps[handle] = module_map
|
|
44
|
+
return len(result.symbols)
|
|
45
|
+
|
|
46
|
+
def update_file(self, handle: Handle, path: Path) -> None:
|
|
47
|
+
"""Re-parse a single file and replace its symbols in the index."""
|
|
48
|
+
root = self._roots.get(handle)
|
|
49
|
+
if root is None:
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
rel = str(path.relative_to(root)).replace("\\", "/")
|
|
53
|
+
except ValueError:
|
|
54
|
+
return
|
|
55
|
+
file_result = parse_file(path, root, self._module_maps.get(handle))
|
|
56
|
+
with self._lock:
|
|
57
|
+
existing = self._symbols.get(handle, [])
|
|
58
|
+
kept = [s for s in existing if s.file != rel]
|
|
59
|
+
self._symbols[handle] = kept + (
|
|
60
|
+
[] if file_result.skipped else file_result.symbols
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def remove_file(self, handle: Handle, path: Path) -> None:
|
|
64
|
+
"""Drop all symbols for a deleted file."""
|
|
65
|
+
root = self._roots.get(handle)
|
|
66
|
+
if root is None:
|
|
67
|
+
return
|
|
68
|
+
try:
|
|
69
|
+
rel = str(path.relative_to(root)).replace("\\", "/")
|
|
70
|
+
except ValueError:
|
|
71
|
+
return
|
|
72
|
+
with self._lock:
|
|
73
|
+
existing = self._symbols.get(handle)
|
|
74
|
+
if existing is not None:
|
|
75
|
+
self._symbols[handle] = [s for s in existing if s.file != rel]
|
|
76
|
+
|
|
77
|
+
def evict(self, handle: Handle) -> None:
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._symbols.pop(handle, None)
|
|
80
|
+
self._roots.pop(handle, None)
|
|
81
|
+
self._module_maps.pop(handle, None)
|
|
82
|
+
|
|
83
|
+
def has(self, handle: Handle) -> bool:
|
|
84
|
+
with self._lock:
|
|
85
|
+
return handle in self._symbols
|
|
86
|
+
|
|
87
|
+
def get_root(self, handle: Handle) -> Path | None:
|
|
88
|
+
return self._roots.get(handle)
|
|
89
|
+
|
|
90
|
+
def get_symbols(self, handle: Handle) -> list[Symbol] | None:
|
|
91
|
+
with self._lock:
|
|
92
|
+
symbols = self._symbols.get(handle)
|
|
93
|
+
return list(symbols) if symbols is not None else None
|
|
94
|
+
|
|
95
|
+
def outline(self, handle: Handle, query: str, k: int = 20) -> list[Symbol]:
|
|
96
|
+
"""Fuzzy lexical search over symbol names and file paths."""
|
|
97
|
+
symbols = self.get_symbols(handle) or []
|
|
98
|
+
if not symbols:
|
|
99
|
+
return []
|
|
100
|
+
choices = [f"{s.name} {s.file}" for s in symbols]
|
|
101
|
+
results = process.extract(
|
|
102
|
+
query, choices, scorer=fuzz.WRatio, limit=k, score_cutoff=35
|
|
103
|
+
)
|
|
104
|
+
return [symbols[idx] for _, _, idx in results]
|
|
105
|
+
|
|
106
|
+
def outline_paths(
|
|
107
|
+
self, handle: Handle, paths: list[str]
|
|
108
|
+
) -> tuple[list[Symbol], list[str]]:
|
|
109
|
+
"""Exact-file symbol outline lookup for a batch of paths, bypassing fuzzy search."""
|
|
110
|
+
symbols = self.get_symbols(handle) or []
|
|
111
|
+
by_file: dict[str, list[Symbol]] = {}
|
|
112
|
+
for s in symbols:
|
|
113
|
+
by_file.setdefault(s.file, []).append(s)
|
|
114
|
+
|
|
115
|
+
matched: list[Symbol] = []
|
|
116
|
+
warnings: list[str] = []
|
|
117
|
+
for p in paths:
|
|
118
|
+
if p in by_file:
|
|
119
|
+
matched.extend(by_file[p])
|
|
120
|
+
else:
|
|
121
|
+
warnings.append(f"no symbols found for path: {p!r}")
|
|
122
|
+
return matched, warnings
|
|
123
|
+
|
|
124
|
+
def find(self, handle: Handle, symbol: str) -> list[Symbol]:
|
|
125
|
+
"""Substring match by symbol name."""
|
|
126
|
+
symbols = self.get_symbols(handle) or []
|
|
127
|
+
return [s for s in symbols if symbol in s.name]
|
eulerian/mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""eulerian MCP server package."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""FastMCP singleton — leaf module with no package imports."""
|
|
2
|
+
|
|
3
|
+
from fastmcp import FastMCP
|
|
4
|
+
|
|
5
|
+
server = FastMCP(
|
|
6
|
+
name="eulerian",
|
|
7
|
+
instructions=(
|
|
8
|
+
"Real-time code navigation. Local mode: call watch(path) once, then "
|
|
9
|
+
"outline/find/read against it — the index reflects on-disk edits "
|
|
10
|
+
"instantly, no re-indexing calls needed. Repo mode: repo_add a git "
|
|
11
|
+
"remote, checkout a ref, then repo_outline/repo_read against it — "
|
|
12
|
+
"freshness is checked against the remote on each call."
|
|
13
|
+
),
|
|
14
|
+
)
|
eulerian/mcp/server.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""FastMCP server entry point — importing tools registers their @server.tool() decorators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._instance import server # noqa: F401 — re-exported; callers do `from .mcp.server import server`
|
|
6
|
+
|
|
7
|
+
from . import tools # noqa: F401, E402
|