The Problem: Static Analysis Has Limits
Static analysis reads your code without running it. This works great for direct calls:
def helper ():
return "I help"
def main ():
helper() # ✅ Static analysis sees this call
But Python is dynamic. Code can call functions without naming them directly:
class Visitor ( ast . NodeVisitor ):
def visit_FunctionDef ( self , node ): # ❌ Flagged as unused!
return self .generic_visit(node)
# Called via: getattr(self, 'visit_' + node.__class__.__name__)
visitor.visit(some_node)
Static analysis sees visitor.visit() but can’t know that visit_FunctionDef gets called internally via getattr().
The Solution: Runtime Call Tracing
The --trace flag runs your test suite with Python’s sys.settrace() enabled, recording every function that actually gets called .
This:
Runs pytest with call tracing enabled
Records every function call to .skylos_trace
Runs static analysis
Cross-references findings with trace data
Removes false positives
What Gets Captured
Pattern Static Analysis With --trace visitor.visit(node) → visit_FunctionDef❌ Missed ✅ Caught getattr(obj, "method")()❌ Missed ✅ Caught globals()["func_name"]()❌ Missed ✅ Caught Plugin hooks (pytest_configure) ❌ Missed ✅ Caught Signal handlers (@receiver) ❌ Missed ✅ Caught Dynamic imports + calls ❌ Missed ✅ Caught
When to Use --trace
Use It
AST/CST visitor patterns
Plugin architectures
Heavy getattr() / reflection
Many false positives from static analysis
Projects with good test coverage
Skip It
Simple codebases with direct calls
No test suite
CI where speed is critical
Already getting accurate results
How It Works
1. Call Recording
When you run skylos . --trace, Skylos executes:
import sys
def trace_calls ( frame , event , arg ):
if event == 'call' :
filename = frame.f_code.co_filename
func_name = frame.f_code.co_name
line = frame.f_code.co_firstlineno
# Record: (filename, func_name, line)
return trace_calls
sys.settrace(trace_calls)
pytest.main([ "-q" ])
sys.settrace( None )
2. Trace File
The results are saved to .skylos_trace:
{
"version" : 1 ,
"calls" : [
{
"file" : "/project/visitor.py" ,
"function" : "visit_FunctionDef" ,
"line" : 42 ,
"count" : 156
},
{
"file" : "/project/plugin.py" ,
"function" : "pytest_configure" ,
"line" : 10 ,
"count" : 1
}
]
}
3. Cross-Reference
During analysis, Skylos checks each “unused” function against the trace:
Definition: visit_FunctionDef at visitor.py:42
Trace: visit_FunctionDef at visitor.py:42 (called 156 times)
Result: ✅ Mark as used
Usage
Basic
With Other Flags
skylos . --trace --danger --quality
Reusing Trace Data
The .skylos_trace file persists. On subsequent runs, Skylos uses it automatically:
# First run - generates trace
skylos . --trace
# Later runs - reuses existing trace
skylos .
Commit .skylos_trace to your repo if your test suite is stable. CI runs will use it without re-running tests.
Best Practices
1. Good Test Coverage = Better Trace
The trace only captures functions that run during tests. If a function isn’t tested, it won’t appear in the trace.
# Check your coverage first
pytest --cov=. --cov-report=term-missing
2. Regenerate After Major Changes
If you add new dynamic patterns, regenerate the trace:
rm .skylos_trace
skylos . --trace
3. Exclude Test Files from Trace
By default, Skylos excludes pytest internals. Your test functions themselves are recorded, which is correct - they verify that test code is executed.
4. CI Integration
# .github/workflows/skylos.yml
- name : Run Skylos with Tracing
run : |
# Use cached trace if available
if [ ! -f .skylos_trace ]; then
skylos . --trace
else
skylos .
fi
Comparison
Approach Accuracy Speed Setup Static only 70-85% Fast None Static + Framework rules 85-95% Fast None Static + --trace 95-99% Slower (runs tests) Requires tests
Troubleshooting
”No tests ran”
The trace runs pytest by default. If your tests are elsewhere:
# Ensure pytest can find your tests
pytest --collect-only
Trace file not created
Check if pytest is failing:
Still getting false positives
Some patterns can’t be traced:
Code only called in production (not in tests)
External process calls
C extensions
For these, use # pragma: no skylos to suppress:
def production_only_handler (): # pragma: no skylos
...
Next Steps