Skip to content

Commit c86ba9e

Browse files
committed
Merge remote-tracking branch 'origin/main' into 963-cache-fields
2 parents 76b04cf + f51f378 commit c86ba9e

13 files changed

Lines changed: 348 additions & 26 deletions

File tree

‎.github/dependabot.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ updates:
1010
- "*"
1111
cooldown:
1212
default-days: 7
13+
- package-ecosystem: uv
14+
directory: /
15+
schedule:
16+
interval: weekly
17+
cooldown:
18+
default-days: 7

‎.github/workflows/build-wheels.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ jobs:
9191
- name: Build & Test Wheels
9292
if: runner.os != 'Windows'
9393
run: >-
94-
uvx --from cibuildwheel==4.1.0
94+
uvx --from cibuildwheel==4.2.0
9595
cibuildwheel
9696
--config-file .cibuildwheel.toml
9797
--output-dir wheelhouse
@@ -100,7 +100,7 @@ jobs:
100100
- name: Build & Test Wheels
101101
if: runner.os == 'Windows'
102102
run: >-
103-
uvx --from cibuildwheel==4.1.0
103+
uvx --from cibuildwheel==4.2.0
104104
cibuildwheel
105105
--config-file .cibuildwheel.toml
106106
--output-dir wheelhouse

