diff --git a/src/coretrace_python/engine.py b/src/coretrace_python/engine.py index 8d6fa71..3b022d8 100644 --- a/src/coretrace_python/engine.py +++ b/src/coretrace_python/engine.py @@ -16,6 +16,7 @@ from coretrace_python.findings import Confidence, Finding, Severity from coretrace_python.frontend import build_hir from coretrace_python.hir import nodes +from coretrace_python.interprocedural import CallGraphAnalysis, SummaryAnalysis from coretrace_python.ir.defuse import DefUseAnalysis from coretrace_python.ir.lowering import ( LoweringError, @@ -42,6 +43,8 @@ SSAAnalysis, DefUseAnalysis, ConstantPropagation, + CallGraphAnalysis, + SummaryAnalysis, SecurityModelAnalysis, TaintAnalysis, ) diff --git a/src/coretrace_python/interprocedural/__init__.py b/src/coretrace_python/interprocedural/__init__.py new file mode 100644 index 0000000..c259c85 --- /dev/null +++ b/src/coretrace_python/interprocedural/__init__.py @@ -0,0 +1,31 @@ +"""Call graph and function summaries (architecture §19, §20).""" + +from coretrace_python.interprocedural.callgraph import ( + CallGraph, + CallGraphAnalysis, + CallSite, + ExternalSymbol, + KnownFunction, + Target, + UnknownTarget, +) +from coretrace_python.interprocedural.summaries import ( + ExternalCall, + FunctionSummary, + SummaryAnalysis, + SummaryTable, +) + +__all__ = [ + "CallGraph", + "CallGraphAnalysis", + "CallSite", + "ExternalCall", + "ExternalSymbol", + "FunctionSummary", + "KnownFunction", + "SummaryAnalysis", + "SummaryTable", + "Target", + "UnknownTarget", +] diff --git a/src/coretrace_python/interprocedural/callgraph.py b/src/coretrace_python/interprocedural/callgraph.py new file mode 100644 index 0000000..2464859 --- /dev/null +++ b/src/coretrace_python/interprocedural/callgraph.py @@ -0,0 +1,150 @@ +"""Intra-module call graph (architecture §20). + +Every call site of every analysable function resolves, through the SSA form, to a +``KnownFunction`` defined at module level, an ``ExternalSymbol`` reached through imports +or builtins, or ``UnknownTarget`` (parameters, attributes, methods) until type inference +and framework models narrow it down. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import ClassVar + +from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis +from coretrace_python.cfg import CFGError +from coretrace_python.hir import nodes +from coretrace_python.ir.lowering import LoweringError, analyzable_functions, qualified_name +from coretrace_python.ir.model import Call, FunctionIR, Global, Symbol, Value +from coretrace_python.ir.ssa import SSAAnalysis +from coretrace_python.semantic.scopes import BindingKind, ScopeAnalysis, ScopeTable +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.source import SourceSpan + + +@dataclass(frozen=True) +class KnownFunction: + name: str + + +@dataclass(frozen=True) +class ExternalSymbol: + symbol: SymbolId + + +@dataclass(frozen=True) +class UnknownTarget: + pass + + +Target = KnownFunction | ExternalSymbol | UnknownTarget + + +@dataclass(frozen=True) +class CallSite: + caller: str + location: SourceSpan + target: Target + arguments: int + keywords: int + + +class CallGraph: + def __init__( + self, + definitions: Mapping[str, nodes.Function], + sites: Mapping[str, tuple[CallSite, ...]], + unsupported: frozenset[str], + ) -> None: + self.definitions: Mapping[str, nodes.Function] = MappingProxyType(dict(definitions)) + self.functions = tuple(definitions) + self.unsupported = unsupported + self._sites = MappingProxyType(dict(sites)) + self._targets = { + (site.caller, site.location): site.target for found in sites.values() for site in found + } + callers: dict[str, set[str]] = {name: set() for name in definitions} + for found in sites.values(): + for site in found: + if isinstance(site.target, KnownFunction): + callers[site.target.name].add(site.caller) + self._callers = {name: frozenset(found) for name, found in callers.items()} + + def sites(self, caller: str) -> tuple[CallSite, ...]: + return self._sites.get(caller, ()) + + def target_at(self, caller: str, location: SourceSpan) -> Target: + return self._targets.get((caller, location), UnknownTarget()) + + def callees(self, caller: str) -> frozenset[str]: + return frozenset( + site.target.name for site in self.sites(caller) if isinstance(site.target, KnownFunction) + ) + + def callers(self, name: str) -> frozenset[str]: + return self._callers.get(name, frozenset()) + + +def resolve_targets( + function: FunctionIR, scopes: ScopeTable, known: frozenset[str] +) -> dict[Value, Target]: + """Map every callee value of ``function`` to its target.""" + + module = scopes.module_scope + targets: dict[Value, Target] = {} + for block in function.blocks: + for instruction in block.instructions: + if isinstance(instruction, Symbol): + targets[instruction.result] = ExternalSymbol(instruction.symbol_id) + elif isinstance(instruction, Global): + binding = module.bindings.get(instruction.name) + if ( + binding is not None + and binding.kind is BindingKind.FUNCTION + and instruction.name in known + ): + targets[instruction.result] = KnownFunction(instruction.name) + return targets + + +class CallGraphAnalysis(Analysis[CallGraph]): + name: ClassVar[str] = "interprocedural.callgraph" + requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, ScopeAnalysis}) + + @classmethod + def compute(cls, ctx: AnalysisContext) -> CallGraph: + scopes = ctx.get(ScopeAnalysis) + definitions = { + qualified_name(scopes, function): function + for function in analyzable_functions(ctx.module) + } + known = frozenset( + name for name, function in definitions.items() if function in ctx.module.body + ) + sites: dict[str, tuple[CallSite, ...]] = {} + unsupported: set[str] = set() + for name, function in definitions.items(): + try: + ssa = ctx.get(SSAAnalysis, function) + except (LoweringError, CFGError): + unsupported.add(name) + sites[name] = () + continue + targets = resolve_targets(ssa, scopes, known) + found: list[CallSite] = [] + for block in ssa.blocks: + for instruction in block.instructions: + if isinstance(instruction, Call): + found.append( + CallSite( + name, + instruction.location, + targets.get(instruction.callee, UnknownTarget()), + len(instruction.arguments), + len(instruction.keywords), + ) + ) + sites[name] = tuple(found) + return CallGraph(definitions, sites, frozenset(unsupported)) diff --git a/src/coretrace_python/interprocedural/summaries.py b/src/coretrace_python/interprocedural/summaries.py new file mode 100644 index 0000000..9358d03 --- /dev/null +++ b/src/coretrace_python/interprocedural/summaries.py @@ -0,0 +1,252 @@ +"""Function summaries (architecture §19). + +A summary says which parameters a function's return value depends on and which external +symbols its parameters reach, directly or through calls to other known functions. It is +computed as a dependence data-flow problem over the SSA form and iterated to a fixpoint +over the call graph, so recursion converges to the least solution. Summaries carry no +security knowledge: the taint engine decides which external symbols matter. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import ClassVar + +from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis +from coretrace_python.cfg import CFG, BlockId, CFGAnalysis +from coretrace_python.dataflow import DataflowProblem, Direction, solve +from coretrace_python.interprocedural.callgraph import ( + CallGraph, + CallGraphAnalysis, + ExternalSymbol, + KnownFunction, + UnknownTarget, +) +from coretrace_python.ir.model import ( + BasicBlock, + Branch, + Call, + Constant, + ForNext, + FunctionIR, + Global, + Instruction, + Jump, + Phi, + Return, + Symbol, + Undefined, + Value, +) +from coretrace_python.ir.ssa import SSAAnalysis +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.source import SourceSpan + +Dependencies = frozenset[int] +NONE: Dependencies = frozenset() + + +@dataclass(frozen=True) +class ExternalCall: + """An external symbol reached from this function; dependencies are parameter indices.""" + + symbol: SymbolId + argument_dependencies: tuple[Dependencies, ...] + keyword_dependencies: Dependencies + location: SourceSpan + call_site: SourceSpan | None + + +@dataclass(frozen=True) +class FunctionSummary: + name: str + parameters: int + return_dependencies: Dependencies + external_calls: tuple[ExternalCall, ...] + unsupported: bool = False + + +class SummaryTable: + def __init__(self, summaries: Mapping[str, FunctionSummary]) -> None: + self._summaries = MappingProxyType(dict(summaries)) + self.names = tuple(summaries) + + def summary(self, name: str) -> FunctionSummary: + return self._summaries[name] + + +State = Mapping[Value, Dependencies] +_CallKey = tuple[SymbolId, SourceSpan, SourceSpan | None] + + +class _DependenceProblem(DataflowProblem[State]): + direction: ClassVar[Direction] = Direction.FORWARD + + def __init__( + self, name: str, function: FunctionIR, graph: CallGraph, table: Mapping[str, FunctionSummary] + ) -> None: + self.name = name + self.function = function + self.graph = graph + self.table = table + self.blocks = {block.id: block for block in function.blocks} + self.external: dict[_CallKey, ExternalCall] = {} + self.returns: Dependencies = NONE + + def initial(self) -> State: + return MappingProxyType( + {p: frozenset({i}) for i, p in enumerate(self.function.parameters)} + ) + + def join(self, a: State, b: State) -> State: + merged = dict(a) + for value, deps in b.items(): + merged[value] = merged[value] | deps if value in merged else deps + return MappingProxyType(merged) + + def evaluate(self, block: BasicBlock, incoming: Mapping[BlockId, State]) -> State: + states = list(incoming.values()) + state: dict[Value, Dependencies] = dict(states[0]) if states else {} + for other in states[1:]: + state = dict(self.join(state, other)) + for instruction in block.instructions: + if instruction.result is None: + continue + if isinstance(instruction, Phi): + deps = NONE + for value, predecessor in instruction.incoming: + if predecessor in incoming: + deps |= incoming[predecessor].get(value, NONE) + elif isinstance(instruction, Call): + deps = self.call(instruction, state) + else: + deps = self.instruction(instruction, state) + state[instruction.result] = deps + terminator = block.terminator + if isinstance(terminator, ForNext) and terminator.result is not None: + state[terminator.result] = state.get(terminator.iterator, NONE) + if isinstance(terminator, Return) and terminator.value is not None: + self.returns |= state.get(terminator.value, NONE) + return MappingProxyType(state) + + def flow(self, cfg: CFG, block_id: BlockId, incoming: Mapping[BlockId, State]) -> Mapping[BlockId, State]: + block = self.blocks[block_id] + state = self.evaluate(block, incoming) + terminator = block.terminator + if isinstance(terminator, Branch): + return {terminator.then_block: state, terminator.else_block: state} + if isinstance(terminator, Jump): + return {terminator.target: state} + if isinstance(terminator, ForNext): + return {terminator.body: state, terminator.exit: state} + return {} + + # ------------------------------------------------------------------ transfer + + @staticmethod + def instruction(instruction: Instruction, state: Mapping[Value, Dependencies]) -> Dependencies: + if isinstance(instruction, Constant | Global | Symbol | Undefined): + return NONE + deps = NONE + for operand in instruction.operands(): + deps |= state.get(operand, NONE) + return deps + + def call(self, call: Call, state: Mapping[Value, Dependencies]) -> Dependencies: + arguments = tuple(state.get(a, NONE) for a in call.arguments) + keywords = NONE + for _, value in call.keywords: + keywords |= state.get(value, NONE) + everything = keywords + for deps in arguments: + everything |= deps + target = self.graph.target_at(self.name, call.location) + + if isinstance(target, ExternalSymbol): + self.record(target.symbol, arguments, keywords, call.location, None) + return everything + if isinstance(target, KnownFunction): + callee = self.table[target.name] + if callee.unsupported: + return everything + + def mapped(deps: Dependencies) -> Dependencies: + result = NONE + for index in deps: + result |= arguments[index] if index < len(arguments) else keywords + return result + + for reached in callee.external_calls: + self.record( + reached.symbol, + tuple(mapped(d) for d in reached.argument_dependencies), + mapped(reached.keyword_dependencies), + reached.location, + call.location, + ) + return mapped(callee.return_dependencies) + assert isinstance(target, UnknownTarget) + return everything | state.get(call.callee, NONE) + + def record( + self, + symbol: SymbolId, + arguments: tuple[Dependencies, ...], + keywords: Dependencies, + location: SourceSpan, + call_site: SourceSpan | None, + ) -> None: + key = (symbol, location, call_site) + previous = self.external.get(key) + if previous is not None: + arguments = tuple( + a | b for a, b in zip(previous.argument_dependencies, arguments, strict=False) + ) + keywords |= previous.keyword_dependencies + self.external[key] = ExternalCall(symbol, arguments, keywords, location, call_site) + + +def summarize( + name: str, function: FunctionIR, cfg: CFG, graph: CallGraph, table: Mapping[str, FunctionSummary] +) -> FunctionSummary: + problem = _DependenceProblem(name, function, graph, table) + solution = solve(problem, cfg) + problem.external = {} + problem.returns = NONE + for block in function.blocks: + if solution.reached(block.id): + problem.evaluate(block, solution.incoming(block.id)) + return FunctionSummary( + name, len(function.parameters), problem.returns, tuple(problem.external.values()) + ) + + +class SummaryAnalysis(Analysis[SummaryTable]): + name: ClassVar[str] = "interprocedural.summaries" + requires: ClassVar[frozenset[AnyAnalysis]] = frozenset( + {CallGraphAnalysis, SSAAnalysis, CFGAnalysis} + ) + + @classmethod + def compute(cls, ctx: AnalysisContext) -> SummaryTable: + graph = ctx.get(CallGraphAnalysis) + table: dict[str, FunctionSummary] = {} + supported: dict[str, tuple[FunctionIR, CFG]] = {} + for name, function in graph.definitions.items(): + if name in graph.unsupported: + count = len(function.parameters) + table[name] = FunctionSummary(name, count, frozenset(range(count)), (), True) + else: + supported[name] = (ctx.get(SSAAnalysis, function), ctx.get(CFGAnalysis, function)) + table[name] = FunctionSummary(name, len(function.parameters), NONE, ()) + changed = True + while changed: + changed = False + for name, (ssa, cfg) in supported.items(): + updated = summarize(name, ssa, cfg, graph, table) + if updated != table[name]: + table[name] = updated + changed = True + return SummaryTable(table) diff --git a/src/coretrace_python/ir/lowering.py b/src/coretrace_python/ir/lowering.py index e9bc64c..3714523 100644 --- a/src/coretrace_python/ir/lowering.py +++ b/src/coretrace_python/ir/lowering.py @@ -278,16 +278,20 @@ def function(self, node: nodes.Function) -> FunctionIR: terminator = self.terminator(cfg_block) blocks.append(BasicBlock(cfg_block.id, tuple(self.instructions), terminator)) return FunctionIR( - self.qualified_name(node), parameter_values, self.cfg.entry, tuple(blocks), node.span + qualified_name(self.scopes, node), parameter_values, self.cfg.entry, tuple(blocks), node.span ) - def qualified_name(self, node: nodes.Function) -> str: - names = [node.name] - parent = self.scopes.scope(self.scope.parent) if self.scope.parent else None - while parent is not None and parent.kind is not ScopeKind.MODULE: - names.append(parent.name) - parent = self.scopes.scope(parent.parent) if parent.parent else None - return ".".join(reversed(names)) + +def qualified_name(scopes: ScopeTable, function: nodes.Function) -> str: + """``Class.method`` for methods, the bare name for module-level functions.""" + + names = [function.name] + scope = scopes.scope_for(function) + parent = scopes.scope(scope.parent) if scope.parent else None + while parent is not None and parent.kind is not ScopeKind.MODULE: + names.append(parent.name) + parent = scopes.scope(parent.parent) if parent.parent else None + return ".".join(reversed(names)) def _reassigned_parameters(function: nodes.Function) -> frozenset[str]: diff --git a/tests/test_interprocedural.py b/tests/test_interprocedural.py new file mode 100644 index 0000000..dbbf819 --- /dev/null +++ b/tests/test_interprocedural.py @@ -0,0 +1,231 @@ +"""Acceptance tests for the call graph and function summaries (§19, §20 of the doc). + +``CallGraphAnalysis`` resolves every call site of a module to a ``KnownFunction`` defined +in the module, an ``ExternalSymbol`` reached through imports or builtins, or +``UnknownTarget``. ``SummaryAnalysis`` computes, for every function, which parameters +its return value depends on and which external symbols its parameters reach, including +through calls to other known functions, by iterating to a fixpoint over the call graph. +Summaries carry no security knowledge; the taint engine interprets them. + +Expected to remain red until ``coretrace_python.interprocedural`` exists. +""" + +from __future__ import annotations + +import pytest + +from coretrace_python import engine +from coretrace_python.analysis import AnalysisManager +from coretrace_python.frontend import build_hir +from coretrace_python.ir.ssa import SSAAnalysis +from coretrace_python.semantic.scopes import ScopeAnalysis +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.source import SourceManager + +try: + from coretrace_python.interprocedural import ( + CallGraph, + CallGraphAnalysis, + CallSite, + ExternalCall, + ExternalSymbol, + FunctionSummary, + KnownFunction, + SummaryAnalysis, + SummaryTable, + UnknownTarget, + ) +except ImportError as error: # pragma: no cover - red until interprocedural lands + MISSING = error +else: + MISSING = None + + +@pytest.fixture(autouse=True) +def require_interprocedural() -> None: + if MISSING is not None: + pytest.fail(f"interprocedural analyses are not implemented yet: {MISSING}") + + +def manager_for(source_text: str) -> AnalysisManager: + return engine.build_manager(build_hir(SourceManager().add_source("ip.py", source_text))) + + +def call_graph(source_text: str) -> CallGraph: + return manager_for(source_text).get(CallGraphAnalysis) + + +def summaries(source_text: str) -> SummaryTable: + return manager_for(source_text).get(SummaryAnalysis) + + +# --------------------------------------------------------------------------- call graph + + +def test_calls_to_module_functions_are_known_targets() -> None: + graph = call_graph("def helper(x):\n return x\n\ndef main(y):\n return helper(y)\n") + + (site,) = graph.sites("main") + assert isinstance(site, CallSite) + assert site.caller == "main" + assert site.target == KnownFunction("helper") + assert site.location.start_line == 5 + assert graph.callees("main") == frozenset({"helper"}) + assert graph.callers("helper") == frozenset({"main"}) + assert graph.callees("helper") == frozenset() + + +def test_calls_to_imported_and_builtin_symbols_are_external() -> None: + graph = call_graph("import os\n\ndef run(c):\n print(c)\n os.system(c)\n") + + targets = [site.target for site in graph.sites("run")] + assert targets == [ + ExternalSymbol(SymbolId("python.builtins.print")), + ExternalSymbol(SymbolId("python.os.system")), + ] + assert graph.callees("run") == frozenset() + + +def test_calls_through_parameters_and_attributes_are_unknown() -> None: + graph = call_graph("def run(self, callback, obj):\n callback(1)\n obj.method(2)\n self.go()\n") + + assert [site.target for site in graph.sites("run")] == [UnknownTarget()] * 3 + + +def test_local_aliases_and_later_definitions_resolve() -> None: + graph = call_graph( + "def main(y):\n fn = helper\n return fn(y) + helper(y)\n\n" + "def helper(x):\n return x\n" + ) + + assert [site.target for site in graph.sites("main")] == [KnownFunction("helper")] * 2 + + +def test_shadowed_names_are_not_known_functions() -> None: + graph = call_graph("def helper():\n pass\n\ndef main(helper):\n helper()\n") + + assert [site.target for site in graph.sites("main")] == [UnknownTarget()] + + +def test_methods_are_named_by_their_class() -> None: + graph = call_graph("class Runner:\n def go(self):\n return top()\n\ndef top():\n pass\n") + + assert graph.functions == ("Runner.go", "top") + assert graph.callees("Runner.go") == frozenset({"top"}) + + +def test_recursion_and_unsupported_functions() -> None: + graph = call_graph( + "def loop(n):\n return loop(n)\n\n" + "def outer():\n def inner():\n pass\n return inner\n" + ) + + assert graph.callees("loop") == frozenset({"loop"}) + assert graph.callers("loop") == frozenset({"loop"}) + assert graph.unsupported == frozenset({"outer"}) + assert graph.sites("outer") == () + + +def test_call_graph_is_a_module_analysis_over_ssa() -> None: + assert CallGraphAnalysis.name == "interprocedural.callgraph" + assert {SSAAnalysis, ScopeAnalysis} <= CallGraphAnalysis.requires + + +# --------------------------------------------------------------------------- summaries + + +def test_identity_returns_its_parameter() -> None: + table = summaries("def identity(x):\n return x\n") + summary = table.summary("identity") + + assert isinstance(summary, FunctionSummary) + assert summary.parameters == 1 + assert summary.return_dependencies == frozenset({0}) + assert summary.external_calls == () + + +def test_return_dependencies_follow_data_flow() -> None: + table = summaries( + "def pick(a, b, c):\n if c:\n r = a + 1\n else:\n r = 'x'\n return r\n\n" + "def const(a):\n return 42\n" + ) + + assert table.summary("pick").return_dependencies == frozenset({0}) + assert table.summary("const").return_dependencies == frozenset() + + +def test_return_dependencies_compose_through_known_callees() -> None: + table = summaries( + "def identity(x):\n return x\n\n" + "def wrap(a, b):\n return identity(b)\n\n" + "def twice(c):\n return wrap(1, wrap(c, c))\n" + ) + + assert table.summary("wrap").return_dependencies == frozenset({1}) + assert table.summary("twice").return_dependencies == frozenset({0}) + + +def test_unknown_calls_depend_on_every_argument() -> None: + table = summaries("def run(f, x, y):\n return f(x) + y.method()\n") + assert table.summary("run").return_dependencies == frozenset({0, 1, 2}) + + +def test_external_calls_record_which_parameters_reach_them() -> None: + table = summaries("import os\n\ndef run(cmd, extra):\n os.system(cmd + extra)\n print(extra)\n") + calls = table.summary("run").external_calls + + assert [c.symbol for c in calls] == [SymbolId("python.os.system"), SymbolId("python.builtins.print")] + assert calls[0].argument_dependencies == (frozenset({0, 1}),) + assert calls[1].argument_dependencies == (frozenset({1}),) + assert calls[0].location.start_line == 4 + assert calls[0].call_site is None + + +def test_external_calls_propagate_transitively_through_known_callees() -> None: + table = summaries( + "import os\n\n" + "def execute(command):\n os.system(command)\n\n" + "def run(user, other):\n execute(user)\n" + ) + (reached,) = table.summary("run").external_calls + + assert isinstance(reached, ExternalCall) + assert reached.symbol == SymbolId("python.os.system") + assert reached.argument_dependencies == (frozenset({0}),) + assert reached.location.start_line == 4 + assert reached.call_site is not None and reached.call_site.start_line == 7 + + +def test_keyword_arguments_are_conservatively_merged() -> None: + table = summaries("import subprocess\n\ndef run(cmd, flag):\n subprocess.run(cmd, check=flag)\n") + (call,) = table.summary("run").external_calls + + assert call.argument_dependencies == (frozenset({0}),) + assert call.keyword_dependencies == frozenset({1}) + + +def test_recursive_summaries_reach_a_fixpoint() -> None: + table = summaries( + "def ping(n):\n return pong(n)\n\n" + "def pong(n):\n if n:\n return ping(n)\n return n\n" + ) + + assert table.summary("ping").return_dependencies == frozenset({0}) + assert table.summary("pong").return_dependencies == frozenset({0}) + + +def test_unsupported_functions_have_conservative_summaries() -> None: + table = summaries("def outer(a):\n def inner():\n pass\n return inner\n\ndef use(b):\n return outer(b)\n") + + assert table.summary("outer").return_dependencies == frozenset({0}) + assert table.summary("outer").unsupported is True + assert table.summary("use").return_dependencies == frozenset({0}) + + +def test_summary_analysis_is_declared_over_the_call_graph() -> None: + assert SummaryAnalysis.name == "interprocedural.summaries" + assert {CallGraphAnalysis, SSAAnalysis} <= SummaryAnalysis.requires + table = summaries("def f():\n pass\n") + assert table.summary("f").return_dependencies == frozenset() + with pytest.raises(KeyError): + table.summary("missing")