|
1 | 1 | """Test validation Failure.""" |
2 | 2 |
|
3 | 3 | # standard |
| 4 | +import asyncio |
| 5 | +from concurrent.futures import ThreadPoolExecutor |
| 6 | +from contextvars import Context, copy_context |
| 7 | +import sys |
| 8 | +from threading import Event |
4 | 9 | from typing import Any, Callable |
5 | 10 |
|
6 | 11 | # third-party |
7 | 12 | import pytest |
8 | 13 |
|
9 | 14 | # local |
10 | | -from validators import ValidationError, between, slug, uuid |
| 15 | +from validators import ValidationError, between, hostname, slug, url, uuid |
| 16 | +from validators.utils import validator |
11 | 17 |
|
12 | 18 |
|
13 | 19 | @pytest.mark.parametrize("function,value", [(slug, "invalid_"), (uuid, "not-a-uuid")]) |
@@ -41,6 +47,176 @@ def test_true_raise_validation_error_flag( |
41 | 47 | function(value, r_ve=True) |
42 | 48 |
|
43 | 49 |
|
| 50 | +@pytest.mark.parametrize("global_raise", [False, True]) |
| 51 | +@pytest.mark.parametrize( |
| 52 | + "function,value", |
| 53 | + [ |
| 54 | + (hostname, "127.0.0.1"), |
| 55 | + (hostname, "::1"), |
| 56 | + (hostname, "127.0.0.1:8080"), |
| 57 | + (hostname, "[::1]:8080"), |
| 58 | + (url, "http://10.0.0.1"), |
| 59 | + (url, "http://[::1]"), |
| 60 | + ], |
| 61 | +) |
| 62 | +def test_raise_mode_accepts_valid_composite_values( |
| 63 | + function: Callable[..., Any], |
| 64 | + value: str, |
| 65 | + global_raise: bool, |
| 66 | + monkeypatch: pytest.MonkeyPatch, |
| 67 | +) -> None: |
| 68 | + """Both raise modes allow failed alternatives inside a valid composite validator.""" |
| 69 | + monkeypatch.setenv("RAISE_VALIDATION_ERROR", str(global_raise)) |
| 70 | + assert function(value, r_ve=not global_raise) is True |
| 71 | + |
| 72 | + |
| 73 | +@pytest.mark.parametrize("global_raise", [False, True]) |
| 74 | +@pytest.mark.parametrize( |
| 75 | + "function,value", [(hostname, "not a hostname"), (url, "http://not a hostname")] |
| 76 | +) |
| 77 | +def test_raise_mode_reports_invalid_composite_at_public_boundary( |
| 78 | + function: Callable[..., Any], |
| 79 | + value: str, |
| 80 | + global_raise: bool, |
| 81 | + monkeypatch: pytest.MonkeyPatch, |
| 82 | +) -> None: |
| 83 | + """Invalid composite inputs raise an error for the validator the caller invoked.""" |
| 84 | + monkeypatch.setenv("RAISE_VALIDATION_ERROR", str(global_raise)) |
| 85 | + with pytest.raises(ValidationError) as exc_info: |
| 86 | + function(value, r_ve=not global_raise) |
| 87 | + assert exc_info.value.func.__name__ == function.__name__ |
| 88 | + assert exc_info.value.__dict__["value"] == value |
| 89 | + |
| 90 | + |
| 91 | +def test_nested_explicit_raise_flag_is_respected(monkeypatch: pytest.MonkeyPatch) -> None: |
| 92 | + """A nested caller can still explicitly request an exception.""" |
| 93 | + monkeypatch.setenv("RAISE_VALIDATION_ERROR", "True") |
| 94 | + |
| 95 | + @validator |
| 96 | + def explicit_nested_raise(value: str): |
| 97 | + return slug(value, r_ve=True) |
| 98 | + |
| 99 | + with pytest.raises(ValidationError) as exc_info: |
| 100 | + explicit_nested_raise("invalid_") |
| 101 | + assert exc_info.value.func.__name__ == "slug" |
| 102 | + |
| 103 | + |
| 104 | +@pytest.mark.parametrize("global_raise", [False, True]) |
| 105 | +def test_raise_mode_is_independent_between_threads( |
| 106 | + global_raise: bool, monkeypatch: pytest.MonkeyPatch |
| 107 | +) -> None: |
| 108 | + """A validation in another thread must not suppress this caller's raise mode.""" |
| 109 | + monkeypatch.setenv("RAISE_VALIDATION_ERROR", str(global_raise)) |
| 110 | + entered = Event() |
| 111 | + release = Event() |
| 112 | + |
| 113 | + @validator |
| 114 | + def waiting_validation(): |
| 115 | + entered.set() |
| 116 | + assert release.wait(timeout=5) |
| 117 | + return True |
| 118 | + |
| 119 | + with ThreadPoolExecutor(max_workers=1) as executor: |
| 120 | + pending = executor.submit(waiting_validation, r_ve=not global_raise) |
| 121 | + try: |
| 122 | + assert entered.wait(timeout=5) |
| 123 | + with pytest.raises(ValidationError): |
| 124 | + slug("invalid_", r_ve=not global_raise) |
| 125 | + finally: |
| 126 | + release.set() |
| 127 | + assert pending.result(timeout=5) is True |
| 128 | + |
| 129 | + |
| 130 | +def test_copied_context_is_independent_in_another_thread(monkeypatch: pytest.MonkeyPatch) -> None: |
| 131 | + """Copying an active context to another thread must not suppress its raise mode.""" |
| 132 | + monkeypatch.setenv("RAISE_VALIDATION_ERROR", "True") |
| 133 | + |
| 134 | + def validate_on_worker() -> bool: |
| 135 | + try: |
| 136 | + slug("invalid_") |
| 137 | + except ValidationError: |
| 138 | + return True |
| 139 | + return False |
| 140 | + |
| 141 | + @validator |
| 142 | + def validation_with_worker(): |
| 143 | + context = copy_context() |
| 144 | + with ThreadPoolExecutor(max_workers=1) as executor: |
| 145 | + pending = executor.submit(context.run, validate_on_worker) |
| 146 | + assert pending.result(timeout=5) |
| 147 | + return True |
| 148 | + |
| 149 | + assert validation_with_worker() is True |
| 150 | + |
| 151 | + |
| 152 | +@pytest.mark.parametrize("eager", [False, True]) |
| 153 | +def test_inherited_task_context_does_not_suppress_independent_validation( |
| 154 | + eager: bool, |
| 155 | + monkeypatch: pytest.MonkeyPatch, |
| 156 | +) -> None: |
| 157 | + """An inherited task context must not suppress independent synchronous validation.""" |
| 158 | + if eager and sys.version_info < (3, 12): |
| 159 | + pytest.skip("Eager task factories require Python 3.12 or newer") |
| 160 | + monkeypatch.setenv("RAISE_VALIDATION_ERROR", "True") |
| 161 | + |
| 162 | + async def validate_later() -> None: |
| 163 | + with pytest.raises(ValidationError): |
| 164 | + slug("invalid_") |
| 165 | + |
| 166 | + async def run_validation() -> None: |
| 167 | + if eager and sys.version_info >= (3, 12): |
| 168 | + asyncio.get_running_loop().set_task_factory(asyncio.eager_task_factory) |
| 169 | + tasks: list[asyncio.Task[None]] = [] |
| 170 | + |
| 171 | + @validator |
| 172 | + def schedule_validation(): |
| 173 | + assert hostname("127.0.0.1") is True |
| 174 | + assert isinstance(slug("invalid_"), ValidationError) |
| 175 | + tasks.append(asyncio.create_task(validate_later())) |
| 176 | + assert hostname("::1") is True |
| 177 | + return True |
| 178 | + |
| 179 | + assert schedule_validation() is True |
| 180 | + await tasks[0] |
| 181 | + |
| 182 | + asyncio.run(run_validation()) |
| 183 | + |
| 184 | + |
| 185 | +@pytest.mark.parametrize("outcome", ["valid", "invalid", "parse_error", "unexpected_error"]) |
| 186 | +def test_global_raise_mode_is_restored_after_validation( |
| 187 | + outcome: str, monkeypatch: pytest.MonkeyPatch |
| 188 | +) -> None: |
| 189 | + """Returning or raising from a validator must not suppress a later independent call.""" |
| 190 | + monkeypatch.setenv("RAISE_VALIDATION_ERROR", "True") |
| 191 | + contexts: list[Context] = [] |
| 192 | + |
| 193 | + @validator |
| 194 | + def capture_context(): |
| 195 | + contexts.append(copy_context()) |
| 196 | + return True |
| 197 | + |
| 198 | + @validator |
| 199 | + def sample_validation(): |
| 200 | + assert capture_context() is True |
| 201 | + assert isinstance(contexts[0].run(slug, "invalid_"), ValidationError) |
| 202 | + if outcome == "unexpected_error": |
| 203 | + raise RuntimeError("unexpected") |
| 204 | + if outcome == "parse_error": |
| 205 | + raise ValueError("parse error") |
| 206 | + return outcome == "valid" |
| 207 | + |
| 208 | + if outcome == "valid": |
| 209 | + assert sample_validation() is True |
| 210 | + else: |
| 211 | + expected_error = RuntimeError if outcome == "unexpected_error" else ValidationError |
| 212 | + with pytest.raises(expected_error): |
| 213 | + sample_validation() |
| 214 | + with pytest.raises(ValidationError): |
| 215 | + slug("invalid_") |
| 216 | + with pytest.raises(ValidationError): |
| 217 | + contexts[0].run(slug, "invalid_") |
| 218 | + |
| 219 | + |
44 | 220 | failed_obj_repr = "ValidationError(func=between" |
45 | 221 |
|
46 | 222 |
|
|
0 commit comments