From e206ad4f2c2a59bbffc76698e422be58f4b5224c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:03:02 +0000 Subject: [PATCH 01/16] Initial plan From cee64580e22f0544e794cae3818e7f06b7c54c6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:10:03 +0000 Subject: [PATCH 02/16] Add class-based schema notation support with InstanceValidator Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- src/validkit/__init__.py | 4 +- src/validkit/v.py | 35 ++++ src/validkit/validator.py | 110 +++++++++++- tests/test_class_schema.py | 359 +++++++++++++++++++++++++++++++++++++ 4 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 tests/test_class_schema.py diff --git a/src/validkit/__init__.py b/src/validkit/__init__.py index c8288bb..6085a0d 100644 --- a/src/validkit/__init__.py +++ b/src/validkit/__init__.py @@ -1,5 +1,5 @@ -from .v import v +from .v import v, InstanceValidator from .validator import validate, ValidationError, Schema, ValidationResult __version__ = "1.2.0" -__all__ = ["v", "validate", "ValidationError", "Schema", "ValidationResult"] +__all__ = ["v", "validate", "ValidationError", "Schema", "ValidationResult", "InstanceValidator"] diff --git a/src/validkit/v.py b/src/validkit/v.py index 59a6963..69b2fe4 100644 --- a/src/validkit/v.py +++ b/src/validkit/v.py @@ -231,6 +231,27 @@ def validate(self, value: Any, data: Optional[Dict[str, Any]] = None, path_prefi raise ValueError(f"Value '{value}' is not one of {self._choices}") return self._validate_base(value, data) +class InstanceValidator(Validator): + """カスタム型(任意のクラス)の isinstance チェックを行うバリデータ。 + + Usage:: + + import pytz + schema = {"tz": v.instance(pytz.BaseTzInfo)} + validate({"tz": pytz.utc}, schema) + """ + + def __init__(self, type_cls: Type[Any]) -> None: + super().__init__() + self._instance_type = type_cls + + def validate(self, value: Any, data: Optional[Dict[str, Any]] = None, path_prefix: str = "", collect_errors: bool = False, errors: Optional[List[Any]] = None) -> Any: + if not isinstance(value, self._instance_type): + raise TypeError( + f"Expected instance of {self._instance_type.__name__}, got {type(value).__name__}" + ) + return self._validate_base(value, data) + class VBuilder: def str(self) -> StringValidator: return StringValidator() @@ -253,4 +274,18 @@ def dict(self, key_type: Type[Any], value_validator: Union[Validator, Dict[built def oneof(self, choices: List[Any]) -> OneOfValidator: return OneOfValidator(choices) + def instance(self, type_cls: Type[Any]) -> InstanceValidator: + """カスタム型(任意のクラス)の isinstance チェックを行うバリデータを返します。 + + Args: + type_cls: バリデーション対象の型。 + + Example:: + + import datetime + schema = {"ts": v.instance(datetime.datetime)} + validate({"ts": datetime.datetime.now()}, schema) + """ + return InstanceValidator(type_cls) + v = VBuilder() diff --git a/src/validkit/validator.py b/src/validkit/validator.py index a5c4b65..e0dd8d9 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -11,10 +11,36 @@ Literal, cast, ) -from .v import Validator, v +from .v import Validator, v, InstanceValidator, StringValidator, NumberValidator, BoolValidator T = TypeVar("T") +# Basic Python types supported as schema shorthand (str, int, float, bool) +_BASIC_TYPES = (str, int, float, bool) + + +def _is_class_schema(schema: Any) -> bool: + """Return True if *schema* is a class that should be treated as a class-based schema. + + A class qualifies when it: + - is a plain class (not one of the basic shorthand types), + - is not a Validator subclass, and + - either declares ``__annotations__`` or has at least one Validator class attribute. + """ + if not isinstance(schema, type): + return False + if schema in _BASIC_TYPES: + return False + if issubclass(schema, Validator): + return False + if hasattr(schema, "__annotations__"): + return True + return any( + isinstance(vars(schema).get(k), Validator) + for k in vars(schema) + if not k.startswith("_") + ) + class Schema(Generic[T]): """ @@ -91,6 +117,74 @@ def __init__(self, data: Any, errors: Optional[List[ErrorDetail]] = None) -> Non self.data = data self.errors = errors or [] +def _class_to_schema(cls: type) -> Dict[str, Any]: + """クラスのアノテーションとクラス属性からスキーマ辞書を生成します。 + + 優先順位: + 1. クラス属性が Validator インスタンスの場合、そのまま使用する。 + 2. 型アノテーションが str/int/float/bool の場合、ショートハンドとして使用する。 + 3. それ以外のクラス型の場合、isinstance チェックを行う InstanceValidator を生成する。 + + クラス属性として Validator 以外のデフォルト値が定義されている場合、 + 対応する Validator に .default() 相当の設定を自動付与します。 + + Args: + cls: ``__annotations__`` を持つ任意のクラス。 + + Returns: + validkit のスキーマ辞書。 + """ + schema: Dict[str, Any] = {} + + # 1. Collect fields defined as Validator class attributes (with or without annotation) + for key in vars(cls): + if key.startswith("_"): + continue + attr = vars(cls)[key] + if isinstance(attr, Validator): + schema[key] = attr + + # 2. Process type annotations + annotations: Dict[str, Any] = getattr(cls, "__annotations__", {}) + for key, type_hint in annotations.items(): + if key in schema: + # Already have a Validator class attribute for this field — skip + continue + + if type_hint in _BASIC_TYPES: + # Shorthand type; check for a non-Validator class attribute as default + if key in vars(cls) and not isinstance(vars(cls)[key], Validator): + default_val = vars(cls)[key] + # Promote shorthand to a full Validator with a default + if type_hint is str: + val: Validator = StringValidator() + elif type_hint is float: + val = NumberValidator(float) + elif type_hint is bool: + val = BoolValidator() + else: + val = NumberValidator(int) + val._has_default = True + val._default_value = default_val + val._optional = True + schema[key] = val + else: + schema[key] = type_hint # handled as shorthand in validate_internal + elif isinstance(type_hint, type): + # Custom class type → isinstance check + inst_val = InstanceValidator(type_hint) + if key in vars(cls) and not isinstance(vars(cls)[key], Validator): + inst_val._has_default = True + inst_val._default_value = vars(cls)[key] + inst_val._optional = True + schema[key] = inst_val + else: + # Fallback: pass through (e.g. typing generics) + schema[key] = type_hint + + return schema + + def validate_internal( value: Any, schema: Any, @@ -112,6 +206,10 @@ def validate_internal( elif schema is bool: schema = v.bool() + # 1b. Class-based schema (class with __annotations__ or Validator class attributes) + if _is_class_schema(schema): + schema = _class_to_schema(schema) + # 2. Validator objects if isinstance(schema, Validator): # Allow None if optional @@ -223,7 +321,7 @@ def _generate_sample(schema: Any) -> Any: Schema.generate_sample() の内部実装です。 Args: - schema: dict スキーマ、Validator インスタンス、または Python 型。 + schema: dict スキーマ、Validator インスタンス、Python 型、またはクラス。 Returns: 生成されたサンプル値。 @@ -232,6 +330,10 @@ def _generate_sample(schema: Any) -> Any: if isinstance(schema, type) and schema in _TYPE_DUMMY: return _TYPE_DUMMY[schema] + # 1b. クラスベーススキーマ → dict スキーマに変換してから再帰処理 + if _is_class_schema(schema): + return _generate_sample(_class_to_schema(schema)) + # 2. Validator オブジェクト if isinstance(schema, Validator): # 優先順位: default > examples の先頭 > 型ダミー @@ -240,7 +342,7 @@ def _generate_sample(schema: Any) -> Any: if schema._examples: return schema._examples[0] # 型ダミーを型名から推定 - from .v import StringValidator, NumberValidator, BoolValidator, ListValidator, DictValidator, OneOfValidator + from .v import StringValidator, NumberValidator, BoolValidator, ListValidator, DictValidator, OneOfValidator, InstanceValidator if isinstance(schema, StringValidator): return "example" if isinstance(schema, NumberValidator): @@ -255,6 +357,8 @@ def _generate_sample(schema: Any) -> Any: if isinstance(schema, DictValidator): inner = _generate_sample(schema._value_validator) return {"key": inner} + if isinstance(schema, InstanceValidator): + return None return None # 3. dict スキーマ → 再帰的に走査 diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py new file mode 100644 index 0000000..968647e --- /dev/null +++ b/tests/test_class_schema.py @@ -0,0 +1,359 @@ +""" +tests/test_class_schema.py +クラス記法によるスキーマ定義のテスト: + - 型アノテーション (str / int / float / bool) からのスキーマ生成 + - カスタム型 (original class) の isinstance バリデーション + - クラス属性をデフォルト値として使用 + - Validator インスタンスをクラス属性として使用 + - v.instance() ビルダー + - partial / base / collect_errors との組み合わせ + - Schema.generate_sample() との組み合わせ +""" + +import datetime +import sys +import os +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from validkit import v, validate, ValidationError, Schema, ValidationResult, InstanceValidator + + +# --------------------------------------------------------------------------- +# カスタム型の準備 +# --------------------------------------------------------------------------- + +class Timezone: + """テスト用オリジナルタイムゾーン型""" + def __init__(self, name: str) -> None: + self.name = name + + def __repr__(self) -> str: + return f"Timezone({self.name!r})" + + +UTC = Timezone("UTC") +JST = Timezone("Asia/Tokyo") + + +# --------------------------------------------------------------------------- +# 基本的なクラス記法のテスト +# --------------------------------------------------------------------------- + +class TestClassSchemaBasic: + def test_basic_annotations_pass(self): + """str / int / float / bool のアノテーションで辞書データを検証できる""" + class Profile: + name: str + age: int + score: float + active: bool + + data = {"name": "Alice", "age": 30, "score": 9.5, "active": True} + result = validate(data, Profile) + assert result == data + + def test_missing_required_field_raises(self): + """必須フィールドが欠けている場合は ValidationError を送出する""" + class Profile: + name: str + age: int + + with pytest.raises(ValidationError) as exc_info: + validate({"name": "Alice"}, Profile) + assert exc_info.value.path == "age" + + def test_wrong_type_raises(self): + """型が一致しない場合は ValidationError を送出する""" + class Profile: + name: str + age: int + + with pytest.raises(ValidationError): + validate({"name": 123, "age": 30}, Profile) + + def test_extra_keys_are_ignored(self): + """スキーマに定義されていない余分なキーは無視される""" + class Profile: + name: str + + result = validate({"name": "Alice", "extra": 99}, Profile) + assert result == {"name": "Alice"} + + def test_empty_class_schema(self): + """アノテーションなしのクラスは空のスキーマとして扱われる""" + class Empty: + pass + + result = validate({}, Empty) + assert result == {} + + +# --------------------------------------------------------------------------- +# カスタム型 (original) のテスト +# --------------------------------------------------------------------------- + +class TestClassSchemaCustomType: + def test_custom_type_instance_passes(self): + """アノテーションにカスタム型を使い、正しいインスタンスで検証が通る""" + class Config: + name: str + timezone: Timezone + + result = validate({"name": "server1", "timezone": UTC}, Config) + assert result["name"] == "server1" + assert result["timezone"] is UTC + + def test_custom_type_wrong_instance_raises(self): + """カスタム型のアノテーションで、型が違う値を渡すとエラーになる""" + class Config: + name: str + timezone: Timezone + + with pytest.raises(ValidationError) as exc_info: + validate({"name": "server1", "timezone": "UTC"}, Config) + assert exc_info.value.path == "timezone" + + def test_stdlib_custom_type(self): + """datetime.datetime のような標準ライブラリのカスタム型もサポートする""" + class Event: + title: str + timestamp: datetime.datetime + + now = datetime.datetime.now() + result = validate({"title": "Launch", "timestamp": now}, Event) + assert result["timestamp"] == now + + def test_stdlib_custom_type_wrong_value_raises(self): + """datetime.datetime 型に str を渡すとエラーになる""" + class Event: + title: str + timestamp: datetime.datetime + + with pytest.raises(ValidationError): + validate({"title": "Launch", "timestamp": "2026-01-01"}, Event) + + +# --------------------------------------------------------------------------- +# デフォルト値のテスト +# --------------------------------------------------------------------------- + +class TestClassSchemaDefaults: + def test_class_attribute_as_default(self): + """クラス属性のデフォルト値が欠損キーの補完に使われる""" + class Config: + host: str = "localhost" + port: int = 5432 + + result = validate({}, Config) + assert result["host"] == "localhost" + assert result["port"] == 5432 + + def test_input_overrides_class_default(self): + """入力値はクラス属性のデフォルト値より優先される""" + class Config: + host: str = "localhost" + port: int = 5432 + + result = validate({"host": "db.example.com", "port": 3306}, Config) + assert result["host"] == "db.example.com" + assert result["port"] == 3306 + + def test_falsy_defaults(self): + """False / 0 などの falsy なデフォルト値も正しく機能する""" + class Settings: + ssl: bool = False + timeout: int = 0 + ratio: float = 0.0 + + result = validate({}, Settings) + assert result["ssl"] is False + assert result["timeout"] == 0 + assert result["ratio"] == 0.0 + + def test_custom_type_with_default(self): + """カスタム型アノテーションのフィールドにもデフォルト値が機能する""" + class Config: + timezone: Timezone = UTC + + result = validate({}, Config) + assert result["timezone"] is UTC + + def test_required_field_without_default_still_raises(self): + """デフォルトなし必須フィールドが欠損すれば従来どおりエラー""" + class Config: + host: str = "localhost" + port: int # デフォルトなし必須 + + with pytest.raises(ValidationError) as exc_info: + validate({"host": "example.com"}, Config) + assert exc_info.value.path == "port" + + +# --------------------------------------------------------------------------- +# Validator クラス属性のテスト +# --------------------------------------------------------------------------- + +class TestClassSchemaValidatorAttributes: + def test_validator_as_class_attribute(self): + """Validator インスタンスをクラス属性として定義したフィールドが正しく動作する""" + class Profile: + name = v.str().regex(r"^\w{3,10}$") + age = v.int().range(0, 150) + + result = validate({"name": "alice", "age": 25}, Profile) + assert result == {"name": "alice", "age": 25} + + def test_validator_attribute_with_annotation(self): + """アノテーション付きでも Validator クラス属性が優先される""" + class Profile: + name: str = v.str().regex(r"^\w+$") # type: ignore[assignment] + + result = validate({"name": "alice"}, Profile) + assert result["name"] == "alice" + + def test_validator_class_attribute_invalid_value(self): + """Validator クラス属性のルールに違反するとエラーになる""" + class Profile: + name = v.str().regex(r"^\w{3,10}$") + age = v.int().range(0, 150) + + with pytest.raises(ValidationError): + validate({"name": "x", "age": 25}, Profile) # name が短すぎる + + def test_validator_with_default_via_class_attribute(self): + """Validator クラス属性に .default() を付けるとデフォルト補完が効く""" + class Config: + role = v.str().default("worker") + active = v.bool().default(True) + + result = validate({}, Config) + assert result["role"] == "worker" + assert result["active"] is True + + def test_mixed_annotations_and_validator_attributes(self): + """アノテーションとValidatorクラス属性の混在が正しく動作する""" + class Profile: + name: str # アノテーション型ショートハンド + age = v.int().min(0) # Validator クラス属性 + + result = validate({"name": "Bob", "age": 20}, Profile) + assert result == {"name": "Bob", "age": 20} + + +# --------------------------------------------------------------------------- +# v.instance() ビルダーのテスト +# --------------------------------------------------------------------------- + +class TestInstanceValidator: + def test_instance_validator_pass(self): + """v.instance() で正しい型のインスタンスが通る""" + schema = {"tz": v.instance(Timezone)} + result = validate({"tz": UTC}, schema) + assert result["tz"] is UTC + + def test_instance_validator_fail(self): + """v.instance() で型が違う値を渡すとエラーになる""" + schema = {"tz": v.instance(Timezone)} + with pytest.raises(ValidationError) as exc_info: + validate({"tz": "UTC"}, schema) + assert exc_info.value.path == "tz" + assert "Expected instance of Timezone" in exc_info.value.message + + def test_instance_validator_optional(self): + """v.instance().optional() で省略可能なカスタム型フィールドが機能する""" + schema = {"tz": v.instance(Timezone).optional()} + result = validate({}, schema) + assert "tz" not in result + + def test_instance_validator_with_default(self): + """v.instance().default() でデフォルト値補完が機能する""" + schema = {"tz": v.instance(Timezone).default(UTC)} + result = validate({}, schema) + assert result["tz"] is UTC + + def test_instance_validator_stdlib_type(self): + """v.instance() で標準ライブラリの型もサポートする""" + schema = {"ts": v.instance(datetime.datetime)} + now = datetime.datetime.now() + result = validate({"ts": now}, schema) + assert result["ts"] == now + + +# --------------------------------------------------------------------------- +# partial / base / collect_errors との組み合わせ +# --------------------------------------------------------------------------- + +class TestClassSchemaOptions: + def test_partial_validation(self): + """partial=True でクラス記法スキーマの一部フィールドだけ検証できる""" + class Config: + host: str + port: int + + result = validate({"host": "localhost"}, Config, partial=True) + assert result == {"host": "localhost"} + + def test_base_merge(self): + """base= でクラス記法スキーマに既定値をマージできる""" + class Config: + host: str + port: int + + result = validate({"host": "db.local"}, Config, base={"port": 5432}) + assert result == {"host": "db.local", "port": 5432} + + def test_collect_errors(self): + """collect_errors=True でクラス記法スキーマの複数エラーを一括収集できる""" + class Profile: + name: str + age: int + + result = validate({"name": 123, "age": "thirty"}, Profile, collect_errors=True) + assert isinstance(result, ValidationResult) + assert len(result.errors) == 2 + + def test_class_schema_with_schema_wrapper(self): + """Schema() ラッパーはクラス記法スキーマを直接受け取れる(dict変換後に渡す)""" + class Profile: + name: str + age: int + + from validkit.validator import _class_to_schema + schema = Schema(_class_to_schema(Profile)) + result = validate({"name": "Alice", "age": 30}, schema) + assert result == {"name": "Alice", "age": 30} + + +# --------------------------------------------------------------------------- +# generate_sample との組み合わせ +# --------------------------------------------------------------------------- + +class TestClassSchemaGenerateSample: + def test_generate_sample_from_class_schema(self): + """_class_to_schema 経由で Schema.generate_sample() が動作する""" + from validkit.validator import _class_to_schema + + class Config: + host: str = "localhost" + port: int = 5432 + ssl: bool = False + + schema = Schema(_class_to_schema(Config)) + sample = schema.generate_sample() + assert sample["host"] == "localhost" + assert sample["port"] == 5432 + assert sample["ssl"] is False + + def test_instance_validator_generates_none_sample(self): + """InstanceValidator のサンプル値は None (型ダミーなし) になる""" + schema = Schema({"tz": v.instance(Timezone)}) + sample = schema.generate_sample() + assert sample["tz"] is None + + def test_instance_validator_with_default_generates_sample(self): + """InstanceValidator に .default() があればサンプルにデフォルト値が使われる""" + schema = Schema({"tz": v.instance(Timezone).default(UTC)}) + sample = schema.generate_sample() + assert sample["tz"] is UTC From edb27445df1297d989eb1e202230e8f0c196f0ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:20:55 +0000 Subject: [PATCH 03/16] Extend class schema: Optional/List/Dict typing support, docs, bug fix Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- README.md | 117 +++++++++++++++++++++++ src/validkit/validator.py | 158 +++++++++++++++++++++++------- tests/test_class_schema.py | 191 +++++++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e3b7567..53074f0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ ValidKit は、「直感的なスキーマ定義」と「日本語キー * [クイックスタート](#クイックスタート) * [API 例](#api-例) * [高度な使い方](#高度な使い方) + * [クラス記法によるスキーマ定義](#クラス記法によるスキーマ定義) * [IDE 補完を効かせる(TypedDict + Schema)](#ide-補完を効かせるtypeddict--schema) * [部分更新とデフォルト値のマージ](#部分更新とデフォルト値のマージ) * [マイグレーション](#マイグレーション) @@ -84,6 +85,7 @@ except ValidationError as e: ## 特徴 * 📝 **直感的なチェインメソッド** — `v.int().range(1, 10).optional()` のように流れるように記述。 +* 🏷️ **クラス記法対応** — Python クラスの型アノテーションをそのままスキーマとして使えます。`Optional[T]`・`List[T]`・`Dict[K,V]`・カスタム型にも対応。 * 🌏 **日本語キー対応** — 日本語のキー名をそのまま扱えるため、仕様書に近いコードが書けます。 * 🔄 **強力な変換・マイグレーション** — 旧形式から新形式へのキー名変換や、値の動的変換を検証時に同時に行えます。 * 🛠️ **デフォルト値とマージ** — 不足している値をベース設定(デフォルト値)で自動補完します。 @@ -101,6 +103,7 @@ except ValidationError as e: * `v.bool()`: 真偽値 * `v.list(schema)`: リスト(要素のスキーマを指定) * `v.dict(key_type, value_schema)`: 辞書 +* `v.instance(type_cls)`: 任意のクラスの isinstance チェック ### 修飾メソッド * `.optional()`: 必須でないフィールドにする @@ -116,6 +119,120 @@ except ValidationError as e: ## 高度な使い方 +### クラス記法によるスキーマ定義 + +辞書スキーマに加えて、Python のクラス型アノテーションをそのままスキーマとして使えます。 + +#### 基本的なアノテーション + +```python +from validkit import v, validate + +class UserProfile: + name: str + age: int + score: float + active: bool + +data = {"name": "Alice", "age": 30, "score": 9.5, "active": True} +result = validate(data, UserProfile) +# -> {"name": "Alice", "age": 30, "score": 9.5, "active": True} +``` + +#### `Optional` / `List` / `Dict` など typing モジュールの型 + +```python +from typing import Dict, List, Optional +from validkit import validate + +class ServerConfig: + host: str + port: int + tags: Optional[List[str]] # 省略可能なリスト + metadata: Dict[str, int] # 辞書 + +# tags は省略してもエラーにならない +result = validate( + {"host": "db.local", "port": 5432, "metadata": {"connections": 10}}, + ServerConfig, +) +# -> {"host": "db.local", "port": 5432, "metadata": {"connections": 10}} +``` + +Python 3.9 以降では `list[T]` / `dict[K, V]` の組み込みジェネリクス記法も使えます。 + +```python +class Report: + scores: list[int] + labels: dict[str, str] +``` + +#### カスタム型 (オリジナルクラス) + +任意のクラスをアノテーションに使うと、`isinstance` チェックが自動的に行われます。 + +```python +from validkit import validate + +class Timezone: + def __init__(self, name: str) -> None: + self.name = name + +UTC = Timezone("UTC") + +class Config: + name: str + age: int + timezone: Timezone # カスタム型 → isinstance チェック + +result = validate({"name": "server1", "age": 5, "timezone": UTC}, Config) +``` + +`v.instance()` を使うと辞書スキーマ内でも同じチェックができます。 + +```python +from validkit import v, validate + +schema = { + "name": v.str(), + "timezone": v.instance(Timezone).default(UTC), +} +result = validate({"name": "server1"}, schema) +# timezone は省略時に UTC が補完される +``` + +#### クラス属性によるデフォルト値と Validator の組み合わせ + +クラス属性に具体的な値を設定するとデフォルト値として機能します。 +Validator インスタンスを直接クラス属性にすることで、詳細な制約も記述できます。 + +```python +from typing import Optional +from validkit import v, validate + +class Config: + host: str # 必須 + port: int = 5432 # デフォルト値 5432 + ssl: bool = False # デフォルト値 False + timeout: Optional[int] = 30 # オプション + デフォルト + role = v.str().default("worker") # Validator で詳細に定義 + +result = validate({"host": "db.local"}, Config) +# -> {"host": "db.local", "port": 5432, "ssl": False, "timeout": 30, "role": "worker"} +``` + +#### 対応型ヒント一覧 + +| アノテーション | 動作 | +|---|---| +| `str` / `int` / `float` / `bool` | 型チェック | +| `Optional[T]` / `Union[T, None]` | 内部型をチェック・省略可能 | +| `List[T]` / `list[T]` | リスト要素の型チェック | +| `Dict[K, V]` / `dict[K, V]` | 辞書の値の型チェック | +| 任意のクラス | `isinstance` チェック | +| `Any` / 不明な型 | チェックなし(パススルー) | +| `Validator` インスタンス | ValidKit の完全なバリデーション | + ### IDE 補完を効かせる(TypedDict + Schema) `Schema[T]` クラスを使うと、IDE(PyCharm / VS Code)での型補完が有効になります。 diff --git a/src/validkit/validator.py b/src/validkit/validator.py index e0dd8d9..fab1cef 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -6,12 +6,24 @@ Optional, TypeVar, Union, + get_args, + get_origin, overload, TYPE_CHECKING, Literal, cast, ) -from .v import Validator, v, InstanceValidator, StringValidator, NumberValidator, BoolValidator +from .v import ( + Validator, + v, + InstanceValidator, + StringValidator, + NumberValidator, + BoolValidator, + ListValidator, + DictValidator, + OneOfValidator, +) T = TypeVar("T") @@ -117,16 +129,115 @@ def __init__(self, data: Any, errors: Optional[List[ErrorDetail]] = None) -> Non self.data = data self.errors = errors or [] +def _type_hint_to_validator( + hint: Any, + has_default: bool = False, + default_val: Any = None, +) -> Validator: + """Python 型ヒントを Validator に変換します。 + + 対応する型ヒント: + + * ``str``, ``int``, ``float``, ``bool`` — 各型の基本バリデータ + * ``Optional[T]`` / ``Union[T, None]`` — 内部型のバリデータに ``.optional()`` を付与 + * ``list[T]`` / ``List[T]`` — ``v.list()`` ラッパー (要素型を再帰的に変換) + * ``dict[K, V]`` / ``Dict[K, V]`` — ``v.dict()`` ラッパー (値型を再帰的に変換) + * 任意のクラス — ``InstanceValidator`` による isinstance チェック + * ``Any`` / 不明な型 — すべての値を通過させる基底 Validator + + Args: + hint: 変換元の Python 型ヒント。 + has_default: デフォルト値を付与するかどうか。 + default_val: ``has_default=True`` のときに使用するデフォルト値。 + + Returns: + 対応する Validator インスタンス。 + """ + val: Validator + optional_flag = False + + origin = get_origin(hint) + args = get_args(hint) + + # --- Optional[T] / Union[T, None] --- + if origin is Union: + non_none_args = [a for a in args if a is not type(None)] + if type(None) in args: + optional_flag = True + if len(non_none_args) == 1: + # True Optional[T]: recurse with the single inner type + val = _type_hint_to_validator(non_none_args[0]) + else: + # Union[T1, T2, ...] without None: passthrough (no strict type check) + val = Validator() + + # --- list[T] / List[T] --- + elif origin is list: + item_hint: Any = args[0] if args else Any + # For basic types use the shorthand; for others build a Validator + if isinstance(item_hint, type) and item_hint in _BASIC_TYPES: + item_validator: Any = item_hint + else: + item_validator = _type_hint_to_validator(item_hint) + val = ListValidator(item_validator) + + # --- dict[K, V] / Dict[K, V] --- + elif origin is dict: + key_type: Any = args[0] if args else str + val_hint: Any = args[1] if len(args) > 1 else Any + if isinstance(val_hint, type) and val_hint in _BASIC_TYPES: + val_validator: Any = val_hint + else: + val_validator = _type_hint_to_validator(val_hint) + # Key type must be a concrete type; fall back to str for generics + if not isinstance(key_type, type): + key_type = str + val = DictValidator(key_type, val_validator) + + # --- Basic shorthand types --- + elif hint is str: + val = StringValidator() + elif hint is int: + val = NumberValidator(int) + elif hint is float: + val = NumberValidator(float) + elif hint is bool: + val = BoolValidator() + + # --- Arbitrary concrete class → isinstance check --- + elif isinstance(hint, type): + val = InstanceValidator(hint) + + # --- Any / Unknown (e.g. typing.Any, forward references) → passthrough --- + else: + val = Validator() + + if optional_flag: + val._optional = True + if has_default: + val._has_default = True + val._default_value = default_val + val._optional = True + + return val + + def _class_to_schema(cls: type) -> Dict[str, Any]: """クラスのアノテーションとクラス属性からスキーマ辞書を生成します。 優先順位: 1. クラス属性が Validator インスタンスの場合、そのまま使用する。 - 2. 型アノテーションが str/int/float/bool の場合、ショートハンドとして使用する。 - 3. それ以外のクラス型の場合、isinstance チェックを行う InstanceValidator を生成する。 + 2. 型アノテーションを ``_type_hint_to_validator()`` で Validator に変換する: + + * ``str``, ``int``, ``float``, ``bool`` → 基本バリデータ + * ``Optional[T]`` / ``Union[T, None]`` → 内部型バリデータ + ``.optional()`` + * ``list[T]`` / ``List[T]`` → ``ListValidator`` + * ``dict[K, V]`` / ``Dict[K, V]`` → ``DictValidator`` + * 任意クラス → ``InstanceValidator`` + * ``Any`` / 不明 → パススルー Validator クラス属性として Validator 以外のデフォルト値が定義されている場合、 - 対応する Validator に .default() 相当の設定を自動付与します。 + 生成した Validator に自動的にデフォルト値を付与します。 Args: cls: ``__annotations__`` を持つ任意のクラス。 @@ -151,36 +262,14 @@ def _class_to_schema(cls: type) -> Dict[str, Any]: # Already have a Validator class attribute for this field — skip continue - if type_hint in _BASIC_TYPES: - # Shorthand type; check for a non-Validator class attribute as default - if key in vars(cls) and not isinstance(vars(cls)[key], Validator): - default_val = vars(cls)[key] - # Promote shorthand to a full Validator with a default - if type_hint is str: - val: Validator = StringValidator() - elif type_hint is float: - val = NumberValidator(float) - elif type_hint is bool: - val = BoolValidator() - else: - val = NumberValidator(int) - val._has_default = True - val._default_value = default_val - val._optional = True - schema[key] = val - else: - schema[key] = type_hint # handled as shorthand in validate_internal - elif isinstance(type_hint, type): - # Custom class type → isinstance check - inst_val = InstanceValidator(type_hint) - if key in vars(cls) and not isinstance(vars(cls)[key], Validator): - inst_val._has_default = True - inst_val._default_value = vars(cls)[key] - inst_val._optional = True - schema[key] = inst_val - else: - # Fallback: pass through (e.g. typing generics) - schema[key] = type_hint + # Check for a non-Validator class attribute that acts as default value + has_default = False + default_val: Any = None + if key in vars(cls) and not isinstance(vars(cls)[key], Validator): + has_default = True + default_val = vars(cls)[key] + + schema[key] = _type_hint_to_validator(type_hint, has_default=has_default, default_val=default_val) return schema @@ -342,7 +431,6 @@ def _generate_sample(schema: Any) -> Any: if schema._examples: return schema._examples[0] # 型ダミーを型名から推定 - from .v import StringValidator, NumberValidator, BoolValidator, ListValidator, DictValidator, OneOfValidator, InstanceValidator if isinstance(schema, StringValidator): return "example" if isinstance(schema, NumberValidator): diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index 968647e..876feb0 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -2,6 +2,8 @@ tests/test_class_schema.py クラス記法によるスキーマ定義のテスト: - 型アノテーション (str / int / float / bool) からのスキーマ生成 + - typing モジュールのアノテーション (Optional / List / Dict / Union) + - Python 3.9+ 組み込みジェネリクス (list[T] / dict[K, V]) - カスタム型 (original class) の isinstance バリデーション - クラス属性をデフォルト値として使用 - Validator インスタンスをクラス属性として使用 @@ -11,6 +13,8 @@ """ import datetime +import typing +from typing import Dict, List, Optional import sys import os import pytest @@ -242,6 +246,193 @@ class Profile: assert result == {"name": "Bob", "age": 20} +# --------------------------------------------------------------------------- +# typing モジュールのアノテーション (Optional / List / Dict / Union) +# --------------------------------------------------------------------------- + +class TestClassSchemaTypingAnnotations: + def test_optional_field_can_be_omitted(self): + """Optional[T] アノテーションのフィールドは省略可能""" + class Profile: + name: str + nickname: Optional[str] + + result = validate({"name": "Alice"}, Profile) + assert result["name"] == "Alice" + assert "nickname" not in result + + def test_optional_field_accepts_value(self): + """Optional[T] アノテーションのフィールドは値が渡されれば検証を通る""" + class Profile: + name: str + nickname: Optional[str] + + result = validate({"name": "Alice", "nickname": "Ally"}, Profile) + assert result["nickname"] == "Ally" + + def test_optional_field_rejects_wrong_type(self): + """Optional[T] で型が違う値を渡すとエラーになる""" + class Profile: + name: str + age: Optional[int] + + with pytest.raises(ValidationError): + validate({"name": "Alice", "age": "thirty"}, Profile) + + def test_optional_with_default(self): + """Optional[T] + クラス属性のデフォルト値が正しく機能する""" + class Config: + host: str + timeout: Optional[int] = 30 + + result = validate({"host": "example.com"}, Config) + assert result["timeout"] == 30 + + def test_list_annotation(self): + """List[T] アノテーションで list の各要素が検証される""" + class Report: + scores: List[int] + + result = validate({"scores": [10, 20, 30]}, Report) + assert result["scores"] == [10, 20, 30] + + def test_list_annotation_rejects_wrong_element_type(self): + """List[int] に文字列要素が含まれるとエラーになる""" + class Report: + scores: List[int] + + with pytest.raises(ValidationError): + validate({"scores": [10, "bad", 30]}, Report) + + def test_dict_annotation(self): + """Dict[K, V] アノテーションで辞書の各値が検証される""" + class Metrics: + values: Dict[str, int] + + result = validate({"values": {"a": 1, "b": 2}}, Metrics) + assert result["values"] == {"a": 1, "b": 2} + + def test_dict_annotation_rejects_wrong_value_type(self): + """Dict[str, int] に文字列値が含まれるとエラーになる""" + class Metrics: + values: Dict[str, int] + + with pytest.raises(ValidationError): + validate({"values": {"a": "one"}}, Metrics) + + def test_optional_custom_type(self): + """Optional[CustomType] でカスタム型がオプショナルになる""" + class Config: + name: str + timezone: Optional[Timezone] + + result = validate({"name": "srv"}, Config) + assert "timezone" not in result + + result2 = validate({"name": "srv", "timezone": UTC}, Config) + assert result2["timezone"] is UTC + + def test_list_of_custom_type(self): + """List[CustomType] でカスタム型のリストを検証できる""" + class Config: + zones: List[Timezone] + + result = validate({"zones": [UTC, JST]}, Config) + assert result["zones"] == [UTC, JST] + + def test_list_of_custom_type_rejects_wrong(self): + """List[Timezone] に str が含まれるとエラーになる""" + class Config: + zones: List[Timezone] + + with pytest.raises(ValidationError): + validate({"zones": [UTC, "NOT_A_TZ"]}, Config) + + def test_complex_class_with_all_types(self): + """str / int / Optional / List / Dict / CustomType が混在するクラス""" + class ServerConfig: + host: str + port: int + ssl: bool + tags: Optional[List[str]] + metadata: Dict[str, int] + timezone: Timezone + + data = { + "host": "example.com", + "port": 443, + "ssl": True, + "tags": ["web", "prod"], + "metadata": {"requests": 1000}, + "timezone": UTC, + } + result = validate(data, ServerConfig) + assert result["host"] == "example.com" + assert result["port"] == 443 + assert result["ssl"] is True + assert result["tags"] == ["web", "prod"] + assert result["metadata"] == {"requests": 1000} + assert result["timezone"] is UTC + + def test_complex_class_optional_list_omitted(self): + """Optional[List[str]] フィールドを省略しても通る""" + class ServerConfig: + host: str + port: int + ssl: bool + tags: Optional[List[str]] + metadata: Dict[str, int] + timezone: Timezone + + data = { + "host": "example.com", + "port": 80, + "ssl": False, + "metadata": {}, + "timezone": JST, + } + result = validate(data, ServerConfig) + assert "tags" not in result + + +# --------------------------------------------------------------------------- +# Python 3.9+ 組み込みジェネリクス (list[T] / dict[K, V]) +# --------------------------------------------------------------------------- + +class TestClassSchemaBuiltinGenerics: + def test_builtin_list_annotation(self): + """Python 3.9+ の list[T] アノテーションが機能する""" + class Report: + scores: list[int] + + result = validate({"scores": [1, 2, 3]}, Report) + assert result["scores"] == [1, 2, 3] + + def test_builtin_list_rejects_wrong_element(self): + """list[int] に文字列要素が含まれるとエラーになる""" + class Report: + scores: list[int] + + with pytest.raises(ValidationError): + validate({"scores": [1, "bad"]}, Report) + + def test_builtin_dict_annotation(self): + """Python 3.9+ の dict[K, V] アノテーションが機能する""" + class Metrics: + values: dict[str, float] + + result = validate({"values": {"rate": 0.95}}, Metrics) + assert result["values"]["rate"] == 0.95 + + def test_builtin_dict_rejects_wrong_value(self): + """dict[str, int] に整数でない値が含まれるとエラーになる""" + class Metrics: + values: dict[str, int] + + with pytest.raises(ValidationError): + validate({"values": {"a": "bad"}}, Metrics) + + # --------------------------------------------------------------------------- # v.instance() ビルダーのテスト # --------------------------------------------------------------------------- From 686c53e656975ab7da061ee5caeee0227ca0d7fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:34:26 +0000 Subject: [PATCH 04/16] Address review: implement coerce(), use public API, clean imports, add 26 tests Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- src/validkit/v.py | 19 ++- src/validkit/validator.py | 10 +- tests/test_class_schema.py | 323 ++++++++++++++++++++++++++++++++++++- 3 files changed, 342 insertions(+), 10 deletions(-) diff --git a/src/validkit/v.py b/src/validkit/v.py index 69b2fe4..e3aaaeb 100644 --- a/src/validkit/v.py +++ b/src/validkit/v.py @@ -247,9 +247,22 @@ def __init__(self, type_cls: Type[Any]) -> None: def validate(self, value: Any, data: Optional[Dict[str, Any]] = None, path_prefix: str = "", collect_errors: bool = False, errors: Optional[List[Any]] = None) -> Any: if not isinstance(value, self._instance_type): - raise TypeError( - f"Expected instance of {self._instance_type.__name__}, got {type(value).__name__}" - ) + if self._coerce: + try: + coerced_value = self._instance_type(value) + except Exception: + raise TypeError( + f"Expected instance of {self._instance_type.__name__}, got {type(value).__name__}" + ) + if not isinstance(coerced_value, self._instance_type): + raise TypeError( + f"Expected instance of {self._instance_type.__name__}, got {type(coerced_value).__name__}" + ) + value = coerced_value + else: + raise TypeError( + f"Expected instance of {self._instance_type.__name__}, got {type(value).__name__}" + ) return self._validate_base(value, data) class VBuilder: diff --git a/src/validkit/validator.py b/src/validkit/validator.py index fab1cef..ccd132f 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -212,12 +212,12 @@ def _type_hint_to_validator( else: val = Validator() - if optional_flag: - val._optional = True if has_default: - val._has_default = True - val._default_value = default_val - val._optional = True + # Apply default value via public API; defaulted fields are effectively optional. + val = val.default(default_val) + elif optional_flag: + # Mark field as optional via public API. + val = val.optional() return val diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index 876feb0..96c293e 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -13,7 +13,6 @@ """ import datetime -import typing from typing import Dict, List, Optional import sys import os @@ -21,7 +20,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from validkit import v, validate, ValidationError, Schema, ValidationResult, InstanceValidator +from validkit import v, validate, ValidationError, Schema, ValidationResult # --------------------------------------------------------------------------- @@ -548,3 +547,323 @@ def test_instance_validator_with_default_generates_sample(self): schema = Schema({"tz": v.instance(Timezone).default(UTC)}) sample = schema.generate_sample() assert sample["tz"] is UTC + + +# --------------------------------------------------------------------------- +# Fix 1: InstanceValidator.coerce() 実装の検証 +# --------------------------------------------------------------------------- + +class TestInstanceValidatorCoerce: + """v.instance(T).coerce() が型変換を試みることを検証するテスト群。""" + + def test_coerce_successful_construction(self): + """coerce=True のとき type_cls(value) が成功すれば変換後の値が返る""" + class Celsius: + def __init__(self, value: float) -> None: + self.value = float(value) + def __repr__(self) -> str: + return f"Celsius({self.value})" + + schema = {"temp": v.instance(Celsius).coerce()} + # Pass a raw float → Celsius(float) should succeed + result = validate({"temp": 36.6}, schema) + assert isinstance(result["temp"], Celsius) + assert result["temp"].value == 36.6 + + def test_coerce_from_string(self): + """coerce=True のとき str から型変換できる場合は成功する""" + class Celsius: + def __init__(self, value: float) -> None: + self.value = float(value) + + schema = {"temp": v.instance(Celsius).coerce()} + result = validate({"temp": "37.0"}, schema) + assert isinstance(result["temp"], Celsius) + assert result["temp"].value == 37.0 + + def test_coerce_raises_when_construction_fails(self): + """coerce=True でも type_cls(value) が例外を投げたらバリデーションエラー""" + class StrictType: + def __init__(self, value: object) -> None: + if not isinstance(value, int): + raise ValueError("must be int") + + schema = {"x": v.instance(StrictType).coerce()} + with pytest.raises(ValidationError) as exc_info: + validate({"x": "not an int"}, schema) + assert "Expected instance of StrictType" in exc_info.value.message + + def test_no_coerce_raises_on_wrong_type(self): + """coerce=False (デフォルト) では型違いで即エラー""" + schema = {"tz": v.instance(Timezone)} + with pytest.raises(ValidationError) as exc_info: + validate({"tz": "UTC"}, schema) + assert "Expected instance of Timezone" in exc_info.value.message + + def test_coerce_already_correct_type_is_passthrough(self): + """coerce=True でもすでに正しい型ならそのまま通る""" + schema = {"tz": v.instance(Timezone).coerce()} + result = validate({"tz": UTC}, schema) + assert result["tz"] is UTC + + def test_coerce_with_stdlib_int(self): + """coerce=True で int型に対して文字列数値が変換される (標準ライブラリ型)""" + # Note: v.instance(int).coerce() does int("42") which succeeds + schema = {"count": v.instance(int).coerce()} + result = validate({"count": "42"}, schema) + assert result["count"] == 42 + assert isinstance(result["count"], int) + + def test_coerce_with_optional_and_default(self): + """coerce と optional / default の組み合わせが正しく動作する""" + class Port: + def __init__(self, n: int) -> None: + self.n = int(n) + + schema = {"port": v.instance(Port).coerce().default(Port(80))} + # value provided as raw int → coerced to Port + r1 = validate({"port": 443}, schema) + assert isinstance(r1["port"], Port) + assert r1["port"].n == 443 + # value absent → default (already a Port) is returned + r2 = validate({}, schema) + assert isinstance(r2["port"], Port) + assert r2["port"].n == 80 + + +# --------------------------------------------------------------------------- +# Fix 2: _type_hint_to_validator が公開 API を使うことを検証するテスト群 +# --------------------------------------------------------------------------- + +class TestTypeHintToValidatorPublicAPI: + """_type_hint_to_validator() が .optional() / .default() 公開メソッドを通じて + validator の状態を設定することを、クラス記法経由で end-to-end に検証する。""" + + def test_optional_annotation_sets_optional_via_public_api(self): + """Optional[T] アノテーションのフィールドは省略可能 (response に含まれない)""" + class Config: + host: str + nickname: Optional[str] + + # nickname は省略しても Missing required key が出ない + result = validate({"host": "db"}, Config) + assert result["host"] == "db" + assert "nickname" not in result + + def test_optional_annotation_passes_value_through(self): + """Optional[T] で値を渡したときは検証してから応答に含まれる""" + class Config: + host: str + nickname: Optional[str] + + result = validate({"host": "db", "nickname": "alias"}, Config) + assert result["nickname"] == "alias" + + def test_optional_annotation_rejects_wrong_type(self): + """Optional[int] に str を渡すとバリデーションエラー""" + class Config: + count: Optional[int] + + with pytest.raises(ValidationError): + validate({"count": "bad"}, Config) + + def test_default_annotation_sets_default_via_public_api(self): + """クラス属性デフォルト値がフィールド欠損時に応答に補完される""" + class Config: + host: str = "localhost" + port: int = 5432 + + result = validate({}, Config) + assert result["host"] == "localhost" + assert result["port"] == 5432 + + def test_default_annotation_explicit_value_overrides_default(self): + """入力値があればデフォルトより入力値が優先される""" + class Config: + port: int = 5432 + + result = validate({"port": 9000}, Config) + assert result["port"] == 9000 + + def test_optional_with_default_annotation(self): + """Optional[T] + クラス属性デフォルトの組み合わせ: 省略時はデフォルト、指定時は値""" + class Config: + timeout: Optional[int] = 30 + + r_default = validate({}, Config) + assert r_default["timeout"] == 30 + + r_override = validate({"timeout": 60}, Config) + assert r_override["timeout"] == 60 + + def test_list_annotation_response_contains_all_elements(self): + """List[T] アノテーションで応答がリスト全体を正確に含む""" + class Report: + scores: List[int] + + result = validate({"scores": [10, 20, 30]}, Report) + assert result["scores"] == [10, 20, 30] + + def test_dict_annotation_response_contains_all_entries(self): + """Dict[K, V] アノテーションで応答が辞書全体を正確に含む""" + class Metrics: + values: Dict[str, int] + + result = validate({"values": {"a": 1, "b": 2}}, Metrics) + assert result["values"] == {"a": 1, "b": 2} + + +# --------------------------------------------------------------------------- +# 網羅的な end-to-end レスポンス検証テスト群 +# --------------------------------------------------------------------------- + +class TestEndToEndClassSchemaResponse: + """クラス記法スキーマ全体の応答値を網羅的に検証するテスト群。 + 各フィールドの応答値が正確であることをアサートする。""" + + def test_full_response_with_all_basic_types(self): + """str / int / float / bool の全フィールドが応答に正確に含まれる""" + class Profile: + name: str + age: int + score: float + active: bool + + data = {"name": "Alice", "age": 30, "score": 9.85, "active": True} + result = validate(data, Profile) + assert result == {"name": "Alice", "age": 30, "score": 9.85, "active": True} + + def test_full_response_with_defaults_and_overrides(self): + """デフォルト値と入力値が混在するときの応答全体を検証""" + class Config: + host: str + port: int = 5432 + ssl: bool = False + timeout: Optional[int] = 30 + + result = validate({"host": "prod.example.com", "port": 443}, Config) + assert result == { + "host": "prod.example.com", + "port": 443, + "ssl": False, + "timeout": 30, + } + + def test_full_response_with_nested_list_and_dict(self): + """List と Dict が混在するスキーマの応答全体を検証""" + class Analytics: + hits: List[int] + labels: Dict[str, str] + + result = validate( + {"hits": [100, 200, 300], "labels": {"env": "prod", "region": "us"}}, + Analytics, + ) + assert result == { + "hits": [100, 200, 300], + "labels": {"env": "prod", "region": "us"}, + } + + def test_full_response_with_custom_type(self): + """カスタム型フィールドがある応答全体を検証""" + class Deployment: + name: str + timezone: Timezone + + result = validate({"name": "deploy-1", "timezone": UTC}, Deployment) + assert result["name"] == "deploy-1" + assert result["timezone"] is UTC + + def test_full_response_optional_fields_absent_when_not_provided(self): + """省略可能フィールドは未指定時に応答に含まれない""" + class UserProfile: + username: str + bio: Optional[str] + website: Optional[str] + + result = validate({"username": "nana_kit"}, UserProfile) + assert set(result.keys()) == {"username"} + assert result["username"] == "nana_kit" + + def test_full_response_validator_attributes_respected(self): + """Validator インスタンスをクラス属性として使ったときの応答全体を検証""" + class Profile: + role = v.str().default("guest") + score = v.int().range(0, 100).default(0) + + r_defaults = validate({}, Profile) + assert r_defaults == {"role": "guest", "score": 0} + + r_provided = validate({"role": "admin", "score": 99}, Profile) + assert r_provided == {"role": "admin", "score": 99} + + def test_full_response_coerce_in_instance_validator(self): + """coerce=True の InstanceValidator でのレスポンス全体を検証""" + class Celsius: + def __init__(self, val: float) -> None: + self.val = float(val) + + class Reading: + sensor: str + temp = v.instance(Celsius).coerce() + + result = validate({"sensor": "S1", "temp": 36.6}, Reading) + assert result["sensor"] == "S1" + assert isinstance(result["temp"], Celsius) + assert result["temp"].val == 36.6 + + def test_full_response_collect_errors_returns_all_failures(self): + """collect_errors=True のとき全エラーが ValidationResult.errors に収集される""" + class Form: + name: str + age: int + score: float + + result = validate( + {"name": 42, "age": "thirty", "score": "high"}, + Form, + collect_errors=True, + ) + assert isinstance(result, ValidationResult) + assert len(result.errors) >= 3 + + def test_full_response_partial_validation_omits_unset_keys(self): + """partial=True のとき不足フィールドはエラーにならず、送ったフィールドだけ応答に含まれる""" + class Config: + host: str + port: int + ssl: bool + + result = validate({"port": 8080}, Config, partial=True) + assert result.get("port") == 8080 + assert "host" not in result + + def test_full_response_base_merge_fills_missing_fields(self): + """base= のとき欠損フィールドがベース値でマージされる""" + class Config: + host: str + port: int + + base = {"host": "old.host", "port": 5432} + result = validate({"host": "new.host"}, Config, base=base, partial=True) + assert result["host"] == "new.host" + assert result["port"] == 5432 + + def test_full_response_generate_sample_uses_defaults(self): + """generate_sample() がデフォルト値をサンプルとして返す""" + from validkit.validator import _class_to_schema + + class Config: + host: str = "localhost" + port: int = 5432 + ssl: bool = False + timeout: Optional[int] = 30 + + schema = Schema(_class_to_schema(Config)) + sample = schema.generate_sample() + assert sample == { + "host": "localhost", + "port": 5432, + "ssl": False, + "timeout": 30, + } From 14ef031fccfd81bb6a80fda35ab530c026fb2bf6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:48:52 +0000 Subject: [PATCH 05/16] Fix Union passthrough/empty class schema/docstring; add 12 regression tests (130 total) Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- src/validkit/validator.py | 23 ++++--- tests/test_class_schema.py | 126 ++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 13 deletions(-) diff --git a/src/validkit/validator.py b/src/validkit/validator.py index ccd132f..09ebaaf 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -35,9 +35,10 @@ def _is_class_schema(schema: Any) -> bool: """Return True if *schema* is a class that should be treated as a class-based schema. A class qualifies when it: - - is a plain class (not one of the basic shorthand types), - - is not a Validator subclass, and - - either declares ``__annotations__`` or has at least one Validator class attribute. + - is a plain class (not one of the basic shorthand types), and + - is not a Validator subclass. + + An empty class (no annotations, no Validator attributes) is treated as an empty schema. """ if not isinstance(schema, type): return False @@ -45,13 +46,7 @@ def _is_class_schema(schema: Any) -> bool: return False if issubclass(schema, Validator): return False - if hasattr(schema, "__annotations__"): - return True - return any( - isinstance(vars(schema).get(k), Validator) - for k in vars(schema) - if not k.startswith("_") - ) + return True class Schema(Generic[T]): @@ -168,8 +163,12 @@ def _type_hint_to_validator( # True Optional[T]: recurse with the single inner type val = _type_hint_to_validator(non_none_args[0]) else: - # Union[T1, T2, ...] without None: passthrough (no strict type check) - val = Validator() + # Non-optional Union[T1, T2, ...] is not supported: fail fast instead of silently + # disabling type checking. + raise TypeError( + f"Non-optional typing.Union types are not supported as schema annotations: {hint!r}. " + "Use Optional[T] (Union[T, None]) or a plain type instead." + ) # --- list[T] / List[T] --- elif origin is list: diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index 96c293e..d69d7cb 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -2,7 +2,7 @@ tests/test_class_schema.py クラス記法によるスキーマ定義のテスト: - 型アノテーション (str / int / float / bool) からのスキーマ生成 - - typing モジュールのアノテーション (Optional / List / Dict / Union) + - typing モジュールのアノテーション (Optional / List / Dict) - Python 3.9+ 組み込みジェネリクス (list[T] / dict[K, V]) - カスタム型 (original class) の isinstance バリデーション - クラス属性をデフォルト値として使用 @@ -10,6 +10,8 @@ - v.instance() ビルダー - partial / base / collect_errors との組み合わせ - Schema.generate_sample() との組み合わせ + - 空クラスのスキーマとしての動作 + - サポート外の型 (non-optional Union) の明示的エラー """ import datetime @@ -867,3 +869,125 @@ class Config: "ssl": False, "timeout": 30, } + + +# --------------------------------------------------------------------------- +# Fix 5: 非 Optional の Union[T1, T2] は明示的 TypeError を送出する +# --------------------------------------------------------------------------- + +class TestNonOptionalUnionRaisesError: + """非 Optional な Union[T1, T2,...] は _type_hint_to_validator() / _class_to_schema() で + TypeError を送出し、サイレントパススルーにならないことを検証するテスト群。""" + + def test_non_optional_union_raises_type_error_via_class_to_schema(self): + """Union[int, str] アノテーションはクラス記法スキーマ生成時に TypeError を送出する""" + from typing import Union + from validkit.validator import _class_to_schema + + class BadSchema: + value: Union[int, str] + + with pytest.raises(TypeError, match="Non-optional typing.Union"): + _class_to_schema(BadSchema) + + def test_non_optional_union_raises_type_error_via_type_hint_to_validator(self): + """_type_hint_to_validator() に直接 Union[int, str] を渡しても TypeError が送出される""" + from typing import Union + from validkit.validator import _type_hint_to_validator + + with pytest.raises(TypeError, match="Non-optional typing.Union"): + _type_hint_to_validator(Union[int, str]) + + def test_optional_t_does_not_raise(self): + """Optional[T] (= Union[T, None]) は正常に動作し TypeError を送出しない""" + from validkit.validator import _type_hint_to_validator + from typing import Optional + + val = _type_hint_to_validator(Optional[str]) + assert val._optional is True + + def test_union_with_none_only_as_non_none_still_raises(self): + """Union[int, str, float] (None なし) でも TypeError を送出する""" + from typing import Union + from validkit.validator import _type_hint_to_validator + + with pytest.raises(TypeError, match="Non-optional typing.Union"): + _type_hint_to_validator(Union[int, str, float]) + + def test_validate_with_class_with_non_optional_union_raises(self): + """validate() にクラス記法スキーマを渡したとき Union[T1,T2] フィールドが TypeError になる""" + from typing import Union + + class Config: + host: Union[str, bytes] + + with pytest.raises(TypeError, match="Non-optional typing.Union"): + validate({"host": "db"}, Config) + + +# --------------------------------------------------------------------------- +# Fix 6: 空クラスはクラス記法スキーマとして {} と同等に扱われる +# --------------------------------------------------------------------------- + +class TestEmptyClassSchema: + """空クラス (アノテーションも Validator 属性もない) をスキーマとして渡したとき、 + 空スキーマ {} と同等に動作することを検証するテスト群。""" + + def test_empty_class_is_detected_as_class_schema(self): + """_is_class_schema() が空クラスを True と判定する""" + from validkit.validator import _is_class_schema + + class Empty: + pass + + assert _is_class_schema(Empty) is True + + def test_empty_class_produces_empty_dict_schema(self): + """_class_to_schema() が空クラスから空辞書を返す""" + from validkit.validator import _class_to_schema + + class Empty: + pass + + assert _class_to_schema(Empty) == {} + + def test_validate_with_empty_class_accepts_any_data(self): + """空クラスをスキーマとして validate() に渡すと、スキーマ外のキーは除外されて空辞書が返る""" + class Empty: + pass + + # Empty schema {} strips all unknown keys — consistent with dict schema behavior + result = validate({"foo": 1, "bar": "baz"}, Empty) + assert result == {} + + def test_validate_with_empty_class_accepts_empty_data(self): + """空クラスをスキーマとして validate() に渡すと、空辞書でも通過する""" + class Empty: + pass + + result = validate({}, Empty) + assert result == {} + + def test_basic_types_are_not_class_schemas(self): + """基本型 (str / int / float / bool) は _is_class_schema() で False になる""" + from validkit.validator import _is_class_schema + + for t in (str, int, float, bool): + assert _is_class_schema(t) is False, f"{t} should not be a class schema" + + def test_validator_subclass_is_not_class_schema(self): + """Validator のサブクラスは _is_class_schema() で False になる""" + from validkit.validator import _is_class_schema + from validkit.v import StringValidator + + assert _is_class_schema(StringValidator) is False + + def test_generate_sample_from_empty_class_returns_empty_dict(self): + """空クラスを Schema でラップした generate_sample() は空辞書を返す""" + from validkit.validator import _class_to_schema + + class Empty: + pass + + schema = Schema(_class_to_schema(Empty)) + assert schema.generate_sample() == {} From 9c386d78f0a744dd3aaba50e74e420f2baa55bc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 05:11:42 +0000 Subject: [PATCH 06/16] TDD: fix _is_class_schema() over-broad detection; document Union TypeError in README (139 tests) Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- README.md | 2 + src/validkit/validator.py | 25 ++++++-- tests/test_class_schema.py | 123 +++++++++++++++++++++++++++++++++---- 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 53074f0..0419f2d 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,8 @@ result = validate({"host": "db.local"}, Config) | `Any` / 不明な型 | チェックなし(パススルー) | | `Validator` インスタンス | ValidKit の完全なバリデーション | +> **注意**: `Union[int, str]` のように `None` を含まない非 Optional な Union 型は現在サポートされていません。`validate()` の呼び出し時(スキーマ変換フェーズ)に `TypeError` が送出されます。代わりに `Optional[T]`(= `Union[T, None]`)か具体的な単一型を使用してください。複数の型を受け付けたい場合は `v.instance(MyBaseClass)` などを検討してください。 + ### IDE 補完を効かせる(TypedDict + Schema) `Schema[T]` クラスを使うと、IDE(PyCharm / VS Code)での型補完が有効になります。 diff --git a/src/validkit/validator.py b/src/validkit/validator.py index 09ebaaf..d7809a3 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -35,10 +35,14 @@ def _is_class_schema(schema: Any) -> bool: """Return True if *schema* is a class that should be treated as a class-based schema. A class qualifies when it: - - is a plain class (not one of the basic shorthand types), and - - is not a Validator subclass. - - An empty class (no annotations, no Validator attributes) is treated as an empty schema. + - is a plain class (not one of the basic shorthand types), + - is not a Validator subclass, and + - either declares own ``__annotations__`` (non-empty) or has at least one Validator + class attribute in its own ``__dict__``. + + Using ``cls.__dict__.get("__annotations__", {})`` (rather than ``hasattr``) ensures that + inherited annotations from parent classes or stdlib types (e.g. ``datetime.datetime``, + ``pathlib.Path``) do not cause false positives. """ if not isinstance(schema, type): return False @@ -46,7 +50,18 @@ def _is_class_schema(schema: Any) -> bool: return False if issubclass(schema, Validator): return False - return True + # Check only the class's OWN annotations — not those inherited from base classes. + # This prevents stdlib classes (datetime.datetime, pathlib.Path, etc.) from being + # mistakenly detected as class schemas when their parent classes carry annotations. + own_annotations = schema.__dict__.get("__annotations__", {}) + if own_annotations: + return True + # Also accept classes whose only schema fields are Validator instances. + return any( + isinstance(val, Validator) + for k, val in schema.__dict__.items() + if not k.startswith("_") + ) class Schema(Generic[T]): diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index d69d7cb..8d9b32f 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -926,24 +926,26 @@ class Config: # --------------------------------------------------------------------------- -# Fix 6: 空クラスはクラス記法スキーマとして {} と同等に扱われる +# 空クラスの動作(注: 空クラスはスキーマと認識されず、validate() でパススルーになる) # --------------------------------------------------------------------------- class TestEmptyClassSchema: - """空クラス (アノテーションも Validator 属性もない) をスキーマとして渡したとき、 - 空スキーマ {} と同等に動作することを検証するテスト群。""" + """空クラス (アノテーションも Validator 属性もない) は _is_class_schema() で False になり、 + クラス記法スキーマとしては扱われないことを検証するテスト群。 + 空クラスを validate() に渡すと、スキーマ処理をバイパスしてデータをそのまま返す(パススルー)。 + アノテーションや Validator 属性を持つクラスのみがスキーマとして認識される。""" - def test_empty_class_is_detected_as_class_schema(self): - """_is_class_schema() が空クラスを True と判定する""" + def test_empty_class_is_not_detected_as_class_schema(self): + """_is_class_schema() が空クラスを False と判定する(スキーマではない)""" from validkit.validator import _is_class_schema class Empty: pass - assert _is_class_schema(Empty) is True + assert _is_class_schema(Empty) is False def test_empty_class_produces_empty_dict_schema(self): - """_class_to_schema() が空クラスから空辞書を返す""" + """_class_to_schema() に空クラスを渡しても空辞書を返す(内部的には安全)""" from validkit.validator import _class_to_schema class Empty: @@ -951,23 +953,39 @@ class Empty: assert _class_to_schema(Empty) == {} - def test_validate_with_empty_class_accepts_any_data(self): - """空クラスをスキーマとして validate() に渡すと、スキーマ外のキーは除外されて空辞書が返る""" + def test_validate_with_empty_class_passes_through_data(self): + """空クラスはスキーマではないので validate() はデータをそのまま返す(パススルー)""" class Empty: pass - # Empty schema {} strips all unknown keys — consistent with dict schema behavior + # Empty class is NOT a class schema; validate() passes the value through unchanged. result = validate({"foo": 1, "bar": "baz"}, Empty) - assert result == {} + assert result == {"foo": 1, "bar": "baz"} - def test_validate_with_empty_class_accepts_empty_data(self): - """空クラスをスキーマとして validate() に渡すと、空辞書でも通過する""" + def test_validate_with_empty_class_passes_through_empty_data(self): + """空クラスをスキーマとして validate() に渡すと、空辞書もそのまま返す""" class Empty: pass result = validate({}, Empty) assert result == {} + def test_validate_with_empty_class_passes_through_list_input(self): + """空クラスはスキーマではないので、リストを渡してもそのまま返す(パススルー)""" + class Empty: + pass + + result = validate([1, 2, 3], Empty) + assert result == [1, 2, 3] + + def test_validate_with_empty_class_passes_through_none_input(self): + """空クラスはスキーマではないので None を渡してもそのまま返す(パススルー)""" + class Empty: + pass + + result = validate(None, Empty) + assert result is None + def test_basic_types_are_not_class_schemas(self): """基本型 (str / int / float / bool) は _is_class_schema() で False になる""" from validkit.validator import _is_class_schema @@ -991,3 +1009,82 @@ class Empty: schema = Schema(_class_to_schema(Empty)) assert schema.generate_sample() == {} + + +# --------------------------------------------------------------------------- +# _is_class_schema() の過剰検出リグレッション防止テスト +# --------------------------------------------------------------------------- + +class TestIsClassSchemaTooBroad: + """_is_class_schema() が標準ライブラリクラスや任意クラスを + 誤ってクラス記法スキーマと判定しないことを検証するテスト群。 + + これらのテストは「すべての非基本・非 Validator クラスを True にする」修正の + リグレッション(後退)を防ぐために作成した。 + 正しい動作: own __annotations__ または Validator 属性を持つクラスのみを True とする。 + """ + + def test_datetime_datetime_is_not_a_class_schema(self): + """datetime.datetime はスキーマではなく型として使われるべきなので False""" + import datetime + from validkit.validator import _is_class_schema + + assert _is_class_schema(datetime.datetime) is False + + def test_datetime_date_is_not_a_class_schema(self): + """datetime.date も同様に False""" + import datetime + from validkit.validator import _is_class_schema + + assert _is_class_schema(datetime.date) is False + + def test_pathlib_path_is_not_a_class_schema(self): + """pathlib.Path もスキーマと誤認されてはならない""" + import pathlib + from validkit.validator import _is_class_schema + + assert _is_class_schema(pathlib.Path) is False + + def test_empty_class_is_not_a_class_schema(self): + """アノテーションも Validator 属性もない空クラスはクラス記法スキーマと見なさない""" + from validkit.validator import _is_class_schema + + class Empty: + pass + + assert _is_class_schema(Empty) is False + + def test_annotated_class_is_a_class_schema(self): + """型アノテーションを持つユーザー定義クラスはクラス記法スキーマとして認識される""" + from validkit.validator import _is_class_schema + + class Profile: + name: str + age: int + + assert _is_class_schema(Profile) is True + + def test_validator_attr_class_is_a_class_schema(self): + """Validator 属性のみを持つクラスはクラス記法スキーマとして認識される""" + from validkit.validator import _is_class_schema + + class Config: + role = v.str() + + assert _is_class_schema(Config) is True + + def test_validate_with_datetime_class_does_not_treat_as_empty_schema(self): + """validate() で datetime.datetime を schema に渡しても誤って {} スキーマで処理されない""" + import datetime + from validkit.validator import _is_class_schema + + # With the overly-broad _is_class_schema(), validate(data, datetime.datetime) would + # try to validate data as an empty dict schema, stripping all keys to {}. + # The correct behavior is to NOT treat datetime.datetime as a class schema, + # so validate() passes the data through unchanged (passthrough path). + assert _is_class_schema(datetime.datetime) is False + + data = {"year": 2024, "month": 1, "day": 15} + result = validate(data, datetime.datetime) + # Must NOT silently strip all keys to {} + assert result == data From 66a1b12805895b6911d0568c1ab3263a8ccc0ad3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 05:27:10 +0000 Subject: [PATCH 07/16] TDD cycle 2: fix Union error message, exception chaining, own-dict annotation lookup, test docstrings (146 tests) Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- src/validkit/v.py | 4 +- src/validkit/validator.py | 13 ++-- tests/test_class_schema.py | 125 +++++++++++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 13 deletions(-) diff --git a/src/validkit/v.py b/src/validkit/v.py index e3aaaeb..eef7824 100644 --- a/src/validkit/v.py +++ b/src/validkit/v.py @@ -250,10 +250,10 @@ def validate(self, value: Any, data: Optional[Dict[str, Any]] = None, path_prefi if self._coerce: try: coerced_value = self._instance_type(value) - except Exception: + except Exception as e: raise TypeError( f"Expected instance of {self._instance_type.__name__}, got {type(value).__name__}" - ) + ) from e if not isinstance(coerced_value, self._instance_type): raise TypeError( f"Expected instance of {self._instance_type.__name__}, got {type(coerced_value).__name__}" diff --git a/src/validkit/validator.py b/src/validkit/validator.py index d7809a3..00285fc 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -178,11 +178,12 @@ def _type_hint_to_validator( # True Optional[T]: recurse with the single inner type val = _type_hint_to_validator(non_none_args[0]) else: - # Non-optional Union[T1, T2, ...] is not supported: fail fast instead of silently - # disabling type checking. + # Union with multiple non-None members is not supported: fail fast instead of silently + # disabling type checking. (This also covers Union[T1, T2, None] where None is present + # but there are still multiple non-None members and no single target type can be inferred.) raise TypeError( - f"Non-optional typing.Union types are not supported as schema annotations: {hint!r}. " - "Use Optional[T] (Union[T, None]) or a plain type instead." + f"typing.Union with multiple non-None members is not supported as schema annotations: {hint!r}. " + "Use Optional[T] (Union[T, None]) with a single non-None type, or a plain type instead." ) # --- list[T] / List[T] --- @@ -269,8 +270,8 @@ def _class_to_schema(cls: type) -> Dict[str, Any]: if isinstance(attr, Validator): schema[key] = attr - # 2. Process type annotations - annotations: Dict[str, Any] = getattr(cls, "__annotations__", {}) + # 2. Process type annotations (only those declared directly on this class, not inherited) + annotations: Dict[str, Any] = cls.__dict__.get("__annotations__", {}) for key, type_hint in annotations.items(): if key in schema: # Already have a Validator class attribute for this field — skip diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index 8d9b32f..d2b3ce9 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -87,7 +87,8 @@ class Profile: assert result == {"name": "Alice"} def test_empty_class_schema(self): - """アノテーションなしのクラスは空のスキーマとして扱われる""" + """アノテーションなしのクラスはスキーマとして認識されない。 + validate() はデータをそのまま返す(パススルー)。""" class Empty: pass @@ -887,7 +888,7 @@ def test_non_optional_union_raises_type_error_via_class_to_schema(self): class BadSchema: value: Union[int, str] - with pytest.raises(TypeError, match="Non-optional typing.Union"): + with pytest.raises(TypeError, match="multiple non-None members"): _class_to_schema(BadSchema) def test_non_optional_union_raises_type_error_via_type_hint_to_validator(self): @@ -895,7 +896,7 @@ def test_non_optional_union_raises_type_error_via_type_hint_to_validator(self): from typing import Union from validkit.validator import _type_hint_to_validator - with pytest.raises(TypeError, match="Non-optional typing.Union"): + with pytest.raises(TypeError, match="multiple non-None members"): _type_hint_to_validator(Union[int, str]) def test_optional_t_does_not_raise(self): @@ -911,7 +912,7 @@ def test_union_with_none_only_as_non_none_still_raises(self): from typing import Union from validkit.validator import _type_hint_to_validator - with pytest.raises(TypeError, match="Non-optional typing.Union"): + with pytest.raises(TypeError, match="multiple non-None members"): _type_hint_to_validator(Union[int, str, float]) def test_validate_with_class_with_non_optional_union_raises(self): @@ -921,7 +922,7 @@ def test_validate_with_class_with_non_optional_union_raises(self): class Config: host: Union[str, bytes] - with pytest.raises(TypeError, match="Non-optional typing.Union"): + with pytest.raises(TypeError, match="multiple non-None members"): validate({"host": "db"}, Config) @@ -1088,3 +1089,117 @@ def test_validate_with_datetime_class_does_not_treat_as_empty_schema(self): result = validate(data, datetime.datetime) # Must NOT silently strip all keys to {} assert result == data + + +# --------------------------------------------------------------------------- +# TDD Cycle 2: 新しいレビュー指摘への対応 +# --------------------------------------------------------------------------- + +class TestUnionWithNoneErrorMessage: + """Union 型のエラーメッセージが正確であることを検証するテスト群。 + 複数の非 None メンバーを持つ Union アノテーションは TypeError を送出し、 + エラーメッセージは正確に問題を説明しなければならない。""" + + def test_union_with_none_and_multiple_non_none_raises_with_accurate_message(self): + """Union[int, str, None] は非 None メンバーが複数あるため TypeError を送出する""" + from typing import Union + from validkit.validator import _type_hint_to_validator + + with pytest.raises(TypeError, match="multiple non-None members"): + _type_hint_to_validator(Union[int, str, None]) + + def test_union_without_none_still_raises_type_error(self): + """Union[int, str] (None なし) は引き続き TypeError を送出する""" + from typing import Union + from validkit.validator import _type_hint_to_validator + + with pytest.raises(TypeError, match="multiple non-None members"): + _type_hint_to_validator(Union[int, str]) + + def test_union_int_str_none_error_message_contains_type_repr(self): + """エラーメッセージはどの型アノテーションが問題かを repr で含む""" + from typing import Union + from validkit.validator import _type_hint_to_validator + + with pytest.raises(TypeError) as exc_info: + _type_hint_to_validator(Union[int, str, None]) + + # The hint repr (e.g. "typing.Union[int, str, NoneType]") must be in the message + assert "typing.Union" in str(exc_info.value), ( + "Error message should include the exact hint repr so users know what failed" + ) + + +class TestInstanceValidatorExceptionChaining: + """InstanceValidator.coerce() が元の例外を正しくチェーンすることを検証。""" + + def test_coercion_failure_chains_original_exception(self): + """coerce() が失敗したとき、送出される TypeError は元の例外を __cause__ に持つ""" + from validkit import v + + class StrictType: + def __init__(self, value: str) -> None: + raise ValueError(f"Cannot construct from {value!r}") + + validator = v.instance(StrictType).coerce() + + with pytest.raises(TypeError) as exc_info: + validator.validate("bad_value") + + assert exc_info.value.__cause__ is not None, ( + "TypeError raised by coerce() should chain the original exception via 'raise ... from e'" + ) + assert isinstance(exc_info.value.__cause__, ValueError), ( + "The chained exception should be the original ValueError from the constructor" + ) + + def test_coercion_failure_chain_preserves_original_message(self): + """チェーンされた例外は元のコンストラクタのメッセージを保持する""" + from validkit import v + + class StrictType: + def __init__(self, value: str) -> None: + raise ValueError("original error message") + + validator = v.instance(StrictType).coerce() + + with pytest.raises(TypeError) as exc_info: + validator.validate("bad_value") + + assert "original error message" in str(exc_info.value.__cause__) + + +class TestClassSchemaAnnotationInheritance: + """_class_to_schema() が継承されたアノテーションを含めず、 + クラス自身の __dict__ からのみアノテーションを読み取ることを検証。""" + + def test_class_to_schema_uses_own_annotations_only(self): + """_class_to_schema() は cls.__dict__ のアノテーションのみ処理する""" + from validkit.validator import _class_to_schema + + class Base: + x: int + + class ChildWithValidatorAttr(Base): + role = v.str() # Validator attr → treated as schema + + schema = _class_to_schema(ChildWithValidatorAttr) + # Only own fields should appear; 'x' is inherited from Base, not declared on Child + assert "role" in schema, "'role' (Validator attr) must be in schema" + assert "x" not in schema, "'x' (inherited from Base) must NOT be in schema" + + def test_validate_with_child_schema_excludes_parent_annotations(self): + """validate() でも親クラスのアノテーションは引き継がれない""" + from validkit.validator import validate + from validkit import v + + class Base: + x: int + + class ChildSchema(Base): + role = v.str() # Only this is owned + + # 'role' is required; 'x' (from Base) should NOT be required + result = validate({"role": "admin"}, ChildSchema) + assert result == {"role": "admin"} + assert "x" not in result From 0513245b0b22ffe39b24c2b65666ae1787399ff3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 05:41:04 +0000 Subject: [PATCH 08/16] =?UTF-8?q?TDD=20cycle=203:=20add=20PEP=20604=20unio?= =?UTF-8?q?n=20syntax=20(T=20|=20None)=20support=20=E2=80=94=2013=20new=20?= =?UTF-8?q?tests,=20159=20total=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- src/validkit/validator.py | 8 ++- tests/test_class_schema.py | 122 +++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/validkit/validator.py b/src/validkit/validator.py index 00285fc..b8e8eb0 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -13,6 +13,7 @@ Literal, cast, ) +import types as _types from .v import ( Validator, v, @@ -169,8 +170,8 @@ def _type_hint_to_validator( origin = get_origin(hint) args = get_args(hint) - # --- Optional[T] / Union[T, None] --- - if origin is Union: + # --- Optional[T] / Union[T, None] and PEP 604 T | None / T1 | T2 --- + if origin is Union or isinstance(hint, _types.UnionType): non_none_args = [a for a in args if a is not type(None)] if type(None) in args: optional_flag = True @@ -180,7 +181,8 @@ def _type_hint_to_validator( else: # Union with multiple non-None members is not supported: fail fast instead of silently # disabling type checking. (This also covers Union[T1, T2, None] where None is present - # but there are still multiple non-None members and no single target type can be inferred.) + # but there are still multiple non-None members and no single target type can be inferred. + # Applies equally to PEP 604 syntax: int | str | None raises the same error.) raise TypeError( f"typing.Union with multiple non-None members is not supported as schema annotations: {hint!r}. " "Use Optional[T] (Union[T, None]) with a single non-None type, or a plain type instead." diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index d2b3ce9..21d9af7 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -1203,3 +1203,125 @@ class ChildSchema(Base): result = validate({"role": "admin"}, ChildSchema) assert result == {"role": "admin"} assert "x" not in result + + +# --------------------------------------------------------------------------- +# TDD Cycle 3: PEP 604 ユニオン記法 (T | None, T1 | T2) のサポート +# --------------------------------------------------------------------------- + +class TestPEP604UnionSyntax: + """Python 3.10+ の PEP 604 パイプ記法 (T | None, T1 | T2) が + typing.Optional / typing.Union と同等に処理されることを検証するテスト群。""" + + # ---- Optional-like: T | None ---- + + def test_str_or_none_is_optional(self): + """str | None は Optional[str] と同等に扱われ、省略可能フィールドになる""" + from validkit.validator import _type_hint_to_validator + val = _type_hint_to_validator(str | None) + assert val._optional is True, "str | None validator must be optional" + + def test_str_or_none_passes_string_value(self): + """str | None アノテーションのフィールドは文字列値を受け入れる""" + from validkit import validate + + class Schema: + nickname: str | None + + result = validate({"nickname": "Alice"}, Schema) + assert result == {"nickname": "Alice"} + + def test_str_or_none_allows_missing_field(self): + """str | None アノテーションのフィールドは省略可能""" + from validkit import validate + + class Schema: + nickname: str | None + + result = validate({}, Schema) + assert "nickname" not in result or result.get("nickname") is None + + def test_int_or_none_is_optional(self): + """int | None は Optional[int] と同等に扱われ、省略可能フィールドになる""" + from validkit.validator import _type_hint_to_validator + val = _type_hint_to_validator(int | None) + assert val._optional is True + + def test_none_or_str_is_optional(self): + """None | str も str | None と同等に扱われる""" + from validkit.validator import _type_hint_to_validator + val = _type_hint_to_validator(None | str) + assert val._optional is True + + def test_pep604_optional_rejects_wrong_type(self): + """str | None フィールドに非文字列を渡すと ValidationError が送出される""" + from validkit import validate, ValidationError + + class Schema: + label: str | None + + with pytest.raises((TypeError, ValidationError)): + validate({"label": 123}, Schema) + + # ---- Multi-member: T1 | T2 (should raise TypeError) ---- + + def test_int_or_str_raises_type_error(self): + """int | str は複数の非 None メンバーを持つため TypeError を送出する""" + from validkit.validator import _type_hint_to_validator + + with pytest.raises(TypeError, match="multiple non-None members"): + _type_hint_to_validator(int | str) + + def test_int_or_str_or_none_raises_type_error(self): + """int | str | None も複数の非 None メンバーを持つため TypeError を送出する""" + from validkit.validator import _type_hint_to_validator + + with pytest.raises(TypeError, match="multiple non-None members"): + _type_hint_to_validator(int | str | None) + + def test_validate_with_class_using_multi_member_pep604_raises(self): + """クラスアノテーションに int | str を使うと validate() が TypeError を送出する""" + from validkit import validate + + class BadSchema: + value: int | str + + with pytest.raises(TypeError, match="multiple non-None members"): + validate({"value": 1}, BadSchema) + + # ---- Validator type (inner type) is correct ---- + + def test_str_or_none_inner_validator_is_string_validator(self): + """str | None から生成される内部バリデータは StringValidator であるべき""" + from validkit.validator import _type_hint_to_validator + from validkit.v import StringValidator + val = _type_hint_to_validator(str | None) + assert isinstance(val, StringValidator), ( + f"Expected StringValidator, got {type(val).__name__}" + ) + + def test_int_or_none_inner_validator_is_int_validator(self): + """int | None から生成される内部バリデータは NumberValidator であるべき""" + from validkit.validator import _type_hint_to_validator + from validkit.v import NumberValidator + val = _type_hint_to_validator(int | None) + assert isinstance(val, NumberValidator), ( + f"Expected NumberValidator, got {type(val).__name__}" + ) + + def test_list_or_none_produces_optional_list_validator(self): + """list[str] | None は省略可能な ListValidator を生成する""" + from validkit.validator import _type_hint_to_validator + from validkit.v import ListValidator + val = _type_hint_to_validator(list[str] | None) + assert isinstance(val, ListValidator) + assert val._optional is True + + def test_pep604_error_message_contains_hint_repr(self): + """TypeError のメッセージはヒントの repr を含む""" + from validkit.validator import _type_hint_to_validator + + with pytest.raises(TypeError) as exc_info: + _type_hint_to_validator(int | str) + + assert "int" in str(exc_info.value) or "str" in str(exc_info.value) From cefcb542abd11ef2fe6c8ac990a5299cfdf91910 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:04:36 +0000 Subject: [PATCH 09/16] =?UTF-8?q?TDD=20cycle=204:=20Python=203.9=20compat?= =?UTF-8?q?=20=E2=80=94=20module-level=20=5FUnionType=20guard=20+=20skipif?= =?UTF-8?q?=20for=20PEP=20604=20tests,=206=20new=20tests,=20165=20total=20?= =?UTF-8?q?pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- src/validkit/validator.py | 9 ++- tests/test_class_schema.py | 127 ++++++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/validkit/validator.py b/src/validkit/validator.py index b8e8eb0..3656e29 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -28,6 +28,11 @@ T = TypeVar("T") +# Python 3.10+ introduced types.UnionType for PEP 604 (T | None) syntax. +# On Python 3.9, types.UnionType does not exist; capture it once at module +# load time so that _type_hint_to_validator() never accesses it at call time. +_UnionType: type | None = getattr(_types, "UnionType", None) + # Basic Python types supported as schema shorthand (str, int, float, bool) _BASIC_TYPES = (str, int, float, bool) @@ -171,7 +176,9 @@ def _type_hint_to_validator( args = get_args(hint) # --- Optional[T] / Union[T, None] and PEP 604 T | None / T1 | T2 --- - if origin is Union or isinstance(hint, _types.UnionType): + # Also handles Python 3.10+ types.UnionType (PEP 604); _UnionType is None + # on Python 3.9 where types.UnionType doesn't exist (module-level guard). + if origin is Union or (_UnionType is not None and isinstance(hint, _UnionType)): non_none_args = [a for a in args if a is not type(None)] if type(None) in args: optional_flag = True diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index 21d9af7..b781fdd 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -1209,9 +1209,17 @@ class ChildSchema(Base): # TDD Cycle 3: PEP 604 ユニオン記法 (T | None, T1 | T2) のサポート # --------------------------------------------------------------------------- +@pytest.mark.skipif( + sys.version_info < (3, 10), + reason="PEP 604 union syntax (T | None) creates types.UnionType only on Python 3.10+", +) class TestPEP604UnionSyntax: """Python 3.10+ の PEP 604 パイプ記法 (T | None, T1 | T2) が - typing.Optional / typing.Union と同等に処理されることを検証するテスト群。""" + typing.Optional / typing.Union と同等に処理されることを検証するテスト群。 + + PEP 604 の T | None 記法は Python 3.10+ でのみ有効なため、 + このテストクラスは Python 3.10 未満では自動的にスキップされる。 + """ # ---- Optional-like: T | None ---- @@ -1325,3 +1333,120 @@ def test_pep604_error_message_contains_hint_repr(self): _type_hint_to_validator(int | str) assert "int" in str(exc_info.value) or "str" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# TDD Cycle 4: Python 3.9 互換性 (types.UnionType ガード) +# --------------------------------------------------------------------------- + +class TestPEP604Python39Compatibility: + """Python 3.9 では types.UnionType が存在しない。 + _type_hint_to_validator() がモジュールレベルの _UnionType ガード変数を使用し、 + 呼び出し時に _types.UnionType を直接参照しないことを検証する。 + (呼び出し時直接参照は Python 3.9 で AttributeError を発生させる)""" + + def test_module_has_union_type_guard(self): + """validkit.validator モジュールは _UnionType モジュールレベル変数を持つ必要がある。 + Python 3.10+ では types.UnionType に設定され、Python 3.9 では None になる。""" + import validkit.validator as vmod + assert hasattr(vmod, '_UnionType'), ( + "_UnionType module-level guard not found in validkit.validator. " + "This is required for Python 3.9 compatibility where types.UnionType " + "does not exist." + ) + + def test_non_union_hints_work_when_union_type_guard_is_none(self): + """_UnionType を None に設定した状態 (Python 3.9 シミュレーション) でも + 基本型ヒントが AttributeError を発生させない。""" + import validkit.validator as vmod + + original = vmod._UnionType + try: + vmod._UnionType = None # Simulate Python 3.9: no types.UnionType + val_str = vmod._type_hint_to_validator(str) + val_int = vmod._type_hint_to_validator(int) + val_float = vmod._type_hint_to_validator(float) + val_bool = vmod._type_hint_to_validator(bool) + assert val_str is not None + assert val_int is not None + assert val_float is not None + assert val_bool is not None + except AttributeError as e: + pytest.fail( + f"AttributeError raised on non-union hint when _UnionType=None " + f"(Python 3.9 incompatibility): {e}" + ) + finally: + vmod._UnionType = original + + def test_optional_hint_works_when_union_type_guard_is_none(self): + """typing.Optional[str] は _UnionType=None でも正しく処理される。""" + from typing import Optional + import validkit.validator as vmod + + original = vmod._UnionType + try: + vmod._UnionType = None # Simulate Python 3.9 + val = vmod._type_hint_to_validator(Optional[str]) + assert val is not None + assert val._optional is True + except AttributeError as e: + pytest.fail( + f"AttributeError raised on Optional[str] when _UnionType=None: {e}" + ) + finally: + vmod._UnionType = original + + def test_list_hint_works_when_union_type_guard_is_none(self): + """List[str] は _UnionType=None でも正しく処理される。""" + from typing import List + import validkit.validator as vmod + + original = vmod._UnionType + try: + vmod._UnionType = None # Simulate Python 3.9 + val = vmod._type_hint_to_validator(List[str]) + assert val is not None + except AttributeError as e: + pytest.fail( + f"AttributeError raised on List[str] when _UnionType=None: {e}" + ) + finally: + vmod._UnionType = original + + def test_validate_with_class_schema_works_when_union_type_guard_is_none(self): + """validate() でクラススキーマを使っても _UnionType=None で動作する。""" + from typing import Optional + from validkit import validate + import validkit.validator as vmod + + class Schema: + name: str + age: Optional[int] + + original = vmod._UnionType + try: + vmod._UnionType = None # Simulate Python 3.9 + result = validate({"name": "Alice", "age": 30}, Schema) + assert result["name"] == "Alice" + assert result["age"] == 30 + except AttributeError as e: + pytest.fail( + f"AttributeError raised in validate() when _UnionType=None: {e}" + ) + finally: + vmod._UnionType = original + + def test_union_type_guard_value_on_current_python(self): + """現在の Python バージョンに応じた _UnionType の値を検証する。""" + import types + import validkit.validator as vmod + + if sys.version_info >= (3, 10): + assert vmod._UnionType is types.UnionType, ( + "On Python 3.10+, _UnionType must be types.UnionType" + ) + else: + assert vmod._UnionType is None, ( + "On Python < 3.10, _UnionType must be None" + ) From 24175296edbc50515a60febfd731c4b5e4e605c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:20:03 +0000 Subject: [PATCH 10/16] TDD cycle 5: Fix _UnionType annotation to Optional[type] (not type | None) for Python 3.9 compat, 2 new regression tests, 167 total pass Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- src/validkit/validator.py | 4 ++- tests/test_class_schema.py | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/validkit/validator.py b/src/validkit/validator.py index 3656e29..333743a 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -31,7 +31,9 @@ # Python 3.10+ introduced types.UnionType for PEP 604 (T | None) syntax. # On Python 3.9, types.UnionType does not exist; capture it once at module # load time so that _type_hint_to_validator() never accesses it at call time. -_UnionType: type | None = getattr(_types, "UnionType", None) +# Note: the annotation uses Optional[type] (not `type | None`) so the line +# itself is valid on Python 3.9 where PEP 604 runtime unions don't exist. +_UnionType: Optional[type] = getattr(_types, "UnionType", None) # Basic Python types supported as schema shorthand (str, int, float, bool) _BASIC_TYPES = (str, int, float, bool) diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index b781fdd..065011c 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -1450,3 +1450,60 @@ def test_union_type_guard_value_on_current_python(self): assert vmod._UnionType is None, ( "On Python < 3.10, _UnionType must be None" ) + + +# --------------------------------------------------------------------------- +# TDD Cycle 5: _UnionType annotation must not use PEP 604 (T | None) syntax +# --------------------------------------------------------------------------- + +class TestUnionTypeAnnotationPython39Safe: + """_UnionType の型アノテーション `type | None` は Python 3.9 で + `TypeError` を発生させる (PEP 604 は実行時注釈としては 3.10+ のみ対応)。 + 正しくは `Optional[type]` を使う必要がある。""" + + def test_union_type_annotation_does_not_use_pep604_syntax(self): + """validator.py の _UnionType アノテーションが PEP 604 記法でないことを検証する。 + `type | None` は Python 3.9 の import 時に TypeError を発生させる。""" + import ast + import pathlib + import validkit.validator as vmod + + # Locate the source file dynamically so this test works in any environment + validator_src = pathlib.Path(vmod.__file__).read_text(encoding="utf-8") + + tree = ast.parse(validator_src) + + pep604_union_annotations: List[str] = [] + for node in ast.walk(tree): + # AnnAssign: variable: annotation = value + if not isinstance(node, ast.AnnAssign): + continue + # Check if the target is _UnionType + if not (isinstance(node.target, ast.Name) and node.target.id == "_UnionType"): + continue + # BinOp with BitOr is the AST form of PEP 604 T | U + if isinstance(node.annotation, ast.BinOp) and isinstance( + node.annotation.op, ast.BitOr + ): + pep604_union_annotations.append(ast.unparse(node)) + + assert not pep604_union_annotations, ( + f"_UnionType uses PEP 604 (T | None) syntax in its annotation, " + f"which causes TypeError on Python 3.9: {pep604_union_annotations}. " + f"Use Optional[type] instead." + ) + + def test_validator_module_importable_when_union_type_is_none(self): + """_UnionType が None のときでも validkit.validator が正常にインポートできる。 + (Python 3.9 シミュレーション)""" + import validkit.validator as vmod + + original = vmod._UnionType + try: + vmod._UnionType = None + # Re-accessing module attributes must not raise + assert hasattr(vmod, "_is_class_schema") + assert hasattr(vmod, "_class_to_schema") + assert hasattr(vmod, "_type_hint_to_validator") + finally: + vmod._UnionType = original From 6e91dd1926471b3796749e6741f0052590ba553d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 09:36:19 +0000 Subject: [PATCH 11/16] Merge main, fix _BASIC_TYPES reuse, sync docs/changelog, and align Schema(Profile) runtime behavior Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- CHANGELOG.md | 99 ++++++++ README.md | 31 ++- docs/index.md | 127 +++++++++- src/validkit/__init__.py | 2 +- src/validkit/v.py | 143 +++++++++++ src/validkit/validator.py | 107 +++++--- tests/conftest.py | 22 ++ tests/test_class_schema.py | 45 +++- tests/test_coercion.py | 6 +- tests/test_features_v120.py | 472 +++++++++++++++++++++++++++++++++++- tests/test_validkit.py | 33 ++- 11 files changed, 1037 insertions(+), 50 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/conftest.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..53b2123 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,99 @@ +# Changelog + +このファイルは `git tag` とコミット履歴をもとに、バージョンごとの変更点を再構成したものです。 + +- タグが存在する版はタグを基準に整理しています。 +- `1.2.0` は現時点で `git tag` が見当たらないため、`__version__ = "1.2.0"` とリリースコミット(2026-02-27)を基準に整理しています。 + +## Unreleased + +### Added +- クラス記法スキーマ (`class Config: ...`) を追加し、型アノテーションと `Validator` クラス属性を既存の dict スキーマ検証経路へ変換できるようになりました。 +- `v.instance(type_cls)` / `InstanceValidator` を追加し、任意クラスに対する `isinstance` ベースの検証と `.coerce()` をサポートしました。 +- `v.auto_infer(data, type_map=None, schema_overrides=None)` を追加し、既存データから ValidKit スキーマを逆生成できるようになりました。 +- `type_map` によるカスタム型対応を追加しました。callable が `Validator` を返す場合はそのまま使用し、プリミティブ値を返す場合は変換後の値で再推論します。 +- `schema_overrides` を追加し、トップレベルの dict フィールドに対して推論結果を明示的なバリデータで上書きできるようになりました。 + +### Fixed +- クラス記法スキーマで `Optional[T]` / `Union[T, None]` が必須扱いになっていた問題を修正しました。 +- `typing.Union` / PEP 604 (`T | None`) のうち、`None` 以外の複数メンバーを持つ型がサイレントにパススルーされる問題を修正し、明示的に `TypeError` を送出するようにしました。 +- Python 3.9 で `types.UnionType` や `_UnionType: type | None` に起因する import / 実行時エラーが発生しないよう互換性を改善しました。 +- `InstanceValidator.coerce()` が元例外を失っていた問題を修正し、例外チェーンを保持するようにしました。 +- `schema_overrides` がネストした dict やリスト要素に漏れて適用される問題を修正しました。 +- `type_map` の callable による再推論時に `schema_overrides` が意図せず伝播する問題を修正しました。 +- `NumberValidator.range()` で `min > max` の不正な境界を定義時に `ValueError` として拒否し、`.min()` / `.max()` の組み合わせでも矛盾を防ぐようにしました。 +- `Schema.generate_sample()` が生成候補を各バリデータで再検証するようになり、`regex()` や `custom()` を満たせない不正なサンプルを返さず `ValueError` を送出するようになりました。 + +### Changed +- `Schema(...)` は実行時に dict スキーマだけでなく class 記法スキーマも直接ラップできるようになりました。 +- README / `docs/index.md` / 回帰テストをクラス記法スキーマと Python 3.9+ 型ヒント対応に合わせて更新しました。 +- `v.auto_infer()` の型ヒントと回帰テストを拡充し、mypy 互換性を改善しました。 +- ドキュメントとサンプルを `auto_infer()` / `schema_overrides` の仕様に合わせて更新しました。 + +## [1.2.0] - 2026-02-27 + +> 注: この版はリポジトリ内のバージョン定義とリリースコミットを基準に整理していますが、現時点では対応する `git tag` は確認できていません。 + +### Added +- すべてのバリデータに `.default(value)` を追加しました。欠損キーを自動補完し、設定したフィールドは自動的に optional として扱われます。 +- すべてのバリデータに `.examples(list)` を追加しました。サンプル生成やドキュメント補助に使える例を保持できます。 +- すべてのバリデータに `.description(text)` を追加しました。フィールドの説明文を保持できます。 +- `Schema.generate_sample()` を追加しました。スキーマからサンプルデータを再帰的に生成でき、優先順位は `.default()` → `.examples()[0]` → 型ごとの代表値です。 + +### Changed +- `example.py` とドキュメントを更新し、新しい補完・サンプル生成 API を反映しました。 +- 型チェック関連の修正を行い、公開 API の利用時に静的解析しやすくしました。 + +## [1.1.3] - 2026-02-27 + +### Added +- 各型バリデータの coercion(自動型変換)を実装しました。 +- 型変換の挙動を検証する専用テストを追加しました。 +- `validkit` パッケージ初期化を整備し、主要な関数・クラスをトップレベルから import できるようにしました。 + +## [1.1.2] - 2026-02-27 + +### Added +- 宣言的なスキーマ定義によるバリデーションライブラリ本体を実装しました。 +- カスタムルールを含む基本的な検証機能を追加しました。 + +## [1.1.1] - 2026-02-27 + +### Changed +- `example.py` を更新し、利用例を見直しました。 + +### Fixed +- 軽微な不具合を修正しました。 + +## [1.1.0] - 2026-02-27 + +### Added +- `Schema[T]` ジェネリックラッパーを追加し、`validate()` の戻り値を IDE / 型チェッカーがより正確に推論できるようにしました。 +- `validate()` に型補完向けのオーバーロードを追加し、TypedDict と組み合わせた補完体験を改善しました。 + +### Changed +- 実行時に不要なオーバーロード定義を `TYPE_CHECKING` 配下へ移し、静的解析向けの実装に整理しました。 +- `1.1.0` へバージョンを更新し、`Schema[T]` を公開 API として位置づけました。 + +## [1.0.2] - 2026-01-24 + +### Added +- `SECURITY.md` を追加し、セキュリティポリシーを整備しました。 + +### Changed +- `SECURITY.md` の日英構成と書式を整理しました。 +- ライセンス表記まわりの説明を見直しました。 + +## [1.0.1] - 2026-01-24 + +### Added +- MIT ライセンスを追加しました。 + +### Changed +- `README.md` に追加情報とバッジを反映しました。 + +## [1.0.0] - 2026-01-24 + +### Added +- CI ワークフローと自動チェック基盤を追加しました。 +- パッケージ設定を整備し、最初の公開リリースを作成しました。 diff --git a/README.md b/README.md index 0419f2d..403c610 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ except ValidationError as e: * `.examples(list)`: サンプル生成やドキュメント用の具体例を設定 * `.description(str)`: フィールドの説明文を設定 * `.regex(pattern)`: 正規表現チェック -* `.range(min, max)` / `.min(val)` / `.max(val)`: 範囲チェック +* `.range(min, max)` / `.min(val)` / `.max(val)`: 範囲チェック(`min <= max` が必須。不正な境界は定義時に `ValueError`) * `.custom(func)`: 独自の変換・検証ロジックを注入 * `.coerce()`: 入力値の型を自動的に変換(例: "123" -> 123) @@ -292,7 +292,7 @@ result = validate({}, SCHEMA) ### サンプルデータの自動生成 (`generate_sample`) -定義したスキーマから、仕様書の雛形や設定ファイルのテンプレートとして使えるサンプルデータを自動生成できます。 +定義したスキーマから、仕様書の雛形や設定ファイルのテンプレートとして使えるサンプルデータを自動生成できます。生成された値は各バリデータで再検証されるため、`regex()` や `custom()` を満たせない不正なサンプルは返しません。 ```python SCHEMA = Schema({ @@ -311,6 +311,33 @@ sample = SCHEMA.generate_sample() 2. `.examples()` リストの最初の要素 3. 各型のダミー値(`str`: "example", `int`: 0, `bool`: False 等) +`regex()` / `custom()` などで上記候補が制約を満たせない場合、`generate_sample()` は `ValueError` を送出します。その場合は妥当な `.default(...)` または `.examples([...])` を与えてください。 + +### スキーマ自動生成 (`v.auto_infer`) + +既存データから ValidKit スキーマを逆生成できます。プロトタイピングや既存 JSON / dict からの移行時に便利です。 + +```python +import datetime +from validkit import v + +data = { + "name": "Alice", + "score": 9.5, + "created_at": datetime.date(2024, 1, 1), +} + +schema = v.auto_infer( + data, + type_map={datetime.date: v.str()}, + schema_overrides={"score": v.float().range(0.0, 10.0)}, +) +``` + +- `type_map` でカスタム型を処理できます +- `schema_overrides` でトップレベル dict の特定フィールドだけ推論結果を上書きできます +- `None` は `optional()` なバリデータとして推論されます + ### 部分更新とデフォルト値のマージ (base引数) 既存の辞書データを「ベース」として、入力された不完全なデータをマージする場合に便利です。これは `.default()` よりも優先されます。 diff --git a/docs/index.md b/docs/index.md index dbd6a84..280e95a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,8 +32,22 @@ ValidKit の API リファレンスおよび高度な使用方法について詳 型情報を持つスキーマのラッパーです。 +`Schema({...})` による従来の dict スキーマに加えて、実行時には `Schema(MyClass)` のように +クラス記法スキーマもそのままラップできます。 + +```python +from validkit import Schema, validate + +class Profile: + name: str + age: int + +schema = Schema(Profile) +result = validate({"name": "Alice", "age": 30}, schema) +``` + #### `.generate_sample()` -スキーマ定義からサンプルデータの辞書を生成します。 +スキーマ定義からサンプルデータの辞書を生成します。生成後の値は各バリデータで再検証されるため、制約を満たさないサンプルは返されません。 ```python sample = SCHEMA.generate_sample() @@ -41,6 +55,112 @@ sample = SCHEMA.generate_sample() 優先順位: `.default()` > `.examples()[0]` > 各型のデフォルト値。 +`regex()` や `custom()` などでこれらの候補が制約を満たせない場合は、`ValueError` を送出します。その場合は `.default(...)` または `.examples([...])` で妥当なサンプル候補を与えてください。 + +### クラス記法スキーマ + +辞書スキーマに加えて、Python クラスの型アノテーションや `Validator` クラス属性をそのまま +スキーマとして扱えます。 + +```python +from typing import Dict, List, Optional +from validkit import v, validate + +class Config: + host: str + port: int = 5432 + tags: Optional[List[str]] + metadata: Dict[str, int] + role = v.str().default("worker") +``` + +対応する型ヒント: + +| アノテーション | 動作 | +|---|---| +| `str` / `int` / `float` / `bool` | 型チェック | +| `Optional[T]` / `Union[T, None]` | 内部型をチェックし、省略可能 | +| `List[T]` / `list[T]` | 要素ごとの型チェック | +| `Dict[K, V]` / `dict[K, V]` | 値の型チェック | +| 任意のクラス | `isinstance` チェック | +| `Validator` クラス属性 | その Validator をそのまま使用 | + +`v.instance(MyType)` を使うと、辞書スキーマでも同じ `isinstance` チェックを明示的に書けます。 + +> **注意**: `Union[int, str]` や `int | str` のように、`None` 以外の複数型を持つ Union は現在サポートしていません。スキーマ変換時に `TypeError` を送出します。代わりに `Optional[T]`、単一型、または `v.instance(...)` を使用してください。 + +### スキーマ自動生成 + +#### `v.auto_infer(data, type_map=None, schema_overrides=None)` + +渡されたデータから ValidKit スキーマを **逆生成** します。既存のデータ構造からスキーマをブートストラップするのに便利です。 + +**引数:** +- `data` (Any): スキーマを推論する元データ。 +- `type_map` (dict, 省略可能): カスタム型 → バリデータのマッピング。 + - 値に `Validator` インスタンスを渡すとそのまま使用します。 + - 値に callable (値 → `Validator`) を渡すと、呼び出し結果を使用します。 + - 値に callable (値 → プリミティブ) を渡すと、変換後の値で `auto_infer` を再帰呼び出しします(**オプション自動変換**)。 +- `schema_overrides` (dict, 省略可能): フィールド名 → バリデータの補完マッピング。`data` が `dict` の場合のみ有効。指定されたフィールドは型推論をスキップします。`.optional()` もチェーン可能です。**トップレベルの dict のキーにのみ適用され、ネストした dict やリスト内の要素には適用されません。** + +**型推論のルール:** + +| 型 | 返すバリデータ | +|---|---| +| `None` | `Validator().optional()` (型不明のため optional 扱い) | +| `bool` | `BoolValidator` (int より先に評価) | +| `int` | `NumberValidator(int)` | +| `float` | `NumberValidator(float)` | +| `str` | `StringValidator` | +| `list` | `ListValidator` (最初の要素から推論; 空は `StringValidator`) | +| `dict` | ネストした dict スキーマ (再帰) | +| その他 | `type_map` で処理; なければ `TypeError` | + +**使用例:** + +```python +# 基本的な使い方 +data = {"name": "Alice", "age": 30, "active": True, "tags": ["admin"]} +schema = v.auto_infer(data) +result = validate(data, schema) # 元データでそのまま検証できる + +# None フィールドは自動で optional になる +data = {"name": "Alice", "nickname": None} +schema = v.auto_infer(data) +# -> {"name": StringValidator, "nickname": Validator().optional()} + +# type_map でカスタム型を処理 (バリデータインスタンス) +import datetime +schema = v.auto_infer( + {"created_at": datetime.date(2024, 1, 1)}, + type_map={datetime.date: v.str()}, +) + +# type_map の callable がプリミティブを返す場合 → 変換後の値で再推論 (オプション自動変換) +schema = v.auto_infer( + {"ts": datetime.datetime(2024, 6, 15, 12, 0)}, + type_map={datetime.datetime: lambda val: val.isoformat()}, # str に変換 → StringValidator +) + +# schema_overrides でフィールドを手動補完・optional 指定 +schema = v.auto_infer( + {"name": "Alice", "score": 9.5, "bio": "dev"}, + schema_overrides={ + "score": v.float().range(0.0, 10.0), + "bio": v.str().optional(), + }, +) + +# type_map と schema_overrides の併用 +schema = v.auto_infer( + {"name": "Alice", "score": 9.5, "created_at": datetime.date(2024, 1, 1)}, + type_map={datetime.date: v.str()}, + schema_overrides={"score": v.float().range(0.0, 10.0)}, +) +``` + +--- + ### 型バリデータ - `v.str()`: 文字列であることを検証。 - `v.int()`: 整数であることを検証。 @@ -51,6 +171,7 @@ sample = SCHEMA.generate_sample() - `v.list(item_schema)`: 各要素が `item_schema` に適合するリストであることを検証。 - `v.dict(key_type, value_schema)`: キーが `key_type` であり、値が `value_schema` に適合する辞書であることを検証。 - `v.oneof(choices)`: 値が `choices` リストのいずれかであることを検証。 +- `v.instance(type_cls)`: 任意クラスに対する `isinstance` チェックを行います。 ### 修飾メソッド(チェーンメソッド) すべてのバリデータで使用可能なメソッド: @@ -63,8 +184,8 @@ sample = SCHEMA.generate_sample() 型固有のメソッド: - `.regex(pattern)`: (str限定) 正規表現にマッチするか検証。 -- `.range(min, max)`: (int/float限定) 値が範囲内にあるか検証。 -- `.min(val)` / `.max(val)`: (int/float限定) 最小値または最大値を検証。 +- `.range(min, max)`: (int/float限定) 値が範囲内にあるか検証。`min <= max` が必須で、不正な境界は定義時に `ValueError` になります。 +- `.min(val)` / `.max(val)`: (int/float限定) 最小値または最大値を検証。既存の反対側境界と矛盾する値は設定できません。 --- diff --git a/src/validkit/__init__.py b/src/validkit/__init__.py index 6085a0d..d906485 100644 --- a/src/validkit/__init__.py +++ b/src/validkit/__init__.py @@ -1,5 +1,5 @@ from .v import v, InstanceValidator from .validator import validate, ValidationError, Schema, ValidationResult -__version__ = "1.2.0" +__version__ = "1.2.1" __all__ = ["v", "validate", "ValidationError", "Schema", "ValidationResult", "InstanceValidator"] diff --git a/src/validkit/v.py b/src/validkit/v.py index eef7824..8fc2bbc 100644 --- a/src/validkit/v.py +++ b/src/validkit/v.py @@ -137,15 +137,21 @@ def __init__(self, type_cls: Union[Type[int], Type[float]]) -> None: self._max: Optional[float] = None def range(self, min_val: float, max_val: float) -> "NumberValidator": + if min_val > max_val: + raise ValueError(f"Invalid range: minimum {min_val} cannot be greater than maximum {max_val}") self._min = min_val self._max = max_val return self def min(self, min_val: float) -> "NumberValidator": + if self._max is not None and min_val > self._max: + raise ValueError(f"Invalid range: minimum {min_val} cannot be greater than maximum {self._max}") self._min = min_val return self def max(self, max_val: float) -> "NumberValidator": + if self._min is not None and self._min > max_val: + raise ValueError(f"Invalid range: minimum {self._min} cannot be greater than maximum {max_val}") self._max = max_val return self @@ -301,4 +307,141 @@ def instance(self, type_cls: Type[Any]) -> InstanceValidator: """ return InstanceValidator(type_cls) + @staticmethod + def auto_infer( + data: Any, + type_map: Optional[Dict[type, Any]] = None, + schema_overrides: Optional[Dict[builtins.str, Any]] = None, + ) -> Any: + """ + 渡されたデータから ValidKit スキーマを逆生成します。 + + 各値の型を再帰的に解析し、対応するバリデータを返します。 + dict を渡すとネストしたスキーマ (dict) を返します。 + + Args: + data: スキーマを推論する元データ。 + type_map: カスタム型とバリデータのマッピング (省略可能)。 + キーに Python の型、値に以下のいずれかを指定します。組み込み型より先に + 評価されるため、組み込み型の挙動を上書きすることもできます。 + + - :class:`Validator` インスタンス: そのまま使用します。 + - ``(value) -> Validator`` 形式の呼び出し可能オブジェクト: + 値を受け取り :class:`Validator` を返す関数。 + - ``(value) -> primitive`` 形式の呼び出し可能オブジェクト: + 値をプリミティブ値 (``str``, ``int``, ``float``, ``bool``, ``list``, + ``dict``, ``None``) に変換する関数。変換後の値で ``auto_infer`` を + 再帰呼び出しします (オプション自動変換)。 + + schema_overrides: フィールドごとに推論を手動で上書きするマッピング + (省略可能)。キーにフィールド名 (文字列)、値に :class:`Validator` + インスタンスを指定します。``data`` が dict の場合のみ有効です。 + 指定されたフィールドは型推論をスキップし、指定のバリデータをそのまま + 使用します。``.optional()`` をチェーンすることでフィールドを任意にも + できます。ネストした dict やリスト内の要素には適用されません + (トップレベルの dict のキーのみ対象)。 + + Returns: + データ構造に対応する ValidKit スキーマ。 + + - ``schema_overrides`` に一致するキー → 対応するバリデータ (推論をスキップ) + - ``type_map`` に一致する型 → 対応するバリデータ (または呼び出し結果) + - ``None`` → :class:`Validator` に ``.optional()`` を付けたもの (型不明のため optional 扱い) + - ``dict`` → 各キーに対応するバリデータを含む dict スキーマ + - ``list`` → :class:`ListValidator` (要素が存在する場合は最初の要素から推論) + - ``bool`` → :class:`BoolValidator` (int より先に評価) + - ``int`` → :class:`NumberValidator` (int) + - ``float`` → :class:`NumberValidator` (float) + - ``str`` → :class:`StringValidator` + + Raises: + TypeError: ``type_map`` に一致せず、かつサポートされていない型が渡された場合。 + + Example:: + + data = {"name": "Alice", "age": 30, "active": True} + schema = v.auto_infer(data) + # -> {"name": v.str(), "age": v.int(), "active": v.bool()} + + nested = {"user": {"id": 1, "tags": ["admin"]}} + schema = v.auto_infer(nested) + # -> {"user": {"id": v.int(), "tags": v.list(v.str())}} + + # None フィールドは optional なバリデータになる + data = {"name": "Alice", "nickname": None} + schema = v.auto_infer(data) + # -> {"name": v.str(), "nickname": Validator().optional()} + + # カスタム型は type_map で対応できる (バリデータインスタンス) + import datetime + schema = v.auto_infer( + {"ts": datetime.datetime(2024, 1, 1)}, + type_map={datetime.datetime: v.str()}, + ) + # -> {"ts": StringValidator} + + # type_map の callable がプリミティブを返す場合は再帰推論 (オプション自動変換) + schema = v.auto_infer( + {"ts": datetime.datetime(2024, 1, 1)}, + type_map={datetime.datetime: lambda val: val.isoformat()}, + ) + # isoformat() -> str -> auto_infer("2024-01-01T00:00:00") -> StringValidator + + # schema_overrides でフィールドを手動補完 (optional 指定も可能) + schema = v.auto_infer( + {"name": "Alice", "score": 9.5, "note": ""}, + schema_overrides={ + "score": v.float().range(0.0, 10.0), + "note": v.str().optional(), + }, + ) + # -> {"name": StringValidator, "score": NumberValidator(float, 0..10), + # "note": StringValidator(optional)} + """ + # schema_overrides only applies at the dict level; handled inside dict branch below + # User-supplied type_map is checked first so custom types (and overrides) are handled + if type_map: + for custom_type, handler in type_map.items(): + if isinstance(data, custom_type): + if callable(handler) and not isinstance(handler, Validator): + result = handler(data) + # If the callable returned a Validator, use it directly. + # Otherwise treat the result as a converted primitive and re-infer. + # Do not propagate schema_overrides: the converted value is not the + # original dict, so top-level overrides must not bleed into it. + if isinstance(result, Validator): + return result + return VBuilder.auto_infer(result, type_map, schema_overrides=None) + return handler + # None: type cannot be inferred, mark as optional with no type constraint + if data is None: + return Validator().optional() + # bool must be checked before int because bool is a subclass of int + if isinstance(data, bool): + return BoolValidator() + if isinstance(data, int): + return NumberValidator(int) + if isinstance(data, float): + return NumberValidator(float) + if isinstance(data, str): + return StringValidator() + if isinstance(data, list): + item_schema: Union[Validator, Dict[str, Any]] = ( + VBuilder.auto_infer(data[0], type_map) if data else StringValidator() + ) + return ListValidator(item_schema) + if isinstance(data, dict): + result_schema: Dict[str, Any] = {} + for key, value in data.items(): + if schema_overrides and key in schema_overrides: + result_schema[key] = schema_overrides[key] + else: + result_schema[key] = VBuilder.auto_infer(value, type_map) + return result_schema + raise TypeError( + f"auto_infer: unsupported type '{type(data).__name__}'. " + "Pass a type_map to handle custom types, or use one of the built-in " + "supported types: None, bool, int, float, str, list, dict." + ) + v = VBuilder() diff --git a/src/validkit/validator.py b/src/validkit/validator.py index 333743a..b787873 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -14,6 +14,7 @@ cast, ) import types as _types +import math from .v import ( Validator, v, @@ -23,7 +24,6 @@ BoolValidator, ListValidator, DictValidator, - OneOfValidator, ) T = TypeVar("T") @@ -95,7 +95,7 @@ class UserDict(TypedDict): より豊かなスキーマ定義が可能になります。 """ - def __init__(self, schema: Dict[str, Any]) -> None: + def __init__(self, schema: Any) -> None: self._schema = schema def generate_sample(self) -> Dict[str, Any]: @@ -106,7 +106,8 @@ def generate_sample(self) -> Dict[str, Any]: 1. `.default(value)` が設定されている場合 → そのデフォルト値 2. `.examples([...])` が設定されている場合 → リストの最初の要素 - 3. どちらも設定されていない場合 → 型に応じたダミー値 (str: "example", int: 0 など) + 3. どちらも設定されていない場合 → 型に応じたダミー値 + (`range()` / `min()` / `max()` がある数値は制約内の代表値を優先) ネストされた辞書スキーマやリストスキーマも再帰的に処理されます。 @@ -311,7 +312,7 @@ def validate_internal( errors: Optional[List[ErrorDetail]] = None ) -> Any: # 1. Shorthand types - if isinstance(schema, type) and schema in (str, int, float, bool): + if isinstance(schema, type) and schema in _BASIC_TYPES: if schema is str: schema = v.str() elif schema is int: @@ -430,6 +431,80 @@ def validate_internal( bool: False, } + +def _generate_number_sample(schema: Any) -> Union[int, float]: + """NumberValidator の制約内に収まる代表値を返します。""" + zero: Union[int, float] = 0 if schema._type_cls is int else 0.0 + lower: Optional[Union[int, float]] + upper: Optional[Union[int, float]] + + if schema._type_cls is int: + lower = math.ceil(schema._min) if schema._min is not None else None + upper = math.floor(schema._max) if schema._max is not None else None + else: + lower = float(schema._min) if schema._min is not None else None + upper = float(schema._max) if schema._max is not None else None + + if lower is not None and zero < lower: + return lower + if upper is not None and zero > upper: + return upper + return zero + + +def _validate_generated_value(schema: Validator, candidate: Any) -> Any: + """生成した候補値を Validator 自身で再検証し、必要なら変換後の値を返します。""" + try: + return schema.validate(candidate, data={}) + except (TypeError, ValueError) as exc: + raise ValueError( + "generate_sample() could not produce a valid sample for this validator. " + "Provide a .default(...) or .examples([...]) value that satisfies all constraints." + ) from exc + + +def _generate_validator_sample(schema: Validator) -> Any: + """Validator 1件分の候補値を生成し、制約を満たすことを保証します。""" + if isinstance(schema, InstanceValidator): + if schema._has_default: + return _validate_generated_value(schema, schema._default_value) + if schema._examples: + return _validate_generated_value(schema, schema._examples[0]) + # 任意クラスの妥当なダミー値は一般に構成できないため、従来どおり None を返す。 + return None + + if schema._has_default: + return _validate_generated_value(schema, schema._default_value) + if schema._examples: + return _validate_generated_value(schema, schema._examples[0]) + + from .v import ( + StringValidator, + NumberValidator, + BoolValidator, + ListValidator, + DictValidator, + OneOfValidator, + ) + + if isinstance(schema, StringValidator): + candidate: Any = "example" + elif isinstance(schema, NumberValidator): + candidate = _generate_number_sample(schema) + elif isinstance(schema, BoolValidator): + candidate = False + elif isinstance(schema, OneOfValidator): + candidate = schema._choices[0] if schema._choices else None + elif isinstance(schema, ListValidator): + candidate = [_generate_sample(schema._item_validator)] + elif isinstance(schema, DictValidator): + candidate = {"key": _generate_sample(schema._value_validator)} + else: + candidate = None + + return _validate_generated_value(schema, candidate) + + def _generate_sample(schema: Any) -> Any: """ スキーマ定義を再帰的に走査し、サンプルデータを生成します。 @@ -451,29 +526,7 @@ def _generate_sample(schema: Any) -> Any: # 2. Validator オブジェクト if isinstance(schema, Validator): - # 優先順位: default > examples の先頭 > 型ダミー - if schema._has_default: - return schema._default_value - if schema._examples: - return schema._examples[0] - # 型ダミーを型名から推定 - if isinstance(schema, StringValidator): - return "example" - if isinstance(schema, NumberValidator): - return 0 if schema._type_cls is int else 0.0 - if isinstance(schema, BoolValidator): - return False - if isinstance(schema, OneOfValidator): - return schema._choices[0] if schema._choices else None - if isinstance(schema, ListValidator): - inner = _generate_sample(schema._item_validator) - return [inner] - if isinstance(schema, DictValidator): - inner = _generate_sample(schema._value_validator) - return {"key": inner} - if isinstance(schema, InstanceValidator): - return None - return None + return _generate_validator_sample(schema) # 3. dict スキーマ → 再帰的に走査 if isinstance(schema, dict): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d18df5b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +import os +import sys + + +ROOT_DIR = os.path.dirname(os.path.dirname(__file__)) +SRC_DIR = os.path.join(ROOT_DIR, "src") + + +if SRC_DIR in sys.path: + sys.path.remove(SRC_DIR) +sys.path.insert(0, SRC_DIR) + + +loaded_validkit = sys.modules.get("validkit") +if loaded_validkit is not None: + loaded_from = getattr(loaded_validkit, "__file__", "") or "" + expected_prefix = os.path.join(SRC_DIR, "validkit") + if not os.path.abspath(loaded_from).startswith(os.path.abspath(expected_prefix)): + for module_name in list(sys.modules): + if module_name == "validkit" or module_name.startswith("validkit."): + sys.modules.pop(module_name, None) + diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py index 065011c..ff9aa40 100644 --- a/tests/test_class_schema.py +++ b/tests/test_class_schema.py @@ -508,16 +508,55 @@ class Profile: assert len(result.errors) == 2 def test_class_schema_with_schema_wrapper(self): - """Schema() ラッパーはクラス記法スキーマを直接受け取れる(dict変換後に渡す)""" + """Schema() ラッパーはクラス記法スキーマを実行時にそのままラップして使える""" class Profile: name: str age: int - from validkit.validator import _class_to_schema - schema = Schema(_class_to_schema(Profile)) + schema = Schema(Profile) result = validate({"name": "Alice", "age": 30}, schema) assert result == {"name": "Alice", "age": 30} + def test_schema_wrapper_can_wrap_class_schema_directly_at_runtime(self): + """Schema() は実行時にはクラス記法スキーマもそのままラップして検証できる""" + class Profile: + name: str + age: int + + schema = Schema(Profile) + result = validate({"name": "Alice", "age": 30}, schema) + assert result == {"name": "Alice", "age": 30} + + +class TestBasicTypeConstantConsistency: + def test_validate_internal_does_not_duplicate_basic_type_tuple(self): + """validate_internal() は基本型判定に _BASIC_TYPES を使い、型タプルを重複定義しない""" + import ast + import pathlib + import validkit.validator as vmod + + source = pathlib.Path(vmod.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + + duplicated_tuple_nodes = [] + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef) or node.name != "validate_internal": + continue + for child in ast.walk(node): + if not isinstance(child, ast.Tuple): + continue + values = [] + for elt in child.elts: + if isinstance(elt, ast.Name): + values.append(elt.id) + if values == ["str", "int", "float", "bool"]: + duplicated_tuple_nodes.append(ast.unparse(child)) + + assert not duplicated_tuple_nodes, ( + "validate_internal() duplicates the basic type tuple instead of reusing " + f"_BASIC_TYPES: {duplicated_tuple_nodes}" + ) + # --------------------------------------------------------------------------- # generate_sample との組み合わせ diff --git a/tests/test_coercion.py b/tests/test_coercion.py index 836d0e7..c471e6a 100644 --- a/tests/test_coercion.py +++ b/tests/test_coercion.py @@ -1,12 +1,8 @@ import pytest -import sys -import os - -# Add src to path -sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), "src")) from validkit import v, validate, ValidationError + def test_string_coercion(): schema = {"val": v.str().coerce()} diff --git a/tests/test_features_v120.py b/tests/test_features_v120.py index 478f30d..ff849d0 100644 --- a/tests/test_features_v120.py +++ b/tests/test_features_v120.py @@ -8,11 +8,8 @@ - 後方互換性の保証 """ -import sys -import os import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) from validkit import v, Schema, validate, ValidationError @@ -210,3 +207,472 @@ def test_generate_sample_does_not_modify_schema(self): sample1 = schema.generate_sample() sample2 = schema.generate_sample() assert sample1 == sample2 + + def test_number_range_uses_lower_bound_when_zero_is_out_of_range(self): + """int の range 制約がある場合、0 ではなく範囲内の代表値を返す""" + schema = Schema({"level": v.int().range(1, 100)}) + sample = schema.generate_sample() + assert sample["level"] == 1 + assert validate(sample, schema)["level"] == 1 + + def test_number_max_uses_upper_bound_when_zero_is_too_large(self): + """max 制約だけの負数レンジでも、制約内の値を返す""" + schema = Schema({"offset": v.int().max(-1)}) + sample = schema.generate_sample() + assert sample["offset"] == -1 + assert validate(sample, schema)["offset"] == -1 + + def test_float_range_uses_lower_bound_when_zero_is_out_of_range(self): + """float の range 制約でも代表値が制約内になる""" + schema = Schema({"ratio": v.float().range(0.5, 1.5)}) + sample = schema.generate_sample() + assert sample["ratio"] == 0.5 + assert validate(sample, schema)["ratio"] == 0.5 + + def test_generate_sample_uses_example_that_matches_regex(self): + """regex 制約つきでも examples の先頭が妥当ならその値を返す""" + schema = Schema({"postal_code": v.str().regex(r"^\d{3}-\d{4}$").examples(["123-4567"])}) + sample = schema.generate_sample() + assert sample["postal_code"] == "123-4567" + assert validate(sample, schema)["postal_code"] == "123-4567" + + def test_generate_sample_applies_custom_transformations(self): + """custom が変換を返す場合、generate_sample も変換後の値を返す""" + schema = Schema({ + "code": v.str().custom(lambda value: value.strip()).custom(lambda value: value.upper()) + }) + sample = schema.generate_sample() + assert sample["code"] == "EXAMPLE" + assert validate(sample, schema)["code"] == "EXAMPLE" + + def test_generate_sample_raises_when_regex_cannot_be_satisfied(self): + """default/examples がなく regex を満たす候補を作れない場合は ValueError を出す""" + schema = Schema({"postal_code": v.str().regex(r"^\d{3}-\d{4}$")}) + with pytest.raises(ValueError, match=r"generate_sample\(\) could not produce a valid sample"): + schema.generate_sample() + + def test_generate_sample_raises_when_default_violates_constraints(self): + """default が制約違反なら不正なサンプルを返さず ValueError を出す""" + schema = Schema({"postal_code": v.str().regex(r"^\d{3}-\d{4}$").default("example")}) + with pytest.raises(ValueError, match=r"generate_sample\(\) could not produce a valid sample"): + schema.generate_sample() + + def test_generate_sample_raises_when_custom_rejects_dummy_value(self): + """custom が型ダミー値を拒否する場合は ValueError を出す""" + schema = Schema({"code": v.str().custom(lambda value: value if value.startswith("ID-") else (_ for _ in ()).throw(ValueError("invalid code")))}) + with pytest.raises(ValueError, match=r"generate_sample\(\) could not produce a valid sample"): + schema.generate_sample() + + +# ============================================================ +# v.auto_infer() のテスト +# ============================================================ + +class TestAutoInfer: + def test_primitive_str(self): + """str 値から StringValidator が生成される""" + from validkit.v import StringValidator + schema = v.auto_infer("hello") + assert isinstance(schema, StringValidator) + + def test_primitive_int(self): + """int 値から NumberValidator(int) が生成される""" + from validkit.v import NumberValidator + schema = v.auto_infer(42) + assert isinstance(schema, NumberValidator) + assert schema._type_cls is int + + def test_primitive_float(self): + """float 値から NumberValidator(float) が生成される""" + from validkit.v import NumberValidator + schema = v.auto_infer(3.14) + assert isinstance(schema, NumberValidator) + assert schema._type_cls is float + + def test_primitive_bool(self): + """bool 値から BoolValidator が生成される (int の前に評価される)""" + from validkit.v import BoolValidator + schema = v.auto_infer(True) + assert isinstance(schema, BoolValidator) + + def test_bool_not_confused_with_int(self): + """bool は int のサブクラスだが、NumberValidator ではなく BoolValidator が返る""" + from validkit.v import BoolValidator, NumberValidator + schema_true = v.auto_infer(True) + schema_false = v.auto_infer(False) + assert isinstance(schema_true, BoolValidator) + assert isinstance(schema_false, BoolValidator) + # False (== 0) が int と誤認されないことを確認 + assert not isinstance(schema_false, NumberValidator) + + def test_list_with_str_elements(self): + """str 要素のリストから v.list(v.str()) が生成される""" + from validkit.v import ListValidator, StringValidator + schema = v.auto_infer(["a", "b", "c"]) + assert isinstance(schema, ListValidator) + assert isinstance(schema._item_validator, StringValidator) + + def test_list_with_int_elements(self): + """int 要素のリストから v.list(v.int()) が生成される""" + from validkit.v import ListValidator, NumberValidator + schema = v.auto_infer([1, 2, 3]) + assert isinstance(schema, ListValidator) + assert isinstance(schema._item_validator, NumberValidator) + assert schema._item_validator._type_cls is int + + def test_empty_list_defaults_to_str(self): + """空リストは v.list(v.str()) にデフォルトされる""" + from validkit.v import ListValidator, StringValidator + schema = v.auto_infer([]) + assert isinstance(schema, ListValidator) + assert isinstance(schema._item_validator, StringValidator) + + def test_flat_dict(self): + """フラットな dict から対応するバリデータを持つ dict スキーマが生成される""" + from validkit.v import StringValidator, NumberValidator, BoolValidator + data = {"name": "Alice", "age": 30, "active": True} + schema = v.auto_infer(data) + assert isinstance(schema, dict) + assert isinstance(schema["name"], StringValidator) + assert isinstance(schema["age"], NumberValidator) + assert schema["age"]._type_cls is int + assert isinstance(schema["active"], BoolValidator) + + def test_nested_dict(self): + """ネストした dict は再帰的にスキーマ化される""" + from validkit.v import StringValidator, NumberValidator + data = {"user": {"id": 1, "name": "Bob"}} + schema = v.auto_infer(data) + assert isinstance(schema, dict) + assert isinstance(schema["user"], dict) + assert isinstance(schema["user"]["id"], NumberValidator) + assert isinstance(schema["user"]["name"], StringValidator) + + def test_dict_with_list_value(self): + """dict 内の list フィールドも正しく推論される""" + from validkit.v import ListValidator, StringValidator + data = {"tags": ["python", "java"]} + schema = v.auto_infer(data) + assert isinstance(schema, dict) + assert isinstance(schema["tags"], ListValidator) + assert isinstance(schema["tags"]._item_validator, StringValidator) + + def test_inferred_schema_can_validate_original_data(self): + """auto_infer で生成したスキーマで元データをバリデーションできる""" + data = {"name": "Alice", "age": 30, "score": 9.5, "active": True} + schema = v.auto_infer(data) + result = validate(data, schema) + assert result["name"] == "Alice" + assert result["age"] == 30 + assert result["score"] == 9.5 + assert result["active"] is True + + def test_inferred_nested_schema_can_validate_original_data(self): + """ネスト付き auto_infer スキーマで元データをバリデーションできる""" + data = {"user": {"id": 1, "tags": ["admin", "editor"]}} + schema = v.auto_infer(data) + result = validate(data, schema) + assert result["user"]["id"] == 1 + assert result["user"]["tags"] == ["admin", "editor"] + + def test_none_returns_optional_validator(self): + """None 値から optional な基底 Validator が生成される""" + from validkit.v import Validator + schema = v.auto_infer(None) + assert isinstance(schema, Validator) + assert schema._optional is True + + def test_dict_with_none_field_is_optional(self): + """dict 内の None フィールドは optional なバリデータになる""" + from validkit.v import Validator + data = {"name": "Alice", "nickname": None} + schema = v.auto_infer(data) + assert isinstance(schema["nickname"], Validator) + assert schema["nickname"]._optional is True + + def test_dict_with_none_field_validates_with_none(self): + """auto_infer した None フィールドは None 値でバリデーションが通る""" + data = {"name": "Alice", "nickname": None} + schema = v.auto_infer(data) + result = validate(data, schema) + assert result["name"] == "Alice" + assert result.get("nickname") is None + + def test_unsupported_type_raises_type_error(self): + """サポートされていない型 (カスタムクラスなど) は TypeError を送出する""" + import datetime + with pytest.raises(TypeError, match="auto_infer: unsupported type"): + v.auto_infer(datetime.datetime.now()) + + def test_dict_with_unsupported_type_raises_type_error(self): + """dict 内にサポートされていない型が含まれると TypeError を送出する""" + import datetime + data = {"name": "Alice", "created_at": datetime.date(2024, 1, 1)} + with pytest.raises(TypeError, match="auto_infer: unsupported type"): + v.auto_infer(data) + + def test_type_map_with_validator_instance(self): + """type_map にバリデータインスタンスを渡すとカスタム型を処理できる""" + import datetime + from validkit.v import StringValidator + schema = v.auto_infer( + datetime.datetime(2024, 1, 1), + type_map={datetime.datetime: v.str()}, + ) + assert isinstance(schema, StringValidator) + + def test_type_map_with_callable(self): + """type_map に呼び出し可能オブジェクトを渡すと値を受けて Validator を返せる""" + import datetime + from validkit.v import StringValidator + dt = datetime.datetime(2024, 6, 15, 12, 0, 0) + schema = v.auto_infer( + dt, + type_map={datetime.datetime: lambda val: v.str().description(str(val))}, + ) + assert isinstance(schema, StringValidator) + assert schema._description == str(dt) + + def test_type_map_in_dict_field(self): + """dict 内のカスタム型フィールドも type_map で処理される""" + import datetime + from validkit.v import StringValidator + data = {"name": "Alice", "created_at": datetime.date(2024, 1, 1)} + schema = v.auto_infer(data, type_map={datetime.date: v.str()}) + assert isinstance(schema["name"], StringValidator) + assert isinstance(schema["created_at"], StringValidator) + + def test_type_map_in_list_element(self): + """list 内のカスタム型も type_map で処理される""" + import datetime + from validkit.v import ListValidator, StringValidator + data = [datetime.date(2024, 1, 1), datetime.date(2024, 2, 1)] + schema = v.auto_infer(data, type_map={datetime.date: v.str()}) + assert isinstance(schema, ListValidator) + assert isinstance(schema._item_validator, StringValidator) + + def test_type_map_with_custom_class(self): + """ユーザー定義のカスタムクラスも type_map で処理できる""" + from validkit.v import StringValidator + + class MyModel: + def __init__(self, name: str) -> None: + self.name = name + + schema = v.auto_infer(MyModel("test"), type_map={MyModel: v.str()}) + assert isinstance(schema, StringValidator) + + def test_type_map_without_match_still_raises_type_error(self): + """type_map に対象型がなければ依然として TypeError が送出される""" + import datetime + + class Unrelated: + pass + + with pytest.raises(TypeError, match="auto_infer: unsupported type"): + v.auto_infer( + Unrelated(), + type_map={datetime.date: v.str()}, # Unrelated は含まれない + ) + + # ---- type_map 自動変換 (callable がプリミティブを返す) --- + + def test_type_map_callable_returning_str_re_infers(self): + """type_map の callable が str を返す場合 → StringValidator として再推論される""" + import datetime + from validkit.v import StringValidator + schema = v.auto_infer( + datetime.date(2024, 1, 1), + type_map={datetime.date: lambda val: val.isoformat()}, + ) + assert isinstance(schema, StringValidator) + + def test_type_map_callable_returning_int_re_infers(self): + """type_map の callable が int を返す場合 → NumberValidator(int) として再推論される""" + import datetime + from validkit.v import NumberValidator + schema = v.auto_infer( + datetime.date(2024, 1, 1), + type_map={datetime.date: lambda val: val.toordinal()}, + ) + assert isinstance(schema, NumberValidator) + assert schema._type_cls is int + + def test_type_map_callable_returning_dict_re_infers(self): + """type_map の callable が dict を返す場合 → ネスト dict スキーマとして再推論される""" + import datetime + from validkit.v import NumberValidator + schema = v.auto_infer( + datetime.date(2024, 6, 15), + type_map={ + datetime.date: lambda val: {"year": val.year, "month": val.month, "day": val.day} + }, + ) + assert isinstance(schema, dict) + assert isinstance(schema["year"], NumberValidator) + assert isinstance(schema["month"], NumberValidator) + assert isinstance(schema["day"], NumberValidator) + + def test_type_map_callable_returning_validator_used_directly(self): + """type_map の callable が Validator を返す場合はそのまま使用される (再推論なし)""" + import datetime + from validkit.v import StringValidator + dt = datetime.datetime(2024, 6, 15, 12, 0, 0) + schema = v.auto_infer( + dt, + type_map={datetime.datetime: lambda val: v.str().description(str(val))}, + ) + assert isinstance(schema, StringValidator) + assert schema._description == str(dt) + + def test_type_map_auto_convert_in_dict_field(self): + """dict フィールドの auto-convert callable も正しく re-infer される""" + import datetime + from validkit.v import StringValidator + data = {"name": "Alice", "created_at": datetime.date(2024, 1, 1)} + schema = v.auto_infer( + data, + type_map={datetime.date: lambda val: val.isoformat()}, + ) + assert isinstance(schema["name"], StringValidator) + assert isinstance(schema["created_at"], StringValidator) + + # ---- schema_overrides ----------------------------------------------- + + def test_schema_overrides_replaces_inferred_field(self): + """schema_overrides で指定したフィールドは推論をスキップして指定バリデータを使う""" + from validkit.v import NumberValidator + data = {"name": "Alice", "score": 9.5} + schema = v.auto_infer( + data, + schema_overrides={"score": v.float().range(0.0, 10.0)}, + ) + assert isinstance(schema["score"], NumberValidator) + assert schema["score"]._min == 0.0 + assert schema["score"]._max == 10.0 + + def test_schema_overrides_optional_field(self): + """schema_overrides で .optional() を付けると optional フィールドになる""" + from validkit.v import StringValidator + data = {"name": "Alice", "bio": "some text"} + schema = v.auto_infer( + data, + schema_overrides={"bio": v.str().optional()}, + ) + assert isinstance(schema["bio"], StringValidator) + assert schema["bio"]._optional is True + + def test_schema_overrides_handles_unsupported_type_field(self): + """schema_overrides があれば推論できない型のフィールドもエラーにならない""" + import datetime + from validkit.v import StringValidator + data = {"name": "Alice", "created_at": datetime.date(2024, 1, 1)} + schema = v.auto_infer( + data, + schema_overrides={"created_at": v.str()}, + ) + assert isinstance(schema["name"], StringValidator) + assert isinstance(schema["created_at"], StringValidator) + + def test_schema_overrides_does_not_affect_non_dict_data(self): + """schema_overrides は dict 以外のデータに渡しても影響しない (str を推論する)""" + from validkit.v import StringValidator + schema = v.auto_infer("hello", schema_overrides={"hello": v.int()}) + assert isinstance(schema, StringValidator) + + def test_schema_overrides_unmentioned_fields_still_inferred(self): + """schema_overrides に含まれないフィールドは通常どおり推論される""" + from validkit.v import NumberValidator + data = {"name": "Alice", "age": 30} + schema = v.auto_infer( + data, + schema_overrides={"name": v.str().description("表示名")}, + ) + assert schema["name"]._description == "表示名" + assert isinstance(schema["age"], NumberValidator) + + def test_schema_overrides_combined_with_type_map(self): + """schema_overrides と type_map を同時に使うと両方有効になる""" + import datetime + from validkit.v import StringValidator, NumberValidator + data = { + "name": "Alice", + "score": 9.5, + "created_at": datetime.date(2024, 1, 1), + } + schema = v.auto_infer( + data, + type_map={datetime.date: v.str()}, + schema_overrides={"score": v.float().range(0.0, 10.0)}, + ) + assert isinstance(schema["name"], StringValidator) + assert isinstance(schema["score"], NumberValidator) + assert schema["score"]._min == 0.0 + assert isinstance(schema["created_at"], StringValidator) + + def test_schema_overrides_round_trip_validation(self): + """schema_overrides を含むスキーマで元データをバリデーションできる""" + data = {"name": "Alice", "score": 8.5, "bio": "developer"} + schema = v.auto_infer( + data, + schema_overrides={ + "score": v.float().range(0.0, 10.0), + "bio": v.str().optional(), + }, + ) + result = validate(data, schema) + assert result["name"] == "Alice" + assert result["score"] == 8.5 + assert result["bio"] == "developer" + + def test_schema_overrides_does_not_leak_into_nested_dict(self): + """schema_overrides はトップレベルの dict にのみ適用され、ネストした dict には適用されない""" + from validkit.v import StringValidator + data = {"name": "Alice", "user": {"name": "Bob", "age": 25}} + schema = v.auto_infer( + data, + schema_overrides={"name": v.str().description("top-level override")}, + ) + # Top-level override is applied + assert schema["name"]._description == "top-level override" + # Nested dict is NOT affected by the override + assert isinstance(schema["user"]["name"], StringValidator) + assert schema["user"]["name"]._description is None + + def test_schema_overrides_does_not_leak_into_list_items(self): + """schema_overrides はリスト内の dict 要素には適用されない""" + from validkit.v import StringValidator, ListValidator + data = {"items": [{"name": "foo", "val": 1}]} + schema = v.auto_infer( + data, + schema_overrides={"name": v.str().description("top-level override")}, + ) + assert isinstance(schema["items"], ListValidator) + item_validator = schema["items"]._item_validator + assert isinstance(item_validator["name"], StringValidator) + # List item's name field should NOT carry the top-level override + assert item_validator["name"]._description is None + + def test_type_map_callable_returning_dict_does_not_apply_schema_overrides(self): + """type_map の callable が dict を返して再推論するとき、schema_overrides は適用されない""" + from validkit.v import NumberValidator + + class SpecialDate: + def __init__(self, y, m, d): + self.y, self.m, self.d = y, m, d + + # Top-level value is the custom type; callable returns a dict. + # schema_overrides has "year" which MUST NOT bleed into the re-inferred dict. + schema = v.auto_infer( + SpecialDate(2024, 1, 15), + type_map={ + SpecialDate: lambda val: {"year": val.y, "month": val.m, "day": val.d}, + }, + schema_overrides={"year": v.str().description("should-not-apply")}, + ) + assert isinstance(schema, dict), "converted dict should produce a nested dict schema" + # "year" must be inferred from the int value, NOT overridden by schema_overrides + assert isinstance(schema["year"], NumberValidator), ( + "year inside the converted dict must be NumberValidator, not the schema_overrides value" + ) + assert schema["year"]._description is None diff --git a/tests/test_validkit.py b/tests/test_validkit.py index 8873ea9..e60458f 100644 --- a/tests/test_validkit.py +++ b/tests/test_validkit.py @@ -1,10 +1,6 @@ -import pytest -import sys -import os from typing import TypedDict -# Add src to path -sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src")) +import pytest from validkit import v, validate, ValidationError, Schema, ValidationResult @@ -12,7 +8,7 @@ def test_basic_types(): assert validate("hello", v.str()) == "hello" assert validate(123, v.int()) == 123 assert validate(1.23, v.float()) == 1.23 - assert validate(True, v.bool()) == True + assert validate(True, v.bool()) is True with pytest.raises(ValidationError): validate(123, v.str()) @@ -33,6 +29,31 @@ def test_number_range(): with pytest.raises(ValidationError): validate(21, validator) +def test_number_range_allows_equal_bounds(): + validator = v.int().range(10, 10) + assert validate(10, validator) == 10 + with pytest.raises(ValidationError): + validate(9, validator) + + +def test_number_range_rejects_inverted_bounds(): + with pytest.raises(ValueError, match=r"minimum 111 cannot be greater than maximum 100"): + v.int().range(111, 100) + + +def test_number_min_max_rejects_inverted_bounds_in_any_order(): + with pytest.raises(ValueError, match=r"minimum 111 cannot be greater than maximum 100"): + v.int().min(111).max(100) + + with pytest.raises(ValueError, match=r"minimum 111 cannot be greater than maximum 100"): + v.int().max(100).min(111) + + +def test_float_range_rejects_inverted_bounds(): + with pytest.raises(ValueError, match=r"minimum 1.5 cannot be greater than maximum 1.0"): + v.float().range(1.5, 1.0) + + def test_list_validation(): validator = v.list(v.int()) assert validate([1, 2, 3], validator) == [1, 2, 3] From d245ce0954213396ff9a66f78e6090be8044f81f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:04:42 +0000 Subject: [PATCH 12/16] Merge latest main docs updates and re-sync class-schema documentation Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- CHANGELOG.md | 38 +++++++----- README.md | 127 +++++++++++++++++++++++++++++++------- src/validkit/v.py | 1 - src/validkit/validator.py | 1 - 4 files changed, 125 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b2123..9663a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,39 +1,45 @@ # Changelog -このファイルは `git tag` とコミット履歴をもとに、バージョンごとの変更点を再構成したものです。 +このファイルは `git tag`・コミット履歴・バージョン更新コミットをもとに再構成した変更履歴です。 -- タグが存在する版はタグを基準に整理しています。 -- `1.2.0` は現時点で `git tag` が見当たらないため、`__version__ = "1.2.0"` とリリースコミット(2026-02-27)を基準に整理しています。 +- `v1.0.0` 〜 `v1.2.0` はリポジトリ内のタグを基準に整理しています。 +- `1.2.1` は `src/validkit/__init__.py` の `__version__ = "1.2.1"` と 2026-03-07 の関連コミットを基準に整理しています(現時点で対応タグは未確認)。 +- 変更点は読みやすさのために `Added` / `Changed` / `Fixed` に要約しています。 ## Unreleased ### Added - クラス記法スキーマ (`class Config: ...`) を追加し、型アノテーションと `Validator` クラス属性を既存の dict スキーマ検証経路へ変換できるようになりました。 - `v.instance(type_cls)` / `InstanceValidator` を追加し、任意クラスに対する `isinstance` ベースの検証と `.coerce()` をサポートしました。 -- `v.auto_infer(data, type_map=None, schema_overrides=None)` を追加し、既存データから ValidKit スキーマを逆生成できるようになりました。 -- `type_map` によるカスタム型対応を追加しました。callable が `Validator` を返す場合はそのまま使用し、プリミティブ値を返す場合は変換後の値で再推論します。 -- `schema_overrides` を追加し、トップレベルの dict フィールドに対して推論結果を明示的なバリデータで上書きできるようになりました。 + +### Changed +- `Schema(...)` は実行時に dict スキーマだけでなく class 記法スキーマも直接ラップできるようになりました。 +- README / `docs/index.md` / 回帰テストをクラス記法スキーマと Python 3.9+ 型ヒント対応に合わせて更新しました。 ### Fixed - クラス記法スキーマで `Optional[T]` / `Union[T, None]` が必須扱いになっていた問題を修正しました。 - `typing.Union` / PEP 604 (`T | None`) のうち、`None` 以外の複数メンバーを持つ型がサイレントにパススルーされる問題を修正し、明示的に `TypeError` を送出するようにしました。 - Python 3.9 で `types.UnionType` や `_UnionType: type | None` に起因する import / 実行時エラーが発生しないよう互換性を改善しました。 - `InstanceValidator.coerce()` が元例外を失っていた問題を修正し、例外チェーンを保持するようにしました。 + +## [1.2.1] - 2026-03-07 + +### Added +- `v.auto_infer(data, type_map=None, schema_overrides=None)` を追加し、既存データから ValidKit スキーマを逆生成できるようになりました。 +- `type_map` によるカスタム型対応を追加しました。callable が `Validator` を返す場合はそのまま使い、プリミティブ値を返す場合は変換後の値で再推論できます。 +- `schema_overrides` により、トップレベルの dict フィールドを明示的なバリデータで上書きできるようになりました。 + +### Changed +- `v.auto_infer()` の型ヒントと回帰テストを拡充し、mypy / IDE で扱いやすい API に整理しました。 +- ドキュメントとサンプルを `auto_infer()`・`schema_overrides`・`generate_sample()` の現在仕様に合わせて更新しました。 + +### Fixed - `schema_overrides` がネストした dict やリスト要素に漏れて適用される問題を修正しました。 - `type_map` の callable による再推論時に `schema_overrides` が意図せず伝播する問題を修正しました。 -- `NumberValidator.range()` で `min > max` の不正な境界を定義時に `ValueError` として拒否し、`.min()` / `.max()` の組み合わせでも矛盾を防ぐようにしました。 +- `NumberValidator.range()` で `min > max` の不正な境界を定義時に `ValueError` として拒否し、`.min()` / `.max()` との組み合わせでも矛盾を防ぐようにしました。 - `Schema.generate_sample()` が生成候補を各バリデータで再検証するようになり、`regex()` や `custom()` を満たせない不正なサンプルを返さず `ValueError` を送出するようになりました。 -### Changed -- `Schema(...)` は実行時に dict スキーマだけでなく class 記法スキーマも直接ラップできるようになりました。 -- README / `docs/index.md` / 回帰テストをクラス記法スキーマと Python 3.9+ 型ヒント対応に合わせて更新しました。 -- `v.auto_infer()` の型ヒントと回帰テストを拡充し、mypy 互換性を改善しました。 -- ドキュメントとサンプルを `auto_infer()` / `schema_overrides` の仕様に合わせて更新しました。 - ## [1.2.0] - 2026-02-27 - -> 注: この版はリポジトリ内のバージョン定義とリリースコミットを基準に整理していますが、現時点では対応する `git tag` は確認できていません。 - ### Added - すべてのバリデータに `.default(value)` を追加しました。欠損キーを自動補完し、設定したフィールドは自動的に optional として扱われます。 - すべてのバリデータに `.examples(list)` を追加しました。サンプル生成やドキュメント補助に使える例を保持できます。 diff --git a/README.md b/README.md index 403c610..c8b9ff7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ ValidKit は、「直感的なスキーマ定義」と「日本語キー 複雑にネストされた設定ファイルや、Discord ボットのユーザー設定、外部 API からのレスポンスなどを、シンプルかつ堅牢に検証するために設計されました。Pydantic ほど重厚ではなく、しかし辞書ベースの柔軟性と強力なチェーンメソッドを提供します。 +最新リリースは `1.2.1` です。`Schema[T]` による IDE 補完、`.coerce()` による型変換、`.default()` / `.examples()` / `.description()`、`Schema.generate_sample()`、`v.auto_infer()` に対応しています。 + --- @@ -16,13 +18,14 @@ ValidKit は、「直感的なスキーマ定義」と「日本語キー - **クラス定義不要**: 辞書そのものがスキーマになります。既存の JSON/YAML 構造をそのまま定義に落とし込めます。 - **日本語キーにフレンドリー**: `v.str()` や `v.int()` を日本語のキー名と組み合わせて、可読性の高いバリデーションを記述できます。 - **高度な検証をシンプルに**: 正規表現、数値範囲、カスタム関数、さらには「他のフィールドの値に応じた検証(条件付き検証)」も直感的に書けます。 +- **仕様書づくりにも強い**: `Schema.generate_sample()` で設定テンプレートを生成し、`v.auto_infer()` で既存データからスキーマを逆生成できます。 - **モダンな開発フロー**: SLSA v3 準拠の来歴証明(provenance)に対応し、サプライチェーンの安全性を確保しています。 --- ## 目次 -* [概要](#概要) +* [最近のアップデート](#最近のアップデート) * [特徴](#特徴) * [インストール](#インストール) * [クイックスタート](#クイックスタート) @@ -33,11 +36,29 @@ ValidKit は、「直感的なスキーマ定義」と「日本語キー * [部分更新とデフォルト値のマージ](#部分更新とデフォルト値のマージ) * [マイグレーション](#マイグレーション) * [品質管理・セキュリティ](#品質管理セキュリティ) +* [変更履歴](#変更履歴) * [貢献ガイドライン](#貢献ガイドライン) * [ライセンス](#ライセンス) --- +## 最近のアップデート + +- **v1.2.1** + - `v.auto_infer(data, type_map=None, schema_overrides=None)` を追加 + - `Schema.generate_sample()` の安全性を改善し、制約を満たせない候補は `ValueError` を返すように修正 + - `.range()` / `.min()` / `.max()` の境界矛盾を定義時に検出するよう改善 +- **v1.2.0** + - `.default()` / `.examples()` / `.description()` を追加 + - `Schema.generate_sample()` でスキーマからサンプル設定を生成可能に +- **v1.1.x** + - `Schema[T]` と `validate()` の型補完対応を強化 + - `.coerce()` による自動型変換を追加 + +詳細は [`CHANGELOG.md`](CHANGELOG.md) を参照してください。API の詳説は [`docs/index.md`](docs/index.md) にまとめています。 + +--- + ## インストール ```bash @@ -87,15 +108,16 @@ except ValidationError as e: * 📝 **直感的なチェインメソッド** — `v.int().range(1, 10).optional()` のように流れるように記述。 * 🏷️ **クラス記法対応** — Python クラスの型アノテーションをそのままスキーマとして使えます。`Optional[T]`・`List[T]`・`Dict[K,V]`・カスタム型にも対応。 * 🌏 **日本語キー対応** — 日本語のキー名をそのまま扱えるため、仕様書に近いコードが書けます。 +* 🧠 **型補完に強い** — `Schema[T]` と TypedDict を組み合わせると、IDE / 型チェッカーが戻り値の形を推論できます。 * 🔄 **強力な変換・マイグレーション** — 旧形式から新形式へのキー名変換や、値の動的変換を検証時に同時に行えます。 -* 🛠️ **デフォルト値とマージ** — 不足している値をベース設定(デフォルト値)で自動補完します。 +* 🛠️ **デフォルト値とサンプル生成** — `.default()`・`.examples()`・`.description()` と `Schema.generate_sample()` で設定テンプレート作成にも向きます。 * 🔍 **全エラーの一括収集** — 最初のエラーで止まらず、すべての不備を洗い出すことが可能です。 --- ## API 例 -詳細なリファレンスは [docs/index.md](docs/index.md) を参照してください。 +詳細なリファレンスは [`docs/index.md`](docs/index.md)、版ごとの変更点は [`CHANGELOG.md`](CHANGELOG.md) を参照してください。 ### 基本バリデータ * `v.str()`: 文字列 @@ -113,7 +135,7 @@ except ValidationError as e: * `.regex(pattern)`: 正規表現チェック * `.range(min, max)` / `.min(val)` / `.max(val)`: 範囲チェック(`min <= max` が必須。不正な境界は定義時に `ValueError`) * `.custom(func)`: 独自の変換・検証ロジックを注入 -* `.coerce()`: 入力値の型を自動的に変換(例: "123" -> 123) +* `.coerce()`: 入力値の型を自動的に変換(例: `"123" -> 123`) --- @@ -241,24 +263,16 @@ result = validate({"host": "db.local"}, Config) ```python from typing import TypedDict -from validkit import v, validate, Schema +from validkit import v, validate -# 1. TypedDict でキーと型を定義 class UserDict(TypedDict): name: str level: int -# 2. Schema[T] でスキーマをラップ -SCHEMA: Schema[UserDict] = Schema({ - "name": v.str(), - "level": v.int().range(1, 100), -}) - data = {"name": "nana_kit", "level": 50} +plain_schema = {"name": v.str(), "level": v.int()} -# 3. validate に渡すだけ — IDE が戻り値を UserDict として推論する -result = validate(data, SCHEMA) -print(result["name"]) # ← IDE が "name" / "level" を補完してくれる +result: UserDict = validate(data, plain_schema) # IDE への型ヒントは変数側で提供 ``` 注意: @@ -278,9 +292,11 @@ result: UserDict = validate(data, plain_schema) # IDE への型ヒントは変 スキーマ定義時にデフォルト値を設定しておくと、データにそのキーが含まれていない場合に自動的に補完されます。 ```python +from validkit import v, validate + SCHEMA = { "host": v.str().default("localhost"), - "port": v.int().default(5432) + "port": v.int().default(5432), } # どちらも未指定でも、デフォルト値が補完される @@ -295,10 +311,12 @@ result = validate({}, SCHEMA) 定義したスキーマから、仕様書の雛形や設定ファイルのテンプレートとして使えるサンプルデータを自動生成できます。生成された値は各バリデータで再検証されるため、`regex()` や `custom()` を満たせない不正なサンプルは返しません。 ```python +from validkit import v, Schema + SCHEMA = Schema({ "app_name": v.str().examples(["MyAwesomeApp"]), "port": v.int().default(8080).description("待機ポート"), - "debug": v.bool().default(False) + "debug": v.bool().default(False), }) # スキーマからサンプルを辞書形式で取得 @@ -309,10 +327,49 @@ sample = SCHEMA.generate_sample() 生成の優先順位: 1. `.default()` で設定された値 2. `.examples()` リストの最初の要素 -3. 各型のダミー値(`str`: "example", `int`: 0, `bool`: False 等) +3. 各型のダミー値(`str`: `"example"`, `int`: `0`, `bool`: `False` 等) `regex()` / `custom()` などで上記候補が制約を満たせない場合、`generate_sample()` は `ValueError` を送出します。その場合は妥当な `.default(...)` または `.examples([...])` を与えてください。 + +### スキーマを既存データから逆生成する (`v.auto_infer`) + +既存の設定ファイルや API レスポンスがすでにある場合、`v.auto_infer()` で ValidKit スキーマのたたき台を作れます。 + +```python +from datetime import date +from validkit import v, validate + +raw = { + "name": "Alice", + "age": 30, + "active": True, + "created_at": date(2026, 3, 1), +} + +schema = v.auto_infer( + raw, + type_map={date: lambda value: value.isoformat()}, + schema_overrides={ + "age": v.int().range(0, 150), + }, +) + +normalized = { + "name": "Alice", + "age": 30, + "active": True, + "created_at": "2026-03-01", +} + +result = validate(normalized, schema) +``` + +使いどころ: +- 既存データからスキーマをブートストラップしたいとき +- カスタム型を `type_map` でプリミティブへ落として推論したいとき +- 一部のフィールドだけ `schema_overrides` で明示的に厳しくしたいとき + ### スキーマ自動生成 (`v.auto_infer`) 既存データから ValidKit スキーマを逆生成できます。プロトタイピングや既存 JSON / dict からの移行時に便利です。 @@ -343,6 +400,12 @@ schema = v.auto_infer( 既存の辞書データを「ベース」として、入力された不完全なデータをマージする場合に便利です。これは `.default()` よりも優先されます。 ```python +from validkit import v, validate + +SCHEMA = { + "言語": v.oneof(["English", "日本語"]), + "音量": v.int(), +} DEFAULT_CONFIG = {"言語": "English", "音量": 50} user_input = {"音量": 80} @@ -356,15 +419,21 @@ updated = validate(user_input, SCHEMA, partial=True, base=DEFAULT_CONFIG) 古いバージョンの設定データを自動的に新しい形式へ変換します。 ```python +from validkit import v, validate + +SCHEMA = { + "通知": v.str(), + "timeout": v.str(), +} old_data = {"旧設定": "on", "timeout": 30} migrated = validate( - old_data, - SCHEMA, + old_data, + SCHEMA, migrate={ "旧設定": "通知", - "timeout": lambda v: f"{v}s" - } + "timeout": lambda value: f"{value}s", + }, ) ``` @@ -373,10 +442,11 @@ migrated = validate( 入力データの型が期待される型と異なる場合に、自動的に変換を試行します。 ```python -# 文字列を整数へ、数値を真偽値へ自動変換 +from validkit import v, validate + SCHEMA = { "id": v.int().coerce(), - "is_active": v.bool().coerce() + "is_active": v.bool().coerce(), } data = {"id": "1001", "is_active": 1} @@ -415,6 +485,15 @@ slsa-verifier verify-artifact dist/validkit-*.whl \ --- + +## 変更履歴 + +- 版ごとの詳細な変更点は [`CHANGELOG.md`](CHANGELOG.md) を参照してください。 +- API の詳しい説明は [`docs/index.md`](docs/index.md) にあります。 +- 現在の公開バージョンは `1.2.1` です。 + +--- + ## 貢献ガイドライン Issue の報告や Pull Request を歓迎します!詳細は [SECURITY.md](SECURITY.md) または Issue テンプレートを確認してください。 diff --git a/src/validkit/v.py b/src/validkit/v.py index 8fc2bbc..be317eb 100644 --- a/src/validkit/v.py +++ b/src/validkit/v.py @@ -306,7 +306,6 @@ def instance(self, type_cls: Type[Any]) -> InstanceValidator: validate({"ts": datetime.datetime.now()}, schema) """ return InstanceValidator(type_cls) - @staticmethod def auto_infer( data: Any, diff --git a/src/validkit/validator.py b/src/validkit/validator.py index b787873..ade8cc1 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -472,7 +472,6 @@ def _generate_validator_sample(schema: Validator) -> Any: return _validate_generated_value(schema, schema._examples[0]) # 任意クラスの妥当なダミー値は一般に構成できないため、従来どおり None を返す。 return None - if schema._has_default: return _validate_generated_value(schema, schema._default_value) if schema._examples: From 66959a22f526a3ca8502eaa58318500584db61bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:46:04 +0000 Subject: [PATCH 13/16] Resolve latest PR conflicts and preserve class-schema docs Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- README.md | 2 +- docs/index.md | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index c8b9ff7..934cb17 100644 --- a/README.md +++ b/README.md @@ -503,4 +503,4 @@ Issue の報告や Pull Request を歓迎します!詳細は [SECURITY.md](SEC ## ライセンス 本プロジェクトは **MIT ライセンス**の下で公開されています。 -詳細は [LICENSE](LICENSE) ファイルをご覧ください。 +詳細は [LICENSE](LICENSE) ファイルをご覧ください. diff --git a/docs/index.md b/docs/index.md index 280e95a..785b0c9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -88,7 +88,6 @@ class Config: `v.instance(MyType)` を使うと、辞書スキーマでも同じ `isinstance` チェックを明示的に書けます。 > **注意**: `Union[int, str]` や `int | str` のように、`None` 以外の複数型を持つ Union は現在サポートしていません。スキーマ変換時に `TypeError` を送出します。代わりに `Optional[T]`、単一型、または `v.instance(...)` を使用してください。 - ### スキーマ自動生成 #### `v.auto_infer(data, type_map=None, schema_overrides=None)` From e6796697505cc583b697e119a3109ac06a0f01bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:09:56 +0000 Subject: [PATCH 14/16] Resolve latest main conflicts and re-sync docs/runtime Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- README.md | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/README.md b/README.md index 934cb17..35e898c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ ValidKit は、「直感的なスキーマ定義」と「日本語キー * [高度な使い方](#高度な使い方) * [クラス記法によるスキーマ定義](#クラス記法によるスキーマ定義) * [IDE 補完を効かせる(TypedDict + Schema)](#ide-補完を効かせるtypeddict--schema) + * [スキーマを既存データから逆生成する(v.auto_infer)](#auto-infer) * [部分更新とデフォルト値のマージ](#部分更新とデフォルト値のマージ) * [マイグレーション](#マイグレーション) * [品質管理・セキュリティ](#品質管理セキュリティ) @@ -370,31 +371,6 @@ result = validate(normalized, schema) - カスタム型を `type_map` でプリミティブへ落として推論したいとき - 一部のフィールドだけ `schema_overrides` で明示的に厳しくしたいとき -### スキーマ自動生成 (`v.auto_infer`) - -既存データから ValidKit スキーマを逆生成できます。プロトタイピングや既存 JSON / dict からの移行時に便利です。 - -```python -import datetime -from validkit import v - -data = { - "name": "Alice", - "score": 9.5, - "created_at": datetime.date(2024, 1, 1), -} - -schema = v.auto_infer( - data, - type_map={datetime.date: v.str()}, - schema_overrides={"score": v.float().range(0.0, 10.0)}, -) -``` - -- `type_map` でカスタム型を処理できます -- `schema_overrides` でトップレベル dict の特定フィールドだけ推論結果を上書きできます -- `None` は `optional()` なバリデータとして推論されます - ### 部分更新とデフォルト値のマージ (base引数) 既存の辞書データを「ベース」として、入力された不完全なデータをマージする場合に便利です。これは `.default()` よりも優先されます。 From 21e36f68657ed2d60491460fc596f2e245ca9289 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:11:35 +0000 Subject: [PATCH 15/16] Finalize main merge and clarify unsupported union docs Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- README.md | 2 +- docs/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 35e898c..5a152a5 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ result = validate({"host": "db.local"}, Config) | `Any` / 不明な型 | チェックなし(パススルー) | | `Validator` インスタンス | ValidKit の完全なバリデーション | -> **注意**: `Union[int, str]` のように `None` を含まない非 Optional な Union 型は現在サポートされていません。`validate()` の呼び出し時(スキーマ変換フェーズ)に `TypeError` が送出されます。代わりに `Optional[T]`(= `Union[T, None]`)か具体的な単一型を使用してください。複数の型を受け付けたい場合は `v.instance(MyBaseClass)` などを検討してください。 +> **注意**: `Union[int, str]` / `Union[int, str, None]` や `int | str` / `int | str | None` のように、`None` 以外の複数型を持つ Union は現在サポートしていません。`validate()` の呼び出し時(スキーマ変換フェーズ)に `TypeError` が送出されます。代わりに `Optional[T]`(= `Union[T, None]`)か具体的な単一型を使用してください。複数の型を受け付けたい場合は `v.instance(MyBaseClass)` などを検討してください。 ### IDE 補完を効かせる(TypedDict + Schema) diff --git a/docs/index.md b/docs/index.md index 785b0c9..8fb0b30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -87,7 +87,7 @@ class Config: `v.instance(MyType)` を使うと、辞書スキーマでも同じ `isinstance` チェックを明示的に書けます。 -> **注意**: `Union[int, str]` や `int | str` のように、`None` 以外の複数型を持つ Union は現在サポートしていません。スキーマ変換時に `TypeError` を送出します。代わりに `Optional[T]`、単一型、または `v.instance(...)` を使用してください。 +> **注意**: `Union[int, str]` / `Union[int, str, None]` や `int | str` / `int | str | None` のように、`None` 以外の複数型を持つ Union は現在サポートしていません。スキーマ変換時に `TypeError` を送出します。代わりに `Optional[T]`、単一型、または `v.instance(...)` を使用してください。 ### スキーマ自動生成 #### `v.auto_infer(data, type_map=None, schema_overrides=None)` From 1197f311f9a3f7bf3177de3e5cb6f142902a847d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 02:36:19 +0000 Subject: [PATCH 16/16] Add class schema examples to example.py and improve docs/index.md Co-authored-by: harumaki4649 <83683593+harumaki4649@users.noreply.github.com> --- docs/index.md | 54 +++++++++++++-- example.py | 180 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 7 deletions(-) diff --git a/docs/index.md b/docs/index.md index 8fb0b30..29bb9b1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,16 +62,59 @@ sample = SCHEMA.generate_sample() 辞書スキーマに加えて、Python クラスの型アノテーションや `Validator` クラス属性をそのまま スキーマとして扱えます。 +#### 基本的な使い方 + ```python from typing import Dict, List, Optional -from validkit import v, validate +from validkit import v, validate, ValidationError class Config: host: str - port: int = 5432 - tags: Optional[List[str]] + port: int = 5432 # クラス属性 → デフォルト値 + tags: Optional[List[str]] # 省略可能なリスト metadata: Dict[str, int] - role = v.str().default("worker") + role = v.str().default("worker") # Validator クラス属性 + +# 必須フィールドのみ指定 — デフォルト値が自動補完される +result = validate( + {"host": "db.local", "metadata": {"connections": 10}}, + Config, +) +# -> {"host": "db.local", "port": 5432, "metadata": {"connections": 10}, "role": "worker"} +# tags は Optional なので省略可能 + +# 型が合わない場合は ValidationError +try: + validate({"host": 123, "metadata": {}}, Config) +except ValidationError as e: + print(e.path, e.message) # "host" / "Expected str, got int" +``` + +#### カスタム型 (オリジナルクラス) + +```python +from validkit import v, validate + +class Timezone: + def __init__(self, name: str) -> None: + self.name = name + +UTC = Timezone("UTC") + +class ServerConfig: + name: str + timezone: Timezone # → isinstance(value, Timezone) で検証 + +result = validate({"name": "server1", "timezone": UTC}, ServerConfig) +# -> {"name": "server1", "timezone": } + +# 辞書スキーマで同じことをするには v.instance() を使う +schema = { + "name": v.str(), + "timezone": v.instance(Timezone).default(UTC), +} +result = validate({"name": "server1"}, schema) +# -> {"name": "server1", "timezone": } (デフォルト補完) ``` 対応する型ヒント: @@ -88,6 +131,9 @@ class Config: `v.instance(MyType)` を使うと、辞書スキーマでも同じ `isinstance` チェックを明示的に書けます。 > **注意**: `Union[int, str]` / `Union[int, str, None]` や `int | str` / `int | str | None` のように、`None` 以外の複数型を持つ Union は現在サポートしていません。スキーマ変換時に `TypeError` を送出します。代わりに `Optional[T]`、単一型、または `v.instance(...)` を使用してください。 + +--- + ### スキーマ自動生成 #### `v.auto_infer(data, type_map=None, schema_overrides=None)` diff --git a/example.py b/example.py index 29bb517..3018851 100644 --- a/example.py +++ b/example.py @@ -1,6 +1,6 @@ import sys import os -from typing import TypedDict +from typing import Dict, List, Optional, TypedDict import io # Handle UTF-8 output on Windows @@ -185,8 +185,6 @@ class UserConfig(TypedDict): # ========================================== header("3. v1.2.0 新機能 (デフォルト値・サンプル生成)") -from typing import List - class NodeConfig(TypedDict): ip: str role: str @@ -258,3 +256,179 @@ class ClusterConfig(TypedDict): log_success(f"欠損キーがデフォルト値で補完: region={r2['region']!r}, nodes[0].role={r2['nodes'][0]['role']!r}") header("v1.2.0 の全デモが正常に終了しました。") + +# ========================================== +# 4. クラス記法によるスキーマ定義 +# ========================================== +header("4. クラス記法スキーマ (Class-based Schema)") + +# --- 4.1 基本的なアノテーション --- +print("[4.1 基本的なアノテーション]") + +class UserProfile: + name: str + age: int + score: float + active: bool + +data = {"name": "Alice", "age": 30, "score": 9.5, "active": True} +result = validate(data, UserProfile) +assert result == data +log_success(f"基本アノテーション検証成功: {result}") + +# 型不一致はエラー +try: + validate({"name": 123, "age": 30, "score": 9.5, "active": True}, UserProfile) + assert False +except ValidationError as e: + log_success(f"型不一致を検知: path={e.path!r}, msg={e.message}") + +# --- 4.2 Optional / List / Dict と typing モジュールの型 --- +print("\n[4.2 Optional / List / Dict]") + +class ServerConfig: + host: str + port: int + tags: Optional[List[str]] # 省略可能なリスト + metadata: Dict[str, int] # 辞書 + +# tags は Optional なので省略してもエラーにならない +result = validate( + {"host": "db.local", "port": 5432, "metadata": {"connections": 10}}, + ServerConfig, +) +assert result["host"] == "db.local" +assert result["port"] == 5432 +assert "tags" not in result +log_success(f"Optional フィールドの省略が許容された: {result}") + +# tags を明示的に指定した場合は検証される +result2 = validate( + {"host": "db.local", "port": 5432, "metadata": {}, "tags": ["web", "api"]}, + ServerConfig, +) +assert result2["tags"] == ["web", "api"] +log_success(f"タグ付き: {result2['tags']}") + +# --- 4.3 カスタム型 (isinstance チェック) --- +print("\n[4.3 カスタム型 (isinstance チェック)]") + +class Timezone: + def __init__(self, name: str) -> None: + self.name = name + def __repr__(self) -> str: + return f"Timezone({self.name!r})" + +UTC = Timezone("UTC") +JST = Timezone("Asia/Tokyo") + +class Config: + name: str + age: int + timezone: Timezone # → isinstance(value, Timezone) で検証 + +result = validate({"name": "server1", "age": 5, "timezone": UTC}, Config) +assert result["timezone"] is UTC +log_success(f"カスタム型の isinstance チェック成功: timezone={result['timezone']!r}") + +# 誤った型を渡すとエラー +try: + validate({"name": "server1", "age": 5, "timezone": "UTC"}, Config) + assert False +except ValidationError as e: + log_success(f"カスタム型の型不一致を検知: {e.message}") + +# --- 4.4 クラス属性をデフォルト値として使用 + Validator クラス属性 --- +print("\n[4.4 デフォルト値 + Validator クラス属性]") + +class AppConfig: + host: str # 必須 + port: int = 5432 # デフォルト値 5432 + ssl: bool = False # デフォルト値 False + timeout: Optional[int] = 30 # Optional + デフォルト + role = v.str().default("worker") # Validator で詳細に定義 + +result = validate({"host": "db.local"}, AppConfig) +assert result["host"] == "db.local" +assert result["port"] == 5432 +assert result["ssl"] is False +assert result["timeout"] == 30 +assert result["role"] == "worker" +log_success(f"デフォルト値が自動補完された: {result}") + +# 明示的な値はデフォルトを上書きする +result2 = validate({"host": "db.local", "port": 3306, "role": "master"}, AppConfig) +assert result2["port"] == 3306 +assert result2["role"] == "master" +log_success(f"明示的な値がデフォルトを上書き: port={result2['port']}, role={result2['role']!r}") + +# --- 4.5 v.instance() — 辞書スキーマでの isinstance チェック --- +print("\n[4.5 v.instance() — 辞書スキーマでの isinstance チェック]") + +schema = { + "name": v.str(), + "timezone": v.instance(Timezone).default(UTC), +} + +# timezone を省略 → デフォルトの UTC が補完される +result = validate({"name": "server1"}, schema) +assert result["name"] == "server1" +assert result["timezone"] is UTC +log_success(f"v.instance() デフォルト補完: timezone={result['timezone']!r}") + +# 正しい型を渡した場合も通る +result2 = validate({"name": "server2", "timezone": JST}, schema) +assert result2["timezone"] is JST +log_success(f"v.instance() 正常ケース: timezone={result2['timezone']!r}") + +# 誤った型を渡した場合はエラー +try: + validate({"name": "server3", "timezone": "JST"}, schema) + assert False +except ValidationError as e: + log_success(f"v.instance() 型不一致を検知: {e.message}") + +# .coerce() で型変換も可能(コンストラクタで変換) +schema_coerce = { + "name": v.str(), + "timezone": v.instance(Timezone).coerce(), +} +result3 = validate({"name": "server4", "timezone": "Asia/Tokyo"}, schema_coerce) +assert isinstance(result3["timezone"], Timezone) +assert result3["timezone"].name == "Asia/Tokyo" +log_success(f"v.instance().coerce() 型変換成功: {result3['timezone']!r}") + +# --- 4.6 Schema(MyClass) と generate_sample() --- +print("\n[4.6 Schema(MyClass) と generate_sample()]") + +class SampleConfig: + app_name: str + debug: bool = False + port = v.int().default(8080).description("待機ポート") + +schema_obj = Schema(SampleConfig) +sample = schema_obj.generate_sample() +assert sample["debug"] is False # クラス属性デフォルト +assert sample["port"] == 8080 # Validator デフォルト +log_success(f"generate_sample() 成功: {sample}") + +# --- 4.7 collect_errors と partial の組み合わせ --- +print("\n[4.7 collect_errors / partial との組み合わせ]") + +class Profile: + name: str + age: int + +# collect_errors=True: 複数エラーを一度に収集 +bad = {"name": 99, "age": "thirty"} +res = validate(bad, Profile, collect_errors=True) +assert len(res.errors) == 2 +log_success(f"collect_errors: {len(res.errors)} 件のエラーを収集") + +# partial=True: 欠損フィールドを許容 +partial_result = validate({"name": "Bob"}, Profile, partial=True) +assert partial_result["name"] == "Bob" +assert "age" not in partial_result +log_success(f"partial=True: 欠損フィールドを許容: {partial_result}") + +header("クラス記法スキーマのデモが正常に終了しました。")