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

promote code for 0.29 #1331

Merged
merged 38 commits into from
Jan 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3fefcfa
Fix for #1301 of duplicate default permission requests
gconklin Oct 19, 2023
25a59d0
Fixed type annotation for workbook.refresh
pes-magic Nov 13, 2023
400c966
Merge pull request #1319 from pes-magic/issue-1318
jacalata Nov 16, 2023
5b73beb
Remove comment with fake password that was causing confusion
bcantoni Nov 18, 2023
b7d4729
Merge pull request #1323 from tableau/clean-up-comments
jacalata Nov 20, 2023
082cec0
Add all missing fields
jorwoods Oct 26, 2023
613334b
Make imports absolute
jorwoods Oct 15, 2023
2082414
Add types to TaskItem
jorwoods Oct 15, 2023
0a720e9
Make Tasks endpoint imports absolute
jorwoods Oct 15, 2023
cdbaf98
Add task test asset
jorwoods Oct 15, 2023
e65ca39
More typing of TaskItem
jorwoods Oct 15, 2023
600a0b7
Permit missing tasks missing schedule
jorwoods Oct 15, 2023
b44d69e
Fix import references
jorwoods Oct 15, 2023
f428031
Add test for missing schedule
jorwoods Oct 15, 2023
95d6697
Formatting
jorwoods Oct 15, 2023
82ff83a
Add type annotations
jorwoods Oct 15, 2023
36a5547
Permit creation of tasks without schedules
jorwoods Oct 15, 2023
11656c4
Fix logging format
jorwoods Oct 16, 2023
246b449
issue-1299 set empty async response to None
kykrueger Oct 19, 2023
88d4614
issue-1299 remove unused import
kykrueger Oct 19, 2023
3ff3131
issue-1299 fix timeout missed when longer than 60s
kykrueger Oct 19, 2023
f7d60f9
issue-1299 paint it black
kykrueger Oct 20, 2023
538324e
issue-1299 raise exception when returned from blocking request
kykrueger Oct 25, 2023
5653a3e
issue-1299 black line length 120
kykrueger Nov 15, 2023
b5f5caa
Merge pull request #1307 from jorwoods/jorwoods/req_opts_fields
jacalata Nov 29, 2023
1f9088f
fix: correct type hint on download_revision revision_number
jorwoods Dec 2, 2023
f42948a
fix: handle filename* in download response
jorwoods Dec 10, 2023
76559d4
style: black formatting
jorwoods Dec 10, 2023
19a9f51
fix: strip typing from fix_filename
jorwoods Dec 10, 2023
f17a75d
add support for multiple intervals for hourly, daily, and monthly sch…
a-torres-2 Dec 13, 2023
e8c9e0a
Merge branch 'master' into schedules-multiple-intervals
jacalata Jan 4, 2024
666fcd8
Merge pull request #1330 from jorwoods/jorwoods/download_hotfix
jacalata Jan 4, 2024
e72552d
Merge pull request #1328 from a-torres-2/schedules-multiple-intervals
jacalata Jan 4, 2024
696f20d
Merge pull request #1297 from jorwoods/jorwoods/tasks_no_schedule
jacalata Jan 4, 2024
e0581ce
Merge branch 'development' into issue-1299
jacalata Jan 4, 2024
52d7f7c
Merge pull request #1300 from kykrueger/issue-1299
jacalata Jan 4, 2024
95755be
Merge branch 'development' into 1301-fix-duplicate-permission-requests
jacalata Jan 6, 2024
d79ca4c
Merge pull request #1302 from gconklin/1301-fix-duplicate-permission-…
jacalata Jan 6, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading