Metadata-Version: 2.4
Name: simplibs-introspection
Version: 0.2.0
Summary: Introspection and stack-walking utilities for the simplibs ecosystem.
Author-email: "Dalibor Sova (Sudip2708)" <daliborsova@seznam.cz>
License-Expression: MIT
Project-URL: Homepage, https://github.com/simplibs/simplibs-introspection
Project-URL: Repository, https://github.com/simplibs/simplibs-introspection
Project-URL: Issues, https://github.com/simplibs/simplibs-introspection/issues
Project-URL: Changelog, https://github.com/simplibs/simplibs-introspection/blob/main/CHANGELOG.md
Keywords: introspection,stack-trace,caller-info,simplibs,developer-tools,debugging
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Dynamic: license-file

# simplibs-introspection

> **Find what you're looking for in the call stack — safely.**
>
> Diagnostic tools that inspect the runtime state without crashing your program. Perfect for logging, error handling, and defensive architecture.

![Python](https://img.shields.io/badge/python-3.11%2B-blue)
![Licence](https://img.shields.io/badge/licence-MIT-green)
![PyPI](https://img.shields.io/pypi/v/simplibs-introspection)

```python
from simplibs.introspection import extract_caller_info

# You're deep in a library. Find who called you — without crashing.
info = extract_caller_info()
if info:
    print(f"{info['function']} in {info['file']}:{info['line']}")
```

---

## Why This Library?

You're building a library. Your function is buried three levels deep in helper calls — it needs to know who the *actual user* is, not which internal function called it directly. You reach for Python's `inspect` module... and suddenly you're wrestling with stack frames, dynamic frames, exception handling, and hardcoding frame offsets.

`simplibs-introspection` solves this. It gives you a simple, defensive interface for reading the call stack. Filter out your library's internals. Get the first frame that matters. No crashes, no exceptions — just data or `None`.

---

## Installation

```bash
pip install simplibs-introspection
```

---

## Quick Start

### Level 1: "Who called me?"

The simplest case: you want the immediate caller in user code (skipping your library's internals).

```python
from simplibs.introspection import extract_caller_info

info = extract_caller_info()
# Returns the first frame outside simplibs-introspection
```

### Level 2: "Walk up the call chain"

You need the second or third user frame — useful when tracing ownership through wrapper layers.

```python
from simplibs.introspection import extract_caller_info

# Get the caller's caller
info = extract_caller_info(expected_frames=2)

# Get the third level up
info = extract_caller_info(expected_frames=3)
```

### Level 3: "Hide extra patterns"

When building a wrapper library, you may want to hide *your* internals too. Pass patterns to skip.

```python
from simplibs.introspection import extract_caller_info

# Skip both simplibs-introspection AND your own internal modules
info = extract_caller_info(
    expected_frames=1,
    excluded_patterns=("my_internal_lib", "decorators")
)
```

---

## Core Functions

### `extract_caller_info()` — Find the first relevant frame

Walks the call stack and returns information about the first frame that isn't in your exclusion list.

```python
from simplibs.introspection import extract_caller_info

info = extract_caller_info()

if info:
    print(f"File: {info['file']}")              # "main.py"
    print(f"Full path: {info['full_path']}")    # "/home/user/project/main.py"
    print(f"Function: {info['function']}")      # "process_data"
    print(f"Line: {info['line']}")              # 42 (int)
else:
    print("No relevant frame found (rare)")
```

**Return dictionary:**

| Key | Type | Meaning |
|-----|------|---------|
| `file` | str | Filename without path (basename) |
| `full_path` | str | Absolute file path, platform-native format |
| `line` | int | Line number where the call happened |
| `function` | str | Function name (or `<module>` for module level) |

**Parameters:**

| Parameter | Type | Default | Purpose |
|-----------|------|---------|---------|
| `expected_frames` | int | `1` | Which valid frame to return (1 = first, 2 = second, etc.) |
| `excluded_patterns` | tuple | `()` | Strings that, if found in a path, cause that frame to be skipped |

---

## How It Works

### The Two-Layer Filter

By default, the function skips two types of frames:

1. **Always excluded**: Python's dynamic frames (`<string>`, `<frozen importlib>`, etc.)
2. **Auto-excluded**: `simplibs-introspection` itself

But it keeps *every* other frame.

Then it counts: "How many kept frames have I seen?" When that count reaches `expected_frames`, it returns that frame.

**This means `expected_frames=1` always returns the first line of *user* code**, regardless of how many internal wrappers sit above it:

```
Stack (bottom to top):
0  extract_caller_info        ← skipped (the function itself)
1  library.helper()           ← skipped (your internal code)
2  library.validate()         ← skipped (your internal code)
3  user_code.py:main()        ← frame #1 (valid) → returned
4  user_code.py:wrapper()     ← frame #2 (valid) → would be returned for expected_frames=2

extract_caller_info(expected_frames=1)  # → Returns frame 3
extract_caller_info(expected_frames=2)  # → Returns frame 4
```

### Cross-Platform Paths

The function automatically normalizes paths. Whether you're on Windows or Unix, patterns work consistently:

```python
# Works on both Windows and Unix:
excluded_patterns = ("my_lib/utils", "my_lib/helpers")

info = extract_caller_info(expected_frames=1, excluded_patterns=excluded_patterns)
# On Windows: C:\project\my_lib\utils\tool.py is matched
# On Unix:    /home/user/project/my_lib/utils/tool.py is matched
```

### The Fallout Mechanism: Always Returns Something

When the stack is shallower than `expected_frames`, the function doesn't return `None`. Instead, it returns the last frame it examined — usually the program entry point.

This is intentional:

```python
# Stack is only 3 frames deep, but you ask for frame #10
info = extract_caller_info(expected_frames=10)

# You get frame #3 (the deepest)
# Why? Because even if you don't get what you asked for,
# returning *something* is better than silence.
```

This is especially useful in error handling: even if your code path is shallower than expected, you still get *some* diagnostic context.

---

## Filtering Your Own Code

When you're building a library, use `excluded_patterns` to hide your internals:

```python
from simplibs.introspection import extract_caller_info

class MyLibrary:
    def __init__(self):
        self._internal_patterns = ("mylib.internals", "mylib.decorators")
    
    def public_function(self, user_data):
        # Tell the user where they're calling from
        info = extract_caller_info(excluded_patterns=self._internal_patterns)
        
        if info:
            print(f"Called from user code: {info['function']} ({info['file']}:{info['line']})")
```

---

## Advanced: Custom Frame Selection

### Walking up the call chain

Track the flow through multiple user-level function calls:

```python
from simplibs.introspection import extract_caller_info

def first_level():
    """You are here"""
    info = extract_caller_info(expected_frames=1)
    # → Returns the function that called first_level()

def second_level():
    info = extract_caller_info(expected_frames=2)
    # → Returns the function that called the function that called second_level()
```

Real use case: a logging decorator that needs to know the *original user function*, not the decorator itself.

```python
def log_calls(func):
    def wrapper(*args, **kwargs):
        # Find the actual user code, not wrapper()
        info = extract_caller_info(expected_frames=2, excluded_patterns=("decorator_lib",))
        
        if info:
            print(f"[LOG] User called {info['function']} at {info['file']}:{info['line']}")
        
        return func(*args, **kwargs)
    return wrapper

@log_calls
def process():
    pass
```

### Excluding multiple patterns

Hide multiple parts of your codebase:

```python
info = extract_caller_info(
    expected_frames=1,
    excluded_patterns=("mylib.core", "mylib.internals", "mylib.helpers")
)
```

Every frame containing *any* of these strings is skipped. The first frame *not* containing them is returned.

---

## Advanced: Error Reporting with Diagnostics

Use stack inspection to provide context in error messages:

```python
from simplibs.introspection import extract_caller_info
from simplibs.exception import SimpleException

def validate_config(config):
    if not isinstance(config, dict):
        # Report where the bad call came from
        info = extract_caller_info()
        location = f"{info['file']}:{info['line']}" if info else "unknown"
        
        raise SimpleException(
            problem=f"config must be dict, got {type(config).__name__}",
            value=config,
            # Include the caller's location in the error context
        )
```

---

## Error Handling

`extract_caller_info()` is defensive. It will never raise an exception.

**Returns:**
- A dictionary with keys `file`, `full_path`, `line`, `function` — on success
- `None` — only in exceptional circumstances (stack inspection failed internally)

**It always degrades gracefully:**

```python
result = extract_caller_info()

# Always safe to check:
if result:
    print(f"{result['function']} at {result['file']}:{result['line']}")
else:
    print("Could not inspect stack (very rare)")
```

**Invalid inputs are handled silently:**

```python
# Bad input? Just returns None or a frame, never raises
extract_caller_info(expected_frames="not an int")        # → None or frame
extract_caller_info(excluded_patterns="not a tuple")     # → None or frame
extract_caller_info(expected_frames=-999)                # → None or frame
```

---

## Performance

- **Single-pass stack walk**: Iterates the stack once, early exit on success
- **No source code loading**: Uses `context=0` to skip file I/O
- **Minimal overhead**: Only filters and counts, no heavy operations
- **Safe defaults**: Filtering is fast string matching

For most use cases, `extract_caller_info()` takes less than 1ms.

---

## Under the Hood

### Why `expected_frames` instead of fixed offsets?

Old approach: `skip_frames=2` means "skip 2 frames and return the 3rd".

Problem: If your library has internal frames, the math breaks:
```
Real stack:     0  1  2  3  4
Your code:      X  X  X  ✓  ✓
skip_frames=2:  Skip to → 3 ✗ (wrong, that's library internals)
```

New approach: `expected_frames=1` means "return the 1st frame that isn't excluded".

```
Real stack:     0  1  2  3  4
Your code:      X  X  X  ✓  ✓
Filter:         ×  ×  ×  ✓  ✓
expected_frames=1:  → 3 ✓ (correct, first user frame)
```

### Cross-platform normalization

Paths are normalized via `Path().as_posix()`, which converts Windows backslashes to forward slashes. This makes pattern matching work consistently:

```python
# Pattern: "mylib/utils"
# Windows path: C:\project\mylib\utils\helper.py → normalized to C:/project/mylib/utils/helper.py
# Unix path:    /home/user/mylib/utils/helper.py → unchanged
# Match result: ✓ Both match
```

---

## About simplibs

`simplibs-introspection` is part of the **simplibs** ecosystem — a collection of small, self-contained Python libraries.

### Philosophy

**Dyslexia-friendly** — minimize mental load. Atomize code into self-contained units, name things after what they do, explain *why* not just *what*.

**Programmer's zen** — nothing missing, nothing superfluous. Better to go slowly and correctly than quickly with mistakes. Gradual refinement towards crystallization.

**Defensive style** — anticipate failure modes. Degrade gracefully, never raise unexpected errors.

**Minimalism** — find the shortest path to the goal, but leave nothing out. Each file has one responsibility.

**Code as craft** — code is a small work of art. Optimize for the reader.

### Designed for expansion

Currently `simplibs-introspection` offers stack frame inspection. As the ecosystem grows, additional introspection tools (call graph analysis, runtime state snapshots, etc.) may be added while keeping the same defensive philosophy.

---

## Reference: Common Patterns

### Pattern: Library error reporting with context

```python
from simplibs.introspection import extract_caller_info

def my_library_validate(value):
    if not isinstance(value, str):
        info = extract_caller_info(excluded_patterns=("my_library",))
        location = f"{info['file']}:{info['line']}" if info else "unknown location"
        raise ValueError(f"Expected str at {location}, got {type(value)}")

# User code
my_library_validate(42)
# Error message includes: my_script.py:10
```

### Pattern: Tracing through decorator layers

```python
from simplibs.introspection import extract_caller_info

def traced_decorator(func):
    def wrapper(*args, **kwargs):
        # Find the original caller, skip the decorator wrapper
        info = extract_caller_info(expected_frames=2)
        
        print(f"Trace: {func.__name__} called from {info['function'] if info else '?'}")
        return func(*args, **kwargs)
    return wrapper

@traced_decorator
def my_function():
    pass

# my_function()  →  prints: "Trace: my_function called from <module>"
```

### Pattern: Conditional logging based on depth

```python
from simplibs.introspection import extract_caller_info

def deep_function():
    # Log where we're being called from
    for level in range(1, 4):
        info = extract_caller_info(expected_frames=level)
        if info:
            print(f"Level {level}: {info['function']} in {info['file']}")
        else:
            break

# Shows the call chain: who called who
```

### Pattern: Multi-library environment

```python
from simplibs.introspection import extract_caller_info

# Hide multiple libraries' internals
info = extract_caller_info(
    expected_frames=1,
    excluded_patterns=("mylib", "simplibs", "requests", "flask")
)

if info:
    print(f"First user code: {info['function']} ({info['file']})")
```

---

## License

MIT.

---

*Tests are part of the repository and serve as living documentation of expected behavior.*
