fcp-python 0.1.6__py3-none-any.whl → 0.2.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/bridge.py CHANGED
@@ -47,8 +47,6 @@ slipstream fcp python_query "unused @file:src/utils.py"
47
47
  #### Refactoring
48
48
  ```
49
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
50
  ```
53
51
 
54
52
  #### Selectors
@@ -57,7 +55,6 @@ slipstream fcp python "import os @file:main.py @line:5"
57
55
  - `@module:NAME` — filter by module
58
56
  - `@kind:KIND` — function, class, method, variable, constant, module, property
59
57
  - `@line:N` — filter by line number
60
- - `@lines:N-M` — line range (for extract)
61
58
  - `@decorator:NAME` — filter by decorator (e.g. `@decorator:staticmethod`)
62
59
  """
63
60
 
@@ -2,24 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from pathlib import Path
6
- from urllib.parse import unquote, urlparse
7
-
8
5
  from fcp_core import VerbRegistry, parse_op, suggest, ParseError
9
6
 
10
- from fcp_python.lsp.types import (
11
- CodeAction,
12
- WorkspaceEdit,
13
- )
14
- from fcp_python.lsp.workspace_edit import apply_workspace_edit, ApplyResult
15
- from fcp_python.resolver.selectors import (
16
- SelectorType,
17
- parse_line_range,
18
- parse_selector,
19
- )
7
+ from fcp_python.lsp.types import WorkspaceEdit
8
+ from fcp_python.lsp.workspace_edit import apply_workspace_edit
9
+ from fcp_python.resolver.selectors import parse_selector
20
10
 
21
11
  from .format import (
22
- format_code_action_choices,
23
12
  format_disambiguation,
24
13
  format_error,
25
14
  format_mutation_result,
@@ -49,42 +38,10 @@ async def dispatch_mutation(
49
38
  match op.verb:
50
39
  case "rename":
51
40
  return await handle_rename(model, op.positionals, op.selectors)
52
- case "extract":
53
- return await handle_extract(model, op.positionals, op.selectors)
54
- case "import":
55
- return await handle_import(model, op.positionals, op.selectors)
56
41
  case _:
57
42
  return format_error(f"verb '{op.verb}' is not a mutation.", None)
58
43
 
59
44
 
60
- async def ensure_file_synced(model: PythonModel, uri: str) -> None:
61
- """Open a file in LSP if not already open, to sync with disk."""
62
- client = model.lsp_client
63
- if client is None:
64
- return
65
- parsed = urlparse(uri)
66
- if parsed.scheme != "file":
67
- return
68
- path = Path(unquote(parsed.path))
69
- text = path.read_text()
70
- await client.did_open(uri, text)
71
-
72
-
73
- async def sync_after_edit(model: PythonModel, result: ApplyResult) -> None:
74
- """After applying a WorkspaceEdit, sync all changed files with LSP."""
75
- for uri, _ in result.files_changed:
76
- await ensure_file_synced(model, uri)
77
- for uri in result.files_created:
78
- await ensure_file_synced(model, uri)
79
-
80
-
81
- def file_uri(model: PythonModel, file_value: str) -> str:
82
- """Build a file URI from a selector value, using model root as base."""
83
- if file_value.startswith("file://"):
84
- return file_value
85
- return f"{model.root_uri.rstrip('/')}/{file_value}"
86
-
87
-
88
45
  # -- rename ---------------------------------------------------------------
89
46
 
90
47
  async def handle_rename(
@@ -136,258 +93,3 @@ async def handle_rename(
136
93
  "rename", f"{old_name} → {new_name}", result, model.root_uri
137
94
  )
138
95
 
139
-
140
- # -- extract ---------------------------------------------------------------
141
-
142
- async def handle_extract(
143
- model: PythonModel,
144
- positionals: list[str],
145
- selectors: list[str],
146
- ) -> str:
147
- if not positionals:
148
- return format_error("extract requires FUNC_NAME.", None)
149
- func_name = positionals[0]
150
-
151
- parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
152
-
153
- file_sel = next((s for s in parsed_selectors if s.selector_type == SelectorType.FILE), None)
154
- lines_sel = next((s for s in parsed_selectors if s.selector_type == SelectorType.LINES), None)
155
-
156
- if file_sel is None:
157
- return format_error("extract requires @file:PATH selector.", None)
158
- if lines_sel is None:
159
- return format_error("extract requires @lines:N-M selector.", None)
160
-
161
- line_range = parse_line_range(lines_sel.value)
162
- if line_range is None:
163
- return format_error(
164
- f"invalid line range '{lines_sel.value}'. Use @lines:N-M.", None
165
- )
166
- start_line, end_line = line_range
167
-
168
- uri = file_uri(model, file_sel.value)
169
- # Convert 1-indexed user lines to 0-indexed LSP
170
- lsp_start = start_line - 1 if start_line > 0 else start_line
171
- lsp_end = end_line - 1 if end_line > 0 else end_line
172
-
173
- client = model.lsp_client
174
- assert client is not None
175
-
176
- try:
177
- await ensure_file_synced(model, uri)
178
- except Exception as e:
179
- return format_error(f"extract: {e}", None)
180
-
181
- params = {
182
- "textDocument": {"uri": uri},
183
- "range": {
184
- "start": {"line": lsp_start, "character": 0},
185
- "end": {"line": lsp_end, "character": 999},
186
- },
187
- "context": {
188
- "diagnostics": [],
189
- "only": ["refactor.extract.function", "refactor.extract"],
190
- "triggerKind": 1,
191
- },
192
- }
193
-
194
- try:
195
- raw_actions = await client.request("textDocument/codeAction", params)
196
- except Exception as e:
197
- return format_error(f"extract failed: {e}", None)
198
-
199
- if not raw_actions:
200
- raw_actions = []
201
-
202
- actions = [CodeAction.from_dict(a) for a in raw_actions]
203
- extract_actions = [
204
- a for a in actions
205
- if a.kind and a.kind.startswith("refactor.extract")
206
- ]
207
-
208
- if not extract_actions:
209
- return format_error("no extract action available for the selected range.", None)
210
-
211
- if len(extract_actions) == 1:
212
- action = extract_actions[0]
213
- else:
214
- # Prefer "Extract into function" over others
215
- func_action = next(
216
- (a for a in extract_actions if "function" in a.title.lower()), None
217
- )
218
- if func_action:
219
- action = func_action
220
- else:
221
- preferred = next((a for a in extract_actions if a.is_preferred), None)
222
- if preferred:
223
- action = preferred
224
- else:
225
- return format_code_action_choices(extract_actions)
226
-
227
- if action.edit is None:
228
- return format_error("extract action has no edit.", None)
229
-
230
- try:
231
- apply_result = apply_workspace_edit(action.edit)
232
- except Exception as e:
233
- return format_error(f"failed to apply extract: {e}", None)
234
-
235
- # Sync changed files
236
- try:
237
- await sync_after_edit(model, apply_result)
238
- except Exception:
239
- pass
240
-
241
- # Follow-up rename: pylsp/rope generates a placeholder name.
242
- rename_result = await _follow_up_rename(model, uri, func_name)
243
-
244
- if rename_result is not None:
245
- return format_mutation_result("extract", func_name, rename_result, model.root_uri)
246
- return format_mutation_result("extract", func_name, apply_result, model.root_uri)
247
-
248
-
249
- async def _follow_up_rename(
250
- model: PythonModel,
251
- uri: str,
252
- desired_name: str,
253
- ) -> ApplyResult | None:
254
- """After extract, rename the generated function to the user's desired name."""
255
- client = model.lsp_client
256
- if client is None:
257
- return None
258
-
259
- parsed = urlparse(uri)
260
- if parsed.scheme != "file":
261
- return None
262
- path = Path(unquote(parsed.path))
263
- try:
264
- content = path.read_text()
265
- except Exception:
266
- return None
267
-
268
- # Rope generates "extracted_function" or similar placeholder names
269
- for generated_name in ["extracted_function", "extracted_method", "extracted_variable"]:
270
- fn_pattern = f"def {generated_name}"
271
- byte_offset = content.find(fn_pattern)
272
- if byte_offset >= 0:
273
- name_offset = byte_offset + 4 # "def ".len()
274
- line = content[:name_offset].count("\n")
275
- last_newline = content.rfind("\n", 0, name_offset)
276
- col = name_offset - (last_newline + 1) if last_newline >= 0 else name_offset
277
-
278
- params = {
279
- "textDocument": {"uri": uri},
280
- "position": {"line": line, "character": col},
281
- "newName": desired_name,
282
- }
283
- try:
284
- raw_edit = await client.request("textDocument/rename", params)
285
- if raw_edit:
286
- workspace_edit = WorkspaceEdit.from_dict(raw_edit)
287
- return apply_workspace_edit(workspace_edit)
288
- except Exception:
289
- pass
290
- break
291
-
292
- return None
293
-
294
-
295
- # -- import ---------------------------------------------------------------
296
-
297
- async def handle_import(
298
- model: PythonModel,
299
- positionals: list[str],
300
- selectors: list[str],
301
- ) -> str:
302
- if not positionals:
303
- return format_error("import requires SYMBOL.", None)
304
- symbol_name = positionals[0]
305
-
306
- parsed_selectors = [s for s in (parse_selector(sel) for sel in selectors) if s is not None]
307
-
308
- file_sel = next((s for s in parsed_selectors if s.selector_type == SelectorType.FILE), None)
309
- line_sel = next((s for s in parsed_selectors if s.selector_type == SelectorType.LINE), None)
310
-
311
- if file_sel is None:
312
- return format_error("import requires @file:PATH selector.", None)
313
- if line_sel is None:
314
- return format_error("import requires @line:N selector.", None)
315
-
316
- try:
317
- line_num = int(line_sel.value)
318
- except ValueError:
319
- return format_error("invalid line number.", None)
320
-
321
- uri = file_uri(model, file_sel.value)
322
- lsp_line = line_num - 1 if line_num > 0 else line_num
323
-
324
- client = model.lsp_client
325
- assert client is not None
326
-
327
- try:
328
- await ensure_file_synced(model, uri)
329
- except Exception as e:
330
- return format_error(f"import: {e}", None)
331
-
332
- params = {
333
- "textDocument": {"uri": uri},
334
- "range": {
335
- "start": {"line": lsp_line, "character": 0},
336
- "end": {"line": lsp_line, "character": 999},
337
- },
338
- "context": {
339
- "diagnostics": [],
340
- "only": ["quickfix", "source", "source.organizeImports"],
341
- "triggerKind": 1,
342
- },
343
- }
344
-
345
- try:
346
- raw_actions = await client.request("textDocument/codeAction", params)
347
- except Exception as e:
348
- return format_error(f"import failed: {e}", None)
349
-
350
- if not raw_actions:
351
- raw_actions = []
352
-
353
- actions = [CodeAction.from_dict(a) for a in raw_actions]
354
- symbol_lower = symbol_name.lower()
355
-
356
- import_actions = [
357
- a for a in actions
358
- if (
359
- (a.kind and (
360
- "import" in a.kind
361
- or a.kind == "quickfix"
362
- or a.kind.startswith("source")
363
- ))
364
- or "import" in a.title.lower()
365
- or "use " in a.title.lower()
366
- )
367
- and symbol_lower in a.title.lower()
368
- ]
369
-
370
- if not import_actions:
371
- return format_error(
372
- f"no import action for '{symbol_name}' at {file_sel.value}:{line_num}.",
373
- None,
374
- )
375
-
376
- if len(import_actions) == 1:
377
- action = import_actions[0]
378
- else:
379
- preferred = next((a for a in import_actions if a.is_preferred), None)
380
- if preferred:
381
- action = preferred
382
- else:
383
- return format_code_action_choices(import_actions)
384
-
385
- if action.edit is None:
386
- return format_error("import action has no edit.", None)
387
-
388
- try:
389
- result = apply_workspace_edit(action.edit)
390
- except Exception as e:
391
- return format_error(f"failed to apply import: {e}", None)
392
-
393
- return format_mutation_result("import", symbol_name, result, model.root_uri)
@@ -24,8 +24,6 @@ def register_query_verbs(registry: VerbRegistry) -> None:
24
24
  def register_mutation_verbs(registry: VerbRegistry) -> None:
