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

Adding Schedules Support Round 2 #48

Merged
merged 17 commits into from Oct 7, 2016
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
78 changes: 78 additions & 0 deletions samples/create_schedules.py
@@ -0,0 +1,78 @@
####
# This script demonstrates how to create schedules using the Tableau
# Server Client.
#
# To run the script, you must have installed Python 2.7.9 or later.
####


import argparse
import getpass
import logging

from datetime import time

import tableauserverclient as TSC


def main():

parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.')
parser.add_argument('--server', '-s', required=True, help='server address')
parser.add_argument('--username', '-u', required=True, help='username to sign into server')
parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error',
help='desired logging level (set to error by default)')
args = parser.parse_args()

password = getpass.getpass("Password: ")

# Set logging level based on user input, or error by default
logging_level = getattr(logging, args.logging_level.upper())
logging.basicConfig(level=logging_level)

tableau_auth = TSC.TableauAuth(args.username, password)
server = TSC.Server(args.server)
with server.auth.sign_in(tableau_auth):
# Hourly Schedule
# This schedule will run every 2 hours between 2:30AM and 11:00PM
hourly_interval = TSC.HourlyInterval(start_time=time(2, 30),
end_time=time(23, 0),
interval_occurrence=TSC.IntervalItem.Occurrence.Hours,
interval_value=2)

hourly_schedule = TSC.ScheduleItem("Hourly-Schedule", 50, TSC.ScheduleItem.Type.Extract,
TSC.ScheduleItem.ExecutionOrder.Parallel, hourly_interval)
hourly_schedule = server.schedules.create(hourly_schedule)
print("Hourly schedule created (ID: {}).".format(hourly_schedule.id))

# Daily Schedule
# This schedule will run every day at 5AM
daily_interval = TSC.DailyInterval(start_time=time(5))
daily_schedule = TSC.ScheduleItem("Daily-Schedule", 60, TSC.ScheduleItem.Type.Subscription,
TSC.ScheduleItem.ExecutionOrder.Serial, daily_interval)
daily_schedule = server.schedules.create(daily_schedule)
print("Daily schedule created (ID: {}).".format(daily_schedule.id))

# Weekly Schedule
# This schedule will wun every Monday, Wednesday, and Friday at 7:15PM
weekly_interval = TSC.WeeklyInterval(time(19, 15),
TSC.IntervalItem.Day.Monday,
TSC.IntervalItem.Day.Wednesday,
TSC.IntervalItem.Day.Friday)
weekly_schedule = TSC.ScheduleItem("Weekly-Schedule", 70, TSC.ScheduleItem.Type.Extract,
TSC.ScheduleItem.ExecutionOrder.Serial, weekly_interval)
weekly_schedule = server.schedules.create(weekly_schedule)
print("Weekly schedule created (ID: {}).".format(weekly_schedule.id))

# Monthly Schedule
# This schedule will run on the 15th of every month at 11:30PM
monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30),
interval_value=15)
monthly_schedule = TSC.ScheduleItem("Monthly-Schedule", 80, TSC.ScheduleItem.Type.Subscription,
TSC.ScheduleItem.ExecutionOrder.Parallel, monthly_interval)
monthly_schedule = server.schedules.create(monthly_schedule)
print("Monthly schedule created (ID: {}).".format(monthly_schedule.id))


if __name__ == '__main__':
main()
5 changes: 3 additions & 2 deletions tableauserverclient/__init__.py
@@ -1,7 +1,8 @@
from .namespace import NAMESPACE
from .models import ConnectionItem, DatasourceItem,\
GroupItem, PaginationItem, ProjectItem, \
SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError
GroupItem, PaginationItem, ProjectItem, ScheduleItem, \
SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError, \
HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem
from .server import RequestOptions, Filter, Sort, Server, ServerResponseError,\
MissingRequiredFieldError, NotSignedInError

Expand Down
2 changes: 2 additions & 0 deletions tableauserverclient/models/__init__.py
Expand Up @@ -2,8 +2,10 @@
from .datasource_item import DatasourceItem
from .exceptions import UnpopulatedPropertyError
from .group_item import GroupItem
from .interval_item import IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval
from .pagination_item import PaginationItem
from .project_item import ProjectItem
from .schedule_item import ScheduleItem
from .site_item import SiteItem
from .tableau_auth import TableauAuth
from .user_item import UserItem
Expand Down
166 changes: 166 additions & 0 deletions tableauserverclient/models/interval_item.py
@@ -0,0 +1,166 @@
from .property_decorators import property_is_valid_time, property_not_nullable


