Metadata-Version: 2.4
Name: dcnr-minisandbox
Version: 1.0.7
Summary: Minisandbox for Python interpreter.
Project-URL: Homepage, https://github.com/gopa810/dcnr-minisandbox/blob/main/README.md
Project-URL: Changelog, https://github.com/gopa810/dcnr-minisandbox/blob/main/CHANGELOG.md
Author-email: Peter Kollath <peter.kollath@gopal.home.sk>
License: MIT
Keywords: functional-programming,interpreter,parser,python,sandbox
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# DCNR Minisandbox

A secure, lightweight Python sandbox interpreter that allows safe execution of untrusted Python code with strict limitations, plus a global object registry and LPC (Local Procedure Call) system for application data management and anonymous method execution.

## Features

- **Secure AST-based execution** - Uses Python's AST parser to validate and execute code without using `eval()` or `exec()`
- **Restricted syntax** - Only allows a safe subset of Python constructs
- **Built-in safety limits** - Prevents infinite loops with iteration limits
- **Customizable environment** - Pre-populate variables and control available functions
- **Comprehensive error handling** - Clear error messages for syntax and runtime violations
- **Global object registry** - Hierarchical storage and access system for application objects and data
- **LPC (Local Procedure Call)** - Anonymous execution of registered methods by name

## Installation

```bash
pip install dcnr-minisandbox
```

## Components

This package provides three main components:

1. **Sandbox Interpreter** (`SandboxInterpreter`, `sandbox_exec`) - Secure execution of Python code with AST validation
2. **Object Registry** (`get_registry`, `register_object`) - Global hierarchical storage for application objects
3. **LPC (Local Procedure Call)** (`register_proc`, `exec`) - Anonymous execution of registered methods

## Quick Start

### Sandbox Execution

The sandbox can be used either via the convenience function `sandbox_exec` or directly through the `SandboxInterpreter` class.

```python
from dcnr.minisandbox import sandbox_exec

# Simple calculation
code = """
x = 10
y = 20
result = x + y * 2
"""

variables = {}
result = sandbox_exec(code, variables)
print(result)  # {'x': 10, 'y': 20, 'result': 50}

# Pre-populate with variables
initial_vars = {'data': [1, 2, 3, 4, 5]}
code = """
total = sum(data)
average = total / len(data)
"""

result = sandbox_exec(code, initial_vars)
print(result)  # {'data': [1, 2, 3, 4, 5], 'total': 15, 'average': 3.0}

# Using attribute access and method calls
code = """
text = "hello world"
upper_text = text.upper()
words = text.split()
data = [1, 2, 3]
data.append(4)
"""

result = sandbox_exec(code, {})
print(result)  # {'text': 'hello world', 'upper_text': 'HELLO WORLD', 'words': ['hello', 'world'], 'data': [1, 2, 3, 4]}
```

### Global Object Registry

```python
from dcnr.minisandbox import register_object, get_registry

# Register functions and data
def fetchone():
    return {"id": 1, "name": "Alice"}

register_object("database.prod.fetchone", fetchone)
register_object("database.prod.version", "1.0")
register_object("config.debug", True)

# Access through registry
registry = get_registry()
print(registry.database.prod.fetchone())  # {'id': 1, 'name': 'Alice'}
print(registry.database.prod.version)     # '1.0'
print(registry.config.debug)              # True
```

### LPC (Local Procedure Call)

```python
from dcnr.lpc import register_proc, exec

# Register procedures
def calculate_sum(a, b):
    return a + b

def format_message(name, age):
    return f"{name} is {age} years old"

register_proc("math.sum", calculate_sum)
register_proc("string.format", format_message)

# Execute procedures anonymously
result = exec("math.sum", 10, 5)
print(result)  # 15

message = exec("string.format", "Alice", 30)
print(message)  # "Alice is 30 years old"

# Using decorator syntax
from dcnr.lpc import lpc_proc

@lpc_proc("math.multiply")
def multiply(x, y):
    return x * y

result = exec("math.multiply", 4, 7)
print(result)  # 28
```

## Allowed Features

### Data Types
- Numbers: `int`, `float`
- Strings: `str`
- Collections: `list`, `tuple`, `dict`, `set`
- Booleans: `bool`

### Operators
- Arithmetic: `+`, `-`, `*`, `/`, `//`, `%`, `**`
- Comparison: `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `not in`, `is`, `is not`
- Logical: `and`, `or`, `not`

### Control Flow
- Conditional statements: `if`, `elif`, `else`
- Loops: `for`, `while` (with iteration limits)
- Loop control: `break`, `continue`

### Built-in Functions
- Math: `abs()`, `min()`, `max()`, `sum()`
- Type conversion: `int()`, `float()`, `str()`, `bool()`
- Collections: `len()`, `range()`, `list()`, `tuple()`, `dict()`, `set()`, `sorted()`

### Variable Operations
- Assignment: `x = value`
- Augmented assignment: `x += 1`, `x -= 1`, etc.
- Tuple/list unpacking: `a, b = (1, 2)`
- Subscript access: `data[0]`, `data[1:3]`
- Attribute access: `obj.attr`, `obj.method()`

## Prohibited Features

For security reasons, the following are **not allowed**:

- **Imports**: No `import` or `from ... import` statements
- **Function definitions**: No `def`, `lambda`, or `class` definitions
- **Dangerous attribute access**: No access to `__class__`, `__dict__`, `__module__`, etc.
- **Exception handling**: No `try`, `except`, `finally`, `raise`
- **File I/O**: No file operations or system calls
- **Advanced features**: No comprehensions, generators, decorators, or async code

## Global Object Registry for storing and accessing application objects, functions, and configuration data.

### Registry Features

- **Dot-notation access** - Navigate the registry like `registry.database.prod.version`
- **Safe path validation** - Prevents conflicts with Python keywords and reserved names
- **Singleton pattern** - Single global registry instance across your application
- **Type safety** - Automatic wrapping of nested dictionaries for consistent access

### Registry API

```python
from dcnr.minisandbox import get_registry, register_object

# Get the global registry instance
registry = get_registry()

# Register objects at various paths
register_object("app.config.debug", True)
register_object("app.database.host", "localhost")
register_object("app.database.port", 5432)

# Alternative: use registry methods directly
registry.register_object("services.auth.enabled", True)

# Access registered objects
print(registry.app.config.debug)      # True
print(registry.app.database.host)     # 'localhost'
print(registry.services.auth.enabled) # True

# Check if path exists
if registry.has_path("app.config.debug"):
    print("Debug mode is configured")

# Get object programmatically
db_host = registry.get_object("app.database.host")

# Remove objects
registry.unregister_object("app.config.debug")

# Clear entire registry
registry.clear()
```

### Path Restrictions

For safety and consistency, certain path components are not allowed:

```python
# ❌ These will raise ValueError:
register_object("app.items.test", 1)      # 'items' is reserved (dict method)
register_object("app.__class__.x", 1)     # Names starting with '_' not allowed
register_object("app.for.x", 1)          # 'for' is a Python keyword
register_object("app..x", 1)             # Empty components not allowed
```

## SandboxInterpreter Class

For advanced use cases, use `SandboxInterpreter` directly. This gives you full control over the interpreter lifecycle, debug logging, and the ability to inject callable functions that interact with the sandbox environment.

### Basic Class Usage

```python
from dcnr.minisandbox import SandboxInterpreter

interp = SandboxInterpreter(variables={"x": 10, "y": 5})
env = interp.execute("result = x + y")
print(env["result"])  # 15

# Re-use the same instance for multiple executions
# (environment accumulates across calls)
interp.execute("z = result * 2")
print(interp.env["z"])  # 30
```

### Debug Mode

`SandboxInterpreter` has a built-in debug buffer that records every statement, its input values and its result in execution order.

```python
from dcnr.minisandbox import SandboxInterpreter

interp = SandboxInterpreter(debug=True)
interp.execute("""
x = 4
y = x * 3
items = [1, 2, 3]
items.append(y)
""")

print(interp.get_debug_log())
```

Example output:

```
[Constant]
  => 4
[Assign.Name]
  name: 'x'
  => 4
[Assign]
  targets: ['x']
  value: 4
  => 4
[BinOp]
  op: 'Mult'
  left: 4
  right: 3
  => 12
...
```

Debug mode can also be toggled at runtime and the buffer cleared between executions:

```python
interp = SandboxInterpreter()         # debug off by default
interp.set_debug(True)
interp.execute("a = 1 + 2")
log = interp.get_debug_log()
interp.clear_debug_log()              # reset buffer for next run
```

### Registering Callable Functions in the Environment

Any callable placed in the `variables` dict is directly callable from sandbox code by its key name. This is the primary mechanism for extending the sandbox with host-side logic.

```python
from dcnr.minisandbox import SandboxInterpreter

