Understand exactly what your code is doing in real time, no debugger needed.
LogEye is a frictionless runtime logger for Python that shows variable changes, function calls, and data mutations as they happen.
Think of it as "print debugging" just better - automated, structured, easy to drop into, and remove from any codebase
pip install logeye
from logeye import log @log def add(a, b): return a + b add(2, 3) @log(mode="edu") def add_edu(a, b): return a + b add_edu(2, 3)
Output:
[0.000s] playground.py:7 (call) add args=(2, 3)
[0.000s] playground.py:5 (set) add.a = 2
[0.000s] playground.py:5 (set) add.b = 3
[0.000s] playground.py:5 (return) add args=(2, 3) -> 5
[0.000s] Calling add_edu(2, 3)
[0.000s] Defined add_edu.a = 2
[0.000s] Defined add_edu.b = 3
[0.000s] add_edu(2, 3) returned 5
- Who is it for?
- What does it do?
- Quick start
- Educational Mode
- Logging functions
- Advanced function logging
- Logging objects
- Messages
- Utility functions
- Output format
- Some Usage Examples
- Inspiration
- Limitations
- Contact
- License
LogEye helps you see how your code executes step by step.
Perfect for:
- beginners learning programming
- students studying algorithms
- teachers explaining concepts
No more scattered print() calls. No debugger setup. Simply run your code and see everything.
Why keep doing this?
print(x) print(y) print(queue)
When a single | l suffices?
Added (1, 'B') to queue -> [(1, 'B')]
Sorted queue -> [(1, 'B'), (4, 'C')]
Popped (1, 'B') from queue
Core features:
- educational mode for algorithm tracing
- log values with automatic variable name inference
- trace function calls, local variables, and returns
- track object and data structure mutations in real time
- format messages using f-string, template, or scope variables
Advanced features:
- filter variables and control verbosity (
level,filter) - log to files or stdout
- recursively track nested structures
- AST-based name inference (including multi-line assignments)
However, keep in mind that name inference is best-effort and may not be accurate in some more extreme cases.
Without changing your code, LogEye shows:
- function calls and returns
- local variables inside functions
- object attribute changes
- list / dict / set mutations
- nested structures
- recursion and call depth
from logeye import log x = log(10) message = log("Hello from {name}", name="Matt") @log(level="call") def add(a, b): something = 2 + 2 # Unused return a + b add(2, 2) name = "Matt" message2 = log("Hello from $name") config = log({"debug": True, "port": 8080}) config.port = 9090 config["debug"] = False
Example output:
[0.000s] playground.py:3 (set) x = 10
[0.024s] playground.py:4 (set) message = 'Hello from Matt'
[0.026s] playground.py:13 (call) add args=(2, 2)
[0.026s] playground.py:10 (return) add args=(2, 2) -> 4
[0.026s] playground.py:16 (set) message2 = 'Hello from Matt'
[0.027s] playground.py:18 (set) config = {'debug': True, 'port': 8080}
[0.027s] playground.py:19 (change) config.port = 9090
[0.027s] playground.py:20 (change) config.debug = False
Educational mode is designed to make algorithms read like a story instead of a trace. :)
Instead of raw internal logs, it shows:
- clean function calls
- meaningful variable changes
- human-readable operations
- minimal noise
Enable it with:
from logeye import log, set_mode # Globally set_mode("edu") # Locally @log(mode="edu") def my_function(): ...
[0.000s] demo_dijkstra.py:36 (call) dijkstra args=({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A')
[0.001s] demo_dijkstra.py:8 (set) dijkstra.graph = {'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}
[0.001s] demo_dijkstra.py:8 (set) dijkstra.start = 'A'
[0.001s] demo_dijkstra.py:8 (set) dijkstra.node = 'A'
...
[0.005s] demo_dijkstra.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (6, 'D'), 'state': []}
[0.005s] demo_dijkstra.py:17 (change) dijkstra.current_dist = 6
[0.005s] demo_dijkstra.py:31 (return) dijkstra args=({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A') -> {'A': 0, 'B': 1, 'C': 3, 'D': 4}
[0.000s] demo_dijkstra.py:3 DIJKSTRA - SHORTEST PATH
[0.000s] Calling dijkstra({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A')
[0.001s] Defined dijkstra.graph = {'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}
[0.001s] Defined dijkstra.start = 'A'
...
[0.004s] Sorted queue -> [(6, 'D')]
[0.005s] Popped (6, 'D') from queue
[0.005s] dijkstra.current_dist = 6
[0.005s] dijkstra({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A') returned {'A': 0, 'B': 1, 'C': 3, 'D': 4}
-
Function calls become readable:
Calling foo(1, b=2) -
No raw
args/kwargsdictionaries -
Internal noise is removed:
- no
<func ...> - no test/module prefixes
- no irrelevant internals
- no
-
Data structure operations are human-friendly:
Added 5 to arr -> [1, 2, 5]
from logeye import log, l l("FACTORIAL") @log(mode="edu") def factorial(n): if n == 1: return 1 return n * factorial(n - 1) factorial(5)
Output:
[0.000s] FACTORIAL
[0.000s] Calling factorial(5)
[0.000s] Defined factorial.n = 5
[0.001s] Calling factorial#2(4)
[0.001s] Defined factorial#2.n = 4
[0.002s] Calling factorial#3(3)
[0.002s] Defined factorial#3.n = 3
[0.002s] Calling factorial#4(2)
[0.003s] Defined factorial#4.n = 2
[0.003s] Calling factorial#5(1)
[0.003s] Defined factorial#5.n = 1
[0.003s] factorial#5(1) returned 1
[0.003s] factorial#4(2) returned 2
[0.003s] factorial#3(3) returned 6
[0.003s] factorial#2(4) returned 24
[0.003s] factorial(5) returned 120
It’s especially useful for:
- learning recursion
- understanding sorting algorithms
- teaching data structures
- quickly verifying logic without a debugger
Decorate a function or wrap it with log:
from logeye import log @log def add(a, b): total = a + b return total add(2, 3)
[0.000s] playground.py:10 (call) add args=(2, 3)
[0.000s] playground.py:6 (set) add.a = 2
[0.000s] playground.py:6 (set) add.b = 3
[0.000s] playground.py:7 (set) add.total = 5
[0.000s] playground.py:7 (return) add args=(2, 3) -> 5
This will log:
- the function call
- local variable changes inside the function
- the return value
You can customise how functions are logged using @log(...):
from logeye import log @log(level="call") def foo(): x = 10 return x
- "call" - only function calls and returns
- "state" - variable changes only (no call spam)
- "full" - full tracing (default)
from logeye import log @log(filter=["x"]) def foo(): x = 10 y = 20 return x + y
Only selected variables will be logged.
from logeye import log @log(filepath="logs/my_func.log") def foo(): x = 10 return x
Logs for this function will be written to a file instead of stdout.
from logeye import log @log(level="state", filter=["queue"], filepath="queue.log") def process(): queue = [] queue.append(1) queue.append(2)
log() can wrap mappings and objects with __dict__ into a LoggedObject:
from logeye import log settings = log({"theme": "dark", "volume": 3}) settings.theme = "light" settings.volume += 1
You can also pass an object:
from logeye import * @log class User: def __init__(self): self.name = "Matt" self.active = True user = l(User()) user.name = "For"
[0.000s] playground.py:11 (call) user <- User.__init__
[0.000s] playground.py:7 (set) user.name = 'Matt'
[0.000s] playground.py:8 (set) user.active = True
[0.000s] playground.py:12 (change) user.name = 'For'
Use log() with a string to emit a formatted message:
from logeye import log name = "Matt" email = "mattfor@relaxy.xyz" log("Current user: $name\nEmail: $email") # Also works like this! log("Current user: {}\nEmail: {}", "Matt", "mattfor@relaxy.xyz")
[0.001s] demo9.py:5
Current user: Matt
Email: mattfor@relaxy.xyz
[0.002s] demo9.py:7
Current user: Matt
Email: mattfor@relaxy.xyz
str.format() is tried first. If that fails, the logger also tries caller globals / locals and template substitution.
Wraps a value for logging without changing its type unless needed.
Disable or enable logging globally.
Controls how file paths are shown in output.
Accepted values:
absoluteprojectfile
Replace the default formatter.
Signature:
func(elapsed, kind, name, value, filename, lineno)
Restore the built-in formatter.
By default, messages look like this:
(note, the path by default is relative to the run directory of the file you're launching the module in)
[0.123s] path/to/file.py:42 (set) x = 10
For plain messages:
[0.123s] path/to/file.py:42 some text
from logeye.config import set_global_log_file, toggle_global_log_file set_global_log_file("logs/app.log") toggle_global_log_file(True)
All logs will now be written to the specified file.
To disable: toggle_global_log_file(False)
Example 1: Master Demo, a bit of everything
from logeye import ( log, l, set_path_mode, toggle_logs, reset_output_formatter, set_output_formatter, ) log("=== BASIC MESSAGES ===", show_time=False, show_file=False, show_lineno=False) x = 5 log("value is {}", x) log("value via template: $x") log("file absolute: $apath") log("file relative: $rpath") log("file name: $fpath") log("\n=== ASSIGNMENTS ===", show_time=False, show_file=False, show_lineno=False) a = log(10) b = l(20) c = 30 | l # tuple unpacking d, e = log("hello"), log("world") log("\n=== EXPRESSIONS ===", show_time=False, show_file=False, show_lineno=False) f = (10 + 5) | l g = l(100 + 200) log("\n=== FUNCTIONS ===", show_time=False, show_file=False, show_lineno=False) @log def add(a, b): total = a + b total = total * 2 return total res = add(3, 4) log("\n=== NESTED FUNCTIONS ===", show_time=False, show_file=False, show_lineno=False) @log def outer(x): def inner(y): z = y + 1 return z return inner(x) outer(10) log("\n=== LAMBDAS ===", show_time=False, show_file=False, show_lineno=False) f = lambda: log("lambda called") f() g = lambda v: v * 2 g = l(g) # wrap lambda g(5) log("\n=== OBJECT TRACKING ===", show_time=False, show_file=False, show_lineno=False) obj = log({"x": 1, "nested": {"y": 2}}) obj.x = 10 obj.nested.y = 20 obj["x"] = 30 log("\n=== CLASS TRACKING ===", show_time=False, show_file=False, show_lineno=False) @log class User: def __init__(self, name): self.name = name self.active = True user = l(User("Matt")) user.name = "For" user.active = False log("\n=== PATH MODES ===", show_time=False, show_file=False, show_lineno=False) set_path_mode("absolute") log("absolute path mode") set_path_mode("project") log("project path mode") set_path_mode("file") log("file path mode") log("\n=== CUSTOM FORMATTER ===", show_time=False, show_file=False, show_lineno=False) def simple_formatter(elapsed, kind, name, value, filename, lineno): return f"{kind.upper()} -> {name}: {value}" set_output_formatter(simple_formatter) x = log(123) log("formatted message") reset_output_formatter() log("\n=== ENABLE / DISABLE ===", show_time=False, show_file=False, show_lineno=False) log("this should appear") toggle_logs(False) log("this should NOT appear") toggle_logs(True) log("logging back on") log("\n=== MIXED USAGE ===", show_time=False, show_file=False, show_lineno=False) value = (5 | l) * (10 | l) log("final value is $value")
=== BASIC MESSAGES ===
[0.001s] master_demo.py:13 value is 5
[0.002s] master_demo.py:14 value via template: 5
[0.003s] master_demo.py:15 file absolute: /home/mattfor/Programming/Python/LogEye/demos/master_demo.py
[0.004s] master_demo.py:16 file relative: master_demo.py
[0.005s] master_demo.py:17 file name: master_demo.py
=== ASSIGNMENTS ===
[0.006s] master_demo.py:21 (set) a = 10
[0.006s] master_demo.py:22 (set) b = 20
[0.007s] master_demo.py:23 (set) c = 30
[0.007s] master_demo.py:26 (set) d = 'hello'
[0.008s] master_demo.py:26 (set) e = 'world'
=== EXPRESSIONS ===
[0.016s] master_demo.py:30 (set) f = 15
[0.016s] master_demo.py:31 (set) g = 300
=== FUNCTIONS ===
[0.033s] master_demo.py:43 (call) add args=(3, 4)
[0.033s] master_demo.py:38 (set) add.a = 3
[0.033s] master_demo.py:38 (set) add.b = 4
[0.033s] master_demo.py:39 (set) add.total = 7
[0.033s] master_demo.py:40 (change) add.total = 14
[0.033s] master_demo.py:40 (return) add args=(3, 4) -> 14
=== NESTED FUNCTIONS ===
[0.049s] master_demo.py:57 (call) outer args=(10)
[0.050s] master_demo.py:50 (set) outer.x = 10
[0.050s] master_demo.py:50 (call) outer.inner
[0.050s] master_demo.py:50 (set) outer.inner = {'type': 'function', 'path': 'outer.inner', 'defaults': {'y': 10}}
[0.050s] master_demo.py:50 (set) outer.inner.y = 10
[0.050s] master_demo.py:52 (set) outer.inner.z = 11
[0.050s] master_demo.py:52 (return) outer.inner args=(10) -> 11
[0.050s] master_demo.py:54 (return) outer args=(10) -> 11
=== LAMBDAS ===
[0.058s] master_demo.py:62 (change) f = <function <lambda> at 0x7fe3a8fa7d70>
[0.066s] master_demo.py:61 lambda called
[0.066s] master_demo.py:65 (change) g = <function <lambda> at 0x7fe3a8fa7cc0>
[0.066s] master_demo.py:66 (change) g = <function <lambda> at 0x7fe3a8fa7ed0>
[0.074s] master_demo.py:66 (call) <lambda> args=(5)
[0.074s] master_demo.py:64 (set) <lambda>.v = 5
[0.074s] master_demo.py:64 (return) <lambda> args=(5) -> 10
=== OBJECT TRACKING ===
[0.083s] master_demo.py:70 (set) obj = {'x': 1, 'nested': {'y': 2}}
[0.083s] master_demo.py:72 (change) obj.x = 10
[0.083s] master_demo.py:73 (change) obj.nested.y = 20
[0.083s] master_demo.py:74 (change) obj.x = 30
=== CLASS TRACKING ===
[0.092s] master_demo.py:86 (call) user <- User.__init__ args=('Matt')
[0.092s] master_demo.py:82 (set) user.name = 'Matt'
[0.093s] master_demo.py:83 (set) user.active = True
[0.093s] master_demo.py:87 (change) user.name = 'For'
[0.093s] master_demo.py:88 (change) user.active = False
=== PATH MODES ===
[0.109s] /home/mattfor/Programming/Python/LogEye/demos/master_demo.py:93 absolute path mode
[0.118s] master_demo.py:96 project path mode
[0.126s] master_demo.py:99 file path mode
=== CUSTOM FORMATTER ===
[0.134s] master_demo.py:110 (set) x = 123
[0.143s] master_demo.py:111 formatted message
=== ENABLE / DISABLE ===
[0.159s] master_demo.py:117 this should appear
[0.167s] master_demo.py:123 logging back on
=== MIXED USAGE ===
[0.176s] master_demo.py:127 (set) value = 5
[0.176s] master_demo.py:127 (set) value = 10
[0.184s] master_demo.py:128 final value is 50
Example 2: Factorial
from logeye import log, l l("FACTORIAL - BY ITERATION") # Iteration @log def factorial(n): result = 1 for i in range(1, n + 1): result *= i return result factorial(5) l("FACTORIAL - BY RECURSION") # Recursion @log def factorial(n): if n == 1: return 1 return n * factorial(n - 1) factorial(5)
[0.000s] playground.py:3 FACTORIAL - BY ITERATION
[0.000s] playground.py:15 (call) factorial args=(5)
[0.000s] playground.py:9 (set) factorial.n = 5
[0.000s] playground.py:10 (set) factorial.result = 1
[0.000s] playground.py:11 (set) factorial.i = 1
[0.000s] playground.py:11 (change) factorial.i = 2
[0.000s] playground.py:10 (change) factorial.result = 2
[0.000s] playground.py:11 (change) factorial.i = 3
[0.000s] playground.py:10 (change) factorial.result = 6
[0.000s] playground.py:11 (change) factorial.i = 4
[0.000s] playground.py:10 (change) factorial.result = 24
[0.000s] playground.py:11 (change) factorial.i = 5
[0.000s] playground.py:10 (change) factorial.result = 120
[0.000s] playground.py:12 (return) factorial args=(5) -> 120
[0.001s] playground.py:17 FACTORIAL - BY RECURSION
[0.001s] playground.py:28 (call) factorial args=(5)
[0.001s] playground.py:23 (set) factorial.n = 5
[0.002s] playground.py:25 (call) factorial#2 args=(4)
[0.002s] playground.py:23 (set) factorial#2.n = 4
[0.004s] playground.py:25 (call) factorial#3 args=(3)
[0.004s] playground.py:23 (set) factorial#3.n = 3
[0.005s] playground.py:25 (call) factorial#4 args=(2)
[0.005s] playground.py:23 (set) factorial#4.n = 2
[0.006s] playground.py:25 (call) factorial#5 args=(1)
[0.006s] playground.py:23 (set) factorial#5.n = 1
[0.006s] playground.py:24 (return) factorial#5 args=(1) -> 1
[0.006s] playground.py:25 (return) factorial#4 args=(2) -> 2
[0.006s] playground.py:25 (return) factorial#3 args=(3) -> 6
[0.006s] playground.py:25 (return) factorial#2 args=(4) -> 24
[0.006s] playground.py:25 (return) factorial args=(5) -> 120
Example 3: Dijkstra
from logeye import log, l l("DIJKSTRA - SHORTEST PATH") @log def dijkstra(graph, start): distances = {node: float("inf") for node in graph} distances[start] = 0 visited = set() queue = [(0, start)] while queue: current_dist, node = queue.pop(0) if node in visited: continue visited.add(node) for neighbor, weight in graph[node].items(): new_dist = current_dist + weight if new_dist < distances[neighbor]: distances[neighbor] = new_dist queue.append((new_dist, neighbor)) queue.sort() return distances graph = {"A": {"B": 1, "C": 4}, "B": {"C": 2, "D": 5}, "C": {"D": 1}, "D": {}} dijkstra(graph, "A")
[0.000s] playground.py:3 DIJKSTRA - SHORTEST PATH
[0.001s] playground.py:41 (call) dijkstra args=({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A')
[0.001s] playground.py:8 (set) dijkstra.graph = {'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}
[0.001s] playground.py:8 (set) dijkstra.start = 'A'
[0.001s] playground.py:8 (set) dijkstra.node = 'A'
[0.001s] playground.py:8 (change) dijkstra.node = 'B'
[0.001s] playground.py:8 (change) dijkstra.node = 'C'
[0.001s] playground.py:8 (change) dijkstra.node = 'D'
[0.001s] playground.py:9 (set) dijkstra.distances = {'A': inf, 'B': inf, 'C': inf, 'D': inf}
[0.001s] playground.py:9 (change) dijkstra.distances.A = 0
[0.002s] playground.py:12 (set) dijkstra.visited = set()
[0.002s] playground.py:14 (set) dijkstra.queue = [(0, 'A')]
[0.002s] playground.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (0, 'A'), 'state': []}
[0.002s] playground.py:17 (change) dijkstra.node = 'A'
[0.002s] playground.py:17 (set) dijkstra.current_dist = 0
[0.002s] playground.py:20 (change) dijkstra.visited = {'op': 'add', 'value': 'A', 'state': {'A'}}
[0.002s] playground.py:23 (set) dijkstra.neighbor = 'B'
[0.002s] playground.py:23 (set) dijkstra.weight = 1
[0.002s] playground.py:25 (set) dijkstra.new_dist = 1
[0.002s] playground.py:26 (change) dijkstra.distances.B = 1
[0.002s] playground.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (1, 'B'), 'state': [(1, 'B')]}
[0.003s] playground.py:23 (change) dijkstra.neighbor = 'C'
[0.003s] playground.py:23 (change) dijkstra.weight = 4
[0.003s] playground.py:25 (change) dijkstra.new_dist = 4
[0.003s] playground.py:26 (change) dijkstra.distances.C = 4
[0.003s] playground.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (4, 'C'), 'state': [(1, 'B'), (4, 'C')]}
[0.003s] playground.py:29 (change) dijkstra.queue = {'op': 'sort', 'args': (), 'kwargs': {}, 'state': [(1, 'B'), (4, 'C')]}
[0.003s] playground.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (1, 'B'), 'state': [(4, 'C')]}
[0.003s] playground.py:17 (change) dijkstra.node = 'B'
[0.003s] playground.py:17 (change) dijkstra.current_dist = 1
[0.003s] playground.py:20 (change) dijkstra.visited = {'op': 'add', 'value': 'B', 'state': {'B', 'A'}}
[0.003s] playground.py:23 (change) dijkstra.weight = 2
[0.003s] playground.py:25 (change) dijkstra.new_dist = 3
[0.003s] playground.py:26 (change) dijkstra.distances.C = 3
[0.004s] playground.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (3, 'C'), 'state': [(4, 'C'), (3, 'C')]}
[0.004s] playground.py:23 (change) dijkstra.neighbor = 'D'
[0.004s] playground.py:23 (change) dijkstra.weight = 5
[0.004s] playground.py:25 (change) dijkstra.new_dist = 6
[0.004s] playground.py:26 (change) dijkstra.distances.D = 6
[0.004s] playground.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (6, 'D'), 'state': [(4, 'C'), (3, 'C'), (6, 'D')]}
[0.004s] playground.py:29 (change) dijkstra.queue = {'op': 'sort', 'args': (), 'kwargs': {}, 'state': [(3, 'C'), (4, 'C'), (6, 'D')]}
[0.004s] playground.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (3, 'C'), 'state': [(4, 'C'), (6, 'D')]}
[0.004s] playground.py:17 (change) dijkstra.node = 'C'
[0.004s] playground.py:17 (change) dijkstra.current_dist = 3
[0.004s] playground.py:20 (change) dijkstra.visited = {'op': 'add', 'value': 'C', 'state': {'B', 'C', 'A'}}
[0.005s] playground.py:23 (change) dijkstra.weight = 1
[0.005s] playground.py:25 (change) dijkstra.new_dist = 4
[0.005s] playground.py:26 (change) dijkstra.distances.D = 4
[0.005s] playground.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (4, 'D'), 'state': [(4, 'C'), (6, 'D'), (4, 'D')]}
[0.005s] playground.py:29 (change) dijkstra.queue = {'op': 'sort', 'args': (), 'kwargs': {}, 'state': [(4, 'C'), (4, 'D'), (6, 'D')]}
[0.005s] playground.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (4, 'C'), 'state': [(4, 'D'), (6, 'D')]}
[0.005s] playground.py:17 (change) dijkstra.current_dist = 4
[0.005s] playground.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (4, 'D'), 'state': [(6, 'D')]}
[0.005s] playground.py:17 (change) dijkstra.node = 'D'
[0.006s] playground.py:20 (change) dijkstra.visited = {'op': 'add', 'value': 'D', 'state': {'B', 'C', 'D', 'A'}}
[0.006s] playground.py:29 (change) dijkstra.queue = {'op': 'sort', 'args': (), 'kwargs': {}, 'state': [(6, 'D')]}
[0.006s] playground.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (6, 'D'), 'state': []}
[0.006s] playground.py:17 (change) dijkstra.current_dist = 6
[0.006s] playground.py:31 (return) dijkstra args=({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A') -> {'A': 0, 'B': 1, 'C': 3, 'D': 4}
Idea came to be during Warsaw IT Days 2026. During the Python lecture "Logging module adventures".
I thought there definitely was an easier way to do it without repeating yourself constantly, and it turns out there was!
- variable name inference is best-effort and may fail in complex or highly dynamic expressions
- some edge cases (e.g. deeply nested calls, chained expressions, unusual syntax) may fall back to a generic name like
"set" - lambda functions are not automatically traced unless explicitly wrapped with
log() - function tracing relies on
sys.settrace()and may introduce overhead in performance-sensitive code - logging inside heavily recursive or multithreaded code may produce noisy or hard-to-follow output
- AST-based analysis requires access to source files and may not work correctly in environments without source code ( e.g. compiled/obfuscated code, some REPLs)
- tuple assignment tracking depends on call order and may behave unexpectedly in complex expressions
- object wrapping only supports mappings and objects with
__dict__ - custom objects with unusual attribute behaviour may not be fully tracked
- logging output is intended for debugging and introspection, not structured logging or production telemetry
- local variables may be wrapped at runtime to enable mutation tracking, which can affect identity checks and edge-case behaviour
If you have questions, ideas, or run into issues:
- please open an issue!
- or email me mattfor@relaxy.xyz
- or add me on discord @mattfor
- @OutSquareCapital Helped with typing and refactoring
MIT License © 2026
See LICENSE for details.
Version 1.6.2