Skip to content

Commit d055803

Browse files
authored
Merge branch 'tableau:development' into development
2 parents 10afcc2 + f76d9f3 commit d055803

10 files changed

Lines changed: 276 additions & 44 deletions

File tree

.github/dependabot.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@ updates:
33
- package-ecosystem: "pip"
44
directory: "/"
55
target-branch: "development"
6+
# Security advisories fire independently of the weekly schedule as long
7+
# as the repo has "Dependabot security updates" enabled under
8+
# Settings -> Code security. Weekly = non-security updates.
69
schedule:
710
interval: "weekly"
11+
open-pull-requests-limit: 10
812

913
- package-ecosystem: "github-actions"
1014
directory: "/"
1115
target-branch: "development"
1216
schedule:
1317
interval: "weekly"
18+
open-pull-requests-limit: 10
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: Mark linked issues in-progress
2+
3+
# When an open PR references an issue with Closes/Fixes/Resolves, apply the
4+
# 'in-progress' label to that issue so the stale bot leaves it alone (the
5+
# stale workflow already exempts 'in-progress'). Remove the label when the
6+
# PR closes without merging, so a genuinely abandoned PR does not keep its
7+
# referenced issues shielded forever.
8+
#
9+
# actions/stale looks at issue-level events only; a PR that references an
10+
# issue does not reset the issue's stale timer or move it off the stale
11+
# label. This workflow bridges that gap.
12+
#
13+
# Security note: the script only reads pr.body, extracts decimal issue
14+
# numbers via a fixed regex, and passes those numbers to the REST API.
15+
# Body content is never expanded into a run: command or a shell.
16+
17+
on:
18+
pull_request_target:
19+
types: [opened, edited, reopened, synchronize, ready_for_review, closed]
20+
21+
permissions:
22+
issues: write
23+
pull-requests: read
24+
25+
jobs:
26+
link:
27+
runs-on: ubuntu-latest
28+
steps:
29+
- uses: actions/github-script@v7
30+
with:
31+
script: |
32+
const pr = context.payload.pull_request;
33+
const body = pr.body || '';
34+
const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi;
35+
// Cross-repo references (owner/repo#123) are intentionally skipped;
36+
// this workflow only labels issues in the current repo.
37+
const MAX_REFS = 50; // cap runaway PR bodies from forks (pull_request_target).
38+
const extract = (text) => [...new Set(
39+
[...(text || '').matchAll(re)].map(m => Number(m[1]))
40+
)].slice(0, MAX_REFS);
41+
42+
const current = extract(body);
43+
44+
// On `edited`, compute which references were REMOVED so their
45+
// labels come off. Without this, a PR that once said "Closes #42"
46+
// and no longer does would leave #42 shielded indefinitely.
47+
let removed = [];
48+
if (context.payload.action === 'edited') {
49+
const prevBody = context.payload.changes?.body?.from;
50+
if (prevBody !== undefined) {
51+
const previous = extract(prevBody);
52+
const now = new Set(current);
53+
removed = previous.filter(n => !now.has(n));
54+
}
55+
}
56+
57+
// Apply the label while the PR is open. Remove it when the PR
58+
// closes without merging (merged PRs also close, but the issue
59+
// will be auto-closed by GitHub once the merge lands, so the
60+
// in-progress label on it is harmless). Also remove on `edited`
61+
// when a reference was deleted from the body.
62+
const shouldLabel = pr.state === 'open';
63+
const closeUnlabel = pr.state === 'closed' && !pr.merged ? current : [];
64+
const toUnlabel = [...new Set([...removed, ...closeUnlabel])];
65+
66+
if (current.length === 0 && toUnlabel.length === 0) {
67+
core.info('No Closes/Fixes/Resolves references to process; nothing to do.');
68+
return;
69+
}
70+
71+
const removeLabelSafe = async (n) => {
72+
await github.rest.issues.removeLabel({
73+
...context.repo,
74+
issue_number: n,
75+
name: 'in-progress',
76+
}).catch(err => {
77+
// 404 just means the label was not present; not an error.
78+
if (err.status !== 404) throw err;
79+
});
80+
core.info(`#${n}: removed in-progress`);
81+
};
82+
83+
if (shouldLabel) {
84+
for (const n of current) {
85+
try {
86+
await github.rest.issues.addLabels({
87+
...context.repo,
88+
issue_number: n,
89+
labels: ['in-progress'],
90+
});
91+
core.info(`#${n}: added in-progress`);
92+
} catch (err) {
93+
core.warning(`#${n}: ${err.message}`);
94+
}
95+
}
96+
}
97+
98+
for (const n of toUnlabel) {
99+
try {
100+
await removeLabelSafe(n);
101+
} catch (err) {
102+
core.warning(`#${n}: ${err.message}`);
103+
}
104+
}

