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 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]
@@ -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