Skip to content

Commit

Permalink
promote code for 0.29
Browse files Browse the repository at this point in the history
#1302 
#1300 
#1330 
#1328 
#1297 
#1307 
#1299 
- fix filename handling in downloads
- fix typing on revisions files
- add typings
- update urllib dependency
  • Loading branch information
jacalata authored Jan 13, 2024
2 parents 3ec49bc + d79ca4c commit 0e46e15
Show file tree
Hide file tree
Showing 24 changed files with 400 additions and 108 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ var/
*.egg-info/
.installed.cfg
*.egg
pip-wheel-metadata/

# PyInstaller
# Usually these files are written by a python script from a template
Expand Down Expand Up @@ -89,6 +90,7 @@ env.py
# virtualenv
venv/
ENV/
.venv/

# Spyder project settings
.spyderproject
Expand Down
2 changes: 1 addition & 1 deletion tableauserverclient/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks
CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50

DELAY_SLEEP_SECONDS = 10
DELAY_SLEEP_SECONDS = 0.1

# The maximum size of a file that can be published in a single request is 64MB
FILESIZE_LIMIT_MB = 64
17 changes: 17 additions & 0 deletions tableauserverclient/helpers/headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from copy import deepcopy
from urllib.parse import unquote_plus


def fix_filename(params):
if "filename*" not in params:
return params

params = deepcopy(params)
filename = params["filename*"]
prefix = "UTF-8''"
if filename.startswith(prefix):
filename = filename[len(prefix) :]

params["filename"] = unquote_plus(filename)
del params["filename*"]
return params
2 changes: 0 additions & 2 deletions tableauserverclient/helpers/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
T = TypeVar("T", str, bytes)


# usage: _redact_any_type("<xml workbook password= cooliothesecond>")
# -> b"<xml workbook password =***************">
def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T:
try:
root = fromstring(xml)
Expand Down
124 changes: 95 additions & 29 deletions tableauserverclient/models/interval_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}>"
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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}>"
Expand All @@ -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)]
3 changes: 0 additions & 3 deletions tableauserverclient/models/project_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,6 @@ def _set_default_permissions(self, permissions, content_type):
attr,
permissions,
)
fetch_call = getattr(self, attr)
logging.getLogger().info({"type": attr, "value": fetch_call()})
return fetch_call()

@classmethod
def from_response(cls, resp, ns) -> List["ProjectItem"]:
Expand Down
36 changes: 27 additions & 9 deletions tableauserverclient/models/schedule_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
34 changes: 18 additions & 16 deletions tableauserverclient/models/task_item.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from datetime import datetime
from typing import List, Optional

from defusedxml.ElementTree import fromstring

from tableauserverclient.datetime_helpers import parse_datetime
from .schedule_item import ScheduleItem
from .target import Target
from tableauserverclient.models.schedule_item import ScheduleItem
from tableauserverclient.models.target import Target


class TaskItem(object):
Expand All @@ -19,14 +22,14 @@ class Type:

def __init__(
self,
id_,
task_type,
priority,
consecutive_failed_count=0,
schedule_id=None,
schedule_item=None,
last_run_at=None,
target=None,
id_: str,
task_type: str,
priority: int,
consecutive_failed_count: int = 0,
schedule_id: Optional[str] = None,
schedule_item: Optional[ScheduleItem] = None,
last_run_at: Optional[datetime] = None,
target: Optional[Target] = None,
):
self.id = id_
self.task_type = task_type
Expand All @@ -37,14 +40,14 @@ def __init__(
self.last_run_at = last_run_at
self.target = target

def __repr__(self):
def __repr__(self) -> str:
return (
"<Task#{id} {task_type} pri({priority}) failed({consecutive_failed_count}) schedule_id({"
"schedule_id}) target({target})>".format(**self.__dict__)
)

@classmethod
def from_response(cls, xml, ns, task_type=Type.ExtractRefresh):
def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]:
parsed_response = fromstring(xml)
all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns)

Expand All @@ -62,8 +65,7 @@ def _parse_element(cls, element, ns):
last_run_at_element = element.find(".//t:lastRunAt", namespaces=ns)

schedule_item_list = ScheduleItem.from_element(element, ns)
if len(schedule_item_list) >= 1:
schedule_item = schedule_item_list[0]
schedule_item = next(iter(schedule_item_list), None)

# according to the Tableau Server REST API documentation,
# there should be only one of workbook or datasource
Expand All @@ -87,14 +89,14 @@ def _parse_element(cls, element, ns):
task_type,
priority,
consecutive_failed_count,
schedule_item.id,
schedule_item.id if schedule_item is not None else None,
schedule_item,
last_run_at,
target,
)

@staticmethod
def _translate_task_type(task_type):
def _translate_task_type(task_type: str) -> str:
if task_type in TaskItem._TASK_TYPE_MAPPING:
return TaskItem._TASK_TYPE_MAPPING[task_type]
else:
Expand Down
3 changes: 3 additions & 0 deletions tableauserverclient/server/endpoint/datasources_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from pathlib import Path
from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union

from tableauserverclient.helpers.headers import fix_filename

if TYPE_CHECKING:
from tableauserverclient.server import Server
from tableauserverclient.models import PermissionsRule
Expand Down Expand Up @@ -441,6 +443,7 @@ def download_revision(
filepath.write(chunk)
return_path = filepath
else:
params = fix_filename(params)
filename = to_filename(os.path.basename(params["filename"]))
download_path = make_download_path(filepath, filename)
with open(download_path, "wb") as f:
Expand Down
Loading

0 comments on commit 0e46e15

Please sign in to comment.