Skip to content

[tabcmd] refactor: extract CSVImport._decompose_site_role, inverse of _evaluate_site_role - #1812

Open
jacalata wants to merge 10 commits into
developmentfrom
jac/csv-site-role-decompose
Open

[tabcmd] refactor: extract CSVImport._decompose_site_role, inverse of _evaluate_site_role#1812
jacalata wants to merge 10 commits into
developmentfrom
jac/csv-site-role-decompose

Conversation

@jacalata

@jacalata jacalata commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Draft while stacked on #1811. Marked ready for review once #1811 merges.

Motivation

create_users_csv had an ad-hoc if/elif block that mapped site roles
back to CSV columns. It's the inverse of the existing
UserItem.CSVImport._evaluate_site_role, but sat in a different module
where nobody looking at the encoder would find the decoder. Moving it
into CSVImport puts both directions in one place.

Also picked up three pre-existing bugs in the decomposition path along
the way (see Behavior change).

Behavior change

create_users_csv now emits three fields correctly that it previously
emitted wrongly:

Site role Before After
ExplorerCanPublish license="ExplorerCanPublish" (not a valid CSV license value) ("Explorer", "None", "1")
SiteAdministrator (legacy) license="" via str.replace(...) ("Explorer", "Site", "1")
Non-admin roles admin_level="" admin_level="None" (explicit CSV spec value)

The "" -> "None" change is cosmetic on the server side (both
accepted) but is what _evaluate_site_role expects on the input side,
so a round-trip through the CSV now composes cleanly.

