pyspokes 0.1.0a2__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.
pyspokes/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ from ._constants import LOGGER_NAME
2
+ from .bindings import SpokeBinding, SpokeDef
3
+ from .exceptions import (
4
+ PySpokesError,
5
+ SpokeDefinitionError,
6
+ SpokeLoadError,
7
+ SpokeNotFoundError,
8
+ SpokeRawMismatchError,
9
+ )
10
+ from .hub import (
11
+ UNNAMED,
12
+ Hub,
13
+ disable_discovery,
14
+ get_resolutions,
15
+ get_spoke,
16
+ get_spokes,
17
+ hub,
18
+ register_spoke,
19
+ spokes_by_name,
20
+ track_resolutions,
21
+ )
22
+ from .resolutions import Resolution
23
+
24
+ __all__ = [
25
+ # Resolving and registering spokes
26
+ "get_spoke",
27
+ "get_spokes",
28
+ "register_spoke",
29
+ "disable_discovery",
30
+ "spokes_by_name",
31
+ "UNNAMED",
32
+ # Resolution tracking
33
+ "get_resolutions",
34
+ "track_resolutions",
35
+ "Resolution",
36
+ # Types
37
+ "SpokeDef",
38
+ "SpokeBinding",
39
+ "Hub",
40
+ "hub",
41
+ # Errors
42
+ "PySpokesError",
43
+ "SpokeDefinitionError",
44
+ "SpokeLoadError",
45
+ "SpokeNotFoundError",
46
+ "SpokeRawMismatchError",
47
+ # Logging
48
+ "LOGGER_NAME",
49
+ ]
pyspokes/_constants.py ADDED
@@ -0,0 +1,4 @@
1
+ """Shared package constants."""
2
+
3
+ ENV_VARS_PREFIX = "PYSPOKES_"
4
+ LOGGER_NAME = "pyspokes"
pyspokes/bindings.py ADDED
@@ -0,0 +1,147 @@
1
+ """Core spoke definitions and runtime wrappers."""
2
+
3
+ import importlib
4
+ import inspect
5
+ import logging
6
+ import time
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from typing import Any, Generic, TypeVar
10
+
11
+ from ._constants import LOGGER_NAME
12
+ from .exceptions import SpokeLoadError
13
+
14
+ logger = logging.getLogger(LOGGER_NAME)
15
+
16
+
17
+ @dataclass
18
+ class SpokeDef:
19
+ """One spoke declaration, as written in ``spokes.yaml`` or passed to ``register_spoke``.
20
+
21
+ :param path: Location of the class or function, relative to its package.
22
+ :param name: A label requests can filter on.
23
+ :param priority: Higher wins when several spokes match the same request.
24
+ :param raw: Hand out the class or function itself instead of calling it.
25
+ """
26
+
27
+ path: str
28
+ name: str | None = None
29
+ priority: int = 1
30
+ raw: bool = False
31
+
32
+
33
+ T = TypeVar("T")
34
+
35
+
36
+ class SpokeBinding(Generic[T]):
37
+ """A declaration bound to the class or function it points to.
38
+
39
+ :ivar def_: The declaration.
40
+ :ivar module: The package that provides the spoke.
41
+ :ivar class_or_method: The imported class or function.
42
+ :ivar type_: The type the spoke answers to.
43
+ """
44
+
45
+ def __init__(self, spoke_def: SpokeDef, module: str, class_or_method: type | Callable | None = None):
46
+ """
47
+ Create a SpokeBinding instance.
48
+
49
+ :param spoke_def: The spoke definition
50
+ :param module: The name of the module where the spoke is defined
51
+ :param class_or_method: Optional direct reference to the class or method (for __main__ module)
52
+ """
53
+ self._obj: Any = None
54
+ self.type_: type[T] | None = None
55
+ self.class_or_method: Callable[..., Any]
56
+ self.def_: SpokeDef = spoke_def
57
+ self.module = module
58
+
59
+ names = spoke_def.path.rsplit(".", 1)
60
+ module = module + ("." + names[0] if len(names) > 1 else "")
61
+
62
+ try:
63
+ start = time.time()
64
+ if class_or_method is not None:
65
+ self.class_or_method = class_or_method
66
+ logger.debug(f"registering {class_or_method} by direct reference")
67
+ else:
68
+ self.class_or_method = getattr(importlib.import_module(module), names[-1])
69
+ logger.debug(f"imported {module} in {time.time() - start:.3f}s")
70
+ self.type_ = SpokeBinding.get_type(self.class_or_method)
71
+ obj = None
72
+ if not self.type_:
73
+ obj = self.class_or_method()
74
+
75
+ if obj is not None:
76
+ self._obj = obj
77
+ self.type_ = SpokeBinding.get_type(type(obj))
78
+ logger.warning(
79
+ "%s has no return annotation, so it was called at registration time to find its type. "
80
+ "Annotate its return type to defer the call until the spoke is requested.",
81
+ self.class_or_method,
82
+ )
83
+
84
+ logger.debug(f"resolved spoke target {self.class_or_method}")
85
+ except Exception as error:
86
+ raise SpokeLoadError(f"Cannot import {names[-1]} from {module}: {type(error).__name__}: {error}") from error
87
+
88
+ def get(self, singleton: bool = True) -> Any:
89
+ """Return the spoke's value, building it on first use; a generator yields its next item."""
90
+ result: Any = None
91
+ if singleton and self._obj is not None:
92
+ result = self._obj
93
+
94
+ if result is None:
95
+ result = self.class_or_method if self.def_.raw else self.class_or_method()
96
+
97
+ if singleton:
98
+ self._obj = result
99
+
100
+ if inspect.isgenerator(result):
101
+ return next(result)
102
+
103
+ return result
104
+
105
+ def matches(self, target_type: type) -> bool:
106
+ """Return whether this spoke can answer a request for ``target_type``."""
107
+ if self.type_ == target_type:
108
+ return True
109
+ # Special case: if target_type is 'type', match any class
110
+ if target_type is type and inspect.isclass(self.type_):
111
+ return True
112
+ try:
113
+ spoke_type = SpokeBinding.get_type(self.class_or_method)
114
+ if spoke_type and inspect.isclass(spoke_type) and inspect.isclass(target_type):
115
+ return issubclass(spoke_type, target_type)
116
+ except TypeError:
117
+ # issubclass raises TypeError for non-class types
118
+ pass
119
+ return False
120
+
121
+ @staticmethod
122
+ def get_type(spoke):
123
+ """Return the type represented by a spoke class, function, or instance."""
124
+ if inspect.isclass(spoke):
125
+ return spoke
126
+ if inspect.isfunction(spoke) or inspect.ismethod(spoke):
127
+ signature = inspect.signature(spoke)
128
+ return None if signature.return_annotation is inspect.Signature.empty else signature.return_annotation
129
+ return type(spoke)
130
+
131
+ def __str__(self):
132
+ """Return the spoke metadata as a formatted string."""
133
+ return (
134
+ f"Module: {self.module}\n"
135
+ f"Class or Method: {self.class_or_method}\n"
136
+ f"Type: {self.type_}\n"
137
+ f"Object: {self._obj}\n"
138
+ f"SpokeBinding Definition:\n"
139
+ f" Name: {self.def_.name}\n"
140
+ f" Path: {self.def_.path}\n"
141
+ f" Priority: {self.def_.priority}\n"
142
+ f" Raw: {self.def_.raw}\n"
143
+ )
144
+
145
+ def __repr__(self):
146
+ """Return the official string representation."""
147
+ return self.__str__()
@@ -0,0 +1,20 @@
1
+ """Command-line interface for pyspokes.
2
+
3
+ The CLI needs the optional ``cli`` extra (``pip install "pyspokes[cli]"``). Nothing in this
4
+ package is imported by the core library, so ``import pyspokes`` never requires it.
5
+ """
6
+
7
+ import sys
8
+
9
+ _CLI_DEPENDENCIES = {"typer", "rich", "click"}
10
+
11
+
12
+ def main():
13
+ """Main entry point for the CLI."""
14
+ try:
15
+ from .app import run
16
+ except ModuleNotFoundError as error:
17
+ if error.name is None or error.name.split(".")[0] not in _CLI_DEPENDENCIES:
18
+ raise
19
+ sys.exit(f"The pyspokes command line needs the 'cli' extra ({error.name} is not installed).\nInstall it with: pip install \"pyspokes[cli]\"")
20
+ run()
pyspokes/cli/app.py ADDED
@@ -0,0 +1,44 @@
1
+ """Typer application wiring the pyspokes commands together."""
2
+
3
+ import logging
4
+ from logging import ERROR
5
+
6
+ import typer
7
+
8
+ from pyspokes._constants import LOGGER_NAME
9
+
10
+ from .check import check_spoke as check_spoke_cmd
11
+ from .inventory import inventory as inventory_cmd
12
+
13
+ cli_app = typer.Typer()
14
+
15
+ logger = logging.getLogger(LOGGER_NAME)
16
+ if not logger.hasHandlers():
17
+ handler = logging.StreamHandler()
18
+ formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
19
+ handler.setFormatter(formatter)
20
+ logger.addHandler(handler)
21
+
22
+ # Register commands
23
+ cli_app.command()(inventory_cmd)
24
+ cli_app.command(name="check")(check_spoke_cmd)
25
+
26
+
27
+ @cli_app.command(name="status")
28
+ def status_cmd(
29
+ path: str | None = typer.Argument(None, help="Path to scan for spoke invocations. Defaults to current path."),
30
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
31
+ ):
32
+ """Check spoke status (alias for check --format table)."""
33
+ return check_spoke_cmd(path=path, format="table", verbose=verbose)
34
+
35
+
36
+ def run():
37
+ """Run the CLI application."""
38
+ # Keep CLI logging quiet without changing the library logger during import.
39
+ logger.setLevel(ERROR)
40
+ cli_app()
41
+
42
+
43
+ if __name__ == "__main__":
44
+ run()
pyspokes/cli/check.py ADDED
@@ -0,0 +1,179 @@
1
+ """Check-spoke command for scanning spoke invocations."""
2
+
3
+ import inspect
4
+ import json
5
+ import os
6
+ import traceback
7
+ from pathlib import Path
8
+ from typing import Annotated, Any, Literal
9
+
10
+ import typer
11
+
12
+ from .executor import execute_invocation
13
+ from .scanner import find_spoke_invocations, get_python_files
14
+
15
+
16
+ def check_spoke(
17
+ path: str | None = typer.Argument(None, help="Path to scan for spoke invocations. Defaults to current path."),
18
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed invocation report"),
19
+ format: Annotated[Literal["json", "table"], typer.Option("--format", "-f", help="Output format: 'json' or 'table'", show_default=True)] = "json",
20
+ ):
21
+ """
22
+ Scan Python files for spoke retrievals in a given path and report them.
23
+
24
+ This command recursively scans Python files in the specified path (or current directory)
25
+ looking for invocations of get_spoke or get_spokes from pyspokes.
26
+ For each invocation found, it:
27
+ - Reports the file, line number, function name, and arguments
28
+ - Executes the invocation with raw=True to get the actual spoke
29
+ - Includes the result or any error in the report
30
+
31
+ Note: resolving the requested types requires executing the scanned files,
32
+ so only run this against code you trust.
33
+ """
34
+ if path is None:
35
+ path = os.getcwd()
36
+
37
+ scan_path = Path(path)
38
+ if not scan_path.exists():
39
+ typer.echo(f"Error: Path '{path}' does not exist.", err=True)
40
+ raise typer.Exit(code=1)
41
+
42
+ report = _build_report(scan_path, verbose=verbose)
43
+
44
+ if format == "json":
45
+ _render_json(report)
46
+ elif format == "table":
47
+ _render_table(report, verbose=verbose)
48
+
49
+
50
+ def _build_report(scan_path: Path, verbose: bool) -> dict:
51
+ """Scan for invocations, execute each one, and assemble the report."""
52
+ invocations = find_spoke_invocations(scan_path)
53
+
54
+ report: dict[str, Any] = {
55
+ "scan_path": str(scan_path),
56
+ "total_files_scanned": len(get_python_files(scan_path)),
57
+ "total_invocations": len(invocations),
58
+ "invocations": [],
59
+ }
60
+
61
+ namespace_cache: dict[str, dict[str, Any]] = {}
62
+ for inv in invocations:
63
+ requested_type_docstring = None
64
+ try:
65
+ execution_result = execute_invocation(inv, namespace_cache)
66
+ result = execution_result["result"]
67
+ had_default = execution_result["had_default"]
68
+ requested_type = execution_result.get("requested_type")
69
+ status = "success"
70
+ used_default = result is None and had_default
71
+ error = None
72
+ result_field = (
73
+ {
74
+ "type": "default",
75
+ "message": "No spoke found, would use default value",
76
+ }
77
+ if used_default
78
+ else _serialize_result(result)
79
+ )
80
+ # Extract docstring from requested type
81
+ if requested_type is not None:
82
+ requested_type_docstring = _get_docstring(requested_type)
83
+ except Exception as e:
84
+ status = "error"
85
+ error = {"type": type(e).__name__, "message": str(e)}
86
+ if verbose:
87
+ error["traceback"] = traceback.format_exc()
88
+
89
+ used_default = False
90
+ result_field = None
91
+ # Unified report logic
92
+ inv_report = {
93
+ "location": f"{inv['file']}:{inv['line']}",
94
+ "status": status,
95
+ "result": result_field,
96
+ "error": error,
97
+ "used_default": used_default,
98
+ "args": inv["args"],
99
+ }
100
+ if verbose:
101
+ inv_report.update({"requested_type_docstring": requested_type_docstring, "function": inv["function"], "local_name": inv["local_name"]})
102
+ report["invocations"].append(inv_report)
103
+
104
+ return report
105
+
106
+
107
+ def _render_json(report: dict) -> None:
108
+ """Print the report as indented JSON."""
109
+ print(json.dumps(report, indent=2))
110
+
111
+
112
+ def _render_table(report: dict, verbose: bool) -> None:
113
+ """Print the report as a rich table."""
114
+ from rich.console import Console
115
+ from rich.table import Table
116
+
117
+ console = Console()
118
+ table = Table(*(["Spoke type", "Required", "Has default", "Name", "Status"] + (["Docstring"] if verbose else [])))
119
+ for invocation in report["invocations"]:
120
+ args = invocation.get("args", {})
121
+ req_spoke = (args.get("positional") or ["N/A"])[0]
122
+ docstring = invocation.get("requested_type_docstring", "N/A")
123
+ if docstring is None:
124
+ docstring = "No docstring"
125
+ is_required = args.get("keyword", {}).get("required", False)
126
+ has_default = "default" in args.get("keyword", {})
127
+ table.add_row(
128
+ *(
129
+ [
130
+ str(req_spoke),
131
+ f"[bold yellow]{str(is_required)}[/bold yellow]" if is_required else str(is_required),
132
+ str(has_default),
133
+ args.get("keyword", {}).get("name", "N/A"),
134
+ f"[bold red]{invocation['status']}[/bold red]"
135
+ if invocation["status"] == "error"
136
+ else f"[bold green]{invocation['status']}[/bold green]",
137
+ ]
138
+ + ([docstring] if verbose else [])
139
+ )
140
+ )
141
+ console.print(table)
142
+
143
+
144
+ def _serialize_result(obj: Any) -> Any:
145
+ """Serialize the result for JSON output. Handles various types that might be returned."""
146
+ if obj is None:
147
+ return None
148
+ if isinstance(obj, list):
149
+ return [_serialize_result(r) for r in obj]
150
+ if isinstance(obj, type):
151
+ return {"type": "class", "name": obj.__name__, "module": obj.__module__, "qualname": obj.__qualname__}
152
+ elif callable(obj):
153
+ return {
154
+ "type": "callable",
155
+ "name": getattr(obj, "__name__", str(obj)),
156
+ "module": getattr(obj, "__module__", "unknown"),
157
+ "qualname": getattr(obj, "__qualname__", str(obj)),
158
+ }
159
+ else:
160
+ return {"type": "object", "class": type(obj).__name__, "module": type(obj).__module__, "repr": repr(obj)}
161
+
162
+
163
+ def _get_docstring(obj: Any) -> str | None:
164
+ """
165
+ Extract and clean the docstring from an object.
166
+
167
+ Returns the docstring if available, otherwise None.
168
+ Only returns the object's own docstring, not inherited ones.
169
+ """
170
+ try:
171
+ # Use __doc__ directly to avoid getting inherited docstrings
172
+ # inspect.getdoc() would return parent class docstrings
173
+ doc = obj.__doc__
174
+ if doc:
175
+ # Clean up the docstring like inspect.getdoc() does
176
+ return inspect.cleandoc(doc)
177
+ return None
178
+ except Exception:
179
+ return None
@@ -0,0 +1,246 @@
1
+ """Execute scanned spoke invocations to verify they resolve.
2
+
3
+ Note: resolving the types used at a call site requires importing the scanned
4
+ file's dependencies and executing the file itself, so running this against a
5
+ path executes the code found there.
6
+ """
7
+
8
+ import ast
9
+ import builtins
10
+ import inspect
11
+ import logging
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pyspokes._constants import LOGGER_NAME
17
+ from pyspokes.hub import get_spoke, get_spokes
18
+
19
+ logger = logging.getLogger(LOGGER_NAME)
20
+
21
+ # Centralized function name mapping
22
+ SPOKE_FUNC_MAP: dict[str, Callable[..., Any]] = {"get_spoke": get_spoke, "get_spokes": get_spokes}
23
+
24
+
25
+ def execute_invocation(invocation: dict, namespace_cache: dict[str, dict[str, Any]] | None = None) -> dict[str, Any]:
26
+ """
27
+ Execute a spoke invocation with raw=True and no default.
28
+
29
+ This recreates the call but ensures raw=True is set and removes the default parameter.
30
+ Returns a dict with 'result' and 'had_default' keys.
31
+ Note: Dynamically imports types from the file to resolve custom classes.
32
+ Pass a ``namespace_cache`` dict to reuse file namespaces across invocations
33
+ of one scan, so a file with several invocations is only executed once.
34
+ """
35
+ func_name = invocation["function"]
36
+ func = SPOKE_FUNC_MAP.get(func_name)
37
+ if not func:
38
+ raise ValueError(f"Unknown function: {func_name}")
39
+
40
+ # Copy builtins so evaluated arguments can't leak names into the real builtins module
41
+ safe_namespace = dict(vars(builtins))
42
+
43
+ # Add imports from the file being analyzed
44
+ if namespace_cache is not None and invocation["file"] in namespace_cache:
45
+ file_namespace = namespace_cache[invocation["file"]]
46
+ else:
47
+ file_namespace = _build_file_namespace(invocation["file"])
48
+ if namespace_cache is not None:
49
+ namespace_cache[invocation["file"]] = file_namespace
50
+ safe_namespace.update(file_namespace)
51
+
52
+ # Parse positional arguments
53
+ args = []
54
+ requested_type = None # Track the first argument (requested type)
55
+ for i, arg_str in enumerate(invocation["args"]["positional"]):
56
+ try:
57
+ arg_value = ast.literal_eval(arg_str)
58
+ args.append(arg_value)
59
+ except (ValueError, SyntaxError):
60
+ try:
61
+ arg_value = eval(arg_str, {"__builtins__": safe_namespace}, safe_namespace)
62
+ args.append(arg_value)
63
+ # Capture the first positional argument as the requested type
64
+ if i == 0:
65
+ requested_type = arg_value
66
+ except Exception as e:
67
+ raise ValueError(f"Cannot evaluate positional argument '{arg_str}'. " f"Error: {e}") from e
68
+
69
+ # Parse keyword arguments
70
+ kwargs = {}
71
+ had_default = False
72
+ original_default = None
73
+
74
+ for key, value_str in invocation["args"]["keyword"].items():
75
+ if key == "default":
76
+ had_default = True
77
+ original_default = value_str
78
+ continue
79
+ try:
80
+ kwargs[key] = ast.literal_eval(value_str)
81
+ except (ValueError, SyntaxError):
82
+ try:
83
+ kwargs[key] = eval(value_str, {"__builtins__": safe_namespace}, safe_namespace)
84
+ except Exception:
85
+ kwargs[key] = value_str
86
+
87
+ kwargs["raw"] = True
88
+ result = func(*args, **kwargs)
89
+ return {"result": result, "had_default": had_default, "original_default": original_default, "requested_type": requested_type}
90
+
91
+
92
+ def _build_file_namespace(filepath: str) -> dict[str, Any]:
93
+ """
94
+ Build a namespace dictionary from the imports in a Python file.
95
+
96
+ This allows us to resolve custom types used in get_spoke calls.
97
+ """
98
+ import sys
99
+
100
+ namespace: dict[str, Any] = {}
101
+
102
+ try:
103
+ with open(filepath, encoding="utf-8") as f:
104
+ source = f.read()
105
+
106
+ tree = ast.parse(source, filename=filepath)
107
+
108
+ # Determine the package path for resolving relative imports
109
+ file_path = Path(filepath).resolve()
110
+ package_path = _get_package_path(file_path)
111
+
112
+ # Add package parent to sys.path temporarily to enable imports
113
+ path_added = False
114
+ if package_path and package_path.parent not in [Path(p) for p in sys.path]:
115
+ sys.path.insert(0, str(package_path.parent))
116
+ path_added = True
117
+
118
+ try:
119
+ # First, add classes and functions defined in the file itself
120
+ # We need to actually execute the code to get the class/function objects
121
+ try:
122
+ exec_namespace: dict[str, Any] = {}
123
+ exec(compile(tree, filepath, "exec"), exec_namespace)
124
+ # Add all classes and functions to namespace
125
+ for name, obj in exec_namespace.items():
126
+ if not name.startswith("_") and (inspect.isclass(obj) or inspect.isfunction(obj)):
127
+ namespace[name] = obj
128
+ except Exception as e:
129
+ logger.debug(f"Could not execute file to extract classes/functions: {e}")
130
+
131
+ for node in ast.walk(tree):
132
+ # Handle "from module import name" and "from module import name as alias"
133
+ if isinstance(node, ast.ImportFrom):
134
+ module_name = node.module
135
+
136
+ # Handle relative imports
137
+ if node.level > 0:
138
+ # This is a relative import
139
+ module_name = _resolve_relative_import(file_path, package_path, node.level, node.module)
140
+ if not module_name:
141
+ # Could not resolve relative import
142
+ continue
143
+
144
+ if module_name:
145
+ try:
146
+ module = __import__(module_name, fromlist=[alias.name for alias in node.names])
147
+ for alias in node.names:
148
+ if alias.name == "*":
149
+ # Skip wildcard imports for safety
150
+ continue
151
+ try:
152
+ obj = getattr(module, alias.name)
153
+ local_name = alias.asname if alias.asname else alias.name
154
+ namespace[local_name] = obj
155
+ except AttributeError:
156
+ # Name doesn't exist in module, skip
157
+ continue
158
+ except (ImportError, ModuleNotFoundError) as e:
159
+ # Module can't be imported, skip
160
+ logger.debug(f"Could not import {module_name}: {e}")
161
+ continue
162
+
163
+ # Handle "import module" and "import module as alias"
164
+ elif isinstance(node, ast.Import):
165
+ for alias in node.names:
166
+ try:
167
+ module = __import__(alias.name)
168
+ local_name = alias.asname if alias.asname else alias.name
169
+ namespace[local_name] = module
170
+ except (ImportError, ModuleNotFoundError):
171
+ # Module can't be imported, skip
172
+ continue
173
+ finally:
174
+ # Clean up sys.path
175
+ if path_added:
176
+ sys.path.remove(str(package_path.parent))
177
+
178
+ except Exception as e:
179
+ # If we can't build namespace, just return empty dict
180
+ logger.debug(f"Could not build namespace for {filepath}: {e}")
181
+
182
+ return namespace
183
+
184
+
185
+ def _get_package_path(file_path: Path) -> Path:
186
+ """
187
+ Get the package path for a Python file by walking up the directory tree
188
+ until we find a directory without __init__.py.
189
+ """
190
+ current = file_path.parent
191
+ package_root = current
192
+
193
+ while current.parent != current: # Not at filesystem root
194
+ parent = current.parent
195
+ init_file = parent / "__init__.py"
196
+ if not init_file.exists():
197
+ break
198
+ package_root = parent
199
+ current = parent
200
+
201
+ return package_root
202
+
203
+
204
+ def _resolve_relative_import(file_path: Path, package_path: Path | None, level: int, module: str | None) -> str | None:
205
+ """
206
+ Resolve a relative import to an absolute module name.
207
+
208
+ Args:
209
+ file_path: Path to the file containing the import
210
+ package_path: Root path of the package
211
+ level: Number of dots in the relative import (1 for ., 2 for .., etc.)
212
+ module: The module name after the dots (can be None for imports like "from . import foo")
213
+
214
+ Returns:
215
+ Absolute module name, or None if it cannot be resolved
216
+ """
217
+ if not package_path:
218
+ return None
219
+
220
+ # Start from the directory containing the file
221
+ current = file_path.parent
222
+
223
+ # Go up 'level' directories (level 1 means current package, level 2 means parent, etc.)
224
+ for _ in range(level - 1):
225
+ if current.parent == current: # At filesystem root
226
+ return None
227
+ current = current.parent
228
+
229
+ # Build the module path from the package root
230
+ try:
231
+ relative_to_package = current.relative_to(package_path.parent)
232
+ parts = list(relative_to_package.parts)
233
+
234
+ # Add the module name if provided
235
+ if module:
236
+ parts.append(module)
237
+
238
+ # Convert path parts to module name
239
+ if parts:
240
+ return ".".join(parts)
241
+ else:
242
+ # This shouldn't happen, but return package name as fallback
243
+ return package_path.name if package_path else None
244
+ except ValueError:
245
+ # current is not relative to package_path.parent
246
+ return None