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 +49 -0
- pyspokes/_constants.py +4 -0
- pyspokes/bindings.py +147 -0
- pyspokes/cli/__init__.py +20 -0
- pyspokes/cli/app.py +44 -0
- pyspokes/cli/check.py +179 -0
- pyspokes/cli/executor.py +246 -0
- pyspokes/cli/inventory.py +14 -0
- pyspokes/cli/scanner.py +125 -0
- pyspokes/config.py +33 -0
- pyspokes/definitions.py +87 -0
- pyspokes/discovery.py +245 -0
- pyspokes/exceptions.py +25 -0
- pyspokes/hub.py +334 -0
- pyspokes/inventory.py +139 -0
- pyspokes/py.typed +0 -0
- pyspokes/resolution_log.py +41 -0
- pyspokes/resolutions.py +83 -0
- pyspokes-0.1.0a2.dist-info/METADATA +120 -0
- pyspokes-0.1.0a2.dist-info/RECORD +22 -0
- pyspokes-0.1.0a2.dist-info/WHEEL +4 -0
- pyspokes-0.1.0a2.dist-info/entry_points.txt +3 -0
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
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__()
|
pyspokes/cli/__init__.py
ADDED
|
@@ -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
|
pyspokes/cli/executor.py
ADDED
|
@@ -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
|