Unknown-auth handling (post-#1811 rebase adjustment):
create_user_from_line and _validate_import_line_or_throw now
warnings.warn(UserWarning) on an auth value that isn't in
_AUTH_CANONICAL and pass the raw value through, rather than raising
ValueError. TSC's hardcoded canonical set lags server-side auth-type
additions; refusing would block CSV imports against newer servers as
soon as Tableau ships a new auth type. If the value really is a typo,
the server rejects the row when the request posts. Same category of
stale-list problem the _set_values enum-guard bypass exists to
avoid on the server-parse path.

Test plan

  • pytest test/test_user.py test/test_user_model.py — 63 passed
  • Full suite passes
  • test_create_users_csv updated for the "None" and auth-column
    assertions
  • Round-trip test _evaluate_site_role(*_decompose_site_role(role)) == role
    across every current-model role -- added as
    test_decompose_then_evaluate_round_trips parametrized over the
    full role set (with the two documented asymmetries called out
    explicitly: ServerAdministrator -> SiteAdministrator label,
    legacy-role fold to modern equivalents)
  • Warn-on-unknown-auth: test_create_user_with_unknown_auth_passes_through_with_warning
    and test_validate_import_line_warns_on_unknown_auth

🤖 Generated with Claude Code

@bcantoni

Copy link
Copy Markdown
Contributor

Correctness: silent behavior change for several Roles values

The new _role_map lookup table doesn't faithfully preserve the old if/elif logic for every UserItem.Roles value. Diffing old vs. new decomposition for each role constant:

Role Old CSV output (license, admin, publish) New CSV output
Interactor ("Interactor", "", 1) ("Explorer", "None", "0")
Publisher ("Publisher", "", 1) ("Explorer", "None", "1")
ReadOnly ("ReadOnly", "", 0) ("Viewer", "None", "0")
UnlicensedWithPublish ("UnlicensedWithPublish", "", 1) ("Unlicensed", "None", "0")falls into the .get() default, silently drops publish
ViewerWithPublish ("ViewerWithPublish", "", 1) ("Unlicensed", "None", "0")same silent drop
Guest ("Guest", "", 0) ("Unlicensed", "None", "0")
SupportUser ("SupportUser", "", 0) ("Unlicensed", "None", "0")

Some of these (Interactor, Publisher, ReadOnly) look like reasonable improvements — their old outputs already emitted invalid license strings, so mapping them to real license values is a fix. But UnlicensedWithPublish and ViewerWithPublish look like a real regression: the old code at least preserved publish=1 even though license was garbage. The new _role_map.get(..., default) silently coerces both to Unlicensed/publish=0. Any caller using site_role = UserItem.Roles.ViewerWithPublish will now get a user provisioned as fully unlicensed instead of a licensed publisher, with no error or warning.

Suggestion: add explicit entries for Guest, SupportUser, UnlicensedWithPublish, and ViewerWithPublish to _role_map (matching whatever the CSV import spec actually expects for these), or if they're genuinely unsupported by CSV import, raise/log rather than silently defaulting to Unlicensed. At minimum, worth calling out in the PR description that these four roles' behavior is changing — two of them silently and detrimentally.

@bcantoni

Copy link
Copy Markdown
Contributor

@jacalata take a look at the consistency check that claude called out - does that need to be fixed/improved?

…servation; add find_by_name

Fixes for UserItem.CSVImport (issue #1809):
- MAX=8 (was 7=AUTH index): 8-column lines with auth type no longer
  rejected as "too many columns"
- create_user_from_line no longer lowercases the whole line before
  splitting — username case is preserved
- _validate_import_line_or_throw normalizes license/admin/publisher to
  lowercase and auth to canonical form before comparison, so 'Viewer',
  'Creator', 'SAML', 'tableauidwithmfa' etc. are all accepted
- Add TableauIDWithMFA to valid auth values in validation (was missing)
- 5 new tests covering each fix

Add QuerysetEndpoint.find_by_name(name) (issue #1810):
- Thin wrapper over .filter(name=name) returning a list
- Available on all content-item endpoints (workbooks, datasources,
  views, users, projects, groups)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jacalata
jacalata force-pushed the jac/csv-import-fixes-and-find-by-name branch from d3376c9 to 948e382 Compare July 28, 2026 01:01
jacalata and others added 2 commits July 27, 2026 18:01
…ion check and debug prints

- create_users_csv was producing 7-column CSV, silently dropping
  auth_setting; bulk_add roundtrip would lose auth type
- create_from_file extension check used filepath.find("csv") which
  evaluates as falsy only when "csv" is at index 0, letting all other
  paths through; fixed to "csv" not in filepath
- remove two debug print() calls left in create_from_file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…th _evaluate_site_role

Moves the ad-hoc site role → (license, admin_level, publish) logic from
create_users_csv into UserItem.CSVImport._decompose_site_role, making it
the explicit inverse of _evaluate_site_role.

Also fixes pre-existing bugs in the decomposition:
- ExplorerCanPublish was emitted as license="ExplorerCanPublish" (not a
  valid CSV license value); now correctly "Explorer" with publish=1
- SiteAdministrator (legacy role) was emitting license="" via
  str.replace; now maps to ("Explorer", "Site", "1")
- Non-admin roles now emit admin_level="None" (explicit CSV spec value)
  rather than "" (empty string); both are accepted by the server but
  "None" is consistent with the spec and _evaluate_site_role input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jacalata
jacalata force-pushed the jac/csv-site-role-decompose branch from 6402e1d to 7d4522d Compare July 28, 2026 01:01
…ensed"

Legacy values in UserItem.Roles (UnlicensedWithPublish, ViewerWithPublish,
Guest, SupportUser) have never been accepted by the server-side CSV license
parser (workgroup: CsvLicenseRoleTypeConverter). The initial _decompose_site_role
default of ("Unlicensed", "None", "0") silently coerced these to a valid but
semantically wrong Unlicensed user, replacing an old server-side per-row
rejection with silent success.

Default to license="Invalid" instead so the server continues to reject rows
with USER_CSV_INVALID_LICENSE, preserving the pre-refactor observable
behavior for callers who inspect job results for per-row failures. Batch
resilience is unaffected: bad rows fail, good rows succeed, no client-side
throw takes down the whole bulk_add call.

Follow-up: deprecate UnlicensedWithPublish/ViewerWithPublish from
UserItem.Roles (never worked on any code path); see forthcoming issue.
@jacalata

jacalata commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Good question. Several of the old values were actually broken already, and I was under the impression that the server was returning a new Unlicensed user. However it turns out it was throwing an error and not creating an account at all, so Claude was correct that we were changing from an error to a silent not-quite-as-expected success.

so, the old behavior: The server's parser (CsvLicenseRoleTypeConverter) accepts only creator / interactor / explorer / viewer / unlicensed / empty. Anything else throws USER_CSV_INVALID_LICENSE and the row is rejected.
However the roles 'Guest' and 'SupportUser' were accepted when calling the individual users.add method, just not in CSVs: that's a server-side asymmetry between the two endpoints, not a TSC bug. They will still work in users.add. We might try and clean that up as part of future work.

Fix: unmapped roles now emit license="Invalid", which the server rejects with the same USER_CSV_INVALID_LICENSE error you got before. Per-row failure semantics preserved for bulk_add callers who inspect the job result; batch resilience unaffected (bad rows fail, good rows succeed, no client-side throw takes down the whole call).

The legacy Roles values (UnlicensedWithPublish, ViewerWithPublish) didn't work at all. They've been in UserItem.Roles since the first commit of the library in 2016 and appear to have been fossilized dead code since Tableau Server moved off the old license model. Filed #1834 to formally deprecate them.

@jacalata
jacalata marked this pull request as draft July 28, 2026 07:19
The role map handles legacy UserItem.Roles values in two ways depending
on whether the server has a modern equivalent. SiteAdministrator,
Publisher, Interactor, and ReadOnly map to their current-model
equivalents; UnlicensedWithPublish, ViewerWithPublish, Guest, and
SupportUser fall to license="Invalid" so the server rejects the row.

Reviewers otherwise read the code and see "why does Publisher get
mapped but Guest doesn't?" - the answer is server behavior, not
arbitrary choice. Docstring now says so.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jacalata
jacalata requested a lite review from Copilot August 6, 2026 21:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR updates user CSV generation/import helpers to support an additional “auth” column and centralizes site-role-to-CSV column derivation into a shared helper, with corresponding test updates.

Changes:

  • Add auth column to create_users_csv() output and update tests accordingly.
  • Refactor site-role decomposition logic into UserItem.CSVImport._decompose_site_role().
  • Fix create_from_file() CSV file validation and remove debug prints.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
test/test_user.py Updates CSV expectations and adds coverage for unsupported legacy roles producing license="Invalid".
tableauserverclient/server/endpoint/users_endpoint.py Updates CSV validation logic and extends generated CSV rows with auth setting.
tableauserverclient/models/user_item.py Introduces _decompose_site_role() mapping for consistent CSV export semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tableauserverclient/models/user_item.py
Comment thread tableauserverclient/server/endpoint/users_endpoint.py
@jacalata jacalata changed the title refactor: extract CSVImport._decompose_site_role, inverse of _evaluate_site_role [tabcmd] refactor: extract CSVImport._decompose_site_role, inverse of _evaluate_site_role Aug 11, 2026
…test

Two related changes to make `_evaluate_site_role` and
`_decompose_site_role` a real inverse pair:

- `_evaluate_site_role` now accepts "1", "true", and "yes" for the
  publisher column (and, symmetrically, treats anything else as "no").
  Previously only "yes" was accepted, which meant `_decompose_site_role`
  emitting "1" for Creator/ExplorerCanPublish would round-trip as
  Explorer. The set of publisher values mirrors what
  `_valid_attributes[publisher]` already documents as legal
  (["yes", "true", "1", "no", "false", "0"]), so this brings the two
  code paths in agreement.

- Added a parametrized `test_decompose_then_evaluate_round_trips`
  covering every entry in `_role_map` plus the two documented label
  asymmetries (ServerAdministrator -> SiteAdministrator on the way
  back; legacy roles Publisher/Interactor/ReadOnly/SiteAdministrator
  folded into their modern equivalents). If either function drifts,
  a specific input names the broken case rather than the whole loop
  dying on the first mismatch.

- `_decompose_site_role` docstring updated with a Round-trip note
  naming the two intentional asymmetries so readers do not have to
  reason about them from the mapping table.

Full suite still passes: 884 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Base automatically changed from jac/csv-import-fixes-and-find-by-name to development August 20, 2026 02:19
- Strip five non-ASCII characters (arrow + em-dashes) from docstrings
  and comments; project convention is ASCII-only.
- create_user_from_line now passes an unknown auth value through
  unchanged (previously silently .get'd to None). _validate_import_
  line_or_throw on the same input raises, so the two entry points
  no longer disagree on what counts as valid.

Not addressed here (per fresh-eyes review, non-blocking):
- Deprecated create_from_file's "csv" substring check remains loose
  (matches report.csv.bak, rejects USERS.CSV); pre-existing on a
  @deprecated method, out of scope for this refactor.
- PR body test-plan checkbox that's actually complete: tick before
  un-drafting.

59 user tests pass.
…ole-decompose

# Conflicts:
#	tableauserverclient/models/user_item.py
#	test/test_user_model.py
Post-merge follow-up on #1811 -- the strict behavior it introduced
(create_user_from_line raises ValueError on any auth value not in
_AUTH_CANONICAL) would block CSV imports against newer servers as
soon as Tableau ships an auth type TSC's hardcoded list doesn't yet
know about. This is the same category of stale-list problem the
_set_values enum-guard bypass exists to avoid on the server-parse
path.

Both entry points now warn and pass the value through:

- create_user_from_line: unknown values raise a UserWarning naming
  the value and known set, then get assigned to auth_setting as-is.
  If the value really is a typo, the server rejects the row when
  the request posts -- a slightly-later error, but the import stays
  possible against forward-compatible servers.

- _validate_import_line_or_throw: same shape. Skips the allowlist
  check for the AUTH column when the value isn't in _AUTH_CANONICAL,
  so validate_file_for_import doesn't return the row as invalid.

Server-version-aware validation would be the cleaner long-term fix
here (and for the enum-guard bypass in _set_values); noted for
planning, not filing an issue.

Tests updated: two former "raises ValueError" cases now assert
pytest.warns(UserWarning) and confirm the raw value round-trips
onto UserItem.auth_setting.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Coverage

Coverage Report
FileStmtsMissCoverMissing
tableauserverclient
   __init__.py50100% 
   config.py150100% 
   datetime_helpers.py2511 96%
   exponential_backoff.py200100% 
   filesys_helpers.py310100% 
   namespace.py2633 88%
tableauserverclient/bin
   __init__.py20100% 
   _version.py358212212 41%
tableauserverclient/helpers
   __init__.py10100% 
   logging.py20100% 
   strings.py3111 97%
tableauserverclient/models
   __init__.py460100% 
   collection_item.py4177 83%
   column_item.py553232 42%
   connection_credentials.py351111 69%
   connection_item.py941414 85%
   custom_view_item.py1442121 85%
   data_acceleration_report_item.py5411 98%
   data_alert_item.py15844 97%
   data_freshness_policy_item.py1551515 90%
   database_item.py2073636 83%
   datasource_item.py3001212 96%
   dqw_item.py10455 95%
   exceptions.py40100% 
   extensions_item.py13244 97%
   extract_item.py4444 91%
   favorites_item.py6988 88%
   fileupload_item.py190100% 
   flow_item.py1491010 93%
   flow_run_item.py710100% 
   group_item.py8966 93%
   groupset_item.py4977 86%
   interval_item.py1823232 82%
   job_item.py1921010 95%
   linked_tasks_item.py7911 99%
   location_item.py2922 93%
   metric_item.py1291313 90%
   oidc_item.py6333 95%
   pagination_item.py3411 97%
   permissions_item.py1111212 89%
   project_item.py2073131 85%
   property_decorators.py1001818 82%
   reference_item.py2622 92%
   revision_item.py5911 98%
   schedule_item.py20966 97%
   server_info_item.py3777 81%
   site_item.py6361313 98%
   subscription_item.py10122 98%
   table_item.py1191818 85%
   tableau_auth.py612525 59%
   tableau_types.py2711 96%
   tag_item.py150100% 
   target.py60100% 
   task_item.py5622 96%
   user_item.py3381717 95%
   view_item.py2201616 93%
   virtual_connection_item.py6488 88%
   webhook_item.py6911 99%
   workbook_item.py3621616 96%
tableauserverclient/server
   __init__.py90100% 
   exceptions.py40100% 
   filter.py2911 97%
   pager.py3311 97%
   query.py1431515 90%
   request_factory.py1335195195 85%
   request_options.py38655 99%
   server.py1882323 88%
   sort.py60100% 
tableauserverclient/server/endpoint
   __init__.py350100% 
   auth_endpoint.py731010 86%
   custom_views_endpoint.py1521212 92%
   data_acceleration_report_endpoint.py210100% 
   data_alert_endpoint.py942323 76%
   databases_endpoint.py1113030 73%
   datasources_endpoint.py3233333 90%
   default_permissions_endpoint.py4433 93%
   dqw_endpoint.py451616 64%
   endpoint.py2612525 90%
   exceptions.py7966 92%
   extensions_endpoint.py310100% 
   favorites_endpoint.py942222 77%
   fileuploads_endpoint.py510100% 
   flow_runs_endpoint.py6299 85%
   flow_task_endpoint.py2122 90%
   flows_endpoint.py1985353 73%
   groups_endpoint.py12699 93%
   groupsets_endpoint.py7277 90%
   jobs_endpoint.py6799 87%
   linked_tasks_endpoint.py370100% 
   metadata_endpoint.py881414 84%
   metrics_endpoint.py5566 89%
   oidc_endpoint.py4211 98%
   permissions_endpoint.py4433 93%
   projects_endpoint.py1782424 87%
   resource_tagger.py1273535 72%
   schedules_endpoint.py1191111 91%
   server_info_endpoint.py361010 72%
   sites_endpoint.py1302727 79%
   subscriptions_endpoint.py561414 75%
   tables_endpoint.py1103636 67%
   tasks_endpoint.py6366 90%
   users_endpoint.py17077 96%
   views_endpoint.py15099 94%
   virtual_connections_endpoint.py1131010 91%
   webhooks_endpoint.py5499 83%
   workbooks_endpoint.py3382222 93%
TOTAL12069142588% 

Inadvertently pulled the password-column mask
(safe_value = "***" if column == PASS else value) into this PR while
restructuring the validation loop for the warn-on-unknown-auth
change. The mask is #1862's core content and shouldn't sneak in
through the site-role decompose PR -- reviewers on either PR would
see mysterious overlap. #1862's full feature (log mask + INFO->DEBUG
downgrade + _redact_password_column helper for invalid_lines
sanitization) stays where it belongs, on jac/csv-import-privacy.

The `column = ColumnType(i)` local rename stays because it's used
by the log line's `{column.name}` format and by the AUTH branch's
comparison; that's plain cleanup and doesn't overlap with #1862.
@jacalata
jacalata marked this pull request as ready for review August 20, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants