various-api-tools 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/PKG-INFO +3 -2
  2. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/pyproject.toml +15 -2
  3. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/src/various_api_tools/__init__.py +4 -0
  4. various_api_tools-0.3.3/src/various_api_tools/error_maps/__init__.py +1 -0
  5. various_api_tools-0.3.3/src/various_api_tools/error_maps/pydantic.py +40 -0
  6. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/src/various_api_tools/translators/pydantic.py +5 -34
  7. various_api_tools-0.3.3/src/various_api_tools/validators/__init__.py +1 -0
  8. various_api_tools-0.3.3/src/various_api_tools/validators/pydantic.py +221 -0
  9. various_api_tools-0.3.3/tests/validators/__init__.py +0 -0
  10. various_api_tools-0.3.3/tests/validators/test_pydantic.py +120 -0
  11. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/README.md +0 -0
  12. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/src/various_api_tools/translators/__init__.py +0 -0
  13. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/src/various_api_tools/translators/json.py +0 -0
  14. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/src/various_api_tools/translators/psycopg2.py +0 -0
  15. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/tests/__init__.py +0 -0
  16. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/tests/translators/__init__.py +0 -0
  17. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/tests/translators/test_json.py +0 -0
  18. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/tests/translators/test_psycopg2.py +0 -0
  19. {various_api_tools-0.3.2 → various_api_tools-0.3.3}/tests/translators/test_pydantic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: various-api-tools
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: A lightweight utility package for common API-related tasks in Python, including JSON and Pydantic error translators that provide user-friendly Russian messages.
5
5
  Author-Email: dkurchigin <kurchigin.dmitry@yandex.ru>
6
6
  License: MIT
@@ -16,8 +16,9 @@ Project-URL: Homepage, https://gitverse.ru/dkurchigin/various-api-tools
16
16
  Project-URL: Documentation, https://various-api-tools.dkurchigin.ru/
17
17
  Project-URL: Source, https://gitverse.ru/dkurchigin/various-api-tools
18
18
  Requires-Python: >=3.10
19
- Requires-Dist: pydantic>=2.11.7
19
+ Requires-Dist: pydantic>=2.12.3
20
20
  Requires-Dist: psycopg2-binary>=2.9.10
21
+ Requires-Dist: email-validator>=2.3.0
21
22
  Description-Content-Type: text/markdown
22
23
 
23
24
  # Various_api_tools
@@ -1,13 +1,14 @@
1
1
  [project]
2
2
  name = "various-api-tools"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "A lightweight utility package for common API-related tasks in Python, including JSON and Pydantic error translators that provide user-friendly Russian messages."
5
5
  authors = [
6
6
  { name = "dkurchigin", email = "kurchigin.dmitry@yandex.ru" },
7
7
  ]
8
8
  dependencies = [
9
- "pydantic>=2.11.7",
9
+ "pydantic>=2.12.3",
10
10
  "psycopg2-binary>=2.9.10",
11
+ "email-validator>=2.3.0",
11
12
  ]
12
13
  requires-python = ">=3.10"
13
14
  readme = "README.md"
@@ -40,6 +41,7 @@ build-backend = "pdm.backend"
40
41
  distribution = true
41
42
 
42
43
  [tool.pdm.scripts]
44
+ isort = "ruff check --select I --fix ."
43
45
  test = "pytest --cov-report term-missing --cov=src tests/"
44
46
  lint = "ruff check --fix"
45
47
  format = "ruff format"
@@ -144,11 +146,22 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
144
146
  "E",
145
147
  "ANN",
146
148
  "EM",
149
+ "B",
147
150
  "TRY",
148
151
  "PT",
152
+ "FBT",
153
+ "SLF",
149
154
  ]
