granttrace 2.4.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.
core/generator.py ADDED
@@ -0,0 +1,1048 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Constraint-aware request data generation for GrantTrace."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ import datetime
8
+ import json
9
+ import math
10
+ import re
11
+ import uuid
12
+ from fractions import Fraction
13
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
14
+
15
+
16
+ JsonPathPart = Union[str, int]
17
+
18
+
19
+ class SchemaGenerationError(ValueError):
20
+ """The bounded generator cannot construct a demonstrably valid request."""
21
+
22
+
23
+ class SmartDataGenerator:
24
+ PRIVILEGE_KEYS = [
25
+ "is_admin",
26
+ "isAdmin",
27
+ "admin",
28
+ "role",
29
+ "roles",
30
+ "user_type",
31
+ "privilege",
32
+ "privileges",
33
+ "permission",
34
+ "permissions",
35
+ "is_superuser",
36
+ "superuser",
37
+ "verified",
38
+ "status",
39
+ "group",
40
+ "group_id",
41
+ "tier",
42
+ "plan",
43
+ "credit",
44
+ "balance",
45
+ ]
46
+
47
+ _NON_RESOURCE_PARAMETER_NAMES = {
48
+ "page",
49
+ "page_id",
50
+ "page_size",
51
+ "pagesize",
52
+ "limit",
53
+ "offset",
54
+ "cursor",
55
+ "sort",
56
+ "order",
57
+ "q",
58
+ "query",
59
+ "search",
60
+ "filter",
61
+ }
62
+ _PARAMETER_SCHEMA_KEYS = {
63
+ "type",
64
+ "format",
65
+ "enum",
66
+ "default",
67
+ "example",
68
+ "examples",
69
+ "minimum",
70
+ "maximum",
71
+ "exclusiveMinimum",
72
+ "exclusiveMaximum",
73
+ "multipleOf",
74
+ "minLength",
75
+ "maxLength",
76
+ "pattern",
77
+ "minItems",
78
+ "maxItems",
79
+ "items",
80
+ "nullable",
81
+ }
82
+
83
+ @classmethod
84
+ def _parameter_schema(cls, param_meta: Dict[str, Any]) -> Dict[str, Any]:
85
+ schema = param_meta.get("schema")
86
+ if isinstance(schema, dict):
87
+ return schema
88
+ # Swagger 2.0 puts primitive parameter constraints directly on the
89
+ # parameter instead of under a schema key.
90
+ return {
91
+ key: copy.deepcopy(value)
92
+ for key, value in param_meta.items()
93
+ if key in cls._PARAMETER_SCHEMA_KEYS
94
+ }
95
+
96
+ @classmethod
97
+ def _is_resource_identifier(cls, param_meta: Dict[str, Any]) -> bool:
98
+ original = str(param_meta.get("name", ""))
99
+ normalized = re.sub(r"[-.\s]+", "_", original).lower()
100
+ if normalized in cls._NON_RESOURCE_PARAMETER_NAMES:
101
+ return False
102
+ if normalized in {"id", "uuid", "uid", "resource_id", "object_id"}:
103
+ return True
104
+ if re.search(r"(?:^|_)(?:id|uuid|uid)$", normalized):
105
+ return True
106
+ return bool(re.search(r"(?:Id|ID|Uuid|UUID|Uid|UID)$", original))
107
+
108
+ @staticmethod
109
+ def _example_from_examples(examples: Any) -> Any:
110
+ if isinstance(examples, list) and examples:
111
+ return examples[0]
112
+ if isinstance(examples, dict):
113
+ for entry in examples.values():
114
+ if isinstance(entry, dict) and "value" in entry:
115
+ return entry["value"]
116
+ if not isinstance(entry, dict):
117
+ return entry
118
+ return None
119
+
120
+ @classmethod
121
+ def _documented_values(
122
+ cls, param_meta: Dict[str, Any], schema: Dict[str, Any]
123
+ ) -> Iterable[Any]:
124
+ if "example" in param_meta:
125
+ yield param_meta["example"]
126
+ parameter_example = cls._example_from_examples(param_meta.get("examples"))
127
+ if parameter_example is not None:
128
+ yield parameter_example
129
+ if "example" in schema:
130
+ yield schema["example"]
131
+ schema_example = cls._example_from_examples(schema.get("examples"))
132
+ if schema_example is not None:
133
+ yield schema_example
134
+ if "default" in schema:
135
+ yield schema["default"]
136
+ if "const" in schema:
137
+ yield schema["const"]
138
+ enum = schema.get("enum")
139
+ if isinstance(enum, list):
140
+ yield from enum
141
+
142
+ @staticmethod
143
+ def _coerce_for_schema(value: Any, schema: Dict[str, Any]) -> Any:
144
+ schema_type = schema.get("type")
145
+ if schema_type == "integer" and not isinstance(value, bool):
146
+ try:
147
+ return int(value)
148
+ except (TypeError, ValueError):
149
+ return value
150
+ if schema_type == "number" and not isinstance(value, bool):
151
+ try:
152
+ return float(value)
153
+ except (TypeError, ValueError):
154
+ return value
155
+ if schema_type == "boolean" and isinstance(value, str):
156
+ if value.lower() in {"true", "1"}:
157
+ return True
158
+ if value.lower() in {"false", "0"}:
159
+ return False
160
+ if schema_type == "string" and value is not None:
161
+ return str(value)
162
+ return value
163
+
164
+ @classmethod
165
+ def validate_schema_value(cls, value: Any, schema: Any, *, request: bool = True) -> bool:
166
+ """Validate the supported schema subset recursively, failing closed.
167
+
168
+ In request mode, required readOnly properties need not be supplied.
169
+ readOnly is otherwise an annotation, so explicitly authorized injection
170
+ probes can still validate the field's type and value constraints.
171
+ Unresolved references and unsupported evaluation-dependent keywords
172
+ return False. This is not a complete JSON Schema implementation.
173
+ """
174
+ try:
175
+ return cls._validate_schema_value(value, schema, request=request)
176
+ except (TypeError, ValueError, OverflowError, RecursionError, re.error):
177
+ return False
178
+
179
+ @classmethod
180
+ def _value_satisfies_schema(cls, value: Any, schema: Dict[str, Any]) -> bool:
181
+ """Backward-compatible request-value validation helper."""
182
+ return cls.validate_schema_value(value, schema)
183
+
184
+ @classmethod
185
+ def _validate_schema_value(cls, value: Any, schema: Any, *, request: bool) -> bool:
186
+ if isinstance(schema, bool):
187
+ return schema
188
+ if not isinstance(schema, dict):
189
+ return False
190
+ if any(key in schema for key in (
191
+ "$ref", "$dynamicRef", "$recursiveRef", "unevaluatedProperties", "unevaluatedItems"
192
+ )):
193
+ return False
194
+
195
+ def check(candidate, child):
196
+ return cls._validate_schema_value(candidate, child, request=request)
197
+
198
+ for keyword in ("allOf", "anyOf", "oneOf"):
199
+ if keyword not in schema:
200
+ continue
201
+ branches = schema[keyword]
202
+ if not isinstance(branches, list) or not branches:
203
+ return False
204
+ matches = [check(value, child) for child in branches]
205
+ if keyword == "allOf" and not all(matches):
206
+ return False
207
+ if keyword == "anyOf" and not any(matches):
208
+ return False
209
+ if keyword == "oneOf" and sum(matches) != 1:
210
+ return False
211
+ if "not" in schema and check(value, schema["not"]):
212
+ return False
213
+ if "if" in schema:
214
+ selected = "then" if check(value, schema["if"]) else "else"
215
+ if selected in schema and not check(value, schema[selected]):
216
+ return False
217
+
218
+ def json_equal(left: Any, right: Any) -> bool:
219
+ # Python considers True == 1, but JSON Schema does not.
220
+ if isinstance(left, bool) != isinstance(right, bool):
221
+ return False
222
+ if isinstance(left, dict) and isinstance(right, dict):
223
+ return left.keys() == right.keys() and all(json_equal(left[k], right[k]) for k in left)
224
+ if isinstance(left, list) and isinstance(right, list):
225
+ return len(left) == len(right) and all(json_equal(a, b) for a, b in zip(left, right))
226
+ return left == right
227
+
228
+ if "const" in schema and not json_equal(value, schema["const"]):
229
+ return False
230
+
231
+ enum = schema.get("enum")
232
+ if enum is not None and (not isinstance(enum, list) or not any(json_equal(value, item) for item in enum)):
233
+ return False
234
+
235
+ schema_type = schema.get("type")
236
+ if value is None and schema.get("nullable"):
237
+ return True
238
+ if isinstance(schema_type, list):
239
+ return any(
240
+ check(value, {**schema, "type": item})
241
+ for item in schema_type
242
+ )
243
+ if schema_type == "null":
244
+ return value is None
245
+ if value is None:
246
+ return schema_type is None
247
+ if schema_type is not None and schema_type not in {
248
+ "integer", "number", "boolean", "string", "array", "object"
249
+ }:
250
+ return False
251
+ if schema_type == "integer" and (isinstance(value, bool) or not isinstance(value, int)):
252
+ return False
253
+ if schema_type == "number" and (
254
+ isinstance(value, bool) or not isinstance(value, (int, float))
255
+ ):
256
+ return False
257
+ if schema_type == "boolean" and not isinstance(value, bool):
258
+ return False
259
+ if schema_type == "string" and not isinstance(value, str):
260
+ return False
261
+ if schema_type == "array" and not isinstance(value, list):
262
+ return False
263
+ if schema_type == "object" and not isinstance(value, dict):
264
+ return False
265
+
266
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
267
+ if not math.isfinite(value):
268
+ return False
269
+ minimum = schema.get("minimum")
270
+ maximum = schema.get("maximum")
271
+ exclusive_minimum = schema.get("exclusiveMinimum")
272
+ exclusive_maximum = schema.get("exclusiveMaximum")
273
+ if minimum is not None:
274
+ if exclusive_minimum is True and value <= minimum:
275
+ return False
276
+ if exclusive_minimum is not True and value < minimum:
277
+ return False
278
+ if isinstance(exclusive_minimum, (int, float)) and not isinstance(exclusive_minimum, bool):
279
+ if value <= exclusive_minimum:
280
+ return False
281
+ if maximum is not None:
282
+ if exclusive_maximum is True and value >= maximum:
283
+ return False
284
+ if exclusive_maximum is not True and value > maximum:
285
+ return False
286
+ if isinstance(exclusive_maximum, (int, float)) and not isinstance(exclusive_maximum, bool):
287
+ if value >= exclusive_maximum:
288
+ return False
289
+ multiple = schema.get("multipleOf")
290
+ if isinstance(multiple, (int, float)) and multiple > 0:
291
+ quotient = Fraction(str(value)) / Fraction(str(multiple))
292
+ if quotient.denominator != 1:
293
+ return False
294
+
295
+ if isinstance(value, str):
296
+ if len(value) < int(schema.get("minLength", 0)):
297
+ return False
298
+ max_length = schema.get("maxLength")
299
+ if max_length is not None and len(value) > int(max_length):
300
+ return False
301
+ pattern = schema.get("pattern")
302
+ if pattern:
303
+ try:
304
+ if re.search(str(pattern), value) is None:
305
+ return False
306
+ except re.error:
307
+ return False
308
+ fmt = str(schema.get("format", "")).lower()
309
+ if fmt == "uuid":
310
+ try:
311
+ uuid.UUID(value)
312
+ except (ValueError, AttributeError):
313
+ return False
314
+ elif fmt == "email" and not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value):
315
+ return False
316
+ elif fmt == "date":
317
+ try:
318
+ datetime.date.fromisoformat(value)
319
+ except ValueError:
320
+ return False
321
+ elif fmt == "date-time":
322
+ try:
323
+ datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
324
+ except ValueError:
325
+ return False
326
+
327
+ if isinstance(value, list):
328
+ if len(value) < int(schema.get("minItems", 0)):
329
+ return False
330
+ max_items = schema.get("maxItems")
331
+ if max_items is not None and len(value) > int(max_items):
332
+ return False
333
+ if schema.get("uniqueItems"):
334
+ if any(json_equal(item, previous) for index, item in enumerate(value) for previous in value[:index]):
335
+ return False
336
+ prefix = schema.get("prefixItems", [])
337
+ items = schema.get("items", {})
338
+ if isinstance(items, list):
339
+ prefix, items = items, schema.get("additionalItems", {})
340
+ if not all(check(item, prefix[index] if index < len(prefix) else items) for index, item in enumerate(value)):
341
+ return False
342
+ if "contains" in schema:
343
+ count = sum(check(item, schema["contains"]) for item in value)
344
+ if count < schema.get("minContains", 1) or count > schema.get("maxContains", math.inf):
345
+ return False
346
+
347
+ if isinstance(value, dict):
348
+ properties = schema.get("properties", {})
349
+ if not isinstance(properties, dict):
350
+ return False
351
+ required = schema.get("required", [])
352
+ for name in required:
353
+ child = properties.get(name, {})
354
+ effective = cls._effective_schema(child) if isinstance(child, dict) else {}
355
+ if request and effective.get("readOnly"):
356
+ continue
357
+ if name not in value:
358
+ return False
359
+ if len(value) < schema.get("minProperties", 0) or len(value) > schema.get("maxProperties", math.inf):
360
+ return False
361
+ patterns = schema.get("patternProperties", {})
362
+ for name, item in value.items():
363
+ if "propertyNames" in schema and not check(name, schema["propertyNames"]):
364
+ return False
365
+ if name in properties and not check(item, properties[name]):
366
+ return False
367
+ matched = [child for pattern, child in patterns.items() if re.search(pattern, name)]
368
+ if not all(check(item, child) for child in matched):
369
+ return False
370
+ if name not in properties and not matched and not check(item, schema.get("additionalProperties", {})):
371
+ return False
372
+ for name, dependencies in schema.get("dependentRequired", {}).items():
373
+ if name in value and not all(key in value for key in dependencies):
374
+ return False
375
+ for name, child in schema.get("dependentSchemas", {}).items():
376
+ if name in value and not check(value, child):
377
+ return False
378
+ for name, dependency in schema.get("dependencies", {}).items():
379
+ if name in value:
380
+ if isinstance(dependency, list):
381
+ if not all(key in value for key in dependency):
382
+ return False
383
+ elif not check(value, dependency):
384
+ return False
385
+
386
+ return True
387
+
388
+ @staticmethod
389
+ def _serialize_parameter_value(value: Any) -> str:
390
+ if isinstance(value, bool):
391
+ return "true" if value else "false"
392
+ if isinstance(value, list):
393
+ return ",".join(SmartDataGenerator._serialize_parameter_value(item) for item in value)
394
+ if isinstance(value, dict):
395
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
396
+ return str(value)
397
+
398
+ @classmethod
399
+ def generate_value_for_param(
400
+ cls, param_meta: Dict[str, Any], candidate_id: Optional[str] = None
401
+ ) -> str:
402
+ """Return the historical string representation for existing callers.
403
+
404
+ Request builders should use ``generate_raw_value_for_param`` so arrays,
405
+ objects and booleans retain their types until style-aware serialization.
406
+ """
407
+ return cls._serialize_parameter_value(cls.generate_raw_value_for_param(param_meta, candidate_id))
408
+
409
+ @classmethod
410
+ def generate_raw_value_for_param(
411
+ cls, param_meta: Dict[str, Any], candidate_id: Optional[str] = None
412
+ ) -> Any:
413
+ """Generate a schema-valid parameter value without serializing it.
414
+
415
+ ``candidate_id`` is applied only to parameters whose names identify a
416
+ resource (for example ``user_id`` or ``documentUuid``). Paging, search,
417
+ sorting, and unrelated query parameters continue to use their schemas.
418
+ """
419
+ schema = cls._parameter_schema(param_meta)
420
+
421
+ if candidate_id is not None and cls._is_resource_identifier(param_meta):
422
+ candidate = cls._coerce_for_schema(candidate_id, schema)
423
+ if cls._value_satisfies_schema(candidate, schema):
424
+ return candidate
425
+
426
+ for documented in cls._documented_values(param_meta, schema):
427
+ value = cls._coerce_for_schema(documented, schema)
428
+ if cls._value_satisfies_schema(value, schema):
429
+ return value
430
+
431
+ if not schema:
432
+ parameter_name = str(param_meta.get("name", "")).lower()
433
+ return "test_user_id" if ("user" in parameter_name or "name" in parameter_name) else "sample_param"
434
+
435
+ value = cls._generate_schema_sample(schema, include_optional=True, field_name=param_meta.get("name"))
436
+ return value
437
+
438
+ @classmethod
439
+ def _numeric_bounds(cls, schema: Dict[str, Any]) -> Tuple[Any, bool, Any, bool]:
440
+ bounds = []
441
+ for ordinary, exclusive, lower in (
442
+ ("minimum", "exclusiveMinimum", True), ("maximum", "exclusiveMaximum", False)
443
+ ):
444
+ bound = schema.get(ordinary)
445
+ strict = schema.get(exclusive) is True
446
+ extra = schema.get(exclusive)
447
+ if isinstance(extra, (int, float)) and not isinstance(extra, bool):
448
+ if bound is None or (extra >= bound if lower else extra <= bound):
449
+ bound, strict = extra, True
450
+ bounds.extend((bound, strict))
451
+ return tuple(bounds)
452
+
453
+ @classmethod
454
+ def _numeric_sample(cls, schema: Dict[str, Any], integer: bool) -> Union[int, float]:
455
+ lower, lower_strict, upper, upper_strict = cls._numeric_bounds(schema)
456
+ if lower is not None and upper is not None:
457
+ if lower > upper or (lower == upper and (lower_strict or upper_strict)):
458
+ raise SchemaGenerationError("Numeric constraints have an empty intersection")
459
+ multiple = schema.get("multipleOf")
460
+ candidates: List[Any] = [0, 1, -1]
461
+ if multiple is not None:
462
+ if isinstance(multiple, bool) or multiple <= 0:
463
+ raise SchemaGenerationError("multipleOf must be positive")
464
+ step = Fraction(str(multiple))
465
+ if integer:
466
+ # Integer multiples of p/q are exactly multiples of p.
467
+ step = Fraction(step.numerator)
468
+ if lower is not None:
469
+ count = math.ceil(Fraction(str(lower)) / step)
470
+ if lower_strict and count * step <= Fraction(str(lower)):
471
+ count += 1
472
+ elif upper is not None:
473
+ count = math.floor(Fraction(str(upper)) / step)
474
+ if upper_strict and count * step >= Fraction(str(upper)):
475
+ count -= 1
476
+ else:
477
+ count = 1
478
+ candidates.insert(0, int(count * step) if integer else float(count * step))
479
+ elif integer:
480
+ if lower is not None:
481
+ candidates.insert(0, math.floor(lower) + 1 if lower_strict else math.ceil(lower))
482
+ if upper is not None:
483
+ candidates.append(math.ceil(upper) - 1 if upper_strict else math.floor(upper))
484
+ else:
485
+ if lower is not None and upper is not None:
486
+ candidates.insert(0, lower / 2 + upper / 2)
487
+ if lower is not None:
488
+ candidates.append(math.nextafter(float(lower), math.inf) if lower_strict else float(lower))
489
+ if upper is not None:
490
+ candidates.append(math.nextafter(float(upper), -math.inf) if upper_strict else float(upper))
491
+ for candidate in candidates:
492
+ if cls.validate_schema_value(candidate, schema):
493
+ return int(candidate) if integer else float(candidate)
494
+ raise SchemaGenerationError("Cannot construct a number satisfying all constraints")
495
+
496
+ @staticmethod
497
+ def _pattern_token(pattern: str) -> Optional[str]:
498
+ """Synthesize common anchored regexes without adding a dependency."""
499
+ source = pattern
500
+ if source.startswith("^"):
501
+ source = source[1:]
502
+ if source.endswith("$") and not source.endswith("\\$"):
503
+ source = source[:-1]
504
+
505
+ result: List[str] = []
506
+ index = 0
507
+ try:
508
+ while index < len(source):
509
+ char = source[index]
510
+ if char in "()|":
511
+ return None
512
+ if char == "\\":
513
+ index += 1
514
+ token = source[index]
515
+ atom = {"d": "0", "w": "a", "s": " "}.get(token, token)
516
+ elif char == "[":
517
+ end = source.index("]", index + 1)
518
+ content = source[index + 1 : end]
519
+ if content.startswith("^"):
520
+ return None
521
+ if "A-Z" in content:
522
+ atom = "A"
523
+ elif "a-z" in content:
524
+ atom = "a"
525
+ elif "0-9" in content or "\\d" in content:
526
+ atom = "0"
527
+ else:
528
+ atom = content[0] if content else "a"
529
+ index = end
530
+ elif char == ".":
531
+ atom = "a"
532
+ else:
533
+ atom = char
534
+
535
+ repeat = 1
536
+ if index + 1 < len(source):
537
+ next_char = source[index + 1]
538
+ if next_char == "{":
539
+ end = source.index("}", index + 2)
540
+ repeat = int(source[index + 2 : end].split(",", 1)[0] or "0")
541
+ index = end
542
+ elif next_char == "+":
543
+ repeat = 1
544
+ index += 1
545
+ elif next_char in "*?":
546
+ repeat = 0
547
+ index += 1
548
+ result.append(atom * repeat)
549
+ index += 1
550
+ except (IndexError, ValueError):
551
+ return None
552
+
553
+ value = "".join(result)
554
+ try:
555
+ return value if re.search(pattern, value) is not None else None
556
+ except re.error:
557
+ return None
558
+
559
+ @classmethod
560
+ def _string_sample(cls, schema: Dict[str, Any], field_name: Optional[str]) -> str:
561
+ fmt = str(schema.get("format", "")).lower()
562
+ normalized_name = str(field_name or "").lower()
563
+ if fmt == "uuid" or "uuid" in normalized_name:
564
+ value = str(uuid.uuid4())
565
+ elif fmt == "email" or "email" in normalized_name:
566
+ value = "audit@example.com"
567
+ elif fmt == "date-time":
568
+ value = datetime.datetime.now(datetime.timezone.utc).isoformat()
569
+ elif fmt == "date":
570
+ value = datetime.date.today().isoformat()
571
+ elif schema.get("pattern"):
572
+ value = cls._pattern_token(str(schema["pattern"])) or "sample"
573
+ elif "name" in normalized_name:
574
+ value = "test_user"
575
+ else:
576
+ value = "sample_text"
577
+
578
+ minimum = int(schema.get("minLength", 0))
579
+ maximum_raw = schema.get("maxLength")
580
+ maximum = int(maximum_raw) if maximum_raw is not None else None
581
+ if len(value) < minimum:
582
+ value += "a" * (minimum - len(value))
583
+ if maximum is not None and len(value) > maximum:
584
+ value = value[:maximum]
585
+ return value
586
+
587
+ @classmethod
588
+ def _merge_constraints(cls, left: Dict[str, Any], right: Dict[str, Any]) -> Dict[str, Any]:
589
+ """Build an intersection view for synthesis, never last-wins constraints.
590
+
591
+ Original schemas remain the authority for final recursive validation;
592
+ this view intentionally does not claim to normalize all JSON Schema.
593
+ """
594
+ merged = copy.deepcopy(left)
595
+ for key, value in right.items():
596
+ if key not in merged:
597
+ merged[key] = copy.deepcopy(value)
598
+ elif key == "properties":
599
+ for name, child in value.items():
600
+ old = merged[key].get(name)
601
+ merged[key][name] = copy.deepcopy(child) if old is None or old == child else {"allOf": [old, child]}
602
+ elif key == "required":
603
+ merged[key] = list(dict.fromkeys(merged[key] + value))
604
+ elif key in {"minLength", "minItems", "minProperties", "minContains"}:
605
+ merged[key] = max(merged[key], value)
606
+ elif key in {"maxLength", "maxItems", "maxProperties", "maxContains"}:
607
+ merged[key] = min(merged[key], value)
608
+ elif key == "enum":
609
+ merged[key] = [item for item in merged[key] if cls.validate_schema_value(item, {"enum": value})]
610
+ elif key == "const" and merged[key] != value:
611
+ merged["enum"] = []
612
+ elif key == "type":
613
+ old_types = merged[key] if isinstance(merged[key], list) else [merged[key]]
614
+ new_types = value if isinstance(value, list) else [value]
615
+ shared = [kind for kind in old_types if kind in new_types]
616
+ if ("integer" in old_types and "number" in new_types) or ("number" in old_types and "integer" in new_types):
617
+ shared.append("integer")
618
+ shared = list(dict.fromkeys(shared))
619
+ merged[key] = shared[0] if len(shared) == 1 else shared
620
+ elif key in {"readOnly", "writeOnly", "uniqueItems"}:
621
+ merged[key] = bool(merged[key] or value)
622
+ elif key == "multipleOf":
623
+ a, b = Fraction(str(merged[key])), Fraction(str(value))
624
+ if a <= 0 or b <= 0:
625
+ raise SchemaGenerationError("multipleOf must be positive")
626
+ merged[key] = float(Fraction(math.lcm(a.numerator, b.numerator), math.gcd(a.denominator, b.denominator)))
627
+ elif key in {"items", "additionalProperties"} and merged[key] != value:
628
+ merged[key] = {"allOf": [merged[key], copy.deepcopy(value)]}
629
+
630
+ for offset, ordinary, exclusive in (
631
+ (0, "minimum", "exclusiveMinimum"), (2, "maximum", "exclusiveMaximum")
632
+ ):
633
+ bounds = [cls._numeric_bounds(item)[offset:offset + 2] for item in (left, right)]
634
+ bounds = [item for item in bounds if item[0] is not None]
635
+ if bounds:
636
+ extreme = (max if offset == 0 else min)(item[0] for item in bounds)
637
+ merged[ordinary] = extreme
638
+ merged[exclusive] = any(strict for bound, strict in bounds if bound == extreme)
639
+ return merged
640
+
641
+ @classmethod
642
+ def _effective_schema(cls, schema: Dict[str, Any]) -> Dict[str, Any]:
643
+ if schema is True:
644
+ return {}
645
+ if schema is False:
646
+ return {"enum": []}
647
+ if not isinstance(schema, dict):
648
+ raise SchemaGenerationError("A schema must be an object or boolean")
649
+ effective = {key: copy.deepcopy(value) for key, value in schema.items() if key != "allOf"}
650
+ branches = schema.get("allOf", [])
651
+ if not isinstance(branches, list):
652
+ raise SchemaGenerationError("allOf must be an array")
653
+ for branch in branches:
654
+ effective = cls._merge_constraints(effective, cls._effective_schema(branch))
655
+ return effective
656
+
657
+ @classmethod
658
+ def _request_sample(cls, value: Any, schema: Any) -> Any:
659
+ """Remove readOnly data from documented examples before using them."""
660
+ effective = cls._effective_schema(schema)
661
+ if isinstance(value, dict):
662
+ properties = effective.get("properties", {})
663
+ return {
664
+ name: cls._request_sample(item, properties.get(name, {}))
665
+ for name, item in value.items()
666
+ if not cls._effective_schema(properties.get(name, {})).get("readOnly")
667
+ }
668
+ if isinstance(value, list):
669
+ items = effective.get("items", {})
670
+ prefix = effective.get("prefixItems", [])
671
+ if isinstance(items, list):
672
+ prefix, items = items, effective.get("additionalItems", {})
673
+ return [cls._request_sample(item, prefix[index] if index < len(prefix) else items) for index, item in enumerate(value)]
674
+ return copy.deepcopy(value)
675
+
676
+ @classmethod
677
+ def _generate_schema_sample(
678
+ cls,
679
+ schema: Dict[str, Any],
680
+ *,
681
+ include_optional: bool,
682
+ field_name: Optional[str] = None,
683
+ ) -> Any:
684
+ if not isinstance(schema, (dict, bool)):
685
+ raise SchemaGenerationError("A schema must be an object or boolean")
686
+ effective = cls._effective_schema(schema)
687
+ for documented in cls._documented_values({}, effective):
688
+ candidate = cls._request_sample(documented, schema)
689
+ if cls.validate_schema_value(candidate, schema):
690
+ return candidate
691
+ if "enum" in effective and not effective["enum"]:
692
+ raise SchemaGenerationError("Schema constraints have an empty enum intersection")
693
+
694
+ for keyword in ("oneOf", "anyOf"):
695
+ if keyword not in effective:
696
+ continue
697
+ branches = effective[keyword]
698
+ base = {key: value for key, value in effective.items() if key != keyword}
699
+ for branch in branches:
700
+ try:
701
+ candidate = cls._generate_schema_sample(
702
+ {"allOf": [base, branch]}, include_optional=include_optional, field_name=field_name
703
+ )
704
+ except SchemaGenerationError:
705
+ continue
706
+ if cls.validate_schema_value(candidate, schema):
707
+ return candidate
708
+ raise SchemaGenerationError(f"Cannot construct a value satisfying {keyword}")
709
+
710
+ candidate = cls._synthesize_schema_sample(effective, include_optional=include_optional, field_name=field_name)
711
+ if not cls.validate_schema_value(candidate, schema):
712
+ raise SchemaGenerationError(
713
+ f"Cannot construct a valid value for {field_name or 'request body'}; "
714
+ "constraints may conflict or require unsupported synthesis. Supply a valid example."
715
+ )
716
+ return candidate
717
+
718
+ @classmethod
719
+ def _synthesize_schema_sample(cls, effective: Dict[str, Any], *, include_optional: bool, field_name: Optional[str]) -> Any:
720
+ if not effective:
721
+ return {}
722
+
723
+ schema_type = effective.get("type")
724
+ if isinstance(schema_type, list):
725
+ for kind in schema_type:
726
+ try:
727
+ return cls._generate_schema_sample({**effective, "type": kind}, include_optional=include_optional, field_name=field_name)
728
+ except SchemaGenerationError:
729
+ continue
730
+ raise SchemaGenerationError("No allowed type yields a valid request value")
731
+ if not schema_type:
732
+ if "properties" in effective or "additionalProperties" in effective:
733
+ schema_type = "object"
734
+ elif "items" in effective:
735
+ schema_type = "array"
736
+ else:
737
+ schema_type = "string"
738
+
739
+ if schema_type == "object":
740
+ properties = effective.get("properties", {})
741
+ if not isinstance(properties, dict):
742
+ raise SchemaGenerationError("Object properties must be a mapping")
743
+ required = set(effective.get("required", []))
744
+ result: Dict[str, Any] = {}
745
+ for name, child in properties.items():
746
+ if cls._effective_schema(child).get("readOnly"):
747
+ continue
748
+ if include_optional or name in required or len(result) < effective.get("minProperties", 0):
749
+ result[name] = cls._generate_schema_sample(
750
+ child, include_optional=include_optional, field_name=name
751
+ )
752
+ for name in required - set(properties):
753
+ result[name] = cls._generate_schema_sample(
754
+ effective.get("additionalProperties", {}), include_optional=include_optional, field_name=name
755
+ )
756
+ return result
757
+
758
+ if schema_type == "array":
759
+ minimum = int(effective.get("minItems", 0))
760
+ maximum_raw = effective.get("maxItems")
761
+ maximum = int(maximum_raw) if maximum_raw is not None else None
762
+ count = max(1, minimum) if include_optional else minimum
763
+ if maximum is not None:
764
+ count = min(count, maximum)
765
+ item_schema = effective.get("items", {})
766
+ prefix = effective.get("prefixItems", [])
767
+ if isinstance(item_schema, list):
768
+ prefix, item_schema = item_schema, effective.get("additionalItems", {})
769
+ return [
770
+ cls._generate_schema_sample(
771
+ prefix[index] if index < len(prefix) else item_schema,
772
+ include_optional=include_optional, field_name=field_name
773
+ )
774
+ for index in range(max(0, count))
775
+ ]
776
+ if schema_type == "integer":
777
+ return cls._numeric_sample(effective, integer=True)
778
+ if schema_type == "number":
779
+ return cls._numeric_sample(effective, integer=False)
780
+ if schema_type == "boolean":
781
+ return False
782
+ if schema_type == "null":
783
+ return None
784
+ return cls._string_sample(effective, field_name)
785
+
786
+ @classmethod
787
+ def generate_schema_sample(cls, schema: Dict[str, Any]) -> Any:
788
+ """Backward-compatible full schema sample (includes optional fields)."""
789
+ return cls._generate_schema_sample(schema, include_optional=True)
790
+
791
+ @classmethod
792
+ def build_baseline_body(
793
+ cls, schema: Optional[Dict[str, Any]], *, include_optional: bool = False
794
+ ) -> Any:
795
+ """Build a valid baseline body, including required fields by default."""
796
+ if schema is None:
797
+ return {}
798
+ return cls._generate_schema_sample(schema, include_optional=include_optional)
799
+
800
+ # A descriptive alias for callers that prefer the word "valid".
801
+ build_valid_baseline_body = build_baseline_body
802
+
803
+ @classmethod
804
+ def _normalized_privilege_names(cls) -> set:
805
+ return {
806
+ re.sub(r"[^a-z0-9]", "", key.lower())
807
+ for key in cls.PRIVILEGE_KEYS
808
+ }
809
+
810
+ @classmethod
811
+ def _is_privilege_key(cls, name: str) -> bool:
812
+ normalized = re.sub(r"[^a-z0-9]", "", name.lower())
813
+ targets = cls._normalized_privilege_names()
814
+ if normalized in targets:
815
+ return True
816
+ if any(normalized.endswith(target) for target in targets if len(target) >= 4):
817
+ return True
818
+ # Preserve the old detector's useful handling of names such as
819
+ # account_role while avoiding matches in arbitrary prose-like names.
820
+ tokens = [
821
+ re.sub(r"[^a-z0-9]", "", token)
822
+ for token in re.split(r"[_\-.\s]+", name.lower())
823
+ ]
824
+ return any(token in targets for token in tokens if token)
825
+
826
+ @classmethod
827
+ def _iter_privilege_fields(
828
+ cls,
829
+ schema: Dict[str, Any],
830
+ path: Tuple[JsonPathPart, ...] = (),
831
+ ) -> Iterable[Tuple[Tuple[JsonPathPart, ...], Dict[str, Any]]]:
832
+ effective = cls._effective_schema(schema)
833
+ for keyword in ("oneOf", "anyOf"):
834
+ for branch in effective.get(keyword, []):
835
+ if isinstance(branch, dict):
836
+ yield from cls._iter_privilege_fields(branch, path)
837
+ properties = effective.get("properties", {})
838
+ if not isinstance(properties, dict):
839
+ return
840
+ for name, child in properties.items():
841
+ # readOnly describes normal writes; it is precisely a useful
842
+ # protected-field candidate for authorized mass-assignment tests.
843
+ if not isinstance(child, dict):
844
+ continue
845
+ child_path = path + (name,)
846
+ if cls._is_privilege_key(name):
847
+ yield child_path, child
848
+
849
+ child_effective = cls._effective_schema(child)
850
+ child_type = child_effective.get("type")
851
+ if child_type == "object" or "properties" in child_effective:
852
+ yield from cls._iter_privilege_fields(child_effective, child_path)
853
+ elif child_type == "array" and isinstance(child_effective.get("items"), dict):
854
+ item_schema = cls._effective_schema(child_effective["items"])
855
+ if item_schema.get("type") == "object" or "properties" in item_schema:
856
+ yield from cls._iter_privilege_fields(item_schema, child_path + (0,))
857
+
858
+ @classmethod
859
+ def _mutation_value(cls, field_name: str, schema: Dict[str, Any]) -> Any:
860
+ effective = cls._effective_schema(schema)
861
+ schema_type = effective.get("type")
862
+ if not schema_type:
863
+ if "properties" in effective:
864
+ schema_type = "object"
865
+ elif "items" in effective:
866
+ schema_type = "array"
867
+ else:
868
+ schema_type = "string"
869
+
870
+ normalized = field_name.lower()
871
+ if schema_type == "boolean":
872
+ candidates: Sequence[Any] = (True, False)
873
+ elif schema_type in {"integer", "number"}:
874
+ maximum = effective.get("maximum")
875
+ candidates = (maximum, 1, cls._numeric_sample(effective, schema_type == "integer"))
876
+ elif schema_type == "array":
877
+ item_schema = effective.get("items", {})
878
+ item_schema = item_schema if isinstance(item_schema, dict) else {}
879
+ item_value = cls._mutation_value(field_name, item_schema)
880
+ count = max(1, int(effective.get("minItems", 0)))
881
+ maximum = effective.get("maxItems")
882
+ if maximum is not None:
883
+ count = min(count, int(maximum))
884
+ candidates = ([copy.deepcopy(item_value) for _ in range(max(0, count))],)
885
+ elif schema_type == "object":
886
+ candidates = (cls._generate_schema_sample(effective, include_optional=True),)
887
+ else:
888
+ if any(word in normalized for word in ("role", "admin", "user_type")):
889
+ preferred = ("admin", "superuser", "root")
890
+ elif any(word in normalized for word in ("permission", "privilege")):
891
+ preferred = ("root", "*", "admin")
892
+ elif normalized in {"status", "verified"}:
893
+ preferred = ("active", "verified", "admin")
894
+ elif normalized in {"tier", "plan", "group", "group_id"}:
895
+ preferred = ("enterprise", "premium", "admin")
896
+ else:
897
+ preferred = ("admin", "root", "superuser")
898
+ enum = effective.get("enum")
899
+ if isinstance(enum, list):
900
+ ordered_enum = [value for value in preferred if value in enum]
901
+ ordered_enum.extend(value for value in enum if value not in ordered_enum)
902
+ candidates = tuple(ordered_enum) + preferred
903
+ else:
904
+ candidates = preferred
905
+
906
+ for candidate in candidates:
907
+ if candidate is None:
908
+ continue
909
+ coerced = cls._coerce_for_schema(candidate, effective)
910
+ if cls._value_satisfies_schema(coerced, effective):
911
+ return copy.deepcopy(coerced)
912
+ return cls._generate_schema_sample(
913
+ effective, include_optional=True, field_name=field_name
914
+ )
915
+
916
+ @staticmethod
917
+ def _set_path(container: Any, path: Tuple[JsonPathPart, ...], value: Any) -> Any:
918
+ if not path:
919
+ return copy.deepcopy(value)
920
+ root = copy.deepcopy(container)
921
+ if not isinstance(root, (dict, list)):
922
+ root = [] if isinstance(path[0], int) else {}
923
+ current = root
924
+ for index, part in enumerate(path):
925
+ final = index == len(path) - 1
926
+ next_part = None if final else path[index + 1]
927
+ if isinstance(part, int):
928
+ if not isinstance(current, list):
929
+ raise TypeError("Numeric mutation path requires a list container")
930
+ while len(current) <= part:
931
+ current.append([] if isinstance(next_part, int) else {})
932
+ if final:
933
+ current[part] = copy.deepcopy(value)
934
+ else:
935
+ if not isinstance(current[part], (dict, list)):
936
+ current[part] = [] if isinstance(next_part, int) else {}
937
+ current = current[part]
938
+ else:
939
+ if not isinstance(current, dict):
940
+ raise TypeError("Named mutation path requires an object container")
941
+ if final:
942
+ current[part] = copy.deepcopy(value)
943
+ else:
944
+ expected = list if isinstance(next_part, int) else dict
945
+ if not isinstance(current.get(part), expected):
946
+ current[part] = [] if isinstance(next_part, int) else {}
947
+ current = current[part]
948
+ return root
949
+
950
+ @staticmethod
951
+ def _format_path(path: Tuple[JsonPathPart, ...]) -> str:
952
+ output = ""
953
+ for part in path:
954
+ if isinstance(part, int):
955
+ output += f"[{part}]"
956
+ else:
957
+ output += ("." if output else "") + part
958
+ return output
959
+
960
+ @classmethod
961
+ def build_mass_assignment_cases(
962
+ cls,
963
+ schema: Optional[Dict[str, Any]],
964
+ baseline: Optional[Any] = None,
965
+ ) -> List[Dict[str, Any]]:
966
+ """Return complete one-field-at-a-time mutation cases.
967
+
968
+ Each result contains ``field_path`` (dot/bracket notation), ``path``
969
+ (tuple form), ``value``, ``mutation`` (sparse body), and ``payload``
970
+ (the valid baseline plus only that mutation).
971
+ """
972
+ effective_schema = schema if isinstance(schema, dict) else {}
973
+ base = (
974
+ copy.deepcopy(baseline)
975
+ if baseline is not None
976
+ else cls.build_baseline_body(effective_schema)
977
+ )
978
+ cases: List[Dict[str, Any]] = []
979
+ fields = []
980
+ seen_fields = set()
981
+ for path, field_schema in cls._iter_privilege_fields(effective_schema):
982
+ if path not in seen_fields:
983
+ fields.append((path, field_schema))
984
+ seen_fields.add(path)
985
+ if not fields:
986
+ fields = [
987
+ (("role",), {"type": "string", "enum": ["admin"]}),
988
+ (("is_admin",), {"type": "boolean"}),
989
+ (("privilege",), {"type": "string", "enum": ["root"]}),
990
+ (("user_type",), {"type": "string", "enum": ["superuser"]}),
991
+ ]
992
+
993
+ for path, field_schema in fields:
994
+ field_name = str(next((part for part in reversed(path) if isinstance(part, str)), ""))
995
+ value = cls._mutation_value(field_name, field_schema)
996
+ mutation = cls._set_path({}, path, value)
997
+ payload = cls._set_path(base, path, value)
998
+ cases.append(
999
+ {
1000
+ "field_path": cls._format_path(path),
1001
+ "path": path,
1002
+ "value": value,
1003
+ "mutation": mutation,
1004
+ "payload": payload,
1005
+ }
1006
+ )
1007
+ return cases
1008
+
1009
+ @classmethod
1010
+ def build_mass_assignment_payloads(
1011
+ cls,
1012
+ schema: Optional[Dict[str, Any]],
1013
+ baseline: Optional[Any] = None,
1014
+ ) -> List[Dict[str, Any]]:
1015
+ """Convenience view returning just complete payloads for each case."""
1016
+ return [case["payload"] for case in cls.build_mass_assignment_cases(schema, baseline)]
1017
+
1018
+ @classmethod
1019
+ def build_mass_assignment_payload(cls, schema: Optional[Dict[str, Any]]) -> Dict[str, Any]:
1020
+ """Return the historical combined sparse mutation payload.
1021
+
1022
+ New callers should prefer :meth:`build_mass_assignment_cases` so each
1023
+ dangerous field is tested independently.
1024
+ """
1025
+ combined: Dict[str, Any] = {}
1026
+ for case in cls.build_mass_assignment_cases(schema, baseline={}):
1027
+ combined = cls._deep_merge(combined, case["mutation"])
1028
+ return combined
1029
+
1030
+ @classmethod
1031
+ def _deep_merge(cls, left: Dict[str, Any], right: Dict[str, Any]) -> Dict[str, Any]:
1032
+ merged = copy.deepcopy(left)
1033
+ for key, value in right.items():
1034
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
1035
+ merged[key] = cls._deep_merge(merged[key], value)
1036
+ elif isinstance(value, list) and isinstance(merged.get(key), list):
1037
+ result = copy.deepcopy(merged[key])
1038
+ for index, item in enumerate(value):
1039
+ if index < len(result) and isinstance(item, dict) and isinstance(result[index], dict):
1040
+ result[index] = cls._deep_merge(result[index], item)
1041
+ elif index < len(result):
1042
+ result[index] = copy.deepcopy(item)
1043
+ else:
1044
+ result.append(copy.deepcopy(item))
1045
+ merged[key] = result
1046
+ else:
1047
+ merged[key] = copy.deepcopy(value)
1048
+ return merged