25
25
  registry.register_many([
26
26
  VerbSpec(verb="rename", syntax="rename SYMBOL NEW_NAME [@selectors...]", category="mutation"),
27
- VerbSpec(verb="extract", syntax="extract FUNC_NAME @file:PATH @lines:N-M", category="mutation"),
28
- VerbSpec(verb="import", syntax="import SYMBOL @file:PATH @line:N", category="mutation"),
29
27
  ])
30
28
 
31
29
 
fcp_python/main.py CHANGED
@@ -55,9 +55,7 @@ _registry = _make_registry()
55
55
  @mcp.tool(
56
56
  description=(
57
57
  "Execute Python mutation operations. Examples: "
58
- "'rename Config Settings', "
59
- "'extract validate @file:server.py @lines:15-30', "
60
- "'import os @file:main.py @line:5'"
58
+ "'rename Config Settings'"
61
59
  )
62
60
  )
63
61
  async def python(ops: list[str]) -> str:
@@ -108,13 +106,10 @@ async def python_help() -> str:
108
106
  " @kind:KIND — filter by symbol kind (function, class, method, variable, ...)\n"
109
107
  " @module:NAME — filter by module\n"
110
108
  " @line:N — filter by line number\n"
111
- " @lines:N-M — line range for extract\n"
112
109
  " @decorator:NAME — filter by decorator"