150
155
  "src/various_api_tools/translators/pydantic.py" = [
151
156
  "RUF001",
157
+ "TID252",
158
+ ]
159
+ "src/various_api_tools/validators/pydantic.py" = [
160
+ "EM101",
161
+ "TID252",
162
+ ]
163
+ "src/various_api_tools/error_maps/pydantic.py" = [
164
+ "RUF001",
152
165
  ]
153
166
 
154
167
  [tool.ruff.format]
@@ -3,12 +3,16 @@
3
3
  Including JSON and Pydantic error translators.
4
4
  """
5
5
 
6
+ from .error_maps.pydantic import PYDANTIC_ERROR_TYPES
6
7
  from .translators.json import JSONDecodeErrorTranslator
7
8
  from .translators.psycopg2 import Psycopg2ErrorTranslator
8
9
  from .translators.pydantic import PydanticValidationErrorTranslator
10
+ from .validators.pydantic import PydanticValidator
9
11
 
10
12
  __all__ = (
13
+ "PYDANTIC_ERROR_TYPES",
11
14
  "JSONDecodeErrorTranslator",
12
15
  "Psycopg2ErrorTranslator",
13
16
  "PydanticValidationErrorTranslator",
17
+ "PydanticValidator",
14
18
  )
@@ -0,0 +1 @@
1
+ """Module for some error mappings."""
@@ -0,0 +1,40 @@
1
+ """Module for mapping Pydantic validation error codes to messages in Russian.
2
+
3
+ This module provides a dictionary that maps common Pydantic error types to descriptive
4
+ error messages in Russian, used for user-friendly validation feedback.
5
+ """
6
+
7
+ from typing import Final
8
+
9
+ PYDANTIC_ERROR_TYPES: dict[str, str] = {
10
+ "missing": "Не заполнено обязательное поле",
11
+ "uuid_parsing": "Невалидное значение для UUID",
12
+ "uuid_type": "Невалидное значение для UUID",
13
+ "uuid_version": "Невалидное значение для UUID",
14
+ "bool_parsing": "Невалидное значение для логического типа(bool)",
15
+ "bool_type": "Невалидное значение для логического типа(bool)",
16
+ "date_type": "Невалидное значение даты(date)",
17
+ "datetime_from_date_parsing": "Невалидное значение даты и времени(datetime)",
18
+ "datetime_type": "Невалидное значение даты и времени(datetime)",
19
+ "dict_type": "Невалидное значение словаря",
20
+ "list_type": "Невалидное значение списка",
21
+ "string_type": "Невалидное строковое значение(str)",
22
+ "enum": "Невалидное значение Enum",
23
+ "float_parsing": "Невалидное значение числа с плавающей точкой(float)",
24
+ "float_type": "Невалидное значение числа с плавающей точкой(float)",
25
+ "int_from_float": "Невалидное значение для целочисленного числа(int)",
26
+ "int_parsing": "Невалидное значение для целочисленного числа(int)",
27
+ "int_parsing_size": "Невалидное значение для целочисленного числа(int)",
28
+ "int_type": "Невалидное значение для целочисленного числа(int)",
29
+ "non_negative_int": "Невалидное значение для целочисленного числа(int), "
30
+ "должно быть больше или равно 0",
31
+ "incorrect_latitude": "Некорректное значение широты, должно быть больше, "
32
+ "чем -90.0 и меньше, чем 90.0",
33
+ "incorrect_longitude": "Некорректное значение долготы, должно быть больше, "
34
+ "чем -180.0 и меньше, чем 180.0",
35
+ "string_too_short": "Cтрока слишком короткая",
36
+ "ip_address": "Невалидное значение адреса IPv4/IPv6",
37
+ "incorrect_email": "Невалидное значение email-адреса",
38
+ "list_expected": "Некорректный тип данных. Ожидается список.",
39
+ }
40
+ UNKNOWN_ERROR_TYPE: Final[str] = "Неизвестная ошибка"
@@ -10,40 +10,9 @@ from typing import Final
10
10
 
11
11
  from pydantic_core import ErrorDetails
12
12
 
13
- pydantic_error_types: dict[str, str] = {
14
- "missing": "Не заполнено обязательное поле",
15
- "uuid_parsing": "Невалидное значение для UUID",
16
- "uuid_type": "Невалидное значение для UUID",
17
- "uuid_version": "Невалидное значение для UUID",
18
- "bool_parsing": "Невалидное значение для логического типа(bool)",
19
- "bool_type": "Невалидное значение для логического типа(bool)",
20
- "date_type": "Невалидное значение даты(date)",
21
- "datetime_from_date_parsing": "Невалидное значение даты и времени(datetime)",
22
- "datetime_type": "Невалидное значение даты и времени(datetime)",
23
- "dict_type": "Невалидное значение словаря",
24
- "list_type": "Невалидное значение списка",
25
- "string_type": "Невалидное строковое значение(str)",
26
- "enum": "Невалидное значение Enum",
27
- "float_parsing": "Невалидное значение числа с плавающей точкой(float)",
28
- "float_type": "Невалидное значение числа с плавающей точкой(float)",
29
- "int_from_float": "Невалидное значение для целочисленного числа(int)",
30
- "int_parsing": "Невалидное значение для целочисленного числа(int)",
31
- "int_parsing_size": "Невалидное значение для целочисленного числа(int)",
32
- "int_type": "Невалидное значение для целочисленного числа(int)",
33
- "non_negative_int": "Невалидное значение для целочисленного числа(int), "
34
- "должно быть больше или равно 0",
35
- "incorrect_latitude": "Некорректное значение широты, должно быть больше, "
36
- "чем -90.0 и меньше, чем 90.0",
37
- "incorrect_longitude": "Некорректное значение долготы, должно быть больше, "
38
- "чем -180.0 и меньше, чем 180.0",
39
- "string_too_short": "Cтрока слишком короткая",
40
- "ip_address": "Невалидное значение адреса IPv4/IPv6",
41
- "incorrect_email": "Невалидное значение email-адреса",
42
- "list_expected": "Некорректный тип данных. Ожидается список.",
43
- }
13
+ from ..error_maps.pydantic import PYDANTIC_ERROR_TYPES, UNKNOWN_ERROR_TYPE
44
14
 
45
15
  DEFAULT_LOCATION_PREFIX: Final[str] = "Поле"
46
- UNKNOWN_ERROR_TYPE: Final[str] = "Неизвестная ошибка"
47
16
 
48
17
 
49
18
  class PydanticValidationErrorTranslator:
@@ -54,6 +23,8 @@ class PydanticValidationErrorTranslator:
54
23
 
55
24
  """
