mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
462 lines
18 KiB
Python
462 lines
18 KiB
Python
import ast
|
|
import hashlib
|
|
import builtins
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PythonGlobalsAnalysis:
|
|
used: list[str]
|
|
exported_with_types: list[dict[str, str]]
|
|
|
|
|
|
class LocalAssignmentCollector(ast.NodeVisitor):
|
|
def __init__(self) -> None:
|
|
self.locals: set[str] = set()
|
|
self.global_names: set[str] = set()
|
|
self.nonlocal_names: set[str] = set()
|
|
|
|
def visit_Global(self, node: ast.Global) -> None:
|
|
self.global_names.update(node.names)
|
|
|
|
def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
|
|
self.nonlocal_names.update(node.names)
|
|
|
|
def visit_Name(self, node: ast.Name) -> None:
|
|
if isinstance(node.ctx, ast.Store):
|
|
self.locals.add(node.id)
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self.locals.add(node.name)
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
self.locals.add(node.name)
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
self.locals.add(node.name)
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
return
|
|
|
|
def visit_ListComp(self, node: ast.ListComp) -> None:
|
|
return
|
|
|
|
def visit_SetComp(self, node: ast.SetComp) -> None:
|
|
return
|
|
|
|
def visit_DictComp(self, node: ast.DictComp) -> None:
|
|
return
|
|
|
|
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
|
|
return
|
|
|
|
def visit_Import(self, node: ast.Import) -> None:
|
|
for alias in node.names:
|
|
name = alias.asname or alias.name.split(".")[0]
|
|
self.locals.add(name)
|
|
|
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
for alias in node.names:
|
|
name = alias.asname or alias.name
|
|
self.locals.add(name)
|
|
|
|
|
|
def collect_scope_locals(body: list[ast.stmt]) -> set[str]:
|
|
collector = LocalAssignmentCollector()
|
|
for statement in body:
|
|
collector.visit(statement)
|
|
locals_set = collector.locals - collector.global_names - collector.nonlocal_names
|
|
return locals_set
|
|
|
|
|
|
def collect_arg_names(arguments: ast.arguments) -> set[str]:
|
|
names = {arg.arg for arg in arguments.args}
|
|
names.update({arg.arg for arg in arguments.posonlyargs})
|
|
names.update({arg.arg for arg in arguments.kwonlyargs})
|
|
if arguments.vararg:
|
|
names.add(arguments.vararg.arg)
|
|
if arguments.kwarg:
|
|
names.add(arguments.kwarg.arg)
|
|
return names
|
|
|
|
|
|
@dataclass
|
|
class Scope:
|
|
kind: str
|
|
locals: set[str]
|
|
|
|
|
|
class GlobalAnalyzer(ast.NodeVisitor):
|
|
def __init__(self, module_locals: set[str], builtins: set[str]) -> None:
|
|
self.module_locals = module_locals
|
|
self.builtins = builtins
|
|
self.used: set[str] = set()
|
|
self.scopes: list[Scope] = [Scope(kind="module", locals=module_locals)]
|
|
# Module names bound so far in execution order. At module (exec) scope a name is a
|
|
# local *from the point it is bound onwards* — reading it before that reads the injected
|
|
# namespace, so a read-before-bind name (df.columns = ...; df = df.assign(...)) is a
|
|
# genuine external input, unlike function scope where any assignment makes the name local.
|
|
self.module_assigned: set[str] = set()
|
|
|
|
def is_global_name(self, name: str) -> bool:
|
|
current_scope = self.scopes[-1]
|
|
if current_scope.kind == "class":
|
|
if name in current_scope.locals:
|
|
return False
|
|
return True
|
|
for scope in reversed(self.scopes):
|
|
if name in scope.locals:
|
|
return scope.kind == "module"
|
|
return True
|
|
|
|
def visit_Name(self, node: ast.Name) -> None:
|
|
if isinstance(node.ctx, ast.Load):
|
|
# A module-scope name already bound before this read is a local, not an input.
|
|
if self.is_global_name(node.id) and node.id not in self.builtins and node.id not in self.module_assigned:
|
|
self.used.add(node.id)
|
|
|
|
def visit_Module(self, node: ast.Module) -> None:
|
|
for statement in node.body:
|
|
self._visit_module_statement(statement)
|
|
|
|
def _visit_module_statement(self, statement: ast.stmt) -> None:
|
|
# Walk top-level statements in execution order, visiting reads before recording the
|
|
# bindings they introduce, so a name read before it is bound counts as an input.
|
|
if isinstance(statement, ast.Assign):
|
|
self.visit(statement.value)
|
|
for target in statement.targets:
|
|
self.visit(target) # attribute/subscript targets still *read* their base object
|
|
self._record_bindings(statement.targets)
|
|
elif isinstance(statement, ast.AnnAssign):
|
|
if statement.value is not None:
|
|
self.visit(statement.value)
|
|
self.visit(statement.annotation)
|
|
self.visit(statement.target)
|
|
if statement.value is not None:
|
|
self._record_bindings([statement.target])
|
|
elif isinstance(statement, ast.AugAssign):
|
|
self.visit(statement.value)
|
|
self.visit(statement.target)
|
|
self._record_bindings([statement.target])
|
|
elif isinstance(statement, ast.Import):
|
|
self.module_assigned.update(alias.asname or alias.name.split(".")[0] for alias in statement.names)
|
|
elif isinstance(statement, ast.ImportFrom):
|
|
self.module_assigned.update(alias.asname or alias.name for alias in statement.names)
|
|
elif isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
|
|
self.visit(statement)
|
|
self.module_assigned.add(statement.name)
|
|
elif isinstance(statement, ast.For | ast.AsyncFor):
|
|
self.visit(statement.iter)
|
|
self._record_bindings([statement.target]) # loop var is bound before the body runs
|
|
for child in [*statement.body, *statement.orelse]:
|
|
self._visit_module_statement(child)
|
|
elif isinstance(statement, ast.With | ast.AsyncWith):
|
|
for item in statement.items:
|
|
self.visit(item.context_expr)
|
|
if item.optional_vars is not None:
|
|
self._record_bindings([item.optional_vars])
|
|
for child in statement.body:
|
|
self._visit_module_statement(child)
|
|
elif isinstance(statement, ast.If | ast.While):
|
|
self.visit(statement.test)
|
|
for child in [*statement.body, *statement.orelse]:
|
|
self._visit_module_statement(child)
|
|
elif isinstance(statement, ast.Try):
|
|
for child in statement.body:
|
|
self._visit_module_statement(child)
|
|
for handler in statement.handlers:
|
|
if handler.type is not None:
|
|
self.visit(handler.type)
|
|
if handler.name is not None:
|
|
self.module_assigned.add(handler.name)
|
|
for child in handler.body:
|
|
self._visit_module_statement(child)
|
|
for child in [*statement.orelse, *statement.finalbody]:
|
|
self._visit_module_statement(child)
|
|
elif isinstance(statement, ast.Match):
|
|
self.visit(statement.subject)
|
|
for case in statement.cases:
|
|
# Capture patterns bind their names before the case body runs.
|
|
self.module_assigned.update(match_capture_names(case.pattern))
|
|
if case.guard is not None:
|
|
self.visit(case.guard)
|
|
for child in case.body:
|
|
self._visit_module_statement(child)
|
|
else:
|
|
self.visit(statement)
|
|
|
|
def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
|
|
# A walrus binds its target in the enclosing scope; at module scope record it so a later
|
|
# read (print(rows) after `if (rows := ...)`) isn't mistaken for an external input.
|
|
self.visit(node.value)
|
|
if len(self.scopes) == 1:
|
|
self.module_assigned.update(extract_target_names(node.target))
|
|
self.visit(node.target)
|
|
|
|
def _record_bindings(self, targets: list[ast.expr]) -> None:
|
|
for target in targets:
|
|
self.module_assigned.update(extract_target_names(target))
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self._visit_function_like(node, "function")
|
|
|
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
self._visit_function_like(node, "function")
|
|
|
|
def _visit_function_like(self, node: ast.AST, kind: str) -> None:
|
|
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
for decorator in node.decorator_list:
|
|
self.visit(decorator)
|
|
for default in node.args.defaults:
|
|
self.visit(default)
|
|
for kw_default in node.args.kw_defaults:
|
|
if kw_default is not None:
|
|
self.visit(kw_default)
|
|
for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
|
|
if arg.annotation:
|
|
self.visit(arg.annotation)
|
|
if node.args.vararg and node.args.vararg.annotation:
|
|
self.visit(node.args.vararg.annotation)
|
|
if node.args.kwarg and node.args.kwarg.annotation:
|
|
self.visit(node.args.kwarg.annotation)
|
|
if node.returns:
|
|
self.visit(node.returns)
|
|
if getattr(node, "type_params", None):
|
|
for param in node.type_params:
|
|
self.visit(param)
|
|
|
|
locals_set = collect_scope_locals(node.body)
|
|
locals_set.update(collect_arg_names(node.args))
|
|
self.scopes.append(Scope(kind=kind, locals=locals_set))
|
|
for statement in node.body:
|
|
self.visit(statement)
|
|
self.scopes.pop()
|
|
|
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
for base in node.bases:
|
|
self.visit(base)
|
|
for keyword in node.keywords:
|
|
self.visit(keyword)
|
|
for decorator in node.decorator_list:
|
|
self.visit(decorator)
|
|
if getattr(node, "type_params", None):
|
|
for param in node.type_params:
|
|
self.visit(param)
|
|
|
|
locals_set = collect_scope_locals(node.body)
|
|
self.scopes.append(Scope(kind="class", locals=locals_set))
|
|
for statement in node.body:
|
|
self.visit(statement)
|
|
self.scopes.pop()
|
|
|
|
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
locals_set = collect_arg_names(node.args)
|
|
self.scopes.append(Scope(kind="lambda", locals=locals_set))
|
|
self.visit(node.body)
|
|
self.scopes.pop()
|
|
|
|
def visit_ListComp(self, node: ast.ListComp) -> None:
|
|
self._visit_comprehension(node, "comprehension")
|
|
|
|
def visit_SetComp(self, node: ast.SetComp) -> None:
|
|
self._visit_comprehension(node, "comprehension")
|
|
|
|
def visit_DictComp(self, node: ast.DictComp) -> None:
|
|
self._visit_comprehension(node, "comprehension")
|
|
|
|
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
|
|
self._visit_comprehension(node, "comprehension")
|
|
|
|
def _visit_comprehension(self, node: ast.AST, kind: str) -> None:
|
|
generators = getattr(node, "generators", [])
|
|
locals_set: set[str] = set()
|
|
for generator in generators:
|
|
locals_set.update(extract_target_names(generator.target))
|
|
self.scopes.append(Scope(kind=kind, locals=locals_set))
|
|
if isinstance(node, ast.DictComp):
|
|
self.visit(node.key)
|
|
self.visit(node.value)
|
|
elif hasattr(node, "elt"):
|
|
self.visit(node.elt)
|
|
for generator in generators:
|
|
self.visit(generator.iter)
|
|
for if_node in generator.ifs:
|
|
self.visit(if_node)
|
|
self.scopes.pop()
|
|
|
|
|
|
def extract_target_names(target: ast.AST) -> set[str]:
|
|
names: set[str] = set()
|
|
if isinstance(target, ast.Name):
|
|
names.add(target.id)
|
|
elif isinstance(target, ast.Tuple | ast.List):
|
|
for item in target.elts:
|
|
names.update(extract_target_names(item))
|
|
elif isinstance(target, ast.Starred):
|
|
names.update(extract_target_names(target.value))
|
|
return names
|
|
|
|
|
|
def match_capture_names(pattern: ast.pattern) -> set[str]:
|
|
"""Names a match-case pattern binds — captures are locals, not external frames."""
|
|
names: set[str] = set()
|
|
if isinstance(pattern, ast.MatchAs):
|
|
if pattern.name is not None:
|
|
names.add(pattern.name)
|
|
if pattern.pattern is not None:
|
|
names |= match_capture_names(pattern.pattern)
|
|
elif isinstance(pattern, ast.MatchStar):
|
|
if pattern.name is not None:
|
|
names.add(pattern.name)
|
|
elif isinstance(pattern, ast.MatchMapping):
|
|
if pattern.rest is not None:
|
|
names.add(pattern.rest)
|
|
for sub in pattern.patterns:
|
|
names |= match_capture_names(sub)
|
|
elif isinstance(pattern, ast.MatchSequence | ast.MatchOr):
|
|
for sub in pattern.patterns:
|
|
names |= match_capture_names(sub)
|
|
elif isinstance(pattern, ast.MatchClass):
|
|
for sub in [*pattern.patterns, *pattern.kwd_patterns]:
|
|
names |= match_capture_names(sub)
|
|
return names
|
|
|
|
|
|
def annotation_to_string(annotation: ast.AST) -> str:
|
|
if hasattr(ast, "unparse"):
|
|
return ast.unparse(annotation)
|
|
return "unknown"
|
|
|
|
|
|
def dotted_name(value: ast.AST) -> str | None:
|
|
if isinstance(value, ast.Name):
|
|
return value.id
|
|
if isinstance(value, ast.Attribute):
|
|
base = dotted_name(value.value)
|
|
if base:
|
|
return f"{base}.{value.attr}"
|
|
return value.attr
|
|
return None
|
|
|
|
|
|
def infer_value_type(value: ast.AST) -> str:
|
|
if isinstance(value, ast.Constant):
|
|
if value.value is None:
|
|
return "None"
|
|
return type(value.value).__name__
|
|
if isinstance(value, ast.List | ast.ListComp):
|
|
return "list"
|
|
if isinstance(value, ast.Tuple):
|
|
return "tuple"
|
|
if isinstance(value, ast.Dict | ast.DictComp):
|
|
return "dict"
|
|
if isinstance(value, ast.Set | ast.SetComp):
|
|
return "set"
|
|
if isinstance(value, ast.GeneratorExp):
|
|
return "generator"
|
|
if isinstance(value, ast.Call):
|
|
call_name = dotted_name(value.func)
|
|
if call_name in {"list", "dict", "set", "tuple", "int", "float", "str", "bool"}:
|
|
return call_name
|
|
return "unknown"
|
|
|
|
|
|
def collect_exported_types(body: list[ast.stmt]) -> dict[str, str]:
|
|
exported_types: dict[str, str] = {}
|
|
|
|
for statement in body:
|
|
if isinstance(statement, ast.Assign):
|
|
type_name = infer_value_type(statement.value)
|
|
for target in statement.targets:
|
|
for name in extract_target_names(target):
|
|
exported_types[name] = type_name
|
|
elif isinstance(statement, ast.AnnAssign):
|
|
type_name = annotation_to_string(statement.annotation)
|
|
if type_name == "unknown" and statement.value:
|
|
type_name = infer_value_type(statement.value)
|
|
for name in extract_target_names(statement.target):
|
|
exported_types[name] = type_name
|
|
elif isinstance(statement, ast.AugAssign):
|
|
for name in extract_target_names(statement.target):
|
|
exported_types.setdefault(name, "unknown")
|
|
elif isinstance(statement, ast.Import):
|
|
for alias in statement.names:
|
|
name = alias.asname or alias.name.split(".")[0]
|
|
exported_types.setdefault(name, "module")
|
|
elif isinstance(statement, ast.ImportFrom):
|
|
for alias in statement.names:
|
|
name = alias.asname or alias.name
|
|
exported_types.setdefault(name, "module")
|
|
|
|
return exported_types
|
|
|
|
|
|
def analyze_python_globals(code: str) -> PythonGlobalsAnalysis:
|
|
if not code or not code.strip():
|
|
return PythonGlobalsAnalysis(used=[], exported_with_types=[])
|
|
|
|
try:
|
|
tree = ast.parse(code)
|
|
except SyntaxError:
|
|
return PythonGlobalsAnalysis(used=[], exported_with_types=[])
|
|
|
|
module_locals = collect_scope_locals(tree.body)
|
|
builtins_names = set(dir(builtins))
|
|
analyzer = GlobalAnalyzer(module_locals, builtins_names)
|
|
analyzer.visit(tree)
|
|
exported_types = collect_exported_types(tree.body)
|
|
exported_with_types = [
|
|
{"name": name, "type": exported_types.get(name, "unknown")} for name in sorted(module_locals)
|
|
]
|
|
|
|
return PythonGlobalsAnalysis(
|
|
used=sorted(analyzer.used),
|
|
exported_with_types=exported_with_types,
|
|
)
|
|
|
|
|
|
def compute_globals_analysis_hash(code: str) -> str:
|
|
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def annotate_python_nodes(content: Any) -> Any:
|
|
if not isinstance(content, dict):
|
|
return content
|
|
|
|
def walk(node: Any) -> Any:
|
|
if not isinstance(node, dict):
|
|
return node
|
|
|
|
node_type = node.get("type")
|
|
if node_type == "ph-python":
|
|
attrs = node.get("attrs")
|
|
if isinstance(attrs, dict):
|
|
code = attrs.get("code", "")
|
|
if isinstance(code, str):
|
|
code_hash = compute_globals_analysis_hash(code)
|
|
existing_hash = attrs.get("globalsAnalysisHash")
|
|
has_cached_analysis = (
|
|
isinstance(existing_hash, str)
|
|
and existing_hash == code_hash
|
|
and "globalsUsed" in attrs
|
|
and "globalsExportedWithTypes" in attrs
|
|
)
|
|
if not has_cached_analysis:
|
|
analysis = analyze_python_globals(code)
|
|
attrs = {
|
|
**attrs,
|
|
"globalsUsed": analysis.used,
|
|
"globalsExportedWithTypes": analysis.exported_with_types,
|
|
"globalsAnalysisHash": code_hash,
|
|
}
|
|
node = {**node, "attrs": attrs}
|
|
|
|
content_nodes = node.get("content")
|
|
if isinstance(content_nodes, list):
|
|
node = {**node, "content": [walk(child) for child in content_nodes]}
|
|
|
|
return node
|
|
|
|
return walk(content)
|