113
110
  ),
114
111
  "Mutation Examples": (
115
- ' python ["rename Config Settings"] — cross-file semantic rename\n'
116
- ' python ["extract validate @file:server.py @lines:15-30"] — extract function\n'
117
- ' python ["import os @file:main.py @line:5"] — add missing import'
112
+ ' python ["rename Config Settings"] — cross-file semantic rename'
118
113
  ),
119
114
  }
120
115
  return _registry.generate_reference_card(extra)
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fcp-python
3
- Version: 0.1.6
3
+ Version: 0.2.0
4
4
  Summary: Python Code Intelligence FCP — semantic Python code operations for LLMs via pylsp
5
5
  Requires-Python: <3.14,>=3.11
6
6
  Requires-Dist: fastmcp>=3.0
7
- Requires-Dist: fcp-core>=0.1.17
7
+ Requires-Dist: fcp-core>=0.2.0
8
8
  Requires-Dist: python-lsp-server[rope]>=1.12
@@ -1,12 +1,12 @@
1
1
  fcp_python/__init__.py,sha256=jXbt2O90IFhcdC8JQ4qr2zYFSlz1Zvpml9hEam5JstU,58
2
- fcp_python/bridge.py,sha256=UmwubXf8BLAFYv8qvn08oUbduGpwifIx_ZJJFqafl9A,5907
3
- fcp_python/main.py,sha256=Q5Z9iMSDjpPWcRa8nH06xNLPBLi75OHOwENhCm4xiJM,9467
2
+ fcp_python/bridge.py,sha256=2hiVRmJJotgCZFVRvZ9BZf3X4rLo5-ILyXmbxNL1dbA,5737
3
+ fcp_python/main.py,sha256=aUQ50PTUCWvW30AnD6TkYo2n69flryVCyE1MHRtwvVc,9116
4
4
  fcp_python/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  fcp_python/domain/format.py,sha256=9oHtn3d9vvRT28DrJxNs9m-YpIWjfjIr-pMWF5Tu4v0,7311
