deputy-cli 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.
deputy/__init__.py ADDED
@@ -0,0 +1,612 @@
1
+ import json
2
+ import typer
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from rich.tree import Tree
6
+ from deputy._version import __version__
7
+ from deputy.database.sqlite import (
8
+ get_direct_subclasses,
9
+ get_transitive_subclasses,
10
+ get_entity_ids_by_fqn,
11
+ upsert_inheritance_pin,
12
+ get_inheritance_pin,
13
+ delete_inheritance_pin,
14
+ list_inheritance_pins,
15
+ get_entity_by_id,
16
+ upsert_class_bases,
17
+ )
18
+ from deputy.tools import (
19
+ build_entity_tree,
20
+ init_database,
21
+ run_sync,
22
+ search_entities,
23
+ get_entity_info,
24
+ InteractiveResolver,
25
+ )
26
+ from deputy.tools.utils import (
27
+ get_containing_module_fqn,
28
+ _open_database
29
+ )
30
+ from deputy.logger import (
31
+ get_logger,
32
+ init_logging
33
+ )
34
+ from deputy.utils.config_file import (
35
+ read_config,
36
+ write_config
37
+ )
38
+ from deputy.tools.inheritance import eager_resolve_all_inherited_members
39
+ from deputy.utils.git import get_current_branch
40
+
41
+ logger = get_logger("cli")
42
+
43
+ AVAILABLE_COLUMNS = {
44
+ "full_path": "Entity full path",
45
+ "language": "Language",
46
+ "type": "Entity type",
47
+ "lineno": "Starting line number",
48
+ "end_lineno": "Ending line number",
49
+ "source": "Source file:lineno",
50
+ "signature": "Signature location as path:line or path:start-end (actual text with --extract)",
51
+ "arguments": "Arguments location as path:line or path:start-end",
52
+ "return_type": "Return type annotation location as path:line or path:start-end",
53
+ "docstring": "Docstring location as path:line or path:start-end (actual text with --extract)",
54
+ "decorators": "Decorator names",
55
+ "parent_classes": "Parent/inherited class names",
56
+ "resolved_bases": "Resolved base class FQNs",
57
+ "unresolved_bases": "Unresolved base class names with candidate details",
58
+ "mro": "Full MRO (method resolution order) chain",
59
+ "inherited_from": "Class in the MRO that provides this member (if inherited)",
60
+ "visibility": "Visibility modifier",
61
+ "exported": "Whether exported in __all__",
62
+ }
63
+
64
+ DEFAULT_COLUMNS = ["full_path", "language", "type", "source"]
65
+
66
+ app = typer.Typer(no_args_is_help=True)
67
+ console = Console()
68
+
69
+ @app.callback()
70
+ def cli_callback(
71
+ ctx: typer.Context,
72
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging"),
73
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output"),
74
+ ) -> None:
75
+ if verbose:
76
+ init_logging(level="DEBUG")
77
+ elif quiet:
78
+ init_logging(level="ERROR")
79
+ else:
80
+ init_logging()
81
+
82
+ @app.command()
83
+ def init(
84
+ path: str = typer.Option(".deputy.db", "--path", "-p", help="Database path"),
85
+ ) -> None:
86
+ init_database(path)
87
+ console.print(f"[green]Initialised database at[/green] [bold]{path}[/bold]")
88
+
89
+ @app.command()
90
+ def sync(
91
+ force: bool = typer.Option(False, "--force", "-f", help="Force full re-sync"),
92
+ sync_deps: bool = typer.Option(None, "--sync-deps", help="Sync dependency packages from .venv"),
93
+ no_sync_deps: bool = typer.Option(None, "--no-sync-deps", help="Skip dependency sync"),
94
+ ) -> None:
95
+ resolved = sync_deps
96
+ if no_sync_deps and resolved is None:
97
+ resolved = False
98
+ try:
99
+ run_sync(force, resolved)
100
+ except FileNotFoundError as e:
101
+ console.print(f"[red]{e}[/red]")
102
+ raise typer.Exit(code=1)
103
+ console.print("[yellow]Sync complete[/yellow]")
104
+
105
+ @app.command(name="search")
106
+ def search(
107
+ pattern: str = typer.Argument(..., help="Regular expression pattern"),
108
+ type_filter: list[str] = typer.Option(None, "--type", "-t", help="Filter by entity type (repeatable)"),
109
+ language: str = typer.Option(None, "--language", "-l", help="Filter by language"),
110
+ limit: int = typer.Option(None, "--limit", help="Max results"),
111
+ offset: int = typer.Option(0, "--offset", help="Result offset"),
112
+ exact: bool = typer.Option(False, "--exact", "-e", help="Exact match on full_path"),
113
+ name_only: bool = typer.Option(False, "--name-only", "-n", help="Match name only, not full_path"),
114
+ show_fqn: bool = typer.Option(False, "--fqn", "-f", help="Show full path in tree output"),
115
+ ) -> None:
116
+ try:
117
+ results = search_entities(
118
+ pattern,
119
+ type_filter=type_filter,
120
+ language=language,
121
+ limit=limit,
122
+ offset=offset,
123
+ exact=exact,
124
+ name_only=name_only,
125
+ )
126
+ except FileNotFoundError as e:
127
+ console.print(f"[red]{e}[/red]")
128
+ raise typer.Exit(code=1)
129
+
130
+ if not results:
131
+ console.print("[yellow]No matching entities found[/yellow]")
132
+ raise typer.Exit()
133
+
134
+ cfg = read_config()
135
+ display_mode = cfg.get("display_mode", "table")
136
+
137
+ if display_mode == "tree":
138
+ tree = build_entity_tree(results, show_fqn=show_fqn)
139
+ console.print(tree)
140
+ else:
141
+ table = Table("Name", "Type", "Language", "Full Path")
142
+ for row in results:
143
+ table.add_row(row["name"], row["type"], row["language"], row["full_path"])
144
+ console.print(table)
145
+
146
+ def _get_file_path(source: str) -> str:
147
+ parts = source.rsplit(":", 1)
148
+ return parts[0] if len(parts) > 1 else source
149
+
150
+ def _format_range(entity: dict, meta: dict, col: str, extracted: dict | None = None) -> str:
151
+ if extracted and col in extracted:
152
+ return extracted[col]
153
+ start = meta.get(f"{col}_lineno")
154
+ end = meta.get(f"{col}_end_lineno")
155
+ if start is None:
156
+ return ""
157
+ path = _get_file_path(entity.get("_source", ""))
158
+ loc = f"{path}:{start}" if start == end else f"{path}:{start}-{end}"
159
+ return loc
160
+
161
+ def _get_column_value(entity: dict, col: str, meta: dict, extracted: dict | None = None) -> str:
162
+ if col == "full_path":
163
+ return entity["full_path"]
164
+ if col == "language":
165
+ return entity["language"]
166
+ if col == "type":
167
+ return entity["type"]
168
+ if col == "lineno":
169
+ return str(meta.get("lineno", ""))
170
+ if col == "end_lineno":
171
+ return str(meta.get("end_lineno", ""))
172
+ if col == "source":
173
+ return entity.get("_source", "")
174
+ if col in ("signature", "arguments", "return_type", "docstring"):
175
+ return _format_range(entity, meta, col, extracted)
176
+ if col == "decorators":
177
+ return ", ".join(meta.get("decorators", []))
178
+ if col == "parent_classes":
179
+ return ", ".join(meta.get("parent_classes", []))
180
+ if col == "visibility":
181
+ return meta.get("visibility", "")
182
+ if col == "exported":
183
+ return str(meta.get("exported", ""))
184
+ if col == "resolved_bases":
185
+ return _format_resolved_bases(entity)
186
+ if col == "unresolved_bases":
187
+ return _format_unresolved_bases(entity)
188
+ if col == "mro":
189
+ return _format_mro(entity)
190
+ if col == "inherited_from":
191
+ inherited = entity.get("_inherited_from", "")
192
+ if inherited:
193
+ idx = entity.get("_mro_index", 0)
194
+ return f"{inherited} (MRO depth {idx})"
195
+ if entity.get("type") == "INHERITED_MEMBER":
196
+ meta_inherited = meta.get("inherited_from", "")
197
+ if meta_inherited:
198
+ depth = meta.get("mro_depth", 0)
199
+ return f"{meta_inherited} (MRO depth {depth})"
200
+ return ""
201
+ return ""
202
+
203
+ def _format_resolved_bases(entity: dict) -> str:
204
+ info = entity.get("_inheritance_info")
205
+ if not info:
206
+ return ""
207
+ resolved = info.get("resolved_bases", [])
208
+ return ", ".join(b["base_full_path"] for b in resolved)
209
+
210
+ def _format_unresolved_bases(entity: dict) -> str:
211
+ """Format unresolved bases for info display.
212
+
213
+ TODO: add --unresolved flag to search command for discovering classes with
214
+ unresolved bases; also handle qualified base names (e.g. c.Y) in
215
+ resolve_all_inherits and pin-inheritance
216
+ """
217
+ info = entity.get("_inheritance_info")
218
+ if not info:
219
+ return ""
220
+ unresolved = info.get("unresolved_bases", [])
221
+ if not unresolved:
222
+ return ""
223
+ parts = []
224
+ for ub in unresolved:
225
+ candidates = ub.get("candidates", [])
226
+ if candidates:
227
+ cand_info = []
228
+ for c in candidates:
229
+ scope = c.get("scope", "")
230
+ loc = f"{c.get('full_path', '?')}"
231
+ if "conditional" in scope:
232
+ loc += f" (conditional)"
233
+ cand_info.append(loc)
234
+ parts.append(f"{ub['base_full_path']}: {', '.join(cand_info)}")
235
+ else:
236
+ parts.append(f"{ub['base_full_path']}: [no candidates]")
237
+ entity_fqn = entity.get("full_path", "")
238
+ hint = f"\nHint: use 'deputy resolve <module>.<name>' to trace imports, then 'deputy pin-inheritance {entity_fqn} <name> <file>:<line>' to pin"
239
+ return "; ".join(parts) + hint
240
+
241
+
242
+ def _print_unresolved_hint(entity: dict) -> None:
243
+ info = entity.get("_inheritance_info")
244
+ if not info:
245
+ return
246
+ unresolved = info.get("unresolved_bases", [])
247
+ if not unresolved:
248
+ return
249
+ base_labels = []
250
+ for ub in unresolved:
251
+ candidates = ub.get("candidates", [])
252
+ if candidates:
253
+ scope = candidates[0].get("scope", "")
254
+ label = ub["base_full_path"]
255
+ if "conditional" in scope:
256
+ label += " (conditional)"
257
+ base_labels.append(label)
258
+ else:
259
+ base_labels.append(f"{ub['base_full_path']} [no candidates]")
260
+ entity_fqn = entity.get("full_path", "")
261
+ console.print(f"\n[bold]Found unresolved bases:[/bold] {', '.join(base_labels)}")
262
+ console.print(f"[dim]Hint: use 'deputy resolve <module>.<name>' to trace imports, then 'deputy pin-inheritance {entity_fqn} <name> <file>:<line>' to pin[/dim]")
263
+
264
+
265
+ def _format_mro(entity: dict) -> str:
266
+ info = entity.get("_inheritance_info")
267
+ if not info:
268
+ return ""
269
+ mro = info.get("mro")
270
+ if mro is None:
271
+ return "[incomplete - unresolved bases]"
272
+ return " → ".join(mro)
273
+
274
+ def _display_info_single(entity: dict, columns: list[str], extract: bool) -> None:
275
+ meta = json.loads(entity["metadata_json"])
276
+ extracted = entity.get("_extracted")
277
+ table = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
278
+ for col in columns:
279
+ val = _get_column_value(entity, col, meta, extracted)
280
+ table.add_row(f"{col}:", val)
281
+ console.print(table)
282
+ if entity.get("type") == "CLASS" and "unresolved_bases" not in columns:
283
+ info = entity.get("_inheritance_info")
284
+ if info and info.get("unresolved_bases"):
285
+ _print_unresolved_hint(entity)
286
+
287
+ def _display_info_table(entities: list[dict], columns: list[str]) -> None:
288
+ meta_list = [json.loads(e["metadata_json"]) for e in entities]
289
+ extracted_list = [e.get("_extracted") for e in entities]
290
+ table = Table(*columns)
291
+ for i, entity in enumerate(entities):
292
+ row = [_get_column_value(entity, col, meta_list[i], extracted_list[i]) for col in columns]
293
+ table.add_row(*row)
294
+ console.print(table)
295
+
296
+ def _print_available_columns() -> None:
297
+ table = Table("Column", "Description")
298
+ for key, desc in AVAILABLE_COLUMNS.items():
299
+ table.add_row(key, desc)
300
+ console.print(table)
301
+
302
+ @app.command(name="info")
303
+ def get_info(
304
+ full_path: str = typer.Argument(None, help="Exact entity full path"),
305
+ all_matches: bool = typer.Option(False, "--all", "-a", help="Show all matching entities"),
306
+ columns: str = typer.Option(None, "--columns", "-c", help="Comma-separated columns to display (use --list-columns to see available)"),
307
+ list_columns: bool = typer.Option(False, "--list-columns", help="List available columns and descriptions"),
308
+ type_filter: str = typer.Option(None, "--type", "-t", help="Filter by entity type (e.g. FUNCTION, CLASS)"),
309
+ lineno: int = typer.Option(None, "--lineno", help="Filter by line number"),
310
+ extract: bool = typer.Option(False, "--extract", "-x", help="Extract and display actual source text for signature/docstring"),
311
+ ) -> None:
312
+ if list_columns:
313
+ _print_available_columns()
314
+ return
315
+ if full_path is None:
316
+ console.print("[red]Missing argument 'FULL_PATH'.[/red]")
317
+ raise typer.Exit(code=1)
318
+
319
+ col_list = columns.split(",") if columns else DEFAULT_COLUMNS
320
+
321
+ try:
322
+ result = get_entity_info(
323
+ full_path,
324
+ all_matches=all_matches,
325
+ type_filter=type_filter,
326
+ lineno=lineno,
327
+ extract=extract,
328
+ )
329
+ except FileNotFoundError as e:
330
+ console.print(f"[red]{e}[/red]")
331
+ raise typer.Exit(code=1)
332
+
333
+ if all_matches:
334
+ if not result:
335
+ console.print(f"[red]Entity not found:[/red] {full_path}")
336
+ raise typer.Exit()
337
+ _display_info_table(result, col_list)
338
+ return
339
+
340
+ if result is None:
341
+ console.print(f"[red]Entity not found:[/red] {full_path}")
342
+ raise typer.Exit()
343
+
344
+ match_count = result.pop("_match_count", 1)
345
+ _display_info_single(result, col_list, extract)
346
+
347
+ if match_count > 1 and not type_filter and lineno is None:
348
+ console.print(f"\n[dark_orange]Found {match_count} matching entities. Use --all to see all, or filter with --type / --lineno. See --help for details.[/dark_orange]")
349
+
350
+ # TODO: Allow user to go back a step, and also go forward to the next step if they had gone back a path
351
+ # TODO: Try handling module members of imported modules (eg: import a.b.c; class X(a.b.c.Base): pass) - this is tricky because we need to resolve the import chain and then find the base class in the imported module
352
+ @app.command(name="resolve")
353
+ def resolve(
354
+ symbol: str = typer.Argument(..., help="Symbol to resolve, in the form <module_fqn>.<symbol_name>"),
355
+ auto: bool = typer.Option(False, "--auto", help="Only stop when multiple choices exist"),
356
+ step: bool = typer.Option(False, "--step", help="Stop at every step regardless of ambiguity"),
357
+ all: bool = typer.Option(False, "--all", help="Show all possible resolutions"),
358
+ compact: bool = typer.Option(False, "--compact", help="Compact output with --all (terminal entities only)"),
359
+ ) -> None:
360
+ parts = symbol.rsplit(".", 1)
361
+ if len(parts) != 2:
362
+ console.print("[red]Symbol must be in the form <module_fqn>.<symbol_name>[/red]")
363
+ raise typer.Exit(code=1)
364
+ module_fqn, symbol_name = parts
365
+
366
+ try:
367
+ conn = _open_database()
368
+ except FileNotFoundError as e:
369
+ console.print(f"[red]{e}[/red]")
370
+ raise typer.Exit(code=1)
371
+
372
+ if compact and not all:
373
+ console.print("[red]--compact requires --all[/red]")
374
+ raise typer.Exit(code=1)
375
+
376
+ resolver = InteractiveResolver(conn, mode="default")
377
+ if all:
378
+ if compact:
379
+ resolver._print_all_compact(module_fqn, symbol_name)
380
+ else:
381
+ resolver._print_all_tree(module_fqn, symbol_name)
382
+ else:
383
+ mode = "step" if step else ("auto" if auto else "default")
384
+ resolver.mode = mode
385
+ result = resolver.resolve(module_fqn, symbol_name)
386
+ if result is None:
387
+ raise typer.Exit(code=1)
388
+
389
+ conn.close()
390
+
391
+ @app.command()
392
+ def subclasses(
393
+ full_path: str = typer.Argument(..., help="Base class FQN to find subclasses of"),
394
+ transitive: bool = typer.Option(False, "--transitive", "-t", help="Include indirect subclasses"),
395
+ ) -> None:
396
+ try:
397
+ conn = _open_database()
398
+ except FileNotFoundError as e:
399
+ console.print(f"[red]{e}[/red]")
400
+ raise typer.Exit(code=1)
401
+
402
+ branch = get_current_branch()
403
+
404
+ if transitive:
405
+ subs = get_transitive_subclasses(conn, full_path, branch_name=branch)
406
+ else:
407
+ subs = get_direct_subclasses(conn, full_path, branch_name=branch)
408
+
409
+ conn.close()
410
+
411
+ if not subs:
412
+ console.print(f"[yellow]No subclasses found for[/yellow] {full_path}")
413
+ raise typer.Exit()
414
+
415
+ tree = Tree(f"Subclasses of [bold]{full_path}[/bold]")
416
+ for sub in subs:
417
+ meta = {}
418
+ try:
419
+ meta = json.loads(sub["metadata_json"])
420
+ except (json.JSONDecodeError, TypeError):
421
+ pass
422
+ lineno = meta.get("lineno", "")
423
+ loc = f" : {lineno}" if lineno else ""
424
+ tree.add(f"{sub['type']} {sub['full_path']}{loc}")
425
+ console.print(tree)
426
+
427
+ @app.command(name="pin-inheritance")
428
+ def pin_inheritance(
429
+ class_fqn: str = typer.Argument(None, help="Class FQN to pin a base for"),
430
+ base_name: str = typer.Argument(None, help="Base class name to resolve"),
431
+ entity_ref: str = typer.Argument(None, help="file_path:lineno[:col_offset] of the candidate to pin"),
432
+ remove: bool = typer.Option(False, "--remove", "-r", help="Remove an existing pin"),
433
+ list_pins: bool = typer.Option(False, "--list", "-l", help="List all pins for current branch"),
434
+ ) -> None:
435
+ try:
436
+ conn = _open_database()
437
+ except FileNotFoundError as e:
438
+ console.print(f"[red]{e}[/red]")
439
+ raise typer.Exit(code=1)
440
+
441
+ branch = get_current_branch()
442
+
443
+ if list_pins:
444
+ pins = list_inheritance_pins(conn, branch)
445
+ conn.close()
446
+ if not pins:
447
+ console.print("[yellow]No inheritance pins set[/yellow]")
448
+ return
449
+ table = Table("Class", "Base Name", "Pinned Entity ID")
450
+ for pin in pins:
451
+ table.add_row(pin.get("class_fqn", pin["class_full_path"]), pin["base_name"], pin["pinned_entity_id"])
452
+ console.print(table)
453
+ return
454
+
455
+ if not class_fqn or not base_name:
456
+ console.print("[red]Usage: deputy pin-inheritance <class_fqn> <base_name> <file_path:lineno>[/red]")
457
+ console.print(" deputy pin-inheritance --list")
458
+ console.print(" deputy pin-inheritance --remove <class_fqn> <base_name>")
459
+ raise typer.Exit(code=1)
460
+
461
+ if remove:
462
+ ids = get_entity_ids_by_fqn(conn, class_fqn)
463
+ if not ids:
464
+ conn.close()
465
+ console.print(f"[red]Class not found:[/red] {class_fqn}")
466
+ raise typer.Exit(code=1)
467
+ for eid in ids:
468
+ entity = get_entity_by_id(conn, eid)
469
+ if entity and entity["type"] == "CLASS":
470
+ pin = get_inheritance_pin(conn, eid, base_name, branch)
471
+ if pin:
472
+ pinned_entity = get_entity_by_id(conn, pin["pinned_entity_id"])
473
+ if pinned_entity:
474
+ conn.execute(
475
+ "DELETE FROM class_bases WHERE class_entity_id = ? AND base_full_path = ?",
476
+ (eid, pinned_entity["full_path"]),
477
+ )
478
+ upsert_class_bases(conn, eid, [{
479
+ "base_full_path": base_name,
480
+ "base_entity_id": None,
481
+ "is_resolved": False,
482
+ }])
483
+ delete_inheritance_pin(conn, eid, base_name, branch)
484
+ console.print(f"[green]Removed pin for[/green] {class_fqn}:{base_name}")
485
+ break
486
+ else:
487
+ console.print(f"[red]Class not found:[/red] {class_fqn}")
488
+ raise typer.Exit(code=1)
489
+ eager_resolve_all_inherited_members(conn, records=None, branch=branch)
490
+ conn.commit()
491
+ console.print(f"[dim]Inherited member aliases re-resolved after pin removal[/dim]")
492
+ conn.close()
493
+ return
494
+
495
+ if not entity_ref:
496
+ console.print("[red]Missing entity reference (file_path:lineno)[/red]")
497
+ raise typer.Exit(code=1)
498
+
499
+ ids = get_entity_ids_by_fqn(conn, class_fqn)
500
+ class_entity_id = None
501
+ for eid in ids:
502
+ entity = get_entity_by_id(conn, eid)
503
+ if entity and entity["type"] == "CLASS":
504
+ class_entity_id = eid
505
+ break
506
+
507
+ if not class_entity_id:
508
+ conn.close()
509
+ console.print(f"[red]Class not found:[/red] {class_fqn}")
510
+ raise typer.Exit(code=1)
511
+
512
+ parts = entity_ref.rsplit(":", 2)
513
+ lineno = int(parts[1]) if len(parts) > 1 else None
514
+ col_offset = int(parts[2]) if len(parts) > 2 else None
515
+
516
+ if lineno is None:
517
+ conn.close()
518
+ console.print("[red]Entity reference must be in the form file_path:lineno[:col_offset][/red]")
519
+ raise typer.Exit(code=1)
520
+
521
+ module_fqn = get_containing_module_fqn(conn, class_entity_id)
522
+ if not module_fqn:
523
+ conn.close()
524
+ console.print(f"[red]Cannot determine module for class {class_fqn}[/red]")
525
+ raise typer.Exit(code=1)
526
+
527
+ module_entities = get_entity_ids_by_fqn(conn, module_fqn)
528
+ module_entity_id = next(iter(module_entities)) if module_entities else None
529
+
530
+ if not module_entity_id:
531
+ conn.close()
532
+ console.print(f"[red]Module not found: {module_fqn}[/red]")
533
+ raise typer.Exit(code=1)
534
+
535
+ rows = conn.execute(
536
+ """SELECT id FROM entities
537
+ WHERE type = 'IMPORT_ALIAS'
538
+ AND full_path = ?
539
+ AND json_extract(metadata_json, '$.lineno') = ?""",
540
+ (f"{module_fqn}.{base_name}", lineno),
541
+ ).fetchall()
542
+ candidates = [dict(r) for r in rows]
543
+
544
+ if col_offset is not None:
545
+ candidates = [c for c in candidates
546
+ if json.loads(get_entity_by_id(conn, c["id"])["metadata_json"]).get("col_offset") == col_offset]
547
+
548
+ if not candidates:
549
+ conn.close()
550
+ console.print(f"[red]No entity found at {entity_ref} in module {module_fqn}[/red]")
551
+ raise typer.Exit(code=1)
552
+
553
+ if len(candidates) > 1:
554
+ conn.close()
555
+ console.print(f"[red]Multiple entities found at {entity_ref}. Provide col_offset to disambiguate.[/red]")
556
+ raise typer.Exit(code=1)
557
+
558
+ import_alias_entity = get_entity_by_id(conn, candidates[0]["id"])
559
+ alias_meta = json.loads(import_alias_entity["metadata_json"])
560
+ import_stmt = get_entity_by_id(conn, import_alias_entity["parent_id"])
561
+ if import_stmt:
562
+ import_path = import_stmt.get("name", "")
563
+ original_name = alias_meta.get("original_name", "")
564
+ target_fqn = f"{import_path}.{original_name}"
565
+ target_ids = get_entity_ids_by_fqn(conn, target_fqn)
566
+ target_entity = None
567
+ for tid in target_ids:
568
+ te = get_entity_by_id(conn, tid)
569
+ if te and te["type"] == "CLASS":
570
+ target_entity = te
571
+ break
572
+ if target_entity:
573
+ pinned_entity_id = target_entity["id"]
574
+ else:
575
+ pinned_entity_id = import_alias_entity["id"]
576
+ else:
577
+ pinned_entity_id = import_alias_entity["id"]
578
+ upsert_inheritance_pin(conn, class_entity_id, base_name, pinned_entity_id, branch)
579
+ conn.execute(
580
+ "DELETE FROM class_bases WHERE class_entity_id = ? AND base_full_path = ?",
581
+ (class_entity_id, base_name),
582
+ )
583
+ pinned_fqn = target_entity["full_path"] if target_entity else base_name
584
+ upsert_class_bases(conn, class_entity_id, [{
585
+ "base_full_path": pinned_fqn,
586
+ "base_entity_id": pinned_entity_id,
587
+ "is_resolved": True,
588
+ }])
589
+ eager_resolve_all_inherited_members(conn, records=None, branch=branch)
590
+ conn.commit()
591
+ console.print(f"[green]Pinned[/green] {class_fqn}:{base_name} → {entity_ref}")
592
+ console.print(f"[dim]Inherited member aliases re-resolved after pin[/dim]")
593
+ conn.close()
594
+
595
+ @app.command()
596
+ def config(
597
+ key: str = typer.Argument(..., help="Config key"),
598
+ value: str = typer.Argument(None, help="Config value (omit to read)"),
599
+ ) -> None:
600
+ if value is None:
601
+ cfg = read_config()
602
+ if key in cfg:
603
+ console.print(cfg[key])
604
+ else:
605
+ console.print(f"[red]Key not found:[/red] {key}")
606
+ raise typer.Exit(code=1)
607
+ else:
608
+ write_config(key, value)
609
+ console.print(f"[green]Set[/green] {key}={value}")
610
+
611
+ def main() -> None:
612
+ app()
deputy/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
deputy/core.py ADDED
@@ -0,0 +1,21 @@
1
+ from deproc.core.context import Context
2
+ from deproc.plugins.python import (
3
+ PythonSourceParser,
4
+ PythonLinker,
5
+ )
6
+ from .database.sqlite import (
7
+ SqliteSymbolCache,
8
+ )
9
+
10
+ def create_context(base_path: str, conn, enable_cache: bool = False) -> Context:
11
+ ctx = Context(base_path=base_path)
12
+ ctx.set_language("python", [".py", ".pyi"], aliases=["py"])
13
+ ctx.set_parser("python", PythonSourceParser())
14
+ ctx.set_linker("python", PythonLinker())
15
+ ctx.set_skip_paths({
16
+ "*.egg-info", "*.dist-info", "__pycache__", "node_modules", ".git",
17
+ ".venv", ".mypy_cache", ".pytest_cache", "build", "dist",
18
+ })
19
+ if enable_cache:
20
+ ctx.set_symbol_cache(SqliteSymbolCache(conn))
21
+ return ctx