diff --git a/CHANGELOG.md b/CHANGELOG.md index 016bf30..9663a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,19 @@ ## Unreleased -- 現在、未整理の変更はありません。 +### Added +- クラス記法スキーマ (`class Config: ...`) を追加し、型アノテーションと `Validator` クラス属性を既存の dict スキーマ検証経路へ変換できるようになりました。 +- `v.instance(type_cls)` / `InstanceValidator` を追加し、任意クラスに対する `isinstance` ベースの検証と `.coerce()` をサポートしました。 + +### 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 @@ -28,7 +40,6 @@ - `Schema.generate_sample()` が生成候補を各バリデータで再検証するようになり、`regex()` や `custom()` を満たせない不正なサンプルを返さず `ValueError` を送出するようになりました。 ## [1.2.0] - 2026-02-27 - ### Added - すべてのバリデータに `.default(value)` を追加しました。欠損キーを自動補完し、設定したフィールドは自動的に optional として扱われます。 - すべてのバリデータに `.examples(list)` を追加しました。サンプル生成やドキュメント補助に使える例を保持できます。 diff --git a/README.md b/README.md index 2df97c7..5a152a5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ ValidKit は、「直感的なスキーマ定義」と「日本語キー * [クイックスタート](#クイックスタート) * [API 例](#api-例) * [高度な使い方](#高度な使い方) + * [クラス記法によるスキーマ定義](#クラス記法によるスキーマ定義) + * [IDE 補完を効かせる(TypedDict + Schema)](#ide-補完を効かせるtypeddict--schema) + * [スキーマを既存データから逆生成する(v.auto_infer)](#auto-infer) + * [部分更新とデフォルト値のマージ](#部分更新とデフォルト値のマージ) + * [マイグレーション](#マイグレーション) * [品質管理・セキュリティ](#品質管理セキュリティ) * [変更履歴](#変更履歴) * [貢献ガイドライン](#貢献ガイドライン) @@ -102,6 +107,7 @@ except ValidationError as e: ## 特徴 * 📝 **直感的なチェインメソッド** — `v.int().range(1, 10).optional()` のように流れるように記述。 +* 🏷️ **クラス記法対応** — Python クラスの型アノテーションをそのままスキーマとして使えます。`Optional[T]`・`List[T]`・`Dict[K,V]`・カスタム型にも対応。 * 🌏 **日本語キー対応** — 日本語のキー名をそのまま扱えるため、仕様書に近いコードが書けます。 * 🧠 **型補完に強い** — `Schema[T]` と TypedDict を組み合わせると、IDE / 型チェッカーが戻り値の形を推論できます。 * 🔄 **強力な変換・マイグレーション** — 旧形式から新形式へのキー名変換や、値の動的変換を検証時に同時に行えます。 @@ -120,6 +126,7 @@ except ValidationError as e: * `v.bool()`: 真偽値 * `v.list(schema)`: リスト(要素のスキーマを指定) * `v.dict(key_type, value_schema)`: 辞書 +* `v.instance(type_cls)`: 任意のクラスの isinstance チェック ### 修飾メソッド * `.optional()`: 必須でないフィールドにする @@ -135,6 +142,122 @@ 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 の完全なバリデーション | + +> **注意**: `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) `Schema[T]` クラスを使うと、IDE(PyCharm / VS Code)での型補完が有効になります。 diff --git a/docs/index.md b/docs/index.md index 97cb5ac..29bb9b1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,20 @@ 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()` スキーマ定義からサンプルデータの辞書を生成します。生成後の値は各バリデータで再検証されるため、制約を満たさないサンプルは返されません。 @@ -43,6 +57,83 @@ sample = SCHEMA.generate_sample() `regex()` や `custom()` などでこれらの候補が制約を満たせない場合は、`ValueError` を送出します。その場合は `.default(...)` または `.examples([...])` で妥当なサンプル候補を与えてください。 +### クラス記法スキーマ + +辞書スキーマに加えて、Python クラスの型アノテーションや `Validator` クラス属性をそのまま +スキーマとして扱えます。 + +#### 基本的な使い方 + +```python +from typing import Dict, List, Optional +from validkit import v, validate, ValidationError + +class Config: + host: str + port: int = 5432 # クラス属性 → デフォルト値 + tags: Optional[List[str]] # 省略可能なリスト + metadata: Dict[str, int] + 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": } (デフォルト補完) +``` + +対応する型ヒント: + +| アノテーション | 動作 | +|---|---| +| `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]` / `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)` @@ -125,6 +216,7 @@ schema = v.auto_infer( - `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` チェックを行います。 ### 修飾メソッド(チェーンメソッド) すべてのバリデータで使用可能なメソッド: 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("クラス記法スキーマのデモが正常に終了しました。") diff --git a/src/validkit/__init__.py b/src/validkit/__init__.py index 12378df..d906485 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.1" -__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 1d3a423..be317eb 100644 --- a/src/validkit/v.py +++ b/src/validkit/v.py @@ -237,6 +237,40 @@ 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): + if self._coerce: + try: + coerced_value = self._instance_type(value) + 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__}" + ) + 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: def str(self) -> StringValidator: return StringValidator() @@ -259,6 +293,19 @@ 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) @staticmethod def auto_infer( data: Any, diff --git a/src/validkit/validator.py b/src/validkit/validator.py index 4f76018..ade8cc1 100644 --- a/src/validkit/validator.py +++ b/src/validkit/validator.py @@ -6,16 +6,71 @@ Optional, TypeVar, Union, + get_args, + get_origin, overload, TYPE_CHECKING, Literal, cast, ) +import types as _types import math -from .v import Validator, v +from .v import ( + Validator, + v, + InstanceValidator, + StringValidator, + NumberValidator, + BoolValidator, + ListValidator, + DictValidator, +) 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. +# 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) + + +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 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 + if schema in _BASIC_TYPES: + return False + if issubclass(schema, Validator): + return False + # 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]): """ @@ -40,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]: @@ -93,6 +148,159 @@ 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] and PEP 604 T | None / T1 | T2 --- + # 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 + 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 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. + # 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." + ) + + # --- 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 has_default: + # 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 + + +def _class_to_schema(cls: type) -> Dict[str, Any]: + """クラスのアノテーションとクラス属性からスキーマ辞書を生成します。 + + 優先順位: + 1. クラス属性が Validator インスタンスの場合、そのまま使用する。 + 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 に自動的にデフォルト値を付与します。 + + 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 (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 + continue + + # 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 + + def validate_internal( value: Any, schema: Any, @@ -104,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: @@ -114,6 +322,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 @@ -253,12 +465,26 @@ def _validate_generated_value(schema: Validator, candidate: Any) -> Any: 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 + from .v import ( + StringValidator, + NumberValidator, + BoolValidator, + ListValidator, + DictValidator, + OneOfValidator, + ) if isinstance(schema, StringValidator): candidate: Any = "example" @@ -284,7 +510,7 @@ def _generate_sample(schema: Any) -> Any: Schema.generate_sample() の内部実装です。 Args: - schema: dict スキーマ、Validator インスタンス、または Python 型。 + schema: dict スキーマ、Validator インスタンス、Python 型、またはクラス。 Returns: 生成されたサンプル値。 @@ -293,6 +519,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): return _generate_validator_sample(schema) diff --git a/tests/test_class_schema.py b/tests/test_class_schema.py new file mode 100644 index 0000000..ff9aa40 --- /dev/null +++ b/tests/test_class_schema.py @@ -0,0 +1,1548 @@ +""" +tests/test_class_schema.py +クラス記法によるスキーマ定義のテスト: + - 型アノテーション (str / int / float / bool) からのスキーマ生成 + - typing モジュールのアノテーション (Optional / List / Dict) + - Python 3.9+ 組み込みジェネリクス (list[T] / dict[K, V]) + - カスタム型 (original class) の isinstance バリデーション + - クラス属性をデフォルト値として使用 + - Validator インスタンスをクラス属性として使用 + - v.instance() ビルダー + - partial / base / collect_errors との組み合わせ + - Schema.generate_sample() との組み合わせ + - 空クラスのスキーマとしての動作 + - サポート外の型 (non-optional Union) の明示的エラー +""" + +import datetime +from typing import Dict, List, Optional +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 + + +# --------------------------------------------------------------------------- +# カスタム型の準備 +# --------------------------------------------------------------------------- + +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): + """アノテーションなしのクラスはスキーマとして認識されない。 + validate() はデータをそのまま返す(パススルー)。""" + 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} + + +# --------------------------------------------------------------------------- +# 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() ビルダーのテスト +# --------------------------------------------------------------------------- + +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() ラッパーはクラス記法スキーマを実行時にそのままラップして使える""" + class Profile: + name: str + age: int + + 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 との組み合わせ +# --------------------------------------------------------------------------- + +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 + + +# --------------------------------------------------------------------------- +# 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, + } + + +# --------------------------------------------------------------------------- +# 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="multiple non-None members"): + _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="multiple non-None members"): + _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="multiple non-None members"): + _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="multiple non-None members"): + validate({"host": "db"}, Config) + + +# --------------------------------------------------------------------------- +# 空クラスの動作(注: 空クラスはスキーマと認識されず、validate() でパススルーになる) +# --------------------------------------------------------------------------- + +class TestEmptyClassSchema: + """空クラス (アノテーションも Validator 属性もない) は _is_class_schema() で False になり、 + クラス記法スキーマとしては扱われないことを検証するテスト群。 + 空クラスを validate() に渡すと、スキーマ処理をバイパスしてデータをそのまま返す(パススルー)。 + アノテーションや Validator 属性を持つクラスのみがスキーマとして認識される。""" + + 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 False + + 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_passes_through_data(self): + """空クラスはスキーマではないので validate() はデータをそのまま返す(パススルー)""" + class Empty: + pass + + # Empty class is NOT a class schema; validate() passes the value through unchanged. + result = validate({"foo": 1, "bar": "baz"}, Empty) + assert result == {"foo": 1, "bar": "baz"} + + 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 + + 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() == {} + + +# --------------------------------------------------------------------------- +# _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 + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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 と同等に処理されることを検証するテスト群。 + + PEP 604 の T | None 記法は Python 3.10+ でのみ有効なため、 + このテストクラスは Python 3.10 未満では自動的にスキップされる。 + """ + + # ---- 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) + + +# --------------------------------------------------------------------------- +# 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" + ) + + +# --------------------------------------------------------------------------- +# 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 diff --git a/tests/test_features_v120.py b/tests/test_features_v120.py index e7fccc0..ff849d0 100644 --- a/tests/test_features_v120.py +++ b/tests/test_features_v120.py @@ -501,7 +501,7 @@ def test_type_map_callable_returning_int_re_infers(self): def test_type_map_callable_returning_dict_re_infers(self): """type_map の callable が dict を返す場合 → ネスト dict スキーマとして再推論される""" import datetime - from validkit.v import StringValidator, NumberValidator + from validkit.v import NumberValidator schema = v.auto_infer( datetime.date(2024, 6, 15), type_map={ @@ -528,7 +528,7 @@ def test_type_map_callable_returning_validator_used_directly(self): def test_type_map_auto_convert_in_dict_field(self): """dict フィールドの auto-convert callable も正しく re-infer される""" import datetime - from validkit.v import StringValidator, NumberValidator + from validkit.v import StringValidator data = {"name": "Alice", "created_at": datetime.date(2024, 1, 1)} schema = v.auto_infer( data, @@ -582,7 +582,7 @@ def test_schema_overrides_does_not_affect_non_dict_data(self): def test_schema_overrides_unmentioned_fields_still_inferred(self): """schema_overrides に含まれないフィールドは通常どおり推論される""" - from validkit.v import StringValidator, NumberValidator + from validkit.v import NumberValidator data = {"name": "Alice", "age": 30} schema = v.auto_infer( data, @@ -627,7 +627,7 @@ def test_schema_overrides_round_trip_validation(self): def test_schema_overrides_does_not_leak_into_nested_dict(self): """schema_overrides はトップレベルの dict にのみ適用され、ネストした dict には適用されない""" - from validkit.v import StringValidator, NumberValidator + from validkit.v import StringValidator data = {"name": "Alice", "user": {"name": "Bob", "age": 25}} schema = v.auto_infer( data, @@ -655,7 +655,7 @@ def test_schema_overrides_does_not_leak_into_list_items(self): def test_type_map_callable_returning_dict_does_not_apply_schema_overrides(self): """type_map の callable が dict を返して再推論するとき、schema_overrides は適用されない""" - from validkit.v import StringValidator, NumberValidator + from validkit.v import NumberValidator class SpecialDate: def __init__(self, y, m, d): diff --git a/tests/test_validkit.py b/tests/test_validkit.py index d824f0a..e60458f 100644 --- a/tests/test_validkit.py +++ b/tests/test_validkit.py @@ -8,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())