conformly 0.7.0__tar.gz → 0.7.2__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.
Files changed (100) hide show
  1. {conformly-0.7.0/src/conformly.egg-info → conformly-0.7.2}/PKG-INFO +47 -3
  2. {conformly-0.7.0 → conformly-0.7.2}/README.md +46 -2
  3. {conformly-0.7.0 → conformly-0.7.2}/pyproject.toml +1 -1
  4. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/api/case.py +12 -1
  5. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/orchestration.py +48 -10
  6. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/registry.py +3 -0
  7. conformly-0.7.2/src/conformly/_internal/generator/types/tuple.py +93 -0
  8. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/core/field.py +24 -1
  9. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/extractors/types.py +37 -1
  10. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/models.py +3 -0
  11. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/planner/field.py +21 -0
  12. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/resolve.py +51 -1
  13. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/__init__.py +3 -0
  14. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/factory.py +15 -0
  15. conformly-0.7.2/src/conformly/_internal/resolver/semantics/tuple.py +21 -0
  16. conformly-0.7.2/src/conformly/_internal/tracer.py +63 -0
  17. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/types/primitives.py +1 -0
  18. conformly-0.7.2/src/conformly/tracer/__init__.py +3 -0
  19. {conformly-0.7.0 → conformly-0.7.2/src/conformly.egg-info}/PKG-INFO +47 -3
  20. {conformly-0.7.0 → conformly-0.7.2}/src/conformly.egg-info/SOURCES.txt +4 -0
  21. {conformly-0.7.0 → conformly-0.7.2}/LICENSE +0 -0
  22. {conformly-0.7.0 → conformly-0.7.2}/setup.cfg +0 -0
  23. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/__init__.py +0 -0
  24. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/__init__.py +0 -0
  25. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/api/__init__.py +0 -0
  26. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/api/_errors.py +0 -0
  27. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/api/_path_proxy.py +0 -0
  28. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/api/_utils.py +0 -0
  29. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/api/cases.py +0 -0
  30. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/api/path.py +0 -0
  31. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/constraints/__init__.py +0 -0
  32. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/constraints/base.py +0 -0
  33. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/constraints/collections.py +0 -0
  34. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/constraints/enum.py +0 -0
  35. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/constraints/keys.py +0 -0
  36. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/constraints/mapping.py +0 -0
  37. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/constraints/numeric.py +0 -0
  38. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/constraints/string.py +0 -0
  39. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/fields/__init__.py +0 -0
  40. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/fields/registry.py +0 -0
  41. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/fields/special_types.py +0 -0
  42. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/__init__.py +0 -0
  43. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/_errors.py +0 -0
  44. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/context.py +0 -0
  45. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/protocol.py +0 -0
  46. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/__init__.py +0 -0
  47. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/_utils.py +0 -0
  48. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/boolean.py +0 -0
  49. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/dictionaries.py +0 -0
  50. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/email.py +0 -0
  51. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/enum.py +0 -0
  52. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/float.py +0 -0
  53. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/integer.py +0 -0
  54. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/ipv4.py +0 -0
  55. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/ipv6.py +0 -0
  56. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/ipvany.py +0 -0
  57. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/list.py +0 -0
  58. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/string.py +0 -0
  59. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/url.py +0 -0
  60. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/generator/types/uuid.py +0 -0
  61. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/__init__.py +0 -0
  62. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/adapters/__init__.py +0 -0
  63. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/adapters/attrs.py +0 -0
  64. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/adapters/bootstrap.py +0 -0
  65. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/adapters/dataclass.py +0 -0
  66. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/adapters/protocol.py +0 -0
  67. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/adapters/pydantic.py +0 -0
  68. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/adapters/registry.py +0 -0
  69. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/adapters/typeddict.py +0 -0
  70. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/api.py +0 -0
  71. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/core/__init__.py +0 -0
  72. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/core/element.py +0 -0
  73. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/extractors/__init__.py +0 -0
  74. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/parser/extractors/constraints.py +0 -0
  75. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/planner/__init__.py +0 -0
  76. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/planner/_errors.py +0 -0
  77. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/planner/model.py +0 -0
  78. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/planner/session.py +0 -0
  79. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/__init__.py +0 -0
  80. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/models.py +0 -0
  81. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/_validators.py +0 -0
  82. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/base.py +0 -0
  83. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/boolean.py +0 -0
  84. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/dict.py +0 -0
  85. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/enum.py +0 -0
  86. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/list.py +0 -0
  87. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/numeric.py +0 -0
  88. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/object.py +0 -0
  89. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/string.py +0 -0
  90. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/resolver/semantics/uuid.py +0 -0
  91. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/types/__init__.py +0 -0
  92. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/types/constants.py +0 -0
  93. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/types/strategies.py +0 -0
  94. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/_internal/types/values.py +0 -0
  95. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/exceptions.py +0 -0
  96. {conformly-0.7.0 → conformly-0.7.2}/src/conformly/py.typed +0 -0
  97. {conformly-0.7.0 → conformly-0.7.2}/src/conformly.egg-info/dependency_links.txt +0 -0
  98. {conformly-0.7.0 → conformly-0.7.2}/src/conformly.egg-info/requires.txt +0 -0
  99. {conformly-0.7.0 → conformly-0.7.2}/src/conformly.egg-info/top_level.txt +0 -0
  100. {conformly-0.7.0 → conformly-0.7.2}/tests/test_path.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conformly
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Generate valid & invalid test data from your typed schemas
5
5
  Author-email: Nikita Shabanov <nik.shabanov2024@gmail.com>