56
25
 
26
+ error_types = PYDANTIC_ERROR_TYPES
27
+
57
28
  @classmethod
58
29
  def get_str_pydantic_loc(cls, loc: tuple[str]) -> str:
59
30
  """Convert a Pydantic location tuple into a dot-separated string.
@@ -114,10 +85,10 @@ class PydanticValidationErrorTranslator:
114
85
  )
115
86
 
116
87
  if "type" in error:
117
- msg = pydantic_error_types.get(error["type"], UNKNOWN_ERROR_TYPE)
88
+ msg = cls.error_types.get(error["type"], UNKNOWN_ERROR_TYPE)
118
89
  type_part = f'Ошибка: "{msg}"'
119
90
 
120
- if "input" in error and pydantic_error_types["missing"] not in type_part:
91
+ if "input" in error and cls.error_types["missing"] not in type_part:
121
92
  input_part = f'заполнено неверно: "{error["input"]!r}"'
122
93
 
123
94
  if input_part != "":
@@ -0,0 +1 @@
1
+ """Module for various API validators."""
@@ -0,0 +1,221 @@
1
+ """Module for custom Pydantic validators with user-friendly error messages.
2
+
3
+ Provides a class with methods to validate various types of input data,
4
+ raising PydanticCustomError with predefined error descriptions.
5
+ """
6
+
7
+ from decimal import Decimal
8
+ from typing import Any
9
+
10
+ from pydantic import EmailStr
11
+ from pydantic_core._pydantic_core import PydanticCustomError
12
+
13
+ from ..error_maps.pydantic import PYDANTIC_ERROR_TYPES
14
+
15
+ MIN_LATITUDE = -90.0
16
+ MAX_LATITUDE = 90.0
17
+ MIN_LONGITUDE = -180.0
18
+ MAX_LONGITUDE = 180.0
19
+
20
+
21
+ class PydanticValidator:
22
+ """A class that provides custom validation methods for Pydantic models.
23
+
24
+ These methods are designed to raise PydanticCustomError with specific error codes
25
+ and human-readable error messages for better end-user experience.
26
+ """
27
+
28
+ error_types = PYDANTIC_ERROR_TYPES
29
+
30
+ @classmethod
31
+ def strip_validator(cls, value: str) -> str:
32
+ """Strip whitespace from both ends of a string.
33
+
34
+ Args:
35
+ value: Input string to be stripped.
36
+
37
+ Returns:
38
+ The stripped string.
39
+
40
+ Example:
41
+ ```python
42
+ print(PydanticValidator.strip_validator(" test "))
43
+ #> "test"
44
+ ```
45
+
46
+ """
47
+ return value.strip()
48
+
49
+ @classmethod
50
+ def optional_string_validator(cls, value: Any | None) -> str | None: # noqa: ANN401
51
+ """Validate and strips an optional string value.
52
+
53
+ Args:
54
+ value: Input value to be validated.
55
+
56
+ Returns:
57
+ A stripped string or None if the value is empty.
58
+
59
+ Raises:
60
+ PydanticCustomError: If the value is not a string.
61
+
62
+ Example:
63
+ ```python
64
+ print(PydanticValidator.optional_string_validator(" test "))
65
+ #> "test"
66
+
67
+ print(PydanticValidator.optional_string_validator(""))
68
+ #> None
69
+
70
+ print(PydanticValidator.optional_string_validator(123))
71
+ #> Traceback (most recent call last):
72
+ #> ...
73
+ #> PydanticCustomError: 'string_type'
74
+ ```
75
+
76
+ """
77
+ if value is not None:
78
+ if not isinstance(value, str):
79
+ raise PydanticCustomError("string_type", cls.error_types["string_type"])
80
+
81
+ value = cls.strip_validator(value=value)
82
+ if value == "":
83
+ value = None
84
+
85
+ return value
86
+
87
+ @classmethod
88
+ def email_validator(cls, value: str) -> EmailStr | None:
89
+ """Validate an email address format.
90
+
91
+ Args:
92
+ value: Input string to be validated as an email.
93
+
94
+ Returns:
95
+ Validated EmailStr object or None if input is invalid.
96
+
97
+ Raises:
98
+ PydanticCustomError: If the email format is incorrect.
99
+
100
+ Example:
101
+ ```python
102
+ print(PydanticValidator.email_validator("test@example.com"))
103
+ #> EmailStr('test@example.com')
104
+
105
+ print(PydanticValidator.email_validator("invalid-email"))
106
+ #> Traceback (most recent call last):
107
+ #> ...
108
+ #> PydanticCustomError: 'incorrect_email'
109
+ ```
110
+
111
+ """
112
+ email_str: EmailStr | None = None
113
+ stripped_value: str | None = cls.optional_string_validator(value=value)
114
+
115
+ if stripped_value is not None:
116
+ try:
117
+ email_str = EmailStr._validate(stripped_value) # noqa: SLF001
118
+ except ValueError as exc:
119
+ raise PydanticCustomError(
120
+ "incorrect_email",
121
+ cls.error_types["incorrect_email"],
122
+ ) from exc
123
+
124
+ return email_str
125
+
126
+ @classmethod
127
+ def latitude_validator(cls, value: Decimal) -> Decimal:
128
+ """Validate that a value is a valid geographic latitude.
129
+
130
+ Args:
131
+ value: Input Decimal to be validated.
132
+
133
+ Returns:
134
+ The validated Decimal value.
135
+
136
+ Raises:
137
+ PydanticCustomError: If the value is not in the range [-90.0, 90.0].
138
+
139
+ Example:
140
+ ```python
141
+ print(PydanticValidator.latitude_validator(Decimal("45.0")))
142
+ #> Decimal('45.0')
143
+
144
+ print(PydanticValidator.latitude_validator(Decimal("100.0")))
145
+ #> Traceback (most recent call last):
146
+ #> ...
147
+ #> PydanticCustomError: 'incorrect_latitude'
148
+ ```
149
+
150
+ """
151
+ if not MIN_LATITUDE <= value <= MAX_LATITUDE:
152
+ raise PydanticCustomError(
153
+ "incorrect_latitude",
154
+ cls.error_types["incorrect_latitude"],
155
+ )
156
+ return value
157
+
158
+ @classmethod
159
+ def longitude_validator(cls, value: Decimal) -> Decimal:
160
+ """Validate that a value is a valid geographic longitude.
161
+
162
+ Args:
163
+ value: Input Decimal to be validated.
164
+
165
+ Returns:
166
+ The validated Decimal value.
167
+
168
+ Raises:
169
+ PydanticCustomError: If the value is not in the range [-180.0, 180.0].
170
+
171
+ Example:
172
+ ```python
173
+ print(PydanticValidator.longitude_validator(Decimal("90.0")))
174
+ #> Decimal('90.0')
175
+
176
+ print(PydanticValidator.longitude_validator(Decimal("200.0")))
177
+ #> Traceback (most recent call last):
178
+ #> ...
179
+ #> PydanticCustomError: 'incorrect_longitude'
180
+ ```
181
+
182
+ """
183
+ if not MIN_LONGITUDE <= value <= MAX_LONGITUDE:
184
+ raise PydanticCustomError(
185
+ "incorrect_longitude",
186
+ cls.error_types["incorrect_longitude"],
187
+ )
188
+ return value
189
+
190
+ @classmethod
191
+ def validate_required_field(cls, value: Any | None) -> Any: # noqa: ANN401
192
+ """Validate that a field is not missing in update models.
193
+
194
+ Args:
195
+ value: Input value to be validated.
196
+
197
+ Returns:
198
+ The original value if it's valid.
199
+
200
+ Raises:
201
+ PydanticCustomError: If the value is None or empty string.
202
+
203
+ Example:
204
+ ```python
205
+ print(PydanticValidator.validate_required_field("test"))
206
+ #> "test"
207
+
208
+ print(PydanticValidator.validate_required_field(None))
209
+ #> Traceback (most recent call last):
210
+ #> ...
211
+ #> PydanticCustomError: 'missing'
212
+ ```
213
+
214
+ """
215
+ if isinstance(value, str):
216
+ value = cls.optional_string_validator(value=value)
217
+
218
+ if value is None:
219
+ raise PydanticCustomError("missing", cls.error_types["missing"])
220
+
221
+ return value
File without changes
@@ -0,0 +1,120 @@
1
+ from decimal import Decimal
2
+ from typing import Any
3
+
4
+ import pytest
5
+ from pydantic import EmailStr
6
+ from pydantic_core._pydantic_core import PydanticCustomError
7
+
8
+ from src.various_api_tools.validators.pydantic import PydanticValidator
9
+
10
+
11
+ class TestPydanticValidator:
12
+ @pytest.mark.parametrize(
13
+ "input_value, expected_output",
14
+ [
15
+ (" test ", "test"),
16
+ ("", ""),
17
+ (" ", ""),
18
+ ],
19
+ )
20
+ def test_strip_validator(self, input_value: str, expected_output: str):
21
+ result = PydanticValidator.strip_validator(input_value)
22
+ assert result == expected_output
23
+
24
+ @pytest.mark.parametrize(
25
+ "input_value, expected_output, is_raised",
26
+ [
27
+ (" test ", "test", False),
28
+ ("", None, False),
29
+ (123, None, True),
30
+ ],
31
+ )
32
+ def test_optional_string_validator(
33
+ self,
34
+ input_value: Any,
35
+ expected_output: str | None,
36
+ is_raised: bool,
37
+ ):
38
+ if is_raised:
39
+ with pytest.raises(Exception):
40
+ PydanticValidator.optional_string_validator(input_value)
41
+ else:
42
+ result = PydanticValidator.optional_string_validator(input_value)
43
+ assert result == expected_output
44
+
45
+ @pytest.mark.parametrize(
46
+ "input_value, expected_output, is_raised",
47
+ [
48
+ ("test@example.com", EmailStr._validate("test@example.com"), False),
49
+ (None, None, False),
50
+ ("invalid-email", None, True),
51
+ ],
52
+ )
53
+ def test_email_validator(
54
+ self, input_value: Any, expected_output: EmailStr | None, is_raised: bool,
55
+ ):
56
+ if is_raised:
57
+ with pytest.raises(Exception):
58
+ PydanticValidator.email_validator(input_value)
59
+ else:
60
+ result = PydanticValidator.email_validator(input_value)
61
+ assert result == expected_output
62
+
63
+ @pytest.mark.parametrize(
64
+ "input_value, expected_output, is_raised",
65
+ [
66
+ (Decimal("45.0"), Decimal("45.0"), False),
67
+ (Decimal("90.0"), Decimal("90.0"), False),
68
+ (Decimal("-90.0"), Decimal("-90.0"), False),
69
+ (Decimal("100.0"), None, True),
70
+ ],
71
+ )
72
+ def test_latitude_validator(
73
+ self, input_value: Decimal, expected_output: Decimal | None, is_raised: bool,
74
+ ):
75
+ if is_raised:
76
+ with pytest.raises(Exception):
77
+ PydanticValidator.latitude_validator(input_value)
78
+ else:
79
+ result = PydanticValidator.latitude_validator(input_value)
80
+ assert result == expected_output
81
+
82
+ @pytest.mark.parametrize(
83
+ "input_value, expected_output, is_raised",
84
+ [
85
+ (Decimal("90.0"), Decimal("90.0"), False),
86
+ (Decimal("-180.0"), Decimal("-180.0"), False),
87
+ (Decimal("180.0"), Decimal("180.0"), False),
88
+ (Decimal("200.0"), None, True),
89
+ ],
90
+ )
91
+ def test_longitude_validator(
92
+ self, input_value: Decimal, expected_output: Decimal | None, is_raised: bool,
93
+ ):
94
+ if is_raised:
95
+ with pytest.raises(Exception):
96
+ PydanticValidator.longitude_validator(input_value)
97
+ else:
98
+ result = PydanticValidator.longitude_validator(input_value)
99
+ assert result == expected_output
100
+
101
+ @pytest.mark.parametrize(
102
+ "input_value, expected_output, is_raised",
103
+ [
104
+ ("test", "test", False),
105
+ (None, pytest.raises(PydanticCustomError), True),
106
+ ("", pytest.raises(PydanticCustomError), True),
107
+ ],
108
+ )
109
+ def test_validate_required_field(
110
+ self,
111
+ input_value: Any,
112
+ expected_output: Any,
113
+ is_raised: bool,
114
+ ):
115
+ if is_raised:
116
+ with pytest.raises(Exception):
117
+ PydanticValidator.validate_required_field(input_value)
118
+ else:
119
+ result = PydanticValidator.validate_required_field(input_value)
120
+ assert result == expected_output