‎.ruff.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
line-length = 88
2+
output-prefer-rule-codes = true
23
extend-exclude = [
34
"src/msgspec/_version.py",
45
"tests/typing/basic_typing_examples.py",

‎docs/changelog.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
# Changelog
22

3+
## Version 0.22.0 (2026-08-11)
4+
5+
- Add `frozendict` support on Python 3.15+ ({pr}`1052`, {pr}`1105`).
6+
- Support passing a callable as `decimal_format` to `msgspec.json.Encoder` and
7+
`msgspec.msgpack.Encoder` for custom `Decimal` encoding ({pr}`978`).
8+
- Support `Literal[True]` and `Literal[False]` types ({pr}`1004`).
9+
- Publish Linux `riscv64` wheels ({pr}`987`).
10+
- Support the PyEmscripten (Pyodide) platform ({pr}`1083`).
11+
- Fix handling of PEP 695 type parameter syntax (`class Foo[T]`) and of
12+
`types.GenericAlias` instances in type annotations ({pr}`962`).
13+
- Fix a crash on incorrect `typing.ClassVar` annotations ({pr}`1097`).
14+
- Fix an `AttributeError` when converting to a `Struct` type defined in a
15+
namespace without a `__name__` ({pr}`1072`).
16+
- Fix a reference leak when decoding msgpack `Ext` payloads ({pr}`1109`).
17+
- Fix reference leaks in `typenode_collect_literal`, `Meta.__rich_repr__`,
18+
`ms_decode_bigint`, and `Encoder.__init__` ({pr}`1021`, {pr}`1022`,
19+
{pr}`1023`, {pr}`1040`).
20+
- Fix `msgspec.inspect.type_info` and `msgspec.json.schema` crashing on mixed-type `Literal`s such as `Literal[1, None]` ({pr}`1080`).
21+
- Fix missing GC traversal and clearing of some module state members ({pr}`1060`).
22+
- Ensure an exception is always set on allocation failures ({pr}`1044`).
23+
- Fix compilation warnings on Python 3.15 ({pr}`1077`).
24+
- Many type stub improvements and fixes ({pr}`1014`, {pr}`1043`, {pr}`1053`,
25+
{pr}`1055`, {pr}`1057`, {pr}`1062`, {pr}`1065`, {pr}`1074`, {pr}`1093`).
26+
- Document the differences between `msgspec.structs.asdict`/`astuple` and
27+
`msgspec.to_builtins` ({pr}`1025`).
28+
- Document that `omit_defaults` ignores fields with a custom
29+
`default_factory` ({pr}`1076`).
30+
- Fix the `msgspec.json.decode` docstring to say `dec_hook` should raise
31+
`NotImplementedError` for unsupported types, not `TypeError` ({issue}`774`).
32+
- Fix backing type declaration of `Ext.code` ({pr}`1135`).
33+
- msgspec moved to the [msgspec GitHub organization](https://github.com/msgspec/msgspec);
34+
documentation now lives at [msgspec.dev](https://msgspec.dev) (repository
35+
references updated in {pr}`1045`).
36+
337
## Version 0.21.1 (2026-04-13)
438

539
- Fix `ValidationError` and `DecodeError` raised in `dec_hook` being incorrectly wrapped in another `ValidationError` ({pr}`1013`).

‎pyproject.toml‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ requires-python = ">=3.10"
2020
maintainers = [
2121
{ name = "Jim Crist-Harif", email = "[email protected]" },
2222
{ name = "Ofek Lev", email = "[email protected]" },
23+
{ name = "Nikita Sobolev", email = "[email protected]" },
2324
]
2425
keywords = [
2526
"JSON",
@@ -83,7 +84,7 @@ doc = [
8384
hooks = [
8485
"codespell==2.4.1",
8586
"prek>=0.2.13",
86-
"ruff==0.15.16",
87+
"ruff==0.16.2",
8788
]
8889
test-base = [
8990
"pytest>=9",
@@ -97,9 +98,9 @@ test-prof = [
9798
]
9899
test-typing = [
99100
{ include-group = "test-base" },
100-
"mypy",
101-
"pyright",
102-
"pyrefly",
101+
"mypy==2.3.0",
102+
"pyright==1.1.411",
103+
"pyrefly==1.2.0",
103104
]
104105
test-unit = [
105106
{ include-group = "test-base" },
@@ -157,3 +158,7 @@ preset = 'strict'
157158
min-severity = 'warn'
158159
# Don't use '# type: ignore' to suppress with pyright:
159160
enabled-ignores = ["pyrefly"]
161+
162+
[tool.pyrefly.errors]
163+
# Do not require type annotations for `lambda` functions:
164+
implicit-any-lambda = false

‎src/msgspec/__init__.pyi‎

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ from typing import (
1515
overload,
1616
)
1717

18-
from typing_extensions import Buffer, Self, dataclass_transform
18+
from typing_extensions import Buffer, Self, TypeAlias, dataclass_transform
1919

2020
from . import inspect, json, msgpack, structs, toml, yaml
2121

@@ -90,6 +90,7 @@ def field(*, name: str | None = None) -> Any: ...
9090
class Struct(metaclass=StructMeta):
9191
__struct_fields__: ClassVar[tuple[str, ...]]
9292
__struct_config__: ClassVar[structs.StructConfig]
93+
__struct_encode_fields__: ClassVar[tuple[str, ...]]
9394
__match_args__: ClassVar[tuple[str, ...]]
9495
# A default __init__ so that Structs with unknown field types (say
9596
# constructed by `defstruct`) won't error on every call to `__init__`
@@ -159,19 +160,75 @@ class Raw(bytes):
159160
def __new__(cls, msg: Buffer | str) -> "Raw": ...
160161
def copy(self) -> "Raw": ...
161162

163+
#: We can't represent this in types, only via a name:
164+
_NonNegativeInt: TypeAlias = int
165+
162166
@final
163167
class Meta:
168+
# Numeric:
169+
# You can't mix:
170+
# - `gt` and `ge`
171+
# - `lt` and `le`
172+
@overload
164173
def __init__(
165174
self,
166175
*,
167176
gt: int | float | None = None,
177+
lt: int | float | None = None,
178+
multiple_of: int | float | None = None,
179+
title: str | None = None,
180+
description: str | None = None,
181+
examples: list[Any] | None = None,
182+
extra_json_schema: dict[str, Any] | None = None,
183+
extra: dict[str, Any] | None = None,
184+
) -> None: ...
185+
@overload
186+
def __init__(
187+
self,
188+
*,
189+
gt: int | float | None = None,
190+
le: int | float | None = None,
191+
multiple_of: int | float | None = None,
192+
title: str | None = None,
193+
description: str | None = None,
194+
examples: list[Any] | None = None,
195+
extra_json_schema: dict[str, Any] | None = None,
196+
extra: dict[str, Any] | None = None,
197+
) -> None: ...
198+
@overload
199+
def __init__(
200+
self,
201+
*,
168202
ge: int | float | None = None,
169203
lt: int | float | None = None,
204+
multiple_of: int | float | None = None,
205+
title: str | None = None,
206+
description: str | None = None,
207+
examples: list[Any] | None = None,
208+
extra_json_schema: dict[str, Any] | None = None,
209+
extra: dict[str, Any] | None = None,
210+
) -> None: ...
211+
@overload
212+
def __init__(
213+
self,
214+
*,
215+
ge: int | float | None = None,
170216
le: int | float | None = None,
171217
multiple_of: int | float | None = None,
218+
title: str | None = None,
219+
description: str | None = None,
220+
examples: list[Any] | None = None,
221+
extra_json_schema: dict[str, Any] | None = None,
222+
extra: dict[str, Any] | None = None,
223+
) -> None: ...
224+
# Other:
225+
@overload
226+
def __init__(
227+
self,
228+
*,
172229
pattern: str | None = None,
173-
min_length: int | None = None,
174-
max_length: int | None = None,
230+
min_length: _NonNegativeInt | None = None,
231+
max_length: _NonNegativeInt | None = None,
175232
tz: bool | None = None,
176233
title: str | None = None,
177234
description: str | None = None,

‎src/msgspec/_core.c‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9315,7 +9315,7 @@ Ext_dealloc(Ext *self)
93159315
}
93169316

93179317
static PyMemberDef Ext_members[] = {
9318-
{"code", T_INT, offsetof(Ext, code), READONLY, "The extension type code"},
9318+
{"code", T_LONG, offsetof(Ext, code), READONLY, "The extension type code"},
93199319
{"data", T_OBJECT_EX, offsetof(Ext, data), READONLY, "The extension data payload"},
93209320
{NULL},
93219321
};
@@ -14153,9 +14153,20 @@ json_encode_enum(EncoderState *self, PyObject *obj, bool is_key)
1415314153
PyObject *value = PyObject_GetAttr(obj, self->mod->str__value_);
1415414154
if (value == NULL) return -1;
1415514155

14156-
int status = (
14157-
is_key ? json_encode_dict_key_noinline(self, value) : json_encode(self, value)
14158-
);
14156+
int status;
14157+
if (is_key) {
14158+
/* A str value must be written as a JSON object key directly.
14159+
* json_encode_dict_key_noinline only handles non-str keys (str keys
14160+
* are fast-pathed in json_encode_dict_key), so recursing into it with
14161+
* a str value would wrongly raise "unsupported key" - e.g. for a plain
14162+
* Enum whose members have str values when used as a dict key. */
14163+
status = PyUnicode_Check(value)
14164+
? json_encode_str(self, value)
14165+
: json_encode_dict_key_noinline(self, value);
14166+
}
14167+
else {
14168+
status = json_encode(self, value);
14169+
}
1415914170

1416014171
Py_DECREF(value);
1416114172
return status;
@@ -17928,11 +17939,11 @@ json_decode_dict_key(JSONDecoderState *self, TypeNode *type, PathNode *path) {
1792817939
bool is_ascii = true;
1792917940
char *view = NULL;
1793017941
Py_ssize_t size;
17931-
bool is_str = type->types == MS_TYPE_ANY || type->types == MS_TYPE_STR;
1793217942

1793317943
size = json_decode_string_view(self, &view, &is_ascii);
1793417944
if (size < 0) return NULL;
1793517945
#ifndef Py_GIL_DISABLED
17946+
bool is_str = type->types == MS_TYPE_ANY || type->types == MS_TYPE_STR;
1793617947
bool cacheable = is_str && is_ascii && size > 0 && size <= STRING_CACHE_MAX_STRING_LENGTH;
1793717948
if (MS_UNLIKELY(!cacheable)) {
1793817949
return json_decode_dict_key_fallback(self, view, size, is_ascii, type, path);
@@ -19934,7 +19945,7 @@ PyDoc_STRVAR(msgspec_json_decode__doc__,
1993419945
" signature ``dec_hook(type: Type, obj: Any) -> Any``, where ``type`` is the\n"
1993519946
" expected message type, and ``obj`` is the decoded representation composed\n"
1993619947
" of only basic JSON types. This hook should transform ``obj`` into type\n"
19937-
" ``type``, or raise a ``TypeError`` if unsupported.\n"
19948+
" ``type``, or raise a ``NotImplementedError`` if unsupported.\n"
1993819949
"\n"
1993919950
"Returns\n"
1994019951
"-------\n"

‎src/msgspec/_json_schema.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ def to_schema(self, t: mi.Type, check_ref: bool = True) -> dict[str, Any]:
330330
elif isinstance(t, mi.UnionType):
331331
structs = {}
332332
other = []
333+
none_member = None
333334
tag_field = None
334335
for subtype in t.types:
335336
real_type = subtype
@@ -338,6 +339,8 @@ def to_schema(self, t: mi.Type, check_ref: bool = True) -> dict[str, Any]:
338339
if isinstance(real_type, mi.StructType) and not real_type.array_like:
339340
tag_field = real_type.tag_field
340341
structs[real_type.tag] = real_type
342+
elif isinstance(real_type, mi.NoneType):
343+
none_member = subtype
341344
else:
342345
other.append(subtype)
343346

@@ -354,17 +357,29 @@ def to_schema(self, t: mi.Type, check_ref: bool = True) -> dict[str, Any]:
354357
}
355358
if options:
356359
options.append(struct_schema)
360+
if none_member is not None:
361+
options.append(self.to_schema(none_member))
357362
schema["anyOf"] = options
363+
elif none_member is not None:
364+
schema["anyOf"] = [struct_schema, self.to_schema(none_member)]
358365
else:
359366
schema.update(struct_schema)
360367
elif len(structs) == 1:
361368
_, subtype = structs.popitem()
362369
options.append(self.to_schema(subtype))
370+
if none_member is not None:
371+
options.append(self.to_schema(none_member))
363372
schema["anyOf"] = options
364373
else:
374+
if none_member is not None:
375+
options.append(self.to_schema(none_member))
365376
schema["anyOf"] = options
366377
elif isinstance(t, mi.LiteralType):
367-
schema["enum"] = sorted(t.values)
378+
# `t.values` may mix types (e.g. `Literal[1, None]`), which a plain
379+
# `sorted` can't order; reuse the same type-aware sort as
380+
# `inspect.type_info` so the two entry points agree and neither
381+
# crashes.
382+
schema["enum"] = list(mi._sort_literal_args(t.values))
368383
elif isinstance(t, mi.EnumType):
369384
schema.setdefault("title", t.cls.__name__)
370385
if doc := _get_doc(t):

‎src/msgspec/inspect.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,11 @@ class LiteralType(Type):
308308
----------
309309
values: tuple
310310
A tuple of possible values for this literal instance. Only `bool`,
311-
`str`, or `int` literals are supported.
311+
`str`, `int`, or `None` literals are supported, and a single literal
312+
may mix these types (e.g. ``Literal[1, None]``).
312313
"""
313314

314-
values: tuple[bool, ...] | tuple[str, ...] | tuple[int, ...]
315+
values: tuple[bool | str | int | None, ...]
315316

316317

317318
class CustomType(Type):
@@ -694,6 +695,18 @@ def _origin_args_metadata(t):
694695
return origin, args, tuple(metadata)
695696

696697

698+
def _sort_literal_args(args):
699+
# `Literal` may mix value types (e.g. `Literal[1, None]`), which a plain
700+
# `sorted` can't order since Python 3 forbids comparing across types. Sort
701+
# by type name first so members of the same type stay ordered as before,
702+
# while mixed-type literals are grouped deterministically instead of
703+
# crashing.
704+
try:
705+
return tuple(sorted(args, key=lambda x: (type(x).__name__, x)))
706+
except TypeError:
707+
return tuple(args)
708+
709+
697710
def _is_enum(t):
698711
return type(t) is enum.EnumMeta
699712

@@ -909,7 +922,7 @@ def _translate_inner(
909922
args = tuple(self.translate(a) for a in args if a is not _UnsetType)
910923
return args[0] if len(args) == 1 else UnionType(args)
911924
elif t is Literal:
912-
return LiteralType(tuple(sorted(args)))
925+
return LiteralType(_sort_literal_args(args))
913926
elif _is_enum(t):
914927
return EnumType(t)
915928
elif is_struct_type(t):

0 commit comments

Comments
 (0)