msgspex 1.0.0__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 (63) hide show
  1. msgspex-1.0.0/LICENSE +21 -0
  2. msgspex-1.0.0/PKG-INFO +97 -0
  3. msgspex-1.0.0/README.md +52 -0
  4. msgspex-1.0.0/msgspex/__init__.py +14 -0
  5. msgspex-1.0.0/msgspex/caster.py +18 -0
  6. msgspex-1.0.0/msgspex/casters/__init__.py +3 -0
  7. msgspex-1.0.0/msgspex/casters/datetime.py +12 -0
  8. msgspex-1.0.0/msgspex/custom_types/__init__.py +52 -0
  9. msgspex-1.0.0/msgspex/custom_types/datetime.py +79 -0
  10. msgspex-1.0.0/msgspex/custom_types/email.py +156 -0
  11. msgspex-1.0.0/msgspex/custom_types/enum.py +84 -0
  12. msgspex-1.0.0/msgspex/custom_types/hostname.py +101 -0
  13. msgspex-1.0.0/msgspex/custom_types/ip.py +12 -0
  14. msgspex-1.0.0/msgspex/custom_types/json_pointer.py +79 -0
  15. msgspex-1.0.0/msgspex/custom_types/literal.py +25 -0
  16. msgspex-1.0.0/msgspex/custom_types/numeric.py +76 -0
  17. msgspex-1.0.0/msgspex/custom_types/option.py +17 -0
  18. msgspex-1.0.0/msgspex/custom_types/regex.py +29 -0
  19. msgspex-1.0.0/msgspex/custom_types/uri.py +160 -0
  20. msgspex-1.0.0/msgspex/decode_hooks/__init__.py +30 -0
  21. msgspex-1.0.0/msgspex/decode_hooks/datetime.py +137 -0
  22. msgspex-1.0.0/msgspex/decode_hooks/email.py +13 -0
  23. msgspex-1.0.0/msgspex/decode_hooks/enum.py +12 -0
  24. msgspex-1.0.0/msgspex/decode_hooks/hostname.py +13 -0
  25. msgspex-1.0.0/msgspex/decode_hooks/ip.py +20 -0
  26. msgspex-1.0.0/msgspex/decode_hooks/json_pointer.py +13 -0
  27. msgspex-1.0.0/msgspex/decode_hooks/literal.py +20 -0
  28. msgspex-1.0.0/msgspex/decode_hooks/numeric.py +57 -0
  29. msgspex-1.0.0/msgspex/decode_hooks/option.py +45 -0
  30. msgspex-1.0.0/msgspex/decode_hooks/regex.py +19 -0
  31. msgspex-1.0.0/msgspex/decode_hooks/sum.py +58 -0
  32. msgspex-1.0.0/msgspex/decode_hooks/timedelta.py +28 -0
  33. msgspex-1.0.0/msgspex/decode_hooks/uri.py +15 -0
  34. msgspex-1.0.0/msgspex/decoder.py +211 -0
  35. msgspex-1.0.0/msgspex/encode_hooks/__init__.py +26 -0
  36. msgspex-1.0.0/msgspex/encode_hooks/datetime.py +66 -0
  37. msgspex-1.0.0/msgspex/encode_hooks/email.py +11 -0
  38. msgspex-1.0.0/msgspex/encode_hooks/enum.py +12 -0
  39. msgspex-1.0.0/msgspex/encode_hooks/hostname.py +11 -0
  40. msgspex-1.0.0/msgspex/encode_hooks/ip.py +11 -0
  41. msgspex-1.0.0/msgspex/encode_hooks/json_pointer.py +11 -0
  42. msgspex-1.0.0/msgspex/encode_hooks/numeric.py +17 -0
  43. msgspex-1.0.0/msgspex/encode_hooks/option.py +14 -0
  44. msgspex-1.0.0/msgspex/encode_hooks/regex.py +10 -0
  45. msgspex-1.0.0/msgspex/encode_hooks/sum.py +13 -0
  46. msgspex-1.0.0/msgspex/encode_hooks/timedelta.py +10 -0
  47. msgspex-1.0.0/msgspex/encode_hooks/uri.py +13 -0
  48. msgspex-1.0.0/msgspex/encoder.py +174 -0
  49. msgspex-1.0.0/msgspex/model.py +117 -0
  50. msgspex-1.0.0/msgspex/model.pyi +57 -0
  51. msgspex-1.0.0/msgspex/py.typed +0 -0
  52. msgspex-1.0.0/msgspex/tools/__init__.py +16 -0
  53. msgspex-1.0.0/msgspex/tools/annotations.py +164 -0
  54. msgspex-1.0.0/msgspex/tools/bundle.py +237 -0
  55. msgspex-1.0.0/msgspex/tools/fullname.py +70 -0
  56. msgspex-1.0.0/msgspex/tools/model.py +53 -0
  57. msgspex-1.0.0/msgspex.egg-info/PKG-INFO +97 -0
  58. msgspex-1.0.0/msgspex.egg-info/SOURCES.txt +61 -0
  59. msgspex-1.0.0/msgspex.egg-info/dependency_links.txt +1 -0
  60. msgspex-1.0.0/msgspex.egg-info/requires.txt +2 -0
  61. msgspex-1.0.0/msgspex.egg-info/top_level.txt +1 -0
  62. msgspex-1.0.0/pyproject.toml +114 -0
  63. msgspex-1.0.0/setup.cfg +4 -0
msgspex-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 luwqz1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
msgspex-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: msgspex
3
+ Version: 1.0.0
4
+ Summary: An extra msgspec collection of custom types, casters, encode hooks and decode hooks.
5
+ Author-email: luwqz1 <howluwqz1@gmail.com>
6
+ Maintainer-email: luwqz1 <howluwqz1@gmail.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 luwqz1
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Project-URL: Source, https://github.com/luwqz1/msgspextra
30
+ Project-URL: Bug Tracker, https://github.com/luwqz1/msgspextra/issues
31
+ Keywords: msgspec,custom types,decode hooks,encode hooks,msgspex,fast model,dataclasses,kungfu
32
+ Classifier: Environment :: Console
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3.14
36
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
37
+ Classifier: Topic :: Software Development :: Quality Assurance
38
+ Classifier: Typing :: Typed
39
+ Requires-Python: >=3.14
40
+ Description-Content-Type: text/markdown
41
+ License-File: LICENSE
42
+ Requires-Dist: kungfu-fp>=1.0.0
43
+ Requires-Dist: msgspec>=0.20.0
44
+ Dynamic: license-file
45
+
46
+ # msgspex
47
+
48
+ A collection of `msgspec` extensions: custom types, cast helpers, `decode hooks`, and `encode hooks`.
49
+
50
+ ## Quick Start
51
+
52
+ ```python
53
+ import msgspex
54
+ from msgspex.custom_types import Email, datetime
55
+
56
+ value = msgspex.decoder.decode('"user@example.com"', type=Email)
57
+ dt = msgspex.decoder.decode('"2024-01-02T03:04:05Z"', type=datetime)
58
+ payload = msgspex.encoder.encode(dt)
59
+ ```
60
+
61
+ After `import msgspex`, all hooks and types are registered automatically.
62
+
63
+ ## Custom Types
64
+
65
+ ### 1. Types from kungfu
66
+
67
+ - `Option[T]` — optional value type based on `kungfu` (`Some | Nothing | msgspec.UnsetType`).
68
+
69
+ There is also decode-hook integration for `kungfu.Sum` (not a custom type, but supported by the decoder).
70
+
71
+ ### 2. Types Derived from stdlib
72
+
73
+ - `date` — re-export of `datetime.date`.
74
+ - `datetime` — meta-type that covers `StringTimestampDatetime`, `IntTimestampDatetime`, `FloatTimestampDatetime`, `ISODatetime` (alias: `isodatetime`), and `datetime.datetime`.
75
+ - `timedelta` — subclass of `datetime.timedelta` with cast support.
76
+ - `StrEnum`, `IntEnum`, `FloatEnum`, `BaseEnumMeta` — `enum` extensions for stable handling of unknown values.
77
+ - `Literal` — runtime type conceptually compatible with `typing.Literal`.
78
+
79
+ ### 3. OpenAPI-Oriented Types
80
+
81
+ - `Email` — `format: email`
82
+ - `IDNEmail` — `format: idn-email`
83
+ - `URI` — `format: uri`
84
+ - `URIReference` — `format: uri-reference`
85
+ - `IRI` — `format: iri`
86
+ - `IRIReference` — `format: iri-reference`
87
+ - `Hostname` — `format: hostname`
88
+ - `IDNHostname` — `format: idn-hostname`
89
+ - `IPv4` — `format: ipv4`
90
+ - `IPv6` — `format: ipv6`
91
+ - `JsonPointer` — `format: json-pointer`
92
+ - `RelativeJsonPointer` — `format: relative-json-pointer`
93
+ - `Regex` — `format: regex`
94
+ - `Int32`, `Int64` — range-limited integer types
95
+ - `Float32`, `Float64` — finite, range-limited floating-point types
96
+
97
+ `UUID` is not redefined here, because it is already supported by `msgspec`.
@@ -0,0 +1,52 @@
1
+ # msgspex
2
+
3
+ A collection of `msgspec` extensions: custom types, cast helpers, `decode hooks`, and `encode hooks`.
4
+
5
+ ## Quick Start
6
+
7
+ ```python
8
+ import msgspex
9
+ from msgspex.custom_types import Email, datetime
10
+
11
+ value = msgspex.decoder.decode('"user@example.com"', type=Email)
12
+ dt = msgspex.decoder.decode('"2024-01-02T03:04:05Z"', type=datetime)
13
+ payload = msgspex.encoder.encode(dt)
14
+ ```
15
+
16
+ After `import msgspex`, all hooks and types are registered automatically.
17
+
18
+ ## Custom Types
19
+
20
+ ### 1. Types from kungfu
21
+
22
+ - `Option[T]` — optional value type based on `kungfu` (`Some | Nothing | msgspec.UnsetType`).
23
+
24
+ There is also decode-hook integration for `kungfu.Sum` (not a custom type, but supported by the decoder).
25
+
26
+ ### 2. Types Derived from stdlib
27
+
28
+ - `date` — re-export of `datetime.date`.
29
+ - `datetime` — meta-type that covers `StringTimestampDatetime`, `IntTimestampDatetime`, `FloatTimestampDatetime`, `ISODatetime` (alias: `isodatetime`), and `datetime.datetime`.
30
+ - `timedelta` — subclass of `datetime.timedelta` with cast support.
31
+ - `StrEnum`, `IntEnum`, `FloatEnum`, `BaseEnumMeta` — `enum` extensions for stable handling of unknown values.
32
+ - `Literal` — runtime type conceptually compatible with `typing.Literal`.
33
+
34
+ ### 3. OpenAPI-Oriented Types
35
+
36
+ - `Email` — `format: email`
37
+ - `IDNEmail` — `format: idn-email`
38
+ - `URI` — `format: uri`
39
+ - `URIReference` — `format: uri-reference`
40
+ - `IRI` — `format: iri`
41
+ - `IRIReference` — `format: iri-reference`
42
+ - `Hostname` — `format: hostname`
43
+ - `IDNHostname` — `format: idn-hostname`
44
+ - `IPv4` — `format: ipv4`
45
+ - `IPv6` — `format: ipv6`
46
+ - `JsonPointer` — `format: json-pointer`
47
+ - `RelativeJsonPointer` — `format: relative-json-pointer`
48
+ - `Regex` — `format: regex`
49
+ - `Int32`, `Int64` — range-limited integer types
50
+ - `Float32`, `Float64` — finite, range-limited floating-point types
51
+
52
+ `UUID` is not redefined here, because it is already supported by `msgspec`.
@@ -0,0 +1,14 @@
1
+ """An extra `msgspec` collection of custom types, casters, encode hooks and decode hooks."""
2
+
3
+ # ruff: noqa: F401, F403
4
+ # type: ignore
5
+
6
+ from msgspex.caster import *
7
+ from msgspex.casters import *
8
+ from msgspex.custom_types import *
9
+ from msgspex.decode_hooks import *
10
+ from msgspex.decoder import *
11
+ from msgspex.encode_hooks import *
12
+ from msgspex.encoder import *
13
+ from msgspex.model import *
14
+ from msgspex.tools import *
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import typing
5
+
6
+
7
+ def is_supports_cast(obj: typing.Any, /) -> typing.TypeGuard[SupportsCast]:
8
+ return isinstance(obj, SupportsCast)
9
+
10
+
11
+ @typing.runtime_checkable
12
+ class SupportsCast(typing.Protocol):
13
+ @classmethod
14
+ @abc.abstractmethod
15
+ def cast(cls, obj: typing.Any) -> typing.Self: ...
16
+
17
+
18
+ __all__ = ("SupportsCast", "is_supports_cast")
@@ -0,0 +1,3 @@
1
+ from msgspex.casters.datetime import datetime, timedelta
2
+
3
+ __all__ = ("datetime", "timedelta")
@@ -0,0 +1,12 @@
1
+ import datetime as dt
2
+ import typing
3
+
4
+ from msgspex.caster import SupportsCast
5
+ from msgspex.custom_types.datetime import datetime, timedelta
6
+ from msgspex.encoder import encoder
7
+
8
+ encoder.add_cast_type(dt.datetime, typing.cast("type[SupportsCast]", datetime))
9
+ encoder.add_cast_type(dt.timedelta, typing.cast("type[SupportsCast]", timedelta))
10
+
11
+
12
+ __all__ = ("datetime", "timedelta")
@@ -0,0 +1,52 @@
1
+ from msgspex.custom_types.datetime import (
2
+ FloatTimestampDatetime,
3
+ IntTimestampDatetime,
4
+ StringTimestampDatetime,
5
+ date,
6
+ datetime,
7
+ isodatetime,
8
+ timedelta,
9
+ )
10
+ from msgspex.custom_types.email import Email, IDNEmail
11
+ from msgspex.custom_types.enum import BaseEnumMeta, FloatEnum, IntEnum, StrEnum
12
+ from msgspex.custom_types.hostname import Hostname, IDNHostname
13
+ from msgspex.custom_types.ip import IPv4, IPv6
14
+ from msgspex.custom_types.json_pointer import JsonPointer, RelativeJsonPointer
15
+ from msgspex.custom_types.literal import Literal
16
+ from msgspex.custom_types.numeric import Float32, Float64, Int32, Int64
17
+ from msgspex.custom_types.option import Option
18
+ from msgspex.custom_types.regex import Regex
19
+ from msgspex.custom_types.uri import IRI, URI, IRIReference, URIReference
20
+
21
+ __all__ = (
22
+ "IRI",
23
+ "URI",
24
+ "BaseEnumMeta",
25
+ "Email",
26
+ "Float32",
27
+ "Float64",
28
+ "FloatEnum",
29
+ "FloatTimestampDatetime",
30
+ "Hostname",
31
+ "IDNEmail",
32
+ "IDNHostname",
33
+ "IPv4",
34
+ "IPv6",
35
+ "IRIReference",
36
+ "Int32",
37
+ "Int64",
38
+ "IntEnum",
39
+ "IntTimestampDatetime",
40
+ "JsonPointer",
41
+ "Literal",
42
+ "Option",
43
+ "Regex",
44
+ "RelativeJsonPointer",
45
+ "StrEnum",
46
+ "StringTimestampDatetime",
47
+ "URIReference",
48
+ "date",
49
+ "datetime",
50
+ "isodatetime",
51
+ "timedelta",
52
+ )
@@ -0,0 +1,79 @@
1
+ import datetime as dt
2
+ import typing
3
+ from datetime import date
4
+
5
+ from msgspex.caster import SupportsCast
6
+
7
+
8
+ class Datetime(dt.datetime, SupportsCast):
9
+ @classmethod
10
+ def cast(cls, obj: dt.datetime) -> typing.Self:
11
+ return cls.fromtimestamp(timestamp=obj.timestamp(), tz=obj.tzinfo)
12
+
13
+
14
+ class StringTimestampDatetime(Datetime):
15
+ """String timestamp datetime."""
16
+
17
+ is_from_digits_string: bool = False
18
+ is_from_float_string: bool = False
19
+
20
+ @classmethod
21
+ def from_digits_string(cls, digits: str, /) -> typing.Self:
22
+ obj = cls.fromtimestamp(timestamp=int(digits), tz=dt.timezone.utc)
23
+ obj.is_from_digits_string = True
24
+ return obj
25
+
26
+ @classmethod
27
+ def from_float_string(cls, float_str: str, /) -> typing.Self:
28
+ obj = cls.fromtimestamp(timestamp=float(float_str), tz=dt.timezone.utc)
29
+ obj.is_from_float_string = True
30
+ return obj
31
+
32
+
33
+ class IntTimestampDatetime(Datetime):
34
+ """Integer timestamp datetime."""
35
+
36
+
37
+ class FloatTimestampDatetime(Datetime):
38
+ """Float timestamp datetime."""
39
+
40
+
41
+ class ISODatetime(Datetime):
42
+ """ISO datetime."""
43
+
44
+
45
+ isodatetime = ISODatetime
46
+
47
+
48
+ class timedelta(dt.timedelta, SupportsCast): # noqa: N801 # type: ignore
49
+ @classmethod
50
+ def cast(cls, obj: dt.timedelta) -> typing.Self:
51
+ return cls(seconds=obj.total_seconds())
52
+
53
+
54
+ DT: typing.TypeAlias = StringTimestampDatetime | IntTimestampDatetime | FloatTimestampDatetime | ISODatetime | dt.datetime
55
+
56
+
57
+ if typing.TYPE_CHECKING:
58
+ from datetime import datetime, timedelta # type: ignore
59
+
60
+ else:
61
+
62
+ class datetimemeta(type): # noqa: N801
63
+ def __instancecheck__(cls, __instance: typing.Any) -> bool:
64
+ return isinstance(__instance, DT)
65
+
66
+ class datetime(metaclass=datetimemeta): # noqa: N801
67
+ pass
68
+
69
+
70
+ __all__ = (
71
+ "FloatTimestampDatetime",
72
+ "ISODatetime",
73
+ "IntTimestampDatetime",
74
+ "StringTimestampDatetime",
75
+ "date",
76
+ "datetime",
77
+ "isodatetime",
78
+ "timedelta",
79
+ )
@@ -0,0 +1,156 @@
1
+ import re
2
+ import typing
3
+ from annotationlib import type_repr
4
+
5
+ LOCAL_PART_PATTERN: typing.Final = re.compile(r"^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+$")
6
+ LABEL_PATTERN: typing.Final = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
7
+
8
+
9
+ def validate_email(email: typing.Any, /) -> str:
10
+ if not isinstance(email, str):
11
+ raise TypeError(f"Email must be `str`, got `{type_repr(email.__class__)}`.")
12
+
13
+ if not email:
14
+ raise TypeError("Email must be non-empty.")
15
+
16
+ if any(ch.isspace() for ch in email):
17
+ raise TypeError("Email must not contain whitespace.")
18
+
19
+ if email.count("@") != 1:
20
+ raise TypeError("Email must contain exactly one `@`.")
21
+
22
+ local, domain = email.split("@", 1)
23
+
24
+ if len(email) > 254:
25
+ raise TypeError("Email is too long (max 254 characters).")
26
+
27
+ if len(local) == 0 or len(local) > 64:
28
+ raise TypeError("Local-part length must be 1..64 characters.")
29
+
30
+ if len(domain) == 0 or len(domain) > 253:
31
+ raise TypeError("Domain length must be 1..253 characters.")
32
+
33
+ if not LOCAL_PART_PATTERN.fullmatch(local):
34
+ raise TypeError("Local-part contains invalid characters (ASCII subset expected).")
35
+
36
+ if local[0] == "." or local[-1] == ".":
37
+ raise TypeError("Local-part must not start or end with `.`.")
38
+
39
+ if ".." in local:
40
+ raise TypeError("Local-part must not contain consecutive dots `..`.")
41
+
42
+ if domain[0] == "." or domain[-1] == ".":
43
+ raise TypeError("Domain must not start or end with `.`.")
44
+
45
+ if ".." in domain:
46
+ raise TypeError("Domain must not contain consecutive dots `..`.")
47
+
48
+ labels = domain.split(".")
49
+ if len(labels) < 2:
50
+ raise TypeError("Domain must contain a dot (e.g. `example.com`).")
51
+
52
+ for label in labels:
53
+ if not label:
54
+ raise TypeError("Domain contains an empty label.")
55
+
56
+ if len(label) > 63:
57
+ raise TypeError("Domain label is too long (max 63 characters).")
58
+
59
+ if not LABEL_PATTERN.fullmatch(label):
60
+ raise TypeError(f"Domain label is invalid: {label!r}.")
61
+
62
+ if labels[-1].isdigit():
63
+ raise TypeError("Top-level domain must not be all numeric.")
64
+
65
+ return email
66
+
67
+
68
+ def validate_idn_email(email: typing.Any, /) -> str:
69
+ if not isinstance(email, str):
70
+ raise TypeError(f"IDNEmail must be `str`, got `{type_repr(email.__class__)}`.")
71
+
72
+ if not email:
73
+ raise TypeError("IDNEmail must be non-empty.")
74
+
75
+ if any(ch.isspace() for ch in email):
76
+ raise TypeError("IDNEmail must not contain whitespace.")
77
+
78
+ if email.count("@") != 1:
79
+ raise TypeError("IDNEmail must contain exactly one `@`.")
80
+
81
+ local, domain = email.split("@", 1)
82
+
83
+ if len(local) == 0 or len(local) > 64:
84
+ raise TypeError("Local-part length must be 1..64 characters.")
85
+
86
+ if local[0] == "." or local[-1] == ".":
87
+ raise TypeError("Local-part must not start or end with `.`.")
88
+
89
+ if ".." in local:
90
+ raise TypeError("Local-part must not contain consecutive dots `..`.")
91
+
92
+ if any(ord(ch) < 0x20 for ch in local):
93
+ raise TypeError("Local-part must not contain control characters.")
94
+
95
+ if len(domain) == 0:
96
+ raise TypeError("Domain must be non-empty.")
97
+
98
+ if domain[0] == "." or domain[-1] == ".":
99
+ raise TypeError("Domain must not start or end with `.`.")
100
+
101
+ if ".." in domain:
102
+ raise TypeError("Domain must not contain consecutive dots `..`.")
103
+
104
+ labels = domain.split(".")
105
+ if len(labels) < 2:
106
+ raise TypeError("Domain must contain a dot (e.g. `example.com`).")
107
+
108
+ ascii_labels: list[str] = []
109
+ for label in labels:
110
+ if not label:
111
+ raise TypeError("Domain contains an empty label.")
112
+
113
+ try:
114
+ ascii_label = label.encode("idna").decode("ascii")
115
+ except UnicodeError as ex:
116
+ raise TypeError(f"Domain label is invalid: {label!r}.") from ex
117
+
118
+ if len(ascii_label) > 63:
119
+ raise TypeError("Domain label is too long (max 63 characters).")
120
+
121
+ if not LABEL_PATTERN.fullmatch(ascii_label):
122
+ raise TypeError(f"Domain label is invalid: {label!r}.")
123
+
124
+ ascii_labels.append(ascii_label)
125
+
126
+ ascii_domain = ".".join(ascii_labels)
127
+
128
+ if len(ascii_domain) > 253:
129
+ raise TypeError("Domain length must be <= 253 characters in IDNA ASCII form.")
130
+
131
+ if ascii_labels[-1].isdigit():
132
+ raise TypeError("Top-level domain must not be all numeric.")
133
+
134
+ if len(local) + 1 + len(ascii_domain) > 254:
135
+ raise TypeError("IDNEmail is too long (max 254 characters in IDNA ASCII form).")
136
+
137
+ return email
138
+
139
+
140
+ class Email(str):
141
+ def __new__(cls, email: str, /) -> typing.Self:
142
+ return super().__new__(cls, validate_email(email))
143
+
144
+ def __repr__(self) -> str:
145
+ return f"{type(self).__name__}({super().__repr__()})"
146
+
147
+
148
+ class IDNEmail(str):
149
+ def __new__(cls, email: str, /) -> typing.Self:
150
+ return super().__new__(cls, validate_idn_email(email))
151
+
152
+ def __repr__(self) -> str:
153
+ return f"{type(self).__name__}({super().__repr__()})"
154
+
155
+
156
+ __all__ = ("Email", "IDNEmail")
@@ -0,0 +1,84 @@
1
+ import enum
2
+ import math
3
+ import sys
4
+ import typing
5
+
6
+ NOT_SUPPORTED: typing.Final = "NOT_SUPPORTED"
7
+
8
+
9
+ def _is_friend(bases: tuple[type[typing.Any], ...], /) -> bool:
10
+ return any(friend in bases for friend in ENUM_FRIENDS)
11
+
12
+
13
+ class StrEnum(str, enum.Enum):
14
+ def __str__(self) -> str:
15
+ return self.value
16
+
17
+
18
+ class IntEnum(int, enum.Enum):
19
+ def __int__(self) -> int:
20
+ return self.value
21
+
22
+ def __float__(self) -> float:
23
+ return float(self.value)
24
+
25
+ def __index__(self) -> int:
26
+ return self.value
27
+
28
+
29
+ class FloatEnum(float, enum.Enum):
30
+ def __int__(self) -> int:
31
+ return int(self.value)
32
+
33
+ def __float__(self) -> float:
34
+ return self.value
35
+
36
+
37
+ class BaseEnumMeta(enum.EnumMeta, type):
38
+ if typing.TYPE_CHECKING:
39
+
40
+ class _BaseEnumMeta(enum.Enum): # noqa
41
+ NOT_SUPPORTED = enum.auto()
42
+
43
+ NOT_SUPPORTED: typing.Literal[_BaseEnumMeta.NOT_SUPPORTED]
44
+
45
+ else:
46
+
47
+ @staticmethod
48
+ def _member_missing(cls, value):
49
+ return cls._member_map_["NOT_SUPPORTED"]
50
+
51
+ def __new__(
52
+ metacls,
53
+ cls,
54
+ bases,
55
+ classdict,
56
+ *,
57
+ boundary=None,
58
+ _simple=False,
59
+ **kwds,
60
+ ):
61
+ if _is_friend(bases):
62
+ classdict["NOT_SUPPORTED"] = next(
63
+ (value for base, value in NOT_SUPPORTED_VALUES.items() if base in bases),
64
+ NOT_SUPPORTED,
65
+ )
66
+
67
+ classdict["_missing_"] = classmethod(BaseEnumMeta._member_missing)
68
+ return super().__new__(metacls, cls, bases, classdict, boundary=boundary, _simple=_simple, **kwds)
69
+
70
+
71
+ ENUM_FRIENDS: typing.Final = (str, int, float, StrEnum, IntEnum, FloatEnum)
72
+ NOT_SUPPORTED_VALUES: typing.Final = {
73
+ str: NOT_SUPPORTED,
74
+ int: sys.maxsize,
75
+ float: math.inf,
76
+ StrEnum: NOT_SUPPORTED,
77
+ IntEnum: sys.maxsize,
78
+ FloatEnum: math.inf,
79
+ enum.StrEnum: NOT_SUPPORTED,
80
+ enum.IntEnum: sys.maxsize,
81
+ }
82
+
83
+
84
+ __all__ = ("BaseEnumMeta", "FloatEnum", "IntEnum", "StrEnum")