devconfig-gen 1.0.0__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.
- devconfig_gen/__init__.py +90 -0
- devconfig_gen/cli.py +213 -0
- devconfig_gen/engine.py +217 -0
- devconfig_gen/formats.py +708 -0
- devconfig_gen/interactive.py +377 -0
- devconfig_gen/models.py +135 -0
- devconfig_gen/providers/__init__.py +7 -0
- devconfig_gen/providers/custom.py +92 -0
- devconfig_gen/providers/env_provider.py +184 -0
- devconfig_gen/providers/json_provider.py +84 -0
- devconfig_gen/py.typed +0 -0
- devconfig_gen/registry.py +38 -0
- devconfig_gen/validation.py +148 -0
- devconfig_gen/web_ui.py +2143 -0
- devconfig_gen-1.0.0.dist-info/METADATA +456 -0
- devconfig_gen-1.0.0.dist-info/RECORD +20 -0
- devconfig_gen-1.0.0.dist-info/WHEEL +5 -0
- devconfig_gen-1.0.0.dist-info/entry_points.txt +2 -0
- devconfig_gen-1.0.0.dist-info/licenses/LICENSE +201 -0
- devconfig_gen-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""A second provider with real transformation: structured data -> ``.env``.
|
|
2
|
+
|
|
3
|
+
Nested mappings are flattened into ``UPPER_SNAKE_CASE`` variables. This is a
|
|
4
|
+
domain-neutral, non-JSON output format, which makes the provider contract's
|
|
5
|
+
``media_type`` handling and multi-source merging easy to demonstrate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Mapping, Optional, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
from ..models import (
|
|
14
|
+
Diagnostic,
|
|
15
|
+
GeneratedArtifact,
|
|
16
|
+
GenerationRequest,
|
|
17
|
+
GenerationResult,
|
|
18
|
+
ProviderField,
|
|
19
|
+
ProviderStep,
|
|
20
|
+
)
|
|
21
|
+
from ..validation import ValidationError, is_mapping
|
|
22
|
+
|
|
23
|
+
_SEGMENT_RE = re.compile(r"[^A-Za-z0-9]+")
|
|
24
|
+
|
|
25
|
+
DEFAULT_ARTIFACT_NAME = ".env"
|
|
26
|
+
ENV_MEDIA_TYPE = "text/plain"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EnvProvider:
|
|
30
|
+
"""Flatten a structured mapping into a ``.env`` document."""
|
|
31
|
+
|
|
32
|
+
name = "env"
|
|
33
|
+
|
|
34
|
+
steps: Sequence[ProviderStep] = (
|
|
35
|
+
ProviderStep(
|
|
36
|
+
id="variables",
|
|
37
|
+
title="Environment variables",
|
|
38
|
+
description="Nested keys are joined with '_' and upper-cased.",
|
|
39
|
+
i18n={"zh": {"title": "环境变量", "description": "嵌套键用 '_' 连接并转为大写。"}},
|
|
40
|
+
fields=(
|
|
41
|
+
ProviderField(
|
|
42
|
+
"variables",
|
|
43
|
+
type="mapping",
|
|
44
|
+
required=True,
|
|
45
|
+
title="Variables",
|
|
46
|
+
description="Variables; nested keys become UPPER_SNAKE_CASE names.",
|
|
47
|
+
i18n={"zh": {"title": "变量", "description": "变量;嵌套键会转为 UPPER_SNAKE_CASE 名称。"}},
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def describe_schema(self) -> Sequence[ProviderStep]:
|
|
54
|
+
return self.steps
|
|
55
|
+
|
|
56
|
+
def diagnose(self, request: GenerationRequest) -> Sequence[Diagnostic]:
|
|
57
|
+
_, diagnostics = self._prepare(request)
|
|
58
|
+
return tuple(diagnostics)
|
|
59
|
+
|
|
60
|
+
def validate(self, request: GenerationRequest):
|
|
61
|
+
return tuple(item.message for item in self.diagnose(request))
|
|
62
|
+
|
|
63
|
+
def generate(self, request: GenerationRequest) -> GenerationResult:
|
|
64
|
+
pairs, diagnostics = self._prepare(request)
|
|
65
|
+
if diagnostics:
|
|
66
|
+
raise ValidationError(diagnostics)
|
|
67
|
+
text = "\n".join(f"{key}={value}" for key, value in pairs.items()) + "\n"
|
|
68
|
+
name = request.options.get("name") or DEFAULT_ARTIFACT_NAME
|
|
69
|
+
return GenerationResult(
|
|
70
|
+
provider=self.name,
|
|
71
|
+
artifacts=(
|
|
72
|
+
GeneratedArtifact(
|
|
73
|
+
name=str(name),
|
|
74
|
+
content=text,
|
|
75
|
+
media_type=ENV_MEDIA_TYPE,
|
|
76
|
+
),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# -- normalization + validation -----------------------------------------
|
|
81
|
+
|
|
82
|
+
def _prepare(self, request: GenerationRequest) -> Tuple[dict, list]:
|
|
83
|
+
diagnostics: list = []
|
|
84
|
+
context = request.context
|
|
85
|
+
if not is_mapping(context):
|
|
86
|
+
return {}, [Diagnostic("variables", "variables must be a mapping at the document root")]
|
|
87
|
+
|
|
88
|
+
raw: Mapping = context
|
|
89
|
+
if set(context.keys()) == {"variables"} and is_mapping(context["variables"]):
|
|
90
|
+
raw = context["variables"]
|
|
91
|
+
|
|
92
|
+
if not raw:
|
|
93
|
+
return {}, [Diagnostic("variables", "variables must not be empty")]
|
|
94
|
+
|
|
95
|
+
pairs: dict = {}
|
|
96
|
+
origins: dict = {}
|
|
97
|
+
self._flatten(raw, (), pairs, origins, diagnostics)
|
|
98
|
+
return pairs, diagnostics
|
|
99
|
+
|
|
100
|
+
def _flatten(
|
|
101
|
+
self,
|
|
102
|
+
source: Mapping,
|
|
103
|
+
prefix: Tuple[str, ...],
|
|
104
|
+
pairs: dict,
|
|
105
|
+
origins: dict,
|
|
106
|
+
diagnostics: list,
|
|
107
|
+
) -> None:
|
|
108
|
+
for key, value in source.items():
|
|
109
|
+
segment = self._segment(key, prefix, diagnostics)
|
|
110
|
+
if segment is None:
|
|
111
|
+
continue
|
|
112
|
+
path = prefix + (segment,)
|
|
113
|
+
|
|
114
|
+
if is_mapping(value):
|
|
115
|
+
if not value:
|
|
116
|
+
env_key = "_".join(path)
|
|
117
|
+
self._assign(env_key, "", path, pairs, origins, diagnostics)
|
|
118
|
+
else:
|
|
119
|
+
self._flatten(value, path, pairs, origins, diagnostics)
|
|
120
|
+
elif isinstance(value, (list, tuple)):
|
|
121
|
+
if any(is_mapping(item) for item in value):
|
|
122
|
+
diagnostics.append(
|
|
123
|
+
Diagnostic(
|
|
124
|
+
".".join(path).lower(),
|
|
125
|
+
f"{'.'.join(path).lower()} must not contain mappings",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
continue
|
|
129
|
+
rendered = ",".join(self._scalar_to_env(item) for item in value)
|
|
130
|
+
self._assign("_".join(path), rendered, path, pairs, origins, diagnostics)
|
|
131
|
+
elif isinstance(value, (dict, set)):
|
|
132
|
+
diagnostics.append(
|
|
133
|
+
Diagnostic(
|
|
134
|
+
".".join(path).lower(),
|
|
135
|
+
f"{'.'.join(path).lower()} has an unsupported value type",
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
self._assign(
|
|
140
|
+
"_".join(path), self._scalar_to_env(value), path, pairs, origins, diagnostics
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _assign(
|
|
144
|
+
self,
|
|
145
|
+
env_key: str,
|
|
146
|
+
value: str,
|
|
147
|
+
path: Tuple[str, ...],
|
|
148
|
+
pairs: dict,
|
|
149
|
+
origins: dict,
|
|
150
|
+
diagnostics: list,
|
|
151
|
+
) -> None:
|
|
152
|
+
origin = ".".join(path).lower()
|
|
153
|
+
if env_key in pairs and origins.get(env_key) != origin:
|
|
154
|
+
diagnostics.append(
|
|
155
|
+
Diagnostic(
|
|
156
|
+
origin,
|
|
157
|
+
f"'{env_key}' collides with '{origins[env_key]}' after normalization",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
pairs[env_key] = value
|
|
162
|
+
origins[env_key] = origin
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _segment(key: Any, prefix: Tuple[str, ...], diagnostics: list) -> Optional[str]:
|
|
166
|
+
segment = _SEGMENT_RE.sub("_", str(key)).strip("_").upper()
|
|
167
|
+
path = ".".join(prefix + (str(key),)).lower()
|
|
168
|
+
if not segment:
|
|
169
|
+
label = path or "<root>"
|
|
170
|
+
diagnostics.append(Diagnostic(path, f"{label} is not a valid variable name"))
|
|
171
|
+
return None
|
|
172
|
+
return segment
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _scalar_to_env(value: Any) -> str:
|
|
176
|
+
if value is None:
|
|
177
|
+
return ""
|
|
178
|
+
if value is True:
|
|
179
|
+
return "true"
|
|
180
|
+
if value is False:
|
|
181
|
+
return "false"
|
|
182
|
+
if isinstance(value, (int, float)):
|
|
183
|
+
return str(value)
|
|
184
|
+
return str(value)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Pass-through JSON/YAML provider.
|
|
2
|
+
|
|
3
|
+
This provider performs no field-level transformation; it exists so callers can
|
|
4
|
+
normalize or re-serialize an arbitrary structured document through the same
|
|
5
|
+
pipeline as richer providers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
from .. import formats
|
|
13
|
+
from ..models import (
|
|
14
|
+
GeneratedArtifact,
|
|
15
|
+
GenerationRequest,
|
|
16
|
+
GenerationResult,
|
|
17
|
+
ProviderField,
|
|
18
|
+
ProviderStep,
|
|
19
|
+
)
|
|
20
|
+
from ..validation import ValidationError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JsonProvider:
|
|
24
|
+
name = "json"
|
|
25
|
+
|
|
26
|
+
steps: Sequence[ProviderStep] = (
|
|
27
|
+
ProviderStep(
|
|
28
|
+
id="document",
|
|
29
|
+
title="Document",
|
|
30
|
+
description="Upload, paste, or edit an arbitrary JSON/YAML document to pass through unchanged.",
|
|
31
|
+
i18n={"zh": {"title": "文档", "description": "支持上传文件或直接粘贴/编辑任意 JSON/YAML 文档,原样透传与格式转换。"}},
|
|
32
|
+
fields=(
|
|
33
|
+
ProviderField(
|
|
34
|
+
"document",
|
|
35
|
+
type="document",
|
|
36
|
+
required=True,
|
|
37
|
+
title="Document Content",
|
|
38
|
+
description="Upload a JSON/YAML file or paste/edit document content directly.",
|
|
39
|
+
i18n={"zh": {"title": "文档内容", "description": "支持直接上传 JSON/YAML 文件,或直接粘贴/编辑文档内容。"}},
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def describe_schema(self) -> Sequence[ProviderStep]:
|
|
46
|
+
return self.steps
|
|
47
|
+
|
|
48
|
+
def _unwrap_context(self, context: Any) -> Any:
|
|
49
|
+
if (
|
|
50
|
+
isinstance(context, Mapping)
|
|
51
|
+
and set(context.keys()) == {"document"}
|
|
52
|
+
and isinstance(context["document"], Mapping)
|
|
53
|
+
):
|
|
54
|
+
return context["document"]
|
|
55
|
+
return context
|
|
56
|
+
|
|
57
|
+
def validate(self, request: GenerationRequest):
|
|
58
|
+
context = self._unwrap_context(request.context)
|
|
59
|
+
if not isinstance(context, Mapping):
|
|
60
|
+
return ("document: expected a mapping at the root",)
|
|
61
|
+
return ()
|
|
62
|
+
|
|
63
|
+
def generate(self, request: GenerationRequest) -> GenerationResult:
|
|
64
|
+
errors = self.validate(request)
|
|
65
|
+
if errors:
|
|
66
|
+
raise ValidationError(errors)
|
|
67
|
+
fmt = formats.resolve_format(
|
|
68
|
+
request.options.get("format"),
|
|
69
|
+
name=request.options.get("name"),
|
|
70
|
+
default=formats.JSON,
|
|
71
|
+
)
|
|
72
|
+
suffix = "yaml" if fmt == formats.YAML else "json"
|
|
73
|
+
filename = request.options.get("name") or f"config.{suffix}"
|
|
74
|
+
context = self._unwrap_context(request.context)
|
|
75
|
+
return GenerationResult(
|
|
76
|
+
provider=self.name,
|
|
77
|
+
artifacts=(
|
|
78
|
+
GeneratedArtifact(
|
|
79
|
+
name=str(filename),
|
|
80
|
+
content=dict(context),
|
|
81
|
+
media_type=formats.media_type_for(fmt),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
)
|
devconfig_gen/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Provider registration and lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Iterable
|
|
6
|
+
|
|
7
|
+
from .models import ConfigProvider
|
|
8
|
+
from .providers import CustomProvider, EnvProvider, JsonProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProviderRegistry:
|
|
12
|
+
def __init__(self, providers: Iterable[ConfigProvider] = ()):
|
|
13
|
+
self._providers: Dict[str, ConfigProvider] = {}
|
|
14
|
+
for provider in providers:
|
|
15
|
+
self.register(provider)
|
|
16
|
+
|
|
17
|
+
def register(self, provider: ConfigProvider) -> None:
|
|
18
|
+
name = str(provider.name).strip().lower()
|
|
19
|
+
if not name or name != provider.name:
|
|
20
|
+
raise ValueError("provider names must be non-empty lowercase strings")
|
|
21
|
+
if name in self._providers:
|
|
22
|
+
raise ValueError(f"provider already registered: {name}")
|
|
23
|
+
self._providers[name] = provider
|
|
24
|
+
|
|
25
|
+
def get(self, name: str) -> ConfigProvider:
|
|
26
|
+
try:
|
|
27
|
+
return self._providers[str(name).strip().lower()]
|
|
28
|
+
except KeyError as exc:
|
|
29
|
+
available = ", ".join(self.names()) or "none"
|
|
30
|
+
raise ValueError(f"unknown provider {name!r}; available: {available}") from exc
|
|
31
|
+
|
|
32
|
+
def names(self):
|
|
33
|
+
return tuple(sorted(self._providers))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
default_registry = ProviderRegistry(
|
|
37
|
+
(CustomProvider(), JsonProvider(), EnvProvider())
|
|
38
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Reusable, path-aware validation helpers.
|
|
2
|
+
|
|
3
|
+
Providers use these primitives to collect every problem in one pass instead of
|
|
4
|
+
failing on the first field. Each helper appends a :class:`Diagnostic` to the
|
|
5
|
+
supplied list. The rendered message uses the leaf field name so it reads
|
|
6
|
+
naturally, for example ``port must be between 1 and 65535, got 99999``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from .models import Diagnostic
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationError(ValueError):
|
|
17
|
+
"""Raised when one or more validation errors are present."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, diagnostics: Sequence[Any]):
|
|
20
|
+
self.diagnostics = tuple(_as_diagnostic(item) for item in diagnostics)
|
|
21
|
+
self.errors = tuple(item.message for item in self.diagnostics)
|
|
22
|
+
super().__init__("; ".join(self.errors))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _as_diagnostic(value: Any) -> Diagnostic:
|
|
26
|
+
if isinstance(value, Diagnostic):
|
|
27
|
+
return value
|
|
28
|
+
return Diagnostic(field="", message=str(value))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _leaf(path: str) -> str:
|
|
32
|
+
return path.rsplit(".", 1)[-1] if path else path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_mapping(value: Any) -> bool:
|
|
36
|
+
return isinstance(value, Mapping)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_integer(value: Any) -> bool:
|
|
40
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def expect_mapping(value: Any, path: str, errors: list) -> Optional[Mapping]:
|
|
44
|
+
if not is_mapping(value):
|
|
45
|
+
errors.append(
|
|
46
|
+
Diagnostic(path, f"{_leaf(path)} must be a mapping, got {_describe(value)}")
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def expect_string(value: Any, path: str, errors: list, *, allow_empty: bool = False) -> Optional[str]:
|
|
53
|
+
leaf = _leaf(path)
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
errors.append(Diagnostic(path, f"{leaf} must be a string, got {_describe(value)}"))
|
|
56
|
+
return None
|
|
57
|
+
text = value.strip()
|
|
58
|
+
if not text and not allow_empty:
|
|
59
|
+
errors.append(Diagnostic(path, f"{leaf} must not be empty"))
|
|
60
|
+
return None
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def expect_integer(
|
|
65
|
+
value: Any,
|
|
66
|
+
path: str,
|
|
67
|
+
errors: list,
|
|
68
|
+
*,
|
|
69
|
+
minimum: Optional[int] = None,
|
|
70
|
+
maximum: Optional[int] = None,
|
|
71
|
+
) -> Optional[int]:
|
|
72
|
+
leaf = _leaf(path)
|
|
73
|
+
if not is_integer(value):
|
|
74
|
+
errors.append(Diagnostic(path, f"{leaf} must be an integer, got {_describe(value)}"))
|
|
75
|
+
return None
|
|
76
|
+
if minimum is not None and maximum is not None:
|
|
77
|
+
if value < minimum or value > maximum:
|
|
78
|
+
errors.append(
|
|
79
|
+
Diagnostic(path, f"{leaf} must be between {minimum} and {maximum}, got {value}")
|
|
80
|
+
)
|
|
81
|
+
return None
|
|
82
|
+
elif minimum is not None and value < minimum:
|
|
83
|
+
errors.append(Diagnostic(path, f"{leaf} must be >= {minimum}, got {value}"))
|
|
84
|
+
return None
|
|
85
|
+
elif maximum is not None and value > maximum:
|
|
86
|
+
errors.append(Diagnostic(path, f"{leaf} must be <= {maximum}, got {value}"))
|
|
87
|
+
return None
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def expect_enum(
|
|
92
|
+
value: Any,
|
|
93
|
+
path: str,
|
|
94
|
+
errors: list,
|
|
95
|
+
*,
|
|
96
|
+
allowed: Sequence[str],
|
|
97
|
+
case_insensitive: bool = True,
|
|
98
|
+
) -> Optional[str]:
|
|
99
|
+
leaf = _leaf(path)
|
|
100
|
+
options = ", ".join(str(option) for option in allowed)
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
errors.append(
|
|
103
|
+
Diagnostic(path, f"{leaf} must be one of {options}, got {_describe(value)}")
|
|
104
|
+
)
|
|
105
|
+
return None
|
|
106
|
+
candidate = value.strip()
|
|
107
|
+
if case_insensitive:
|
|
108
|
+
for option in allowed:
|
|
109
|
+
if candidate.lower() == option.lower():
|
|
110
|
+
return option
|
|
111
|
+
elif candidate in allowed:
|
|
112
|
+
return candidate
|
|
113
|
+
errors.append(Diagnostic(path, f"{leaf} must be one of {options}, got {value!r}"))
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def expect_string_mapping(value: Any, path: str, errors: list) -> Optional[dict]:
|
|
118
|
+
if not is_mapping(value):
|
|
119
|
+
errors.append(
|
|
120
|
+
Diagnostic(path, f"{_leaf(path)} must be a mapping of labels, got {_describe(value)}")
|
|
121
|
+
)
|
|
122
|
+
return None
|
|
123
|
+
normalized = {}
|
|
124
|
+
for key, item in value.items():
|
|
125
|
+
key_text = str(key).strip()
|
|
126
|
+
if not key_text:
|
|
127
|
+
errors.append(Diagnostic(path, "label keys must not be empty"))
|
|
128
|
+
continue
|
|
129
|
+
if isinstance(item, (dict, list, tuple)):
|
|
130
|
+
errors.append(
|
|
131
|
+
Diagnostic(
|
|
132
|
+
f"{path}.{key_text}",
|
|
133
|
+
f"labels.{key_text} must be a scalar value, got {_describe(item)}",
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
normalized[key_text] = "" if item is None else str(item)
|
|
138
|
+
return normalized
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _describe(value: Any) -> str:
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
return f"string {value!r}"
|
|
144
|
+
if value is None:
|
|
145
|
+
return "null"
|
|
146
|
+
if isinstance(value, bool):
|
|
147
|
+
return f"boolean {value}"
|
|
148
|
+
return f"{type(value).__name__} {value!r}"
|