diff --git a/instructor/function_calls.py b/instructor/function_calls.py index a14c88b59..abffe7124 100644 --- a/instructor/function_calls.py +++ b/instructor/function_calls.py @@ -4,10 +4,11 @@ from docstring_parser import parse from openai.types.chat import ChatCompletion -from pydantic import BaseModel, Field, TypeAdapter, create_model # type: ignore - remove once Pydantic is updated +from pydantic import BaseModel, Field, TypeAdapter, ConfigDict, create_model # type: ignore - remove once Pydantic is updated from instructor.exceptions import IncompleteOutputException from instructor.mode import Mode -from instructor.utils import extract_json_from_codeblock +from instructor.utils import extract_json_from_codeblock, classproperty + T = TypeVar("T") @@ -15,8 +16,10 @@ class OpenAISchema(BaseModel): - @classmethod - @property + # Ignore classproperty, since Pydantic doesn't understand it like it would a normal property. + model_config = ConfigDict(ignored_types=(classproperty,)) + + @classproperty def openai_schema(cls) -> dict[str, Any]: """ Return the schema in the format of OpenAI's schema as jsonschema @@ -58,8 +61,7 @@ def openai_schema(cls) -> dict[str, Any]: "parameters": parameters, } - @classmethod - @property + @classproperty def anthropic_schema(cls) -> dict[str, Any]: return { "name": cls.openai_schema["name"], diff --git a/instructor/utils.py b/instructor/utils.py index 66f6beb9c..d5c0ce904 100644 --- a/instructor/utils.py +++ b/instructor/utils.py @@ -3,6 +3,13 @@ import inspect import json import logging +from typing import ( + Callable, + Generic, + Protocol, + TypeVar, +) +from collections.abc import Generator, Iterable, AsyncGenerator from typing import Callable, Protocol, TypeVar from collections.abc import Generator, Iterable, AsyncGenerator from openai.types.completion_usage import CompletionUsage @@ -16,6 +23,7 @@ ) logger = logging.getLogger("instructor") +R_co = TypeVar("R_co", covariant=True) T_Model = TypeVar("T_Model", bound="Response") from enum import Enum @@ -180,3 +188,24 @@ def merge_consecutive_messages(messages: list[dict[str, Any]]) -> list[dict[str, ) return new_messages + + +class classproperty(Generic[R_co]): + """Descriptor for class-level properties. + + Examples: + >>> from instructor.utils import classproperty + + >>> class MyClass: + ... @classproperty + ... def my_property(cls): + ... return cls + + >>> assert MyClass.my_property + """ + + def __init__(self, method: Callable[[Any], R_co]) -> None: + self.cproperty = method + + def __get__(self, instance: object, cls: type[Any]) -> R_co: + return self.cproperty(cls) diff --git a/pyproject.toml b/pyproject.toml index d2a7baa6c..652cb73b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,4 +100,3 @@ typeCheckingMode = "strict" # Allow "redundant" runtime type-checking. reportUnnecessaryIsInstance = "none" reportUnnecessaryTypeIgnoreComment = "error" -reportDeprecated = "warning" diff --git a/tests/test_utils.py b/tests/test_utils.py index f0de74b87..217ce604d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import json import pytest from instructor.utils import ( + classproperty, extract_json_from_codeblock, extract_json_from_stream, extract_json_from_stream_async, @@ -170,3 +171,23 @@ def test_merge_consecutive_messages_single(): {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, {"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}, ] + + +def test_classproperty(): + """Test custom `classproperty` descriptor.""" + + class MyClass: + @classproperty + def my_property(cls): + return cls + + assert MyClass.my_property is MyClass + + class MyClass: + clvar = 1 + + @classproperty + def my_property(cls): + return cls.clvar + + assert MyClass.my_property == 1