diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 02b57591b..f2f159625 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -29,7 +29,12 @@ class HourlyInterval(object): def __init__(self, start_time, end_time, interval_value): self.start_time = start_time self.end_time = end_time - self.interval = interval_value + + # interval should be a tuple, if it is not, assign as a tuple with single value + if isinstance(interval_value, tuple): + self.interval = interval_value + else: + self.interval = (interval_value,) def __repr__(self): return f"<{self.__class__.__name__} start={self.start_time} end={self.end_time} interval={self.interval}>" @@ -63,25 +68,44 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval): + def interval(self, intervals): VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} - if float(interval) not in VALID_INTERVALS: - error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) - raise ValueError(error) + for interval in intervals: + # if an hourly interval is a string, then it is a weekDay interval + if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): + error = "Invalid weekDay interval {}".format(interval) + raise ValueError(error) + + # if an hourly interval is a number, it is an hours or minutes interval + if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + raise ValueError(error) - self._interval = interval + self._interval = intervals def _interval_type_pairs(self): - # We use fractional hours for the two minute-based intervals. - # Need to convert to minutes from hours here - if self.interval in {0.25, 0.5}: - calculated_interval = int(self.interval * 60) - interval_type = IntervalItem.Occurrence.Minutes - else: - calculated_interval = self.interval - interval_type = IntervalItem.Occurrence.Hours + interval_type_pairs = [] + for interval in self.interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to minutes from hours here + if interval in {0.25, 0.5}: + calculated_interval = int(interval * 60) + interval_type = IntervalItem.Occurrence.Minutes + + interval_type_pairs.append((interval_type, str(calculated_interval))) + else: + # if the interval is a non-numeric string, it will always be a weekDay + if isinstance(interval, str) and not interval.isnumeric(): + interval_type = IntervalItem.Occurrence.WeekDay + + interval_type_pairs.append((interval_type, str(interval))) + # otherwise the interval is hours + else: + interval_type = IntervalItem.Occurrence.Hours - return [(interval_type, str(calculated_interval))] + interval_type_pairs.append((interval_type, str(interval))) + + return interval_type_pairs class DailyInterval(object): @@ -111,8 +135,45 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval): - self._interval = interval + def interval(self, intervals): + VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} + + for interval in intervals: + # if an hourly interval is a string, then it is a weekDay interval + if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval): + error = "Invalid weekDay interval {}".format(interval) + raise ValueError(error) + + # if an hourly interval is a number, it is an hours or minutes interval + if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS: + error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS)) + raise ValueError(error) + + self._interval = intervals + + def _interval_type_pairs(self): + interval_type_pairs = [] + for interval in self.interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to minutes from hours here + if interval in {0.25, 0.5}: + calculated_interval = int(interval * 60) + interval_type = IntervalItem.Occurrence.Minutes + + interval_type_pairs.append((interval_type, str(calculated_interval))) + else: + # if the interval is a non-numeric string, it will always be a weekDay + if isinstance(interval, str) and not interval.isnumeric(): + interval_type = IntervalItem.Occurrence.WeekDay + + interval_type_pairs.append((interval_type, str(interval))) + # otherwise the interval is hours + else: + interval_type = IntervalItem.Occurrence.Hours + + interval_type_pairs.append((interval_type, str(interval))) + + return interval_type_pairs class WeeklyInterval(object): @@ -155,7 +216,12 @@ def _interval_type_pairs(self): class MonthlyInterval(object): def __init__(self, start_time, interval_value): self.start_time = start_time - self.interval = str(interval_value) + + # interval should be a tuple, if it is not, assign as a tuple with single value + if isinstance(interval_value, tuple): + self.interval = interval_value + else: + self.interval = (interval_value,) def __repr__(self): return f"<{self.__class__.__name__} start={self.start_time} interval={self.interval}>" @@ -179,24 +245,24 @@ def interval(self): return self._interval @interval.setter - def interval(self, interval_value): - error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - + def interval(self, interval_values): # This is weird because the value could be a str or an int # The only valid str is 'LastDay' so we check that first. If that's not it # try to convert it to an int, if that fails because it's an incorrect string # like 'badstring' we catch and re-raise. Otherwise we convert to int and check # that it's in range 1-31 + for interval_value in interval_values: + error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - if interval_value != "LastDay": - try: - if not (1 <= int(interval_value) <= 31): - raise ValueError(error) - except ValueError: - if interval_value != "LastDay": - raise ValueError(error) + if interval_value != "LastDay": + try: + if not (1 <= int(interval_value) <= 31): + raise ValueError(error) + except ValueError: + if interval_value != "LastDay": + raise ValueError(error) - self._interval = str(interval_value) + self._interval = interval_values def _interval_type_pairs(self): return [(IntervalItem.Occurrence.MonthDay, self.interval)] diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index dc0eca948..db187a5f9 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -254,25 +254,43 @@ def _parse_interval_item(parsed_response, frequency, ns): interval.extend(interval_elem.attrib.items()) if frequency == IntervalItem.Frequency.Daily: - return DailyInterval(start_time) + converted_intervals = [] + + for i in interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to hours from minutes here + if i[0] == IntervalItem.Occurrence.Minutes: + converted_intervals.append(float(i[1]) / 60) + elif i[0] == IntervalItem.Occurrence.Hours: + converted_intervals.append(float(i[1])) + else: + converted_intervals.append(i[1]) + + return DailyInterval(start_time, *converted_intervals) if frequency == IntervalItem.Frequency.Hourly: - interval_occurrence, interval_value = interval.pop() + converted_intervals = [] - # We use fractional hours for the two minute-based intervals. - # Need to convert to hours from minutes here - if interval_occurrence == IntervalItem.Occurrence.Minutes: - interval_value = float(interval_value) / 60 + for i in interval: + # We use fractional hours for the two minute-based intervals. + # Need to convert to hours from minutes here + if i[0] == IntervalItem.Occurrence.Minutes: + converted_intervals.append(float(i[1]) / 60) + elif i[0] == IntervalItem.Occurrence.Hours: + converted_intervals.append(i[1]) + else: + converted_intervals.append(i[1]) - return HourlyInterval(start_time, end_time, interval_value) + return HourlyInterval(start_time, end_time, tuple(converted_intervals)) if frequency == IntervalItem.Frequency.Weekly: interval_values = [i[1] for i in interval] return WeeklyInterval(start_time, *interval_values) if frequency == IntervalItem.Frequency.Monthly: - interval_occurrence, interval_value = interval.pop() - return MonthlyInterval(start_time, interval_value) + interval_values = [i[1] for i in interval] + + return MonthlyInterval(start_time, tuple(interval_values)) @staticmethod def _parse_element(schedule_xml, ns): diff --git a/test/assets/schedule_get_daily_id.xml b/test/assets/schedule_get_daily_id.xml new file mode 100644 index 000000000..99467a391 --- /dev/null +++ b/test/assets/schedule_get_daily_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get_hourly_id.xml b/test/assets/schedule_get_hourly_id.xml new file mode 100644 index 000000000..27c374ccf --- /dev/null +++ b/test/assets/schedule_get_hourly_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get_monthly_id.xml b/test/assets/schedule_get_monthly_id.xml new file mode 100644 index 000000000..3fc32cc57 --- /dev/null +++ b/test/assets/schedule_get_monthly_id.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 807467918..76c8720b9 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -11,6 +11,9 @@ GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml") GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_by_id.xml") +GET_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml") +GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml") +GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") @@ -100,6 +103,51 @@ def test_get_by_id(self) -> None: self.assertEqual("Weekday early mornings", schedule.name) self.assertEqual("Active", schedule.state) + def test_get_hourly_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_HOURLY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Hourly schedule", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", 0.5), schedule.interval_item.interval) + + def test_get_daily_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_DAILY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Daily schedule", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", 2.0), schedule.interval_item.interval) + + def test_get_monthly_by_id(self) -> None: + self.server.version = "3.8" + with open(GET_MONTHLY_ID_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Monthly multiple days", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("1", "2"), schedule.interval_item.interval) + def test_delete(self) -> None: with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) @@ -131,7 +179,7 @@ def test_create_hourly(self) -> None: self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) self.assertEqual(time(2, 30), new_schedule.interval_item.start_time) self.assertEqual(time(23), new_schedule.interval_item.end_time) # type: ignore[union-attr] - self.assertEqual("8", new_schedule.interval_item.interval) # type: ignore[union-attr] + self.assertEqual(("8",), new_schedule.interval_item.interval) # type: ignore[union-attr] def test_create_daily(self) -> None: with open(CREATE_DAILY_XML, "rb") as f: @@ -216,7 +264,7 @@ def test_create_monthly(self) -> None: self.assertEqual("2016-10-12T14:00:00Z", format_datetime(new_schedule.next_run_at)) self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) self.assertEqual(time(7), new_schedule.interval_item.start_time) - self.assertEqual("12", new_schedule.interval_item.interval) # type: ignore[union-attr] + self.assertEqual(("12",), new_schedule.interval_item.interval) # type: ignore[union-attr] def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: