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.
- msgspex-1.0.0/LICENSE +21 -0
- msgspex-1.0.0/PKG-INFO +97 -0
- msgspex-1.0.0/README.md +52 -0
- msgspex-1.0.0/msgspex/__init__.py +14 -0
- msgspex-1.0.0/msgspex/caster.py +18 -0
- msgspex-1.0.0/msgspex/casters/__init__.py +3 -0
- msgspex-1.0.0/msgspex/casters/datetime.py +12 -0
- msgspex-1.0.0/msgspex/custom_types/__init__.py +52 -0
- msgspex-1.0.0/msgspex/custom_types/datetime.py +79 -0
- msgspex-1.0.0/msgspex/custom_types/email.py +156 -0
- msgspex-1.0.0/msgspex/custom_types/enum.py +84 -0
- msgspex-1.0.0/msgspex/custom_types/hostname.py +101 -0
- msgspex-1.0.0/msgspex/custom_types/ip.py +12 -0
- msgspex-1.0.0/msgspex/custom_types/json_pointer.py +79 -0
- msgspex-1.0.0/msgspex/custom_types/literal.py +25 -0
- msgspex-1.0.0/msgspex/custom_types/numeric.py +76 -0
- msgspex-1.0.0/msgspex/custom_types/option.py +17 -0
- msgspex-1.0.0/msgspex/custom_types/regex.py +29 -0
- msgspex-1.0.0/msgspex/custom_types/uri.py +160 -0
- msgspex-1.0.0/msgspex/decode_hooks/__init__.py +30 -0
- msgspex-1.0.0/msgspex/decode_hooks/datetime.py +137 -0
- msgspex-1.0.0/msgspex/decode_hooks/email.py +13 -0
- msgspex-1.0.0/msgspex/decode_hooks/enum.py +12 -0
- msgspex-1.0.0/msgspex/decode_hooks/hostname.py +13 -0
- msgspex-1.0.0/msgspex/decode_hooks/ip.py +20 -0
- msgspex-1.0.0/msgspex/decode_hooks/json_pointer.py +13 -0
- msgspex-1.0.0/msgspex/decode_hooks/literal.py +20 -0
- msgspex-1.0.0/msgspex/decode_hooks/numeric.py +57 -0
- msgspex-1.0.0/msgspex/decode_hooks/option.py +45 -0
- msgspex-1.0.0/msgspex/decode_hooks/regex.py +19 -0
- msgspex-1.0.0/msgspex/decode_hooks/sum.py +58 -0
- msgspex-1.0.0/msgspex/decode_hooks/timedelta.py +28 -0
- msgspex-1.0.0/msgspex/decode_hooks/uri.py +15 -0
- msgspex-1.0.0/msgspex/decoder.py +211 -0
- msgspex-1.0.0/msgspex/encode_hooks/__init__.py +26 -0
- msgspex-1.0.0/msgspex/encode_hooks/datetime.py +66 -0
- msgspex-1.0.0/msgspex/encode_hooks/email.py +11 -0
- msgspex-1.0.0/msgspex/encode_hooks/enum.py +12 -0
- msgspex-1.0.0/msgspex/encode_hooks/hostname.py +11 -0
- msgspex-1.0.0/msgspex/encode_hooks/ip.py +11 -0
- msgspex-1.0.0/msgspex/encode_hooks/json_pointer.py +11 -0
- msgspex-1.0.0/msgspex/encode_hooks/numeric.py +17 -0
- msgspex-1.0.0/msgspex/encode_hooks/option.py +14 -0
- msgspex-1.0.0/msgspex/encode_hooks/regex.py +10 -0
- msgspex-1.0.0/msgspex/encode_hooks/sum.py +13 -0
- msgspex-1.0.0/msgspex/encode_hooks/timedelta.py +10 -0
- msgspex-1.0.0/msgspex/encode_hooks/uri.py +13 -0
- msgspex-1.0.0/msgspex/encoder.py +174 -0
- msgspex-1.0.0/msgspex/model.py +117 -0
- msgspex-1.0.0/msgspex/model.pyi +57 -0
- msgspex-1.0.0/msgspex/py.typed +0 -0
- msgspex-1.0.0/msgspex/tools/__init__.py +16 -0
- msgspex-1.0.0/msgspex/tools/annotations.py +164 -0
- msgspex-1.0.0/msgspex/tools/bundle.py +237 -0
- msgspex-1.0.0/msgspex/tools/fullname.py +70 -0
- msgspex-1.0.0/msgspex/tools/model.py +53 -0
- msgspex-1.0.0/msgspex.egg-info/PKG-INFO +97 -0
- msgspex-1.0.0/msgspex.egg-info/SOURCES.txt +61 -0
- msgspex-1.0.0/msgspex.egg-info/dependency_links.txt +1 -0
- msgspex-1.0.0/msgspex.egg-info/requires.txt +2 -0
- msgspex-1.0.0/msgspex.egg-info/top_level.txt +1 -0
- msgspex-1.0.0/pyproject.toml +114 -0
- 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`.
|
msgspex-1.0.0/README.md
ADDED
|
@@ -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,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")
|