fcp-python 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.
fcp_python/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """fcp-python — Python Code Intelligence FCP server."""
fcp_python/bridge.py ADDED
@@ -0,0 +1,195 @@
1
+ """Slipstream bridge — connects FCP server to daemon via Unix socket.
2
+
3
+ Runs in a daemon thread so it never blocks the main MCP server.
4
+ Silently returns on any connection failure (bridge is invisible
5
+ when Slipstream isn't running).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import threading
14
+ from typing import Any, Awaitable, Callable
15
+
16
+ _AGENT_HELP = """\
17
+ ### python — LSP-powered Python navigation and refactoring
18
+
19
+ #### Session
20
+ ```
21
+ slipstream fcp python "open /path/to/project"
22
+ slipstream fcp python "status"
23
+ slipstream fcp python "close"
24
+ ```
25
+
26
+ #### Navigation
27
+ ```
28
+ slipstream fcp python_query "find MyClass"
29
+ slipstream fcp python_query "find process kind:function"
30
+ slipstream fcp python_query "def main @file:src/app.py"
31
+ slipstream fcp python_query "refs Config @file:models.py"
32
+ slipstream fcp python_query "symbols src/utils.py"
33
+ slipstream fcp python_query "impl BaseHandler"
34
+ ```
35
+
36
+ #### Inspection
37
+ ```
38
+ slipstream fcp python_query "inspect MyClass"
39
+ slipstream fcp python_query "callers process_data"
40
+ slipstream fcp python_query "callees handle_request"
41
+ slipstream fcp python_query "diagnose"
42
+ slipstream fcp python_query "diagnose src/main.py"
43
+ slipstream fcp python_query "map"
44
+ slipstream fcp python_query "unused @file:src/utils.py"
45
+ ```
46
+
47
+ #### Refactoring
48
+ ```
49
+ slipstream fcp python "rename Config Settings"
50
+ slipstream fcp python "extract validate @file:server.py @lines:15-30"
51
+ slipstream fcp python "import os @file:main.py @line:5"
52
+ ```
53
+
54
+ #### Selectors
55
+ - `@file:PATH` — filter by file path
56
+ - `@class:NAME` — filter by containing class
57
+ - `@module:NAME` — filter by module
58
+ - `@kind:KIND` — function, class, method, variable, constant, module, property
59
+ - `@line:N` — filter by line number
60
+ - `@lines:N-M` — line range (for extract)
61
+ - `@decorator:NAME` — filter by decorator (e.g. `@decorator:staticmethod`)
62
+ """
63
+
64
+
65
+ def start_bridge(
66
+ handle_session: Callable[[str], Awaitable[str]],
67
+ handle_query: Callable[[str], Awaitable[str]],
68
+ handle_mutation: Callable[[list[str]], Awaitable[str]],
69
+ ) -> None:
70
+ """Connect to Slipstream daemon if available.
71
+
72
+ Spawns a daemon thread with its own event loop. Silently returns
73
+ if the socket is not found or any connection error occurs.
74
+ """
75
+ try:
76
+ path = _discover_socket()
77
+ if path is None:
78
+ return
79
+ t = threading.Thread(
80
+ target=_bridge_thread,
81
+ args=(path, handle_session, handle_query, handle_mutation),
82
+ daemon=True,
83
+ )
84
+ t.start()
85
+ except Exception: # noqa: BLE001
86
+ pass
87
+
88
+
89
+ def _bridge_thread(
90
+ path: str,
91
+ handle_session: Callable[[str], Awaitable[str]],
92
+ handle_query: Callable[[str], Awaitable[str]],
93
+ handle_mutation: Callable[[list[str]], Awaitable[str]],
94
+ ) -> None:
95
+ """Entry point for the daemon thread."""
96
+ try:
97
+ asyncio.run(_run_bridge_at(path, handle_session, handle_query, handle_mutation))
98
+ except Exception: # noqa: BLE001
99
+ pass
100
+
101
+
102
+ def _discover_socket() -> str | None:
103
+ """Find daemon socket path."""
104
+ # 1. SLIPSTREAM_SOCKET env var
105
+ path = os.environ.get("SLIPSTREAM_SOCKET")
106
+ if path and os.path.exists(path):
107
+ return path
108
+
109
+ # 2. XDG_RUNTIME_DIR/slipstream/daemon.sock
110
+ xdg = os.environ.get("XDG_RUNTIME_DIR")
111
+ if xdg:
112
+ path = os.path.join(xdg, "slipstream", "daemon.sock")
113
+ if os.path.exists(path):
114
+ return path
115
+
116
+ # 3. /tmp/slipstream-{uid}/daemon.sock
117
+ uid = os.getuid()
118
+ path = f"/tmp/slipstream-{uid}/daemon.sock"
119
+ if os.path.exists(path):
120
+ return path
121
+
122
+ return None
123
+
124
+
125
+ async def _run_bridge_at(
126
+ path: str,
127
+ handle_session: Callable[[str], Awaitable[str]],
128
+ handle_query: Callable[[str], Awaitable[str]],
129
+ handle_mutation: Callable[[list[str]], Awaitable[str]],
130
+ ) -> None:
131
+ """Async loop: connect, register, then handle NDJSON requests."""
132
+ reader, writer = await asyncio.open_unix_connection(path)
133
+
134
+ # Send registration
135
+ register = {
136
+ "jsonrpc": "2.0",
137
+ "method": "fcp.register",
138
+ "params": {
139
+ "handler_name": "fcp-py",
140
+ "extensions": ["py"],
141
+ "capabilities": ["ops", "query", "session"],
142
+ "agent_help": _AGENT_HELP,
143
+ },
144
+ }
145
+ writer.write((json.dumps(register) + "\n").encode())
146
+ await writer.drain()
147
+
148
+ # Request loop (newline-delimited JSON)
149
+ while True:
150
+ line = await reader.readline()
151
+ if not line:
152
+ break
153
+
154
+ try:
155
+ req = json.loads(line)
156
+ except json.JSONDecodeError:
157
+ continue
158
+
159
+ req_id = req.get("id")
160
+ method = req.get("method", "")
161
+ params = req.get("params") or {}
162
+
163
+ text = await _handle_request(
164
+ method, params, handle_session, handle_query, handle_mutation
165
+ )
166
+
167
+ response = {
168
+ "jsonrpc": "2.0",
169
+ "id": req_id,
170
+ "result": {"text": text},
171
+ }
172
+ writer.write((json.dumps(response) + "\n").encode())
173
+ await writer.drain()
174
+
175
+ writer.close()
176
+
177
+
178
+ async def _handle_request(
179
+ method: str,
180
+ params: dict[str, Any],
181
+ handle_session: Callable[[str], Awaitable[str]],
182
+ handle_query: Callable[[str], Awaitable[str]],
183
+ handle_mutation: Callable[[list[str]], Awaitable[str]],
184
+ ) -> str:
185
+ if method == "fcp.session":
186
+ action = params.get("action", "")
187
+ return await handle_session(action)
188
+ elif method == "fcp.ops":
189
+ ops = params.get("ops", [])
190
+ return await handle_mutation(ops)
191
+ elif method == "fcp.query":
192
+ q = params.get("q", "")
193
+ return await handle_query(q)
194
+ else:
195
+ return f"unknown method: {method}"
File without changes
@@ -0,0 +1,221 @@
1
+ """Output formatting for query results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fcp_python.lsp.types import (
6
+ CallHierarchyIncomingCall,
7
+ CallHierarchyOutgoingCall,
8
+ CodeAction,
9
+ Diagnostic,
10
+ DiagnosticSeverity,
11
+ DocumentSymbol,
12
+ Location,
13
+ Range,
14
+ )
15
+ from fcp_python.lsp.workspace_edit import ApplyResult
16
+ from fcp_python.resolver.index import SymbolEntry
17
+
18
+
19
+ def format_navigation_result(locations: list[Location], description: str) -> str:
20
+ if not locations:
21
+ return f"No {description} found."
22
+ lines = [f"{description} ({len(locations)}):"]
23
+ for loc in locations:
24
+ lines.append(
25
+ f" {short_uri(loc.uri)} L{loc.range.start.line + 1}:{loc.range.start.character + 1}"
26
+ )
27
+ return "\n".join(lines)
28
+
29
+
30
+ def format_definition(uri: str, range_: Range, source_snippet: str | None = None) -> str:
31
+ result = f"Definition: {short_uri(uri)} L{range_.start.line + 1}:{range_.start.character + 1}"
32
+ if source_snippet:
33
+ result += f"\n\n{source_snippet}"
34
+ return result
35
+
36
+
37
+ def format_symbol_outline(file: str, symbols: list[DocumentSymbol], indent: int) -> str:
38
+ lines: list[str] = []
39
+ if indent == 0:
40
+ lines.append(f"Symbols in {short_uri(file)}:")
41
+ prefix = " " * (indent + 1)
42
+ for sym in symbols:
43
+ kind_str = sym.kind.display_name()
44
+ lines.append(f"{prefix}{sym.name} ({kind_str}) L{sym.range.start.line + 1}")
45
+ if sym.children:
46
+ lines.append(format_symbol_outline(file, sym.children, indent + 1))
47
+ return "\n".join(lines)
48
+
49
+
50
+ def format_diagnostics(uri: str, diagnostics: list[Diagnostic]) -> str:
51
+ if not diagnostics:
52
+ return f"{short_uri(uri)}: clean"
53
+ lines = [f"{short_uri(uri)} ({len(diagnostics)} issues):"]
54
+ for d in diagnostics:
55
+ severity = {
56
+ DiagnosticSeverity.Error: "ERROR",
57
+ DiagnosticSeverity.Warning: "WARN",
58
+ DiagnosticSeverity.Information: "INFO",
59
+ DiagnosticSeverity.Hint: "HINT",
60
+ }.get(d.severity, "???") # type: ignore[arg-type]
61
+ lines.append(
62
+ f" L{d.range.start.line + 1}: [{severity}] {summarize_diagnostic_message(d.message)}"
63
+ )
64
+ return "\n".join(lines)
65
+
66
+
67
+ def format_disambiguation(name: str, entries: list[SymbolEntry]) -> str:
68
+ lines = [f"? Multiple matches for '{name}'. Narrow with a selector:"]
69
+ for i, entry in enumerate(entries):
70
+ container = f" in {entry.container_name}" if entry.container_name else ""
71
+ lines.append(
72
+ f" {i + 1}. {entry.name} ({entry.kind!r}){container} — {short_uri(entry.uri)}"
73
+ )
74
+ return "\n".join(lines)
75
+
76
+
77
+ def format_hover(
78
+ name: str, kind: str, uri: str, range_: Range, contents: str
79
+ ) -> str:
80
+ lines = [f"{name} ({kind}) — {short_uri(uri)} L{range_.start.line + 1}"]
81
+ if contents:
82
+ lines.append("")
83
+ lines.append(contents)
84
+ return "\n".join(lines)
85
+
86
+
87
+ def format_callers(name: str, calls: list[CallHierarchyIncomingCall]) -> str:
88
+ if not calls:
89
+ return f"No callers of '{name}'."
90
+ lines = [f"Callers of '{name}' ({len(calls)}):"]
91
+ for call in calls:
92
+ lines.append(
93
+ f" {call.from_item.name} ({call.from_item.kind!r}) — "
94
+ f"{short_uri(call.from_item.uri)} L{call.from_item.range.start.line + 1}"
95
+ )
96
+ return "\n".join(lines)
97
+
98
+
99
+ def format_callees(name: str, calls: list[CallHierarchyOutgoingCall]) -> str:
100
+ if not calls:
101
+ return f"No callees of '{name}'."
102
+ lines = [f"Callees of '{name}' ({len(calls)}):"]
103
+ for call in calls:
104
+ lines.append(
105
+ f" {call.to.name} ({call.to.kind!r}) — "
106
+ f"{short_uri(call.to.uri)} L{call.to.range.start.line + 1}"
107
+ )
108
+ return "\n".join(lines)
109
+
110
+
111
+ def format_implementations(name: str, locations: list[Location]) -> str:
112
+ if not locations:
113
+ return f"No implementations of '{name}'."
114
+ lines = [f"Implementations of '{name}' ({len(locations)}):"]
115
+ for loc in locations:
116
+ lines.append(
117
+ f" {short_uri(loc.uri)} L{loc.range.start.line + 1}:{loc.range.start.character + 1}"
118
+ )
119
+ return "\n".join(lines)
120
+
121
+
122
+ def format_workspace_map(
123
+ root_uri: str,
124
+ file_count: int,
125
+ symbol_count: int,
126
+ errors: int,
127
+ warnings: int,
128
+ ) -> str:
129
+ lines = [
130
+ f"Workspace: {short_uri(root_uri)}",
131
+ f" Files: {file_count}",
132
+ f" Symbols: {symbol_count}",
133
+ ]
134
+ if errors > 0 or warnings > 0:
135
+ lines.append(f" Diagnostics: {errors} errors, {warnings} warnings")
136
+ else:
137
+ lines.append(" Diagnostics: clean")
138
+ return "\n".join(lines)
139
+
140
+
141
+ def format_unused(items: list[tuple[str, Diagnostic]]) -> str:
142
+ if not items:
143
+ return "No unused symbols found."
144
+ lines = [f"Unused symbols ({len(items)}):"]
145
+ for uri, diag in items:
146
+ classification = _classify_unused(diag.message)
147
+ lines.append(
148
+ f" {short_uri(uri)} L{diag.range.start.line + 1}: "
149
+ f"[{classification}] {summarize_diagnostic_message(diag.message)}"
150
+ )
151
+ return "\n".join(lines)
152
+
153
+
154
+ def format_mutation_result(
155
+ verb: str, description: str, result: ApplyResult, root_uri: str
156
+ ) -> str:
157
+ total = result.total_edits()
158
+ file_count = len(result.files_changed)
159
+ lines = [
160
+ f"{verb}: {description} ({file_count} {'file' if file_count == 1 else 'files'}, "
161
+ f"{total} {'edit' if total == 1 else 'edits'})"
162
+ ]
163
+ for uri, count in result.files_changed:
164
+ lines.append(
165
+ f" {relative_path(uri, root_uri)}: {count} {'edit' if count == 1 else 'edits'}"
166
+ )
167
+ for uri in result.files_created:
168
+ lines.append(f" {relative_path(uri, root_uri)} (created)")
169
+ for old, new in result.files_renamed:
170
+ lines.append(
171
+ f" {relative_path(old, root_uri)} → {relative_path(new, root_uri)} (renamed)"
172
+ )
173
+ return "\n".join(lines)
174
+
175
+
176
+ def format_code_action_choices(actions: list[CodeAction]) -> str:
177
+ lines = [f"? Multiple code actions available ({len(actions)}):"]
178
+ for i, action in enumerate(actions):
179
+ kind = action.kind or "unknown"
180
+ preferred = " (preferred)" if action.is_preferred else ""
181
+ lines.append(f" {i + 1}. [{kind}] {action.title}{preferred}")
182
+ return "\n".join(lines)
183
+
184
+
185
+ def _classify_unused(message: str) -> str:
186
+ lower = message.lower()
187
+ if "dead_code" in lower or "never constructed" in lower:
188
+ return "dead_code"
189
+ if "never read" in lower:
190
+ return "never_read"
191
+ return "unused"
192
+
193
+
194
+ def format_error(message: str, suggestion: str | None = None) -> str:
195
+ if suggestion:
196
+ return f"! {message} Did you mean '{suggestion}'?"
197
+ return f"! {message}"
198
+
199
+
200
+ def summarize_diagnostic_message(raw: str) -> str:
201
+ # Strip Python error code prefixes like "E0308: "
202
+ if raw.startswith("E") and len(raw) > 5:
203
+ code = raw[1:5]
204
+ if code.isdigit():
205
+ after = raw[5:]
206
+ if after.startswith(": "):
207
+ return after[2:]
208
+ return raw
209
+
210
+
211
+ def short_uri(uri: str) -> str:
212
+ return uri.removeprefix("file://")
213
+
214
+
215
+ def relative_path(uri: str, root_uri: str) -> str:
216
+ path = short_uri(uri)
217
+ root = short_uri(root_uri).rstrip("/")
218
+ if path.startswith(root):
219
+ rel = path[len(root):]
220
+ return rel.lstrip("/")
221
+ return path
@@ -0,0 +1,42 @@
1
+ """Domain model for Python workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from fcp_python.lsp.client import LspClient
8
+ from fcp_python.lsp.lifecycle import ServerStatus
9
+ from fcp_python.lsp.types import Diagnostic, DiagnosticSeverity
10
+ from fcp_python.resolver.index import SymbolIndex
11
+
12
+
13
+ class PythonModel:
14
+ def __init__(self, root_uri: str) -> None:
15
+ self.root_uri = root_uri
16
+ self.lsp_client: LspClient | None = None
17
+ self.symbol_index = SymbolIndex()
18
+ self.diagnostics: dict[str, list[Diagnostic]] = {}
19
+ self.open_documents: dict[str, int] = {} # uri -> version
20
+ self.server_status = ServerStatus.NotStarted
21
+ self.py_file_count = 0
22
+ self.last_reload: float | None = None
23
+
24
+ def update_diagnostics(self, uri: str, diagnostics: list[Diagnostic]) -> None:
25
+ if not diagnostics:
26
+ self.diagnostics.pop(uri, None)
27
+ else:
28
+ self.diagnostics[uri] = diagnostics
29
+
30
+ def total_diagnostics(self) -> tuple[int, int]:
31
+ errors = 0
32
+ warnings = 0
33
+ for diags in self.diagnostics.values():
34
+ for d in diags:
35
+ if d.severity == DiagnosticSeverity.Error:
36
+ errors += 1
37
+ elif d.severity == DiagnosticSeverity.Warning:
38
+ warnings += 1
39
+ return (errors, warnings)
40
+
41
+ def diagnostic_count(self) -> int:
42
+ return sum(len(v) for v in self.diagnostics.values())