Skip to content

Commit 482afbd

Browse files
Merge pull request #1103 from SFDO-Tooling/W-19976090/standard-function-validation
@W-19976090: Implement Core Validation Framework and Standard Function Validation
2 parents 2b6e264 + affdf16 commit 482afbd

38 files changed

Lines changed: 14628 additions & 24 deletions

docs/extending.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,3 +568,155 @@ Note the relative paths between these two files.
568568

569569
`examples/use_custom_provider.yml` refers to `examples/plugins/tla_provider.py` as `tla_provider.Provider` because the `plugins` folder is in the search path
570570
described in [How Snowfakery Finds Plugins](#how-snowfakery-finds-plugins).
571+
572+
## Adding Validators to Plugins
573+
574+
When creating custom plugins, you can add parse-time validators that catch errors before runtime. This allows Snowfakery's `--strict-mode` and `--validate-only` flags to validate your plugin functions.
575+
576+
Validators live in a nested `Validators` class alongside the `Functions` class. The validator method name follows the pattern `validate_<function_name>`.
577+
578+
### Example: Validator for DoublingPlugin
579+
580+
Here's the DoublingPlugin from earlier, now with a validator:
581+
582+
```python
583+
from snowfakery import SnowfakeryPlugin
584+
585+
class DoublingPlugin(SnowfakeryPlugin):
586+
class Functions:
587+
def double(self, value):
588+
"""Double a value at runtime."""
589+
return value * 2
590+
591+
class Validators:
592+
@staticmethod
593+
def validate_double(sv, context):
594+
"""Validate double() at parse-time.
595+
596+
Args:
597+
sv: StructuredValue containing args and kwargs from the recipe
598+
context: ValidationContext for error reporting and value resolution
599+
600+
Returns:
601+
A mock value for continued validation of dependent expressions
602+
"""
603+
args = getattr(sv, "args", [])
604+
605+
# Check required argument
606+
if not args:
607+
context.add_error(
608+
"double: Missing required argument",
609+
getattr(sv, "filename", None),
610+
getattr(sv, "line_num", None),
611+
)
612+
return 0 # Return mock value so validation can continue
613+
614+
# Return an intelligent mock (doubled value if literal)
615+
value = args[0]
616+
if isinstance(value, (int, float)):
617+
return value * 2
618+
return 0 # Fallback mock for non-literal values
619+
```
620+
621+
Now when users make mistakes, they get clear error messages. For example, if a user forgets the required argument:
622+
623+
```yaml
624+
Value:
625+
DoublingPlugin.double:
626+
```
627+
628+
```s
629+
$ snowfakery recipe.yml --validate-only
630+
631+
Validation Errors:
632+
1. double: Missing required argument
633+
at recipe.yml:5
634+
```
635+
636+
### Validator Method Signature
637+
638+
Every validator follows this pattern:
639+
640+
```python
641+
@staticmethod
642+
def validate_<function_name>(sv, context):
643+
"""
644+
Args:
645+
sv: StructuredValue with:
646+
- sv.args: List of positional arguments
647+
- sv.kwargs: Dict of keyword arguments
648+
- sv.filename: Source file path
649+
- sv.line_num: Line number in source file
650+
651+
context: ValidationContext with:
652+
- context.add_error(message, filename, line_num): Report an error
653+
- context.add_warning(message, filename, line_num): Report a warning
654+
- context.available_variables: Dict of defined variables
655+
- context.available_objects: Dict of defined objects
656+
657+
Returns:
658+
A mock value representing what the function would return.
659+
This allows validation to continue for expressions that use this result.
660+
"""
661+
pass
662+
```
663+
664+
### Resolving Values
665+
666+
Arguments may be literals, Jinja expressions, or other StructuredValues. Use `resolve_value()` to get the actual value when possible:
667+
668+
```python
669+
from snowfakery.utils.validation_utils import resolve_value
670+
671+
class MyPlugin(SnowfakeryPlugin):
672+
class Validators:
673+
@staticmethod
674+
def validate_my_function(sv, context):
675+
args = getattr(sv, "args", [])
676+
kwargs = sv.kwargs if hasattr(sv, "kwargs") else {}
677+
678+
# Resolve the first argument
679+
min_val = resolve_value(args[0] if args else kwargs.get("min"), context)
680+
681+
# min_val is now a literal (int, str, etc.) or None if unresolvable
682+
if min_val is not None and not isinstance(min_val, int):
683+
context.add_error(
684+
"my_function: 'min' must be an integer",
685+
getattr(sv, "filename", None),
686+
getattr(sv, "line_num", None),
687+
)
688+
```
689+
690+
### Best Practices
691+
692+
1. **Always return a mock value** - Even after reporting errors, return a reasonable mock so validation continues for dependent expressions.
693+
694+
2. **Use `add_error()` for definite problems** - Missing required parameters, invalid types, logical impossibilities.
695+
696+
3. **Use `add_warning()` for potential issues** - Unknown optional parameters, values that might work at runtime.
697+
698+
4. **Include helpful context in messages** - Show the actual values, suggest corrections.
699+
700+
Include helpful context in error messages:
701+
702+
```python
703+
context.add_error(
704+
f"my_function: 'min' ({min_val}) must be <= 'max' ({max_val})",
705+
sv.filename,
706+
sv.line_num,
707+
)
708+
```
709+
710+
Add fuzzy match suggestions for typos:
711+
712+
```python
713+
from snowfakery.utils.validation_utils import get_fuzzy_match
714+
715+
suggestion = get_fuzzy_match(name, valid_names)
716+
msg = f"Unknown option '{name}'"
717+
if suggestion:
718+
msg += f". Did you mean '{suggestion}'?"
719+
context.add_error(msg, sv.filename, sv.line_num)
720+
```
721+
722+
For more examples, see the validators in `snowfakery/template_funcs.py` and the plugin files in `snowfakery/standard_plugins/`.

docs/index.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,47 @@ To include a file by a relative path:
988988
- include_file: child.yml
989989
```
990990

991+
## Recipe Validation
992+
993+
Snowfakery can validate recipes before generating data, catching errors like typos, invalid parameters, and undefined variables all at once instead of discovering them one at a time during execution.
994+
995+
### Validation Modes
996+
997+
| Mode | Flag | Behavior |
998+
|------|------|----------|
999+
| **Default** | (none) | No validation; generate data immediately |
1000+
| **Strict** | `--strict-mode` | Validate first, then generate if no errors |
1001+
| **Validate Only** | `--validate-only` | Validate and exit; no data generation |
1002+
1003+
### Example
1004+
1005+
```s
1006+
$ snowfakery recipe.yml --strict-mode
1007+
1008+
Validating recipe...
1009+
1010+
✓ Validation passed
1011+
1012+
Generating data...
1013+
Account(id=1, Name=Acme Corp)
1014+
```
1015+
1016+
When errors are found, validation reports them all with precise file locations:
1017+
1018+
```s
1019+
$ snowfakery recipe.yml --strict-mode
1020+
1021+
Validating recipe...
1022+
1023+
Validation Errors:
1024+
1. random_number: 'min' (100) must be <= 'max' (50)
1025+
at recipe.yml:12
1026+
2. Unknown Faker provider 'frist_name'. Did you mean 'first_name'?
1027+
at recipe.yml:15
1028+
```
1029+
1030+
To add validators to custom plugins, see [Adding Validators to Plugins](extending.md#adding-validators-to-plugins).
1031+
9911032
## Formulas
9921033

9931034
To insert data from one field into into another, use a formula.
@@ -1371,6 +1412,12 @@ Options:
13711412
--load-declarations FILE Declarations to mix into the generated
13721413
mapping file
13731414
1415+
--strict-mode Validate the recipe before generating data.
1416+
Stops if validation errors are found.
1417+
1418+
--validate-only Validate the recipe without generating any
1419+
data.
1420+
13741421
--version Show the version and exit.
13751422
--help Show this message and exit.
13761423
```
@@ -1805,12 +1852,27 @@ generate_data(
18051852
yaml_file="examples/company.yml",
18061853
option=[("A", "B")],
18071854
target_number=(20, "Employee"),
1855+
strict_mode=True, # validate before generating
18081856
debug_internals=True,
18091857
output_format="json",
18101858
output_file=outfile,
18111859
)
18121860
```
18131861

1862+
To validate without generating data, use `validate_only=True`:
1863+
1864+
```python
1865+
from snowfakery import generate_data
1866+
1867+
result = generate_data(
1868+
yaml_file="examples/company.yml",
1869+
validate_only=True,
1870+
)
1871+
1872+
if result.has_errors():
1873+
print(result.get_summary())
1874+
```
1875+
18141876
To learn more about using Snowfakery in Python, see [Embedding Snowfakery into Python Applications](./embedding.md)
18151877

18161878
### Use Snowfakery with Databases

snowfakery/api.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,9 @@ def generate_data(
151151
update_passthrough_fields: T.Sequence[
152152
str
153153
] = (), # pass through these fields from input to output
154-
) -> None:
154+
strict_mode: bool = False, # same as --strict-mode
155+
validate_only: bool = False, # same as --validate-only
156+
):
155157
stopping_criteria = stopping_criteria_from_target_number(target_number)
156158
dburls = dburls or ([dburl] if dburl else [])
157159
output_files = output_files or []
@@ -193,6 +195,8 @@ def open_with_cleanup(file, mode, **kwargs):
193195
plugin_options=plugin_options,
194196
update_input_file=open_update_input_file,
195197
update_passthrough_fields=update_passthrough_fields,
198+
strict_mode=strict_mode,
199+
validate_only=validate_only,
196200
)
197201

198202
if open_cci_mapping_file:
@@ -205,6 +209,8 @@ def open_with_cleanup(file, mode, **kwargs):
205209
if should_create_cci_record_type_tables:
206210
create_cci_record_type_tables(dburls[0])
207211

212+
return summary
213+
208214

209215
@contextmanager
210216
def configure_output_stream(

snowfakery/cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,16 @@ def __mod__(self, vals) -> str:
167167
hidden=True,
168168
default=None,
169169
)
170+
@click.option(
171+
"--strict-mode",
172+
is_flag=True,
173+
help="Validate the recipe before generating data. Stops if validation errors are found.",
174+
)
175+
@click.option(
176+
"--validate-only",
177+
is_flag=True,
178+
help="Validate the recipe without generating any data.",
179+
)
170180
@click.version_option(version=version, prog_name="snowfakery", message=VersionMessage())
171181
def generate_cli(
172182
yaml_file,
@@ -186,6 +196,8 @@ def generate_cli(
186196
load_declarations=None,
187197
update_input_file=None,
188198
update_passthrough_fields=(), # undocumented feature used mostly for testing
199+
strict_mode=False,
200+
validate_only=False,
189201
):
190202
"""
191203
Generates records from a YAML file
@@ -216,6 +228,7 @@ def generate_cli(
216228
output_folder=output_folder,
217229
target_number=target_number,
218230
reps=reps,
231+
validate_only=validate_only,
219232
)
220233
if update_passthrough_fields:
221234
update_passthrough_fields = update_passthrough_fields.split(",")
@@ -244,6 +257,8 @@ def generate_cli(
244257
plugin_options=plugin_options,
245258
update_input_file=update_input_file,
246259
update_passthrough_fields=update_passthrough_fields,
260+
strict_mode=strict_mode,
261+
validate_only=validate_only,
247262
)
248263
except DataGenError as e:
249264
if debug_internals:
@@ -265,6 +280,7 @@ def validate_options(
265280
output_folder,
266281
target_number,
267282
reps,
283+
validate_only=False,
268284
):
269285
if dburl and output_format:
270286
raise click.ClickException(
@@ -291,6 +307,12 @@ def validate_options(
291307
"because they are mutually exclusive."
292308
)
293309

310+
if validate_only and generate_cci_mapping_file:
311+
raise click.ClickException(
312+
"Cannot generate CCI mapping file in validate-only mode. "
313+
"Remove --validate-only to generate mapping files."
314+
)
315+
294316

295317
def main():
296318
generate_cli.main(prog_name="snowfakery")

snowfakery/data_gen_exceptions.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ class DataGenTypeError(DataGenError):
5757
pass
5858

5959

60+
class DataGenValidationError(DataGenError):
61+
"""Raised when recipe validation fails."""
62+
63+
prefix = "Recipe validation failed. Please fix the errors below before running.\n"
64+
65+
def __init__(self, validation_result):
66+
self.validation_result = validation_result
67+
# Extract first error for basic message
68+
message = "Recipe validation failed"
69+
if validation_result.errors:
70+
message = validation_result.errors[0].message
71+
super().__init__(message)
72+
73+
def __str__(self):
74+
return str(self.validation_result)
75+
76+
6077
def fix_exception(message, parentobj, e, args=(), kwargs=None):
6178
"""Add filename and linenumber to an exception if needed"""
6279
filename, line_num = parentobj.filename, parentobj.line_num

0 commit comments

Comments
 (0)