Skip to content

Commit 6364b16

Browse files
chore(main): release jacobian 0.12.0 (#1186)
* chore(main): release jacobian 0.12.0 * chore(release): synchronize uv and npm lockfiles * docs(release): include merged operation features * docs(release): match changelog issue links * fix(runtime): accept tuple-shaped JSON model fields --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
1 parent 76bbd4b commit 6364b16

10 files changed

Lines changed: 250 additions & 13 deletions

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "0.11.0"
2+
".": "0.12.0"
33
}

CHANGELOG.md

Lines changed: 119 additions & 0 deletions
Large diffs are not rendered by default.

npm/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

npm/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "jacobian",
3-
"version": "0.11.0",
3+
"version": "0.12.0",
44
"mcpName": "io.github.morluto/jacobian",
55
"description": "Composable mathematical capabilities for AI agents, exposed through MCP and a CLI",
66
"license": "MIT",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "jacobian"
7-
version = "0.11.0"
7+
version = "0.12.0"
88
description = "Atomic mathematical tools for agents over MCP and Python"
99
readme = "README.md"
1010
requires-python = ">=3.12,<3.14"

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
"url": "https://github.com/morluto/jacobian",
88
"source": "github"
99
},
10-
"version": "0.11.0",
10+
"version": "0.12.0",
1111
"packages": [
1212
{
1313
"registryType": "npm",
1414
"registryBaseUrl": "https://registry.npmjs.org",
1515
"identifier": "jacobian",
16-
"version": "0.11.0",
16+
"version": "0.12.0",
1717
"runtimeHint": "npx",
1818
"transport": {
1919
"type": "stdio"

src/jacobian/capability_adapters.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
56
from typing import Any, Protocol, TypeVar
67

7-
from pydantic import BaseModel
8+
from pydantic import BaseModel, ValidationError
89

910
from jacobian.canonical import CanonicalizationError, encode_strict_json
1011
from jacobian.capability_errors import CapabilityInvocationError
@@ -19,6 +20,86 @@
1920
PreparedT = TypeVar("PreparedT")
2021

2122

23+
def _restore_json_tuple_paths(
24+
value: Any,
25+
paths: tuple[tuple[Any, ...], ...],
26+
) -> Any:
27+
"""Restore tuple-shaped model fields after JSON parsing.
28+
29+
JSON arrays are the wire representation for both lists and tuples. Pydantic
30+
strict JSON validation accepts the former, but rejects some constrained
31+
tuple annotations even though their JSON representation is valid. Only the
32+
paths identified by that precise validation error are adapted; all scalar
33+
values remain subject to strict Python-mode validation below.
34+
"""
35+
36+
if any(not path for path in paths):
37+
return tuple(value) if isinstance(value, list) else value
38+
39+
if isinstance(value, Mapping):
40+
restored_mapping = dict(value)
41+
for key in restored_mapping:
42+
child_paths = tuple(path[1:] for path in paths if path and path[0] == key)
43+
if child_paths:
44+
restored_mapping[key] = _restore_json_tuple_paths(
45+
restored_mapping[key], child_paths
46+
)
47+
return restored_mapping
48+
49+
if isinstance(value, (list, tuple)):
50+
restored_sequence = list(value)
51+
for index in range(len(restored_sequence)):
52+
child_paths = tuple(path[1:] for path in paths if path and path[0] == index)
53+
if child_paths:
54+
restored_sequence[index] = _restore_json_tuple_paths(
55+
restored_sequence[index], child_paths
56+
)
57+
return (
58+
tuple(restored_sequence) if isinstance(value, tuple) else restored_sequence
59+
)
60+
61+
return value
62+
63+
64+
def _validate_strict_json_model[ModelT: BaseModel](
65+
model: type[ModelT], encoded: bytes, wire_payload: object
66+
) -> ModelT:
67+
"""Validate JSON strictly while honoring tuple fields' array wire form."""
68+
69+
try:
70+
parsed = model.model_validate_json(encoded, strict=True)
71+
except ValidationError as exc:
72+
# Pydantic's strict JSON path treats some constrained tuple fields as
73+
# Python-only tuples, although JSON arrays are their valid wire form.
74+
# Normalize only paths reported as tuple mismatches and retry the whole
75+
# model in strict Python mode. Nested tuple fields are reported only
76+
# after their containing tuple has been normalized, so repeat until the
77+
# model validates or a non-tuple error remains.
78+
normalized_payload = wire_payload
79+
for _ in range(64):
80+
errors = exc.errors()
81+
tuple_paths = tuple(
82+
tuple(error["loc"]) for error in errors if error["type"] == "tuple_type"
83+
)
84+
if not tuple_paths:
85+
raise exc
86+
updated_payload = _restore_json_tuple_paths(
87+
normalized_payload,
88+
tuple_paths,
89+
)
90+
if updated_payload == normalized_payload:
91+
raise exc
92+
normalized_payload = updated_payload
93+
try:
94+
parsed = model.model_validate(normalized_payload, strict=True)
95+
except ValidationError as retry_exc:
96+
exc = retry_exc
97+
else:
98+
return parsed
99+
raise exc
100+
return parsed
101+
102+
22103
def parse_capability_input[ModelT: BaseModel](
23104
model: type[ModelT], payload: dict[str, Any]
24105
) -> ModelT:
@@ -57,7 +138,7 @@ def parse_capability_input[ModelT: BaseModel](
57138
),
58139
)
59140
) from exc
60-
parsed = model.model_validate_json(encoded, strict=True)
141+
parsed = _validate_strict_json_model(model, encoded, wire_payload)
61142
if typed_values:
62143
# Parse all ordinary fields through the strict JSON boundary, then
63144
# restore the already-validated port values. This preserves identity

src/jacobian/capability_dispatch.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88

99
from pydantic import ValidationError
1010

11-
from jacobian.capability_adapters import CapabilityAdapter
11+
from jacobian.capability_adapters import (
12+
CapabilityAdapter,
13+
_validate_strict_json_model,
14+
)
1215
from jacobian.capability_errors import (
1316
CapabilityError,
1417
CapabilityInvocationError,
@@ -291,8 +294,10 @@ def _adapter_output_model_failure(
291294
# PrimeFieldMatrix) into mappings, which strict model validation
292295
# quite correctly rejects even though the published output is
293296
# already a valid typed model.
294-
output_type.model_validate_json(
295-
output.model_dump_json(warnings="error"), strict=True
297+
_validate_strict_json_model(
298+
output_type,
299+
output.model_dump_json(warnings="error").encode(),
300+
output.model_dump(mode="json"),
296301
)
297302
except (TypeError, ValueError, ValidationError):
298303
pass
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from __future__ import annotations
2+
3+
from typing import Annotated
4+
5+
import pytest
6+
from pydantic import StringConstraints, ValidationError
7+
8+
from jacobian.capability_adapters import parse_capability_input
9+
from jacobian.contracts.results import ContractModel
10+
11+
12+
class _TupleRequest(ContractModel):
13+
labels: tuple[Annotated[str, StringConstraints(strict=True)], ...]
14+
limit: int
15+
16+
17+
def test_parse_capability_input_accepts_json_arrays_for_constrained_tuples() -> None:
18+
parsed = parse_capability_input(
19+
_TupleRequest,
20+
{"labels": ["left", "right"], "limit": 2},
21+
)
22+
23+
assert parsed.labels == ("left", "right")
24+
assert isinstance(parsed.labels, tuple)
25+
26+
27+
def test_tuple_normalization_does_not_enable_scalar_coercion() -> None:
28+
with pytest.raises(ValidationError):
29+
parse_capability_input(
30+
_TupleRequest,
31+
{"labels": ["left"], "limit": "1"},
32+
)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)