Some checks failed
CI / skinny-install (aco) (push) Successful in 1m18s
CI / skinny-install (api) (push) Successful in 40s
CI / skinny-install (bcda) (push) Successful in 35s
CI / skinny-install (bib) (push) Successful in 38s
CI / skinny-install (cli) (push) Successful in 46s
CI / skinny-install (conf) (push) Successful in 36s
CI / skinny-install (opps) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 47s
CI / skinny-install (rex) (push) Successful in 35s
Infra CI / notebooks (push) Successful in 3m17s
CI / lint-test (push) Failing after 3m30s
CI / skinny-install (bls) (push) Successful in 34s
CI / skinny-install (ccw) (push) Successful in 45s
CI / skinny-install (cms) (push) Successful in 32s
CI / skinny-install (perf) (push) Successful in 43s
Deploy / build-scan-report (push) Has been cancelled
Infra CI / docs (push) Failing after 20s
Infra CI / api (push) Successful in 16s
Infra CI / mc (push) Successful in 12s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m27s
Infra CI / zotero (push) Successful in 6m10s
229 lines
7.2 KiB
Python
229 lines
7.2 KiB
Python
"""Tests for sem.parse — AST to semantic nodes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import textwrap
|
|
|
|
from sem.nodes import NodeKind
|
|
from sem.parse import parse_tree
|
|
|
|
|
|
def _parse(source: str, module: str = "test_mod") -> list:
|
|
source = textwrap.dedent(source)
|
|
tree = ast.parse(source)
|
|
return parse_tree(tree, module, source)
|
|
|
|
|
|
class TestFunctionNodes:
|
|
def test_top_level_function(self):
|
|
nodes = _parse("""
|
|
def greet(name):
|
|
return f"hi {name}"
|
|
""")
|
|
funcs = [n for n in nodes if n.kind == NodeKind.FUNCTION]
|
|
assert len(funcs) == 1
|
|
assert "greet" in funcs[0].node_id
|
|
assert funcs[0].module == "test_mod"
|
|
|
|
def test_method_inside_class(self):
|
|
nodes = _parse("""
|
|
class Foo:
|
|
def bar(self):
|
|
pass
|
|
""")
|
|
classes = [n for n in nodes if n.kind == NodeKind.CLASS]
|
|
assert len(classes) == 1
|
|
# The method is nested inside the class context
|
|
methods = [n for n in nodes if n.kind in (NodeKind.FUNCTION, NodeKind.METHOD)]
|
|
assert len(methods) == 1
|
|
assert "Foo" in methods[0].node_id
|
|
assert "bar" in methods[0].node_id
|
|
|
|
def test_async_function(self):
|
|
nodes = _parse("""
|
|
async def fetch():
|
|
return 42
|
|
""")
|
|
funcs = [n for n in nodes if n.kind == NodeKind.FUNCTION]
|
|
assert len(funcs) == 1
|
|
assert "fetch" in funcs[0].node_id
|
|
|
|
|
|
class TestBranchNodes:
|
|
def test_if_else(self):
|
|
nodes = _parse("""
|
|
def check(x):
|
|
if x > 0:
|
|
return "pos"
|
|
else:
|
|
return "neg"
|
|
""")
|
|
ifs = [n for n in nodes if n.kind == NodeKind.BRANCH_IF]
|
|
elses = [n for n in nodes if n.kind == NodeKind.BRANCH_ELSE]
|
|
assert len(ifs) == 1
|
|
assert len(elses) == 1
|
|
|
|
def test_elif(self):
|
|
nodes = _parse("""
|
|
def classify(x):
|
|
if x > 0:
|
|
return "pos"
|
|
elif x == 0:
|
|
return "zero"
|
|
else:
|
|
return "neg"
|
|
""")
|
|
elifs = [n for n in nodes if n.kind == NodeKind.BRANCH_ELIF]
|
|
assert len(elifs) == 1
|
|
|
|
def test_match_case(self):
|
|
nodes = _parse("""
|
|
def route(cmd):
|
|
match cmd:
|
|
case "start":
|
|
pass
|
|
case "stop":
|
|
pass
|
|
case _:
|
|
pass
|
|
""")
|
|
cases = [n for n in nodes if n.kind == NodeKind.MATCH_CASE]
|
|
assert len(cases) == 3
|
|
|
|
|
|
class TestExceptionNodes:
|
|
def test_except_handler(self):
|
|
nodes = _parse("""
|
|
def risky():
|
|
try:
|
|
open("f")
|
|
except FileNotFoundError:
|
|
pass
|
|
except ValueError:
|
|
pass
|
|
""")
|
|
handlers = [n for n in nodes if n.kind == NodeKind.EXCEPT_HANDLER]
|
|
assert len(handlers) == 2
|
|
|
|
|
|
class TestControlFlow:
|
|
def test_for_loop(self):
|
|
nodes = _parse("""
|
|
for i in range(10):
|
|
print(i)
|
|
""")
|
|
loops = [n for n in nodes if n.kind == NodeKind.FOR_LOOP]
|
|
assert len(loops) == 1
|
|
|
|
def test_async_for_loop(self):
|
|
nodes = _parse("""
|
|
async def fetch_all():
|
|
async for item in aiter():
|
|
process(item)
|
|
""")
|
|
loops = [n for n in nodes if n.kind == NodeKind.FOR_LOOP]
|
|
assert len(loops) == 1
|
|
|
|
def test_with_block(self):
|
|
nodes = _parse("""
|
|
def process():
|
|
with open("f") as fh:
|
|
fh.read()
|
|
""")
|
|
withs = [n for n in nodes if n.kind == NodeKind.WITH_BLOCK]
|
|
assert len(withs) == 1
|
|
|
|
def test_async_with_block(self):
|
|
nodes = _parse("""
|
|
async def process():
|
|
async with aopen("f") as fh:
|
|
await fh.read()
|
|
""")
|
|
withs = [n for n in nodes if n.kind == NodeKind.WITH_BLOCK]
|
|
assert len(withs) == 1
|
|
|
|
def test_assert_node(self):
|
|
nodes = _parse("""
|
|
def check(x):
|
|
assert x > 0, "must be positive"
|
|
""")
|
|
asserts = [n for n in nodes if n.kind == NodeKind.ASSERT]
|
|
assert len(asserts) == 1
|
|
|
|
def test_while_loop(self):
|
|
nodes = _parse("""
|
|
while True:
|
|
break
|
|
""")
|
|
loops = [n for n in nodes if n.kind == NodeKind.WHILE_LOOP]
|
|
assert len(loops) == 1
|
|
|
|
def test_return_and_raise(self):
|
|
nodes = _parse("""
|
|
def f(x):
|
|
if x:
|
|
return 1
|
|
raise ValueError("bad")
|
|
""")
|
|
returns = [n for n in nodes if n.kind == NodeKind.RETURN]
|
|
raises = [n for n in nodes if n.kind == NodeKind.RAISE]
|
|
assert len(returns) == 1
|
|
assert len(raises) == 1
|
|
|
|
|
|
class TestNodeIdentity:
|
|
def test_node_id_is_stable(self):
|
|
"""Same source should produce identical node IDs across parses."""
|
|
src = "def f():\n return 1\n"
|
|
nodes_a = parse_tree(ast.parse(src), "mod", src)
|
|
nodes_b = parse_tree(ast.parse(src), "mod", src)
|
|
ids_a = [n.node_id for n in nodes_a]
|
|
ids_b = [n.node_id for n in nodes_b]
|
|
assert ids_a == ids_b
|
|
|
|
def test_source_hash_changes_with_content(self):
|
|
src_a = "def f():\n return 1\n"
|
|
src_b = "def f():\n return 2\n"
|
|
nodes_a = parse_tree(ast.parse(src_a), "mod", src_a)
|
|
nodes_b = parse_tree(ast.parse(src_b), "mod", src_b)
|
|
funcs_a = [n for n in nodes_a if n.kind == NodeKind.FUNCTION]
|
|
funcs_b = [n for n in nodes_b if n.kind == NodeKind.FUNCTION]
|
|
assert funcs_a[0].source_hash != funcs_b[0].source_hash
|
|
|
|
def test_parse_module_from_file(self, tmp_path):
|
|
"""parse_module reads a file and produces nodes."""
|
|
from sem.parse import parse_module
|
|
|
|
src = tmp_path / "example.py"
|
|
src.write_text("def hello():\n return 1\n")
|
|
nodes = parse_module(src)
|
|
funcs = [n for n in nodes if n.kind == NodeKind.FUNCTION]
|
|
assert len(funcs) == 1
|
|
assert "hello" in funcs[0].node_id
|
|
# Module name derived from file stem
|
|
assert funcs[0].module == "example"
|
|
|
|
def test_parse_module_with_explicit_name(self, tmp_path):
|
|
"""parse_module uses explicit module name when provided."""
|
|
from sem.parse import parse_module
|
|
|
|
src = tmp_path / "foo.py"
|
|
src.write_text("x = 1\n")
|
|
nodes = parse_module(src, module="my.custom.module")
|
|
# No function nodes, but module name should be set
|
|
assert all(n.module == "my.custom.module" for n in nodes) or len(nodes) == 0
|
|
|
|
def test_node_id_no_line_numbers(self):
|
|
"""Node IDs must not contain raw line numbers."""
|
|
nodes = _parse("""
|
|
def hello():
|
|
if True:
|
|
return 1
|
|
""")
|
|
for node in nodes:
|
|
# Line numbers appear in span, not in node_id
|
|
assert node.span.start_line > 0
|
|
# node_id uses ordinal, not line number
|
|
assert ":L" not in node.node_id
|