suitable-loop 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.
@@ -0,0 +1,3 @@
1
+ """Suitable Loop — Local Production Engineering Platform."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m suitable_loop`."""
2
+
3
+ from .server import main
4
+
5
+ main()
@@ -0,0 +1 @@
1
+ """Suitable Loop analysis engines."""
@@ -0,0 +1,652 @@
1
+ """AST-based Python code analyzer for Suitable Loop.
2
+
3
+ Walks a project directory, parses Python source files, and extracts
4
+ functions, classes, imports, and call relationships into the database.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import fnmatch
11
+ import hashlib
12
+ import logging
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from suitable_loop.config import SuitableLoopConfig
17
+ from suitable_loop.db import Database
18
+ from suitable_loop.models import (
19
+ CallEdge,
20
+ ClassEntity,
21
+ FileDependency,
22
+ FileEntity,
23
+ FunctionEntity,
24
+ ImportEntity,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _end_line(node: ast.AST) -> int:
31
+ """Return the end line of an AST node, falling back to start line."""
32
+ return getattr(node, "end_lineno", None) or node.lineno
33
+
34
+
35
+ def _get_docstring(node: ast.AST) -> str | None:
36
+ """Extract the docstring from a function or class node."""
37
+ try:
38
+ return ast.get_docstring(node)
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def _signature_from_args(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
44
+ """Build a human-readable signature string from function arguments."""
45
+ parts: list[str] = []
46
+ args = node.args
47
+
48
+ # Positional-only args
49
+ for arg in args.posonlyargs:
50
+ parts.append(arg.arg)
51
+ if args.posonlyargs:
52
+ parts.append("/")
53
+
54
+ # Regular positional args
55
+ num_defaults = len(args.defaults)
56
+ num_args = len(args.args)
57
+ for i, arg in enumerate(args.args):
58
+ name = arg.arg
59
+ default_idx = i - (num_args - num_defaults)
60
+ if default_idx >= 0:
61
+ name += "=..."
62
+ parts.append(name)
63
+
64
+ # *args
65
+ if args.vararg:
66
+ parts.append(f"*{args.vararg.arg}")
67
+ elif args.kwonlyargs:
68
+ parts.append("*")
69
+
70
+ # Keyword-only args
71
+ for i, arg in enumerate(args.kwonlyargs):
72
+ name = arg.arg
73
+ if i < len(args.kw_defaults) and args.kw_defaults[i] is not None:
74
+ name += "=..."
75
+ parts.append(name)
76
+
77
+ # **kwargs
78
+ if args.kwarg:
79
+ parts.append(f"**{args.kwarg.arg}")
80
+
81
+ return f"({', '.join(parts)})"
82
+
83
+
84
+ class _CallCollector(ast.NodeVisitor):
85
+ """Collect function calls within a function body.
86
+
87
+ Each call is recorded as (callee_name, line_number). For attribute
88
+ calls like ``self.foo()`` or ``module.bar()`` the full dotted name
89
+ is stored.
90
+ """
91
+
92
+ def __init__(self) -> None:
93
+ self.calls: list[tuple[str, int]] = []
94
+
95
+ def visit_Call(self, node: ast.Call) -> None: # noqa: N802
96
+ name = self._resolve_call_name(node.func)
97
+ if name:
98
+ self.calls.append((name, node.lineno))
99
+ self.generic_visit(node)
100
+
101
+ @staticmethod
102
+ def _resolve_call_name(node: ast.expr) -> str | None:
103
+ if isinstance(node, ast.Name):
104
+ return node.id
105
+ if isinstance(node, ast.Attribute):
106
+ parts: list[str] = [node.attr]
107
+ current = node.value
108
+ while isinstance(current, ast.Attribute):
109
+ parts.append(current.attr)
110
+ current = current.value
111
+ if isinstance(current, ast.Name):
112
+ parts.append(current.id)
113
+ parts.reverse()
114
+ return ".".join(parts)
115
+ return None
116
+
117
+
118
+ class CodeAnalyzer:
119
+ """Indexes a Python codebase by parsing ASTs and persisting entities."""
120
+
121
+ def __init__(self, db: Database, config: SuitableLoopConfig) -> None:
122
+ self.db = db
123
+ self.config = config
124
+ # Temporary storage for raw calls accumulated during file indexing.
125
+ # List of (file_path, caller_qualified_name, callee_name, line).
126
+ self._raw_calls: list[tuple[str, str, str, int]] = []
127
+
128
+ # ------------------------------------------------------------------
129
+ # Public API
130
+ # ------------------------------------------------------------------
131
+
132
+ def index_codebase(self, project_path: str, force: bool = False) -> dict:
133
+ """Walk *project_path* and index every ``.py`` file.
134
+
135
+ Returns a summary dict with counts of files, functions, classes,
136
+ imports, and call edges processed.
137
+ """
138
+ root = Path(project_path).resolve()
139
+ if not root.is_dir():
140
+ raise FileNotFoundError(f"Project path does not exist: {root}")
141
+
142
+ self._raw_calls.clear()
143
+
144
+ py_files = self._discover_files(root)
145
+ logger.info("Discovered %d Python files in %s", len(py_files), root)
146
+
147
+ stats: dict[str, int] = {
148
+ "files_total": len(py_files),
149
+ "files_indexed": 0,
150
+ "files_skipped": 0,
151
+ "functions": 0,
152
+ "classes": 0,
153
+ "imports": 0,
154
+ "call_edges": 0,
155
+ }
156
+
157
+ for file_path in py_files:
158
+ file_stats = self._index_file(file_path, str(root), force=force)
159
+ if file_stats["indexed"]:
160
+ stats["files_indexed"] += 1
161
+ stats["functions"] += file_stats["functions"]
162
+ stats["classes"] += file_stats["classes"]
163
+ stats["imports"] += file_stats["imports"]
164
+ else:
165
+ stats["files_skipped"] += 1
166
+
167
+ # Resolve file-level dependencies now that all files are in the DB.
168
+ self._resolve_file_dependencies(str(root))
169
+
170
+ # Resolve raw calls across the whole project into call edges.
171
+ edge_count = self._resolve_calls(str(root))
172
+ stats["call_edges"] = edge_count
173
+
174
+ self.db.commit()
175
+ logger.info(
176
+ "Indexing complete: %d files indexed, %d skipped, %d functions, "
177
+ "%d classes, %d call edges",
178
+ stats["files_indexed"],
179
+ stats["files_skipped"],
180
+ stats["functions"],
181
+ stats["classes"],
182
+ stats["call_edges"],
183
+ )
184
+ return stats
185
+
186
+ # ------------------------------------------------------------------
187
+ # File-level indexing
188
+ # ------------------------------------------------------------------
189
+
190
+ def _index_file(
191
+ self, file_path: Path, project_root: str, *, force: bool = False
192
+ ) -> dict:
193
+ """Index a single Python file.
194
+
195
+ Returns a small dict with keys ``indexed`` (bool) and entity counts.
196
+ """
197
+ result = {"indexed": False, "functions": 0, "classes": 0, "imports": 0}
198
+
199
+ try:
200
+ source = file_path.read_text(encoding="utf-8", errors="replace")
201
+ except OSError as exc:
202
+ logger.warning("Cannot read %s: %s", file_path, exc)
203
+ return result
204
+
205
+ content_hash = hashlib.sha256(source.encode("utf-8")).hexdigest()
206
+ stat = file_path.stat()
207
+ rel_path = str(file_path)
208
+
209
+ # Check whether the file has changed since the last indexing.
210
+ existing = self.db.get_file_by_path(rel_path)
211
+ if existing and not force and existing.hash == content_hash:
212
+ logger.debug("Skipping unchanged file: %s", rel_path)
213
+ return result
214
+
215
+ # Upsert the file entity.
216
+ file_entity = FileEntity(
217
+ path=rel_path,
218
+ project_root=project_root,
219
+ size_bytes=stat.st_size,
220
+ last_modified=stat.st_mtime,
221
+ last_indexed=time.time(),
222
+ line_count=source.count("\n") + 1,
223
+ hash=content_hash,
224
+ )
225
+ file_id = self.db.upsert_file(file_entity)
226
+
227
+ # Clear stale data for this file before re-inserting.
228
+ self.db.clear_file_data(file_id)
229
+
230
+ # Parse the AST.
231
+ try:
232
+ functions, classes, imports, raw_calls = self._parse_ast(source, rel_path)
233
+ except SyntaxError as exc:
234
+ logger.warning("Syntax error in %s: %s", rel_path, exc)
235
+ return result
236
+
237
+ # Compute cyclomatic complexity for each function.
238
+ complexity_map = self._compute_complexity(source)
239
+
240
+ # Persist functions.
241
+ for func in functions:
242
+ func.file_id = file_id
243
+ func.complexity = complexity_map.get(func.name, 0)
244
+ self.db.insert_function(func)
245
+ result["functions"] = len(functions)
246
+
247
+ # Persist classes.
248
+ for cls in classes:
249
+ cls.file_id = file_id
250
+ self.db.insert_class(cls)
251
+ result["classes"] = len(classes)
252
+
253
+ # Persist imports (file dependencies are resolved in a second pass).
254
+ for imp in imports:
255
+ imp.file_id = file_id
256
+ self.db.insert_import(imp)
257
+ result["imports"] = len(imports)
258
+
259
+ # Stash raw calls for cross-file resolution later.
260
+ for caller_qname, callee_name, line in raw_calls:
261
+ self._raw_calls.append((rel_path, caller_qname, callee_name, line))
262
+
263
+ self.db.commit()
264
+ result["indexed"] = True
265
+ return result
266
+
267
+ # ------------------------------------------------------------------
268
+ # AST parsing
269
+ # ------------------------------------------------------------------
270
+
271
+ def _parse_ast(
272
+ self, source: str, file_path: str
273
+ ) -> tuple[
274
+ list[FunctionEntity],
275
+ list[ClassEntity],
276
+ list[ImportEntity],
277
+ list[tuple[str, str, int]],
278
+ ]:
279
+ """Parse *source* and extract entities.
280
+
281
+ Returns:
282
+ (functions, classes, imports, raw_calls)
283
+ where raw_calls is a list of ``(caller_qualified_name, callee_name, line)``.
284
+ """
285
+ tree = ast.parse(source, filename=file_path)
286
+
287
+ functions: list[FunctionEntity] = []
288
+ classes: list[ClassEntity] = []
289
+ imports: list[ImportEntity] = []
290
+ raw_calls: list[tuple[str, str, int]] = []
291
+
292
+ module_name = self._path_to_module(file_path)
293
+
294
+ for node in ast.iter_child_nodes(tree):
295
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
296
+ func = self._extract_function(node, module_name, class_name=None)
297
+ functions.append(func)
298
+ raw_calls.extend(
299
+ self._collect_calls(node, func.qualified_name)
300
+ )
301
+
302
+ elif isinstance(node, ast.ClassDef):
303
+ cls = self._extract_class(node, module_name)
304
+ classes.append(cls)
305
+ # Extract methods inside the class.
306
+ for item in ast.iter_child_nodes(node):
307
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
308
+ method = self._extract_function(
309
+ item, module_name, class_name=node.name
310
+ )
311
+ functions.append(method)
312
+ raw_calls.extend(
313
+ self._collect_calls(item, method.qualified_name)
314
+ )
315
+
316
+ elif isinstance(node, ast.Import):
317
+ for alias in node.names:
318
+ imports.append(
319
+ ImportEntity(module=alias.name, alias=alias.asname)
320
+ )
321
+
322
+ elif isinstance(node, ast.ImportFrom):
323
+ base = node.module or ""
324
+ for alias in node.names:
325
+ full_module = f"{base}.{alias.name}" if base else alias.name
326
+ imports.append(
327
+ ImportEntity(module=full_module, alias=alias.asname)
328
+ )
329
+
330
+ return functions, classes, imports, raw_calls
331
+
332
+ # ------------------------------------------------------------------
333
+ # Entity extraction helpers
334
+ # ------------------------------------------------------------------
335
+
336
+ @staticmethod
337
+ def _extract_function(
338
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
339
+ module_name: str,
340
+ class_name: str | None,
341
+ ) -> FunctionEntity:
342
+ if class_name:
343
+ qualified_name = f"{module_name}.{class_name}.{node.name}"
344
+ else:
345
+ qualified_name = f"{module_name}.{node.name}"
346
+
347
+ return FunctionEntity(
348
+ name=node.name,
349
+ qualified_name=qualified_name,
350
+ class_name=class_name,
351
+ line_start=node.lineno,
352
+ line_end=_end_line(node),
353
+ signature=_signature_from_args(node),
354
+ docstring=_get_docstring(node),
355
+ is_method=class_name is not None,
356
+ is_async=isinstance(node, ast.AsyncFunctionDef),
357
+ )
358
+
359
+ @staticmethod
360
+ def _extract_class(node: ast.ClassDef, module_name: str) -> ClassEntity:
361
+ bases = []
362
+ for base in node.bases:
363
+ if isinstance(base, ast.Name):
364
+ bases.append(base.id)
365
+ elif isinstance(base, ast.Attribute):
366
+ parts: list[str] = [base.attr]
367
+ current = base.value
368
+ while isinstance(current, ast.Attribute):
369
+ parts.append(current.attr)
370
+ current = current.value
371
+ if isinstance(current, ast.Name):
372
+ parts.append(current.id)
373
+ parts.reverse()
374
+ bases.append(".".join(parts))
375
+
376
+ return ClassEntity(
377
+ name=node.name,
378
+ qualified_name=f"{module_name}.{node.name}",
379
+ line_start=node.lineno,
380
+ line_end=_end_line(node),
381
+ bases=bases,
382
+ docstring=_get_docstring(node),
383
+ )
384
+
385
+ @staticmethod
386
+ def _collect_calls(
387
+ node: ast.FunctionDef | ast.AsyncFunctionDef, caller_qname: str
388
+ ) -> list[tuple[str, str, int]]:
389
+ collector = _CallCollector()
390
+ collector.visit(node)
391
+ return [(caller_qname, name, line) for name, line in collector.calls]
392
+
393
+ # ------------------------------------------------------------------
394
+ # File dependency resolution (second pass)
395
+ # ------------------------------------------------------------------
396
+
397
+ def _resolve_file_dependencies(self, project_root: str) -> None:
398
+ """Resolve imports to file dependencies now that all files are indexed."""
399
+ all_files = self.db.get_all_files(project_root)
400
+
401
+ for f in all_files:
402
+ self.db.delete_file_dependencies_by_source(f.id)
403
+ imports = self.db.get_imports_by_file(f.id)
404
+ for imp in imports:
405
+ resolved = self._resolve_import_to_file(imp.module, project_root)
406
+ if resolved is not None and resolved.id != f.id:
407
+ self.db.insert_file_dependency(
408
+ FileDependency(source_file_id=f.id, target_file_id=resolved.id)
409
+ )
410
+
411
+ self.db.commit()
412
+
413
+ # ------------------------------------------------------------------
414
+ # Call resolution
415
+ # ------------------------------------------------------------------
416
+
417
+ def _resolve_calls(self, project_root: str) -> int:
418
+ """Resolve accumulated raw calls into :class:`CallEdge` records.
419
+
420
+ Resolution strategy (in order):
421
+ 1. ``self.<method>()`` — look for a method with the same class in the
422
+ same file.
423
+ 2. Bare ``func()`` — look in the same module, then across the project.
424
+ 3. ``module.func()`` — try to match via imports recorded in the same
425
+ file.
426
+
427
+ Returns the number of call edges inserted.
428
+ """
429
+ # Build lookup caches.
430
+ all_files = self.db.get_all_files(project_root)
431
+ # qname -> FunctionEntity (with id populated)
432
+ qname_index: dict[str, FunctionEntity] = {}
433
+ # bare name -> list of FunctionEntity
434
+ name_index: dict[str, list[FunctionEntity]] = {}
435
+ # file_path -> file_id
436
+ file_id_index: dict[str, int] = {}
437
+
438
+ for f in all_files:
439
+ file_id_index[f.path] = f.id # type: ignore[arg-type]
440
+ for func in self.db.get_functions_by_file(f.id): # type: ignore[arg-type]
441
+ qname_index[func.qualified_name] = func
442
+ name_index.setdefault(func.name, []).append(func)
443
+
444
+ # Build import alias index: (file_path, alias_or_module_tail) -> full module
445
+ import_alias_index: dict[tuple[str, str], str] = {}
446
+ for f in all_files:
447
+ for imp in self.db.get_imports_by_file(f.id): # type: ignore[arg-type]
448
+ key_name = imp.alias or imp.module.rsplit(".", 1)[-1]
449
+ import_alias_index[(f.path, key_name)] = imp.module
450
+
451
+ inserted = 0
452
+ for file_path, caller_qname, callee_name, line in self._raw_calls:
453
+ caller = qname_index.get(caller_qname)
454
+ if caller is None:
455
+ continue
456
+
457
+ file_id = file_id_index.get(file_path)
458
+ callee = self._resolve_single_call(
459
+ caller_qname, callee_name, file_path,
460
+ qname_index, name_index, import_alias_index,
461
+ )
462
+ if callee is None:
463
+ continue
464
+
465
+ edge = CallEdge(
466
+ caller_id=caller.id,
467
+ callee_id=callee.id,
468
+ file_id=file_id,
469
+ line_number=line,
470
+ )
471
+ if self.db.insert_call_edge(edge) is not None:
472
+ inserted += 1
473
+
474
+ self._raw_calls.clear()
475
+ return inserted
476
+
477
+ @staticmethod
478
+ def _resolve_single_call(
479
+ caller_qname: str,
480
+ callee_name: str,
481
+ file_path: str,
482
+ qname_index: dict[str, FunctionEntity],
483
+ name_index: dict[str, list[FunctionEntity]],
484
+ import_alias_index: dict[tuple[str, str], str],
485
+ ) -> FunctionEntity | None:
486
+ # Derive the module part of the caller's qualified name.
487
+ # For "pkg.mod.Class.method" the module is "pkg.mod".
488
+ caller_parts = caller_qname.rsplit(".", 1)
489
+ caller_module = caller_parts[0] if len(caller_parts) > 1 else ""
490
+ # If caller is a method, module is one level higher.
491
+ # e.g. "pkg.mod.MyClass.my_method" -> class_prefix = "pkg.mod.MyClass"
492
+ # We try the class prefix first for self.* calls.
493
+
494
+ # 1. self.<method> — resolve within the same class.
495
+ if "." in callee_name:
496
+ prefix, method_name = callee_name.rsplit(".", 1)
497
+ if prefix == "self":
498
+ # caller_qname is like "mod.Class.method"; class qname is "mod.Class"
499
+ class_qname = caller_qname.rsplit(".", 1)[0]
500
+ candidate_qname = f"{class_qname}.{method_name}"
501
+ if candidate_qname in qname_index:
502
+ return qname_index[candidate_qname]
503
+
504
+ # 3. module.func() — resolve via imports.
505
+ # prefix might be an import alias.
506
+ resolved_module = import_alias_index.get((file_path, prefix))
507
+ if resolved_module:
508
+ candidate_qname = f"{resolved_module}.{method_name}"
509
+ if candidate_qname in qname_index:
510
+ return qname_index[candidate_qname]
511
+
512
+ # Try direct dotted qualified name in the same module.
513
+ candidate_qname = f"{caller_module}.{callee_name}"
514
+ if candidate_qname in qname_index:
515
+ return qname_index[candidate_qname]
516
+
517
+ return None
518
+
519
+ # 2. Bare func() — same module first, then project-wide.
520
+ same_module_qname = f"{caller_module}.{callee_name}"
521
+ if same_module_qname in qname_index:
522
+ return qname_index[same_module_qname]
523
+
524
+ # Check if callee_name matches an import alias that points to a function.
525
+ resolved_module = import_alias_index.get((file_path, callee_name))
526
+ if resolved_module and resolved_module in qname_index:
527
+ return qname_index[resolved_module]
528
+
529
+ # Fall back to first project-wide match by bare name.
530
+ candidates = name_index.get(callee_name, [])
531
+ if len(candidates) == 1:
532
+ return candidates[0]
533
+
534
+ return None
535
+
536
+ # ------------------------------------------------------------------
537
+ # Cyclomatic complexity via radon
538
+ # ------------------------------------------------------------------
539
+
540
+ @staticmethod
541
+ def _compute_complexity(source: str) -> dict[str, int]:
542
+ """Return ``{function_name: complexity}`` for all functions in *source*.
543
+
544
+ Uses ``radon.complexity`` when available; returns an empty dict if
545
+ radon is not installed or the source cannot be analysed.
546
+ """
547
+ try:
548
+ from radon.complexity import cc_visit # type: ignore[import-untyped]
549
+ except ImportError:
550
+ logger.debug("radon is not installed; skipping complexity analysis")
551
+ return {}
552
+
553
+ try:
554
+ blocks = cc_visit(source)
555
+ except Exception:
556
+ return {}
557
+
558
+ result: dict[str, int] = {}
559
+ for block in blocks:
560
+ result[block.name] = block.complexity
561
+ return result
562
+
563
+ # ------------------------------------------------------------------
564
+ # File discovery
565
+ # ------------------------------------------------------------------
566
+
567
+ def _discover_files(self, root: Path) -> list[Path]:
568
+ """Recursively find ``.py`` files under *root*, respecting config."""
569
+ exclude_patterns = self.config.analysis.exclude_patterns
570
+ max_size = self.config.analysis.max_file_size_kb * 1024
571
+
572
+ results: list[Path] = []
573
+ for py_file in root.rglob("*.py"):
574
+ rel = str(py_file.relative_to(root))
575
+ if self._is_excluded(rel, exclude_patterns):
576
+ continue
577
+ try:
578
+ if py_file.stat().st_size > max_size:
579
+ logger.debug("Skipping oversized file: %s", py_file)
580
+ continue
581
+ except OSError:
582
+ continue
583
+ results.append(py_file)
584
+
585
+ return sorted(results)
586
+
587
+ @staticmethod
588
+ def _is_excluded(rel_path: str, patterns: list[str]) -> bool:
589
+ parts = rel_path.replace("\\", "/").split("/")
590
+ for pattern in patterns:
591
+ # Strip **/ wrappers to get the core directory name.
592
+ core = pattern.strip("*").strip("/")
593
+ if core and core in parts:
594
+ return True
595
+ if fnmatch.fnmatch(rel_path, pattern):
596
+ return True
597
+ return False
598
+
599
+ # ------------------------------------------------------------------
600
+ # Import resolution helpers
601
+ # ------------------------------------------------------------------
602
+
603
+ def _resolve_import_to_file(
604
+ self, module: str, project_root: str
605
+ ) -> FileEntity | None:
606
+ """Try to map an import module string to a file already in the DB.
607
+
608
+ Handles several forms:
609
+ - ``codezero.db`` — full dotted module
610
+ - ``db`` — bare module name
611
+ - ``db.Database`` — module.name where name is a class/function, not a
612
+ sub-module — we try the full path first, then strip the last
613
+ component and retry.
614
+ """
615
+ root = Path(project_root)
616
+
617
+ # Try progressively shorter suffixes of the dotted path. For each
618
+ # suffix also try dropping the last component (it may be a symbol name,
619
+ # not a sub-package).
620
+ parts = module.split(".")
621
+ attempts: list[list[str]] = []
622
+ for start in range(len(parts)):
623
+ sub = parts[start:]
624
+ if sub:
625
+ attempts.append(sub)
626
+ # Also try without the last component (e.g. "db.Database" -> "db")
627
+ if len(sub) > 1:
628
+ attempts.append(sub[:-1])
629
+
630
+ for sub in attempts:
631
+ candidates = [
632
+ root / Path(*sub).with_suffix(".py"),
633
+ root / Path(*sub) / "__init__.py",
634
+ ]
635
+ for candidate in candidates:
636
+ entity = self.db.get_file_by_path(str(candidate))
637
+ if entity is not None:
638
+ return entity
639
+ return None
640
+
641
+ @staticmethod
642
+ def _path_to_module(file_path: str) -> str:
643
+ """Convert a file path to a dotted module name.
644
+
645
+ ``/project/pkg/mod.py`` -> ``pkg.mod``
646
+ ``/project/pkg/__init__.py`` -> ``pkg``
647
+ """
648
+ p = Path(file_path)
649
+ parts = list(p.with_suffix("").parts)
650
+ if parts and parts[-1] == "__init__":
651
+ parts.pop()
652
+ return ".".join(parts[-3:]) if len(parts) > 3 else ".".join(parts)