schemez 0.0.1__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
schemez/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.0.1"
1
+ __version__ = "0.1.0"
2
2
 
3
3
 
4
4
  from schemez.schema import Schema
schemez/convert.py ADDED
@@ -0,0 +1,142 @@
1
+ """BaseModel tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import inspect
7
+ from types import UnionType
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ Any,
11
+ TypeAliasType,
12
+ Union,
13
+ get_args,
14
+ get_origin,
15
+ get_type_hints,
16
+ )
17
+
18
+ from pydantic import BaseModel, Field, create_model
19
+
20
+ from schemez.docstrings import get_docstring_info
21
+ from schemez.schema import Schema
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from llmling_agent.common_types import AnyCallable
26
+ from pydantic.fields import FieldInfo
27
+
28
+
29
+ def get_union_args(tp: Any) -> tuple[Any, ...]:
30
+ """Extract arguments of a Union type."""
31
+ if isinstance(tp, TypeAliasType):
32
+ tp = tp.__value__
33
+
34
+ origin = get_origin(tp)
35
+ if origin is Union or origin is UnionType:
36
+ return get_args(tp)
37
+ return ()
38
+
39
+
40
+ def get_function_model(func: AnyCallable, *, name: str | None = None) -> type[Schema]:
41
+ """Convert a function's signature to a Pydantic model.
42
+
43
+ Args:
44
+ func: The function to convert (can be method)
45
+ name: Optional name for the model
46
+
47
+ Returns:
48
+ Pydantic model representing the function parameters
49
+
50
+ Example:
51
+ >>> def greet(name: str, age: int | None = None) -> str:
52
+ ... '''Greet someone.
53
+ ... Args:
54
+ ... name: Person's name
55
+ ... age: Optional age
56
+ ... '''
57
+ ... return f"Hello {name}"
58
+ >>> model = get_function_model(greet)
59
+ """
60
+ sig = inspect.signature(func)
61
+ hints = get_type_hints(func, include_extras=True)
62
+ fields: dict[str, tuple[type, FieldInfo]] = {}
63
+ description, param_docs = get_docstring_info(func, sig)
64
+
65
+ for param_name, param in sig.parameters.items():
66
+ # Skip self/cls for methods
67
+ if param_name in ("self", "cls"):
68
+ continue
69
+
70
+ type_hint = hints.get(param_name, Any)
71
+
72
+ # Handle unions (including Optional)
73
+ if union_args := get_union_args(type_hint): # noqa: SIM102
74
+ if len(union_args) == 2 and type(None) in union_args: # noqa: PLR2004
75
+ type_hint = next(t for t in union_args if t is not type(None))
76
+
77
+ # Create field with defaults if available
78
+ field = Field(
79
+ default=... if param.default is param.empty else param.default,
80
+ description=param_docs.get(param_name), # TODO: Add docstring parsing
81
+ )
82
+ fields[param_name] = (type_hint, field)
83
+
84
+ model_name = name or f"{func.__name__}Params"
85
+ return create_model(model_name, **fields, __base__=Schema, __doc__=description) # type: ignore
86
+
87
+
88
+ def get_ctor_basemodel(cls: type) -> type[Schema]:
89
+ """Convert a class constructor to a Pydantic model.
90
+
91
+ Args:
92
+ cls: The class whose constructor to convert
93
+
94
+ Returns:
95
+ Pydantic model for the constructor parameters
96
+
97
+ Example:
98
+ >>> class Person:
99
+ ... def __init__(self, name: str, age: int | None = None):
100
+ ... self.name = name
101
+ ... self.age = age
102
+ >>> model = get_ctor_basemodel(Person)
103
+ """
104
+ if issubclass(cls, BaseModel):
105
+ if issubclass(cls, Schema):
106
+ return cls
107
+
108
+ # Create a new Schema-based model with the same fields
109
+ fields = {}
110
+ for field_name, field_info in cls.model_fields.items():
111
+ field_type = field_info.annotation
112
+ field_default = (
113
+ field_info.default if field_info.default is not Ellipsis else ...
114
+ )
115
+ fields[field_name] = (field_type, field_default)
116
+
117
+ return create_model(cls.__name__, **fields, __base__=Schema) # type: ignore
118
+
119
+ if dataclasses.is_dataclass(cls):
120
+ fields = {}
121
+ hints = get_type_hints(cls)
122
+ for field in dataclasses.fields(cls):
123
+ fields[field.name] = (hints[field.name], ...)
124
+ return create_model(cls.__name__, __base__=Schema, **fields) # type: ignore
125
+ return get_function_model(cls.__init__, name=cls.__name__)
126
+
127
+
128
+ if __name__ == "__main__":
129
+
130
+ class Person:
131
+ """Person class."""
132
+
133
+ def __init__(self, name: str, age: int | None = None):
134
+ self.name = name
135
+ self.age = age
136
+
137
+ def func_google(self, name: str, age: int | None = None):
138
+ """Do something."""
139
+
140
+ model = get_function_model(Person.func_google)
141
+ instance = model(name="Test", age=30) # type: ignore
142
+ print(instance, isinstance(instance, BaseModel))
schemez/docstrings.py ADDED
@@ -0,0 +1,157 @@
1
+ """Credits to pydantic-ai."""
2
+
3
+ from __future__ import annotations as _annotations
4
+
5
+ from collections.abc import Callable
6
+ from contextlib import contextmanager
7
+ import logging
8
+ import re
9
+ from typing import TYPE_CHECKING, Any, Literal, cast
10
+
11
+ from griffe import Docstring, DocstringSectionKind, Object as GriffeObject
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from inspect import Signature
16
+
17
+
18
+ DocstringStyle = Literal["google", "numpy", "sphinx"]
19
+ DocstringFormat = Literal["google", "numpy", "sphinx", "auto"]
20
+ AnyCallable = Callable[..., Any]
21
+
22
+
23
+ def get_docstring_info(
24
+ func: AnyCallable,
25
+ sig: Signature,
26
+ *,
27
+ docstring_format: DocstringFormat = "auto",
28
+ ) -> tuple[str, dict[str, str]]:
29
+ """Extract the fn description and parameter descriptions from a fn's docstring.
30
+
31
+ Returns:
32
+ A tuple of (main function description, parameter descriptions).
33
+ """
34
+ doc = func.__doc__
35
+ if doc is None:
36
+ return "", {}
37
+
38
+ # see https://github.com/mkdocstrings/griffe/issues/293
39
+ parent = cast(GriffeObject, sig)
40
+
41
+ docstring_style = (
42
+ _infer_docstring_style(doc) if docstring_format == "auto" else docstring_format
43
+ )
44
+ docstring = Docstring(doc, lineno=1, parser=docstring_style, parent=parent)
45
+ with _disable_griffe_logging():
46
+ sections = docstring.parse()
47
+
48
+ params = {}
49
+ if parameters := next(
50
+ (p for p in sections if p.kind == DocstringSectionKind.parameters), None
51
+ ):
52
+ params = {p.name: p.description for p in parameters.value}
53
+
54
+ main_desc = ""
55
+ if main := next((p for p in sections if p.kind == DocstringSectionKind.text), None):
56
+ main_desc = main.value
57
+
58
+ return main_desc, params
59
+
60
+
61
+ def _infer_docstring_style(doc: str) -> DocstringStyle:
62
+ """Simplistic docstring style inference."""
63
+ for pattern, replacements, style in _docstring_style_patterns:
64
+ matches = (
65
+ re.search(pattern.format(replacement), doc, re.IGNORECASE | re.MULTILINE)
66
+ for replacement in replacements
67
+ )
68
+ if any(matches):
69
+ return style
70
+ # fallback to google style
71
+ return "google"
72
+
73
+
74
+ # See https://github.com/mkdocstrings/griffe/issues/329#issuecomment-2425017804
75
+ _docstring_style_patterns: list[tuple[str, list[str], DocstringStyle]] = [
76
+ (
77
+ r"\n[ \t]*:{0}([ \t]+\w+)*:([ \t]+.+)?\n",
78
+ [
79
+ "param",
80
+ "parameter",
81
+ "arg",
82
+ "argument",
83
+ "key",
84
+ "keyword",
85
+ "type",
86
+ "var",
87
+ "ivar",
88
+ "cvar",
89
+ "vartype",
90
+ "returns",
91
+ "return",
92
+ "rtype",
93
+ "raises",
94
+ "raise",
95
+ "except",
96
+ "exception",
97
+ ],
98
+ "sphinx",
99
+ ),
100
+ (
101
+ r"\n[ \t]*{0}:([ \t]+.+)?\n[ \t]+.+",
102
+ [
103
+ "args",
104
+ "arguments",
105
+ "params",
106
+ "parameters",
107
+ "keyword args",
108
+ "keyword arguments",
109
+ "other args",
110
+ "other arguments",
111
+ "other params",
112
+ "other parameters",
113
+ "raises",
114
+ "exceptions",
115
+ "returns",
116
+ "yields",
117
+ "receives",
118
+ "examples",
119
+ "attributes",
120
+ "functions",
121
+ "methods",
122
+ "classes",
123
+ "modules",
124
+ "warns",
125
+ "warnings",
126
+ ],
127
+ "google",
128
+ ),
129
+ (
130
+ r"\n[ \t]*{0}\n[ \t]*---+\n",
131
+ [
132
+ "deprecated",
133
+ "parameters",
134
+ "other parameters",
135
+ "returns",
136
+ "yields",
137
+ "receives",
138
+ "raises",
139
+ "warns",
140
+ "attributes",
141
+ "functions",
142
+ "methods",
143
+ "classes",
144
+ "modules",
145
+ ],
146
+ "numpy",
147
+ ),
148
+ ]
149
+
150
+
151
+ @contextmanager
152
+ def _disable_griffe_logging():
153
+ # Hacky, but suggested here: https://github.com/mkdocstrings/griffe/issues/293#issuecomment-2167668117
154
+ old_level = logging.root.getEffectiveLevel()
155
+ logging.root.setLevel(logging.ERROR)
156
+ yield
157
+ logging.root.setLevel(old_level)
schemez/helpers.py CHANGED
@@ -1,35 +1,35 @@
1
- """Helpers for BaseModels."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
-
7
- from pydantic import BaseModel
8
-
9
-
10
- StrPath = str | os.PathLike[str]
11
-
12
-
13
- def merge_models[T: BaseModel](base: T, overlay: T) -> T:
14
- """Deep merge two Pydantic models."""
15
- if not isinstance(overlay, type(base)):
16
- msg = f"Cannot merge different types: {type(base)} and {type(overlay)}"
17
- raise TypeError(msg)
18
-
19
- merged_data = base.model_dump()
20
- overlay_data = overlay.model_dump(exclude_none=True)
21
- for field_name, field_value in overlay_data.items():
22
- base_value = merged_data.get(field_name)
23
-
24
- match (base_value, field_value):
25
- case (list(), list()):
26
- merged_data[field_name] = [
27
- *base_value,
28
- *(item for item in field_value if item not in base_value),
29
- ]
30
- case (dict(), dict()):
31
- merged_data[field_name] = base_value | field_value
32
- case _:
33
- merged_data[field_name] = field_value
34
-
35
- return base.__class__.model_validate(merged_data)
1
+ """Helpers for BaseModels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ StrPath = str | os.PathLike[str]
11
+
12
+
13
+ def merge_models[T: BaseModel](base: T, overlay: T) -> T:
14
+ """Deep merge two Pydantic models."""
15
+ if not isinstance(overlay, type(base)):
16
+ msg = f"Cannot merge different types: {type(base)} and {type(overlay)}"
17
+ raise TypeError(msg)
18
+
19
+ merged_data = base.model_dump()
20
+ overlay_data = overlay.model_dump(exclude_none=True)
21
+ for field_name, field_value in overlay_data.items():
22
+ base_value = merged_data.get(field_name)
23
+
24
+ match (base_value, field_value):
25
+ case (list(), list()):
26
+ merged_data[field_name] = [
27
+ *base_value,
28
+ *(item for item in field_value if item not in base_value),
29
+ ]
30
+ case (dict(), dict()):
31
+ merged_data[field_name] = base_value | field_value
32
+ case _:
33
+ merged_data[field_name] = field_value
34
+
35
+ return base.__class__.model_validate(merged_data)
schemez/schema.py CHANGED
@@ -1,67 +1,102 @@
1
- """Configuration models for Schemez."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- from typing import Self
7
-
8
- from pydantic import BaseModel, ConfigDict
9
- import upath
10
-
11
-
12
- StrPath = str | os.PathLike[str]
13
-
14
-
15
- class Schema(BaseModel):
16
- """Base class configuration models.
17
-
18
- Provides:
19
- - Common Pydantic settings
20
- - YAML serialization
21
- - Basic merge functionality
22
- """
23
-
24
- model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True)
25
-
26
- def merge(self, other: Self) -> Self:
27
- """Merge with another instance by overlaying its non-None values."""
28
- from schemez.helpers import merge_models
29
-
30
- return merge_models(self, other)
31
-
32
- @classmethod
33
- def from_yaml(cls, content: str, inherit_path: StrPath | None = None) -> Self:
34
- """Create from YAML string."""
35
- import yamling
36
-
37
- data = yamling.load_yaml(content, resolve_inherit=inherit_path or False)
38
- return cls.model_validate(data)
39
-
40
- def model_dump_yaml(self) -> str:
41
- """Dump configuration to YAML string."""
42
- import yamling
43
-
44
- return yamling.dump_yaml(self.model_dump(exclude_none=True))
45
-
46
- def save(self, path: StrPath, overwrite: bool = False) -> None:
47
- """Save configuration to a YAML file.
48
-
49
- Args:
50
- path: Path to save the configuration to
51
- overwrite: Whether to overwrite an existing file
52
-
53
- Raises:
54
- OSError: If file cannot be written
55
- ValueError: If path is invalid
56
- """
57
- yaml_str = self.model_dump_yaml()
58
- try:
59
- file_path = upath.UPath(path)
60
- if file_path.exists() and not overwrite:
61
- msg = f"File already exists: {path}"
62
- raise FileExistsError(msg) # noqa: TRY301
63
- file_path.parent.mkdir(parents=True, exist_ok=True)
64
- file_path.write_text(yaml_str)
65
- except Exception as exc:
66
- msg = f"Failed to save configuration to {path}"
67
- raise ValueError(msg) from exc
1
+ """Configuration models for Schemez."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING, Any, Self
7
+
8
+ from pydantic import BaseModel, ConfigDict
9
+ import upath
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+
16
+ StrPath = str | os.PathLike[str]
17
+
18
+
19
+ class Schema(BaseModel):
20
+ """Base class configuration models.
21
+
22
+ Provides:
23
+ - Common Pydantic settings
24
+ - YAML serialization
25
+ - Basic merge functionality
26
+ """
27
+
28
+ model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True)
29
+
30
+ def merge(self, other: Self) -> Self:
31
+ """Merge with another instance by overlaying its non-None values."""
32
+ from schemez.helpers import merge_models
33
+
34
+ return merge_models(self, other)
35
+
36
+ @classmethod
37
+ def from_yaml(cls, content: str, inherit_path: StrPath | None = None) -> Self:
38
+ """Create from YAML string."""
39
+ import yamling
40
+
41
+ data = yamling.load_yaml(content, resolve_inherit=inherit_path or False)
42
+ return cls.model_validate(data)
43
+
44
+ @classmethod
45
+ def for_function(
46
+ cls, func: Callable[..., Any], *, name: str | None = None
47
+ ) -> type[Schema]:
48
+ """Create a schema model from a function's signature.
49
+
50
+ Args:
51
+ func: The function to create a schema from
52
+ name: Optional name for the model
53
+
54
+ Returns:
55
+ A new schema model class based on the function parameters
56
+ """
57
+ from schemez.convert import get_function_model
58
+
59
+ return get_function_model(func, name=name)
60
+
61
+ @classmethod
62
+ def for_class_ctor(cls, target_cls: type) -> type[Schema]:
63
+ """Create a schema model from a class constructor.
64
+
65
+ Args:
66
+ target_cls: The class whose constructor to convert
67
+
68
+ Returns:
69
+ A new schema model class based on the constructor parameters
70
+ """
71
+ from schemez.convert import get_ctor_basemodel
72
+
73
+ return get_ctor_basemodel(target_cls)
74
+
75
+ def model_dump_yaml(self) -> str:
76
+ """Dump configuration to YAML string."""
77
+ import yamling
78
+
79
+ return yamling.dump_yaml(self.model_dump(exclude_none=True))
80
+
81
+ def save(self, path: StrPath, overwrite: bool = False) -> None:
82
+ """Save configuration to a YAML file.
83
+
84
+ Args:
85
+ path: Path to save the configuration to
86
+ overwrite: Whether to overwrite an existing file
87
+
88
+ Raises:
89
+ OSError: If file cannot be written
90
+ ValueError: If path is invalid
91
+ """
92
+ yaml_str = self.model_dump_yaml()
93
+ try:
94
+ file_path = upath.UPath(path)
95
+ if file_path.exists() and not overwrite:
96
+ msg = f"File already exists: {path}"
97
+ raise FileExistsError(msg) # noqa: TRY301
98
+ file_path.parent.mkdir(parents=True, exist_ok=True)
99
+ file_path.write_text(yaml_str)
100
+ except Exception as exc:
101
+ msg = f"Failed to save configuration to {path}"
102
+ raise ValueError(msg) from exc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: schemez
3
- Version: 0.0.1
3
+ Version: 0.1.0
4
4
  Summary: Pydantic shim for config stuff
5
5
  Project-URL: Documentation, https://phil65.github.io/schemez/
6
6
  Project-URL: Source, https://github.com/phil65/schemez
@@ -46,6 +46,7 @@ Classifier: Topic :: Software Development
46
46
  Classifier: Topic :: Utilities
47
47
  Classifier: Typing :: Typed
48
48
  Requires-Python: >=3.12
49
+ Requires-Dist: griffe>=1.7.3
49
50
  Requires-Dist: pydantic
50
51
  Requires-Dist: universal-pathlib>=0.2.6
51
52
  Description-Content-Type: text/markdown
@@ -0,0 +1,10 @@
1
+ schemez/__init__.py,sha256=VpbilATEu92pmAaUaenQPcyPCRA2itKLdszXxI-pakM,80
2
+ schemez/convert.py,sha256=b6Sz11lq0HvpXfMREOqnnw8rcVg2XzTKhjjPNc4YIoE,4403
3
+ schemez/docstrings.py,sha256=kmd660wcomXzKac0SSNYxPRNbVCUovrpmE9jwnVRS6c,4115
4
+ schemez/helpers.py,sha256=_leGedEf5AoeQOV0eyrJpDnvDOPB5XV3pd5YNANASeI,1081
5
+ schemez/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ schemez/schema.py,sha256=qlkNigpDQJIopjSjfS4yp8vXReCr2o2eWBEDjIN7YjM,3021
7
+ schemez-0.1.0.dist-info/METADATA,sha256=N5ew7ne2W8qznqDjxcI15hMDPbzzDJRC_Hw4U11ECRw,5722
8
+ schemez-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ schemez-0.1.0.dist-info/licenses/LICENSE,sha256=AteGCH9r177TxxrOFEiOARrastASsf7yW6MQxlAHdwA,1078
10
+ schemez-0.1.0.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- schemez/__init__.py,sha256=8GownnUkTUVUdUuosh5mCOW9OqoY_9-_66_BmQL9yEM,80
2
- schemez/helpers.py,sha256=bbmtpB9hz3iyc7u9zpuOPQnUuwj5J751C3RtmXs8PzU,1116
3
- schemez/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- schemez/schema.py,sha256=6rubAfdwdcmFRP8SfPQpfyuR674uuIy9u3zo7PndlWU,2070
5
- schemez-0.0.1.dist-info/METADATA,sha256=o78fWu1GlwqnJGwjWGZVNGyVETwb66CWcBMzNsQoYv8,5693
6
- schemez-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
- schemez-0.0.1.dist-info/licenses/LICENSE,sha256=AteGCH9r177TxxrOFEiOARrastASsf7yW6MQxlAHdwA,1078
8
- schemez-0.0.1.dist-info/RECORD,,