diff --git a/README.md b/README.md index de3d328..354dc76 100644 --- a/README.md +++ b/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: "" +``` --- diff --git a/multinut/explain.py b/multinut/explain.py new file mode 100644 index 0000000..6280e1c --- /dev/null +++ b/multinut/explain.py @@ -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"" + + 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"" + + _with_explain.explain = explain_func + return _with_explain + \ No newline at end of file diff --git a/multinut/funky.py b/multinut/funky.py new file mode 100644 index 0000000..2783c7f --- /dev/null +++ b/multinut/funky.py @@ -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 diff --git a/setup.py b/setup.py index e903645..5dd4b9f 100644 --- a/setup.py +++ b/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', diff --git a/tests/explain-test.py b/tests/explain-test.py new file mode 100644 index 0000000..be59adc --- /dev/null +++ b/tests/explain-test.py @@ -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())