Skip to content

Commit a609a9b

Browse files
committed
Raise global validation errors at the outermost call
1 parent 22786cc commit a609a9b

3 files changed

Lines changed: 236 additions & 3 deletions

File tree

‎docs/install_and_use.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,8 @@ print(validators.email("someone@example.com"))
4242
raise ValidationError(func, _func_args_as_dict(func, *args, **kwargs))
4343
validators.utils.ValidationError: ValidationError(func=visa, args={'value': 'bad_visa_number'})
4444
```
45+
46+
The environment option raises at the outermost validation call. Nested validators
47+
return validation errors so a composite validator can try alternatives, such as
48+
checking an IP address after a domain check fails. Passing `r_ve=True` explicitly
49+
still raises even inside another validator.

‎src/validators/utils.py‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,45 @@
11
"""Utils."""
22

33
# standard
4+
from contextvars import ContextVar
5+
from dataclasses import dataclass
46
from functools import wraps
57
from inspect import getfullargspec
68
from itertools import chain
79
from os import environ
10+
from sys import modules
11+
from threading import get_ident
812
from typing import Any, Callable, Dict
913

1014

15+
@dataclass
16+
class _ValidationScope:
17+
"""Track the lifetime, thread and task of an outermost validation call."""
18+
19+
thread_id: int
20+
task_id: int | None
21+
active: bool = True
22+
23+
24+
_validation_scope: ContextVar[_ValidationScope | None] = ContextVar(
25+
"_validation_scope", default=None
26+
)
27+
28+
29+
def _current_task_id() -> int | None:
30+
"""Identify an active task without loading asyncio for synchronous callers."""
31+
if "asyncio" not in modules:
32+
return None
33+
34+
from asyncio import current_task
35+
36+
try:
37+
task = current_task()
38+
except RuntimeError:
39+
return None
40+
return id(task) if task is not None else None
41+
42+
1143
class ValidationError(Exception):
1244
"""Exception class when validation failure occurs."""
1345

@@ -48,6 +80,9 @@ def validator(func: Callable[..., Any]):
4880
Whenever the given `func` returns `False` this
4981
decorator returns `ValidationError` object.
5082
83+
The environment option raises at the outermost validation call so nested
84+
validators can try alternatives. An explicit `r_ve=True` always raises.
85+
5186
Examples:
5287
>>> @validator
5388
... def even(value):
@@ -73,8 +108,21 @@ def validator(func: Callable[..., Any]):
73108
@wraps(func)
74109
def wrapper(*args: Any, **kwargs: Any):
75110
raise_validation_error = bool(kwargs.pop("r_ve", False))
76-
if environ.get("RAISE_VALIDATION_ERROR", "False") == "True":
77-
raise_validation_error = True
111+
scope = _validation_scope.get()
112+
thread_id = get_ident()
113+
task_id = _current_task_id()
114+
if (
115+
scope is None
116+
or not scope.active
117+
or scope.thread_id != thread_id
118+
or scope.task_id != task_id
119+
):
120+
if environ.get("RAISE_VALIDATION_ERROR", "False") == "True":
121+
raise_validation_error = True
122+
scope = _ValidationScope(thread_id, task_id)
123+
token = _validation_scope.set(scope)
124+
else:
125+
token = None
78126

79127
try:
80128
if raise_validation_error:
@@ -95,5 +143,9 @@ def wrapper(*args: Any, **kwargs: Any):
95143
) from exp
96144
else:
97145
return ValidationError(func, _func_args_as_dict(func, *args, **kwargs), str(exp))
146+
finally:
147+
if token is not None:
148+
scope.active = False
149+
_validation_scope.reset(token)
98150

99151
return wrapper

‎tests/test_validation_failure.py‎

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
"""Test validation Failure."""
22

33
# 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
49
from typing import Any, Callable
510

611
# third-party
712
import pytest
813

914
# 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
1117

1218

1319
@pytest.mark.parametrize("function,value", [(slug, "invalid_"), (uuid, "not-a-uuid")])
@@ -41,6 +47,176 @@ def test_true_raise_validation_error_flag(
4147
function(value, r_ve=True)
4248

4349

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+
44220
failed_obj_repr = "ValidationError(func=between"
45221

46222

0 commit comments

Comments
 (0)