6
6
  License: MIT
@@ -71,6 +71,7 @@ No factories, no hardcoded fixtures, no drift when schema changes.
71
71
  - [User Cases](#use-cases)
72
72
  - [Nested Models](#nested-models)
73
73
  - [Collections](#collections)
74
+ - [Experimental](#experimental)
74
75
  - [Development](#development)
75
76
  - [Changelog](#changelog)
76
77
  - [License](#license)
@@ -242,8 +243,8 @@ path("profile.name").violate(V.PATTERN_MISMATCH)
242
243
  - `"first"` - violate the first constrained field (for `case`) or take the first N constrained fields (for `cases`)
243
244
  - `"all_violations"` - generate one invalid case per every available violations including constraints, structural and type violations (ignores count)
244
245
  - `"overrides"` - default values for fields in current generation
245
- > If a field is selected for invalidation → override is ignored
246
- > Otherwise → override is applied
246
+ > Note: If a field is selected for invalidation → override is ignored.
247
+ > Otherwise → override is applied.
247
248
  ```python
248
249
  overrides=[
249
250
  path(User, lambda u: u.full_name).set("Amogus"),
@@ -560,6 +561,24 @@ case(Model)
560
561
 
561
562
  This ensures consistent output format (list) while preserving uniqueness guarantees.
562
563
 
564
+ ### Tuple support
565
+
566
+ Fixed tuples and variadic tuples (`tuple[T, ...]`) are generated in their declared
567
+ order. Element annotations and nested models are handled independently:
568
+
569
+ ```python
570
+ @dataclass
571
+ class Event:
572
+ point: tuple[float, float]
573
+ labels: tuple[str, ...]
574
+
575
+ case(Event, valid=True)
576
+ # -> {"point": (1.2, 3.4), "labels": ("abc", "def")}
577
+ ```
578
+
579
+ For invalid generation, a constrained tuple element is selected and invalidated
580
+ without changing the other elements.
581
+
563
582
  ### Dict support
564
583
 
565
584
  `conformly` supports generation of `dict[K, V]`, where K must be a hashable type (`str` or `Enum`) and `V` can be any supported type (primitives, nested models, etc.).
@@ -606,6 +625,31 @@ invalid = case(Inventory, valid=False, strategy=path("products").violate(V.TOO_S
606
625
  - Uniqueness for non-hashable elements (e.g., dicts) is best-effort (based on structural comparison fallback)
607
626
  - Dictionary keys are restricted to `str` and `Enum` types; complex objects as keys are not supported
608
627
 
628
+ ## Experimental
629
+
630
+ > APIs in this section are experimental and may change in future minor releases.
631
+
632
+ ### Tracing
633
+
634
+ `Tracer` captures metadata about case generation, making it easier to debug, reproduce, and report generated test cases.
635
+
636
+ ```python
637
+ from conformly import case, Email
638
+ from conformly.tracer import Tracer
639
+
640
+ tracer = Tracer()
641
+
642
+ payload = case(User, valid=False, tracer=tracer)
643
+
644
+ trace = tracer.build()
645
+
646
+ print(trace.target_path)
647
+ print(trace.generated_value)
648
+ print(trace.seed)
649
+ ```
650
+
651
+ The trace includes generation metadata such as the target path, generated value, violation type, random seed, and value source.
652
+
609
653
 
610
654
  ## Development
611
655
 
@@ -38,6 +38,7 @@ No factories, no hardcoded fixtures, no drift when schema changes.
38
38
  - [User Cases](#use-cases)
39
39
  - [Nested Models](#nested-models)
40
40
  - [Collections](#collections)
41
+ - [Experimental](#experimental)
41
42
  - [Development](#development)
42
43
  - [Changelog](#changelog)
43
44
  - [License](#license)
@@ -209,8 +210,8 @@ path("profile.name").violate(V.PATTERN_MISMATCH)
209
210
  - `"first"` - violate the first constrained field (for `case`) or take the first N constrained fields (for `cases`)
210
211
  - `"all_violations"` - generate one invalid case per every available violations including constraints, structural and type violations (ignores count)
211
212
  - `"overrides"` - default values for fields in current generation
212
- > If a field is selected for invalidation → override is ignored
213
- > Otherwise → override is applied
213
+ > Note: If a field is selected for invalidation → override is ignored.
214
+ > Otherwise → override is applied.
214
215
  ```python
215
216
  overrides=[
216
217
  path(User, lambda u: u.full_name).set("Amogus"),
@@ -527,6 +528,24 @@ case(Model)
527
528
 
528
529
  This ensures consistent output format (list) while preserving uniqueness guarantees.
529
530
 
531
+ ### Tuple support
532
+
533
+ Fixed tuples and variadic tuples (`tuple[T, ...]`) are generated in their declared
534
+ order. Element annotations and nested models are handled independently:
535
+
536
+ ```python
537
+ @dataclass
538
+ class Event:
539
+ point: tuple[float, float]
540
+ labels: tuple[str, ...]
541
+
542
+ case(Event, valid=True)
543
+ # -> {"point": (1.2, 3.4), "labels": ("abc", "def")}
544
+ ```
545
+
546
+ For invalid generation, a constrained tuple element is selected and invalidated
547
+ without changing the other elements.
548
+
530
549
  ### Dict support
531
550
 
532
551
  `conformly` supports generation of `dict[K, V]`, where K must be a hashable type (`str` or `Enum`) and `V` can be any supported type (primitives, nested models, etc.).
@@ -573,6 +592,31 @@ invalid = case(Inventory, valid=False, strategy=path("products").violate(V.TOO_S
573
592
  - Uniqueness for non-hashable elements (e.g., dicts) is best-effort (based on structural comparison fallback)
574
593
  - Dictionary keys are restricted to `str` and `Enum` types; complex objects as keys are not supported
575
594
 
595
+ ## Experimental
596
+
597
+ > APIs in this section are experimental and may change in future minor releases.
598
+
599
+ ### Tracing
600
+
601
+ `Tracer` captures metadata about case generation, making it easier to debug, reproduce, and report generated test cases.
602
+
603
+ ```python
604
+ from conformly import case, Email
605
+ from conformly.tracer import Tracer
606
+
607
+ tracer = Tracer()
608
+
609
+ payload = case(User, valid=False, tracer=tracer)
610
+
611
+ trace = tracer.build()
612
+
613
+ print(trace.target_path)
614
+ print(trace.generated_value)
615
+ print(trace.seed)
616
+ ```
617
+
618
+ The trace includes generation metadata such as the target path, generated value, violation type, random seed, and value source.
619
+
576
620
 
577
621
  ## Development
578
622
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "conformly"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Generate valid & invalid test data from your typed schemas"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -7,6 +7,7 @@ from .path import PathSelector
7
7
  from conformly._internal.generator.context import create_context
8
8
  from conformly._internal.generator.orchestration import generate_invalid, generate_valid
9
9
  from conformly._internal.parser import ModelSpec
10
+ from conformly._internal.tracer import Tracer
10
11
  from conformly._internal.types import CaseStrategy
11
12
 
12
13
 
@@ -18,6 +19,7 @@ def case(
18
19
  strategy: CaseStrategy | PathSelector = "first",
19
20
  overrides: list[PathSelector] | None = None,
20
21
  allow_type_mismatch: bool = False,
22
+ tracer: Tracer | None = None,
21
23
  ) -> dict[str, Any]:
22
24
  """
23
25
  Generate a single example of a model.
@@ -79,6 +81,12 @@ def case(
79
81
  _overrides = normalize_overrides(model, overrides)
80
82
 
81
83
  if valid:
84
+ if tracer:
85
+ raise api_error(
86
+ "Tracing is only available for invalid generation",
87
+ code="tracing_for_valid",
88
+ )
89
+
82
90
  if allow_type_mismatch:
83
91
  raise api_error(
84
92
  "Type mismatch is only available for invalid generation",
@@ -103,6 +111,9 @@ def case(
103
111
  strategy=strategy,
104
112
  )
105
113
 
114
+ if tracer and isinstance(strategy, PathSelector):
115
+ tracer.set_target_path(strategy.raw_path)
116
+
106
117
  task = plan_tasks(
107
118
  model,
108
119
  ctx=ctx,
@@ -111,4 +122,4 @@ def case(
111
122
  count=1,
112
123
  allow_type_mismatch=allow_type_mismatch,
113
124
  )[0]
114
- return generate_invalid(ctx, model, task, _overrides)
125
+ return generate_invalid(ctx, model, task, _overrides, tracer)
@@ -10,7 +10,8 @@ from conformly._internal.resolver import (
10
10
  ResolvedModel,
11
11
  create_minimal_semantic,
12
12
  )
13
- from conformly._internal.resolver.semantics import ListSemantic
13
+ from conformly._internal.resolver.semantics import ListSemantic, TupleSemantic
14
+ from conformly._internal.tracer import Tracer, ValueSource
14
15
  from conformly._internal.types import UNSET, FieldKind, FieldPath, ViolationType
15
16
 
16
17
 
@@ -30,7 +31,10 @@ def generate_invalid(
30
31
  model: ResolvedModel,
31
32
  task: PlannedTask,
32
33
  overrides: dict[FieldPath, Any] | None = None,
34
+ tracer: Tracer | None = None,
33
35
  ) -> dict[str, Any]:
36
+ if tracer:
37
+ tracer.set_seed(ctx.seed)
34
38
  return _built_dict_with_violations(
35
39
  ctx=ctx,
36
40
  model=model,
@@ -38,6 +42,7 @@ def generate_invalid(
38
42
  depth=0,
39
43
  violations=task.allowed_violations,
40
44
  overrides=overrides,
45
+ tracer=tracer,
41
46
  )
42
47
 
43
48
 
@@ -48,6 +53,7 @@ def _built_dict_with_violations(
48
53
  depth: int,
49
54
  violations: tuple[ViolationType, ...],
50
55
  overrides: dict[FieldPath, Any] | None = None,
56
+ tracer: Tracer | None = None,
51
57
  ) -> dict[str, Any]:
52
58
  result: dict[str, Any] = {}
53
59
  target_index = target_path[depth]
@@ -66,7 +72,7 @@ def _built_dict_with_violations(
66
72
 
67
73
  for i in range(0, target_index):
68
74
  field = fields[i]
69
- result[field.name] = generate_field(ctx, field, None, overrides)
75
+ result[field.name] = generate_field(ctx, field, None, overrides, tracer)
70
76
 
71
77
  if target_index < total_fields:
72
78
  field = fields[target_index]
@@ -79,7 +85,7 @@ def _built_dict_with_violations(
79
85
  field=field.name,
80
86
  )
81
87
 
82
- value = generate_field(ctx, field, violations, overrides)
88
+ value = generate_field(ctx, field, violations, overrides, tracer)
83
89
  if value is not UNSET:
84
90
  result[field.name] = value
85
91
 
@@ -98,18 +104,19 @@ def _built_dict_with_violations(
98
104
  depth=depth + 1,
99
105
  violations=violations,
100
106
  overrides=overrides,
107
+ tracer=tracer,
101
108
  )
102
109
 
103
110
  else:
104
111
  for i, field in enumerate(fields):
105
- result[field.name] = generate_field(ctx, field)
112
+ result[field.name] = generate_field(ctx, field, tracer=tracer)
106
113
 
107
114
  key, value = _generate_extra_field_value(ctx)
108
115
  result[key] = value
109
116
 
110
117
  for i in range(target_index + 1, total_fields):
111
118
  field = fields[i]
112
- result[field.name] = generate_field(ctx, field)
119
+ result[field.name] = generate_field(ctx, field, tracer=tracer)
113
120
 
114
121
  return result
115
122
 
@@ -119,38 +126,69 @@ def generate_field(
119
126
  field: ResolvedField,
120
127
  violations: tuple[ViolationType, ...] | None = None,
121
128
  overrides: dict[FieldPath, Any] | None = None,
129
+ tracer: Tracer | None = None,
122
130
  ) -> Any:
123
131
  if violations is None:
124
132
  if overrides and field.path in overrides:
125
- return overrides[field.path]
133
+ value = overrides[field.path]
134
+ if tracer:
135
+ tracer.set_value_source(ValueSource.OVERRIDDEN)
136
+ tracer.set_generated_value(value)
137
+ return value
126
138
 
127
139
  if field.nullable:
128
140
  return None
129
141
 
130
142
  if field.default is not UNSET:
143
+ if tracer:
144
+ tracer.set_value_source(ValueSource.MODEL_DEFAULT)
145
+
131
146
  default = field.default
132
147
  if callable(default):
148
+ if tracer:
149
+ tracer.set_generated_value(default())
133
150
  return default()
134
151
 
135
- return field.default
152
+ if tracer:
153
+ tracer.set_generated_value(default())
154
+
155
+ return default
136
156
 
137
- if field.nested_model and not isinstance(field.semantic, ListSemantic):
157
+ if field.nested_model and not isinstance(
158
+ field.semantic, (ListSemantic, TupleSemantic)
159
+ ):
138
160
  return generate_valid(ctx, field.nested_model, overrides)
139
161
 
140
162
  violation = _choose_violation(violations)
141
163
 
164
+ if tracer:
165
+ tracer.set_violation(violation)
166
+ tracer.set_value_source(ValueSource.GENERATED)
167
+
142
168
  if violation == ViolationType.TYPE_MISMATCH:
143
169
  mismatch_kind = choose_mismatch_kind(field.semantic.kind)
144
170
  mismatch_semantic = create_minimal_semantic(mismatch_kind)
145
- return get_generator(mismatch_kind).generate_value(ctx, mismatch_semantic, None)
171
+ value = get_generator(mismatch_kind).generate_value(
172
+ ctx, mismatch_semantic, None
173
+ )
174
+ if tracer:
175
+ tracer.set_generated_value(value)
176
+ return value
146
177
 
147
178
  if violation == ViolationType.MISSING_FIELD:
179
+ if tracer:
180
+ tracer.set_generated_value(UNSET)
148
181
  return UNSET
149
182
 
150
- return get_generator(field.semantic.kind).generate_value(
183
+ value = get_generator(field.semantic.kind).generate_value(
151
184
  ctx, field.semantic, violation
152
185
  )
153
186
 
187
+ if tracer:
188
+ tracer.set_generated_value(value)
189
+
190
+ return value
191
+
154
192
 
155
193
  def _choose_violation(
156
194
  violations: tuple[ViolationType, ...] | None,
@@ -12,6 +12,7 @@ from .types import (
12
12
  ipvany,
13
13
  list,
14
14
  string,
15
+ tuple,
15
16
  url,
16
17
  uuid,
17
18
  )
@@ -30,6 +31,7 @@ _GENERATORS: dict[FieldKind, TypeGeneratorProtocol] = {
30
31
  FieldKind.IPvAny: ipvany,
31
32
  FieldKind.LIST: list,
32
33
  FieldKind.DICT: dictionaries,
34
+ FieldKind.TUPLE: tuple,
33
35
  FieldKind.UUID: uuid,
34
36
  FieldKind.URL: url,
35
37
  FieldKind.HTTPURL: url,
@@ -51,6 +53,7 @@ _MISMATCH_MAPPING: dict[FieldKind, FieldKind] = {
51
53
  FieldKind.UUID: FieldKind.FLOAT,
52
54
  FieldKind.URL: FieldKind.FLOAT,
53
55
  FieldKind.HTTPURL: FieldKind.INTEGER,
56
+ FieldKind.TUPLE: FieldKind.INTEGER,
54
57
  }
55
58
 
56
59
 
@@ -0,0 +1,93 @@
1
+ from typing import Any
2
+
3
+ from ..context import GenerationContext
4
+ from ._utils import generate_collection_item
5
+
6
+ from conformly._internal.resolver.semantics import TupleSemantic
7
+ from conformly._internal.types import ViolationType
8
+ from conformly.exceptions import PlanningError
9
+
10
+
11
+ def _supports_violation(semantic: Any, violation: ViolationType) -> bool:
12
+ from conformly._internal.planner.field import _define_allowed_violation_types
13
+
14
+ try:
15
+ return violation in _define_allowed_violation_types(semantic)
16
+ except PlanningError:
17
+ return False
18
+
19
+
20
+ def generate_value(
21
+ ctx: GenerationContext,
22
+ semantic: TupleSemantic,
23
+ violation: ViolationType | None = None,
24
+ ) -> tuple[Any, ...]:
25
+ if violation is None:
26
+ return _generate_valid_tuple(ctx, semantic)
27
+
28
+ return _generate_invalid_tuple(ctx, semantic, violation)
29
+
30
+
31
+ def _generate_valid_tuple(
32
+ ctx: GenerationContext, semantic: TupleSemantic
33
+ ) -> tuple[Any, ...]:
34
+ if not semantic.is_variadic:
35
+ elements = semantic.elements_semantics
36
+ else:
37
+ min_len = semantic.length_range.min_length if semantic.length_range else 1
38
+ max_len = semantic.length_range.max_length if semantic.length_range else 3
39
+ length = ctx.rng.randint(min_len or 1, max_len or 3)
40
+ elements = semantic.elements_semantics * length
41
+
42
+ return tuple(
43
+ generate_collection_item(ctx, item_semantic, nested_model, None)
44
+ for item_semantic, nested_model in elements
45
+ )
46
+
47
+
48
+ def _generate_invalid_tuple(
49
+ ctx: GenerationContext, semantic: TupleSemantic, violation: ViolationType
50
+ ) -> tuple[Any, ...]:
51
+ if violation in (ViolationType.TOO_LESS_ITEMS, ViolationType.TOO_MANY_ITEMS):
52
+ if not semantic.is_variadic:
53
+ return _generate_valid_tuple(ctx, semantic)
54
+
55
+ min_len = semantic.length_range.min_length if semantic.length_range else 1
56
+ max_len = semantic.length_range.max_length if semantic.length_range else 3
57
+ length = (
58
+ max(0, min_len - 1)
59
+ if violation == ViolationType.TOO_LESS_ITEMS
60
+ else (max_len or 3) + 1
61
+ )
62
+ return tuple(
63
+ generate_collection_item(ctx, *semantic.elements_semantics[0], None)
64
+ for _ in range(length)
65
+ )
66
+
67
+ if semantic.is_variadic:
68
+ min_len = semantic.length_range.min_length if semantic.length_range else 1
69
+ max_len = semantic.length_range.max_length if semantic.length_range else 3
70
+ length = ctx.rng.randint(min_len or 1, max_len or 3)
71
+ elements = semantic.elements_semantics * length
72
+ else:
73
+ elements = semantic.elements_semantics
74
+
75
+ candidates = [
76
+ i
77
+ for i, (item_semantic, _) in enumerate(elements)
78
+ if _supports_violation(item_semantic, violation)
79
+ ]
80
+ violate_idx = (
81
+ ctx.rng.choice(candidates)
82
+ if candidates
83
+ else ctx.rng.randint(0, len(elements) - 1)
84
+ )
85
+ return tuple(
86
+ generate_collection_item(
87
+ ctx,
88
+ item_semantic,
89
+ nested_model,
90
+ (violation,) if i == violate_idx else None,
91
+ )
92
+ for i, (item_semantic, nested_model) in enumerate(elements)
93
+ )
@@ -2,7 +2,14 @@ from collections.abc import Callable
2
2
  from typing import Any
3
3
 
4
4
  from ..extractors.constraints import split_collection_constraints
5
- from ..extractors.types import DictNode, ListNode, ScalarNode, TypeNode, normalize_type
5
+ from ..extractors.types import (
6
+ DictNode,
7
+ ListNode,
8
+ ScalarNode,
9
+ TupleNode,
10
+ TypeNode,
11
+ normalize_type,
12
+ )
6
13
  from ..models import ElementSpec, FieldSpec
7
14
 
8
15
  from conformly._internal.constraints import Constraint
@@ -41,6 +48,22 @@ def build_field_spec(
41
48
  nullable=node.nullable,
42
49
  )
43
50
 
51
+ case TupleNode():
52
+ element_constraints, collection_constraints = split_collection_constraints(
53
+ external_constraints
54
+ )
55
+
56
+ return FieldSpec(
57
+ name=name,
58
+ collection_type=node.origin,
59
+ collection_constraints=(*node.constraints, *collection_constraints),
60
+ items=tuple(
61
+ resolve_element(i, name, element_constraints) for i in node.items
62
+ ),
63
+ default=default,
64
+ nullable=node.nullable,
65
+ )
66
+
44
67
  case DictNode():
45
68
  return FieldSpec(
46
69
  name=name,
@@ -37,6 +37,16 @@ class ListNode:
37
37
  item: "TypeNode"
38
38
 
39
39
 
40
+ @dataclass(frozen=True, slots=True)
41
+ class TupleNode:
42
+ kind: Literal["tuple"]
43
+ runtime_type: Any
44
+ origin: type
45
+ nullable: bool
46
+ constraints: tuple[Constraint, ...]
47
+ items: tuple["TypeNode", ...]
48
+
49
+
40
50
  @dataclass(frozen=True, slots=True)
41
51
  class DictNode:
42
52
  kind: Literal["dict"]
@@ -47,7 +57,7 @@ class DictNode:
47
57
  value: "TypeNode"
48
58
 
49
59
 
50
- TypeNode = ScalarNode | ListNode | DictNode
60
+ TypeNode = ScalarNode | ListNode | DictNode | TupleNode
51
61
 
52
62
 
53
63
  def normalize_type(field_type: Any, field_name: str) -> TypeNode:
@@ -116,6 +126,32 @@ def _build_node(
116
126
  item=item,
117
127
  )
118
128
 
129
+ if origin is tuple:
130
+ if not args:
131
+ raise SchemaError(
132
+ f"Field '{field_name}': '{origin.__name__}' must be parameterized",
133
+ context={
134
+ "code": "empty_collection",
135
+ "field_name": field_name,
136
+ "field_type": repr(t),
137
+ },
138
+ )
139
+ items: list[TypeNode] = []
140
+ if len(args) == 2 and args[1] is Ellipsis:
141
+ items.append(normalize_type(args[0], field_name))
142
+ else:
143
+ for i, _ in enumerate(args):
144
+ item = normalize_type(args[i], field_name)
145
+ items.append(item)
146
+ return TupleNode(
147
+ kind="tuple",
148
+ runtime_type=origin,
149
+ origin=origin,
150
+ nullable=nullable,
151
+ constraints=constraints,
152
+ items=tuple(items),
153
+ )
154
+
119
155
  if origin is dict:
120
156
  if len(args) != 2:
121
157
  raise SchemaError(
@@ -23,6 +23,7 @@ class FieldSpec:
23
23
  collection_type: type | None = None
24
24
  collection_constraints: tuple[Constraint, ...] = ()
25
25
  item: ElementSpec | None = None
26
+ items: tuple[ElementSpec, ...] | None = None
26
27
  key: ElementSpec | None = None
27
28
  value: ElementSpec | None = None
28
29
  nullable: bool = False
@@ -38,6 +39,7 @@ class FieldSpec:
38
39
  return (
39
40
  (self.element is not None and len(self.element.constraints) != 0)
40
41
  or (self.item is not None and len(self.item.constraints) != 0)
42
+ or any(len(item.constraints) != 0 for item in (self.items or ()))
41
43
  or (self.key is not None and len(self.key.constraints) != 0)
42
44
  or (self.value is not None and len(self.value.constraints) != 0)
43
45
  or len(self.collection_constraints) != 0
@@ -47,6 +49,7 @@ class FieldSpec:
47
49
  return (
48
50
  (self.element is not None and self.element.nested_model is not None)
49
51
  or (self.item is not None and self.item.nested_model is not None)
52
+ or any(item.nested_model is not None for item in (self.items or ()))
50
53
  or (self.key is not None and self.key.nested_model is not None)
51
54
  or (self.value is not None and self.value.nested_model is not None)
52
55
  )
@@ -11,6 +11,7 @@ from conformly._internal.resolver.semantics import (
11
11
  NumericSemantic,
12
12
  ObjectSemantic,
13
13
  StringSemantic,
14
+ TupleSemantic,
14
15
  UUIDSemantic,
15
16
  )
16
17
  from conformly._internal.resolver.semantics.dict import DictSemantic
@@ -123,6 +124,9 @@ def _define_allowed_violation_types(
123
124
  case DictSemantic(kind=FieldKind.DICT):
124
125
  violations = _define_dict_violations(semantic)
125
126
 
127
+ case TupleSemantic(kind=FieldKind.TUPLE):
128
+ violations = _define_tuple_violations(semantic)
129
+
126
130
  case EnumSemantic(kind=FieldKind.ENUM):
127
131
  violations = [ViolationType.NOT_ALLOWED_VALUE]
128
132
 
@@ -252,3 +256,20 @@ def _safe_define(semantic: FieldSemantics) -> list[ViolationType]:
252
256
  return list(_define_allowed_violation_types(semantic, False, False))
253
257
  except PlanningError:
254
258
  return []
259
+
260
+
261
+ def _define_tuple_violations(semantic: TupleSemantic) -> list[ViolationType]:
262
+ result: list[ViolationType] = []
263
+
264
+ if semantic.is_variadic and semantic.length_range is not None:
265
+ if semantic.length_range.min_length > 0:
266
+ result.append(ViolationType.TOO_LESS_ITEMS)
267
+ if semantic.length_range.max_length is not None:
268
+ result.append(ViolationType.TOO_MANY_ITEMS)
269
+
270
+ for item_semantic, _ in semantic.elements_semantics:
271
+ for violation in _safe_define(item_semantic):
272
+ if violation not in result:
273
+ result.append(violation)
274
+
275
+ return result