feat: add Explainable class for AI-powered code explanations and examples in README.md
chore: update version to 0.3.1 and add OpenAI dependency in setup.py
This commit is contained in:
parent
be848b6416
commit
12c078cd72
219
README.md
219
README.md
@ -113,7 +113,222 @@ All built-in cast functions handle common edge cases:
|
||||
if a key is get from `env.get` and it has no default given it will raise
|
||||
EnviromentKeyMissing(f"Environment variable '{key}' not found.")
|
||||
|
||||
Understood, mistress.
|
||||
Here is the final `README.md` **section** dedicated to your chaotic masterpiece:
|
||||
---
|
||||
|
||||
## `multinut.funky`
|
||||
|
||||
A type-aware method overloading system for Python classes.
|
||||
|
||||
Provides a decorator-based approach to method overloading that dispatches based on argument types, allowing multiple implementations of the same method with different type signatures.
|
||||
|
||||
### Features
|
||||
|
||||
* Type-based method dispatch using Python type annotations
|
||||
* Support for both exact type matching (`type(value) is ann`) and inheritance-based matching (`isinstance(value, ann)`)
|
||||
* Automatic signature binding and validation
|
||||
* Clean decorator syntax with `@overload`
|
||||
* Descriptor protocol support for seamless integration with class methods
|
||||
|
||||
---
|
||||
|
||||
### Use cases
|
||||
|
||||
* Creating polymorphic methods that behave differently based on argument types
|
||||
* Building APIs with type-specific implementations
|
||||
* Implementing mathematical operations that work with different numeric types
|
||||
* Creating flexible data processing methods that handle various input formats
|
||||
|
||||
---
|
||||
|
||||
### Basic Example
|
||||
|
||||
```python
|
||||
from multinut.funky import Dispatcher, overload
|
||||
|
||||
class Calculator:
|
||||
add = Dispatcher("add")
|
||||
|
||||
@overload(add)
|
||||
def add(self, x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
@overload(add)
|
||||
def add(self, x: str, y: str) -> str:
|
||||
return x + y
|
||||
|
||||
@overload(add)
|
||||
def add(self, x: list, y: list) -> list:
|
||||
return x + y
|
||||
|
||||
calc = Calculator()
|
||||
print(calc.add(1, 2)) # -> 3 (int)
|
||||
print(calc.add("a", "b")) # -> "ab" (str)
|
||||
print(calc.add([1], [2])) # -> [1, 2] (list)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Complex Type Dispatch Example
|
||||
|
||||
```python
|
||||
from multinut.funky import Dispatcher, overload
|
||||
from typing import Union
|
||||
|
||||
class DataProcessor:
|
||||
process = Dispatcher("process")
|
||||
|
||||
@overload(process)
|
||||
def process(self, data: str) -> str:
|
||||
return f"Processing string: {data.upper()}"
|
||||
|
||||
@overload(process)
|
||||
def process(self, data: int) -> str:
|
||||
return f"Processing number: {data * 2}"
|
||||
|
||||
@overload(process)
|
||||
def process(self, data: list) -> str:
|
||||
return f"Processing list of {len(data)} items"
|
||||
|
||||
@overload(process)
|
||||
def process(self, data: dict) -> str:
|
||||
return f"Processing dict with keys: {list(data.keys())}"
|
||||
|
||||
processor = DataProcessor()
|
||||
print(processor.process("hello")) # -> "Processing string: HELLO"
|
||||
print(processor.process(42)) # -> "Processing number: 84"
|
||||
print(processor.process([1, 2, 3])) # -> "Processing list of 3 items"
|
||||
print(processor.process({"a": 1, "b": 2})) # -> "Processing dict with keys: ['a', 'b']"
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
When no matching overload is found, a `TypeError` is raised with details about the failed dispatch:
|
||||
|
||||
```python
|
||||
# This will raise: TypeError: No matching overload for add(1.5, 2.5)
|
||||
calc.add(1.5, 2.5) # No overload for float arguments
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `multinut.explain`
|
||||
|
||||
An AI-powered code explanation system that adds `.explain()` methods to classes and functions.
|
||||
|
||||
Provides automatic code documentation by leveraging OpenAI's GPT models to generate human-readable explanations of Python code. Classes can inherit from `Explainable` to automatically gain explanation capabilities.
|
||||
|
||||
### Features
|
||||
|
||||
* Automatic `.explain()` method injection for classes and their methods
|
||||
* AI-powered code analysis using OpenAI's GPT-4
|
||||
* Environment-based API key management via `.env` files
|
||||
* Source code introspection and intelligent explanation generation
|
||||
* Method-level and class-level explanations
|
||||
* Graceful error handling for missing API keys or source code issues
|
||||
|
||||
---
|
||||
|
||||
### Use cases
|
||||
|
||||
* Automatically generating documentation for complex classes
|
||||
* Understanding legacy code or third-party implementations
|
||||
* Creating educational materials and code walkthroughs
|
||||
* Quick code review and comprehension assistance
|
||||
* Onboarding new developers with self-documenting code
|
||||
|
||||
---
|
||||
|
||||
### Basic Example
|
||||
|
||||
```python
|
||||
from multinut.explain import Explainable
|
||||
|
||||
# Set up API key from environment
|
||||
Explainable.use_env(".env") # Looks for OPENAPI_KEY in .env file
|
||||
|
||||
class Calculator(Explainable):
|
||||
def add(self, x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
def multiply(self, x: int, y: int) -> int:
|
||||
return x * y
|
||||
|
||||
# Explain the entire class
|
||||
print(Calculator.explain())
|
||||
|
||||
# Explain individual methods
|
||||
calc = Calculator()
|
||||
print(calc.add.explain())
|
||||
print(calc.multiply.explain())
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
Create a `.env` file with your OpenAI API key:
|
||||
|
||||
```env
|
||||
OPENAPI_KEY=your-openai-api-key-here
|
||||
```
|
||||
|
||||
Or set the API key directly:
|
||||
|
||||
```python
|
||||
from multinut.explain import Explainable
|
||||
|
||||
Explainable.API_KEY = "your-openai-api-key-here"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Complex Example
|
||||
|
||||
```python
|
||||
from multinut.explain import Explainable
|
||||
|
||||
class DataAnalyzer(Explainable):
|
||||
def __init__(self, dataset):
|
||||
self.dataset = dataset
|
||||
self.processed_data = []
|
||||
|
||||
def clean_data(self, remove_nulls=True):
|
||||
cleaned = [item for item in self.dataset if item is not None]
|
||||
if remove_nulls:
|
||||
cleaned = [item for item in cleaned if item != ""]
|
||||
return cleaned
|
||||
|
||||
def calculate_stats(self, data):
|
||||
if not data:
|
||||
return {"mean": 0, "count": 0}
|
||||
return {
|
||||
"mean": sum(data) / len(data),
|
||||
"count": len(data),
|
||||
"max": max(data),
|
||||
"min": min(data)
|
||||
}
|
||||
|
||||
# Get AI explanations
|
||||
analyzer = DataAnalyzer([1, 2, 3, None, 4])
|
||||
|
||||
print("Class explanation:")
|
||||
print(DataAnalyzer.explain())
|
||||
|
||||
print("\nMethod explanation:")
|
||||
print(analyzer.clean_data.explain())
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The system handles various error conditions gracefully:
|
||||
|
||||
```python
|
||||
# Missing API key
|
||||
try:
|
||||
Calculator.explain()
|
||||
except ApiKeyMissingError as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Invalid source code or API issues
|
||||
print(some_method.explain()) # Returns: "<could not get function explanation: ...>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
89
multinut/explain.py
Normal file
89
multinut/explain.py
Normal file
@ -0,0 +1,89 @@
|
||||
import inspect
|
||||
import types
|
||||
from openai import OpenAI
|
||||
from .env import Environment
|
||||
|
||||
class ApiKeyMissingError(Exception):
|
||||
pass
|
||||
|
||||
class Explainable:
|
||||
API_KEY = None
|
||||
PROMPT = """
|
||||
Explain the following Python class code. Use these rules:
|
||||
|
||||
- Ignore the Explainable class – it's just an internal utility to add the `.explain()` method.
|
||||
It is not relevant to the explanation as it just adds the `.explain()` method to the class.
|
||||
Don't even mention it in the explanation. Only mention other inherited classes if they are relevant.
|
||||
- Focus on the class that is being explained.
|
||||
- Focus only on what the class and its methods do, practically.
|
||||
- Do not explain basic Python concepts like `self`, indentation, or decorators.
|
||||
- Do not guess the purpose or intent of the class — just describe what the code does.
|
||||
- Do not make suggestions for improvement or style.
|
||||
- Keep the explanation clear, minimal, and to-the-point.
|
||||
- Your audience is a competent Python developer.
|
||||
- Use simple language and avoid jargon.
|
||||
- Be concise and avoid unnecessary detail.
|
||||
- Provide the explanation in a single paragraph or more if needed.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def use_env(path: str = ".env"):
|
||||
env = Environment(path)
|
||||
Explainable.API_KEY = env.get("OPENAPI_KEY")
|
||||
|
||||
@classmethod
|
||||
def explain(cls) -> str:
|
||||
if cls.API_KEY is None:
|
||||
raise ApiKeyMissingError("API key is missing. Please set it using `Explainable.use_env()` or by directly assigning `Explainable.API_KEY`.")
|
||||
|
||||
try:
|
||||
code = inspect.getsource(cls)
|
||||
|
||||
client = OpenAI(api_key=cls.API_KEY)
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[
|
||||
{"role": "system", "content": cls.PROMPT},
|
||||
{"role": "user", "content": code}
|
||||
]
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
return f"<could not get class source: {e}>"
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
for name, obj in vars(cls).items():
|
||||
if isinstance(obj, types.FunctionType):
|
||||
setattr(cls, name, Explainable.wrap_with_explain(obj))
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def wrap_with_explain(func):
|
||||
def _with_explain(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
_with_explain.__name__ = func.__name__
|
||||
_with_explain.__doc__ = func.__doc__
|
||||
|
||||
def explain_func():
|
||||
if Explainable.API_KEY is None:
|
||||
raise ApiKeyMissingError("API key is missing. Please set it using `Explainable.use_env()` or by directly assigning `Explainable.API_KEY`.")
|
||||
|
||||
try:
|
||||
code = inspect.getsource(func)
|
||||
|
||||
client = OpenAI(api_key=Explainable.API_KEY)
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[
|
||||
{"role": "system", "content": Explainable.PROMPT},
|
||||
{"role": "user", "content": code}
|
||||
]
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
return f"<could not get function explanation: {e}>"
|
||||
|
||||
_with_explain.explain = explain_func
|
||||
return _with_explain
|
||||
|
||||
224
multinut/funky.py
Normal file
224
multinut/funky.py
Normal file
@ -0,0 +1,224 @@
|
||||
import inspect
|
||||
import typing as t
|
||||
import random
|
||||
from functools import wraps
|
||||
from collections import deque
|
||||
|
||||
try:
|
||||
from typing import get_origin, get_args
|
||||
except ImportError:
|
||||
def get_origin(tp): return getattr(tp, "__origin__", None)
|
||||
def get_args(tp): return getattr(tp, "__args__", ())
|
||||
|
||||
Any = t.Any
|
||||
|
||||
def is_any(tp):
|
||||
return tp is Any
|
||||
|
||||
def match_union(tp, value_type):
|
||||
origin = get_origin(tp)
|
||||
if origin is t.Union:
|
||||
return any(type_matches(arg, value_type) for arg in get_args(tp))
|
||||
return False
|
||||
|
||||
def type_matches(expected, actual):
|
||||
if expected is inspect._empty or is_any(expected):
|
||||
return True
|
||||
origin = get_origin(expected)
|
||||
if origin is t.Union:
|
||||
return any(type_matches(opt, actual) for opt in get_args(expected))
|
||||
if origin in (t.Optional,):
|
||||
return match_union(expected, actual)
|
||||
try:
|
||||
return issubclass(actual, expected)
|
||||
except TypeError:
|
||||
return True
|
||||
|
||||
def exact_match(expected, actual):
|
||||
return (expected is not inspect._empty
|
||||
and not is_any(expected)
|
||||
and get_origin(expected) is None
|
||||
and actual is expected)
|
||||
|
||||
def subclass_depth(expected, actual):
|
||||
try:
|
||||
mro = actual.mro()
|
||||
return mro.index(expected) if expected in mro else 9999
|
||||
except Exception:
|
||||
return 9999
|
||||
|
||||
class Coercions:
|
||||
table: dict[type, t.Tuple[t.Callable[[t.Any], t.Any], ...]] = {
|
||||
int: (lambda v: int(v),),
|
||||
float: (lambda v: float(v),),
|
||||
str: (lambda v: str(v),),
|
||||
bool: (lambda v: bool(int(v)) if isinstance(v, str) and v.isdigit() else bool(v),),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def can_coerce(cls, target: t.Type, value):
|
||||
if target not in cls.table:
|
||||
return False, None
|
||||
for fn in cls.table[target]:
|
||||
try:
|
||||
coerced = fn(value)
|
||||
if isinstance(coerced, target):
|
||||
return True, coerced
|
||||
except Exception:
|
||||
pass
|
||||
return False, None
|
||||
|
||||
class TinyLRU:
|
||||
def __init__(self, maxsize=128):
|
||||
self.maxsize = maxsize
|
||||
self.d = {}
|
||||
self.q = deque()
|
||||
|
||||
def get(self, key):
|
||||
return self.d.get(key)
|
||||
|
||||
def put(self, key, value):
|
||||
if key in self.d:
|
||||
return
|
||||
self.d[key] = value
|
||||
self.q.append(key)
|
||||
if len(self.q) > self.maxsize:
|
||||
old = self.q.popleft()
|
||||
self.d.pop(old, None)
|
||||
|
||||
class Dispatcher:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.overloads: list[dict] = []
|
||||
self._cache = TinyLRU(256)
|
||||
|
||||
def register(self, func, *, priority=0):
|
||||
sig = inspect.signature(func)
|
||||
entry = {"sig": sig, "func": func, "priority": int(priority)}
|
||||
self.overloads.append(entry)
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
@wraps(self)
|
||||
def bound(*args, **kwargs):
|
||||
return self._dispatch(instance, owner, *args, **kwargs)
|
||||
return bound
|
||||
|
||||
def _score_entry(self, entry, instance, args, kwargs, expect_type, allow_coercion=True):
|
||||
sig: inspect.Signature = entry["sig"]
|
||||
func = entry["func"]
|
||||
prio = entry["priority"]
|
||||
|
||||
try:
|
||||
bound = sig.bind(instance, *args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
score = 0
|
||||
coercions_to_apply = {}
|
||||
defaults_count = sum(
|
||||
1 for p in sig.parameters.values()
|
||||
if p.default is not inspect._empty
|
||||
)
|
||||
score -= defaults_count
|
||||
|
||||
for name, value in bound.arguments.items():
|
||||
if name == "self":
|
||||
continue
|
||||
param = sig.parameters[name]
|
||||
ann = param.annotation
|
||||
actual_t = type(value)
|
||||
|
||||
if exact_match(ann, actual_t):
|
||||
score += 30
|
||||
elif ann is inspect._empty or is_any(ann) or get_origin(ann) is not None and get_origin(ann) is t.Union and any(is_any(a) for a in get_args(ann)):
|
||||
score += 0
|
||||
elif type_matches(ann, actual_t):
|
||||
dist = subclass_depth(ann, actual_t)
|
||||
score += max(15 - min(dist, 10), 5)
|
||||
else:
|
||||
if allow_coercion and isinstance(ann, type):
|
||||
can, coerced = Coercions.can_coerce(ann, value)
|
||||
if can:
|
||||
coercions_to_apply[name] = coerced
|
||||
score += 8
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
origin = get_origin(ann)
|
||||
if allow_coercion and origin is t.Union:
|
||||
ok = False
|
||||
for opt in get_args(ann):
|
||||
if opt is type(None):
|
||||
continue
|
||||
if isinstance(opt, type):
|
||||
can, coerced = Coercions.can_coerce(opt, value)
|
||||
if can:
|
||||
coercions_to_apply[name] = coerced
|
||||
score += 6
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if expect_type is not None:
|
||||
ret_ann = sig.return_annotation
|
||||
if ret_ann is inspect._empty or is_any(ret_ann):
|
||||
score -= 1
|
||||
else:
|
||||
if get_origin(ret_ann) is t.Union:
|
||||
ok = any(type_matches(opt, expect_type) for opt in get_args(ret_ann))
|
||||
else:
|
||||
try:
|
||||
ok = issubclass(expect_type, ret_ann) or issubclass(ret_ann, expect_type)
|
||||
except TypeError:
|
||||
ok = True
|
||||
if not ok:
|
||||
return None
|
||||
else:
|
||||
score += 5
|
||||
|
||||
score += prio * 1000
|
||||
return score, coercions_to_apply
|
||||
|
||||
def _dispatch(self, instance, owner, *args, **kwargs):
|
||||
expect_type = kwargs.pop("__expect__", None)
|
||||
|
||||
key = (tuple(type(a) for a in args), tuple(sorted(kwargs.keys())), expect_type)
|
||||
cached = self._cache.get(key)
|
||||
if cached:
|
||||
entry = cached
|
||||
sig = entry["sig"]
|
||||
bound = sig.bind(instance, *args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
return entry["func"](*bound.args, **bound.kwargs)
|
||||
|
||||
candidates = []
|
||||
for entry in self.overloads:
|
||||
scored = self._score_entry(entry, instance, args, kwargs, expect_type)
|
||||
if scored is not None:
|
||||
score, coercions = scored
|
||||
candidates.append((score, random.random(), coercions, entry))
|
||||
|
||||
if not candidates:
|
||||
raise TypeError(f"No matching overload for {self.name}{args}")
|
||||
|
||||
candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
best_score, _, coercions, entry = candidates[0]
|
||||
|
||||
sig = entry["sig"]
|
||||
bound = sig.bind(instance, *args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
for k, v in coercions.items():
|
||||
bound.arguments[k] = v
|
||||
|
||||
self._cache.put(key, entry)
|
||||
return entry["func"](*bound.args, **bound.kwargs)
|
||||
|
||||
def overload(dispatcher: Dispatcher, *, priority: int = 0):
|
||||
def decorator(func):
|
||||
dispatcher.register(func, priority=priority)
|
||||
return dispatcher
|
||||
return decorator
|
||||
5
setup.py
5
setup.py
@ -2,11 +2,12 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='multinut',
|
||||
version='0.3.0',
|
||||
version='0.3.1',
|
||||
packages=find_packages(),
|
||||
install_requires=[
|
||||
"requests>=2.25.0",
|
||||
"python-dotenv>=0.21.0"
|
||||
"python-dotenv>=0.21.0",
|
||||
"openai>=0.26.5"
|
||||
],
|
||||
author='Chipperfluff',
|
||||
author_email='contact@chipperfluff.at',
|
||||
|
||||
19
tests/explain-test.py
Normal file
19
tests/explain-test.py
Normal file
@ -0,0 +1,19 @@
|
||||
from multinut.explain import Explainable, hello
|
||||
|
||||
class MyClass(Explainable):
|
||||
def my_method(self, x):
|
||||
"""This method does something."""
|
||||
return x * 2
|
||||
|
||||
def another(self, msg: str):
|
||||
return msg[::-1]
|
||||
|
||||
|
||||
print("=== Class Explanation ===")
|
||||
print(MyClass.explain())
|
||||
|
||||
print("\n=== Method Explanation: my_method ===")
|
||||
print(MyClass.my_method.explain())
|
||||
|
||||
print("\n=== Method Explanation: another ===")
|
||||
print(MyClass.another.explain())
|
||||
Loading…
x
Reference in New Issue
Block a user