def fetch_price(product_id):
    prices = {1: 9.99, 2: 14.99, 3: 4.49}
    return prices.get(product_id, 0.0)

interp = SandboxInterpreter(variables={"fetch_price": fetch_price})
env = interp.execute("""
price = fetch_price(2)
total = price * 3
""")
print(env["total"])  # 44.97
```

### Writing Back to the Sandbox Environment from a Registered Function

A registered function can write values directly into `interp.env` so those values appear as normal variables in the dict returned by `execute()`. The cleanest approach is to **close over `interp.env`** using a factory function:

```python
from dcnr.minisandbox import SandboxInterpreter

def make_env_writer(env):
    """Return a callable the sandbox can call to write a named variable."""
    def set_var(name, value):
        env[name] = value
    return set_var

interp = SandboxInterpreter()
# Register after creation so the closure captures the live env dict
interp.env["set_var"] = make_env_writer(interp.env)

env = interp.execute("""
set_var("status", "done")
set_var("count", 42)
""")

print(env["status"])  # "done"
print(env["count"])   # 42
```

A more elaborate example — a host-side function that fetches a record and writes multiple result variables back into the sandbox:

```python
from dcnr.minisandbox import SandboxInterpreter

def make_db_fetch(env):
    database = {
        1: {"name": "Alice", "score": 95},
        2: {"name": "Bob",   "score": 82},
    }
    def db_fetch(record_id):
        record = database.get(record_id)
        if record:
            for key, value in record.items():
                env[key] = value      # each field becomes a sandbox variable
            env["found"] = True
        else:
            env["found"] = False
    return db_fetch

interp = SandboxInterpreter(variables={"user_id": 1})
interp.env["db_fetch"] = make_db_fetch(interp.env)

env = interp.execute("""
db_fetch(user_id)
if found:
    result = name + " scored " + str(score)
""")

print(env["result"])  # "Alice scored 95"
```

### TrackedDict

`TrackedDict` is a `dict` subclass that records which keys were **written** or **deleted** since the last `clear_updates()` call. Pass it as the `variables` argument to observe exactly what the sandbox script produced or modified, without inspecting the whole environment.

```python
from dcnr.minisandbox import SandboxInterpreter, TrackedDict

initial = TrackedDict({"x": 10, "y": 5})
interp = SandboxInterpreter(variables=initial)
env = interp.execute("""
result = x + y
label  = "sum"
""")

# Only keys written during execution are reported
for key, value in env.updated_items():
    print(f"  {key} = {value!r}")
# result = 15
# label  = 'sum'

print(env.deleted_keys())  # []
```

#### Resetting the Tracker Between Runs

Call `clear_updates()` before each `execute()` call to get only the changes from that specific run:

```python
env.clear_updates()
interp.execute("z = result * 2")

new_writes = dict(env.updated_items())
print(new_writes)  # {'z': 20}
```

#### Combining TrackedDict with a Registered Writer Function

`TrackedDict` works transparently with the env-writer pattern — writes made by the registered function are tracked just like writes made by sandbox statements:

```python
from dcnr.minisandbox import SandboxInterpreter, TrackedDict

tracked = TrackedDict()
interp = SandboxInterpreter(variables=tracked)
interp.env["set_var"] = lambda name, value: tracked.__setitem__(name, value)

interp.execute("""
set_var("api_result", 42)
total = api_result * 2
""")

print(dict(tracked.updated_items()))
# {'api_result': 42, 'total': 84}
```

#### TrackedDict API Reference

| Method | Description |
|---|---|
| `updated_items()` | Generator of `(key, value)` for every key written since last `clear_updates()` |
| `deleted_keys()` | List of keys deleted since last `clear_updates()` |
| `clear_updates()` | Reset both the written and deleted sets |

## LPC (Local Procedure Call)

The LPC package provides anonymous execution of registered callable methods, enabling dynamic procedure dispatch without direct references to the implementation.

### LPC Features

- **Anonymous execution** - Call procedures by name without importing or referencing them directly
- **Dynamic registration** - Register procedures at runtime from any part of your application
- **Decorator support** - Use `@lpc_proc` decorator for clean registration syntax
- **Type safety** - Validates that registered objects are callable
- **Error handling** - Clear exceptions for missing procedures or execution failures
- **Procedure management** - List, check, and remove registered procedures

### Core API

#### Registration Functions

```python
from dcnr.lpc import register_proc, lpc_proc

