Skip to content

Commit

Permalink
Add extra field to msgspec.Meta
Browse files Browse the repository at this point in the history
This adds a new `extra` field to `msgspec.Meta` annotations. This field
takes a dict of user-defined additional metadata. For the most part
``msgspec`` will ignore this dict entirely (leaving it up to the user
how it's used). The one exception is the tooling in `msgspec.inspect`,
which will extract it and present it as part of a
`msgspec.inspect.Metadata` type node.
  • Loading branch information
jcrist committed Jan 5, 2023
1 parent f9732e0 commit 6163365
Show file tree
Hide file tree
Showing 6 changed files with 57 additions and 13 deletions.
2 changes: 2 additions & 0 deletions msgspec/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class Meta:
description: Union[str, None] = None,
examples: Union[list, None] = None,
extra_json_schema: Union[dict, None] = None,
extra: Union[dict, None] = None,
): ...
gt: Final[Union[int, float, None]]
ge: Final[Union[int, float, None]]
Expand All @@ -127,6 +128,7 @@ class Meta:
description: Final[Union[str, None]]
examples: Final[Union[list, None]]
extra_json_schema: Final[Union[dict, None]]
extra: Final[Union[dict, None]]
def __rich_repr__(self) -> Iterable[Tuple[str, Any]]: ...

class MsgspecError(Exception): ...
Expand Down
31 changes: 26 additions & 5 deletions msgspec/_core.c
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,7 @@ typedef struct Meta {
PyObject *description;
PyObject *examples;
PyObject *extra_json_schema;
PyObject *extra;
} Meta;

static bool
Expand Down Expand Up @@ -1334,7 +1335,7 @@ ensure_is_finite_numeric(PyObject *val, const char *param, bool positive) {
PyDoc_STRVAR(Meta__doc__,
"Meta(*, gt=None, ge=None, lt=None, le=None, multiple_of=None, pattern=None, "
"min_length=None, max_length=None, tz=None, title=None, description=None, "
"examples=None, extra_json_schema=None)\n"
"examples=None, extra_json_schema=None, extra=None)\n"
"--\n"
"\n"
"Extra metadata and constraints for a type or field.\n"
Expand Down Expand Up @@ -1378,6 +1379,8 @@ PyDoc_STRVAR(Meta__doc__,
" A dict of extra fields to set for the annotated value when generating\n"
" a json-schema. This dict is recursively merged with the generated schema,\n"
" with ``extra_json_schema`` overriding any conflicting autogenerated fields.\n"
"extra: dict, optional\n"
" Any additional user-defined metadata.\n"
"\n"
"Examples\n"
"--------\n"
Expand All @@ -1403,19 +1406,21 @@ Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
"gt", "ge", "lt", "le", "multiple_of",
"pattern", "min_length", "max_length", "tz",
"title", "description", "examples", "extra_json_schema",
NULL
"extra", NULL
};
PyObject *gt = NULL, *ge = NULL, *lt = NULL, *le = NULL, *multiple_of = NULL;
PyObject *pattern = NULL, *min_length = NULL, *max_length = NULL, *tz = NULL;
PyObject *title = NULL, *description = NULL, *examples = NULL, *extra_json_schema = NULL;
PyObject *title = NULL, *description = NULL, *examples = NULL;
PyObject *extra_json_schema = NULL, *extra = NULL;
PyObject *regex = NULL;

/* Parse arguments: (name, bases, dict) */
if (!PyArg_ParseTupleAndKeywords(
args, kwargs, "|$OOOOOOOOOOOOO:Meta.__new__", kwlist,
args, kwargs, "|$OOOOOOOOOOOOOO:Meta.__new__", kwlist,
&gt, &ge, &lt, &le, &multiple_of,
&pattern, &min_length, &max_length, &tz,
&title, &description, &examples, &extra_json_schema
&title, &description, &examples, &extra_json_schema,
&extra
)
)
return NULL;
Expand All @@ -1434,6 +1439,7 @@ Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
NONE_TO_NULL(description);
NONE_TO_NULL(examples);
NONE_TO_NULL(extra_json_schema);
NONE_TO_NULL(extra);
#undef NONE_TO_NULL

/* Check parameter types/values */
Expand Down Expand Up @@ -1486,6 +1492,14 @@ Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
);
return NULL;
}
if (extra != NULL && !PyDict_CheckExact(extra)) {
PyErr_Format(
PyExc_TypeError,
"`extra` must be a dict, got %.200s",
Py_TYPE(extra)->tp_name
);
return NULL;
}

/* regex compile pattern if provided */
if (pattern != NULL) {
Expand All @@ -1512,6 +1526,7 @@ Meta_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) {
SET_FIELD(description);
SET_FIELD(examples);
SET_FIELD(extra_json_schema);
SET_FIELD(extra);
#undef SET_FIELD
return (PyObject *)out;
}
Expand All @@ -1521,6 +1536,7 @@ Meta_traverse(Meta *self, visitproc visit, void *arg) {
Py_VISIT(self->regex);
Py_VISIT(self->examples);
Py_VISIT(self->extra_json_schema);
Py_VISIT(self->extra);
return 0;
}

