Skip to content

fix: parse Tableau Cloud's inlined subscription <schedule> (#1627) - #1875

Open
jacalata wants to merge 1 commit into
developmentfrom
jac/subscription-schedule-cloud-parity
Open

fix: parse Tableau Cloud's inlined subscription <schedule> (#1627)#1875
jacalata wants to merge 1 commit into
developmentfrom
jac/subscription-schedule-cloud-parity

Conversation

@jacalata

Copy link
Copy Markdown
Contributor

Motivation

Fixes #1627. Tableau Server references a schedule by id inside a <subscription>:

<schedule id="cfb2..." name="Weekday mornings"/>

Tableau Cloud inlines the schedule instead — no id, but a frequency, nextRunAt, and nested <frequencyDetails>. SubscriptionItem only parsed the Server shape, so on Cloud every subscription came back with schedule_id is None and no structured access to the schedule metadata. Client code doing [s for s in subs if s.schedule_id == target] silently returned [] on Cloud.

Behavior change

Read side (server response → model):

  • SubscriptionItem.schedule (new) is populated for both shapes with a ScheduleItem. On Server: schedule.id and .name are set. On Cloud: schedule.frequency, schedule.next_run_at, and schedule.interval_item are set; schedule_id remains None on Cloud because the API doesn't send one.
  • ScheduleItem.frequency (new property): the underlying XML attribute was already being read to pick the interval type but was discarded — now exposed.
  • parse_datetime accepts Cloud's %Y-%m-%dT%H:%M:%S%z form (e.g. 2026-08-29T16:55:00-0700) in addition to Server's ...Z. Unparseable input still returns None.
  • Malformed Cloud interval data (missing start attribute, or <interval hours="3"/> outside IntervalItem.VALID_INTERVALS) no longer crashes subscriptions.get(); the offending schedule degrades to interval_item = None with a warning log, sibling schedules are unaffected.

Write side (user assignment → model):

  • property_is_datetime (setter decorator) now raises ValueError when a str value fails to parse. Previously the attribute was silently set to None. This affects direct user assignment to typed datetime properties on model classes; server-response parsing is unchanged.

Testing Run

  • New test/assets/subscription_get_cloud.xml mirrors the shape observed on a live Cloud site (API 3.29). Companion parse tests verify Server and Cloud paths.
  • Three malformed-Cloud fixtures + tests cover _parse_interval_item's graceful-degradation paths (missing <frequencyDetails>, empty <intervals/>, <interval hours="3"/>).
  • Existing subscription tests extended to cover sub 2 and get_subscription_by_id.
  • New test/test_datetime_helpers.py directly covers parse_datetime (None / "" / garbage / Server / Cloud / colon-offset / microseconds) and property_is_datetime (valid / bad / non-str inputs).
  • test/test_schedule.py::test_get asserts the new frequency property on the standard /schedules endpoint.
  • Live verification (not in-tree): raw XML captured from main-windows.tsi.lan (API 3.31, Server shape) and stage-dp1 (API 3.29, Cloud shape) — matches the two fixture shapes.
  • Full suite: 936 passed, 1 skipped. black --check and mypy clean.

Tableau Server references a schedule by id inside a <subscription>:

    <schedule id="cfb2..." name="Weekday mornings"/>

Tableau Cloud inlines the schedule instead -- no id attribute, but
a `frequency`, `nextRunAt` and a nested `<frequencyDetails>`:

    <schedule frequency="Daily" nextRunAt="2026-08-29T16:55:00-0700">
      <frequencyDetails start="16:55:00" end="16:55:00">
        <intervals>
          <interval hours="24"/>
          <interval weekDay="Saturday"/>
        </intervals>
      </frequencyDetails>
    </schedule>

Previously `SubscriptionItem` only pulled `id`/`name` off `<schedule>`,
so on Cloud every subscription came back with `schedule_id == None`
and no structured way to see what the API sent. Client code filtering
`[s for s in subs if s.schedule_id == target]` silently returned `[]`
on Cloud. This is the bug described in issue #1627.