# Function registration
def add_numbers(a, b):
    return a + b

register_proc("math.add", add_numbers)

# Decorator registration
@lpc_proc("math.subtract")
def subtract_numbers(a, b):
    return a - b

# Lambda registration
register_proc("math.square", lambda x: x ** 2)
```

#### Execution Function

```python
from dcnr.lpc import exec

# Execute registered procedures
result1 = exec("math.add", 10, 5)        # 15
result2 = exec("math.subtract", 10, 5)   # 5
result3 = exec("math.square", 4)         # 16

# With keyword arguments
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

register_proc("utils.greet", greet)
message = exec("utils.greet", "Alice", greeting="Hi")  # "Hi, Alice!"
```

#### Management Functions

```python
from dcnr.lpc import has_proc, list_procs, proc_count, unregister_proc, clear_procs

# Check if procedure exists
if has_proc("math.add"):
    print("Math add procedure is available")

# List all registered procedures
procedures = list_procs()
print("Available procedures:", procedures)

# Get procedure count
count = proc_count()
print(f"Total procedures: {count}")

# Remove specific procedure
unregister_proc("math.add")

# Clear all procedures
clear_procs()
```

### Advanced Usage

#### Procedure Pipelines

```python
from dcnr.lpc import register_proc, exec

# Register data processing pipeline
register_proc("data.load", lambda: [1, 2, 3, 4, 5])
register_proc("data.filter_even", lambda data: [x for x in data if x % 2 == 0])
register_proc("data.sum", lambda data: sum(data))

# Execute pipeline
raw_data = exec("data.load")
filtered_data = exec("data.filter_even", raw_data)
result = exec("data.sum", filtered_data)
print(result)  # 6 (2 + 4)
```

#### Conditional Procedure Execution

```python
from dcnr.lpc import exec, has_proc

def safe_exec(proc_name, *args, **kwargs):
    if has_proc(proc_name):
        return exec(proc_name, *args, **kwargs)
    else:
        print(f"Procedure '{proc_name}' not available")
        return None

# Safe execution
result = safe_exec("math.add", 1, 2)  # Works if registered
result = safe_exec("missing.proc", 1, 2)  # Prints warning, returns None
```

#### Dynamic Procedure Loading

```python
from dcnr.lpc import register_proc, exec

# Dynamic loading based on configuration
config = {
    "processors": ["data.clean", "data.transform", "data.validate"]
}

def clean_data(data):
    return [x for x in data if x is not None]

def transform_data(data):
    return [x * 2 for x in data]

def validate_data(data):
    return all(isinstance(x, (int, float)) for x in data)

# Register based on configuration
processors = {
    "data.clean": clean_data,
    "data.transform": transform_data,
    "data.validate": validate_data
}

for proc_name in config["processors"]:
    if proc_name in processors:
        register_proc(proc_name, processors[proc_name])

# Execute processing chain
data = [1, None, 3, 4.5]
for proc_name in config["processors"]:
    data = exec(proc_name, data)
print(data)  # [2, 6, 9.0] (cleaned, transformed, validated)
```

### Error Handling

The LPC package provides specific exception types for different error scenarios:

```python
from dcnr.lpc import exec, register_proc
from dcnr.lpc import ProcedureNotFoundError, ProcedureExecutionError, LPCError

# Handle procedure not found
try:
    result = exec("nonexistent.procedure", 1, 2, 3)
except ProcedureNotFoundError as e:
    print(f"Procedure not found: {e}")

# Handle execution errors
def risky_procedure(x):
    if x < 0:
        raise ValueError("Negative values not allowed")
    return x * 2

register_proc("math.risky", risky_procedure)

try:
    result = exec("math.risky", -5)
except ProcedureExecutionError as e:
    print(f"Procedure execution failed: {e}")
    # Original exception available via e.__cause__

# Handle registration errors
try:
    register_proc("invalid.proc", "not_a_function")
except TypeError as e:
    print(f"Registration failed: {e}")

# Catch all LPC errors
try:
    result = exec("some.procedure", arg1, arg2)
except LPCError as e:
    print(f"LPC error occurred: {e}")
