Skip to content

Commit 5e4731f

Browse files
authored
Merge pull request #1111 from SFDO-Tooling/fix/validation-dunder-temp-variables
Fix: allow __ prefixed temp variables in recipe validation
2 parents 10c109d + a04b309 commit 5e4731f

2 files changed

Lines changed: 205 additions & 5 deletions

File tree

snowfakery/recipe_validator.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,17 @@ class SandboxedNativeEnvironment(SandboxedEnvironment, nativetypes.NativeEnviron
4646
type compatibility with Snowfakery's runtime behavior.
4747
"""
4848

49-
pass
49+
def is_safe_attribute(self, obj, attr, value):
50+
"""Override to allow access to __ prefixed temp variable fields on MockObjectRow.
51+
52+
Snowfakery uses __ prefixed field names as temporary/hidden variables in recipes.
53+
These are legitimate field accesses, not attempts to access private Python attributes.
54+
"""
55+
# Allow __ prefixed attributes on MockObjectRow objects (temp vars)
56+
if attr.startswith("__") and "MockObjectRow" in type(obj).__name__:
57+
return True
58+
# Fall back to default sandbox behavior for other cases
59+
return super().is_safe_attribute(obj, attr, value)
5060

5161

5262
@dataclass
@@ -479,7 +489,10 @@ def __init__(self, template, obj_name):
479489
self._field_definitions = {}
480490

481491
def __getattr__(self, attr):
482-
if attr.startswith("_"):
492+
# Allow __ prefixed temp vars if they are valid fields
493+
# Only reject truly private attributes (single underscore internal attrs)
494+
# that are NOT in the accessible fields list
495+
if attr.startswith("_") and attr not in self._all_field_names:
483496
raise AttributeError(f"'{attr}' not found")
484497

485498
# For 'this': only fields defined so far are accessible
@@ -504,9 +517,21 @@ def __getattr__(self, attr):
504517
from snowfakery.utils.validation_utils import resolve_value
505518

506519
field_def = self._field_definitions[attr]
507-
resolved = resolve_value(field_def, context)
508-
if resolved is not None:
509-
return resolved
520+
try:
521+
# Track error count before resolution to detect new errors
522+
error_count_before = len(context.errors)
523+
resolved = resolve_value(field_def, context)
524+
# If resolution added errors (e.g., from context mismatch), remove them
525+
# and fall back to mock value - these aren't real errors
526+
if len(context.errors) > error_count_before:
527+
# Remove errors added during this resolution attempt
528+
context.errors = context.errors[:error_count_before]
529+
return f"<mock_{self._name}.{attr}>"
530+
if resolved is not None:
531+
return resolved
532+
except Exception:
533+
# Fall back to mock value if resolution fails
534+
pass
510535

511536
# Fall back to mock value if we can't resolve
512537
return f"<mock_{self._name}.{attr}>"

tests/test_recipe_validator.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,6 +1743,181 @@ def test_deeply_nested_choice_objects(self):
17431743
assert not result.has_errors()
17441744

17451745

1746+
class TestDoubleUnderscoreTempVariables:
1747+
"""Test suite for __ prefixed temp variable handling in validation.
1748+
1749+
Snowfakery supports temporary/hidden variables prefixed with __ (double underscore).
1750+
These should be validated correctly and accessible both within the same object
1751+
and via cross-object references.
1752+
"""
1753+
1754+
def test_dunder_variable_within_same_object(self):
1755+
"""Test that __ prefixed variables work within the same object"""
1756+
yaml = """
1757+
- snowfakery_version: 3
1758+
- object: User
1759+
fields:
1760+
__gender_folder:
1761+
random_choice: [men, women]
1762+
FirstName: ${{ fake.FirstNameMale() if __gender_folder == 'men' else fake.FirstNameFemale() }}
1763+
"""
1764+
result = generate(StringIO(yaml), validate_only=True)
1765+
assert not result.has_errors()
1766+
1767+
def test_dunder_variable_cross_object_reference(self):
1768+
"""Test that __ prefixed variables can be accessed via object reference"""
1769+
yaml = """
1770+
- snowfakery_version: 3
1771+
- object: User
1772+
nickname: tech_user
1773+
fields:
1774+
__gender_folder:
1775+
random_choice: [men, women]
1776+
FirstName:
1777+
fake: FirstName
1778+
1779+
- object: ServiceResource
1780+
fields:
1781+
Name: ${{tech_user.FirstName}}
1782+
ProfilePicture: "https://example.com/${{tech_user.__gender_folder}}/photo.jpg"
1783+
"""
1784+
result = generate(StringIO(yaml), validate_only=True)
1785+
assert not result.has_errors()
1786+
1787+
def test_multiple_dunder_variables(self):
1788+
"""Test that multiple __ prefixed variables work together"""
1789+
yaml = """
1790+
- snowfakery_version: 3
1791+
- object: Item
1792+
fields:
1793+
__idx: ${{child_index}}
1794+
__multiplier: 10
1795+
ComputedValue: ${{ __idx * __multiplier }}
1796+
"""
1797+
result = generate(StringIO(yaml), validate_only=True)
1798+
assert not result.has_errors()
1799+
1800+
def test_dunder_variable_in_nested_friends(self):
1801+
"""Test that __ prefixed variables work in nested friend objects"""
1802+
yaml = """
1803+
- snowfakery_version: 3
1804+
- object: Account
1805+
count: 2
1806+
nickname: parent_account
1807+
fields:
1808+
__region:
1809+
random_choice: [North, South, East, West]
1810+
Name: ${{__region}} Region Account
1811+
friends:
1812+
- object: Contact
1813+
nickname: account_contact
1814+
fields:
1815+
__department:
1816+
random_choice: [Sales, Engineering, Marketing]
1817+
FirstName:
1818+
fake: FirstName
1819+
Department: ${{__department}}
1820+
friends:
1821+
- object: Task
1822+
fields:
1823+
Subject: Task for ${{account_contact.FirstName}} in ${{account_contact.__department}}
1824+
"""
1825+
result = generate(StringIO(yaml), validate_only=True)
1826+
assert not result.has_errors()
1827+
1828+
def test_dunder_variable_with_integer_operations(self):
1829+
"""Test that __ prefixed variables work with integer operations"""
1830+
yaml = """
1831+
- snowfakery_version: 3
1832+
- object: WorkOrder
1833+
nickname: work_order
1834+
fields:
1835+
__wo_idx: ${{child_index}}
1836+
Subject: Work Order ${{__wo_idx}}
1837+
friends:
1838+
- object: ServiceAppointment
1839+
fields:
1840+
__start_hour: ${{ 9 + (int(work_order.__wo_idx) % 8) }}
1841+
ScheduledTime: "${{ '%02d' % __start_hour }}:00"
1842+
"""
1843+
result = generate(StringIO(yaml), validate_only=True)
1844+
assert not result.has_errors()
1845+
1846+
def test_dunder_variable_conditional_logic(self):
1847+
"""Test that __ prefixed variables work in conditional logic"""
1848+
yaml = """
1849+
- snowfakery_version: 3
1850+
- object: Record
1851+
fields:
1852+
__type_selector:
1853+
random_number:
1854+
min: 1
1855+
max: 3
1856+
Type: ${{ 'Premium' if __type_selector == 1 else ('Standard' if __type_selector == 2 else 'Basic') }}
1857+
"""
1858+
result = generate(StringIO(yaml), validate_only=True)
1859+
assert not result.has_errors()
1860+
1861+
def test_dunder_variable_not_accessible_before_definition(self):
1862+
"""Test that __ prefixed variables are not accessible before they are defined"""
1863+
yaml = """
1864+
- snowfakery_version: 3
1865+
- object: Record
1866+
fields:
1867+
EarlyField: ${{__later_var}}
1868+
__later_var: some_value
1869+
"""
1870+
with pytest.raises(DataGenValidationError) as exc_info:
1871+
generate(StringIO(yaml), validate_only=True)
1872+
1873+
# Should report undefined variable
1874+
assert "__later_var" in str(exc_info.value)
1875+
1876+
def test_dunder_variable_in_random_choice_context(self):
1877+
"""Test __ prefixed variables with random_choice as commonly used"""
1878+
yaml = """
1879+
- snowfakery_version: 3
1880+
- object: Territory
1881+
fields:
1882+
__hub_name:
1883+
random_choice: ["Los Angeles", "San Francisco", "Seattle", "Denver"]
1884+
Name: ${{__hub_name}} Hub
1885+
City: ${{__hub_name}}
1886+
"""
1887+
result = generate(StringIO(yaml), validate_only=True)
1888+
assert not result.has_errors()
1889+
1890+
def test_dunder_variable_complex_nested_scenario(self):
1891+
"""Test complex scenario with multiple levels of nesting and __ prefixed variables"""
1892+
yaml = """
1893+
- snowfakery_version: 3
1894+
- object: Company
1895+
nickname: company
1896+
count: 2
1897+
fields:
1898+
__company_type:
1899+
random_choice: [Tech, Finance, Healthcare]
1900+
Name: ${{__company_type}} Corp
1901+
friends:
1902+
- object: Department
1903+
nickname: dept
1904+
count: 3
1905+
fields:
1906+
__dept_code: ${{ 'D' + str(child_index + 1) }}
1907+
Code: ${{__dept_code}}
1908+
CompanyType: ${{company.__company_type}}
1909+
friends:
1910+
- object: Employee
1911+
count: 2
1912+
fields:
1913+
__emp_id: ${{ dept.__dept_code + '-' + str(child_index + 1) }}
1914+
EmployeeId: ${{__emp_id}}
1915+
DeptCode: ${{dept.Code}}
1916+
"""
1917+
result = generate(StringIO(yaml), validate_only=True)
1918+
assert not result.has_errors()
1919+
1920+
17461921
class TestMockThisKeyword:
17471922
"""Test suite for 'this' keyword validation and MockThis error messages"""
17481923

0 commit comments

Comments
 (0)