pydantic-fixturegen 1.0.0__py3-none-any.whl → 1.1.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.

Files changed (39) hide show
  1. pydantic_fixturegen/api/__init__.py +137 -0
  2. pydantic_fixturegen/api/_runtime.py +726 -0
  3. pydantic_fixturegen/api/models.py +73 -0
  4. pydantic_fixturegen/cli/__init__.py +32 -1
  5. pydantic_fixturegen/cli/check.py +230 -0
  6. pydantic_fixturegen/cli/diff.py +992 -0
  7. pydantic_fixturegen/cli/doctor.py +188 -35
  8. pydantic_fixturegen/cli/gen/_common.py +134 -7
  9. pydantic_fixturegen/cli/gen/explain.py +597 -40
  10. pydantic_fixturegen/cli/gen/fixtures.py +244 -112
  11. pydantic_fixturegen/cli/gen/json.py +229 -138
  12. pydantic_fixturegen/cli/gen/schema.py +170 -85
  13. pydantic_fixturegen/cli/init.py +333 -0
  14. pydantic_fixturegen/cli/schema.py +45 -0
  15. pydantic_fixturegen/cli/watch.py +126 -0
  16. pydantic_fixturegen/core/config.py +137 -3
  17. pydantic_fixturegen/core/config_schema.py +178 -0
  18. pydantic_fixturegen/core/constraint_report.py +305 -0
  19. pydantic_fixturegen/core/errors.py +42 -0
  20. pydantic_fixturegen/core/field_policies.py +100 -0
  21. pydantic_fixturegen/core/generate.py +241 -37
  22. pydantic_fixturegen/core/io_utils.py +10 -2
  23. pydantic_fixturegen/core/path_template.py +197 -0
  24. pydantic_fixturegen/core/presets.py +73 -0
  25. pydantic_fixturegen/core/providers/temporal.py +10 -0
  26. pydantic_fixturegen/core/safe_import.py +146 -12
  27. pydantic_fixturegen/core/seed_freeze.py +176 -0
  28. pydantic_fixturegen/emitters/json_out.py +65 -16
  29. pydantic_fixturegen/emitters/pytest_codegen.py +68 -13
  30. pydantic_fixturegen/emitters/schema_out.py +27 -3
  31. pydantic_fixturegen/logging.py +114 -0
  32. pydantic_fixturegen/schemas/config.schema.json +244 -0
  33. pydantic_fixturegen-1.1.0.dist-info/METADATA +173 -0
  34. pydantic_fixturegen-1.1.0.dist-info/RECORD +57 -0
  35. pydantic_fixturegen-1.0.0.dist-info/METADATA +0 -280
  36. pydantic_fixturegen-1.0.0.dist-info/RECORD +0 -41
  37. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/WHEEL +0 -0
  38. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/entry_points.txt +0 -0
  39. {pydantic_fixturegen-1.0.0.dist-info → pydantic_fixturegen-1.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -2,11 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import datetime
5
6
  import json
6
7
  import re
7
8
  import shutil
8
9
  import subprocess
9
- from collections.abc import Iterable, Sequence
10
+ from collections.abc import Iterable, Mapping, Sequence
10
11
  from dataclasses import dataclass
11
12
  from pathlib import Path
12
13
  from pprint import pformat
@@ -14,8 +15,11 @@ from typing import Any, Literal, cast
14
15
 
15
16
  from pydantic import BaseModel
16
17
 
18
+ from pydantic_fixturegen.core.constraint_report import ConstraintReporter
19
+ from pydantic_fixturegen.core.field_policies import FieldPolicy
17
20
  from pydantic_fixturegen.core.generate import GenerationConfig, InstanceGenerator
18
21
  from pydantic_fixturegen.core.io_utils import WriteResult, write_atomic_text
22
+ from pydantic_fixturegen.core.path_template import OutputTemplate, OutputTemplateContext
19
23
  from pydantic_fixturegen.core.version import build_artifact_header
20
24
 
21
25
  DEFAULT_SCOPE = "function"
@@ -36,6 +40,9 @@ class PytestEmitConfig:
36
40
  optional_p_none: float | None = None
37
41
  model_digest: str | None = None
38
42
  hash_compare: bool = True
43
+ per_model_seeds: Mapping[str, int] | None = None
44
+ field_policies: tuple[FieldPolicy, ...] = ()
45
+ time_anchor: datetime.datetime | None = None
39
46
 
40
47
 
41
48
  def emit_pytest_fixtures(
@@ -43,6 +50,8 @@ def emit_pytest_fixtures(
43
50
  *,
44
51
  output_path: str | Path,
45
52
  config: PytestEmitConfig | None = None,
53
+ template: OutputTemplate | None = None,
54
+ template_context: OutputTemplateContext | None = None,
46
55
  ) -> WriteResult:
47
56
  """Generate pytest fixture code for ``models`` and write it atomically."""
48
57
 
@@ -59,21 +68,42 @@ def emit_pytest_fixtures(
59
68
  if cfg.return_type not in {"model", "dict"}:
60
69
  raise ValueError(f"Unsupported return_type: {cfg.return_type!r}")
61
70
 
62
- generation_config = GenerationConfig(seed=cfg.seed)
63
- if cfg.optional_p_none is not None:
64
- generation_config.optional_p_none = cfg.optional_p_none
65
- generator = InstanceGenerator(config=generation_config)
71
+ def _build_generator(seed_value: int | None) -> InstanceGenerator:
72
+ generation_config = GenerationConfig(
73
+ seed=seed_value,
74
+ time_anchor=cfg.time_anchor,
75
+ field_policies=cfg.field_policies,
76
+ )
77
+ if cfg.optional_p_none is not None:
78
+ generation_config.optional_p_none = cfg.optional_p_none
79
+ return InstanceGenerator(config=generation_config)
80
+
81
+ shared_generator: InstanceGenerator | None = None
66
82
 
67
83
  model_entries: list[_ModelEntry] = []
68
84
  fixture_names: dict[str, int] = {}
69
85
  helper_names: dict[str, int] = {}
70
86
 
87
+ per_model_seeds = cfg.per_model_seeds or {}
88
+ constraint_reporters: list[ConstraintReporter] = []
89
+
71
90
  for model in models:
91
+ model_id = f"{model.__module__}.{model.__qualname__}"
92
+ if per_model_seeds:
93
+ seed_value = per_model_seeds.get(model_id, cfg.seed)
94
+ generator = _build_generator(seed_value)
95
+ else:
96
+ if shared_generator is None:
97
+ shared_generator = _build_generator(cfg.seed)
98
+ generator = shared_generator
99
+
72
100
  instances = generator.generate(model, count=cfg.cases)
73
101
  if len(instances) < cfg.cases:
74
102
  raise RuntimeError(
75
103
  f"Failed to generate {cfg.cases} instance(s) for {model.__qualname__}."
76
104
  )
105
+ if per_model_seeds:
106
+ constraint_reporters.append(generator.constraint_report)
77
107
  data = [_model_to_literal(instance) for instance in instances]
78
108
  base_name = model.__name__
79
109
  if cfg.style in {"factory", "class"}:
@@ -96,11 +126,32 @@ def emit_pytest_fixtures(
96
126
  entries=model_entries,
97
127
  config=cfg,
98
128
  )
129
+ template_obj = template or OutputTemplate(output_path)
130
+ context = template_context or OutputTemplateContext()
131
+ resolved_path = template_obj.render(
132
+ context=context,
133
+ case_index=1 if template_obj.uses_case_index() else None,
134
+ )
99
135
  result = write_atomic_text(
100
- output_path,
136
+ resolved_path,
101
137
  rendered,
102
138
  hash_compare=cfg.hash_compare,
103
139
  )
140
+ aggregate_report = ConstraintReporter()
141
+ if shared_generator is not None and not per_model_seeds:
142
+ aggregate_report.merge_from(shared_generator.constraint_report)
143
+ else:
144
+ for reporter in constraint_reporters:
145
+ aggregate_report.merge_from(reporter)
146
+
147
+ if cfg.time_anchor is not None:
148
+ result.metadata = result.metadata or {}
149
+ result.metadata["time_anchor"] = cfg.time_anchor.isoformat()
150
+
151
+ summary = aggregate_report.summary()
152
+ if summary.get("models"):
153
+ result.metadata = result.metadata or {}
154
+ result.metadata["constraints"] = summary
104
155
  return result
105
156
 
106
157
 
@@ -118,16 +169,20 @@ def _render_module(*, entries: Iterable[_ModelEntry], config: PytestEmitConfig)
118
169
  models_metadata = ", ".join(
119
170
  f"{entry.model.__module__}.{entry.model.__name__}" for entry in entries_list
120
171
  )
172
+ header_extras = {
173
+ "style": config.style,
174
+ "scope": config.scope,
175
+ "return": config.return_type,
176
+ "cases": config.cases,
177
+ "models": models_metadata,
178
+ }
179
+ if config.time_anchor is not None:
180
+ header_extras["time_anchor"] = config.time_anchor.isoformat()
181
+
121
182
  header = build_artifact_header(
122
183
  seed=config.seed,
123
184
  model_digest=config.model_digest,
124
- extras={
125
- "style": config.style,
126
- "scope": config.scope,
127
- "return": config.return_type,
128
- "cases": config.cases,
129
- "models": models_metadata,
130
- },
185
+ extras=header_extras,
131
186
  )
132
187
 
133
188
  needs_any = config.return_type == "dict" or config.style in {"factory", "class"}
@@ -10,6 +10,8 @@ from typing import Any
10
10
 
11
11
  from pydantic import BaseModel
12
12
 
13
+ from pydantic_fixturegen.core.path_template import OutputTemplate, OutputTemplateContext
14
+
13
15
 
14
16
  @dataclass(slots=True)
15
17
  class SchemaEmitConfig:
@@ -24,11 +26,20 @@ def emit_model_schema(
24
26
  output_path: str | Path,
25
27
  indent: int | None = 2,
26
28
  ensure_ascii: bool = False,
29
+ template: OutputTemplate | None = None,
30
+ template_context: OutputTemplateContext | None = None,
27
31
  ) -> Path:
28
32
  """Write the model JSON schema to ``output_path``."""
29
33
 
34
+ template_obj = template or OutputTemplate(output_path)
35
+ context = template_context or OutputTemplateContext()
36
+ resolved_path = template_obj.render(
37
+ context=context,
38
+ case_index=1 if template_obj.uses_case_index() else None,
39
+ )
40
+
30
41
  config = SchemaEmitConfig(
31
- output_path=Path(output_path),
42
+ output_path=resolved_path,
32
43
  indent=_normalise_indent(indent),
33
44
  ensure_ascii=ensure_ascii,
34
45
  )
@@ -40,6 +51,8 @@ def emit_model_schema(
40
51
  sort_keys=True,
41
52
  )
42
53
  config.output_path.parent.mkdir(parents=True, exist_ok=True)
54
+ if payload and not payload.endswith("\n"):
55
+ payload += "\n"
43
56
  config.output_path.write_text(payload, encoding="utf-8")
44
57
  return config.output_path
45
58
 
@@ -50,16 +63,25 @@ def emit_models_schema(
50
63
  output_path: str | Path,
51
64
  indent: int | None = 2,
52
65
  ensure_ascii: bool = False,
66
+ template: OutputTemplate | None = None,
67
+ template_context: OutputTemplateContext | None = None,
53
68
  ) -> Path:
54
69
  """Emit a combined schema referencing each model by its qualified name."""
55
70
 
71
+ template_obj = template or OutputTemplate(output_path)
72
+ context = template_context or OutputTemplateContext()
73
+ resolved_path = template_obj.render(
74
+ context=context,
75
+ case_index=1 if template_obj.uses_case_index() else None,
76
+ )
77
+
56
78
  config = SchemaEmitConfig(
57
- output_path=Path(output_path),
79
+ output_path=resolved_path,
58
80
  indent=_normalise_indent(indent),
59
81
  ensure_ascii=ensure_ascii,
60
82
  )
61
83
  combined: dict[str, Any] = {}
62
- for model in models:
84
+ for model in sorted(models, key=lambda m: m.__name__):
63
85
  combined[model.__name__] = model.model_json_schema()
64
86
 
65
87
  payload = json.dumps(
@@ -69,6 +91,8 @@ def emit_models_schema(
69
91
  sort_keys=True,
70
92
  )
71
93
  config.output_path.parent.mkdir(parents=True, exist_ok=True)
94
+ if payload and not payload.endswith("\n"):
95
+ payload += "\n"
72
96
  config.output_path.write_text(payload, encoding="utf-8")
73
97
  return config.output_path
74
98
 
@@ -0,0 +1,114 @@
1
+ """Application-level logging helpers with CLI-friendly formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import Any, Final
10
+
11
+ import typer
12
+
13
+ LOG_LEVEL_ORDER: Final[tuple[str, ...]] = ("silent", "error", "warn", "info", "debug")
14
+ LOG_LEVELS: Final[dict[str, int]] = {
15
+ "silent": 100,
16
+ "error": 40,
17
+ "warn": 30,
18
+ "info": 20,
19
+ "debug": 10,
20
+ }
21
+
22
+
23
+ # Index in LOG_LEVEL_ORDER representing the default "info" verbosity tier
24
+ DEFAULT_VERBOSITY_INDEX: Final[int] = LOG_LEVEL_ORDER.index("info")
25
+
26
+
27
+ _COLOR_BY_LEVEL: Final[dict[str, str]] = {
28
+ "debug": typer.colors.BLUE,
29
+ "info": typer.colors.GREEN,
30
+ "warn": typer.colors.YELLOW,
31
+ "error": typer.colors.RED,
32
+ }
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class LoggerConfig:
37
+ level: int = LOG_LEVELS["info"]
38
+ json: bool = False
39
+
40
+
41
+ class Logger:
42
+ def __init__(self, config: LoggerConfig | None = None) -> None:
43
+ self.config = config or LoggerConfig()
44
+
45
+ def configure(self, *, level: str | None = None, json_mode: bool | None = None) -> None:
46
+ if level is not None:
47
+ canonical = level.lower()
48
+ if canonical not in LOG_LEVELS:
49
+ raise ValueError(f"Unknown log level: {level}")
50
+ self.config.level = LOG_LEVELS[canonical]
51
+ if json_mode is not None:
52
+ self.config.json = json_mode
53
+
54
+ def debug(self, message: str, **extras: Any) -> None:
55
+ self._emit("debug", message, **extras)
56
+
57
+ def info(self, message: str, **extras: Any) -> None:
58
+ self._emit("info", message, **extras)
59
+
60
+ def warn(self, message: str, **extras: Any) -> None:
61
+ self._emit("warn", message, **extras)
62
+
63
+ def error(self, message: str, **extras: Any) -> None:
64
+ self._emit("error", message, **extras)
65
+
66
+ def _emit(self, level_name: str, message: str, **extras: Any) -> None:
67
+ level_value = LOG_LEVELS.get(level_name)
68
+ if level_value is None:
69
+ raise ValueError(f"Unknown log level: {level_name}")
70
+
71
+ if level_value < self.config.level:
72
+ return
73
+
74
+ payload_context = dict(extras)
75
+ event_name = payload_context.pop("event", message)
76
+
77
+ if self.config.json:
78
+ payload = {
79
+ "timestamp": datetime.now().isoformat(timespec="seconds"),
80
+ "level": level_name,
81
+ "event": event_name,
82
+ "message": message,
83
+ "context": payload_context or None,
84
+ }
85
+ sys.stdout.write(json.dumps(payload, sort_keys=True) + "\n")
86
+ return
87
+
88
+ stream_to_err = level_name in {"warn", "error"}
89
+ color = _COLOR_BY_LEVEL.get(level_name, typer.colors.WHITE)
90
+
91
+ typer.secho(message, fg=color, err=stream_to_err)
92
+ if payload_context and self.config.level <= LOG_LEVELS["debug"]:
93
+ typer.secho(
94
+ json.dumps(payload_context, sort_keys=True, indent=2),
95
+ fg=_COLOR_BY_LEVEL["debug"],
96
+ err=stream_to_err,
97
+ )
98
+
99
+
100
+ _GLOBAL_LOGGER = Logger()
101
+
102
+
103
+ def get_logger() -> Logger:
104
+ return _GLOBAL_LOGGER
105
+
106
+
107
+ __all__ = [
108
+ "Logger",
109
+ "LoggerConfig",
110
+ "LOG_LEVELS",
111
+ "LOG_LEVEL_ORDER",
112
+ "DEFAULT_VERBOSITY_INDEX",
113
+ "get_logger",
114
+ ]
@@ -0,0 +1,244 @@
1
+ {
2
+ "$defs": {
3
+ "EmittersSchema": {
4
+ "additionalProperties": false,
5
+ "description": "Schema for emitter configuration sections.",
6
+ "properties": {
7
+ "pytest": {
8
+ "$ref": "#/$defs/PytestEmitterSchema",
9
+ "description": "Configuration for the built-in pytest fixture emitter."
10
+ }
11
+ },
12
+ "title": "EmittersSchema",
13
+ "type": "object"
14
+ },
15
+ "FieldPolicyOptionsSchema": {
16
+ "additionalProperties": false,
17
+ "description": "Schema describing supported field policy overrides.",
18
+ "properties": {
19
+ "enum_policy": {
20
+ "anyOf": [
21
+ {
22
+ "enum": [
23
+ "first",
24
+ "random"
25
+ ],
26
+ "type": "string"
27
+ },
28
+ {
29
+ "type": "null"
30
+ }
31
+ ],
32
+ "default": null,
33
+ "description": "Enum selection policy for matching fields.",
34
+ "title": "Enum Policy"
35
+ },
36
+ "p_none": {
37
+ "anyOf": [
38
+ {
39
+ "maximum": 1.0,
40
+ "minimum": 0.0,
41
+ "type": "number"
42
+ },
43
+ {
44
+ "type": "null"
45
+ }
46
+ ],
47
+ "default": null,
48
+ "description": "Probability override for returning None on optional fields.",
49
+ "title": "P None"
50
+ },
51
+ "union_policy": {
52
+ "anyOf": [
53
+ {
54
+ "enum": [
55
+ "first",
56
+ "random"
57
+ ],
58
+ "type": "string"
59
+ },
60
+ {
61
+ "type": "null"
62
+ }
63
+ ],
64
+ "default": null,
65
+ "description": "Union selection policy for matching fields.",
66
+ "title": "Union Policy"
67
+ }
68
+ },
69
+ "title": "FieldPolicyOptionsSchema",
70
+ "type": "object"
71
+ },
72
+ "JsonSchema": {
73
+ "additionalProperties": false,
74
+ "description": "Schema for JSON emitter settings.",
75
+ "properties": {
76
+ "indent": {
77
+ "default": 2,
78
+ "description": "Indentation level for JSON output (0 for compact).",
79
+ "minimum": 0,
80
+ "title": "Indent",
81
+ "type": "integer"
82
+ },
83
+ "orjson": {
84
+ "default": false,
85
+ "description": "Use orjson for serialization when available.",
86
+ "title": "Orjson",
87
+ "type": "boolean"
88
+ }
89
+ },
90
+ "title": "JsonSchema",
91
+ "type": "object"
92
+ },
93
+ "PytestEmitterSchema": {
94
+ "additionalProperties": false,
95
+ "description": "Schema for pytest emitter settings.",
96
+ "properties": {
97
+ "scope": {
98
+ "default": "function",
99
+ "description": "Default pytest fixture scope.",
100
+ "enum": [
101
+ "function",
102
+ "module",
103
+ "session"
104
+ ],
105
+ "title": "Scope",
106
+ "type": "string"
107
+ },
108
+ "style": {
109
+ "default": "functions",
110
+ "description": "How emitted pytest fixtures are structured.",
111
+ "enum": [
112
+ "functions",
113
+ "factory",
114
+ "class"
115
+ ],
116
+ "title": "Style",
117
+ "type": "string"
118
+ }
119
+ },
120
+ "title": "PytestEmitterSchema",
121
+ "type": "object"
122
+ }
123
+ },
124
+ "$id": "https://raw.githubusercontent.com/CasperKristiansson/pydantic-fixturegen/main/pydantic_fixturegen/schemas/config.schema.json",
125
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
126
+ "additionalProperties": true,
127
+ "description": "Authoritative schema for `[tool.pydantic_fixturegen]` configuration.",
128
+ "properties": {
129
+ "emitters": {
130
+ "$ref": "#/$defs/EmittersSchema",
131
+ "description": "Emitter-specific configuration sections."
132
+ },
133
+ "enum_policy": {
134
+ "default": "first",
135
+ "description": "Strategy for selecting enum members.",
136
+ "enum": [
137
+ "first",
138
+ "random"
139
+ ],
140
+ "title": "Enum Policy",
141
+ "type": "string"
142
+ },
143
+ "exclude": {
144
+ "description": "Glob patterns of fully-qualified model names to exclude by default.",
145
+ "items": {
146
+ "type": "string"
147
+ },
148
+ "title": "Exclude",
149
+ "type": "array"
150
+ },
151
+ "field_policies": {
152
+ "additionalProperties": {
153
+ "$ref": "#/$defs/FieldPolicyOptionsSchema"
154
+ },
155
+ "description": "Field policy definitions keyed by glob or regex patterns that may target model names or dotted field paths.",
156
+ "title": "Field Policies",
157
+ "type": "object"
158
+ },
159
+ "include": {
160
+ "description": "Glob patterns of fully-qualified model names to include by default.",
161
+ "items": {
162
+ "type": "string"
163
+ },
164
+ "title": "Include",
165
+ "type": "array"
166
+ },
167
+ "json": {
168
+ "$ref": "#/$defs/JsonSchema",
169
+ "description": "Settings shared by JSON-based emitters."
170
+ },
171
+ "locale": {
172
+ "default": "en_US",
173
+ "description": "Default Faker locale used when generating data.",
174
+ "title": "Locale",
175
+ "type": "string"
176
+ },
177
+ "overrides": {
178
+ "additionalProperties": {
179
+ "additionalProperties": true,
180
+ "type": "object"
181
+ },
182
+ "description": "Per-model overrides keyed by fully-qualified model name.",
183
+ "title": "Overrides",
184
+ "type": "object"
185
+ },
186
+ "p_none": {
187
+ "anyOf": [
188
+ {
189
+ "maximum": 1.0,
190
+ "minimum": 0.0,
191
+ "type": "number"
192
+ },
193
+ {
194
+ "type": "null"
195
+ }
196
+ ],
197
+ "default": null,
198
+ "description": "Probability of sampling `None` for optional fields when unspecified.",
199
+ "title": "P None"
200
+ },
201
+ "preset": {
202
+ "anyOf": [
203
+ {
204
+ "type": "string"
205
+ },
206
+ {
207
+ "type": "null"
208
+ }
209
+ ],
210
+ "default": null,
211
+ "description": "Curated preset name applied before other configuration (e.g., 'boundary').",
212
+ "title": "Preset"
213
+ },
214
+ "seed": {
215
+ "anyOf": [
216
+ {
217
+ "type": "integer"
218
+ },
219
+ {
220
+ "type": "string"
221
+ },
222
+ {
223
+ "type": "null"
224
+ }
225
+ ],
226
+ "default": null,
227
+ "description": "Global seed controlling deterministic generation. Accepts int or string.",
228
+ "title": "Seed"
229
+ },
230
+ "union_policy": {
231
+ "default": "first",
232
+ "description": "Strategy for selecting branches of `typing.Union`.",
233
+ "enum": [
234
+ "first",
235
+ "random",
236
+ "weighted"
237
+ ],
238
+ "title": "Union Policy",
239
+ "type": "string"
240
+ }
241
+ },
242
+ "title": "pydantic-fixturegen configuration",
243
+ "type": "object"
244
+ }