validkit-py 1.0.0__tar.gz
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.
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: validkit-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A simple and powerful validation library for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/disnana/ValidKit
|
|
6
|
+
Author: disnana
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# ValidKit
|
|
16
|
+
|
|
17
|
+
ValidKit は、辞書ベースで簡潔に定義でき、日本語キーや複雑なネストに対応した Python バリデーションライブラリです。 Pydantic よりも軽量かつ、Discord ボットの設定管理などの複雑なデータ構造を直感的に扱うことができます。
|
|
18
|
+
|
|
19
|
+
## 特徴
|
|
20
|
+
|
|
21
|
+
- 📝 **辞書ベースのスキーマ**: クラス定義不要。データ構造をそのままスキーマとして定義可能
|
|
22
|
+
- 🌏 **日本語キー対応**: 日本語をキーにした設定データもそのまま検証
|
|
23
|
+
- 🔗 **チェイン可能なバリデータ**: `v.int().range(1, 10)` のように直感的に記述
|
|
24
|
+
- 🔄 **マイグレーション機能**: 古い形式のデータを検証と同時に新形式へ自動変換
|
|
25
|
+
- 🛠️ **部分更新・デフォルト値**: 足りないキーの補完や部分的な検証をサポート
|
|
26
|
+
- 🔍 **詳細なエラー収集**: 全エラーを一括で取得し、エラー箇所のパスを明確に表示
|
|
27
|
+
|
|
28
|
+
## インストール
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install validkit
|
|
32
|
+
```
|
|
33
|
+
*(注: 現時点ではプレリリース版です)*
|
|
34
|
+
|
|
35
|
+
## クイックスタート
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from validkit import v, validate
|
|
39
|
+
|
|
40
|
+
# スキーマ定義
|
|
41
|
+
SCHEMA = {
|
|
42
|
+
"ユーザー名": v.str().regex(r"^\w{3,15}$"),
|
|
43
|
+
"レベル": v.int().range(1, 100),
|
|
44
|
+
"スキル": v.list(v.oneof(["火", "水", "風"])),
|
|
45
|
+
"設定": {
|
|
46
|
+
"通知": v.bool(),
|
|
47
|
+
"言語": v.oneof(["日本語", "English"])
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# 検証データ
|
|
52
|
+
data = {
|
|
53
|
+
"ユーザー名": "nana_kit",
|
|
54
|
+
"レベル": 50,
|
|
55
|
+
"スキル": ["火", "風"],
|
|
56
|
+
"設定": {
|
|
57
|
+
"通知": True,
|
|
58
|
+
"言語": "日本語"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
validated = validate(data, SCHEMA)
|
|
64
|
+
print("検証成功!")
|
|
65
|
+
except ValidationError as e:
|
|
66
|
+
print(f"エラー: {e.path} - {e.message}")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 便利な高度な機能
|
|
70
|
+
|
|
71
|
+
### 部分更新とデフォルト値のマージ
|
|
72
|
+
```python
|
|
73
|
+
DEFAULT_CONFIG = {"言語": "English", "音量": 50}
|
|
74
|
+
partial_input = {"音量": 80}
|
|
75
|
+
|
|
76
|
+
updated = validate(
|
|
77
|
+
partial_input,
|
|
78
|
+
SCHEMA,
|
|
79
|
+
partial=True, # 不足キーを許可
|
|
80
|
+
base=DEFAULT_CONFIG # デフォルト値とマージ
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### マイグレーション
|
|
85
|
+
```python
|
|
86
|
+
old_data = {"旧キー": "値", "timeout": 30}
|
|
87
|
+
migrated = validate(old_data, SCHEMA, migrate={
|
|
88
|
+
"旧キー": "新キー",
|
|
89
|
+
"timeout": lambda v: f"{v}s" # 値の変換
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## ライセンス
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# ValidKit
|
|
2
|
+
|
|
3
|
+
ValidKit は、辞書ベースで簡潔に定義でき、日本語キーや複雑なネストに対応した Python バリデーションライブラリです。 Pydantic よりも軽量かつ、Discord ボットの設定管理などの複雑なデータ構造を直感的に扱うことができます。
|
|
4
|
+
|
|
5
|
+
## 特徴
|
|
6
|
+
|
|
7
|
+
- 📝 **辞書ベースのスキーマ**: クラス定義不要。データ構造をそのままスキーマとして定義可能
|
|
8
|
+
- 🌏 **日本語キー対応**: 日本語をキーにした設定データもそのまま検証
|
|
9
|
+
- 🔗 **チェイン可能なバリデータ**: `v.int().range(1, 10)` のように直感的に記述
|
|
10
|
+
- 🔄 **マイグレーション機能**: 古い形式のデータを検証と同時に新形式へ自動変換
|
|
11
|
+
- 🛠️ **部分更新・デフォルト値**: 足りないキーの補完や部分的な検証をサポート
|
|
12
|
+
- 🔍 **詳細なエラー収集**: 全エラーを一括で取得し、エラー箇所のパスを明確に表示
|
|
13
|
+
|
|
14
|
+
## インストール
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install validkit
|
|
18
|
+
```
|
|
19
|
+
*(注: 現時点ではプレリリース版です)*
|
|
20
|
+
|
|
21
|
+
## クイックスタート
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from validkit import v, validate
|
|
25
|
+
|
|
26
|
+
# スキーマ定義
|
|
27
|
+
SCHEMA = {
|
|
28
|
+
"ユーザー名": v.str().regex(r"^\w{3,15}$"),
|
|
29
|
+
"レベル": v.int().range(1, 100),
|
|
30
|
+
"スキル": v.list(v.oneof(["火", "水", "風"])),
|
|
31
|
+
"設定": {
|
|
32
|
+
"通知": v.bool(),
|
|
33
|
+
"言語": v.oneof(["日本語", "English"])
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# 検証データ
|
|
38
|
+
data = {
|
|
39
|
+
"ユーザー名": "nana_kit",
|
|
40
|
+
"レベル": 50,
|
|
41
|
+
"スキル": ["火", "風"],
|
|
42
|
+
"設定": {
|
|
43
|
+
"通知": True,
|
|
44
|
+
"言語": "日本語"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
validated = validate(data, SCHEMA)
|
|
50
|
+
print("検証成功!")
|
|
51
|
+
except ValidationError as e:
|
|
52
|
+
print(f"エラー: {e.path} - {e.message}")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 便利な高度な機能
|
|
56
|
+
|
|
57
|
+
### 部分更新とデフォルト値のマージ
|
|
58
|
+
```python
|
|
59
|
+
DEFAULT_CONFIG = {"言語": "English", "音量": 50}
|
|
60
|
+
partial_input = {"音量": 80}
|
|
61
|
+
|
|
62
|
+
updated = validate(
|
|
63
|
+
partial_input,
|
|
64
|
+
SCHEMA,
|
|
65
|
+
partial=True, # 不足キーを許可
|
|
66
|
+
base=DEFAULT_CONFIG # デフォルト値とマージ
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### マイグレーション
|
|
71
|
+
```python
|
|
72
|
+
old_data = {"旧キー": "値", "timeout": 30}
|
|
73
|
+
migrated = validate(old_data, SCHEMA, migrate={
|
|
74
|
+
"旧キー": "新キー",
|
|
75
|
+
"timeout": lambda v: f"{v}s" # 値の変換
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## ライセンス
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "validkit-py"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A simple and powerful validation library for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "disnana" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/disnana/ValidKit"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.version]
|
|
27
|
+
path = "src/validkit/__init__.py"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = ["/src", "/README.md", "/LICENSE*"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/validkit"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import builtins
|
|
3
|
+
from typing import Any, Callable, Dict, List, Union, Type, Optional, cast
|
|
4
|
+
|
|
5
|
+
class Validator:
|
|
6
|
+
def __init__(self) -> None:
|
|
7
|
+
self._optional = False
|
|
8
|
+
self._custom_checks: List[Callable[[Any], Any]] = []
|
|
9
|
+
self._when_condition: Optional[Callable[[Dict[str, Any]], bool]] = None
|
|
10
|
+
|
|
11
|
+
def optional(self) -> "Validator":
|
|
12
|
+
self._optional = True
|
|
13
|
+
return self
|
|
14
|
+
|
|
15
|
+
def custom(self, func: Callable[[Any], Any]) -> "Validator":
|
|
16
|
+
self._custom_checks.append(func)
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def when(self, condition: Callable[[Dict[str, Any]], bool]) -> "Validator":
|
|
20
|
+
self._when_condition = condition
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def validate(self, value: Any, data: Optional[Dict[str, Any]] = None) -> Any:
|
|
24
|
+
"""Base validate method. Subclasses should override this."""
|
|
25
|
+
return self._validate_base(value, data)
|
|
26
|
+
|
|
27
|
+
def _validate_base(self, value: Any, data: Optional[Dict[str, Any]] = None) -> Any:
|
|
28
|
+
# if condition is not met, this validator might be skipped or handled by caller
|
|
29
|
+
for check in self._custom_checks:
|
|
30
|
+
value = check(value)
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
class StringValidator(Validator):
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._regex: Optional[re.Pattern[str]] = None
|
|
37
|
+
|
|
38
|
+
def regex(self, pattern: str) -> "StringValidator":
|
|
39
|
+
self._regex = re.compile(pattern)
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def validate(self, value: Any, data: Optional[Dict[str, Any]] = None) -> str:
|
|
43
|
+
if not isinstance(value, str):
|
|
44
|
+
raise TypeError(f"Expected str, got {type(value).__name__}")
|
|
45
|
+
if self._regex and not self._regex.match(value):
|
|
46
|
+
raise ValueError(f"Value '{value}' does not match regex '{self._regex.pattern}'")
|
|
47
|
+
return cast(str, self._validate_base(value, data))
|
|
48
|
+
|
|
49
|
+
class NumberValidator(Validator):
|
|
50
|
+
def __init__(self, type_cls: Union[Type[int], Type[float]]) -> None:
|
|
51
|
+
super().__init__()
|
|
52
|
+
self._type_cls = type_cls
|
|
53
|
+
self._min: Optional[float] = None
|
|
54
|
+
self._max: Optional[float] = None
|
|
55
|
+
|
|
56
|
+
def range(self, min_val: float, max_val: float) -> "NumberValidator":
|
|
57
|
+
self._min = min_val
|
|
58
|
+
self._max = max_val
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def min(self, min_val: float) -> "NumberValidator":
|
|
62
|
+
self._min = min_val
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def max(self, max_val: float) -> "NumberValidator":
|
|
66
|
+
self._max = max_val
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def validate(self, value: Any, data: Optional[Dict[str, Any]] = None) -> Union[int, float]:
|
|
70
|
+
if not isinstance(value, self._type_cls):
|
|
71
|
+
raise TypeError(f"Expected {self._type_cls.__name__}, got {type(value).__name__}")
|
|
72
|
+
if self._min is not None and value < self._min:
|
|
73
|
+
raise ValueError(f"Value {value} is less than minimum {self._min}")
|
|
74
|
+
if self._max is not None and value > self._max:
|
|
75
|
+
raise ValueError(f"Value {value} is greater than maximum {self._max}")
|
|
76
|
+
return cast(Union[int, float], self._validate_base(value, data))
|
|
77
|
+
|
|
78
|
+
class BoolValidator(Validator):
|
|
79
|
+
def validate(self, value: Any, data: Optional[Dict[str, Any]] = None) -> bool:
|
|
80
|
+
if not isinstance(value, bool):
|
|
81
|
+
raise TypeError(f"Expected bool, got {type(value).__name__}")
|
|
82
|
+
return cast(bool, self._validate_base(value, data))
|
|
83
|
+
|
|
84
|
+
class ListValidator(Validator):
|
|
85
|
+
def __init__(self, item_validator: Union[Validator, Dict[builtins.str, Any], Type[Any]]) -> None:
|
|
86
|
+
super().__init__()
|
|
87
|
+
self._item_validator = item_validator
|
|
88
|
+
|
|
89
|
+
def validate(self, value: Any, data: Optional[Dict[str, Any]] = None) -> List[Any]:
|
|
90
|
+
if not isinstance(value, (list, tuple)):
|
|
91
|
+
raise TypeError(f"Expected list, got {type(value).__name__}")
|
|
92
|
+
from .validator import validate_internal
|
|
93
|
+
result = []
|
|
94
|
+
root_data = data if data is not None else {}
|
|
95
|
+
for i, item in enumerate(value):
|
|
96
|
+
try:
|
|
97
|
+
result.append(validate_internal(item, self._item_validator, root_data, path_prefix=f"[{i}]"))
|
|
98
|
+
except Exception:
|
|
99
|
+
# Re-raise or collect will be handled by validate_internal/caller
|
|
100
|
+
raise
|
|
101
|
+
return cast(List[Any], self._validate_base(result, data))
|
|
102
|
+
|
|
103
|
+
class DictValidator(Validator):
|
|
104
|
+
def __init__(self, key_type: Type[Any], value_validator: Union[Validator, Dict[str, Any], Type[Any]]) -> None:
|
|
105
|
+
super().__init__()
|
|
106
|
+
self._key_type = key_type
|
|
107
|
+
self._value_validator = value_validator
|
|
108
|
+
|
|
109
|
+
def validate(self, value: Any, data: Optional[Dict[str, Any]] = None) -> Dict[Any, Any]:
|
|
110
|
+
if not isinstance(value, dict):
|
|
111
|
+
raise TypeError(f"Expected dict, got {type(value).__name__}")
|
|
112
|
+
from .validator import validate_internal
|
|
113
|
+
result = {}
|
|
114
|
+
root_data = data if data is not None else {}
|
|
115
|
+
for k, v in value.items():
|
|
116
|
+
if not isinstance(k, self._key_type):
|
|
117
|
+
raise TypeError(f"Expected key type {self._key_type.__name__}, got {type(k).__name__}")
|
|
118
|
+
try:
|
|
119
|
+
result[k] = validate_internal(v, self._value_validator, root_data, path_prefix=f"{k}")
|
|
120
|
+
except Exception:
|
|
121
|
+
raise
|
|
122
|
+
return cast(Dict[Any, Any], self._validate_base(result, data))
|
|
123
|
+
|
|
124
|
+
class OneOfValidator(Validator):
|
|
125
|
+
def __init__(self, choices: List[Any]) -> None:
|
|
126
|
+
super().__init__()
|
|
127
|
+
self._choices = choices
|
|
128
|
+
|
|
129
|
+
def validate(self, value: Any, data: Optional[Dict[str, Any]] = None) -> Any:
|
|
130
|
+
if value not in self._choices:
|
|
131
|
+
raise ValueError(f"Value '{value}' is not one of {self._choices}")
|
|
132
|
+
return self._validate_base(value, data)
|
|
133
|
+
|
|
134
|
+
class VBuilder:
|
|
135
|
+
def str(self) -> StringValidator:
|
|
136
|
+
return StringValidator()
|
|
137
|
+
|
|
138
|
+
def int(self) -> NumberValidator:
|
|
139
|
+
return NumberValidator(int)
|
|
140
|
+
|
|
141
|
+
def float(self) -> NumberValidator:
|
|
142
|
+
return NumberValidator(float)
|
|
143
|
+
|
|
144
|
+
def bool(self) -> BoolValidator:
|
|
145
|
+
return BoolValidator()
|
|
146
|
+
|
|
147
|
+
def list(self, item_validator: Union[Validator, Dict[builtins.str, Any], Type[Any]]) -> ListValidator:
|
|
148
|
+
return ListValidator(item_validator)
|
|
149
|
+
|
|
150
|
+
def dict(self, key_type: Type[Any], value_validator: Union[Validator, Dict[builtins.str, Any], Type[Any]]) -> DictValidator:
|
|
151
|
+
return DictValidator(key_type, value_validator)
|
|
152
|
+
|
|
153
|
+
def oneof(self, choices: List[Any]) -> OneOfValidator:
|
|
154
|
+
return OneOfValidator(choices)
|
|
155
|
+
|
|
156
|
+
v = VBuilder()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Union, Optional
|
|
2
|
+
from .v import Validator, v
|
|
3
|
+
|
|
4
|
+
class ValidationError(Exception):
|
|
5
|
+
def __init__(self, message: str, path: str = "", value: Any = None) -> None:
|
|
6
|
+
self.message = message
|
|
7
|
+
self.path = path
|
|
8
|
+
self.value = value
|
|
9
|
+
super().__init__(f"{path}: {message}" if path else message)
|
|
10
|
+
|
|
11
|
+
class ErrorDetail:
|
|
12
|
+
def __init__(self, path: str, message: str, value: Any) -> None:
|
|
13
|
+
self.path = path
|
|
14
|
+
self.message = message
|
|
15
|
+
self.value = value
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return f"{self.path}: {self.message} (value: {self.value})"
|
|
19
|
+
|
|
20
|
+
class ValidationResult:
|
|
21
|
+
def __init__(self, data: Any, errors: Optional[List[ErrorDetail]] = None) -> None:
|
|
22
|
+
self.data = data
|
|
23
|
+
self.errors = errors or []
|
|
24
|
+
|
|
25
|
+
def validate_internal(
|
|
26
|
+
value: Any,
|
|
27
|
+
schema: Any,
|
|
28
|
+
root_data: Dict[str, Any],
|
|
29
|
+
path_prefix: str = "",
|
|
30
|
+
partial: bool = False,
|
|
31
|
+
base: Any = None,
|
|
32
|
+
collect_errors: bool = False,
|
|
33
|
+
errors: Optional[List[ErrorDetail]] = None
|
|
34
|
+
) -> Any:
|
|
35
|
+
# 1. Shorthand types
|
|
36
|
+
if isinstance(schema, type) and schema in (str, int, float, bool):
|
|
37
|
+
if schema is str:
|
|
38
|
+
schema = v.str()
|
|
39
|
+
elif schema is int:
|
|
40
|
+
schema = v.int()
|
|
41
|
+
elif schema is float:
|
|
42
|
+
schema = v.float()
|
|
43
|
+
elif schema is bool:
|
|
44
|
+
schema = v.bool()
|
|
45
|
+
|
|
46
|
+
# 2. Validator objects
|
|
47
|
+
if isinstance(schema, Validator):
|
|
48
|
+
# Allow None if optional
|
|
49
|
+
if value is None and schema._optional:
|
|
50
|
+
return base if base is not None else None
|
|
51
|
+
|
|
52
|
+
# Check condition if any
|
|
53
|
+
if schema._when_condition and not schema._when_condition(root_data):
|
|
54
|
+
# If condition not met and we have a base value, use it, else return None (or skip)
|
|
55
|
+
return base
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
return schema.validate(value, root_data)
|
|
59
|
+
except (TypeError, ValueError) as e:
|
|
60
|
+
err_msg = str(e)
|
|
61
|
+
if collect_errors and errors is not None:
|
|
62
|
+
errors.append(ErrorDetail(path_prefix, err_msg, value))
|
|
63
|
+
return value
|
|
64
|
+
raise ValidationError(err_msg, path_prefix, value)
|
|
65
|
+
|
|
66
|
+
# 3. Dict schemas
|
|
67
|
+
if isinstance(schema, dict):
|
|
68
|
+
if value is not None and not isinstance(value, dict):
|
|
69
|
+
err_msg = f"Expected dict, got {type(value).__name__}"
|
|
70
|
+
if collect_errors and errors is not None:
|
|
71
|
+
errors.append(ErrorDetail(path_prefix, err_msg, value))
|
|
72
|
+
return value
|
|
73
|
+
raise ValidationError(err_msg, path_prefix, value)
|
|
74
|
+
|
|
75
|
+
result = {}
|
|
76
|
+
input_dict = value if value is not None else {}
|
|
77
|
+
base_dict = base if isinstance(base, dict) else {}
|
|
78
|
+
|
|
79
|
+
# All keys in schema
|
|
80
|
+
if not partial:
|
|
81
|
+
# Check for missing keys
|
|
82
|
+
pass # We'll check individually
|
|
83
|
+
|
|
84
|
+
for key, sub_schema in schema.items():
|
|
85
|
+
current_path = f"{path_prefix}.{key}" if path_prefix else key
|
|
86
|
+
val = input_dict.get(key)
|
|
87
|
+
sub_base = base_dict.get(key)
|
|
88
|
+
|
|
89
|
+
is_optional = False
|
|
90
|
+
if isinstance(sub_schema, Validator) and sub_schema._optional:
|
|
91
|
+
is_optional = True
|
|
92
|
+
|
|
93
|
+
if key not in input_dict:
|
|
94
|
+
if sub_base is not None:
|
|
95
|
+
# Use base value
|
|
96
|
+
result[key] = sub_base
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Check condition for requirement
|
|
100
|
+
if isinstance(sub_schema, Validator) and sub_schema._when_condition:
|
|
101
|
+
if not sub_schema._when_condition(root_data):
|
|
102
|
+
# Condition not met, not required
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if is_optional or partial:
|
|
106
|
+
# Skip or keep as None
|
|
107
|
+
if is_optional and sub_base is not None:
|
|
108
|
+
result[key] = sub_base
|
|
109
|
+
continue
|
|
110
|
+
else:
|
|
111
|
+
err_msg = "Missing required key"
|
|
112
|
+
if collect_errors and errors is not None:
|
|
113
|
+
errors.append(ErrorDetail(current_path, err_msg, None))
|
|
114
|
+
continue
|
|
115
|
+
raise ValidationError(err_msg, current_path, None)
|
|
116
|
+
|
|
117
|
+
# Key exists in input
|
|
118
|
+
try:
|
|
119
|
+
result[key] = validate_internal(
|
|
120
|
+
val, sub_schema, root_data, current_path,
|
|
121
|
+
partial, sub_base, collect_errors, errors
|
|
122
|
+
)
|
|
123
|
+
except ValidationError:
|
|
124
|
+
if collect_errors:
|
|
125
|
+
continue
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
# Check for unknown keys? (Optional feature, not requested but good for strictness)
|
|
129
|
+
# For now, we only process keys in schema.
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
# 4. Literal / Pre-validated?
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
def validate(
|
|
136
|
+
data: Any,
|
|
137
|
+
schema: Any,
|
|
138
|
+
partial: bool = False,
|
|
139
|
+
base: Any = None,
|
|
140
|
+
migrate: Optional[Dict[str, Any]] = None,
|
|
141
|
+
collect_errors: bool = False
|
|
142
|
+
) -> Union[Any, ValidationResult]:
|
|
143
|
+
|
|
144
|
+
# Apply migration if any
|
|
145
|
+
if migrate and isinstance(data, dict):
|
|
146
|
+
data = data.copy()
|
|
147
|
+
for old_key, action in migrate.items():
|
|
148
|
+
if old_key in data:
|
|
149
|
+
val = data.pop(old_key)
|
|
150
|
+
if isinstance(action, str):
|
|
151
|
+
data[action] = val
|
|
152
|
+
elif callable(action):
|
|
153
|
+
# We need to decide where the result goes.
|
|
154
|
+
# If migrate says {"timeout": lambda v: f"{v}s"}, it modifies the value but keeps the key?
|
|
155
|
+
# Actual request: "timeout": lambda v: f"{v}s" -> value transformation.
|
|
156
|
+
# "旧キー名": "新キー名" -> key rename.
|
|
157
|
+
# Let's assume if it's a string, it's a rename. If it's a callable, it's a value transform of the SAME key (or should it be rename + transform?)
|
|
158
|
+
# Usually migration means "old format to new format".
|
|
159
|
+
# Let's support both.
|
|
160
|
+
data[old_key] = action(val)
|
|
161
|
+
# Note: if it's a rename, we might want to transform too.
|
|
162
|
+
# But the prompt example shows them separately.
|
|
163
|
+
|
|
164
|
+
errors: List[ErrorDetail] = []
|
|
165
|
+
try:
|
|
166
|
+
validated_data = validate_internal(
|
|
167
|
+
data, schema, root_data=data,
|
|
168
|
+
partial=partial, base=base,
|
|
169
|
+
collect_errors=collect_errors, errors=errors
|
|
170
|
+
)
|
|
171
|
+
except ValidationError:
|
|
172
|
+
if not collect_errors:
|
|
173
|
+
raise
|
|
174
|
+
validated_data = data # fallback
|
|
175
|
+
|
|
176
|
+
if collect_errors:
|
|
177
|
+
return ValidationResult(validated_data, errors)
|
|
178
|
+
return validated_data
|