Expand All @@ -1540,6 +1556,7 @@ Meta_clear(Meta *self) {
Py_CLEAR(self->description);
Py_CLEAR(self->examples);
Py_CLEAR(self->extra_json_schema);
Py_CLEAR(self->extra);
}

static void
Expand Down Expand Up @@ -1592,6 +1609,7 @@ Meta_repr(Meta *self) {
DO_REPR(description);
DO_REPR(examples);
DO_REPR(extra_json_schema);
DO_REPR(extra);
#undef DO_REPR
if (!strbuilder_extend_literal(&builder, ")")) goto error;
return strbuilder_build(&builder);
Expand Down Expand Up @@ -1623,6 +1641,7 @@ Meta_rich_repr(PyObject *py_self, PyObject *args) {
DO_REPR(description);
DO_REPR(examples);
DO_REPR(extra_json_schema);
DO_REPR(extra);
#undef DO_REPR
return out;
error:
Expand Down Expand Up @@ -1676,6 +1695,7 @@ Meta_richcompare(Meta *self, PyObject *py_other, int op) {
DO_COMPARE(description);
DO_COMPARE(examples);
DO_COMPARE(extra_json_schema);
DO_COMPARE(extra);
}
#undef DO_COMPARE
done:
Expand Down Expand Up @@ -1739,6 +1759,7 @@ static PyMemberDef Meta_members[] = {
{"description", T_OBJECT, offsetof(Meta, description), READONLY, NULL},
{"examples", T_OBJECT, offsetof(Meta, examples), READONLY, NULL},
{"extra_json_schema", T_OBJECT, offsetof(Meta, extra_json_schema), READONLY, NULL},
{"extra", T_OBJECT, offsetof(Meta, extra), READONLY, NULL},
{NULL},
};

Expand Down
22 changes: 15 additions & 7 deletions msgspec/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,13 @@ class Metadata(Type):
extra_json_schema: dict, optional
A dict of extra fields to set for the subtype when generating a
json-schema.
extra: dict, optional
A dict of extra user-defined metadata attached to the subtype.
"""

type: Type
extra_json_schema: Union[dict, None] = None
extra: Union[dict, None] = None


class AnyType(Type):
Expand Down Expand Up @@ -727,7 +730,7 @@ def translate(self, typ):
# Extract and merge components of any `Meta` annotations
constrs = {}
extra_json_schema = {}
temp = {}
extra = {}
for meta in metadata:
for attr in (
"ge",
Expand All @@ -746,14 +749,19 @@ def translate(self, typ):
if (val := getattr(meta, attr)) is not None:
extra_json_schema[attr] = val
if meta.extra_json_schema is not None:
temp = _merge_json(temp, _roundtrip_json(meta.extra_json_schema))
extra_json_schema.update(temp)
extra_json_schema = _merge_json(
extra_json_schema, _roundtrip_json(meta.extra_json_schema)
)
if meta.extra is not None:
extra.update(meta.extra)

out = self._translate_inner(t, args, **constrs)
if extra_json_schema:
# If `extra_json_schema` is present, wrap the output type in a
# Metadata wrapper node
return Metadata(out, extra_json_schema)
if extra_json_schema or extra:
# If extra metadata is present, wrap the output type in a Metadata
# wrapper object
return Metadata(
out, extra_json_schema=extra_json_schema or None, extra=extra or None
)
return out

def _translate_inner(
Expand Down
2 changes: 2 additions & 0 deletions tests/basic_typing_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ def check_meta_constructor() -> None:
msgspec.Meta(examples=val5)
for val6 in [{"foo": "bar"}, None]:
msgspec.Meta(extra_json_schema=val6)
msgspec.Meta(extra=val6)


def check_meta_attributes() -> None:
Expand All @@ -354,6 +355,7 @@ def check_meta_attributes() -> None:
print(c.description)
print(c.examples)
print(c.extra_json_schema)
print(c.extra)


def check_meta_equal() -> None:
Expand Down
3 changes: 2 additions & 1 deletion tests/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def sign(x):
"description": "example description",
"examples": ["example 1", "example 2"],
"extra_json_schema": {"foo": "bar"},
"extra": {"fizz": "buzz"},
}


Expand Down Expand Up @@ -219,7 +220,7 @@ def test_list_fields(self, field):
with pytest.raises(TypeError, match=f"`{field}` must be a list, got str"):
Meta(**{field: "bad"})

@pytest.mark.parametrize("field", ["extra_json_schema"])
@pytest.mark.parametrize("field", ["extra_json_schema", "extra"])
def test_dict_fields(self, field):
Meta(**{field: {"good": "stuff"}})
with pytest.raises(TypeError, match=f"`{field}` must be a dict, got str"):
Expand Down
10 changes: 10 additions & 0 deletions tests/test_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,16 @@ def test_metadata():
mi.IntType(), {"title": "c", "description": "b", "examples": [1, 2]}
)

typ = Annotated[
int,
Meta(extra={"a": 1, "b": 2}),
Meta(extra={"a": 3, "c": 4}),
]

assert mi.type_info(typ) == mi.Metadata(
mi.IntType(), extra={"a": 3, "b": 2, "c": 4}
)


@pytest.mark.parametrize("protocol", [None, "msgpack", "json"])
def test_type_info_protocol(protocol):
Expand Down

0 comments on commit 6163365

Please sign in to comment.