diff --git a/openfisca_core/holders.py b/openfisca_core/holders.py index 233a368cbc..46dd900fe6 100644 --- a/openfisca_core/holders.py +++ b/openfisca_core/holders.py @@ -12,7 +12,7 @@ from openfisca_core.data_storage import InMemoryStorage, OnDiskStorage from openfisca_core.errors import PeriodMismatchError from openfisca_core.indexed_enums import Enum -from openfisca_core.periods import MONTH, YEAR, ETERNITY +from openfisca_core.periods import WEEK, MONTH, YEAR, ETERNITY from openfisca_core.tools import eval_expression log = logging.getLogger(__name__) @@ -157,6 +157,7 @@ def set_input(self, period, array): """ period = periods.period(period) + if period.unit == ETERNITY and self.variable.definition_period != ETERNITY: error_message = os.linesep.join([ 'Unable to set a value for variable {0} for ETERNITY.', @@ -171,16 +172,20 @@ def set_input(self, period, array): self.variable.definition_period, error_message ) + if self.variable.is_neutralized: warning_message = "You cannot set a value for the variable {}, as it has been neutralized. The value you provided ({}) will be ignored.".format(self.variable.name, array) return warnings.warn( warning_message, Warning ) + if self.variable.value_type in (float, int) and isinstance(array, str): array = eval_expression(array) + if self.variable.set_input: return self.variable.set_input(self, period, array) + return self._set(period, array) def _to_array(self, value): @@ -209,6 +214,7 @@ def _set(self, period, value): if self.variable.definition_period != ETERNITY: if period is None: raise ValueError('A period must be specified to set values, except for variables with ETERNITY as as period_definition.') + if (self.variable.definition_period != period.unit or period.size > 1): name = self.variable.name period_size_adj = f'{period.unit}' if (period.size == 1) else f'{period.size}-{period.unit}s' @@ -300,13 +306,16 @@ def set_input_divide_by_period(holder, period, array): """ if not isinstance(array, np.ndarray): array = np.array(array) + period_size = period.size period_unit = period.unit if holder.variable.definition_period == MONTH: cached_period_unit = periods.MONTH + elif holder.variable.definition_period == YEAR: cached_period_unit = periods.YEAR + else: raise ValueError('set_input_divide_by_period can be used only for yearly or monthly variables.') diff --git a/openfisca_core/periods.py b/openfisca_core/periods.py index d906794e94..91fa57017e 100644 --- a/openfisca_core/periods.py +++ b/openfisca_core/periods.py @@ -18,10 +18,12 @@ from typing import Dict -DAY = 'day' -MONTH = 'month' -YEAR = 'year' ETERNITY = 'eternity' +YEAR = 'year' +MONTH = 'month' +DAY = 'day' +WEEK = 'week' +WEEKDAY = 'weekday' INSTANT_PATTERN = re.compile(r'^\d{4}(?:-\d{1,2}){0,2}$') # matches '2015', '2015-01', '2015-01-01' @@ -201,7 +203,7 @@ def offset(self, offset, unit): Instant((2014, 12, 31)) """ year, month, day = self - assert unit in (DAY, MONTH, YEAR), 'Invalid unit: {} of type {}'.format(unit, type(unit)) + assert unit in (WEEKDAY, WEEK, DAY, MONTH, YEAR), 'Invalid unit: {} of type {}'.format(unit, type(unit)) if offset == 'first-of': if unit == MONTH: day = 1 @@ -291,28 +293,36 @@ def __str__(self): >>> str(period(YEAR, '2014-2')) 'year:2014-02' + >>> str(period(MONTH, '2014-2')) '2014-02' >>> str(period(YEAR, 2012, size = 2)) 'year:2012:2' + >>> str(period(MONTH, 2012, size = 2)) 'month:2012-01:2' + >>> str(period(MONTH, 2012, size = 12)) '2012' >>> str(period(YEAR, '2012-3', size = 2)) 'year:2012-03:2' + >>> str(period(MONTH, '2012-3', size = 2)) 'month:2012-03:2' + >>> str(period(MONTH, '2012-3', size = 12)) 'year:2012-03' """ unit, start_instant, size = self + if unit == ETERNITY: return 'ETERNITY' + year, month, day = start_instant + _, week, weekday = datetime.date(year, month, day).isocalendar() # 1 year long period if (unit == MONTH and size == 12 or unit == YEAR and size == 1): @@ -322,9 +332,11 @@ def __str__(self): else: # rolling year return '{}:{}-{:02d}'.format(YEAR, year, month) + # simple month if unit == MONTH and size == 1: return '{}-{:02d}'.format(year, month) + # several civil years if unit == YEAR and month == 1: return '{}:{}:{}'.format(unit, year, size) @@ -335,6 +347,22 @@ def __str__(self): else: return '{}:{}-{:02d}-{:02d}:{}'.format(unit, year, month, day, size) + # 1 week + if (unit == WEEK and size == 1): + return f'{year}-W{week}' + + # several weeks + if (unit == WEEK and size > 1): + return f'{unit}:{year}-W{week}:{size}' + + # 1 weekday + if (unit == WEEKDAY and size == 1): + return f'{year}-W{week}-{weekday}' + + # several weekdays + if (unit == WEEKDAY and size > 1): + return f'{unit}:{year}-W{week}-{weekday}:{size}' + # complex period return '{}:{}-{:02d}:{}'.format(unit, year, month, size) @@ -831,6 +859,40 @@ def parse_simple_period(value): else: return Period((YEAR, Instant((date.year, date.month, 1)), 1)) + def parse_week_period(value): + """ + Parses ISO week date periods, such as 2010-W3 or 2012-W5-7. + """ + + # If it's complex, next! + if len(value.split(':')) != 1: + return None + + # If it's just a year, next! + if len(value.split('-')) == 1: + return None + + # If there are no weeks, next! + if value.find("W") == -1: + return None + + value = value.replace("W", "") + components = list(map(int, value.split("-"))) + + # If it has no weekdays return a week period + if len(components) == 2: + year, week = components + date = datetime.date.fromisocalendar(year, week, 1) + return Period((WEEK, Instant((date.year, date.month, date.day)), 1)) + + # If it has weekdays return a weekday period + if len(components) == 3: + year, week, day = components + date = datetime.date.fromisocalendar(year, week, day) + return Period((WEEKDAY, Instant((date.year, date.month, date.day)), 1)) + + return None + def raise_error(value): message = linesep.join([ "Expected a period (eg. '2017', '2017-01', '2017-01-01', ...); got: '{}'.".format(value), @@ -845,11 +907,19 @@ def raise_error(value): # check the type if isinstance(value, int): return Period((YEAR, Instant((value, 1, 1)), 1)) + if not isinstance(value, str): raise_error(value) # try to parse as a simple period period = parse_simple_period(value) + + if period is not None: + return period + + # try to parse as a week period + period = parse_week_period(value) + if period is not None: return period @@ -861,23 +931,30 @@ def raise_error(value): # left-most component must be a valid unit unit = components[0] - if unit not in (DAY, MONTH, YEAR): + if unit not in (WEEKDAY, WEEK, DAY, MONTH, YEAR): raise_error(value) # middle component must be a valid iso period base_period = parse_simple_period(components[1]) + if not base_period: - raise_error(value) + # or a valid week/day period + base_period = parse_week_period(components[1]) + + if not base_period: + raise_error(value) # period like year:2015-03 have a size of 1 if len(components) == 2: size = 1 + # if provided, make sure the size is an integer elif len(components) == 3: try: size = int(components[2]) except ValueError: raise_error(value) + # if there is more than 2 ":" in the string, the period is invalid else: raise_error(value) @@ -912,10 +989,12 @@ def key_period_size(period): def unit_weights(): return { - DAY: 100, - MONTH: 200, - YEAR: 300, - ETERNITY: 400, + WEEKDAY: 100, + WEEK: 200, + DAY: 300, + MONTH: 400, + YEAR: 500, + ETERNITY: 600, } diff --git a/openfisca_core/variables.py b/openfisca_core/variables.py index c1ecd8ba8c..12c82864a3 100644 --- a/openfisca_core/variables.py +++ b/openfisca_core/variables.py @@ -13,7 +13,7 @@ from openfisca_core import periods from openfisca_core.entities import Entity from openfisca_core.indexed_enums import Enum, EnumArray, ENUM_ARRAY_DTYPE -from openfisca_core.periods import DAY, MONTH, YEAR, ETERNITY +from openfisca_core.periods import ETERNITY, YEAR, MONTH, DAY, WEEK, WEEKDAY from openfisca_core.tools import eval_expression @@ -61,6 +61,7 @@ }, } +ALLOWED_DEFINITION_PERIODS = (ETERNITY, YEAR, MONTH, DAY, WEEK, WEEKDAY) FORMULA_NAME_PREFIX = 'formula' @@ -167,7 +168,7 @@ def __init__(self, baseline_variable = None): else: self.default_value = self.set(attr, 'default_value', allowed_type = self.value_type, default = VALUE_TYPES[self.value_type].get('default')) self.entity = self.set(attr, 'entity', required = True, setter = self.set_entity) - self.definition_period = self.set(attr, 'definition_period', required = True, allowed_values = (DAY, MONTH, YEAR, ETERNITY)) + self.definition_period = self.set(attr, 'definition_period', required = True, allowed_values = ALLOWED_DEFINITION_PERIODS) self.label = self.set(attr, 'label', allowed_type = str, setter = self.set_label) self.end = self.set(attr, 'end', allowed_type = str, setter = self.set_end) self.reference = self.set(attr, 'reference', setter = self.set_reference) diff --git a/tests/core/test_holders.py b/tests/core/test_holders.py index 9e62d6e64b..fe439f96ae 100644 --- a/tests/core/test_holders.py +++ b/tests/core/test_holders.py @@ -11,6 +11,9 @@ from openfisca_core.memory_config import MemoryConfig from openfisca_core.holders import Holder, set_input_dispatch_by_period from openfisca_core.errors import PeriodMismatchError +from openfisca_core.entities import build_entity +from openfisca_core.variables import Variable +from openfisca_core.populations import Population from .test_countries import tax_benefit_system from pytest import fixture @@ -212,3 +215,38 @@ def test_set_input_float_to_int(single): simulation.person.get_holder('age').set_input(period, age) result = simulation.calculate('age', period) assert result == np.asarray([50]) + + +# Unit tests - Set Input / Periods + + +ENTITY = build_entity( + key = "martian", + plural = "martians", + label = "People from Mars", + is_person = True, + ) + + +@fixture +def variable(): + class EternalVariable(Variable): + value_type = bool + entity = ENTITY + definition_period = ETERNITY + + return EternalVariable() + + +@fixture +def population(): + return Population(ENTITY) + + +@fixture +def holder(variable, population): + return Holder(variable, population) + + +def test_holder(holder): + assert holder diff --git a/tests/core/test_periods.py b/tests/core/test_periods.py index 8fa2619c6b..7b76e16906 100644 --- a/tests/core/test_periods.py +++ b/tests/core/test_periods.py @@ -1,12 +1,29 @@ # -*- coding: utf-8 -*- -import pytest +from pytest import fixture, mark, raises -from openfisca_core.periods import Period, Instant, YEAR, MONTH, DAY, period +from openfisca_core.periods import Period, Instant, YEAR, MONTH, DAY, WEEK, WEEKDAY, period -first_jan = Instant((2014, 1, 1)) -first_march = Instant((2014, 3, 1)) + +@fixture +def first_jan(): + return Instant((2014, 1, 1)) + + +@fixture +def first_march(): + return Instant((2014, 3, 1)) + + +@fixture +def first_week(): + return Instant((2013, 12, 30)) + + +@fixture +def ninetieth_week(): + return Instant((2014, 2, 24)) ''' @@ -16,46 +33,76 @@ # Years -def test_year(): +def test_year(first_jan): assert str(Period((YEAR, first_jan, 1))) == '2014' -def test_12_months_is_a_year(): +def test_12_months_is_a_year(first_jan): assert str(Period((MONTH, first_jan, 12))) == '2014' -def test_rolling_year(): +def test_rolling_year(first_march): assert str(Period((MONTH, first_march, 12))) == 'year:2014-03' assert str(Period((YEAR, first_march, 1))) == 'year:2014-03' -def test_several_years(): +def test_several_years(first_jan, first_march): assert str(Period((YEAR, first_jan, 3))) == 'year:2014:3' assert str(Period((YEAR, first_march, 3))) == 'year:2014-03:3' # Months -def test_month(): + +def test_month(first_jan, first_march): assert str(Period((MONTH, first_jan, 1))) == '2014-01' + assert str(Period((MONTH, first_march, 1))) == '2014-03' -def test_several_months(): +def test_several_months(first_jan, first_march): assert str(Period((MONTH, first_jan, 3))) == 'month:2014-01:3' assert str(Period((MONTH, first_march, 3))) == 'month:2014-03:3' # Days -def test_day(): + +def test_day(first_jan, first_march): assert str(Period((DAY, first_jan, 1))) == '2014-01-01' + assert str(Period((DAY, first_march, 1))) == '2014-03-01' -def test_several_days(): +def test_several_days(first_jan, first_march): assert str(Period((DAY, first_jan, 3))) == 'day:2014-01-01:3' assert str(Period((DAY, first_march, 3))) == 'day:2014-03-01:3' +# Weeks + + +def test_week(first_jan, first_march): + assert str(Period((WEEK, first_jan, 1))) == '2014-W1' + assert str(Period((WEEK, first_march, 1))) == '2014-W9' + + +def test_several_weeks(first_jan, first_march): + assert str(Period((WEEK, first_jan, 3))) == 'week:2014-W1:3' + assert str(Period((WEEK, first_march, 3))) == 'week:2014-W9:3' + + +# Weekdays + + +def test_weekday(first_jan, first_march): + assert str(Period((WEEKDAY, first_jan, 1))) == '2014-W1-3' + assert str(Period((WEEKDAY, first_march, 1))) == '2014-W9-6' + + +def test_several_weekdays(first_jan, first_march): + assert str(Period((WEEKDAY, first_jan, 3))) == 'weekday:2014-W1-3:3' + assert str(Period((WEEKDAY, first_march, 3))) == 'weekday:2014-W9-6:3' + + ''' Test String -> Period ''' @@ -63,122 +110,158 @@ def test_several_days(): # Years -def test_parsing_year(): + +def test_parsing_year(first_jan): assert period('2014') == Period((YEAR, first_jan, 1)) -def test_parsing_rolling_year(): +def test_parsing_rolling_year(first_march): assert period('year:2014-03') == Period((YEAR, first_march, 1)) -def test_parsing_several_years(): +def test_parsing_several_years(first_jan): assert period('year:2014:2') == Period((YEAR, first_jan, 2)) def test_wrong_syntax_several_years(): - with pytest.raises(ValueError): + with raises(ValueError): period('2014:2') # Months -def test_parsing_month(): + +def test_parsing_month(first_jan): assert period('2014-01') == Period((MONTH, first_jan, 1)) -def test_parsing_several_months(): +def test_parsing_several_months(first_march): assert period('month:2014-03:3') == Period((MONTH, first_march, 3)) def test_wrong_syntax_several_months(): - with pytest.raises(ValueError): + with raises(ValueError): period('2014-3:3') # Days -def test_parsing_day(): + +def test_parsing_day(first_jan): assert period('2014-01-01') == Period((DAY, first_jan, 1)) -def test_parsing_several_days(): +def test_parsing_several_days(first_march): assert period('day:2014-03-01:3') == Period((DAY, first_march, 3)) def test_wrong_syntax_several_days(): - with pytest.raises(ValueError): + with raises(ValueError): period('2014-2-3:2') def test_day_size_in_days(): - assert Period(('day', Instant((2014, 12, 31)), 1)).size_in_days == 1 + assert Period((DAY, Instant((2014, 12, 31)), 1)).size_in_days == 1 def test_3_day_size_in_days(): - assert Period(('day', Instant((2014, 12, 31)), 3)).size_in_days == 3 + assert Period((DAY, Instant((2014, 12, 31)), 3)).size_in_days == 3 def test_month_size_in_days(): - assert Period(('month', Instant((2014, 12, 1)), 1)).size_in_days == 31 + assert Period((MONTH, Instant((2014, 12, 1)), 1)).size_in_days == 31 def test_leap_month_size_in_days(): - assert Period(('month', Instant((2012, 2, 3)), 1)).size_in_days == 29 + assert Period((MONTH, Instant((2012, 2, 3)), 1)).size_in_days == 29 def test_3_month_size_in_days(): - assert Period(('month', Instant((2013, 1, 3)), 3)).size_in_days == 31 + 28 + 31 + assert Period((MONTH, Instant((2013, 1, 3)), 3)).size_in_days == 31 + 28 + 31 def test_leap_3_month_size_in_days(): - assert Period(('month', Instant((2012, 1, 3)), 3)).size_in_days == 31 + 29 + 31 + assert Period((MONTH, Instant((2012, 1, 3)), 3)).size_in_days == 31 + 29 + 31 def test_year_size_in_days(): - assert Period(('year', Instant((2014, 12, 1)), 1)).size_in_days == 365 + assert Period((YEAR, Instant((2014, 12, 1)), 1)).size_in_days == 365 def test_leap_year_size_in_days(): - assert Period(('year', Instant((2012, 1, 1)), 1)).size_in_days == 366 + assert Period((YEAR, Instant((2012, 1, 1)), 1)).size_in_days == 366 def test_2_years_size_in_days(): - assert Period(('year', Instant((2014, 1, 1)), 2)).size_in_days == 730 + assert Period((YEAR, Instant((2014, 1, 1)), 2)).size_in_days == 730 + + +# Weeks + + +def test_parsing_week(first_week): + assert period('2014-W1') == Period((WEEK, first_week, 1)) + + +def test_parsing_several_weeks(ninetieth_week): + assert period('week:2014-W9:3') == Period((WEEK, ninetieth_week, 3)) + + +def test_wrong_syntax_several_weeks(): + with raises(ValueError): + period('2014-W3:3') + + +# Weekdays + + +def test_parsing_weekday(first_jan): + assert period('2014-W1-3') == Period((WEEKDAY, first_jan, 1)) + + +def test_parsing_several_weekdays(first_march): + assert period('weekday:2014-W9-6:3') == Period((WEEKDAY, first_march, 3)) + + +def test_wrong_syntax_several_weekdays(): + with raises(ValueError): + period('2014-W3-7:3') + # Misc def test_ambiguous_period(): - with pytest.raises(ValueError): + with raises(ValueError): period('month:2014') def test_deprecated_signature(): - with pytest.raises(TypeError): + with raises(TypeError): period(MONTH, 2014) def test_wrong_argument(): - with pytest.raises(ValueError): + with raises(ValueError): period({}) def test_wrong_argument_1(): - with pytest.raises(ValueError): + with raises(ValueError): period([]) def test_none(): - with pytest.raises(ValueError): + with raises(ValueError): period(None) def test_empty_string(): - with pytest.raises(ValueError): + with raises(ValueError): period('') -@pytest.mark.parametrize("test", [ +@mark.parametrize('period, unit, length, first, last', [ (period('year:2014:2'), YEAR, 2, period('2014'), period('2015')), (period(2017), MONTH, 12, period('2017-01'), period('2017-12')), (period('year:2014:2'), MONTH, 24, period('2014-01'), period('2015-12')), @@ -187,12 +270,8 @@ def test_empty_string(): (period('year:2014:2'), DAY, 730, period('2014-01-01'), period('2015-12-31')), (period('month:2014-03:3'), DAY, 92, period('2014-03-01'), period('2014-05-31')), ]) -def test_subperiods(test): - - def check_subperiods(period, unit, length, first, last): - subperiods = period.get_subperiods(unit) - assert len(subperiods) == length - assert subperiods[0] == first - assert subperiods[-1] == last - - check_subperiods(*test) +def test_subperiods(period, unit, length, first, last): + subperiods = period.get_subperiods(unit) + assert len(subperiods) == length + assert subperiods[0] == first + assert subperiods[-1] == last diff --git a/tests/core/variables/test___init__.py b/tests/core/variables/test___init__.py new file mode 100644 index 0000000000..2e5203fe55 --- /dev/null +++ b/tests/core/variables/test___init__.py @@ -0,0 +1,78 @@ +from pytest import fixture, mark, raises + +from openfisca_core.entities import build_entity +from openfisca_core.variables import Variable + + +@fixture +def entity(): + """Create a martian entity for our variable creation tests.""" + return build_entity( + key = "martian", + plural = "martians", + label = "People from Mars", + is_person = True, + ) + + +@fixture +def attributes(entity): + """ + Example attributes of our variable. + + We need to set them to something as variable attributes are class + attributes, therefore we have to define them before we create the variable. + """ + return { + "value_type": bool, + "entity": entity, + "default_value": True, + "definition_period": "year", + "label": "My taxes", + "reference": "https://law.gov.example/my-taxes", + } + + +@fixture +def make_variable(): + """ + Example variable. + + This fixture defines our variable dynamically. We do so because we want to + test the variable valid values, verified at the moment the variable is + instantiated. + """ + def _make_variable(attributes): + return type("my_taxes", (Variable,), attributes)() + + return _make_variable + + +def test_variable(make_variable, attributes): + assert make_variable(attributes) + + +# Definition periods + + +@mark.parametrize("definition_period", ["eternity", "year", "month", "day", "week", "weekday"]) +def test_variable_with_valid_definition_period(definition_period, make_variable, attributes): + attributes["definition_period"] = definition_period + assert make_variable(attributes) + + +@mark.parametrize("definition_period", ["never", 9000]) +def test_variable_with_invalid_definition_period(definition_period, make_variable, attributes): + with raises(ValueError) as error: + attributes["definition_period"] = definition_period + make_variable(attributes) + + assert f"Invalid value '{definition_period}' for attribute 'definition_period'" in str(error.value) + + +def test_variable_without_definition_period(make_variable, attributes): + with raises(ValueError) as error: + attributes["definition_period"] = None + make_variable(attributes) + + assert "Missing attribute 'definition_period'" in str(error.value)