11from tableauserverclient .helpers .logging import logger
22
33import requests
4+ import threading
45import urllib3
56import ssl
7+ import weakref
8+
9+ from typing import NamedTuple , Optional
610
711from defusedxml .ElementTree import fromstring , ParseError
812from packaging .version import Version
6064default_server_version = "2.4" # first version that dropped the legacy auth endpoint
6165
6266
67+ class _AuthState (NamedTuple ):
68+ """
69+ Immutable bundle of the auth fields that must change together.
70+
71+ The whole state is swapped by assigning a new instance, and a single
72+ reference assignment or read is atomic on every CPython build,
73+ free-threaded (no-GIL) builds included. Readers take one snapshot of
74+ ``Server._auth_state`` and destructure it, so they can never observe a
75+ token paired with the site or user of a different sign in.
76+ """
77+
78+ site_id : Optional [str ] = None
79+ user_id : Optional [str ] = None
80+ auth_token : Optional [str ] = None
81+ site_url : Optional [str ] = None
82+
83+
6384class Server :
6485 """
6586 In the Tableau REST API, the server (https://MY-SERVER/) is the base or core
@@ -121,6 +142,18 @@ class Server:
121142
122143 Notes
123144 -----
145+ A single Server instance may be shared across threads (for example with
146+ ``concurrent.futures.ThreadPoolExecutor``). Authentication state (the auth
147+ token, site and user IDs, and API version) is shared by all threads, while
148+ each thread transparently gets its own ``requests.Session`` for HTTP calls,
149+ since ``requests.Session`` itself is not guaranteed to be thread-safe. Sign
150+ in, and call ``use_server_version()`` if you need it, before spawning
151+ worker threads; signing out invalidates the sessions of all threads.
152+ ``session_factory`` may be called once per thread, so a custom factory
153+ should be safe to call concurrently. Call ``close()`` (or use the Server
154+ as a context manager) after worker threads finish to release the pooled
155+ HTTP connections of every thread's session.
156+
124157 When using Python 3.12 or later with older versions of Tableau Server, you may encounter
125158 SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions
126159 enforce stronger security requirements. You can temporarily work around this using
@@ -141,9 +174,7 @@ class PublishMode:
141174 Replace = "Replace"
142175
143176 def __init__ (self , server_address , use_server_version = False , http_options = None , session_factory = None ):
144- self ._auth_token = None
145- self ._site_id = None
146- self ._user_id = None
177+ self ._auth_state = _AuthState ()
147178 self ._ssl_context = None
148179
149180 # TODO: this needs to change to default to https, but without breaking existing code
@@ -153,6 +184,22 @@ def __init__(self, server_address, use_server_version=False, http_options=None,
153184 self ._server_address : str = server_address
154185 self ._session_factory = session_factory or requests .session
155186
187+ # Thread-safety machinery. requests.Session is not guaranteed to be
188+ # thread-safe (see psf/requests#2766), so each thread that makes calls
189+ # through this Server instance gets its own Session object, created
190+ # lazily from session_factory. The epoch counter invalidates every
191+ # thread's cached session when auth state is cleared (sign out). The
192+ # lock guards the epoch counter and the session WeakSet; auth state
193+ # itself is an immutable _AuthState swapped by reference and needs
194+ # no lock.
195+ self ._session_lock = threading .Lock ()
196+ self ._session_epoch = 0
197+ self ._thread_sessions = threading .local ()
198+ # Weak references to every session created for any thread, so close()
199+ # can release their pooled connections. Weak so that sessions belonging
200+ # to threads that have exited can still be garbage collected.
201+ self ._all_sessions : "weakref.WeakSet[requests.Session]" = weakref .WeakSet ()
202+
156203 self .auth = Auth (self )
157204 self .views = Views (self )
158205 self .users = Users (self )
@@ -187,7 +234,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None,
187234 self .oidc = OIDC (self )
188235 self .extensions = Extensions (self )
189236
190- self ._session = self ._session_factory ()
191237 self ._http_options = dict () # must set this before making a server call
192238 if http_options :
193239 self .add_http_options (http_options )
@@ -203,7 +249,7 @@ def validate_connection_settings(self):
203249 Endpoint .set_user_agent (params )
204250 if not self ._server_address .startswith ("http://" ) and not self ._server_address .startswith ("https://" ):
205251 self ._server_address = "http://" + self ._server_address
206- self ._session .prepare_request (requests .Request ("GET" , url = self ._server_address , params = self ._http_options ))
252+ self .session .prepare_request (requests .Request ("GET" , url = self ._server_address , params = self ._http_options ))
207253 except Exception as req_ex :
208254 raise ValueError ("Server connection settings not valid" , req_ex )
209255
@@ -226,21 +272,64 @@ def clear_http_options(self):
226272 self ._http_options = dict ()
227273
228274 def _clear_auth (self ):
229- self ._site_id = None
230- self ._user_id = None
231- self ._auth_token = None
232- self ._site_url = None
233- self ._session = self ._session_factory ()
275+ # swapping the whole immutable state in one assignment is atomic on
276+ # every CPython build, free-threaded (no-GIL) builds included
277+ self ._auth_state = _AuthState ()
278+ with self ._session_lock :
279+ # Invalidate the cached session of every thread so state such as
280+ # cookies does not leak into a later sign in. Sessions are replaced
281+ # lazily on next use rather than closed here, so requests already
282+ # in flight on other threads are not disrupted (this matches the
283+ # previous behavior of re-assigning the shared session). The lock
284+ # guards the epoch increment, which is not atomic without the GIL.
285+ self ._session_epoch += 1
234286
235287 def _set_auth (self , site_id , user_id , auth_token , site_url = None ):
236- self ._site_id = site_id
237- self ._user_id = user_id
238- self ._auth_token = auth_token
239- self ._site_url = site_url
288+ # swapping the whole immutable state in one assignment is atomic on
289+ # every CPython build, free-threaded (no-GIL) builds included, so
290+ # readers can never observe a partially-updated state
291+ self ._auth_state = _AuthState (site_id , user_id , auth_token , site_url )
292+
293+ # Backwards-compatible access to the individual auth fields. Existing code
294+ # (including this project's tests) reads and assigns these directly as a
295+ # sign-in shortcut. Each assignment swaps in a fully-formed state, so
296+ # readers never see torn fields; assigning fields one at a time is not
297+ # atomic as a group, though, so concurrent writers should use _set_auth.
298+ @property
299+ def _auth_token (self ):
300+ return self ._auth_state .auth_token
301+
302+ @_auth_token .setter
303+ def _auth_token (self , value ) -> None :
304+ self ._auth_state = self ._auth_state ._replace (auth_token = value )
305+
306+ @property
307+ def _site_id (self ):
308+ return self ._auth_state .site_id
309+
310+ @_site_id .setter
311+ def _site_id (self , value ) -> None :
312+ self ._auth_state = self ._auth_state ._replace (site_id = value )
313+
314+ @property
315+ def _user_id (self ):
316+ return self ._auth_state .user_id
317+
318+ @_user_id .setter
319+ def _user_id (self , value ) -> None :
320+ self ._auth_state = self ._auth_state ._replace (user_id = value )
321+
322+ @property
323+ def _site_url (self ):
324+ return self ._auth_state .site_url
325+
326+ @_site_url .setter
327+ def _site_url (self , value ) -> None :
328+ self ._auth_state = self ._auth_state ._replace (site_url = value )
240329
241330 def _get_legacy_version (self ):
242331 # the serverInfo call was introduced in 2.4, earlier than that we have this different call
243- response = self ._session .get (self .server_address + "/auth?format=xml" )
332+ response = self .session .get (self .server_address + "/auth?format=xml" )
244333 try :
245334 info_xml = fromstring (response .content )
246335 except ParseError as parseError :
@@ -294,31 +383,37 @@ def namespace(self):
294383
295384 @property
296385 def auth_token (self ):
297- if self ._auth_token is None :
386+ # read the state once: a second self._auth_state read could observe
387+ # a concurrent sign out and return None instead of raising
388+ token = self ._auth_state .auth_token
389+ if token is None :
298390 error = "Missing authentication token. You must sign in first."
299391 raise NotSignedInError (error )
300- return self . _auth_token
392+ return token
301393
302394 @property
303395 def site_id (self ):
304- if self ._site_id is None :
396+ site_id = self ._auth_state .site_id
397+ if site_id is None :
305398 error = "Missing site ID. You must sign in first."
306399 raise NotSignedInError (error )
307- return self . _site_id
400+ return site_id
308401
309402 @property
310403 def site_url (self ):
311- if self ._site_url is None :
404+ site_url = self ._auth_state .site_url
405+ if site_url is None :
312406 error = "Missing site URL. You must sign in first."
313407 raise NotSignedInError (error )
314- return self . _site_url
408+ return site_url
315409
316410 @property
317411 def user_id (self ):
318- if self ._user_id is None :
412+ user_id = self ._auth_state .user_id
413+ if user_id is None :
319414 error = "Missing user ID. You must sign in first."
320415 raise NotSignedInError (error )
321- return self . _user_id
416+ return user_id
322417
323418 @property
324419 def server_address (self ):
@@ -329,11 +424,50 @@ def http_options(self):
329424 return self ._http_options
330425
331426 @property
332- def session (self ):
333- return self ._session
427+ def session (self ) -> requests .Session :
428+ """
429+ The requests.Session used for HTTP calls made by the current thread.
430+
431+ requests.Session is not guaranteed to be thread-safe (see
432+ psf/requests#2766), so each thread that makes calls through this Server
433+ instance transparently gets its own Session object, created from
434+ ``session_factory``. Sessions are cached per thread, so a thread pool
435+ worker reuses its session (and its connection pool) across tasks.
436+ Signing out invalidates the cached sessions of all threads.
437+ """
438+ local = self ._thread_sessions
439+ epoch = self ._session_epoch
440+ if getattr (local , "session" , None ) is None or local .epoch != epoch :
441+ session = self ._session_factory ()
442+ with self ._session_lock :
443+ self ._all_sessions .add (session )
444+ local .session = session
445+ # `epoch` was read before the factory ran: if a sign out happened
446+ # in between, local.epoch is already stale and the session will be
447+ # replaced on the next access.
448+ local .epoch = epoch
449+ return local .session
450+
451+ # Backwards-compatible access to the pre-thread-safety private attribute.
452+ # Reading returns the current thread's session; assigning replaces the
453+ # CURRENT thread's session only (other threads keep sessions created by
454+ # session_factory), which preserves the common single-threaded pattern of
455+ # injecting a prepared session before making calls. The injected session
456+ # is registered so close() still reaches it.
457+ @property
458+ def _session (self ) -> requests .Session :
459+ return self .session
460+
461+ @_session .setter
462+ def _session (self , value : requests .Session ) -> None :
463+ local = self ._thread_sessions
464+ with self ._session_lock :
465+ self ._all_sessions .add (value )
466+ local .session = value
467+ local .epoch = self ._session_epoch
334468
335469 def is_signed_in (self ):
336- return self ._auth_token is not None
470+ return self ._auth_state . auth_token is not None
337471
338472 def configure_ssl (self , * , allow_weak_dh = False ):
339473 """Configure SSL/TLS settings for the server connection.
@@ -357,3 +491,33 @@ def configure_ssl(self, *, allow_weak_dh=False):
357491 # Remove any custom SSL context if we're reverting to default settings
358492 if "verify" in self ._http_options :
359493 del self ._http_options ["verify" ]
494+
495+ def close (self ) -> None :
496+ """
497+ Release the pooled HTTP connections held by every thread's session.
498+
499+ Call this when you are done with the server, after any worker threads
500+ have finished their requests. Closing is a transport-level operation:
501+ it does not sign out, so the auth token remains valid on the server
502+ (use ``auth.sign_out()`` for that). The Server object remains usable
503+ after close; any subsequent call transparently creates a new session.
504+ """
505+ with self ._session_lock :
506+ sessions = list (self ._all_sessions )
507+ self ._all_sessions .clear ()
508+ # Invalidate every thread's cached (now closed) session so later
509+ # use creates a fresh one instead of hitting closed pools.
510+ self ._session_epoch += 1
511+ for session in sessions :
512+ session .close ()
513+
514+ def __enter__ (self ) -> "Server" :
515+ return self
516+
517+ def __exit__ (self , exc_type , exc_val , exc_tb ) -> None :
518+ # Note: does not sign out. server.auth.sign_in() already returns a
519+ # context manager that signs out; nesting the two composes cleanly:
520+ # with TSC.Server(...) as server:
521+ # with server.auth.sign_in(auth):
522+ # ...
523+ self .close ()
0 commit comments