literalenum 0.1.1__py3-none-any.whl
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.
- literalenum/__init__.py +21 -0
- literalenum/compatibility_extensions/__init__.py +15 -0
- literalenum/compatibility_extensions/annotated.py +6 -0
- literalenum/compatibility_extensions/bare_class.py +2 -0
- literalenum/compatibility_extensions/base_model.py +23 -0
- literalenum/compatibility_extensions/click_choice.py +3 -0
- literalenum/compatibility_extensions/django_choices.py +2 -0
- literalenum/compatibility_extensions/enum.py +7 -0
- literalenum/compatibility_extensions/graphene_enum.py +18 -0
- literalenum/compatibility_extensions/int_enum.py +9 -0
- literalenum/compatibility_extensions/json_schema.py +145 -0
- literalenum/compatibility_extensions/literal.py +9 -0
- literalenum/compatibility_extensions/random_choice.py +3 -0
- literalenum/compatibility_extensions/regex.py +10 -0
- literalenum/compatibility_extensions/sqlalchemy_enum.py +6 -0
- literalenum/compatibility_extensions/str_enum.py +9 -0
- literalenum/compatibility_extensions/strawberry_enum.py +18 -0
- literalenum/literal_enum.py +137 -0
- literalenum/mypy_plugin.py +333 -0
- literalenum/py.typed +0 -0
- literalenum/stubgen.py +438 -0
- literalenum-0.1.1.dist-info/METADATA +108 -0
- literalenum-0.1.1.dist-info/RECORD +27 -0
- literalenum-0.1.1.dist-info/WHEEL +4 -0
- literalenum-0.1.1.dist-info/entry_points.txt +2 -0
- literalenum-0.1.1.dist-info/licenses/LICENSE +24 -0
- typing_literalenum.py +670 -0
literalenum/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .literal_enum import LiteralEnum, LiteralEnumMeta
|
|
4
|
+
from .stubgen import main as lestub
|
|
5
|
+
|
|
6
|
+
import typing_literalenum as core
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"LiteralEnum", "LiteralEnumMeta",
|
|
10
|
+
"plugin",
|
|
11
|
+
"compatibility_extensions",
|
|
12
|
+
"lestub",
|
|
13
|
+
"core"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name: str):
|
|
18
|
+
if name == "plugin":
|
|
19
|
+
from .mypy_plugin import plugin
|
|
20
|
+
return plugin
|
|
21
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .annotated import annotated
|
|
2
|
+
from .base_model import base_model
|
|
3
|
+
from .enum import enum
|
|
4
|
+
from .graphene_enum import graphene_enum
|
|
5
|
+
from .int_enum import int_enum
|
|
6
|
+
from .json_schema import json_schema
|
|
7
|
+
from .literal import literal
|
|
8
|
+
from .regex import regex_str, regex_pattern
|
|
9
|
+
from .sqlalchemy_enum import sqlalchemy_enum
|
|
10
|
+
from .str_enum import str_enum
|
|
11
|
+
from .strawberry_enum import strawberry_enum
|
|
12
|
+
from .bare_class import bare_class
|
|
13
|
+
from .click_choice import click_choice
|
|
14
|
+
from .django_choices import django_choices
|
|
15
|
+
from .random_choice import random_choice
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def base_model(
|
|
7
|
+
enum_cls: type,
|
|
8
|
+
*,
|
|
9
|
+
model_name: str | None = None,
|
|
10
|
+
field_name: str = "value",
|
|
11
|
+
description: str | None = None,
|
|
12
|
+
) -> type["BaseModel"]:
|
|
13
|
+
"""
|
|
14
|
+
Create a pydantic BaseModel with a single field that validates against enum_cls.literal.
|
|
15
|
+
"""
|
|
16
|
+
from pydantic import BaseModel, create_model, Field
|
|
17
|
+
ann = Literal[*enum_cls.values()] # <-- Literal["GET","POST",...]
|
|
18
|
+
default = Field(..., description=description) if description else ...
|
|
19
|
+
|
|
20
|
+
return create_model(
|
|
21
|
+
model_name or f"{enum_cls.__name__}Model",
|
|
22
|
+
**{field_name: (ann, default)},
|
|
23
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
def graphene_enum(cls) -> type:
|
|
2
|
+
"""Convert this LiteralEnum to a Graphene GraphQL enum type.
|
|
3
|
+
|
|
4
|
+
Requires ``graphene`` to be installed. Uses canonical members
|
|
5
|
+
only (aliases are excluded)::
|
|
6
|
+
|
|
7
|
+
class Color(LiteralEnum):
|
|
8
|
+
RED = "red"
|
|
9
|
+
GREEN = "green"
|
|
10
|
+
|
|
11
|
+
# use in a Graphene schema
|
|
12
|
+
ColorEnum = Color.graphene_enum
|
|
13
|
+
"""
|
|
14
|
+
import enum as _enum
|
|
15
|
+
import graphene
|
|
16
|
+
|
|
17
|
+
PyEnum = _enum.Enum(cls.__name__, dict(cls.unique_mapping))
|
|
18
|
+
return graphene.Enum.from_enum(PyEnum)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
import typing_literalenum as core
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def int_enum(cls: core.LiteralEnumMeta) -> IntEnum:
|
|
7
|
+
if not all(isinstance(v, int) for v in cls._ordered_values_ if v is not None):
|
|
8
|
+
raise TypeError("int_enum only works on a int-valued LiteralEnum")
|
|
9
|
+
return IntEnum(cls.__name__, dict(cls._members_))
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
JsonSchema = Dict[str, Any]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def json_schema(
|
|
10
|
+
enum_cls: type,
|
|
11
|
+
*,
|
|
12
|
+
title: str | None = None,
|
|
13
|
+
description: str | None = None,
|
|
14
|
+
nullable: bool | None = None,
|
|
15
|
+
openapi: bool = False,
|
|
16
|
+
) -> JsonSchema:
|
|
17
|
+
"""
|
|
18
|
+
Build a JSON Schema (and OpenAPI-friendly) schema for a LiteralEnumMeta class.
|
|
19
|
+
|
|
20
|
+
Expects `enum_cls` to have:
|
|
21
|
+
- _ordered_values_: list/tuple of literal values in order
|
|
22
|
+
- _members_: dict[str, value]
|
|
23
|
+
|
|
24
|
+
Supports literals: str, int, bool, None, bytes.
|
|
25
|
+
For mixed types, emits a union schema (oneOf / anyOf).
|
|
26
|
+
|
|
27
|
+
Params:
|
|
28
|
+
- nullable:
|
|
29
|
+
* None (default): inferred from presence of None in values
|
|
30
|
+
* True/False: force nullable behavior
|
|
31
|
+
- openapi:
|
|
32
|
+
* If True: emits OpenAPI 3.0-friendly shape (uses nullable: true)
|
|
33
|
+
* If False: emits JSON Schema 2020-12-friendly shape (uses type: "null" or oneOf)
|
|
34
|
+
"""
|
|
35
|
+
values: List[Any] = list(getattr(enum_cls, "_ordered_values_", ()))
|
|
36
|
+
if not values:
|
|
37
|
+
raise ValueError(f"{enum_cls!r} has no _ordered_values_")
|
|
38
|
+
|
|
39
|
+
# JSON Schema can't represent bytes directly; common convention is base64 string.
|
|
40
|
+
def _json_type(v: Any) -> str:
|
|
41
|
+
if v is None:
|
|
42
|
+
return "null"
|
|
43
|
+
if isinstance(v, bool):
|
|
44
|
+
return "boolean"
|
|
45
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
46
|
+
return "integer"
|
|
47
|
+
if isinstance(v, str):
|
|
48
|
+
return "string"
|
|
49
|
+
if isinstance(v, (bytes, bytearray, memoryview)):
|
|
50
|
+
return "string"
|
|
51
|
+
# float literal support not listed in your metaclass, but easy to add if you want:
|
|
52
|
+
if isinstance(v, float):
|
|
53
|
+
return "number"
|
|
54
|
+
raise TypeError(f"Unsupported LiteralEnum value {v!r} (type {type(v).__name__})")
|
|
55
|
+
|
|
56
|
+
# Normalize bytes -> base64-ish string representation? (You can swap this.)
|
|
57
|
+
def _normalize(v: Any) -> Any:
|
|
58
|
+
if isinstance(v, (bytes, bytearray, memoryview)):
|
|
59
|
+
# JSON can't carry bytes. Convention: base64 string.
|
|
60
|
+
# If you prefer "binary" format in OpenAPI, keep as string and add format.
|
|
61
|
+
return bytes(v).decode("base64", errors="strict")
|
|
62
|
+
return v
|
|
63
|
+
|
|
64
|
+
normalized_values = [_normalize(v) for v in values]
|
|
65
|
+
types = [_json_type(v) for v in values]
|
|
66
|
+
unique_types = sorted(set(types))
|
|
67
|
+
|
|
68
|
+
has_null = "null" in unique_types
|
|
69
|
+
inferred_nullable = has_null
|
|
70
|
+
if nullable is None:
|
|
71
|
+
nullable = inferred_nullable
|
|
72
|
+
|
|
73
|
+
# Remove nulls from enum list if we represent null via nullable/type: null separately.
|
|
74
|
+
enum_no_null = [v for v, t in zip(normalized_values, types) if t != "null"]
|
|
75
|
+
|
|
76
|
+
schema: JsonSchema = {}
|
|
77
|
+
schema["title"] = title or getattr(enum_cls, "__name__", "LiteralEnum")
|
|
78
|
+
if description:
|
|
79
|
+
schema["description"] = description
|
|
80
|
+
|
|
81
|
+
# If only one non-null type, simplest representation.
|
|
82
|
+
non_null_types = [t for t in unique_types if t != "null"]
|
|
83
|
+
|
|
84
|
+
def _apply_nullable_oas(s: JsonSchema) -> JsonSchema:
|
|
85
|
+
if nullable:
|
|
86
|
+
s["nullable"] = True
|
|
87
|
+
return s
|
|
88
|
+
|
|
89
|
+
# Helper: JSON Schema style nullability (2020-12-ish)
|
|
90
|
+
def _apply_nullable_jsonschema(s: JsonSchema) -> JsonSchema:
|
|
91
|
+
if not nullable:
|
|
92
|
+
return s
|
|
93
|
+
# If already unioned, add {"type": "null"} via oneOf
|
|
94
|
+
if "oneOf" in s:
|
|
95
|
+
s["oneOf"].append({"type": "null"})
|
|
96
|
+
return s
|
|
97
|
+
# If type is a single string, allow null via type array
|
|
98
|
+
t = s.get("type")
|
|
99
|
+
if isinstance(t, str):
|
|
100
|
+
s["type"] = [t, "null"]
|
|
101
|
+
elif isinstance(t, list) and "null" not in t:
|
|
102
|
+
t.append("null")
|
|
103
|
+
else:
|
|
104
|
+
# Fallback: union with null
|
|
105
|
+
s = {"oneOf": [s, {"type": "null"}], **{k: v for k, v in s.items() if k not in ("type", "enum")}}
|
|
106
|
+
return s
|
|
107
|
+
|
|
108
|
+
# Build
|
|
109
|
+
if len(non_null_types) == 1:
|
|
110
|
+
t = non_null_types[0]
|
|
111
|
+
schema["type"] = t
|
|
112
|
+
if enum_no_null:
|
|
113
|
+
schema["enum"] = enum_no_null
|
|
114
|
+
|
|
115
|
+
# bytes convention: if any bytes present, add format hint
|
|
116
|
+
if any(isinstance(v, (bytes, bytearray, memoryview)) for v in values):
|
|
117
|
+
schema.setdefault("format", "byte") # OpenAPI convention for base64
|
|
118
|
+
|
|
119
|
+
if openapi:
|
|
120
|
+
schema = _apply_nullable_oas(schema)
|
|
121
|
+
else:
|
|
122
|
+
schema = _apply_nullable_jsonschema(schema)
|
|
123
|
+
|
|
124
|
+
return schema
|
|
125
|
+
|
|
126
|
+
# Mixed types: use union
|
|
127
|
+
# In OpenAPI 3.0, "oneOf" is supported; "anyOf" also works but oneOf is clearer.
|
|
128
|
+
alts: List[JsonSchema] = []
|
|
129
|
+
for t in sorted(set(non_null_types)):
|
|
130
|
+
vals_for_t = [v for v, tt in zip(normalized_values, types) if tt == t]
|
|
131
|
+
alt: JsonSchema = {"type": t, "enum": vals_for_t}
|
|
132
|
+
if t == "string" and any(isinstance(v, (bytes, bytearray, memoryview)) for v in values):
|
|
133
|
+
# Only add if those strings are actually from bytes;
|
|
134
|
+
# leaving this hint in case you mix bytes + str.
|
|
135
|
+
alt.setdefault("format", "byte")
|
|
136
|
+
alts.append(alt)
|
|
137
|
+
|
|
138
|
+
schema["oneOf"] = alts
|
|
139
|
+
|
|
140
|
+
if openapi:
|
|
141
|
+
schema = _apply_nullable_oas(schema)
|
|
142
|
+
else:
|
|
143
|
+
schema = _apply_nullable_jsonschema(schema)
|
|
144
|
+
|
|
145
|
+
return schema
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
def regex_str(cls) -> str:
|
|
2
|
+
if not all(isinstance(v, str) for v in cls._ordered_values_ if v is not None):
|
|
3
|
+
raise TypeError("regex is only valid for string-valued LiteralEnum")
|
|
4
|
+
import re
|
|
5
|
+
vals = [v for v in cls._ordered_values_ if isinstance(v, str)]
|
|
6
|
+
return "^(?:" + "|".join(re.escape(v) for v in vals) + ")$"
|
|
7
|
+
|
|
8
|
+
def regex_pattern(cls, flags=0) -> "re.Pattern":
|
|
9
|
+
import re
|
|
10
|
+
return re.compile(cls.regex_str(), flags)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
import typing_literalenum as core
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def str_enum(cls: core.LiteralEnumMeta) -> StrEnum:
|
|
7
|
+
if not all(isinstance(v, str) for v in cls._ordered_values_ if v is not None):
|
|
8
|
+
raise TypeError("str_enum only works on a string-valued LiteralEnum")
|
|
9
|
+
return StrEnum(cls.__name__, dict(cls._members_))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
def strawberry_enum(cls) -> type:
|
|
2
|
+
"""Convert this LiteralEnum to a Strawberry GraphQL enum type.
|
|
3
|
+
|
|
4
|
+
Requires ``strawberry`` to be installed. Uses canonical members
|
|
5
|
+
only (aliases are excluded)::
|
|
6
|
+
|
|
7
|
+
class Color(LiteralEnum):
|
|
8
|
+
RED = "red"
|
|
9
|
+
GREEN = "green"
|
|
10
|
+
|
|
11
|
+
# use in a Strawberry schema
|
|
12
|
+
ColorEnum = Color.strawberry_enum
|
|
13
|
+
"""
|
|
14
|
+
import enum as _enum
|
|
15
|
+
import strawberry
|
|
16
|
+
|
|
17
|
+
PyEnum = _enum.Enum(cls.__name__, dict(cls.unique_mapping))
|
|
18
|
+
return strawberry.enum(PyEnum)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Never, NoReturn
|
|
4
|
+
|
|
5
|
+
import typing_literalenum as core
|
|
6
|
+
from literalenum import compatibility_extensions as compat
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LiteralEnumMeta(core.LiteralEnumMeta):
|
|
10
|
+
def literal(cls):
|
|
11
|
+
return compat.literal(cls)
|
|
12
|
+
|
|
13
|
+
def enum(cls):
|
|
14
|
+
return compat.enum(cls)
|
|
15
|
+
|
|
16
|
+
def str_enum(cls):
|
|
17
|
+
return compat.str_enum(cls)
|
|
18
|
+
|
|
19
|
+
def int_enum(cls):
|
|
20
|
+
return compat.int_enum(cls)
|
|
21
|
+
|
|
22
|
+
def json_schema(cls):
|
|
23
|
+
return compat.json_schema(cls)
|
|
24
|
+
|
|
25
|
+
def base_model(cls):
|
|
26
|
+
return compat.base_model(cls)
|
|
27
|
+
|
|
28
|
+
def sqlalchemy_enum(cls):
|
|
29
|
+
return compat.sqlalchemy_enum(cls)
|
|
30
|
+
|
|
31
|
+
def strawberry_enum(cls):
|
|
32
|
+
return compat.strawberry_enum(cls)
|
|
33
|
+
|
|
34
|
+
def graphene_enum(cls):
|
|
35
|
+
return compat.graphene_enum(cls)
|
|
36
|
+
|
|
37
|
+
def regex_str(cls):
|
|
38
|
+
return compat.regex_str(cls)
|
|
39
|
+
|
|
40
|
+
def regex_pattern(cls, flags=0):
|
|
41
|
+
return compat.regex_pattern(cls, flags)
|
|
42
|
+
|
|
43
|
+
def annotated(cls):
|
|
44
|
+
return compat.annotated(cls)
|
|
45
|
+
|
|
46
|
+
def django_choices(cls):
|
|
47
|
+
return compat.django_choices(cls)
|
|
48
|
+
|
|
49
|
+
def click_choice(cls):
|
|
50
|
+
return compat.click_choice(cls)
|
|
51
|
+
|
|
52
|
+
def random_choice(cls):
|
|
53
|
+
return compat.random_choice(cls)
|
|
54
|
+
|
|
55
|
+
def bare_class(cls):
|
|
56
|
+
return compat.bare_class(cls)
|
|
57
|
+
|
|
58
|
+
def set(cls):
|
|
59
|
+
return set(cls)
|
|
60
|
+
|
|
61
|
+
def list(cls):
|
|
62
|
+
return list(cls)
|
|
63
|
+
|
|
64
|
+
def frozenset(cls):
|
|
65
|
+
return frozenset(cls)
|
|
66
|
+
|
|
67
|
+
def dict(cls):
|
|
68
|
+
return dict(cls.mapping)
|
|
69
|
+
|
|
70
|
+
def tuple(cls):
|
|
71
|
+
return tuple(cls)
|
|
72
|
+
|
|
73
|
+
def str(cls):
|
|
74
|
+
return "|".join(f'"{v}"' if isinstance(v, str) else repr(v) for v in cls)
|
|
75
|
+
|
|
76
|
+
def stub(cls):
|
|
77
|
+
from literalenum.stubgen import stub_for
|
|
78
|
+
return stub_for(cls)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def T_(cls):
|
|
82
|
+
return cls.literal()
|
|
83
|
+
|
|
84
|
+
class LiteralEnum(metaclass=LiteralEnumMeta):
|
|
85
|
+
"""Base class for defining a set of named literal values.
|
|
86
|
+
|
|
87
|
+
Subclass ``LiteralEnum`` and assign literal values as class attributes::
|
|
88
|
+
|
|
89
|
+
class Color(LiteralEnum):
|
|
90
|
+
RED = "red"
|
|
91
|
+
GREEN = "green"
|
|
92
|
+
BLUE = "blue"
|
|
93
|
+
|
|
94
|
+
At runtime:
|
|
95
|
+
|
|
96
|
+
* ``Color.RED`` evaluates to ``"red"`` (a plain ``str``).
|
|
97
|
+
* ``list(Color)`` returns ``["red", "green", "blue"]``.
|
|
98
|
+
* ``"red" in Color`` returns ``True``.
|
|
99
|
+
* ``Color.validate(x)`` returns *x* if valid or raises ``ValueError``.
|
|
100
|
+
|
|
101
|
+
At type-check time, ``Color`` is intended to be equivalent to
|
|
102
|
+
``Literal["red", "green", "blue"]``.
|
|
103
|
+
|
|
104
|
+
``LiteralEnum`` is **not instantiable** — its values are plain scalars,
|
|
105
|
+
not wrapper objects. Use ``validate()`` or ``is_valid()`` instead.
|
|
106
|
+
|
|
107
|
+
Duplicate values are permitted; the first declared name is canonical
|
|
108
|
+
and later names are aliases::
|
|
109
|
+
|
|
110
|
+
class Method(LiteralEnum):
|
|
111
|
+
GET = "GET"
|
|
112
|
+
get = "GET" # alias for GET
|
|
113
|
+
|
|
114
|
+
Method.names("GET") # ("GET", "get")
|
|
115
|
+
Method.canonical_name("GET") # "GET"
|
|
116
|
+
list(Method) # ["GET"] (aliases not yielded)
|
|
117
|
+
|
|
118
|
+
To extend an existing LiteralEnum, pass ``extend=True``::
|
|
119
|
+
|
|
120
|
+
class ExtendedColor(Color, extend=True):
|
|
121
|
+
YELLOW = "yellow"
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __new__(cls, value: Never) -> NoReturn | core.LE:
|
|
125
|
+
"""Signal to type checkers that LiteralEnum is not instantiable.
|
|
126
|
+
|
|
127
|
+
At runtime, the metaclass ``__call__`` intercepts before this is
|
|
128
|
+
reached — either validating (``call_to_validate=True``) or raising
|
|
129
|
+
``TypeError``. This method exists solely so that type checkers
|
|
130
|
+
flag ``HttpMethod("GET")`` as an error by default.
|
|
131
|
+
"""
|
|
132
|
+
if cls._call_to_validate_:
|
|
133
|
+
return core.validate_is_member(cls, value)
|
|
134
|
+
raise TypeError(
|
|
135
|
+
f"{cls.__name__} is not instantiable; "
|
|
136
|
+
f"use {cls.__name__}.validate(x) or x in {cls.__name__}"
|
|
137
|
+
)
|