class IntervalItem(object):
class Frequency:
Hourly = "Hourly"
Daily = "Daily"
Weekly = "Weekly"
Monthly = "Monthly"

class Occurrence:
Minutes = "minutes"
Copy link
Contributor

Choose a reason for hiding this comment

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

Minutes should come before hours.

Hours = "hours"
WeekDay = "weekDay"
MonthDay = "monthDay"

class Day:
Sunday = "Sunday"
Monday = "Monday"
Tuesday = "Tuesday"
Wednesday = "Wednesday"
Thursday = "Thursday"
Friday = "Friday"
Saturday = "Saturday"
LastDay = "LastDay"


class HourlyInterval(object):
Copy link
Contributor

Choose a reason for hiding this comment

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

The properties should have an decorators if they cannot be none / empty

def __init__(self, start_time, end_time, interval_value):

self.start_time = start_time
Copy link
Contributor

Choose a reason for hiding this comment

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

For other code, it seems we start by setting the internal variable to None before calling the setter. Any reason for using that pattern versus not?

Copy link
Collaborator Author

@t8y8 t8y8 Oct 6, 2016

Choose a reason for hiding this comment

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

This feels cleaner to me because it hide the _blah versions as the implementation detail that they are and keeps the __init__ function clean.

I then don't need to have a bunch of these in __init__:

        # Invoke setter
        self.name = name

Copy link
Contributor

Choose a reason for hiding this comment

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

Russell?

self.end_time = end_time
self._frequency = IntervalItem.Frequency.Hourly
self.interval = interval_value

@property
def start_time(self):
return self._start_time

@start_time.setter
@property_is_valid_time
@property_not_nullable
def start_time(self, value):
self._start_time = value

@property
def end_time(self):
return self._end_time

@end_time.setter
@property_is_valid_time
@property_not_nullable
def end_time(self, value):
self._end_time = value

@property
def interval(self):
return self._interval

@interval.setter
def interval(self, interval):
VALID_INTERVALS = {.25, .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)

# We use fractional hours for the two minute-based intervals.
# Need to convert to minutes from hours here
if interval in {.25, .5}:
interval_value = int(interval * 60)
interval_occurrence = IntervalItem.Occurrence.Minutes
else:
interval_value = interval
interval_occurrence = IntervalItem.Occurrence.Hours

self._interval = [(interval_occurrence, str(interval_value))]
Copy link
Contributor

Choose a reason for hiding this comment

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

How do I read the interval? I expect to be able to read the value I set or one that was returned from a "get" call to the server.

Copy link
Collaborator Author

@t8y8 t8y8 Oct 7, 2016

Choose a reason for hiding this comment

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

Intervals are tuples of (Occurence, Value), so in this case it would return

('hours', 5) or ('minutes', 15) or ('weekDay', 'Monday')

It's consistent across the different interval values -- which is how the serializer wants it:

        if interval_item.interval:
            intervals_element = ET.SubElement(frequency_element, 'intervals')
            for interval in interval_item.interval:
                expression, value = interval
                single_interval_element = ET.SubElement(intervals_element, 'interval')
                single_interval_element.attrib[expression] = value

Copy link
Contributor

Choose a reason for hiding this comment

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

I want to return what we set. We have made the "Occurence" disappear. Why are we putting it back? if I write:
foo.interval = .25
and then do
x = foo.interval

x should equal .25

We can make the serialization work without exposing this to users.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think that's going to be a fairly large change.

We'd need a serializer for each IntervalType maybe?

Or... I'd have to think more about it, when it's not so late.

Copy link
Contributor

Choose a reason for hiding this comment

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

We could do it in the serializer or you can have an internal function on each of them which returns this pair. We can call internal functions from our serializer ... this is already a pattern we use. But the value I set for interval should be the value I get back for it (and I should be able to directly set it, ie not just in the constructor).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I like the latter

interval_item.interval_pairs() or whatever.

One-line change in the serializer, and then the internal function is easy to write as well.
I'll give it a go tomorrow.



class DailyInterval(object):
def __init__(self, start_time):
self.start_time = start_time
self._frequency = IntervalItem.Frequency.Daily
self.end_time = None
Copy link
Contributor

Choose a reason for hiding this comment

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

why do you have this (besides the issue with serialization)? I would rather have the serializer clean it than have properties which are meaningless but visible.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Are you talking about the end_time arritbutes?

