Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
facdf5e
Bump actions/upload-artifact from 4 to 7 (#392)
dependabot[bot] Jun 8, 2026
ed69d21
Bump actions/checkout from 6 to 7 (#394)
dependabot[bot] Jun 26, 2026
fba9519
Release: development → main (version fix, TSC 0.41, mypy, bug fixes) …
jacalata Jul 8, 2026
6310b91
Bump github/codeql-action from 4 to 4.37.4 (#445)
dependabot[bot] Aug 6, 2026
73f21ec
Bump github/codeql-action from 4.37.4 to 4.37.8 (#462)
dependabot[bot] Aug 24, 2026
d1a7aad
fix: reuse saved auth_token from tabcmd login (#463)
jacalata Aug 25, 2026
adb0efc
Bump github/codeql-action from 4.37.8 to 4.37.9 (#466)
dependabot[bot] Aug 31, 2026
ddd1b24
Bump the pip group across 1 directory with 2 updates (#461)
dependabot[bot] Sep 1, 2026
c6448dd
fix: reuse saved auth_token from tabcmd login (#463) (#465)
yyk16293 Sep 2, 2026
f4f9983
Fix publish failures when using embedded db credentials (#458)
fujihara Sep 8, 2026
b4505a4
Bump github/codeql-action from 4.37.9 to 4.38.0 (#471)
dependabot[bot] Sep 18, 2026
341772d
Bump github/codeql-action from 4.38.0 to 4.38.2 (#475)
dependabot[bot] Oct 5, 2026
d04461f
Update x86 build to macos-15 image (macos-14 deprecated now)
bcantoni Oct 5, 2026
c4e876c
Bump github/codeql-action from 4.38.2 to 4.38.3 (#478)
dependabot[bot] Oct 14, 2026
5cb536c
Update x86 build to macos-15 image (macos-14 deprecated now) (#476)
jacalata Oct 16, 2026
9a74380
Bump github/codeql-action from 4.38.3 to 4.39.0
dependabot[bot] Oct 19, 2026
a7ebda2
Bump github/codeql-action from 4.38.3 to 4.39.0 (#482)
jacalata Oct 21, 2026
d1411ba
Bump github/codeql-action from 4.39.0 to 4.39.1
dependabot[bot] Oct 26, 2026
22f3126
Bump github/codeql-action from 4.39.0 to 4.39.1 (#484)
jacalata Oct 26, 2026
78d161c
Merge main into development
jacalata Oct 26, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v4.39.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
Expand All @@ -53,6 +53,6 @@ jobs:
# queries: security-extended,security-and-quality

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v4.39.1
with:
category: "/language:${{matrix.language}}"
2 changes: 1 addition & 1 deletion .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
UPLOAD_FILE_NAME: tabcmd.exe
OUT_FILE_NAME: tabcmd.exe
ASSET_MIME: application/vnd.microsoft.portable-executable
- os: macos-14-large
- os: macos-15-large
# This builds for x86
TARGET: macos
CMD_BUILD: >
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ test = [
"types-setuptools"
]
localize = ["doit", "ftfy", "click"]
package = ["doit", "pyinstaller==5.13.1", "setuptools==81"]
package = ["doit", "pyinstaller==5.13.1", "setuptools==83.0.0"]
versioning = [
"doit",
"pyinstaller_versionfile",
Expand Down
39 changes: 39 additions & 0 deletions tabcmd/commands/auth/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,39 @@ def _get_saved_credentials(self):

return credentials

def _restore_saved_session(self) -> Optional[TSC.Server]:
# Fixes #463: without this, tabcmd <cmd> after a successful `tabcmd login`
# falls into _get_saved_credentials() -> getpass.getpass() and hangs silently
# in any non-TTY context. Reattach to the token saved in tableau_auth.json instead.
if not (self.auth_token and self.site_id and self.user_id and self.server_url):
return None
try:
server = self._open_connection_with_opts()
except Exception as e:
# Log the exception class only; the message can include the request URL and
# in some code paths downstream would echo request bodies. Class name is
# enough for a fallback-diagnostic breadcrumb.
self.logger.debug("Could not open connection to reuse saved session ({})".format(type(e).__name__))
return None
server._set_auth(self.site_id, self.user_id, self.auth_token)
try:
# use_server_version negotiates the REST API version TSC hard-codes into
# subsequent endpoint URLs; required, not merely informational.
server.use_server_version()
# Probe a user-scoped endpoint so an expired token surfaces here rather
# than later inside a publish() body.
server.users.get_by_id(self.user_id)
except Exception as e:
# See note above: exception class only, no str(e).
self.logger.debug("Saved auth token no longer valid ({}); will re-authenticate".format(type(e).__name__))
return None
Comment on lines +354 to +355
# Match tabcmd classic's on-reuse banner (Continuing previous session + server info)
# so users retain visual confirmation of which server/site each subcommand hits.
self.logger.info(_("session.continuing_session"))
self._print_server_info()
self.tableau_server = server
return server

# external entry point:
def create_session(self, args, logger):
signed_in_object = None
Expand All @@ -353,6 +386,12 @@ def create_session(self, args, logger):
self.logger.info(_("session.continuing_session"))
signed_in_object = self._validate_existing_signin()

# Reattach to the token saved on disk by an earlier `tabcmd login`
# before falling back to _get_saved_credentials, which would prompt
# for a password we don't have. Fixes #463.
if not signed_in_object:
signed_in_object = self._restore_saved_session()

if not signed_in_object:
credentials = self._get_saved_credentials()

Expand Down
116 changes: 116 additions & 0 deletions tests/commands/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,60 @@ def test_create_session_username_and_password_no_prompt(self, mock_tsc, mock_pas
assert auth is not None, auth
mock_pass.assert_not_called()

@mock.patch("tabcmd.commands.auth.session.Session._open_connection_with_opts")
def test_create_session_reuses_saved_auth_token(self, mock_open_conn, mock_pass, mock_file, mock_path, mock_json):
"""After a prior `tabcmd login`, a subsequent no-args command should
reattach to the saved auth_token instead of prompting for a password
(regression test for #463)."""
_set_mocks_for_json_file_exists(mock_path, mock_json)
_set_mocks_for_json_file_saved_username(mock_json, "cookieee", "monster")
mock_auth = vars(mock_data_from_json)
mock_auth["site_id"] = "site-1"
mock_auth["user_id"] = "user-1"

restored_server = mock.MagicMock(name="restored_tsc_server")
_set_mock_signin_validation_succeeds(restored_server, "monster")
mock_open_conn.return_value = restored_server

test_args = Namespace(**vars(args_to_mock))
new_session = Session()
auth = new_session.create_session(test_args, None)

restored_server._set_auth.assert_called_once_with("site-1", "user-1", "cookieee")
restored_server.users.get_by_id.assert_called_once_with("user-1")
assert auth is restored_server
mock_pass.assert_not_called()

@mock.patch("tabcmd.commands.auth.session.Session._open_connection_with_opts")
def test_create_session_falls_back_when_saved_token_expired(
self, mock_open_conn, mock_pass, mock_file, mock_path, mock_json
):
"""If the saved token is present but no longer accepted by the server,
we should fall through to the existing credential-recovery path rather
than pretending the reuse succeeded."""
_set_mocks_for_json_file_exists(mock_path, mock_json)
_set_mocks_for_json_file_saved_username(mock_json, "stale-token", "monster")
mock_auth = vars(mock_data_from_json)
mock_auth["site_id"] = "site-1"
mock_auth["user_id"] = "user-1"

restored_server = mock.MagicMock(name="restored_tsc_server")
restored_server.users.get_by_id.side_effect = Exception("401 Unauthorized")
mock_open_conn.return_value = restored_server
mock_pass.return_value = "prompted_password"

test_args = Namespace(**vars(args_to_mock))
new_session = Session()
# Expect fallback to _get_saved_credentials -> getpass -> fresh sign-in
with self.assertRaises(SystemExit):
# sign_in would try to talk to a real server through the mocked _open_connection_with_opts;
# we just care that we reached the credential path, which raises via TSC-less setup here.
new_session.create_session(test_args, None)
# The restore was actually attempted (proves _restore_saved_session ran; fails if the
# method is deleted in a future refactor, unlike a getpass-only assertion).
restored_server._set_auth.assert_called_once_with("site-1", "user-1", "stale-token")
mock_pass.assert_called()


@mock.patch("tabcmd.commands.auth.session.json")
@mock.patch("tabcmd.commands.auth.session.os.path")
Expand Down Expand Up @@ -602,6 +656,68 @@ def test_create_session_with_missing_token_file_exits(self, mock_tsc, mock_file,
self._assert_missing_file_error(mock_exit, "token_file_not_found", "/nonexistent/token.txt")


class RestoreSavedSessionTests(unittest.TestCase):
"""Direct unit tests for Session._restore_saved_session (issue #463).

These exist as a hard regression guard: they fail if the method is deleted
or if TSC removes the private _set_auth API tabcmd depends on to reattach
a saved token."""

def test_method_exists_on_session(self):
# Guards the fix against accidental removal. If someone deletes the
# method during a refactor, the create_session flow falls back to
# getpass and hangs silently in non-TTY contexts (#463).
assert callable(getattr(Session, "_restore_saved_session", None)), (
"Session._restore_saved_session was removed; login-then-publish "
"will silently hang on getpass again. See #463."
)

def test_tsc_server_still_exposes_set_auth(self):
# Guards the fix against a TSC upstream rename/removal of the private
# _set_auth attachment API. If TSC drops it, _restore_saved_session's
# broad `except Exception` would swallow the AttributeError at DEBUG
# level and reintroduce the silent-hang behavior.
import tableauserverclient as TSC

assert callable(getattr(TSC.Server, "_set_auth", None)), (
"tableauserverclient.Server._set_auth is gone; tabcmd's saved-"
"session reuse will silently fall back to a getpass prompt. See "
"the note in Session._restore_saved_session."
)

def test_returns_none_when_no_saved_token(self):
session = Session()
session.auth_token = None
session.site_id = "s"
session.user_id = "u"
session.server_url = "https://x"
assert session._restore_saved_session() is None

def test_returns_none_when_no_site_id(self):
session = Session()
session.auth_token = "t"
session.site_id = None
session.user_id = "u"
session.server_url = "https://x"
assert session._restore_saved_session() is None

def test_returns_none_when_no_user_id(self):
session = Session()
session.auth_token = "t"
session.site_id = "s"
session.user_id = None
session.server_url = "https://x"
assert session._restore_saved_session() is None

def test_returns_none_when_no_server_url(self):
session = Session()
session.auth_token = "t"
session.site_id = "s"
session.user_id = "u"
session.server_url = None
assert session._restore_saved_session() is None


def _set_mock_tsc_not_signed_in(mock_tsc):
tsc_in_test = mock.MagicMock(name="manually mocking tsc")
tsc_in_test.is_signed_in.return_value = False # CreateSessionTests.return_False
Expand Down
Loading