6
6
  fcp_python/domain/model.py,sha256=yI2kVvEWv50wICqqmrHV_302EVMtZd_t1v1X50ZonO8,1458
7
- fcp_python/domain/mutation.py,sha256=ggkm-4zY9rTHfNYYWEcfGNok14MFXUS6_nsBVXaQqoU,12578
7
+ fcp_python/domain/mutation.py,sha256=ZiTRZdZI0zqqFpFZ-v8CWtNzpMO8L9tcsV_yMipqM1A,2987
8
8
  fcp_python/domain/query.py,sha256=KFve1HFAKwyHJPgT1INEQ97jieUUDvhswho-NHqrxkc,21706
9
- fcp_python/domain/verbs.py,sha256=3u2SJ5do1UKXSA39KaCDvdTkhMXHpUAhm363kEQKDXs,1919
9
+ fcp_python/domain/verbs.py,sha256=-w5zY1BPJmVCOC1a-0xd3uj5EAjpNN4UeY-e51LRLbI,1717
10
10
  fcp_python/lsp/__init__.py,sha256=Kai6_M5ap1qjrLEfpkdcVZenIzQDQHMUu28JscQg_kM,39
11
11
  fcp_python/lsp/client.py,sha256=H-I1Hq8ayAUimGcsMpiLmFZcdYHRvQBL0jYG--F51JU,6428
12
12
  fcp_python/lsp/lifecycle.py,sha256=i-b61LGOeQxZIUjUAf_oR8nMqS7rnGR52EYb9KM3zHc,2774
@@ -17,7 +17,7 @@ fcp_python/resolver/__init__.py,sha256=9ImXXfUvA0TWGDZx1GMniKzav7SKT9q9u1C_0EOZE
17
17
  fcp_python/resolver/index.py,sha256=9y56ZoBNqwl8a4_Ue54ZBjNWzj3kZ548miIZvab_90k,1859
18
18
  fcp_python/resolver/pipeline.py,sha256=cPqawRgQ3hxNKottcsco1RGLT4f2c3IWidqnuSzhh7c,3174
19
19
  fcp_python/resolver/selectors.py,sha256=u_NQdp6eU2fPSKnSpWNhTZbxwy6OgvxVTR3pKdB_7Rg,5070
20
- fcp_python-0.1.6.dist-info/METADATA,sha256=-q3_LDxfvfDxcjoGJ_vWaF-n46AvjhG6S0BOIlDZre0,282
21
- fcp_python-0.1.6.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
22
- fcp_python-0.1.6.dist-info/entry_points.txt,sha256=iSyoGO8uLz21KTX8mL3twnRXWsuc7-8O3U03WCSiJ_s,52
23
- fcp_python-0.1.6.dist-info/RECORD,,
20
+ fcp_python-0.2.0.dist-info/METADATA,sha256=jEWXwfiHLz2iljasXFEDgbUPpPTP7cr6foGcA4UUxuI,281
21
+ fcp_python-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
22
+ fcp_python-0.2.0.dist-info/entry_points.txt,sha256=iSyoGO8uLz21KTX8mL3twnRXWsuc7-8O3U03WCSiJ_s,52
23
+ fcp_python-0.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any