Yeah, that was so I didn't have to re-write the serializer, which I consider to be the riskiest part.

We'd have to add a bunch of hasattr or similar checks into the serializer, I think it's the lesser of two evils to have the empty attributes here

self.interval = None
Copy link
Contributor

Choose a reason for hiding this comment

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

Same for this


@property
def start_time(self):
return self._start_time

@start_time.setter
@property_is_valid_time
@property_not_nullable
def start_time(self, value):
self._start_time = value


class WeeklyInterval(object):
def __init__(self, start_time, *interval_values):
self.start_time = start_time
self._frequency = IntervalItem.Frequency.Weekly
self.interval = interval_values
self.end_time = None
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here


@property
def start_time(self):
return self._start_time

@start_time.setter
@property_is_valid_time
@property_not_nullable
def start_time(self, value):
self._start_time = value

@property
def interval(self):
return self._interval

@interval.setter
def interval(self, interval_values):
if not all(hasattr(IntervalItem.Day, day) for day in interval_values):
raise ValueError("Invalid week day defined " + str(interval_values))

self._interval = [(IntervalItem.Occurrence.WeekDay, day) for day in interval_values]


class MonthlyInterval(object):
def __init__(self, start_time, interval_value):
self.start_time = start_time
self._frequency = IntervalItem.Frequency.Monthly
self.interval = str(interval_value)
self.end_time = None
Copy link
Contributor

Choose a reason for hiding this comment

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

And here. No?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done, you're right, it doesn't look that bad


@property
def start_time(self):
return self._start_time

@start_time.setter
@property_is_valid_time
@property_not_nullable
def start_time(self, value):
self._start_time = value

@property
def interval(self):
return self._interval

@interval.setter
def interval(self, interval_value):
error = "Invalid interval value for a monthly frequency: {}.".format(interval_value)

# 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

if interval_value != "LastDay":
try:
if not (1 <= int(interval_value) <= 31):
raise ValueError(error)
except ValueError as e:
if interval_value != "LastDay":
raise ValueError(error)

self._interval = [(IntervalItem.Occurrence.MonthDay, str(interval_value))]
31 changes: 31 additions & 0 deletions tableauserverclient/models/property_decorators.py
Expand Up @@ -46,3 +46,34 @@ def wrapper(self, value):
return func(self, value)

return wrapper


def property_is_valid_time(func):
@wraps(func)
def wrapper(self, value):
units_of_time = {"hour", "minute", "second"}

if not any(hasattr(value, unit) for unit in units_of_time):
error = "Invalid time object defined."
raise ValueError(error)
return func(self, value)

return wrapper


def property_is_int(min, max=2**31):
def property_type_decorator(func):
@wraps(func)
def wrapper(self, value):
if min is None:
return func(self, value)
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't get this? Why can't I set the min to None and the max to some number (like 100). Not saying you are doing this right now but this code would break. Just do the if "min is not None and value < min" and similarly "if max is not None and value > max". No?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I could enable that, but then how would we say "this should be an int but not checked within a range"

property_is_int(None) is nice and clean to say that.
property_is_int(None, 100) is ambiguous with the above call because both min and max are required positional arguments, so no nice signature :(

I could:

  1. Make a keyword argument that is property_is_int(range=False) or some similar keyword.

Copy link
Contributor

Choose a reason for hiding this comment

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

I am fine with accepting a pair using range=(None,100). It is more readable. I am good with that.

Copy link
Collaborator Author

@t8y8 t8y8 Oct 7, 2016

Choose a reason for hiding this comment

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

Done.

def property_is_int(range):
    def property_type_decorator(func):
        @wraps(func)
        def wrapper(self, value):
            error = "Invalid priority defined: {}.".format(value)

            if range is None:
                if isinstance(value, int):
                    return func(self, value)
                else:
                    raise ValueError(error)

            min, max = range

            if value < min or value > max:

                raise ValueError(error)

            return func(self, value)

        return wrapper

    return property_type_decorator

property_is_int(range=None) means "int but it can be whatevs"
property_is_int(range=(1,100)) means "int between 1 and 100"

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay. I am fine with this. If it is ever actually a need, we can debate a case with only a max or only a min but we have no case so I am not going to debate just for the sake of debating. I like the syntax. Thanks!


if value < min or value > max:
error = "Invalid priority defined: {}.".format(value)
raise ValueError(error)

return func(self, value)

return wrapper

return property_type_decorator