```

### Best Practices

#### Naming Conventions

Use hierarchical naming for better organization:

```python
# Good naming patterns
register_proc("auth.login", login_user)
register_proc("auth.logout", logout_user)
register_proc("data.users.create", create_user)
register_proc("data.users.update", update_user)
register_proc("utils.validation.email", validate_email)
register_proc("utils.formatting.currency", format_currency)
```

#### Error-Safe Execution

```python
from dcnr.lpc import exec, has_proc, ProcedureNotFoundError

def safe_procedure_call(proc_name, *args, default=None, **kwargs):
    """Execute procedure with fallback to default value."""
    try:
        if has_proc(proc_name):
            return exec(proc_name, *args, **kwargs)
        else:
            print(f"Warning: Procedure '{proc_name}' not registered")
            return default
    except Exception as e:
        print(f"Error executing '{proc_name}': {e}")
        return default

# Usage
result = safe_procedure_call("math.divide", 10, 2, default=0)
```

#### Procedure Documentation

```python
from dcnr.lpc import register_proc, list_procs

def documented_procedure(x, y):
    """
    Add two numbers together.
    
    Args:
        x: First number
        y: Second number
    
    Returns:
        Sum of x and y
    """
    return x + y

register_proc("math.add", documented_procedure)

# Access documentation
from dcnr.lpc import get_proc
proc = get_proc("math.add")
print(proc.__doc__)  # Prints the docstring
```

## Error Handling

### Sandbox Errors

The sandbox raises specific exceptions for different types of violations:

```python
from dcnr.minisandbox import sandbox_exec, SandboxSyntaxError, SandboxRuntimeError

try:
    # This will raise SandboxSyntaxError
    sandbox_exec("import os", {})
except SandboxSyntaxError as e:
    print(f"Syntax violation: {e}")

try:
    # This will raise SandboxRuntimeError  
    sandbox_exec("unknown_variable", {})
except SandboxRuntimeError as e:
    print(f"Runtime error: {e}")
```

### Registry Errors

The registry raises standard Python exceptions for invalid operations:

```python
from dcnr.minisandbox import register_object, get_registry

try:
    # Invalid path component
    register_object("app.for.test", 1)  # 'for' is a Python keyword
except ValueError as e:
    print(f"Invalid path: {e}")

try:
    # Path not found
    registry = get_registry()
    value = registry.get_object("nonexistent.path")
except KeyError as e:
    print(f"Path not found: {e}")
```

## Safety Features

### Iteration Limits
Loops are automatically limited to 100,000 iterations to prevent infinite loops:

```python
# This will raise SandboxRuntimeError after 100,000 iterations
code = """
i = 0
while True:
    i += 1
"""
```

### Memory Safety
Only safe data types and operations are allowed. No access to system resources or dangerous built-ins.

## Use Cases

### Sandbox Execution
- **Educational platforms** - Safe execution of student code
- **Code challenges** - Running untrusted submissions
- **Configuration scripts** - Controlled execution of user-defined logic
- **Expression evaluation** - Safe calculation of mathematical expressions
- **Templating** - Dynamic value computation in templates

### Object Registry
- **Application configuration** - Centralized storage of settings and parameters
- **Service registration** - Registry for application services and dependencies
- **Plugin systems** - Dynamic registration and discovery of plugins
- **Feature flags** - Hierarchical feature toggle management
- **Resource management** - Centralized access to database connections, APIs, etc.

### LPC (Local Procedure Call)
- **Plugin architectures** - Dynamic loading and execution of plugin methods
- **Microservices** - Anonymous procedure calls between service components
- **Workflow engines** - Step-by-step execution of named procedures in workflows
- **API routing** - Map request paths to procedure implementations
- **Command pattern** - Decouple command invocation from implementation
- **Data processing pipelines** - Chain procedures for data transformation
- **Event handling** - Register and execute event handlers by name
- **Configuration-driven execution** - Execute procedures based on configuration files

## Limitations

- No custom function definitions
- No module imports or external libraries
- Limited to basic Python constructs
- No file system or network access
- Fixed iteration limits for loops

## Development

To contribute to dcnr-minisandbox:

1. Clone the repository
2. Install development dependencies
3. Run tests with your changes
4. Submit a pull request

## License

This project is licensed under the MIT License - see the LICENSE file for details.

## Author

Peter Kollath (peter.kollath@gopal.home.sk)

## Links

- Homepage: https://dcnr-utilities.com/minisandbox
- PyPI: https://pypi.org/project/dcnr-minisandbox/
