Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ff57dc6
fix: prompt for password when username given but no password provided…
jacalata Jun 29, 2026
a9836b0
fix: --filename extension takes precedence over URL extension (#256)
jacalata Jun 30, 2026
652f0a3
fix: rotate log file using RotatingFileHandler (#211)
jacalata Jun 30, 2026
304b9eb
fix: default to https when no protocol given in server URL (#331)
jacalata Jun 30, 2026
ae9f929
typing: from static methods to class methods
jacalata Aug 14, 2025
ad78386
type fixes: session.py
jacalata Aug 14, 2025
c8901ae
typing: parser tests
jacalata Aug 14, 2025
daee090
Update test_session.py
jacalata Aug 14, 2025
4cb5e68
Update pyproject.toml
jacalata Aug 14, 2025
3c8f7c5
type fixes
jacalata Aug 14, 2025
366c39a
fix type errors in /tests
jacalata Sep 24, 2025
0600fab
Refactor version retrieval to use importlib.metadata
jacalata Feb 13, 2026
5d7247a
Update tabcmd/commands/datasources_and_workbooks/export_command.py
jacalata Apr 13, 2026
ac057f3
Update tabcmd/commands/user/add_users_command.py
jacalata Apr 13, 2026
b202848
fix typings
jacalata Apr 21, 2026
152cb11
fix: set root logger level explicitly; guard against duplicate handlers
jacalata Jun 30, 2026
c7b08ae
test: add error-path test for get_file_type_from_filename
jacalata Jun 30, 2026
8ab8048
fix: don't sign out existing session when only --username is given
jacalata Jun 30, 2026
316c23b
style: black formatting
jacalata Jun 30, 2026
52ff6b2
style: black formatting
jacalata Jun 30, 2026
7e24c46
style: black formatting
jacalata Jun 30, 2026
7b3d329
ci: add workflow to check i18n string keys on PRs
jacalata Jun 30, 2026
2b30522
ci: pin action versions to v6, note regex limitation
jacalata Jun 30, 2026
f0de017
fix: black formatting and mypy errors for check_untyped_defs
jacalata Jul 1, 2026
f278a62
ci: add workflow to check i18n string keys on PRs (#409)
jacalata Jul 1, 2026
001c045
fix: cast _ProjectItem stubs to TSC.ProjectItem in tests
jacalata Jul 1, 2026
6d0b0ac
fix: add rotating log file handler (#211) (#404)
jacalata Jul 1, 2026
096819c
fix: prompt for password when --username given without credentials (#…
jacalata Jul 1, 2026
02e9aba
fix: use explicit extension check in get_file_type_from_filename (#25…
jacalata Jul 1, 2026
d50c35f
fix: default to https when no scheme specified in server URL (#331) (…
jacalata Jul 1, 2026
1ec12f9
mypy: enable checking untyped code (#367)
jacalata Jul 1, 2026
e447579
chore: upgrade tableauserverclient to 0.41, add SVG format tests (#1772)
jacalata Jun 29, 2026
00c09e9
fix: set user_id in test so _validate_existing_signin passes
jacalata Jul 1, 2026
9686e99
chore: upgrade tableauserverclient to 0.41 (#408)
jacalata Jul 1, 2026
ec299d4
fix: show correct version in PyInstaller exe (W-22831646) (#410)
jacalata Jul 2, 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
28 changes: 28 additions & 0 deletions .github/workflows/check-strings.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Check i18n strings

on:
workflow_dispatch:
pull_request:
branches:
- development
- main

permissions:
contents: read

jobs:
check-strings:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'

# Note: check_strings.py uses regex matching and will not catch dynamic keys
# (f-strings, variables, concatenation). This is a best-effort check.
- name: Check for missing i18n string keys
run: python bin/i18n/check_strings.py
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
.idea
*.DS_Store

# setuptools_scm generated version file (may appear in any subpackage)
tabcmd/**/_version.py
tabcmd/_version.py

# python build files
build/
dist/
Expand Down
8 changes: 8 additions & 0 deletions dodo.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,14 @@ def write_for_pyinstaller():
version=numeric_version,
)

# Write tabcmd/_version.py so PyInstaller bundles the correct version string.
# setuptools_scm only writes this file during pip install/build; doit version
# may run in CI without a prior install step.
version_file = os.path.join("tabcmd", "_version.py")
with open(version_file, "w", encoding="utf-8") as f:
f.write("# generated by doit version -- do not edit\n")
f.write("version = {!r}\n".format(version))

return {
"actions": [write_for_pyinstaller],
"verbosity": 2,
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ requires = ["build", "setuptools", "setuptools_scm"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
local_scheme = "no-local-version" # require pypi supported versions always
write_to = "tabcmd/_version.py"
[tool.setuptools]
packages = ["tabcmd"]
[tool.setuptools.package-data]
Expand All @@ -13,6 +14,7 @@ required-version = 22
target-version = ['py310', 'py311']
extend-exclude = '^/bin/*'
[tool.mypy]
check_untyped_defs = true
disable_error_code = [
'misc',
'import'
Expand Down Expand Up @@ -46,7 +48,7 @@ dependencies = [
"appdirs",
"requests>=2.25,<3.0",
"setuptools",
"tableauserverclient==0.40",
"tableauserverclient==0.41",
"urllib3",
]
[project.optional-dependencies]
Expand Down
6 changes: 3 additions & 3 deletions tabcmd/commands/auth/login_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ def define_args(parser):
# just uses global options
pass

@staticmethod
def run_command(args):
logger = log(__class__.__name__, args.logging_level)
@classmethod
def run_command(cls, args):
logger = log(cls.__name__, args.logging_level)
logger.debug(_("tabcmd.launching"))
session = Session()
session.create_session(args, logger)
6 changes: 3 additions & 3 deletions tabcmd/commands/auth/logout_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ def define_args(parser):
# has no options
pass

@staticmethod
def run_command(args):
logger = log(__class__.__name__, args.logging_level)
@classmethod
def run_command(cls, args):
logger = log(cls.__name__, args.logging_level)
logger.debug(_("tabcmd.launching"))
session = Session()
session.end_session_and_clear_data()
82 changes: 49 additions & 33 deletions tabcmd/commands/auth/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from tabcmd.execution.localize import _
from tabcmd.execution.logger_config import log

from typing import Dict, Any
from typing import Dict, Any, Optional


class Session:
Expand All @@ -28,31 +28,31 @@ class Session:
PASSWORD_CRED_TYPE = "password"

def __init__(self):
self.username = None
self.username: Optional[str] = None
# we don't store the password
self.user_id = None
self.auth_token = None
self.token_name = None
self.token_value = None
self.password_file = None
self.token_file = None
self.site_name = None # The site name, e.g 'alpodev'
self.site_id = None # The site id, e.g 'abcd-1234-1234-1244-1234'
self.server_url = None
self.last_command = None # for when we have to renew the session then re-try
self.last_login_using = None

self.no_prompt = False
self.certificate = None
self.no_certcheck = False
self.no_proxy = False
self.proxy = None
self.timeout = None

self.logging_level = "info"
self.user_id: Optional[str] = None
self.auth_token: Optional[str] = None
self.token_name: Optional[str] = None
self.token_value: Optional[str] = None
self.password_file: Optional[str] = None
self.token_file: Optional[str] = None
self.site_name: Optional[str] = None # The site name, e.g 'alpodev'
self.site_id: Optional[str] = None # The site id, e.g 'abcd-1234-1234-1244-1234'
self.server_url: Optional[str] = None
self.last_command: Optional[str] = None # for when we have to renew the session then re-try
self.last_login_using: Optional[str] = None

self.no_prompt: bool = False
self.certificate: Optional[str] = None
self.no_certcheck: bool = False
self.no_proxy: bool = False
self.proxy: Optional[str] = None
self.timeout: Optional[int] = None

self.logging_level: str = "info"
self.logger = log(__name__, self.logging_level) # instantiate here mostly for tests
self._read_from_json()
self.tableau_server = None # this one is an object that doesn't get persisted in the file
self.tableau_server: Optional[TSC.Server] = None # this one is an object that doesn't get persisted in the file

# called before we connect to the server
# generally, we don't want to overwrite stored data with nulls
Expand Down Expand Up @@ -201,6 +201,10 @@ def _open_connection_with_opts(self) -> TSC.Server:
return tableau_server

def _verify_server_connection_unauthed(self):
if not self.tableau_server:
Errors.exit_with_error(self.logger, "No server connection available")

assert self.tableau_server is not None # Type hint for mypy
try:
self.tableau_server.use_server_version()
except requests.exceptions.ReadTimeout as timeout_error:
Expand All @@ -222,7 +226,12 @@ def _create_new_connection(self) -> TSC.Server:
try:
self.tableau_server = self._open_connection_with_opts()
except Exception as e:
Errors.exit_with_error(self.logger, "Failed to connect to server", e)
Errors.exit_with_error(self.logger, "Failed to connect to server", exception=e)

if not self.tableau_server:
Errors.exit_with_error(self.logger, "Failed to create server connection")

assert self.tableau_server is not None # Type hint for mypy
return self.tableau_server

def _read_existing_state(self):
Expand All @@ -246,7 +255,7 @@ def _print_server_info(self):
def _validate_existing_signin(self):
# when do these two messages show up? self.logger.info(_("session.auto_site_login"))
try:
if self.tableau_server and self.tableau_server.is_signed_in():
if self.tableau_server and self.tableau_server.is_signed_in() and self.user_id:
server_user = self.tableau_server.users.get_by_id(self.user_id).name
if not self.username:
self.logger.info("Fetched user details from server")
Expand All @@ -261,8 +270,12 @@ def _validate_existing_signin(self):

# server connection created, not yet logged in
def _sign_in(self, tableau_auth) -> TSC.Server:
self.logger.debug(_("session.login") + self.server_url)
if not self.tableau_server:
Errors.exit_with_error(self.logger, "No server connection available for sign in")

self.logger.debug(_("session.login") + (self.server_url or ""))
self.logger.debug(_("listsites.output").format("", self.username or self.token_name, self.site_name))
assert self.tableau_server is not None # Type hint for mypy
try:
self.tableau_server.auth.sign_in(tableau_auth) # it's the same call for token or user-pass
except Exception as e:
Expand Down Expand Up @@ -298,7 +311,7 @@ def create_session(self, args, logger):
self._read_existing_state()
self._update_session_data(args)
self.logging_level = args.logging_level or self.logging_level
self.logger = logger or log(__class__.__name__, self.logging_level)
self.logger = logger or log(self.__class__.__name__, self.logging_level)

credentials = None
if args.password or args.password_file:
Expand All @@ -308,6 +321,9 @@ def create_session(self, args, logger):
elif args.token_value or args.token_file:
self._end_session()
credentials = self._create_new_token_credential()
elif args.username and not self.tableau_server:
# username given but no password/token and no active session: prompt for password
credentials = self._create_new_credential(None, Session.PASSWORD_CRED_TYPE)
else: # no login arguments given - look for saved info
# maybe we're already signed in!
if self.tableau_server:
Expand Down Expand Up @@ -360,8 +376,8 @@ def _clear_data(self):
self.tableau_server = None

self.certificate = None
self.no_certcheck = None
self.no_proxy = None
self.no_certcheck = False
self.no_proxy = False
self.proxy = None
self.timeout = None

Expand Down Expand Up @@ -440,13 +456,13 @@ def _save_session_to_json(self):
except Exception as e:
self._wipe_bad_json(e, "Failed to save session file")

def _save_file(self, data):
def _save_file(self, data: Dict[str, Any]) -> None:
file_path = self._get_file_path()
with open(str(file_path), "w") as f:
json.dump(data, f)

def _serialize_for_save(self):
data = {"tableau_auth": []}
def _serialize_for_save(self) -> Dict[str, Any]:
data: Dict[str, Any] = {"tableau_auth": []}
data["tableau_auth"].append(
{
"auth_token": self.auth_token,
Expand Down Expand Up @@ -487,7 +503,7 @@ def _remove_json(self):
def _get_server_base_url(self, url: str):
try:
parsed = urlparse(url)
scheme = parsed.scheme or "http"
scheme = parsed.scheme or "https"

# If netloc is empty, treat path as netloc and discard any extra path
netloc = parsed.netloc or parsed.path.split("/")[0] # Keep only the domain
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
from tabcmd.commands.server import Server
from tabcmd.execution.localize import _

RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions


class DatasourcesAndWorkbooks(Server):
"""
Base Class for Operations related to Datasources and Workbooks
"""

def __init__(self, args):
super().__init__(args)
pass

@staticmethod
def get_view_url_from_names(wb_name, view_name):
Expand Down Expand Up @@ -62,7 +64,7 @@ def get_ds_by_content_url(logger, server, datasource_content_url) -> TSC.Datasou
return matching_datasources[0]

@staticmethod
def apply_values_from_url_params(logger, request_options: TSC.RequestOptions, url) -> None:
def apply_values_from_url_params(logger, request_options: RequestOptionsType, url) -> None:
logger.debug(url)
try:
if "?" in url:
Expand All @@ -86,7 +88,7 @@ def apply_values_from_url_params(logger, request_options: TSC.RequestOptions, ur

# this is called from within from_url_params, for each view_filter value
@staticmethod
def apply_encoded_filter_value(logger, request_options, value):
def apply_encoded_filter_value(logger, request_options: RequestOptionsType, value):
# the REST API doesn't appear to have the option to disambiguate with "Parameters.<fieldname>"
value = value.replace("Parameters.", "")
# the filter values received from the url are already url encoded. tsc will encode them again.
Expand All @@ -99,7 +101,7 @@ def apply_encoded_filter_value(logger, request_options, value):
# from apply_options, which expects an un-encoded input,
# or from apply_url_params via apply_encoded_filter_value which decodes the input
@staticmethod
def apply_filter_value(logger, request_options: TSC.RequestOptions, value: str) -> None:
def apply_filter_value(logger, request_options: RequestOptionsType, value: str) -> None:
logger.debug("handling filter param {}".format(value))
data_filter = value.split("=")
# we should export the _DataExportOptions class from tsc
Expand All @@ -108,7 +110,7 @@ def apply_filter_value(logger, request_options: TSC.RequestOptions, value: str)
# this is called from within from_url_params, for each param value
# expects either ImageRequestOptions or PDFRequestOptions
@staticmethod
def apply_options_in_url(logger, request_options: TSC.RequestOptions, value: str) -> None:
def apply_options_in_url(logger, request_options: RequestOptionsType, value: str) -> None:
logger.debug("handling url option {}".format(value))
setting = value.split("=")
if len(setting) != 2:
Expand Down
Loading
Loading