Frictionless runtime logging for Python variables and functions
Project description
LogEye
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
Quick example
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), 'kwargs': {}}
[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 = 5
[0.000s] Calling add_edu(2, 3)
[0.000s] a = 2
[0.000s] b = 3
[0.000s] Returned 5
Table of Contents
- Installation
- 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
Installation
pip install logeye
Who is it for
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. Just run your code and see everything.
What does it do
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.
Quick start
from logeye import log
x = log(10)
message = log("Hello from {name}", name="Matt")
@log(level="call")
def add(a, b):
something = 2 + 2
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.000s] playground.py:4 (set) message = 'Hello from Matt'
[0.000s] playground.py:13 (call) add = {'args': (2, 2), 'kwargs': {}}
[0.000s] playground.py:10 (return) add = 4
[0.000s] playground.py:16 (set) message2 = 'Hello from Matt'
[0.001s] playground.py:18 (set) config = {'debug': True, 'port': 8080}
[0.001s] playground.py:19 (set) config.port = 9090
[0.001s] playground.py:20 (set) config.debug = False
Educational Mode!
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
from logeye.config import set_mode
# Globally
set_mode("edu")
# Locally
@log(mode="edu")
def my_function():
...
Before vs After
Default mode
[0.000s] (call) factorial = {'args': (5,), 'kwargs': {}}
[0.000s] (set) factorial.n = 5
[0.000s] (set) factorial.result = 1
[0.000s] (set) factorial.i = 1
[0.000s] (set) factorial.result = 2
...
[0.001s] (return) factorial = 120
Educational mode
[0.000s] Calling factorial(5)
[0.000s] n = 5
[0.000s] result = 1
[0.000s] result = 2
[0.000s] result = 6
[0.000s] result = 24
[0.000s] result = 120
[0.000s] Returned 120
What changes in educational mode
-
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]
Example - Educational Factorial
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] Calling factorial(4)
[0.000s] Calling factorial(3)
[0.000s] Calling factorial(2)
[0.000s] Calling factorial(1)
[0.000s] Returned 1
[0.000s] Returned 2
[0.000s] Returned 6
[0.000s] Returned 24
[0.000s] Returned 120
It’s especially useful for:
- learning recursion
- understanding sorting algorithms
- teaching data structures
- quickly verifying logic without a debugger
Logging functions
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.001s] demo9.py:17 (call) add = {'args': (2, 3), 'kwargs': {}}
[0.001s] demo9.py:13 (set) add.a = 2
[0.001s] demo9.py:13 (set) add.b = 3
[0.001s] demo9.py:14 (set) add.total = 5
[0.001s] demo9.py:14 (return) add = 5
This will log:
- the function call
- local variable changes inside the function
- the return value
Advanced function logging
You can customise how functions are logged using @log(...):
Control verbosity
from logeye import log
@log(level="call")
def foo():
x = 10
return x
Levels:
- "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.
Combos
from logeye import log
@log(level="state", filter=["queue"], filepath="queue.log")
def process():
queue = []
queue.append(1)
queue.append(2)
Logging objects
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.001s] demo8.py:11 (call) User.__init__ = {'args': (<__main__.User object at 0x7fbacb1f0980>,), 'kwargs': {}}
[0.001s] demo8.py:7 (set) user.name = 'Matt'
[0.001s] demo8.py:8 (set) user.active = True
[0.001s] demo8.py:11 (set) user = {}
[0.001s] demo8.py:12 (set) user.name = 'For'
Messages
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.
Utility functions
watch(value, name=None)
Wraps a value for logging without changing its type unless needed.
toggle_logs(True/False)
Disable or enable logging globally.
set_path_mode(mode)
Controls how file paths are shown in output.
Accepted values:
absoluteprojectfile
set_output_formatter(func)
Replace the default formatter.
Signature:
func(elapsed, kind, name, value, filename, lineno)
reset_output_formatter()
Restore the built-in formatter.
Output format
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
File logging
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)
Some Usage Examples
Example 1: Factorial
Code
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)
Output
[0.002s] demo_factorial.py:3 FACTORIAL - BY ITERATION
[0.002s] demo_factorial.py:15 (call) factorial = {'args': (5,), 'kwargs': {}}
[0.002s] demo_factorial.py:9 (set) factorial.n = 5
[0.002s] demo_factorial.py:10 (set) factorial.result = 1
[0.002s] demo_factorial.py:11 (set) factorial.i = 1
[0.002s] demo_factorial.py:11 (set) factorial.i = 2
[0.002s] demo_factorial.py:10 (set) factorial.result = 2
[0.002s] demo_factorial.py:11 (set) factorial.i = 3
[0.002s] demo_factorial.py:10 (set) factorial.result = 6
[0.002s] demo_factorial.py:11 (set) factorial.i = 4
[0.002s] demo_factorial.py:10 (set) factorial.result = 24
[0.002s] demo_factorial.py:11 (set) factorial.i = 5
[0.002s] demo_factorial.py:10 (set) factorial.result = 120
[0.002s] demo_factorial.py:12 (return) factorial = 120
[0.002s] demo_factorial.py:17 FACTORIAL - BY RECURRENCY
[0.002s] demo_factorial.py:28 (call) factorial = {'args': (5,), 'kwargs': {}}
[0.002s] demo_factorial.py:23 (set) factorial.n = 5
[0.002s] demo_factorial.py:25 (call) factorial_2 = {'args': (4,), 'kwargs': {}}
[0.002s] demo_factorial.py:23 (set) factorial_2.n = 4
[0.002s] demo_factorial.py:25 (call) factorial_3 = {'args': (3,), 'kwargs': {}}
[0.002s] demo_factorial.py:23 (set) factorial_3.n = 3
[0.002s] demo_factorial.py:25 (call) factorial_4 = {'args': (2,), 'kwargs': {}}
[0.002s] demo_factorial.py:23 (set) factorial_4.n = 2
[0.002s] demo_factorial.py:25 (call) factorial_5 = {'args': (1,), 'kwargs': {}}
[0.002s] demo_factorial.py:23 (set) factorial_5.n = 1
[0.002s] demo_factorial.py:24 (return) factorial_5 = 1
[0.002s] demo_factorial.py:25 (return) factorial_4 = 2
[0.002s] demo_factorial.py:25 (return) factorial_3 = 6
[0.002s] demo_factorial.py:25 (return) factorial_2 = 24
[0.002s] demo_factorial.py:25 (return) factorial = 120
Example 2: 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")
Output
[0.000s] demo_dijkstra.py:3 DIJKSTRA - SHORTEST PATH
[0.000s] demo_dijkstra.py:41 (call) dijkstra = {'args': ({'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}, 'A'), 'kwargs': {}}
[0.000s] demo_dijkstra.py:8 (set) dijkstra.graph = {'A': {'B': 1, 'C': 4}, 'B': {'C': 2, 'D': 5}, 'C': {'D': 1}, 'D': {}}
[0.000s] demo_dijkstra.py:8 (set) dijkstra.start = 'A'
[0.000s] demo_dijkstra.py:8 (set) dijkstra.node = 'A'
[0.000s] demo_dijkstra.py:8 (set) dijkstra.node = 'B'
[0.000s] demo_dijkstra.py:8 (set) dijkstra.node = 'C'
[0.000s] demo_dijkstra.py:8 (set) dijkstra.node = 'D'
[0.000s] demo_dijkstra.py:9 (set) dijkstra.distances = {'A': inf, 'B': inf, 'C': inf, 'D': inf}
[0.000s] demo_dijkstra.py:9 (change) dijkstra.distances.A = {'op': 'setitem', 'value': 0, 'state': {'A': 0, 'B': inf, 'C': inf, 'D': inf}}
[0.000s] demo_dijkstra.py:12 (set) dijkstra.visited = set()
[0.000s] demo_dijkstra.py:14 (set) dijkstra.queue = [(0, 'A')]
[0.001s] demo_dijkstra.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (0, 'A'), 'state': []}
[0.001s] demo_dijkstra.py:17 (set) dijkstra.node = 'A'
[0.001s] demo_dijkstra.py:17 (set) dijkstra.current_dist = 0
[0.001s] demo_dijkstra.py:20 (change) dijkstra.visited = {'op': 'add', 'value': 'A', 'state': {'A'}}
[0.001s] demo_dijkstra.py:23 (set) dijkstra.neighbor = 'B'
[0.001s] demo_dijkstra.py:23 (set) dijkstra.weight = 1
[0.001s] demo_dijkstra.py:25 (set) dijkstra.new_dist = 1
[0.001s] demo_dijkstra.py:26 (change) dijkstra.distances.B = {'op': 'setitem', 'value': 1, 'state': {'A': 0, 'B': 1, 'C': inf, 'D': inf}}
[0.001s] demo_dijkstra.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (1, 'B'), 'state': [(1, 'B')]}
[0.001s] demo_dijkstra.py:23 (set) dijkstra.neighbor = 'C'
[0.001s] demo_dijkstra.py:23 (set) dijkstra.weight = 4
[0.001s] demo_dijkstra.py:25 (set) dijkstra.new_dist = 4
[0.001s] demo_dijkstra.py:26 (change) dijkstra.distances.C = {'op': 'setitem', 'value': 4, 'state': {'A': 0, 'B': 1, 'C': 4, 'D': inf}}
[0.001s] demo_dijkstra.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (4, 'C'), 'state': [(1, 'B'), (4, 'C')]}
[0.001s] demo_dijkstra.py:29 (change) dijkstra.queue = {'op': 'sort', 'args': (), 'kwargs': {}, 'state': [(1, 'B'), (4, 'C')]}
[0.001s] demo_dijkstra.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (1, 'B'), 'state': [(4, 'C')]}
[0.001s] demo_dijkstra.py:17 (set) dijkstra.node = 'B'
[0.001s] demo_dijkstra.py:17 (set) dijkstra.current_dist = 1
[0.001s] demo_dijkstra.py:20 (change) dijkstra.visited = {'op': 'add', 'value': 'B', 'state': {'A', 'B'}}
[0.001s] demo_dijkstra.py:23 (set) dijkstra.weight = 2
[0.001s] demo_dijkstra.py:25 (set) dijkstra.new_dist = 3
[0.002s] demo_dijkstra.py:26 (change) dijkstra.distances.C = {'op': 'setitem', 'value': 3, 'state': {'A': 0, 'B': 1, 'C': 3, 'D': inf}}
[0.002s] demo_dijkstra.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (3, 'C'), 'state': [(4, 'C'), (3, 'C')]}
[0.002s] demo_dijkstra.py:23 (set) dijkstra.neighbor = 'D'
[0.002s] demo_dijkstra.py:23 (set) dijkstra.weight = 5
[0.002s] demo_dijkstra.py:25 (set) dijkstra.new_dist = 6
[0.002s] demo_dijkstra.py:26 (change) dijkstra.distances.D = {'op': 'setitem', 'value': 6, 'state': {'A': 0, 'B': 1, 'C': 3, 'D': 6}}
[0.002s] demo_dijkstra.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (6, 'D'), 'state': [(4, 'C'), (3, 'C'), (6, 'D')]}
[0.002s] demo_dijkstra.py:29 (change) dijkstra.queue = {'op': 'sort', 'args': (), 'kwargs': {}, 'state': [(3, 'C'), (4, 'C'), (6, 'D')]}
[0.002s] demo_dijkstra.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (3, 'C'), 'state': [(4, 'C'), (6, 'D')]}
[0.002s] demo_dijkstra.py:17 (set) dijkstra.node = 'C'
[0.002s] demo_dijkstra.py:17 (set) dijkstra.current_dist = 3
[0.002s] demo_dijkstra.py:20 (change) dijkstra.visited = {'op': 'add', 'value': 'C', 'state': {'C', 'A', 'B'}}
[0.002s] demo_dijkstra.py:23 (set) dijkstra.weight = 1
[0.002s] demo_dijkstra.py:25 (set) dijkstra.new_dist = 4
[0.002s] demo_dijkstra.py:26 (change) dijkstra.distances.D = {'op': 'setitem', 'value': 4, 'state': {'A': 0, 'B': 1, 'C': 3, 'D': 4}}
[0.003s] demo_dijkstra.py:27 (change) dijkstra.queue = {'op': 'append', 'value': (4, 'D'), 'state': [(4, 'C'), (6, 'D'), (4, 'D')]}
[0.003s] demo_dijkstra.py:29 (change) dijkstra.queue = {'op': 'sort', 'args': (), 'kwargs': {}, 'state': [(4, 'C'), (4, 'D'), (6, 'D')]}
[0.003s] demo_dijkstra.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (4, 'C'), 'state': [(4, 'D'), (6, 'D')]}
[0.003s] demo_dijkstra.py:17 (set) dijkstra.current_dist = 4
[0.003s] demo_dijkstra.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (4, 'D'), 'state': [(6, 'D')]}
[0.003s] demo_dijkstra.py:17 (set) dijkstra.node = 'D'
[0.003s] demo_dijkstra.py:20 (change) dijkstra.visited = {'op': 'add', 'value': 'D', 'state': {'C', 'A', 'D', 'B'}}
[0.003s] demo_dijkstra.py:29 (change) dijkstra.queue = {'op': 'sort', 'args': (), 'kwargs': {}, 'state': [(6, 'D')]}
[0.003s] demo_dijkstra.py:15 (change) dijkstra.queue = {'op': 'pop', 'index': 0, 'value': (6, 'D'), 'state': []}
[0.003s] demo_dijkstra.py:17 (set) dijkstra.current_dist = 6
[0.003s] demo_dijkstra.py:31 (return) dijkstra = {'A': 0, 'B': 1, 'C': 3, 'D': 4}
Inspiration
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!
Limitations
- 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
Contact
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
License
MIT License © 2026
See LICENSE for details.
Version 1.3.1
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file logeye-1.3.1.tar.gz.
File metadata
- Download URL: logeye-1.3.1.tar.gz
- Upload date:
- Size: 26.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bec9e877328c097d62ededf354831957fe359c9a3ed7f6719ec856f633d1e263
|
|
| MD5 |
4977b6b88c060635584438aef5b4d64c
|
|
| BLAKE2b-256 |
8397f252cfd2c7795828a748a3be880c10cad62d80c91fe048a3153a74d1b663
|
File details
Details for the file logeye-1.3.1-py3-none-any.whl.
File metadata
- Download URL: logeye-1.3.1-py3-none-any.whl
- Upload date:
- Size: 21.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d27e5d0752f4596d0b05d074cb47b135c3bcea19b86440299593b7e9d59db6c
|
|
| MD5 |
38f5d7fd47099eba94e4ee71c674bb24
|
|
| BLAKE2b-256 |
bd59ccf7fc1c50a3b7ccd134060150bf589ea5d64e28fa823ecd06857e03d048
|