Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance error messages in parameter operations #348

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
48 changes: 48 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# PolicyEngine Core - Development Guide 📚

## Common Commands
- `make all`: Full development cycle (install, format, test, build)
- `make install`: Install package in dev mode with dependencies
- `make format`: Format code with Black (79 char line limit)
- `make test`: Run all tests with coverage
- `make mypy`: Run type checking
- `pytest tests/path/to/test_file.py::test_function_name -v`: Run single test

## Code Style Guidelines
- **Formatting**: Black with 79 character line length
- **Imports**: Standard library, third-party, local modules (standard Python grouping)
- **Types**: Use type annotations, checked with mypy
- **Naming**: snake_case for functions/variables, CamelCase for classes
- **Error Handling**: Use custom error classes from policyengine_core/errors/
- **Testing**: All new features need tests, both unit and YAML-based tests supported
- **PRs**: Include "Fixes #{issue}" in description, add changelog entry, pass all checks
- **Changelog**: Update changelog_entry.yaml with proper bump type and change description
- **Dependencies**: All versions must be capped to avoid breaking changes
- **Python**: 3.10+ required

## Git Workflow
- First push to a new branch: `git push -u origin feature/branch-name` to set up tracking
- Subsequent pushes: just use `git push` to update the same PR
- Always run `make format` before committing to ensure code passes style checks

## PR Reviews
- Check PR comments with: `gh pr view <PR_NUMBER> --repo PolicyEngine/policyengine-core`
- Get review comments with: `gh api repos/PolicyEngine/policyengine-core/pulls/<PR_NUMBER>/comments`
- Address all reviewer feedback before merging
- Follow Clean Code principles when refactoring:
- Keep functions small and focused on a single task
- Avoid deeply nested conditionals and exceptions
- Extract complex logic into well-named helper functions
- Minimize duplication and optimize for readability
- Use consistent error handling patterns

## Package Architecture
- **Parameter System**: Core framework for tax-benefit system parameters
- Parameters organized in a hierarchical tree (accessible via dot notation)
- Parameters can be scalar values or bracket-based scales
- Supports time-varying values with date-based lookup
- **Errors**: Custom error classes in `policyengine_core/errors/` for specific failures
- **Entities**: Represents different units (person, household, etc.) in microsimulations
- **Variables**: Calculations that can be performed on entities
- **Testing**: Supports both Python tests and YAML-based test files
- **Country Template**: Reference implementation of a tax-benefit system
9 changes: 9 additions & 0 deletions changelog_entry.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- bump: minor
changes:
added:
- Added ParameterPathError class for specific error handling in parameter operations
changed:
- Completely refactored get_parameter implementation for better readability and maintainability
- Improved error messages with specific details and suggestions for parameter path resolution issues
- Separated parameter path handling into focused helper functions
- Added comprehensive test cases for all error scenarios
1 change: 1 addition & 0 deletions policyengine_core/errors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .nan_creation_error import NaNCreationError
from .parameter_not_found_error import ParameterNotFoundError
from .parameter_parsing_error import ParameterParsingError
from .parameter_path_error import ParameterPathError
from .period_mismatch_error import PeriodMismatchError
from .situation_parsing_error import SituationParsingError
from .spiral_error import SpiralError
Expand Down
21 changes: 21 additions & 0 deletions policyengine_core/errors/parameter_path_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class ParameterPathError(ValueError):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused code, blocking This does not appear to be used anywhere? We're still throwing a ValueError below.

"""
Exception raised when there's an error in parameter path resolution.

This includes:
- Parameter not found errors
- Invalid bracket syntax errors
- Invalid bracket index errors
- Attempted bracket access on non-bracket parameters
"""

def __init__(self, message, parameter_path=None, failed_at=None):
"""
Args:
message (str): The error message.
parameter_path (str, optional): The full parameter path that was being accessed.
failed_at (str, optional): The specific component in the path where the failure occurred.
"""
self.parameter_path = parameter_path
self.failed_at = failed_at
super(ParameterPathError, self).__init__(message)
167 changes: 151 additions & 16 deletions policyengine_core/parameters/operations/get_parameter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from policyengine_core.parameters.parameter import Parameter
from policyengine_core.parameters.parameter_node import ParameterNode
from policyengine_core.errors.parameter_path_error import ParameterPathError


