| """ |
| tests/unit/test_python_sandbox.py |
| Tests for RestrictedPython-based Pandas execution sandbox. |
| """ |
|
|
| import pandas as pd |
| import pytest |
| from sandbox.python_sandbox import validate_python, run_pandas |
|
|
|
|
| @pytest.fixture |
| def df(): |
| return pd.DataFrame({ |
| "product": ["Widget", "Gadget", "Widget", "Doohickey"], |
| "amount": [99.99, 149.99, 99.99, 49.99], |
| "region": ["North", "South", "North", "East"], |
| }) |
|
|
|
|
| |
|
|
| @pytest.mark.unit |
| class TestValidatePython: |
| def test_safe_code_passes(self): |
| code = "result = df.groupby('product')['amount'].sum().to_frame()" |
| ok, err = validate_python(code) |
| assert ok, err |
|
|
| def test_blocks_open(self): |
| ok, err = validate_python("open('/etc/passwd')") |
| assert not ok |
| assert "open" in err |
|
|
| def test_blocks_os_import(self): |
| ok, err = validate_python("import os\nresult = df") |
| assert not ok |
|
|
| def test_blocks_subprocess(self): |
| ok, err = validate_python("import subprocess\nresult = df") |
| assert not ok |
|
|
| def test_blocks_dunder_attr(self): |
| ok, err = validate_python("df.__class__.__bases__") |
| assert not ok |
|
|
| def test_blocks_exec(self): |
| ok, err = validate_python("exec('import os')") |
| assert not ok |
|
|
| def test_blocks_eval(self): |
| ok, err = validate_python("eval('1+1')") |
| assert not ok |
|
|
| def test_allows_pandas_import(self): |
| ok, err = validate_python("import pandas as pd\nresult = df") |
| assert ok |
|
|
| def test_allows_numpy_import(self): |
| ok, err = validate_python("import numpy as np\nresult = df") |
| assert ok |
|
|
| def test_blocks_requests_import(self): |
| ok, err = validate_python("import requests\nresult = df") |
| assert not ok |
|
|
| def test_syntax_error(self): |
| ok, err = validate_python("def broken(:\n pass") |
| assert not ok |
|
|
|
|
| |
|
|
| @pytest.mark.unit |
| class TestRunPandas: |
| def test_basic_groupby(self, df): |
| code = "result = df.groupby('product')['amount'].sum().to_frame()" |
| out = run_pandas(code, df) |
| assert isinstance(out, pd.DataFrame) |
| assert "amount" in out.columns |
|
|
| def test_filter(self, df): |
| code = "result = df[df['region'] == 'North'].copy()" |
| out = run_pandas(code, df) |
| assert len(out) == 2 |
|
|
| def test_new_column(self, df): |
| code = "result = df.copy()\nresult['doubled'] = result['amount'] * 2" |
| out = run_pandas(code, df) |
| assert "doubled" in out.columns |
|
|
| def test_series_result_coerced_to_dataframe(self, df): |
| code = "result = df['amount']" |
| out = run_pandas(code, df) |
| assert isinstance(out, pd.DataFrame) |
|
|
| def test_missing_result_raises(self, df): |
| with pytest.raises(ValueError, match="result"): |
| run_pandas("x = 1", df) |
|
|
| def test_non_dataframe_result_raises(self, df): |
| with pytest.raises(TypeError): |
| run_pandas("result = 42", df) |
|
|
| def test_safety_block_raises_permission_error(self, df): |
| with pytest.raises(PermissionError, match="SAFETY_BLOCK"): |
| run_pandas("import os\nresult = df", df) |
|
|
| def test_augmented_assignment(self, df): |
| |
| |
| code = "result = df.copy()\nresult['amount'] = result['amount'] + 10" |
| out = run_pandas(code, df) |
| assert out["amount"].min() > 50 |
|
|
| def test_sort_and_limit(self, df): |
| code = "result = df.sort_values('amount', ascending=False).head(2)" |
| out = run_pandas(code, df) |
| assert len(out) == 2 |
| assert out.iloc[0]["amount"] == pytest.approx(149.99) |
|
|
| def test_pivot_table(self, df): |
| code = """ |
| result = df.pivot_table( |
| index='region', values='amount', aggfunc='sum' |
| ).reset_index() |
| """ |
| out = run_pandas(code, df) |
| assert "region" in out.columns |
|
|