.github/workflows/stale.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
with:
1919
stale-issue-label: 'stale'
2020
stale-pr-label: 'stale'
21-
exempt-issue-labels: 'good first issue,Design Proposal,in-progress'
21+
exempt-issue-labels: 'good first issue,Design Proposal,in-progress,Server-Side Enhancement'
2222
exempt-pr-labels: 'in-progress'
2323

2424
days-before-issue-stale: 60

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11

22
## Unreleased
33

4+
* Bumped the urllib3 floor to 2.6.3 to pick up the fix for CVE-2026-21441
5+
(GHSA-38jv-5279-wg99, 8.9 High): urllib3's streaming decompression
6+
safeguards were bypassed when HTTP redirects were followed. TSC's manual
7+
redirect walker (#1848) disables urllib3's built-in follower on new code
8+
paths, but downstream callers using urllib3 directly (and TSC endpoints
9+
that predate #1848) still relied on the built-in path, so the floor bump
10+
closes the gap for all callers. The existing `<3` upper bound is unchanged.
411
* Added `Projects.get_by_path(path)` to look up a project by its slash-separated
512
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
613
level using the REST API name filter, so a path with *n* components issues *n*

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
'defusedxml>=0.7.1', # latest as at 7/31/23
1717
'packaging>=23.1', # latest as at 7/31/23
1818
'requests>=2.32', # latest as at 7/31/23
19-
'urllib3>=2.6.0,<3',
19+
'urllib3>=2.6.3,<3',
2020
'typing_extensions>=4.0',
2121
]
2222
requires-python = ">=3.10"

tableauserverclient/models/user_item.py

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import io
2+
import warnings
23
import xml.etree.ElementTree as ET
34
from datetime import datetime
45
from enum import IntEnum
@@ -476,12 +477,23 @@ def create_user_from_line(line: str):
476477
)
477478
raw_auth = values[UserItem.CSVImport.ColumnType.AUTH]
478479
if raw_auth:
479-
auth = UserItem.CSVImport._AUTH_CANONICAL.get(raw_auth.lower())
480-
if auth is None:
481-
raise ValueError(
482-
f"Unknown auth setting: {raw_auth!r}. "
483-
f"Valid values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}"
480+
canonical = UserItem.CSVImport._AUTH_CANONICAL.get(raw_auth.lower())
481+
if canonical is None:
482+
# Unknown auth value: pass it through instead of raising.
483+
# TSC's _AUTH_CANONICAL is a hardcoded list that will lag
484+
# server-side additions; refusing to build the UserItem
485+
# here would block CSV imports against newer servers as
486+
# soon as Tableau ships a new auth type. If it is a
487+
# typo, the server rejects the row when the request
488+
# posts. Warn so the caller has a shot at noticing.
489+
warnings.warn(
490+
f"Unknown auth setting {raw_auth!r}; passing through unchanged. "
491+
f"Known values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}",
492+
stacklevel=2,
484493
)
494+
auth = raw_auth
495+
else:
496+
auth = canonical
485497
else:
486498
auth = None
487499
user._set_values(
@@ -546,14 +558,33 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
546558
for i in range(1, len(line)):
547559
value = line[i]
548560
valid = _valid_attributes[i]
561+
column = UserItem.CSVImport.ColumnType(i)
549562
# normalize case for fields with a restricted value set
563+
skip_validation = False
550564
if valid:
551565
if i == UserItem.CSVImport.ColumnType.AUTH:
552-
value = UserItem.CSVImport._AUTH_CANONICAL.get(value.lower(), value)
566+
canonical = UserItem.CSVImport._AUTH_CANONICAL.get(value.lower())
567+
if canonical is not None:
568+
value = canonical
569+
elif value:
570+
# Unknown auth value: warn and pass through instead
571+
# of raising. TSC's _AUTH_CANONICAL is a hardcoded
572+
# list that lags server-side additions; refusing
573+
# would block CSV imports against newer servers as
574+
# soon as Tableau ships a new auth type. Skip the
575+
# allowlist check so the row still validates.
576+
# Matches create_user_from_line's warn-and-pass.
577+
warnings.warn(
578+
f"Unknown auth setting {value!r}; passing through unchanged. "
579+
f"Known values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}",
580+
stacklevel=2,
581+
)
582+
skip_validation = True
553583
else:
554584
value = value.lower()
555-
logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {value}")
556-
UserItem.CSVImport._validate_attribute_value(value, valid, UserItem.CSVImport.ColumnType(i))
585+
logger.debug(f"column {column.name}: {value}")
586+
if not skip_validation:
587+
UserItem.CSVImport._validate_attribute_value(value, valid, column)
557588

558589
# Given a restricted set of possible values, confirm the item is in that set
559590
@staticmethod
@@ -565,6 +596,49 @@ def _validate_attribute_value(item: str, possible_values: list[str], column_type
565596
return
566597
raise ValueError(f"Invalid value {item} for {column_type}")
567598

599+
# Inverse of _evaluate_site_role: decompose a site role back to (license, admin_level, publish)
600+
# for writing the CSV import format.
601+
@staticmethod
602+
def _decompose_site_role(site_role: str) -> tuple[str, str, str]:
603+
"""Return (license, admin_level, publish) CSV column values for a given site role.
604+
605+
Legacy `UserItem.Roles` values are handled in two ways depending on whether
606+
the server has a sensible modern equivalent:
607+
608+
- **Mapped to modern equivalents** (row emitted, server accepts): the legacy
609+
roles `SiteAdministrator`, `Publisher`, `Interactor`, and `ReadOnly` each
610+
map to the current-model role that best matches their historical intent
611+
(SiteAdministratorExplorer, ExplorerCanPublish, Explorer, Viewer).
612+
- **Emitted as `license="Invalid"`** (row rejected server-side with
613+
USER_CSV_INVALID_LICENSE): the legacy roles `UnlicensedWithPublish`,
614+
`ViewerWithPublish`, `Guest`, and `SupportUser` have no equivalent in the
615+
current server model (`RestApiSiteRole` does not accept them on any code
616+
path). Emitting `"Invalid"` preserves the per-row error semantics callers
617+
of `bulk_add` had before this refactor, rather than silently coercing
618+
those users to a valid-but-wrong Unlicensed account.
619+
620+
Round-trip note: `_evaluate_site_role(*_decompose_site_role(r)) == r` for
621+
every current-model role. Two label asymmetries: `ServerAdministrator`
622+
round-trips through the legacy label `SiteAdministrator` (that's the only
623+
label `_evaluate_site_role` emits for `admin="System"`), and the legacy
624+
roles above are folded into their modern equivalents by design.
625+
"""
626+
_role_map: dict[str, tuple[str, str, str]] = {
627+
"ServerAdministrator": ("Creator", "System", "1"),
628+
"SiteAdministratorCreator": ("Creator", "Site", "1"),
629+
"SiteAdministratorExplorer": ("Explorer", "Site", "1"),
630+
"SiteAdministrator": ("Explorer", "Site", "1"), # legacy, mapped to SiteAdministratorExplorer
631+
"Creator": ("Creator", "None", "1"),
632+
"ExplorerCanPublish": ("Explorer", "None", "1"),
633+
"Explorer": ("Explorer", "None", "0"),
634+
"Viewer": ("Viewer", "None", "0"),
635+
"Unlicensed": ("Unlicensed", "None", "0"),
636+
"ReadOnly": ("Viewer", "None", "0"), # legacy, mapped to Viewer
637+
"Publisher": ("Explorer", "None", "1"), # legacy, mapped to ExplorerCanPublish
638+
"Interactor": ("Explorer", "None", "0"), # legacy, mapped to Explorer
639+
}
640+
return _role_map.get(site_role, ("Invalid", "None", "0"))
641+
568642
# https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles
569643
# This logic is hardcoded to match the existing rules for import csv files
570644
@staticmethod
@@ -586,14 +660,14 @@ def _evaluate_site_role(license_level, admin_level, publisher):
586660
else:
587661
site_role = "SiteAdministratorExplorer"
588662
else: # if it wasn't 'system' or 'site' then we can treat it as 'none'
589-
if publisher == "yes":
663+
if publisher in ("yes", "true", "1"):
590664
if license_level == "creator":
591665
site_role = "Creator"
592666
elif license_level == "explorer":
593667
site_role = "ExplorerCanPublish"
594668
else:
595669
site_role = "Unlicensed" # is this the expected outcome?
596-
else: # publisher == 'no':
670+
else: # publisher is "no" / "false" / "0" / any other value:
597671
if license_level == "explorer" or license_level == "creator":
598672
site_role = "Explorer"
599673
elif license_level == "viewer":

tableauserverclient/server/endpoint/endpoint.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,9 @@ def fields(self: Self, *fields: str) -> QuerySet:
554554
queryset.request_options.fields |= set(fields) | set(("_default_",))
555555
return queryset
556556

557+
def find_by_name(self, name: str) -> list[T]:
558+
return list(self.filter(name=name))
559+
557560
def only_fields(self: Self, *fields: str) -> QuerySet:
558561
"""
559562
Add fields to the request options. If no fields are provided, the

tableauserverclient/server/endpoint/users_endpoint.py

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us
527527
warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning)
528528
created = []
529529
failed = []
530-
if not filepath.find("csv"):
530+
if "csv" not in filepath:
531531
raise ValueError("Only csv files are accepted")
532532

533533
with open(filepath) as csv_file:
@@ -536,11 +536,9 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us
536536
while line and line != "":
537537
user: UserItem = UserItem.CSVImport.create_user_from_line(line)
538538
try:
539-
print(user)
540539
result = self.add(user)
541540
created.append(result)
542541
except ServerResponseError as serverError:
543-
print("failed")
544542
failed.append((user, serverError))
545543
line = csv_file.readline()
546544
return created, failed
@@ -751,6 +749,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
751749
- Admin Level
752750
- Publish capability
753751
- Email
752+
- Auth setting
754753
755754
Parameters
756755
----------
@@ -765,22 +764,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
765764
with io.StringIO() as output:
766765
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
767766
for user in users:
768-
site_role = user.site_role or "Unlicensed"
769-
if site_role == "ServerAdministrator":
770-
license = "Creator"
771-
admin_level = "System"
772-
elif site_role.startswith("SiteAdministrator"):
773-
admin_level = "Site"
774-
license = site_role.replace("SiteAdministrator", "")
775-
else:
776-
license = site_role
777-
admin_level = ""
778-
779-
if any(x in site_role for x in ("Creator", "Admin", "Publish")):
780-
publish = 1
781-
else:
782-
publish = 0
783-
767+
license, admin_level, publish = UserItem.CSVImport._decompose_site_role(user.site_role or "Unlicensed")
784768
writer.writerow(
785769
(
786770
f"{user.domain_name}\\{user.name}" if user.domain_name else user.name,
@@ -790,6 +774,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
790774
admin_level,
791775
publish,
792776
user.email,
777+
user.auth_setting or "",
793778
)
794779
)
795780
output.seek(0)

test/test_user.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ def test_create_users_csv() -> None:
405405
"ServerAdministrator": "System",
406406
}
407407

408-
csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"]
408+
csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email", "auth"]
409409
csv_data = create_users_csv(users)
410410
csv_file = io.StringIO(csv_data.decode("utf-8"))
411411
csv_reader = csv.reader(csv_file)
@@ -417,8 +417,23 @@ def test_create_users_csv() -> None:
417417
assert (user.fullname or "") == csv_user["fullname"]
418418
assert (user.email or "") == csv_user["email"]
419419
assert license_map[site_role] == csv_user["license"]
420-
assert admin_map.get(site_role, "") == csv_user["admin"]
420+
assert admin_map.get(site_role, "None") == csv_user["admin"]
421421
assert publish_map[site_role] == int(csv_user["publish"])
422+
assert (user.auth_setting or "") == csv_user["auth"]
423+
424+
425+
def test_decompose_unsupported_role_emits_invalid_license() -> None:
426+
# UnlicensedWithPublish and ViewerWithPublish are in UserItem.Roles for
427+
# historical reasons but the server-side CSV license parser has never
428+
# accepted them. _decompose_site_role emits license="Invalid" for these
429+
# (and any other unmapped role) so the server rejects the row with
430+
# USER_CSV_INVALID_LICENSE, preserving the per-row error semantics
431+
# callers of bulk_add had before this refactor.
432+
for role in ("UnlicensedWithPublish", "ViewerWithPublish", "Guest", "SupportUser"):
433+
license, admin, publish = TSC.UserItem.CSVImport._decompose_site_role(role)
434+
assert license == "Invalid"
435+
assert admin == "None"
436+
assert publish == "0"
422437

423438

424439
def test_bulk_add(server: TSC.Server) -> None:

0 commit comments

Comments
 (0)