def get_parameter(root: ParameterNode, parameter: str) -> Parameter:
Expand All @@ -13,21 +14,155 @@
Parameter: The parameter.
"""
node = root
for name in parameter.split("."):

for path_component in parameter.split("."):
node = _navigate_to_node(node, path_component, parameter)

return node


def _navigate_to_node(node, path_component, full_path):
"""Navigate to the next node in the parameter tree."""
if hasattr(node, "brackets") and "[" in path_component:
# Handle case where we're already at a scale parameter and need to access a bracket
return _handle_bracket_access(node, path_component, full_path)

# Parse the path component into name part and optional bracket part
name_part, index = _parse_path_component(path_component, full_path)

# For regular node navigation (no brackets)
if not hasattr(node, "children"):
raise ParameterPathError(
f"Cannot access '{path_component}' in '{full_path}'. "
"The parent is not a parameter node with children.",
parameter_path=full_path,
failed_at=path_component,
)

# Look up the name in the node's children
try:
child_node = node.children[name_part]
except KeyError:
suggestions = _find_similar_parameters(node, name_part)
suggestion_text = (
f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
)
raise ParameterPathError(
f"Parameter '{name_part}' not found in path '{full_path}'.{suggestion_text}",
parameter_path=full_path,
failed_at=name_part,
)

# If we have a bracket index, access the brackets property
if index is not None:
return _access_bracket(
child_node, name_part, index, path_component, full_path
)

return child_node


def _handle_bracket_access(node, path_component, full_path):
"""Handle bracket access on an existing ParameterScale object."""
# Extract the bracket index from the path component
if not path_component.startswith("[") or not path_component.endswith("]"):
raise ParameterPathError(
f"Invalid bracket syntax at '{path_component}' in parameter path '{full_path}'. "
"Use format: [index], e.g., [0]",
parameter_path=full_path,
failed_at=path_component,
)

try:
index = int(path_component[1:-1])
except ValueError:
raise ParameterPathError(

Check warning on line 79 in policyengine_core/parameters/operations/get_parameter.py

View check run for this annotation

Codecov / codecov/patch

policyengine_core/parameters/operations/get_parameter.py#L76-L79

Added lines #L76 - L79 were not covered by tests
f"Invalid bracket index in '{path_component}' of parameter path '{full_path}'. "
"Index must be an integer.",
parameter_path=full_path,
failed_at=path_component,
)

try:
return node.brackets[index]
except IndexError:
max_index = len(node.brackets) - 1
raise ParameterPathError(

Check warning on line 90 in policyengine_core/parameters/operations/get_parameter.py

View check run for this annotation

Codecov / codecov/patch

policyengine_core/parameters/operations/get_parameter.py#L86-L90

Added lines #L86 - L90 were not covered by tests
f"Bracket index out of range in '{path_component}' of parameter path '{full_path}'. "
f"Valid indices are 0 to {max_index}.",
parameter_path=full_path,
failed_at=path_component,
)


def _parse_path_component(path_component, full_path):
"""Parse a path component into name and optional bracket index."""
if "[" not in path_component:
return path_component, None

try:
parts = path_component.split(
"[", 1
) # Split only on the first occurrence of "["
name_part = parts[0]
bracket_part = (
"[" + parts[1]
) # Include the "[" for consistent reporting

if not bracket_part.endswith("]"):
raise ParameterPathError(

Check warning on line 113 in policyengine_core/parameters/operations/get_parameter.py

View check run for this annotation

Codecov / codecov/patch

policyengine_core/parameters/operations/get_parameter.py#L113

Added line #L113 was not covered by tests
f"Invalid bracket syntax at '{path_component}' in parameter path '{full_path}'. "
"Use format: parameter_name[index], e.g., brackets[0].rate",
parameter_path=full_path,
failed_at=name_part
+ bracket_part, # Use the original bracket part for error reporting
)

try:
if "[" not in name:
node = node.children[name]
else:
try:
name, index = name.split("[")
index = int(index[:-1])
node = node.children[name].brackets[index]
except:
raise ValueError(
"Invalid bracket syntax (should be e.g. tax.brackets[3].rate"
)
except:
raise ValueError(
f"Could not find the parameter (failed at {name})."
index = int(bracket_part[1:-1]) # Remove both "[" and "]"
return name_part, index
except ValueError:
raise ParameterPathError(

Check warning on line 125 in policyengine_core/parameters/operations/get_parameter.py

View check run for this annotation

Codecov / codecov/patch

policyengine_core/parameters/operations/get_parameter.py#L124-L125

Added lines #L124 - L125 were not covered by tests
f"Invalid bracket index in '{path_component}' of parameter path '{full_path}'. "
"Index must be an integer.",
parameter_path=full_path,
failed_at=name_part + bracket_part,
)
return node

except ValueError: # More than one "[" found
raise ParameterPathError(

Check warning on line 133 in policyengine_core/parameters/operations/get_parameter.py

View check run for this annotation

Codecov / codecov/patch

policyengine_core/parameters/operations/get_parameter.py#L132-L133

Added lines #L132 - L133 were not covered by tests
f"Invalid bracket syntax at '{path_component}' in parameter path '{full_path}'. "
"Use format: parameter_name[index], e.g., brackets[0].rate",
parameter_path=full_path,
failed_at=path_component,
)


def _access_bracket(node, name_part, index, path_component, full_path):
"""Access a bracket by index on a node."""
if not hasattr(node, "brackets"):
raise ParameterPathError(
f"'{name_part}' in parameter path '{full_path}' does not support bracket indexing. "
"Only scale parameters (like brackets) support indexing.",
parameter_path=full_path,
failed_at=path_component,
)

try:
return node.brackets[index]
except IndexError:
max_index = len(node.brackets) - 1
raise ParameterPathError(

Check warning on line 155 in policyengine_core/parameters/operations/get_parameter.py

View check run for this annotation

Codecov / codecov/patch

policyengine_core/parameters/operations/get_parameter.py#L151-L155

Added lines #L151 - L155 were not covered by tests
f"Bracket index out of range in '{path_component}' of parameter path '{full_path}'. "
f"Valid indices are 0 to {max_index}.",
parameter_path=full_path,
failed_at=path_component,
)


def _find_similar_parameters(node, name):
"""Find parameter names similar to the requested one."""
if not hasattr(node, "children"):
return []

Check warning on line 166 in policyengine_core/parameters/operations/get_parameter.py

View check run for this annotation

Codecov / codecov/patch

policyengine_core/parameters/operations/get_parameter.py#L166

Added line #L166 was not covered by tests

return [key for key in node.children.keys() if name.lower() in key.lower()]
89 changes: 89 additions & 0 deletions tests/core/parameters/operations/test_get_parameter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import pytest
from policyengine_core.parameters import ParameterNode
from policyengine_core.parameters.operations import get_parameter
from policyengine_core.errors import ParameterPathError


def test_parameter_not_found_message():
"""Test that not found errors have better messages."""
parameters = ParameterNode(
data={
"tax": {
"income_tax": {
"rate": {
"values": {
"2022-01-01": 0.2,
}
},
},
"sales_tax": {
"rate": {
"values": {
"2022-01-01": 0.1,
}
},
},
}
}
)

# Test parameter not found
with pytest.raises(ParameterPathError) as excinfo:
get_parameter(parameters, "tax.property_tax.rate")

# Ensure the error message contains useful information
error_message = str(excinfo.value)
assert "property_tax" in error_message
assert "not found" in error_message
assert excinfo.value.parameter_path == "tax.property_tax.rate"
assert excinfo.value.failed_at == "property_tax"

# Test parameter not found but with similar names
with pytest.raises(ParameterPathError) as excinfo:
get_parameter(parameters, "tax.income")

# Check that the suggestion for "income" includes "income_tax"
error_message = str(excinfo.value)
assert "income" in error_message
assert "Did you mean" in error_message
assert "income_tax" in error_message
assert excinfo.value.parameter_path == "tax.income"
assert excinfo.value.failed_at == "income"


def test_invalid_bracket_syntax_message():
"""Test error handling with invalid bracket syntax."""
parameters = ParameterNode(
data={
"tax": {
"brackets": [
{
"threshold": {"values": {"2022-01-01": 0}},
"rate": {"values": {"2022-01-01": 0.1}},
},
],
}
}
)

# Test invalid bracket syntax (missing closing bracket)
with pytest.raises(ParameterPathError) as excinfo:
get_parameter(parameters, "tax.brackets[0.rate")

assert "Invalid bracket syntax" in str(excinfo.value)
assert excinfo.value.parameter_path == "tax.brackets[0.rate"
# Don't assert the exact failed_at value since it depends on implementation details


def test_bracket_on_non_bracket_parameter():
"""Test error when trying to use bracket notation on a non-bracket parameter."""
parameters = ParameterNode(
data={"tax": {"simple_rate": {"values": {"2022-01-01": 0.2}}}}
)

with pytest.raises(ParameterPathError) as excinfo:
get_parameter(parameters, "tax.simple_rate[0]")

assert "does not support bracket indexing" in str(excinfo.value)
assert excinfo.value.parameter_path == "tax.simple_rate[0]"
# Don't assert the exact failed_at value since it depends on implementation details