工具参数的 JSON Schema 通过了,为什么运行时仍要拒绝?
两个日期都符合格式,不代表它们组成了一个可执行的时间窗。亲手跑一遍 Pydantic 的参数验证边界。
- 区分 JSON 与 Python 验证入口
- 用模型校验器声明时间先后
- 实测生成 Schema 与运行时差别
- 让参数验证停在权限边界之前
1. 先把那次倒着走的导出拦下来
给一个导出工具声明 start、end 和 limit,看起来已经说得很清楚:时间是日期字符串,数量是整数。可当 start 是 10 月 1 日、end 是 9 月 30 日时,两个字段分别都很像正确答案。生成的 JSON Schema 能读懂各自的形状,却没有自动带上“结束时间必须更晚”这条程序规则。
这篇做一个只读的导出时间窗参数预览器。输入全部是合成数据;程序只验证并打印计划,不读取账户、不查询数据库、不生成业务导出文件。真实运行的是 Pydantic 2.13.4 和 jsonschema 4.26.0。没有调用模型、没有接 Agent SDK,也不需要 API key。
你会拿到同一模型的 Schema、20 组独立输入及预期、30 项测试,以及两种验证入口的真实输出。重点是把第三方库的解析、约束和序列化 API 用对;这里不重做批量文本抽取或来源引用核验。
2. 准备一个可以完整复制的小目录
实测环境是 Python 3.12.14。源码使用 Self,需要 Python 3.11 以上;复现本次结果请用相同版本组合。本文环境的依赖原先已安装,本次没有执行安装。requirements.txt 记录直接依赖版本,并不包含库源码,也不是完整的跨平台锁文件。若你的环境缺少这两个库,先在自己的隔离环境准备依赖,再运行;本例不会替你下载或安装。
requirements.txt
pydantic==2.13.4
jsonschema==4.26.0
建立 fixtures 目录,将下面各段代码分别保存到标出的路径。所有运行依赖的自编文件都在正文完整给出;下载包还带有 HTML 和三张独立 SVG。运行目录应为这些 Python 文件所在的位置。
python -c "import pydantic; from importlib.metadata import version; print(pydantic.__version__, version('jsonschema'))"
python demo.py
python -m unittest -v3. 让模型承担四条明确的规则
这份教学契约要求四个字段全部出现:start 与 end 必须带时区,limit 是 1 到 500 的整数,include_archived 是布尔值。两端转换成 UTC 后,还必须落在 Python datetime 能表示的年份范围内;极端年份与偏移量组合造成的溢出会转换为校验错误。时间窗按半开区间 [start, end) 理解,结束必须晚于开始,跨度最多七个连续的 24 小时。这不是按某个地区日历计算的“七天”;夏令时和地区日历需求要另外定义。
contract.py
"""Offline parameter validation only: no account, network, export or authorization."""
from datetime import timezone, timedelta
from typing import Self
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
class ExportWindow(BaseModel):
model_config = ConfigDict(strict=True, extra='forbid', frozen=True)
start: AwareDatetime
end: AwareDatetime
limit: int = Field(ge=1, le=500)
include_archived: bool
@model_validator(mode='after')
def check_window(self) -> Self:
try:
start = self.start.astimezone(timezone.utc)
end = self.end.astimezone(timezone.utc)
except OverflowError as exc:
raise ValueError('timestamps must be representable in UTC') from exc
if end <= start:
raise ValueError('end must be after start')
if end - start > timedelta(days=7):
raise ValueError('window must not exceed 7 days')
return self
def preview_json(raw: str) -> dict:
# Keep raw JSON at this boundary; do not json.loads then validate_python.
window = ExportWindow.model_validate_json(raw)
return {
'state': 'parameters_valid_authorization_required',
'start_utc': window.start.astimezone(timezone.utc).isoformat(),
'end_utc': window.end.astimezone(timezone.utc).isoformat(),
'limit': window.limit,
'include_archived': window.include_archived,
'export_performed': False,
}
strict 模式文档提醒不同类型、不同入口可能有不同处理规则。这里不给字符串数字兜底转换,"100"、true 和 100.0 都不能冒充 limit 的整数。extra 配置设为 forbid,让拼错的字段和多出的字段都显式报错。frozen 只防止通常的属性赋值,不能把 Python 对象变成安全边界。
AwareDatetime要求带时区。跨字段规则放在 after 模型校验器里:只有字段验证成功后,才拿到可以比较的 datetime。我们把两端转为 UTC 后计算先后与时长,避免直接比较带不同偏移量的日期文字。具体写法对应模型校验器文档,两条业务规则是本文自行定义的。
4. 保留 JSON 边界,别在中途换成另一种输入
最容易踩到的坑藏在一个看似无害的 json.loads 里。model_validate_json 接收原始 JSON;JSON 没有 datetime 这种类型,因此严格模式仍允许符合要求的日期字符串。model_validate 接收 Python 值,strict 模式对日期字段要求 datetime 对象。把同一份 JSON 先转成 dict 再送进去,会让 start 和 end 各报一个 datetime_type。
还要单独考虑重复 JSON 键。在本次固定版本中,同一个对象出现两次 limit 时,Pydantic 默认保留后一个值;strict 与 extra=forbid 不会替你拒绝重复的已知键。新增回归把这一行为固定下来。如果接入契约要求拒绝重复键,必须在原始 JSON 边界另加检测,并保留原始 JSON 交给这里的验证入口;只检查解析后的 dict 已经看不到被覆盖的键。本文预览器没有实现该检测。
这不是随机行为,也不适合用 strict=False 草率掩盖。先确定工具边界交给你的是什么:原始 JSON 用 JSON 入口;已有 Python 对象用 Python 入口,并让日期字段真正成为 datetime。本文同时保留两个失败和成功样本,防止未来把入口重构时悄悄改变契约。
5. 先独立写输入和预期,再运行程序
inputs.json 只描述输入与入口。expected.json 是依据前述契约单独编写的预期,没有从模型返回值自动生成。python_datetime 用标准库显式构造日期对象,python 则保持原样。offset_order 特意写成当地钟面 10:00 对 UTC 03:00;换算后开始是 UTC 02:00,因此应当通过。
fixtures/inputs.json
[
{
"id": "valid",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "limit_string",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": "100",
"include_archived": false
}
},
{
"id": "limit_bool",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": true,
"include_archived": false
}
},
{
"id": "limit_float",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100.0,
"include_archived": false
}
},
{
"id": "limit_zero",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 0,
"include_archived": false
}
},
{
"id": "limit_over",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 501,
"include_archived": false
}
},
{
"id": "bool_string",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100,
"include_archived": "false"
}
},
{
"id": "bool_number",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100,
"include_archived": 0
}
},
{
"id": "approved_extra",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100,
"include_archived": false,
"approved": true
}
},
{
"id": "tenant_extra",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100,
"include_archived": false,
"tenantID": "tenant-other"
}
},
{
"id": "naive",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00",
"end": "2026-10-02T00:00:00Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "reverse",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-09-30T00:00:00Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "equal",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-01T00:00:00Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "seven_days",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-08T00:00:00Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "over_seven_days",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-08T00:00:01Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "offset_order",
"entry": "json",
"input": {
"start": "2026-10-01T10:00:00+08:00",
"end": "2026-10-01T03:00:00Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "python_strings",
"entry": "python",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "python_datetimes",
"entry": "python_datetime",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100,
"include_archived": false
}
},
{
"id": "null_limit",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": null,
"include_archived": false
}
},
{
"id": "missing_bool",
"entry": "json",
"input": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-10-02T00:00:00Z",
"limit": 100
}
}
]
fixtures/expected.json
{
"valid": {
"ok": true,
"errors": []
},
"limit_string": {
"ok": false,
"errors": [
{
"loc": [
"limit"
],
"type": "int_type"
}
]
},
"limit_bool": {
"ok": false,
"errors": [
{
"loc": [
"limit"
],
"type": "int_type"
}
]
},
"limit_float": {
"ok": false,
"errors": [
{
"loc": [
"limit"
],
"type": "int_type"
}
]
},
"limit_zero": {
"ok": false,
"errors": [
{
"loc": [
"limit"
],
"type": "greater_than_equal"
}
]
},
"limit_over": {
"ok": false,
"errors": [
{
"loc": [
"limit"
],
"type": "less_than_equal"
}
]
},
"bool_string": {
"ok": false,
"errors": [
{
"loc": [
"include_archived"
],
"type": "bool_type"
}
]
},
"bool_number": {
"ok": false,
"errors": [
{
"loc": [
"include_archived"
],
"type": "bool_type"
}
]
},
"approved_extra": {
"ok": false,
"errors": [
{
"loc": [
"approved"
],
"type": "extra_forbidden"
}
]
},
"tenant_extra": {
"ok": false,
"errors": [
{
"loc": [
"tenantID"
],
"type": "extra_forbidden"
}
]
},
"naive": {
"ok": false,
"errors": [
{
"loc": [
"start"
],
"type": "timezone_aware"
}
]
},
"reverse": {
"ok": false,
"errors": [
{
"loc": [],
"type": "value_error"
}
]
},
"equal": {
"ok": false,
"errors": [
{
"loc": [],
"type": "value_error"
}
]
},
"seven_days": {
"ok": true,
"errors": []
},
"over_seven_days": {
"ok": false,
"errors": [
{
"loc": [],
"type": "value_error"
}
]
},
"offset_order": {
"ok": true,
"errors": []
},
"python_strings": {
"ok": false,
"errors": [
{
"loc": [
"start"
],
"type": "datetime_type"
},
{
"loc": [
"end"
],
"type": "datetime_type"
}
]
},
"python_datetimes": {
"ok": true,
"errors": []
},
"null_limit": {
"ok": false,
"errors": [
{
"loc": [
"limit"
],
"type": "int_type"
}
]
},
"missing_bool": {
"ok": false,
"errors": [
{
"loc": [
"include_archived"
],
"type": "missing"
}
]
}
}
预期只匹配成功状态、错误位置与类型,不依赖异常文案的标点或错误链接。根级别错误的 loc 是空数组,表示跨字段关系未通过。额外字段的错误位置则直接指向 approved 或 tenantID。
6. 用真库验证,不再手写一套字段检查器
matrix.py
"""Inputs and expectations are separate files. No result-derived oracle."""
import json
from pathlib import Path
from datetime import datetime
from pydantic import ValidationError
from contract import ExportWindow
ROOT = Path(__file__).resolve().parent
def read(name):
return json.loads((ROOT / 'fixtures' / name).read_text(encoding='utf-8'))
def execute(case):
payload = dict(case['input'])
if case['entry'] == 'python_datetime':
for name in ('start', 'end'):
payload[name] = datetime.fromisoformat(payload[name])
try:
if case['entry'] == 'json':
model = ExportWindow.model_validate_json(json.dumps(payload))
else:
model = ExportWindow.model_validate(payload)
return {'ok': True, 'errors': [], 'normalized': model.model_dump(mode='json')}
except ValidationError as exc:
return {'ok': False, 'errors': [
{'loc': list(e['loc']), 'type': e['type']} for e in exc.errors()
]}
demo.py
import json
import platform
import pydantic
from jsonschema import Draft202012Validator
from contract import ExportWindow, preview_json
from matrix import execute, read
print('Python', platform.python_version(), 'Pydantic', pydantic.__version__)
cases = read('inputs.json')
for case in cases:
result = execute(case)
print(case['id'], json.dumps(result, ensure_ascii=False, sort_keys=True))
reverse = next(c['input'] for c in cases if c['id'] == 'reverse')
validator = Draft202012Validator(ExportWindow.model_json_schema())
validator.validate(reverse)
print('reverse: generated JSON Schema structure PASS (format not asserted); runtime REJECT')
print('preview', json.dumps(preview_json(json.dumps(cases[0]['input'])), sort_keys=True))
m = ExportWindow.model_validate_json(json.dumps(cases[0]['input']))
print('json round trip:', ExportWindow.model_validate_json(m.model_dump_json()) == m)
print('python round trip:', ExportWindow.model_validate(m.model_dump()) == m)
print('schema', json.dumps(ExportWindow.model_json_schema(), ensure_ascii=False, sort_keys=True))
demo.py 从同一个 ExportWindow 生成 Schema,再用已安装的 jsonschema Draft202012Validator 验证倒序样本。这里只检查结构和 Schema 中的约束,不断言 format:本次环境没有 date-time 的可选格式检查器,也没有为此安装依赖。jsonschema 的官方验证文档说明,未配置可用格式检查器时,format 不会强制检查。
倒序样本的日期字符串都能被 Pydantic 正常解析;运行时最终拒绝的是两者关系。这一点由返回的根级 value_error 和独立输入入口测试体现。即使下游再增加正确的日期格式检查,格式正确也不会凭空表达 end > start。本文只证明这份生成 Schema 的行为,不声称所有 Schema 或所有验证器都没有表达业务约束的办法。
生成的 Schema 包含 required、additionalProperties: false、整数上下界和日期 format。after 校验器里的七天上限与先后比较没有自动变成其中的关键字。也不要直接把这份 Schema 称作某家模型服务已经支持的工具契约:服务商支持的 Schema 子集和工具包装行为需要另行接线验证,本篇没有做这一步。
7. 读一次真实输出,再检查序列化往返
以下为本次完整演示输出。失败项只打印 loc 和 type,不把传入内容复制进错误日志。实际系统的 ValidationError 可能包含原始输入,记录前需要按数据敏感程度处理。
Python 3.12.14 Pydantic 2.13.4
valid {"errors": [], "normalized": {"end": "2026-10-02T00:00:00Z", "include_archived": false, "limit": 100, "start": "2026-10-01T00:00:00Z"}, "ok": true}
limit_string {"errors": [{"loc": ["limit"], "type": "int_type"}], "ok": false}
limit_bool {"errors": [{"loc": ["limit"], "type": "int_type"}], "ok": false}
limit_float {"errors": [{"loc": ["limit"], "type": "int_type"}], "ok": false}
limit_zero {"errors": [{"loc": ["limit"], "type": "greater_than_equal"}], "ok": false}
limit_over {"errors": [{"loc": ["limit"], "type": "less_than_equal"}], "ok": false}
bool_string {"errors": [{"loc": ["include_archived"], "type": "bool_type"}], "ok": false}
bool_number {"errors": [{"loc": ["include_archived"], "type": "bool_type"}], "ok": false}
approved_extra {"errors": [{"loc": ["approved"], "type": "extra_forbidden"}], "ok": false}
tenant_extra {"errors": [{"loc": ["tenantID"], "type": "extra_forbidden"}], "ok": false}
naive {"errors": [{"loc": ["start"], "type": "timezone_aware"}], "ok": false}
reverse {"errors": [{"loc": [], "type": "value_error"}], "ok": false}
equal {"errors": [{"loc": [], "type": "value_error"}], "ok": false}
seven_days {"errors": [], "normalized": {"end": "2026-10-08T00:00:00Z", "include_archived": false, "limit": 100, "start": "2026-10-01T00:00:00Z"}, "ok": true}
over_seven_days {"errors": [{"loc": [], "type": "value_error"}], "ok": false}
offset_order {"errors": [], "normalized": {"end": "2026-10-01T03:00:00Z", "include_archived": false, "limit": 100, "start": "2026-10-01T10:00:00+08:00"}, "ok": true}
python_strings {"errors": [{"loc": ["start"], "type": "datetime_type"}, {"loc": ["end"], "type": "datetime_type"}], "ok": false}
python_datetimes {"errors": [], "normalized": {"end": "2026-10-02T00:00:00Z", "include_archived": false, "limit": 100, "start": "2026-10-01T00:00:00Z"}, "ok": true}
null_limit {"errors": [{"loc": ["limit"], "type": "int_type"}], "ok": false}
missing_bool {"errors": [{"loc": ["include_archived"], "type": "missing"}], "ok": false}
reverse: generated JSON Schema structure PASS (format not asserted); runtime REJECT
preview {"end_utc": "2026-10-02T00:00:00+00:00", "export_performed": false, "include_archived": false, "limit": 100, "start_utc": "2026-10-01T00:00:00+00:00", "state": "parameters_valid_authorization_required"}
json round trip: True
python round trip: True
schema {"additionalProperties": false, "properties": {"end": {"format": "date-time", "title": "End", "type": "string"}, "include_archived": {"title": "Include Archived", "type": "boolean"}, "limit": {"maximum": 500, "minimum": 1, "title": "Limit", "type": "integer"}, "start": {"format": "date-time", "title": "Start", "type": "string"}}, "required": ["start", "end", "limit", "include_archived"], "title": "ExportWindow", "type": "object"}
序列化文档区分 Python 模式和 JSON 模式。model_dump() 在本例保留 datetime,适合回到 Python 入口;model_dump_json() 产生 JSON 文本,回到 JSON 入口。model_dump(mode="json") 虽是 dict,日期已成字符串,不能直接套进 strict Python 入口。往返测试确认的是这份模型的值保持一致,不保证输出字节、日期原始写法或字段顺序完全不变,也没有获得签名或防篡改能力。
8. 把回归测试一起留下
test_contract.py
import json
import unittest
from datetime import datetime
from pydantic import ValidationError
from jsonschema import Draft202012Validator
from contract import ExportWindow, preview_json
from matrix import execute, read
CASES = read('inputs.json')
EXPECTED = read('expected.json')
class ContractTests(unittest.TestCase):
def good(self):
return ExportWindow.model_validate_json(json.dumps(CASES[0]['input']))
def test_matrix_has_independent_complete_expectations(self):
self.assertEqual(set(EXPECTED), {c['id'] for c in CASES})
self.assertEqual(len(CASES), len(EXPECTED))
def test_schema_accepts_reverse_structure(self):
s = ExportWindow.model_json_schema()
Draft202012Validator.check_schema(s)
payload = next(c['input'] for c in CASES if c['id'] == 'reverse')
Draft202012Validator(s).validate(payload)
self.assertFalse(execute(next(c for c in CASES if c['id'] == 'reverse'))['ok'])
def test_schema_limits_and_extras(self):
s = ExportWindow.model_json_schema()
self.assertFalse(s['additionalProperties'])
self.assertEqual(s['properties']['limit']['maximum'], 500)
self.assertEqual(s['properties']['start']['format'], 'date-time')
self.assertEqual(set(s['required']), {'start', 'end', 'limit', 'include_archived'})
def test_json_roundtrip(self):
m = self.good()
self.assertEqual(ExportWindow.model_validate_json(m.model_dump_json()), m)
def test_python_roundtrip(self):
m = self.good()
self.assertIsInstance(m.model_dump()['start'], datetime)
self.assertEqual(ExportWindow.model_validate(m.model_dump()), m)
def test_json_mode_dict_is_not_python_mode_dict(self):
with self.assertRaises(ValidationError) as caught:
ExportWindow.model_validate(self.good().model_dump(mode='json'))
self.assertEqual([e['type'] for e in caught.exception.errors()], ['datetime_type', 'datetime_type'])
def test_preview_never_claims_authorization_or_export(self):
p = preview_json(json.dumps(CASES[0]['input']))
self.assertEqual(p['state'], 'parameters_valid_authorization_required')
self.assertIs(p['export_performed'], False)
self.assertNotIn('tenantID', p)
def test_utc_representation_overflow_is_validation_error(self):
pairs = [
('0001-01-01T00:00:00+01:00', '0001-01-01T01:00:00+01:00'),
('9999-12-31T22:00:00-02:00', '9999-12-31T23:00:00-02:00'),
]
for start, end in pairs:
with self.subTest(start=start):
payload = CASES[0]['input'] | {'start': start, 'end': end}
with self.assertRaises(ValidationError) as caught:
preview_json(json.dumps(payload))
errors = caught.exception.errors()
self.assertEqual(errors[0]['type'], 'value_error')
self.assertEqual(errors[0]['loc'], ())
self.assertIn('representable in UTC', errors[0]['msg'])
def test_duplicate_known_json_key_uses_last_value(self):
raw = json.dumps(CASES[0]['input'])
duplicated = raw[:-1] + ', "limit": 17}'
self.assertEqual(ExportWindow.model_validate_json(duplicated).limit, 17)
self.assertEqual(preview_json(duplicated)['limit'], 17)
def test_frozen_assignment(self):
with self.assertRaises(ValidationError):
self.good().limit = 999
for case in CASES:
def test(self, case=case):
actual = execute(case)
self.assertEqual({'ok': actual['ok'], 'errors': actual['errors']}, EXPECTED[case['id']])
setattr(ContractTests, 'test_case_' + case['id'], test)
if __name__ == '__main__':
unittest.main(verbosity=2)
本次执行 30 项测试通过:20 个逐项输入用例,加上 Schema、往返、冻结赋值和权限状态等十项检查。测试不依靠网络或 API key,也不会创建导出任务。它们检验的是这份固定契约,不能当作模型抽取准确率、权限覆盖率或真实工具成功率。
test_case_approved_extra (test_contract.ContractTests.test_case_approved_extra) ... ok
test_case_bool_number (test_contract.ContractTests.test_case_bool_number) ... ok
test_case_bool_string (test_contract.ContractTests.test_case_bool_string) ... ok
test_case_equal (test_contract.ContractTests.test_case_equal) ... ok
test_case_limit_bool (test_contract.ContractTests.test_case_limit_bool) ... ok
test_case_limit_float (test_contract.ContractTests.test_case_limit_float) ... ok
test_case_limit_over (test_contract.ContractTests.test_case_limit_over) ... ok
test_case_limit_string (test_contract.ContractTests.test_case_limit_string) ... ok
test_case_limit_zero (test_contract.ContractTests.test_case_limit_zero) ... ok
test_case_missing_bool (test_contract.ContractTests.test_case_missing_bool) ... ok
test_case_naive (test_contract.ContractTests.test_case_naive) ... ok
test_case_null_limit (test_contract.ContractTests.test_case_null_limit) ... ok
test_case_offset_order (test_contract.ContractTests.test_case_offset_order) ... ok
test_case_over_seven_days (test_contract.ContractTests.test_case_over_seven_days) ... ok
test_case_python_datetimes (test_contract.ContractTests.test_case_python_datetimes) ... ok
test_case_python_strings (test_contract.ContractTests.test_case_python_strings) ... ok
test_case_reverse (test_contract.ContractTests.test_case_reverse) ... ok
test_case_seven_days (test_contract.ContractTests.test_case_seven_days) ... ok
test_case_tenant_extra (test_contract.ContractTests.test_case_tenant_extra) ... ok
test_case_valid (test_contract.ContractTests.test_case_valid) ... ok
test_duplicate_known_json_key_uses_last_value (test_contract.ContractTests.test_duplicate_known_json_key_uses_last_value) ... ok
test_frozen_assignment (test_contract.ContractTests.test_frozen_assignment) ... ok
test_json_mode_dict_is_not_python_mode_dict (test_contract.ContractTests.test_json_mode_dict_is_not_python_mode_dict) ... ok
test_json_roundtrip (test_contract.ContractTests.test_json_roundtrip) ... ok
test_matrix_has_independent_complete_expectations (test_contract.ContractTests.test_matrix_has_independent_complete_expectations) ... ok
test_preview_never_claims_authorization_or_export (test_contract.ContractTests.test_preview_never_claims_authorization_or_export) ... ok
test_python_roundtrip (test_contract.ContractTests.test_python_roundtrip) ... ok
test_schema_accepts_reverse_structure (test_contract.ContractTests.test_schema_accepts_reverse_structure) ... ok
test_schema_limits_and_extras (test_contract.ContractTests.test_schema_limits_and_extras) ... ok
test_utc_representation_overflow_is_validation_error (test_contract.ContractTests.test_utc_representation_overflow_is_validation_error) ... ok
----------------------------------------------------------------------
Ran 30 tests in 0.007s
OK
9. 参数合格以后,业务权限仍然要另查
approved: true 和 tenantID: "tenant-other" 在本模型里都是不允许的额外字段,因此直接报 extra_forbidden。这样做能减少接口含混,不能独自承担授权。即便将来业务确实需要 tenantID 字段,它也只能是请求目标,服务端仍须将其与已验证的调用者身份和允许的数据范围核对;请求者填写的值不能成为授予权限的依据。
preview_json 的成功状态故意停在 parameters_valid_authorization_required,export_performed 恒为 false。这里没有假装实现一个能放行的权限函数。要接真实业务,需在服务端用受信任上下文验证身份、租户范围、档案读取许可、必要审批和审计,再进入真正读取或导出步骤。只读操作也可能泄露数据,参数正确不等于有权查看。
同样,七天窗口内的数据是否存在、时间是否符合用户本意、结果是否完整,模型都不知道。不要对外来数据使用 model_construct 绕过验证,也不要把未经验证的更新塞入对象后直接执行。本篇只用正常验证入口和不可变字段,并未测试所有可能的 Python 绕过方式。
把这个例子接进 Agent 时,先记录工具参数究竟在什么时候、由谁解析,确认失败不会进入业务函数;再拿这里的矩阵重跑。到那一步才能声称真实接线已验证。当前完成的是本地参数契约实验,边界止于预览。
strict 模式为什么还接受日期字符串?
JSON 没有 datetime 原生类型,严格 JSON 入口允许符合要求的日期字符串;Python 入口规则不同。
本文运行了真实 Agent 吗?
没有。真实运行的是 Pydantic 和 jsonschema,本例只打印参数预览,不调用模型、SDK 或业务工具。
参数通过能证明有权导出吗?
不能。身份、租户范围与业务权限需由受信任的服务端上下文独立核对。