Model changes:

* `SubscriptionItem` now populates a `schedule: ScheduleItem` attribute
  for both shapes. On Server, `schedule.id`/`.name` are set (and
  `schedule_id` remains populated for back-compat). On Cloud,
  `schedule.frequency`, `schedule.next_run_at`, and
  `schedule.interval_item` are set. `schedule_id` is `None` on Cloud --
  the API does not send one, unavoidable. Docstring calls out the
  Cloud-vs-Server discriminator and warns callers who filter by
  `schedule_id`. Fixes a latent bug where the previous Cloud branch
  assigned a list (return of `ScheduleItem.from_element`) to
  `sub.schedule` instead of a single item.
* `ScheduleItem` gains a `frequency` property. The XML attribute was
  already being read to select the interval type but was discarded;
  now it's exposed so callers can distinguish the Cloud shape without
  reaching into the interval object. The class `Attributes` docstring
  is rewritten to cover every public property and to note that only
  `frequency` / `next_run_at` / `interval_item` are populated when the
  item comes from an inlined Cloud subscription schedule.
* `_parse_interval_item` is defensive against malformed Cloud data:
  a `<frequencyDetails>` without a `start` attribute no longer crashes
  on `strptime(None, ...)`, and an out-of-range `<interval hours="3"/>`
  (or unknown weekDay / monthDay) no longer raises `ValueError` out of
  `IntervalItem` and poisons sibling schedules in the same page. Bad
  data degrades to `interval_item = None` with a warning log.

Datetime plumbing:

* `parse_datetime` learns the Tableau Cloud `%Y-%m-%dT%H:%M:%S%z` form
  (e.g. `2026-08-29T16:55:00-0700`) in addition to the Server `...Z`
  form. Unparseable input still returns `None` on the read path
  (preserving the pre-change contract that a malformed server-side
  date cannot crash a page-through of unrelated data).
* `property_is_datetime` now raises `ValueError` when `parse_datetime`
  returns `None` for a non-empty str value. Bad *user* input is
  surfaced at the assignment site instead of silently nulling the
  attribute.
* `TABLEAU_CLOUD_DATE_FORMAT` is public, matching the existing
  `TABLEAU_DATE_FORMAT`.

Tests:

* New `test/assets/subscription_get_cloud.xml` mirroring the shape
  observed on `stage-dp1` (API 3.29) and matching parse tests for
  both Cloud and Server shapes.
* Three edge-case Cloud fixtures + tests: no `<frequencyDetails>`,
  empty `<intervals/>`, out-of-set `<interval hours="3"/>`.
* Existing subscription tests extended to cover sub 2 and
  `get_subscription_by_id`.
* New `test/test_datetime_helpers.py` with direct coverage of
  `parse_datetime` (None / "" / garbage / Server / Cloud / colon-offset
  / microseconds) and `property_is_datetime` (valid / bad / non-str).
* `test/test_schedule.py::test_get` asserts the new
  `ScheduleItem.frequency` property on the standard /schedules endpoint.

Refs: #1627

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

Copy link
Copy Markdown

Coverage

Coverage Report
FileStmtsMissCoverMissing
tableauserverclient
   __init__.py50100% 
   config.py150100% 
   datetime_helpers.py3011 97%
   exponential_backoff.py200100% 
   filesys_helpers.py310100% 
   namespace.py2533 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.py1823030 84%
   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.py1021212 88%
   reference_item.py2622 92%
   revision_item.py5911 98%
   schedule_item.py22277 97%
   server_info_item.py3777 81%
   site_item.py6361313 98%
   subscription_item.py10211 99%
   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%
TOTAL12089141788% 

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.

SubscriptionItem is different for Tableau Cloud/on-prem: currently only works for Cloud

1 participant