pydantic-fixturegen 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.
Potentially problematic release.
This version of pydantic-fixturegen might be problematic. Click here for more details.
- pydantic_fixturegen/__init__.py +7 -0
- pydantic_fixturegen/cli/__init__.py +85 -0
- pydantic_fixturegen/cli/doctor.py +235 -0
- pydantic_fixturegen/cli/gen/__init__.py +23 -0
- pydantic_fixturegen/cli/gen/_common.py +139 -0
- pydantic_fixturegen/cli/gen/explain.py +145 -0
- pydantic_fixturegen/cli/gen/fixtures.py +283 -0
- pydantic_fixturegen/cli/gen/json.py +262 -0
- pydantic_fixturegen/cli/gen/schema.py +164 -0
- pydantic_fixturegen/cli/list.py +164 -0
- pydantic_fixturegen/core/__init__.py +103 -0
- pydantic_fixturegen/core/ast_discover.py +169 -0
- pydantic_fixturegen/core/config.py +440 -0
- pydantic_fixturegen/core/errors.py +136 -0
- pydantic_fixturegen/core/generate.py +311 -0
- pydantic_fixturegen/core/introspect.py +141 -0
- pydantic_fixturegen/core/io_utils.py +77 -0
- pydantic_fixturegen/core/providers/__init__.py +32 -0
- pydantic_fixturegen/core/providers/collections.py +74 -0
- pydantic_fixturegen/core/providers/identifiers.py +68 -0
- pydantic_fixturegen/core/providers/numbers.py +133 -0
- pydantic_fixturegen/core/providers/registry.py +98 -0
- pydantic_fixturegen/core/providers/strings.py +109 -0
- pydantic_fixturegen/core/providers/temporal.py +42 -0
- pydantic_fixturegen/core/safe_import.py +403 -0
- pydantic_fixturegen/core/schema.py +320 -0
- pydantic_fixturegen/core/seed.py +154 -0
- pydantic_fixturegen/core/strategies.py +193 -0
- pydantic_fixturegen/core/version.py +52 -0
- pydantic_fixturegen/emitters/__init__.py +15 -0
- pydantic_fixturegen/emitters/json_out.py +373 -0
- pydantic_fixturegen/emitters/pytest_codegen.py +365 -0
- pydantic_fixturegen/emitters/schema_out.py +84 -0
- pydantic_fixturegen/plugins/builtin.py +45 -0
- pydantic_fixturegen/plugins/hookspecs.py +59 -0
- pydantic_fixturegen/plugins/loader.py +72 -0
- pydantic_fixturegen-1.0.0.dist-info/METADATA +280 -0
- pydantic_fixturegen-1.0.0.dist-info/RECORD +41 -0
- pydantic_fixturegen-1.0.0.dist-info/WHEEL +4 -0
- pydantic_fixturegen-1.0.0.dist-info/entry_points.txt +5 -0
- pydantic_fixturegen-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Instance generation engine using provider strategies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import enum
|
|
7
|
+
import inspect
|
|
8
|
+
import random
|
|
9
|
+
from collections.abc import Iterable, Sized
|
|
10
|
+
from dataclasses import dataclass, is_dataclass
|
|
11
|
+
from dataclasses import fields as dataclass_fields
|
|
12
|
+
from typing import Any, get_type_hints
|
|
13
|
+
|
|
14
|
+
from faker import Faker
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from pydantic.fields import FieldInfo
|
|
17
|
+
|
|
18
|
+
from pydantic_fixturegen.core import schema as schema_module
|
|
19
|
+
from pydantic_fixturegen.core.providers import ProviderRegistry, create_default_registry
|
|
20
|
+
from pydantic_fixturegen.core.schema import FieldConstraints, FieldSummary, extract_constraints
|
|
21
|
+
from pydantic_fixturegen.core.strategies import (
|
|
22
|
+
Strategy,
|
|
23
|
+
StrategyBuilder,
|
|
24
|
+
StrategyResult,
|
|
25
|
+
UnionStrategy,
|
|
26
|
+
)
|
|
27
|
+
from pydantic_fixturegen.plugins.loader import get_plugin_manager, load_entrypoint_plugins
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class GenerationConfig:
|
|
32
|
+
max_depth: int = 5
|
|
33
|
+
max_objects: int = 100
|
|
34
|
+
enum_policy: str = "first"
|
|
35
|
+
union_policy: str = "first"
|
|
36
|
+
default_p_none: float = 0.0
|
|
37
|
+
optional_p_none: float = 0.0
|
|
38
|
+
seed: int | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InstanceGenerator:
|
|
42
|
+
"""Generate instances of Pydantic models with recursion guards."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
registry: ProviderRegistry | None = None,
|
|
47
|
+
*,
|
|
48
|
+
config: GenerationConfig | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.config = config or GenerationConfig()
|
|
51
|
+
self.registry = registry or create_default_registry(load_plugins=False)
|
|
52
|
+
self.random = random.Random(self.config.seed)
|
|
53
|
+
self.faker = Faker()
|
|
54
|
+
if self.config.seed is not None:
|
|
55
|
+
Faker.seed(self.config.seed)
|
|
56
|
+
self.faker.seed_instance(self.config.seed)
|
|
57
|
+
|
|
58
|
+
load_entrypoint_plugins()
|
|
59
|
+
self._plugin_manager = get_plugin_manager()
|
|
60
|
+
|
|
61
|
+
if registry is None:
|
|
62
|
+
self.registry.load_entrypoint_plugins()
|
|
63
|
+
|
|
64
|
+
self.builder = StrategyBuilder(
|
|
65
|
+
self.registry,
|
|
66
|
+
enum_policy=self.config.enum_policy,
|
|
67
|
+
union_policy=self.config.union_policy,
|
|
68
|
+
default_p_none=self.config.default_p_none,
|
|
69
|
+
optional_p_none=self.config.optional_p_none,
|
|
70
|
+
plugin_manager=self._plugin_manager,
|
|
71
|
+
)
|
|
72
|
+
self._strategy_cache: dict[type[Any], dict[str, StrategyResult]] = {}
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------ public API
|
|
75
|
+
def generate_one(self, model: type[BaseModel]) -> BaseModel | None:
|
|
76
|
+
self._objects_remaining = self.config.max_objects
|
|
77
|
+
return self._build_model_instance(model, depth=0)
|
|
78
|
+
|
|
79
|
+
def generate(self, model: type[BaseModel], count: int = 1) -> list[BaseModel]:
|
|
80
|
+
results: list[BaseModel] = []
|
|
81
|
+
for _ in range(count):
|
|
82
|
+
instance = self.generate_one(model)
|
|
83
|
+
if instance is None:
|
|
84
|
+
break
|
|
85
|
+
results.append(instance)
|
|
86
|
+
return results
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------ internals
|
|
89
|
+
def _build_model_instance(self, model_type: type[Any], *, depth: int) -> Any | None:
|
|
90
|
+
if depth >= self.config.max_depth:
|
|
91
|
+
return None
|
|
92
|
+
if not self._consume_object():
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
strategies = self._get_model_strategies(model_type)
|
|
97
|
+
except TypeError:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
values: dict[str, Any] = {}
|
|
101
|
+
for field_name, strategy in strategies.items():
|
|
102
|
+
values[field_name] = self._evaluate_strategy(strategy, depth)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
if isinstance(model_type, type) and issubclass(model_type, BaseModel):
|
|
106
|
+
return model_type(**values)
|
|
107
|
+
if is_dataclass(model_type):
|
|
108
|
+
return model_type(**values)
|
|
109
|
+
except Exception:
|
|
110
|
+
return None
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def _evaluate_strategy(self, strategy: StrategyResult, depth: int) -> Any:
|
|
114
|
+
if isinstance(strategy, UnionStrategy):
|
|
115
|
+
return self._evaluate_union(strategy, depth)
|
|
116
|
+
return self._evaluate_single(strategy, depth)
|
|
117
|
+
|
|
118
|
+
def _evaluate_union(self, strategy: UnionStrategy, depth: int) -> Any:
|
|
119
|
+
choices = strategy.choices
|
|
120
|
+
if not choices:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
selected = self.random.choice(choices) if strategy.policy == "random" else choices[0]
|
|
124
|
+
return self._evaluate_single(selected, depth)
|
|
125
|
+
|
|
126
|
+
def _evaluate_single(self, strategy: Strategy, depth: int) -> Any:
|
|
127
|
+
if self._should_return_none(strategy):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
summary = strategy.summary
|
|
131
|
+
enum_values = strategy.enum_values or summary.enum_values
|
|
132
|
+
|
|
133
|
+
if enum_values:
|
|
134
|
+
return self._select_enum_value(strategy, enum_values)
|
|
135
|
+
|
|
136
|
+
annotation = strategy.annotation
|
|
137
|
+
|
|
138
|
+
if self._is_model_like(annotation):
|
|
139
|
+
return self._build_model_instance(annotation, depth=depth + 1)
|
|
140
|
+
|
|
141
|
+
if summary.type in {"list", "set", "tuple", "mapping"}:
|
|
142
|
+
return self._evaluate_collection(strategy, depth)
|
|
143
|
+
|
|
144
|
+
if strategy.provider_ref is None:
|
|
145
|
+
return None
|
|
146
|
+
return self._call_strategy_provider(strategy)
|
|
147
|
+
|
|
148
|
+
def _evaluate_collection(self, strategy: Strategy, depth: int) -> Any:
|
|
149
|
+
summary = strategy.summary
|
|
150
|
+
base_value = self._call_strategy_provider(strategy)
|
|
151
|
+
|
|
152
|
+
item_annotation = summary.item_annotation
|
|
153
|
+
if item_annotation is None or not self._is_model_like(item_annotation):
|
|
154
|
+
return base_value
|
|
155
|
+
|
|
156
|
+
if summary.type == "mapping":
|
|
157
|
+
return self._build_mapping_collection(base_value, item_annotation, depth)
|
|
158
|
+
|
|
159
|
+
length = self._collection_length_from_value(base_value)
|
|
160
|
+
count = max(1, length)
|
|
161
|
+
items: list[Any] = []
|
|
162
|
+
for _ in range(count):
|
|
163
|
+
nested = self._build_model_instance(item_annotation, depth=depth + 1)
|
|
164
|
+
if nested is not None:
|
|
165
|
+
items.append(nested)
|
|
166
|
+
|
|
167
|
+
if summary.type == "list":
|
|
168
|
+
return items
|
|
169
|
+
if summary.type == "tuple":
|
|
170
|
+
return tuple(items)
|
|
171
|
+
if summary.type == "set":
|
|
172
|
+
try:
|
|
173
|
+
return set(items)
|
|
174
|
+
except TypeError:
|
|
175
|
+
return set()
|
|
176
|
+
return base_value
|
|
177
|
+
|
|
178
|
+
def _build_mapping_collection(
|
|
179
|
+
self,
|
|
180
|
+
base_value: Any,
|
|
181
|
+
annotation: Any,
|
|
182
|
+
depth: int,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
if isinstance(base_value, dict) and base_value:
|
|
185
|
+
keys: Iterable[str] = base_value.keys()
|
|
186
|
+
else:
|
|
187
|
+
length = self._collection_length_from_value(base_value)
|
|
188
|
+
count = max(1, length)
|
|
189
|
+
keys = (self.faker.pystr(min_chars=3, max_chars=6) for _ in range(count))
|
|
190
|
+
|
|
191
|
+
result: dict[str, Any] = {}
|
|
192
|
+
for key in keys:
|
|
193
|
+
nested = self._build_model_instance(annotation, depth=depth + 1)
|
|
194
|
+
if nested is not None:
|
|
195
|
+
result[str(key)] = nested
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
def _consume_object(self) -> bool:
|
|
199
|
+
if getattr(self, "_objects_remaining", 0) <= 0:
|
|
200
|
+
return False
|
|
201
|
+
self._objects_remaining -= 1
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
def _get_model_strategies(self, model_type: type[Any]) -> dict[str, StrategyResult]:
|
|
205
|
+
cached = self._strategy_cache.get(model_type)
|
|
206
|
+
if cached is not None:
|
|
207
|
+
return cached
|
|
208
|
+
|
|
209
|
+
if isinstance(model_type, type) and issubclass(model_type, BaseModel):
|
|
210
|
+
strategies = dict(self.builder.build_model_strategies(model_type))
|
|
211
|
+
elif is_dataclass(model_type):
|
|
212
|
+
strategies = self._build_dataclass_strategies(model_type)
|
|
213
|
+
else:
|
|
214
|
+
raise TypeError(f"Unsupported model type: {model_type!r}")
|
|
215
|
+
|
|
216
|
+
self._strategy_cache[model_type] = strategies
|
|
217
|
+
return strategies
|
|
218
|
+
|
|
219
|
+
def _build_dataclass_strategies(self, cls: type[Any]) -> dict[str, StrategyResult]:
|
|
220
|
+
strategies: dict[str, StrategyResult] = {}
|
|
221
|
+
type_hints = get_type_hints(cls)
|
|
222
|
+
for field in dataclass_fields(cls):
|
|
223
|
+
if not field.init:
|
|
224
|
+
continue
|
|
225
|
+
annotation = type_hints.get(field.name, field.type)
|
|
226
|
+
summary = self._summarize_dataclass_field(field, annotation)
|
|
227
|
+
strategies[field.name] = self.builder.build_field_strategy(
|
|
228
|
+
cls,
|
|
229
|
+
field.name,
|
|
230
|
+
annotation,
|
|
231
|
+
summary,
|
|
232
|
+
)
|
|
233
|
+
return strategies
|
|
234
|
+
|
|
235
|
+
def _summarize_dataclass_field(
|
|
236
|
+
self,
|
|
237
|
+
field: dataclasses.Field[Any],
|
|
238
|
+
annotation: Any,
|
|
239
|
+
) -> FieldSummary:
|
|
240
|
+
field_info = self._extract_field_info(field)
|
|
241
|
+
if field_info is not None:
|
|
242
|
+
constraints = extract_constraints(field_info)
|
|
243
|
+
else:
|
|
244
|
+
constraints = FieldConstraints()
|
|
245
|
+
return schema_module._summarize_annotation(annotation, constraints)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _extract_field_info(field: dataclasses.Field[Any]) -> FieldInfo | None:
|
|
249
|
+
for meta in getattr(field, "metadata", ()):
|
|
250
|
+
if isinstance(meta, FieldInfo):
|
|
251
|
+
return meta
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
def _should_return_none(self, strategy: Strategy) -> bool:
|
|
255
|
+
if strategy.p_none <= 0:
|
|
256
|
+
return False
|
|
257
|
+
return self.random.random() < strategy.p_none
|
|
258
|
+
|
|
259
|
+
def _select_enum_value(self, strategy: Strategy, enum_values: list[Any]) -> Any:
|
|
260
|
+
if not enum_values:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
policy = strategy.enum_policy or self.config.enum_policy
|
|
264
|
+
selection = self.random.choice(enum_values) if policy == "random" else enum_values[0]
|
|
265
|
+
|
|
266
|
+
annotation = strategy.annotation
|
|
267
|
+
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
|
268
|
+
try:
|
|
269
|
+
return annotation(selection)
|
|
270
|
+
except Exception:
|
|
271
|
+
return selection
|
|
272
|
+
return selection
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _collection_length_from_value(value: Any) -> int:
|
|
276
|
+
if value is None:
|
|
277
|
+
return 0
|
|
278
|
+
if isinstance(value, Sized):
|
|
279
|
+
return len(value)
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _is_model_like(annotation: Any) -> bool:
|
|
284
|
+
if not isinstance(annotation, type):
|
|
285
|
+
return False
|
|
286
|
+
try:
|
|
287
|
+
return issubclass(annotation, BaseModel) or is_dataclass(annotation)
|
|
288
|
+
except TypeError:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
def _call_strategy_provider(self, strategy: Strategy) -> Any:
|
|
292
|
+
if strategy.provider_ref is None:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
func = strategy.provider_ref.func
|
|
296
|
+
kwargs = {
|
|
297
|
+
"summary": strategy.summary,
|
|
298
|
+
"faker": self.faker,
|
|
299
|
+
"random_generator": self.random,
|
|
300
|
+
}
|
|
301
|
+
kwargs.update(strategy.provider_kwargs)
|
|
302
|
+
|
|
303
|
+
sig = inspect.signature(func)
|
|
304
|
+
applicable = {name: value for name, value in kwargs.items() if name in sig.parameters}
|
|
305
|
+
try:
|
|
306
|
+
return func(**applicable)
|
|
307
|
+
except Exception:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
__all__ = ["InstanceGenerator", "GenerationConfig"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""High-level discovery orchestration combining AST and safe import approaches."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from .ast_discover import AstModel
|
|
12
|
+
from .ast_discover import discover_models as ast_discover
|
|
13
|
+
from .safe_import import safe_import_models
|
|
14
|
+
|
|
15
|
+
DiscoveryMethod = Literal["ast", "import", "hybrid"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class IntrospectedModel:
|
|
20
|
+
module: str
|
|
21
|
+
name: str
|
|
22
|
+
qualname: str
|
|
23
|
+
locator: str
|
|
24
|
+
lineno: int | None
|
|
25
|
+
discovery: DiscoveryMethod
|
|
26
|
+
is_public: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class IntrospectionResult:
|
|
31
|
+
models: list[IntrospectedModel]
|
|
32
|
+
warnings: list[str]
|
|
33
|
+
errors: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def discover(
|
|
37
|
+
paths: Iterable[Path | str],
|
|
38
|
+
*,
|
|
39
|
+
method: DiscoveryMethod = "hybrid",
|
|
40
|
+
include: Sequence[str] | None = None,
|
|
41
|
+
exclude: Sequence[str] | None = None,
|
|
42
|
+
public_only: bool = False,
|
|
43
|
+
safe_import_timeout: float = 5.0,
|
|
44
|
+
safe_import_memory_limit_mb: int = 256,
|
|
45
|
+
) -> IntrospectionResult:
|
|
46
|
+
"""Discover Pydantic models using AST, safe import, or both."""
|
|
47
|
+
include_patterns = tuple(include or [])
|
|
48
|
+
exclude_patterns = tuple(exclude or [])
|
|
49
|
+
normalized_paths = [Path(path) for path in paths]
|
|
50
|
+
|
|
51
|
+
models: dict[str, IntrospectedModel] = {}
|
|
52
|
+
warnings: list[str] = []
|
|
53
|
+
errors: list[str] = []
|
|
54
|
+
|
|
55
|
+
if method in {"ast", "hybrid"}:
|
|
56
|
+
ast_result = ast_discover(
|
|
57
|
+
normalized_paths,
|
|
58
|
+
infer_module=True,
|
|
59
|
+
public_only=public_only,
|
|
60
|
+
)
|
|
61
|
+
warnings.extend(ast_result.warnings)
|
|
62
|
+
|
|
63
|
+
for ast_model in ast_result.models:
|
|
64
|
+
entry = _to_introspection_model_from_ast(ast_model)
|
|
65
|
+
if _should_include(entry, include_patterns, exclude_patterns):
|
|
66
|
+
models.setdefault(entry.qualname, entry)
|
|
67
|
+
|
|
68
|
+
if method == "ast":
|
|
69
|
+
return IntrospectionResult(
|
|
70
|
+
models=sorted(models.values(), key=lambda m: m.qualname),
|
|
71
|
+
warnings=warnings,
|
|
72
|
+
errors=errors,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if method in {"import", "hybrid"}:
|
|
76
|
+
safe_result = safe_import_models(
|
|
77
|
+
normalized_paths,
|
|
78
|
+
timeout=safe_import_timeout,
|
|
79
|
+
memory_limit_mb=safe_import_memory_limit_mb,
|
|
80
|
+
)
|
|
81
|
+
if not safe_result.success and safe_result.error:
|
|
82
|
+
errors.append(safe_result.error)
|
|
83
|
+
if safe_result.stderr:
|
|
84
|
+
warnings.append(safe_result.stderr.strip())
|
|
85
|
+
|
|
86
|
+
for import_model in safe_result.models:
|
|
87
|
+
entry = _to_introspection_model_from_import(import_model)
|
|
88
|
+
if public_only and entry.name.startswith("_"):
|
|
89
|
+
continue
|
|
90
|
+
if _should_include(entry, include_patterns, exclude_patterns):
|
|
91
|
+
models[entry.qualname] = entry
|
|
92
|
+
|
|
93
|
+
return IntrospectionResult(
|
|
94
|
+
models=sorted(models.values(), key=lambda m: m.qualname),
|
|
95
|
+
warnings=warnings,
|
|
96
|
+
errors=errors,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _should_include(
|
|
101
|
+
model: IntrospectedModel,
|
|
102
|
+
include_patterns: Sequence[str],
|
|
103
|
+
exclude_patterns: Sequence[str],
|
|
104
|
+
) -> bool:
|
|
105
|
+
identifier = model.qualname
|
|
106
|
+
if include_patterns and not any(
|
|
107
|
+
fnmatch.fnmatchcase(identifier, pattern) for pattern in include_patterns
|
|
108
|
+
):
|
|
109
|
+
return False
|
|
110
|
+
return not (
|
|
111
|
+
exclude_patterns
|
|
112
|
+
and any(fnmatch.fnmatchcase(identifier, pattern) for pattern in exclude_patterns)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _to_introspection_model_from_ast(model: AstModel) -> IntrospectedModel:
|
|
117
|
+
return IntrospectedModel(
|
|
118
|
+
module=model.module,
|
|
119
|
+
name=model.name,
|
|
120
|
+
qualname=model.qualname,
|
|
121
|
+
locator=str(model.path),
|
|
122
|
+
lineno=model.lineno,
|
|
123
|
+
discovery="ast",
|
|
124
|
+
is_public=model.is_public,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _to_introspection_model_from_import(model: dict[str, object]) -> IntrospectedModel:
|
|
129
|
+
module = str(model.get("module"))
|
|
130
|
+
name = str(model.get("name"))
|
|
131
|
+
qualname = str(model.get("qualname") or f"{module}.{name}")
|
|
132
|
+
locator = str(model.get("path") or module)
|
|
133
|
+
return IntrospectedModel(
|
|
134
|
+
module=module,
|
|
135
|
+
name=name,
|
|
136
|
+
qualname=qualname,
|
|
137
|
+
locator=locator,
|
|
138
|
+
lineno=None,
|
|
139
|
+
discovery="import",
|
|
140
|
+
is_public=not name.startswith("_"),
|
|
141
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Filesystem utilities for atomic, hash-aware writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
__all__ = ["WriteResult", "write_atomic_text", "write_atomic_bytes"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class WriteResult:
|
|
16
|
+
"""Metadata describing the outcome of an atomic write."""
|
|
17
|
+
|
|
18
|
+
path: Path
|
|
19
|
+
wrote: bool
|
|
20
|
+
skipped: bool
|
|
21
|
+
reason: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_atomic_text(
|
|
25
|
+
path: str | Path,
|
|
26
|
+
content: str,
|
|
27
|
+
*,
|
|
28
|
+
encoding: str = "utf-8",
|
|
29
|
+
hash_compare: bool = False,
|
|
30
|
+
) -> WriteResult:
|
|
31
|
+
"""Atomically write text content to ``path``."""
|
|
32
|
+
|
|
33
|
+
data = content.encode(encoding)
|
|
34
|
+
return _write_atomic(Path(path), data, hash_compare=hash_compare)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_atomic_bytes(
|
|
38
|
+
path: str | Path,
|
|
39
|
+
data: bytes,
|
|
40
|
+
*,
|
|
41
|
+
hash_compare: bool = False,
|
|
42
|
+
) -> WriteResult:
|
|
43
|
+
"""Atomically write binary content to ``path``."""
|
|
44
|
+
|
|
45
|
+
return _write_atomic(Path(path), data, hash_compare=hash_compare)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _write_atomic(path: Path, data: bytes, *, hash_compare: bool) -> WriteResult:
|
|
49
|
+
path = path.expanduser()
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
digest = _hash_bytes(data) if hash_compare else None
|
|
53
|
+
if hash_compare and path.exists():
|
|
54
|
+
existing = path.read_bytes()
|
|
55
|
+
if _hash_bytes(existing) == digest:
|
|
56
|
+
return WriteResult(path=path, wrote=False, skipped=True, reason="unchanged")
|
|
57
|
+
|
|
58
|
+
with tempfile.NamedTemporaryFile(
|
|
59
|
+
delete=False,
|
|
60
|
+
dir=path.parent,
|
|
61
|
+
prefix=f".{path.name}.",
|
|
62
|
+
suffix=".tmp",
|
|
63
|
+
) as temp_file:
|
|
64
|
+
temp_path = Path(temp_file.name)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
temp_path.write_bytes(data)
|
|
68
|
+
os.replace(temp_path, path)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
temp_path.unlink(missing_ok=True)
|
|
71
|
+
raise exc
|
|
72
|
+
|
|
73
|
+
return WriteResult(path=path, wrote=True, skipped=False, reason=None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _hash_bytes(data: bytes) -> str:
|
|
77
|
+
return hashlib.sha256(data).hexdigest()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Provider registry and built-in providers."""
|
|
2
|
+
|
|
3
|
+
from .collections import register_collection_providers
|
|
4
|
+
from .identifiers import register_identifier_providers
|
|
5
|
+
from .numbers import register_numeric_providers
|
|
6
|
+
from .registry import ProviderRef, ProviderRegistry
|
|
7
|
+
from .strings import register_string_providers
|
|
8
|
+
from .temporal import register_temporal_providers
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_default_registry(load_plugins: bool = True) -> ProviderRegistry:
|
|
12
|
+
registry = ProviderRegistry()
|
|
13
|
+
register_numeric_providers(registry)
|
|
14
|
+
register_string_providers(registry)
|
|
15
|
+
register_collection_providers(registry)
|
|
16
|
+
register_temporal_providers(registry)
|
|
17
|
+
register_identifier_providers(registry)
|
|
18
|
+
if load_plugins:
|
|
19
|
+
registry.load_entrypoint_plugins()
|
|
20
|
+
return registry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ProviderRef",
|
|
25
|
+
"ProviderRegistry",
|
|
26
|
+
"create_default_registry",
|
|
27
|
+
"register_string_providers",
|
|
28
|
+
"register_numeric_providers",
|
|
29
|
+
"register_collection_providers",
|
|
30
|
+
"register_temporal_providers",
|
|
31
|
+
"register_identifier_providers",
|
|
32
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Collection providers for lists, sets, tuples, and mappings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from faker import Faker
|
|
9
|
+
|
|
10
|
+
from pydantic_fixturegen.core.providers.registry import ProviderRegistry
|
|
11
|
+
from pydantic_fixturegen.core.schema import FieldConstraints, FieldSummary
|
|
12
|
+
|
|
13
|
+
DEFAULT_COLLECTION_SIZE = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_collection(
|
|
17
|
+
summary: FieldSummary,
|
|
18
|
+
*,
|
|
19
|
+
faker: Faker | None = None,
|
|
20
|
+
random_generator: random.Random | None = None,
|
|
21
|
+
) -> Any:
|
|
22
|
+
rng = random_generator or random.Random()
|
|
23
|
+
faker = faker or Faker()
|
|
24
|
+
|
|
25
|
+
length = _collection_length(summary.constraints, rng)
|
|
26
|
+
item_values = [_basic_value(summary.item_type, faker, rng) for _ in range(length)]
|
|
27
|
+
|
|
28
|
+
if summary.type == "list":
|
|
29
|
+
return item_values
|
|
30
|
+
if summary.type == "set":
|
|
31
|
+
return set(item_values)
|
|
32
|
+
if summary.type == "tuple":
|
|
33
|
+
return tuple(item_values)
|
|
34
|
+
if summary.type == "mapping":
|
|
35
|
+
return {faker.pystr(min_chars=3, max_chars=6): value for value in item_values}
|
|
36
|
+
|
|
37
|
+
raise ValueError(f"Unsupported collection type: {summary.type}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register_collection_providers(registry: ProviderRegistry) -> None:
|
|
41
|
+
for collection_type in ("list", "set", "tuple", "mapping"):
|
|
42
|
+
registry.register(
|
|
43
|
+
collection_type,
|
|
44
|
+
generate_collection,
|
|
45
|
+
name=f"collection.{collection_type}",
|
|
46
|
+
metadata={"type": collection_type},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _collection_length(constraints: FieldConstraints, rng: random.Random) -> int:
|
|
51
|
+
minimum = constraints.min_length or 1
|
|
52
|
+
maximum = constraints.max_length or max(minimum, DEFAULT_COLLECTION_SIZE)
|
|
53
|
+
if minimum > maximum:
|
|
54
|
+
minimum = maximum
|
|
55
|
+
return rng.randint(minimum, maximum)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _basic_value(item_type: str | None, faker: Faker, rng: random.Random) -> Any:
|
|
59
|
+
if item_type == "int":
|
|
60
|
+
return rng.randint(-10, 10)
|
|
61
|
+
if item_type == "float":
|
|
62
|
+
return rng.uniform(-10, 10)
|
|
63
|
+
if item_type == "bool":
|
|
64
|
+
return rng.choice([True, False])
|
|
65
|
+
if item_type == "string" or item_type is None:
|
|
66
|
+
return faker.pystr(min_chars=1, max_chars=8)
|
|
67
|
+
if item_type == "decimal":
|
|
68
|
+
return faker.pydecimal(left_digits=3, right_digits=2)
|
|
69
|
+
if item_type == "model":
|
|
70
|
+
return {}
|
|
71
|
+
return faker.pystr(min_chars=1, max_chars=8)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["generate_collection", "register_collection_providers"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Identifier providers for emails, URLs, UUIDs, IPs, secrets, and payment cards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from faker import Faker
|
|
10
|
+
from pydantic import SecretBytes, SecretStr
|
|
11
|
+
|
|
12
|
+
from pydantic_fixturegen.core.providers.registry import ProviderRegistry
|
|
13
|
+
from pydantic_fixturegen.core.schema import FieldSummary
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_identifier(
|
|
17
|
+
summary: FieldSummary,
|
|
18
|
+
*,
|
|
19
|
+
faker: Faker | None = None,
|
|
20
|
+
) -> Any:
|
|
21
|
+
faker = faker or Faker()
|
|
22
|
+
type_name = summary.type
|
|
23
|
+
|
|
24
|
+
if type_name == "email":
|
|
25
|
+
return faker.unique.email()
|
|
26
|
+
if type_name == "url":
|
|
27
|
+
return faker.unique.url()
|
|
28
|
+
if type_name == "uuid":
|
|
29
|
+
return uuid.uuid4()
|
|
30
|
+
if type_name == "payment-card":
|
|
31
|
+
return faker.credit_card_number()
|
|
32
|
+
if type_name == "secret-str":
|
|
33
|
+
return SecretStr(faker.password())
|
|
34
|
+
if type_name == "secret-bytes":
|
|
35
|
+
return SecretBytes(faker.binary(length=16))
|
|
36
|
+
if type_name == "ip-address":
|
|
37
|
+
return faker.ipv4()
|
|
38
|
+
if type_name == "ip-interface":
|
|
39
|
+
address = faker.ipv4()
|
|
40
|
+
return str(ipaddress.ip_interface(f"{address}/24"))
|
|
41
|
+
if type_name == "ip-network":
|
|
42
|
+
address = faker.ipv4()
|
|
43
|
+
return str(ipaddress.ip_network(f"{address}/24", strict=False))
|
|
44
|
+
|
|
45
|
+
raise ValueError(f"Unsupported identifier type: {type_name}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register_identifier_providers(registry: ProviderRegistry) -> None:
|
|
49
|
+
for identifier_type in (
|
|
50
|
+
"email",
|
|
51
|
+
"url",
|
|
52
|
+
"uuid",
|
|
53
|
+
"payment-card",
|
|
54
|
+
"secret-str",
|
|
55
|
+
"secret-bytes",
|
|
56
|
+
"ip-address",
|
|
57
|
+
"ip-interface",
|
|
58
|
+
"ip-network",
|
|
59
|
+
):
|
|
60
|
+
registry.register(
|
|
61
|
+
identifier_type,
|
|
62
|
+
generate_identifier,
|
|
63
|
+
name=f"identifier.{identifier_type}",
|
|
64
|
+
metadata={"type": identifier_type},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["generate_identifier", "register_identifier_providers"]
|