diff --git a/.github/workflows/check-strings.yml b/.github/workflows/check-strings.yml new file mode 100644 index 00000000..f918dcec --- /dev/null +++ b/.github/workflows/check-strings.yml @@ -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 diff --git a/.gitignore b/.gitignore index a03c2fcd..8c494c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/dodo.py b/dodo.py index 39aae448..85ea8b15 100644 --- a/dodo.py +++ b/dodo.py @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 3591b042..0456f3c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -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' @@ -46,7 +48,7 @@ dependencies = [ "appdirs", "requests>=2.25,<3.0", "setuptools", - "tableauserverclient==0.40", + "tableauserverclient==0.41", "urllib3", ] [project.optional-dependencies] diff --git a/tabcmd/commands/auth/login_command.py b/tabcmd/commands/auth/login_command.py index d042f724..8d0e9dbf 100644 --- a/tabcmd/commands/auth/login_command.py +++ b/tabcmd/commands/auth/login_command.py @@ -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) diff --git a/tabcmd/commands/auth/logout_command.py b/tabcmd/commands/auth/logout_command.py index b15f8246..f5848e60 100644 --- a/tabcmd/commands/auth/logout_command.py +++ b/tabcmd/commands/auth/logout_command.py @@ -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() diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index cdf0634e..088865f4 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -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: @@ -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 @@ -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: @@ -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): @@ -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") @@ -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: @@ -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: @@ -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: @@ -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 @@ -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, @@ -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 diff --git a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py index 4b1e2f31..43f53ebc 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py @@ -6,6 +6,8 @@ from tabcmd.commands.server import Server from tabcmd.execution.localize import _ +RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions + class DatasourcesAndWorkbooks(Server): """ @@ -13,7 +15,7 @@ class DatasourcesAndWorkbooks(Server): """ def __init__(self, args): - super().__init__(args) + pass @staticmethod def get_view_url_from_names(wb_name, view_name): @@ -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: @@ -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." value = value.replace("Parameters.", "") # the filter values received from the url are already url encoded. tsc will encode them again. @@ -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 @@ -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: diff --git a/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py b/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py index e5d3e4db..d324a2f4 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_workbooks_views_url_parser.py @@ -1,5 +1,6 @@ import os +import tableauserverclient as TSC from uuid import UUID from tabcmd.commands.constants import Errors @@ -14,7 +15,7 @@ class DatasourcesWorkbooksAndViewsUrlParser(Server): """ def __init__(self, args): - super().__init__(args) + pass @staticmethod def get_view_url_from_names(wb_name, view_name): @@ -73,23 +74,24 @@ def explain_expected_get_url(logger, url: str, command: str): @staticmethod def get_file_type_from_filename(logger, url, file_name): logger.debug("Choosing between {}, {}".format(file_name, url)) - file_name = file_name or url logger.debug(_("get.options.file") + ": {}".format(file_name)) # Name to save the file as - type_of_file = DatasourcesWorkbooksAndViewsUrlParser.get_file_extension(file_name) + valid_extensions = ["pdf", "csv", "png", "twb", "twbx", "tdsx", "tds"] - if not type_of_file and file_name is not None: - # check the url - backup = DatasourcesWorkbooksAndViewsUrlParser.get_file_extension(url) - if backup is not None: - type_of_file = backup - else: - Errors.exit_with_error(logger, _("get.extension.not_found").format(file_name)) + # If the user supplied a filename with a recognized extension, that takes precedence. + if file_name: + type_of_file = DatasourcesWorkbooksAndViewsUrlParser.get_file_extension(file_name) + if type_of_file in valid_extensions: + logger.debug("filetype from filename: {}".format(type_of_file)) + return type_of_file - logger.debug("filetype: {}".format(type_of_file)) - if type_of_file in ["pdf", "csv", "png", "twb", "twbx", "tdsx", "tds"]: - return type_of_file + # Fall back to the URL extension (either no filename was given, or filename had no recognized extension). + if url: + type_of_file = DatasourcesWorkbooksAndViewsUrlParser.get_file_extension(url) + if type_of_file in valid_extensions: + logger.debug("filetype from url: {}".format(type_of_file)) + return type_of_file - Errors.exit_with_error(logger, _("get.extension.not_found").format(file_name)) + Errors.exit_with_error(logger, _("get.extension.not_found").format(file_name or url)) @staticmethod def get_file_extension(path): @@ -179,12 +181,16 @@ def get_url_item_and_item_type_from_view_url(logger, url, server): @staticmethod def get_content_and_server_content_type_from_url(logger, server, view_content_url, custom_view_id): - item = DatasourcesAndWorkbooks.get_view_by_content_url(logger, server, view_content_url) + item: TSC.ViewItem | TSC.CustomViewItem = DatasourcesAndWorkbooks.get_view_by_content_url( + logger, server, view_content_url + ) server_content_type = server.views if custom_view_id: - custom_view_item = DatasourcesAndWorkbooks.get_custom_view_by_id(logger, server, custom_view_id) - if custom_view_item.view.id != item.id: + custom_view_item: TSC.CustomViewItem = DatasourcesAndWorkbooks.get_custom_view_by_id( + logger, server, custom_view_id + ) + if not custom_view_item.view or custom_view_item.view.id != item.id: Errors.exit_with_error(logger, "Invalid custom view URL provided") server_content_type = server.custom_views item = custom_view_item diff --git a/tabcmd/commands/datasources_and_workbooks/delete_command.py b/tabcmd/commands/datasources_and_workbooks/delete_command.py index 3e82e081..71085c74 100644 --- a/tabcmd/commands/datasources_and_workbooks/delete_command.py +++ b/tabcmd/commands/datasources_and_workbooks/delete_command.py @@ -27,9 +27,9 @@ def define_args(delete_parser): set_project_r_arg(group) set_parent_project_arg(group) - @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() server = session.create_session(args, logger) @@ -75,4 +75,4 @@ def run_command(args): server.datasources.delete(item_to_delete.id) logger.info(_("common.output.succeeded")) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/commands/datasources_and_workbooks/export_command.py b/tabcmd/commands/datasources_and_workbooks/export_command.py index ac1ed1b3..326fbb82 100644 --- a/tabcmd/commands/datasources_and_workbooks/export_command.py +++ b/tabcmd/commands/datasources_and_workbooks/export_command.py @@ -13,6 +13,7 @@ pagesize = TSC.PDFRequestOptions.PageType # type alias for brevity pageorientation = TSC.PDFRequestOptions.Orientation imageresolution = TSC.ImageRequestOptions.Resolution +RequestOptionsType = TSC.ExcelRequestOptions | TSC.CSVRequestOptions | TSC.PDFRequestOptions | TSC.ImageRequestOptions ImageResolutionStandard = "standard" @@ -81,9 +82,9 @@ def define_args(export_parser): it to a file. This command can also export just the data used for a view_name """ - @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() server = session.create_session(args, logger) @@ -97,7 +98,7 @@ def run_command(args): if not view_content_url and not wb_content_url: view_example = "/workbook_name/view_name" message = "{} [{}]".format( - _("export.errors.requires_workbook_view_param").format(__class__.__name__), view_example + _("export.errors.requires_workbook_view_param").format(cls.__name__), view_example ) Errors.exit_with_error(logger, message) @@ -127,7 +128,7 @@ def run_command(args): default_filename = "{}.png".format(export_item.name) except TSC.ServerResponseError as e: - Errors.exit_with_error(logger, _("publish.errors.unexpected_server_response").format(""), e) + Errors.exit_with_error(logger, _("publish.errors.unexpected_server_response").format(""), exception=e) except Exception as e: Errors.exit_with_error(logger, exception=e) try: @@ -138,10 +139,10 @@ def run_command(args): ExportCommand.save_to_file(logger, output, save_name) except Exception as e: - Errors.exit_with_error(logger, "Error saving to file", e) + Errors.exit_with_error(logger, "Error saving to file", exception=e) @staticmethod - def apply_filters_from_args(request_options: TSC.RequestOptions, args, logger=None) -> None: + def apply_filters_from_args(request_options: RequestOptionsType, args, logger=None) -> None: if args.filter: logger.debug("filter = {}".format(args.filter)) params = args.filter.split("&") diff --git a/tabcmd/commands/datasources_and_workbooks/get_url_command.py b/tabcmd/commands/datasources_and_workbooks/get_url_command.py index c0ea0b72..fb5cb385 100644 --- a/tabcmd/commands/datasources_and_workbooks/get_url_command.py +++ b/tabcmd/commands/datasources_and_workbooks/get_url_command.py @@ -31,14 +31,14 @@ def define_args(get_url_parser): # tabcmd get "/views/Finance/InvestmentGrowth.png?:size=640,480" -f growth.png # tabcmd get "/views/Finance/InvestmentGrowth.png?:refresh=yes" -f growth.png - @staticmethod - def run_command(args): + @classmethod + def run_command(cls, args): # A view can be returned in PDF, PNG, or CSV (summary data only) format. # A Tableau workbook is returned as a TWB if it connects to a datasource/live connection, # or a TWBX if it uses an extract. # A Tableau datasource is returned as a TDS if it connects to a live connection, # or a TDSX if it uses an extract. - logger = log(__class__.__name__, args.logging_level) + logger = log(cls.__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() server = session.create_session(args, logger) diff --git a/tabcmd/commands/datasources_and_workbooks/publish_command.py b/tabcmd/commands/datasources_and_workbooks/publish_command.py index a4139eaa..8ec8871f 100644 --- a/tabcmd/commands/datasources_and_workbooks/publish_command.py +++ b/tabcmd/commands/datasources_and_workbooks/publish_command.py @@ -42,9 +42,9 @@ def define_args(publish_parser): set_append_replace_option(group) set_parent_project_arg(group) - @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() server = session.create_session(args, logger) @@ -140,7 +140,7 @@ def get_files_to_publish(args, logger): ) relative_files = list(map(lambda file: os.path.join(args.filename, file), in_place_files)) except Exception as e: - Errors.exit_with_error(logger, message=in_place_files) + Errors.exit_with_error(logger, message=str(in_place_files)) files.update(relative_files) logger.debug(len(files)) return sorted(files) diff --git a/tabcmd/commands/datasources_and_workbooks/runschedule_command.py b/tabcmd/commands/datasources_and_workbooks/runschedule_command.py index 3f39d499..e6685d0f 100644 --- a/tabcmd/commands/datasources_and_workbooks/runschedule_command.py +++ b/tabcmd/commands/datasources_and_workbooks/runschedule_command.py @@ -18,9 +18,9 @@ def define_args(runschedule_parser): group = runschedule_parser.add_argument_group(title=RunSchedule.name) group.add_argument("schedule", help="Name of the schedule to run") - @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() server = session.create_session(args, logger) diff --git a/tabcmd/commands/extracts/create_extracts_command.py b/tabcmd/commands/extracts/create_extracts_command.py index 16f3b282..8378aa0f 100644 --- a/tabcmd/commands/extracts/create_extracts_command.py +++ b/tabcmd/commands/extracts/create_extracts_command.py @@ -26,9 +26,9 @@ def define_args(create_extract_parser): set_project_arg(group) set_parent_project_arg(group) - @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() server = session.create_session(args, logger) @@ -39,9 +39,10 @@ def run_command(args): ) try: item = Extracts.get_wb_or_ds_for_extracts(args, logger, server) + job: TSC.JobItem if args.datasource: logger.info(_("createextracts.for.datasource").format(args.datasource)) - job: TSC.JobItem = server.datasources.create_extract(item, encrypt=args.encrypt) + job = server.datasources.create_extract(item, encrypt=args.encrypt) else: if not args.include_all and not args.embedded_datasources: @@ -51,7 +52,7 @@ def run_command(args): ) logger.info(_("createextracts.for.workbook_name").format(args.workbook)) - job: TSC.JobItem = server.workbooks.create_extract( + job = server.workbooks.create_extract( item, encrypt=args.encrypt, includeAll=args.include_all, @@ -62,7 +63,7 @@ def run_command(args): if args.continue_if_exists and Errors.is_resource_conflict(e): logger.info(_("errors.xmlapi.already_exists").format(_("tabcmd.content_type.extract"), args.name)) return - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract creation queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/decrypt_extracts_command.py b/tabcmd/commands/extracts/decrypt_extracts_command.py index cb47d7fa..8f7eae99 100644 --- a/tabcmd/commands/extracts/decrypt_extracts_command.py +++ b/tabcmd/commands/extracts/decrypt_extracts_command.py @@ -19,9 +19,9 @@ def define_args(decrypt_extract_parser): group = decrypt_extract_parser.add_argument_group(title=DecryptExtracts.name) group.add_argument("site_name", metavar="site-name", help=_("editsite.options.site-name")) - @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() server = session.create_session(args, logger) @@ -30,7 +30,7 @@ def run_command(args): logger.info(_("decryptextracts.status").format(args.site_name)) job = server.sites.decrypt_extracts(site_item.id) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract decryption queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/delete_extracts_command.py b/tabcmd/commands/extracts/delete_extracts_command.py index ab32a458..e9b2981f 100644 --- a/tabcmd/commands/extracts/delete_extracts_command.py +++ b/tabcmd/commands/extracts/delete_extracts_command.py @@ -25,17 +25,18 @@ def define_args(delete_extract_parser): set_project_arg(group) set_parent_project_arg(group) - @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() server = session.create_session(args, logger) try: item = Extracts.get_wb_or_ds_for_extracts(args, logger, server) + job: TSC.JobItem if args.datasource: logger.info(_("deleteextracts.for.datasource").format(args.datasource)) - job: TSC.JobItem = server.datasources.delete_extract(item) + job = server.datasources.delete_extract(item) else: if not args.include_all and not args.embedded_datasources: Errors.exit_with_error( @@ -43,12 +44,12 @@ def run_command(args): _("extracts.workbook.errors.requires_datasources_or_include_all").format("deleteextracts"), ) logger.info(_("deleteextracts.for.workbook_name").format(args.workbook)) - job: TSC.JobItem = server.workbooks.delete_extract( + job = server.workbooks.delete_extract( item, includeAll=args.include_all, datasources=args.embedded_datasources ) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract deletion queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/encrypt_extracts_command.py b/tabcmd/commands/extracts/encrypt_extracts_command.py index 0454d0ea..42d70cbd 100644 --- a/tabcmd/commands/extracts/encrypt_extracts_command.py +++ b/tabcmd/commands/extracts/encrypt_extracts_command.py @@ -21,9 +21,9 @@ def define_args(encrypt_extract_parser): group = encrypt_extract_parser.add_argument_group(title=EncryptExtracts.name) group.add_argument("site_name", metavar="site-name", help=_("editsite.options.site-name")) - @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() server = session.create_session(args, logger) @@ -32,7 +32,7 @@ def run_command(args): logger.info(_("encryptextracts.status").format(site_item.name)) job = server.sites.encrypt_extracts(site_item.id) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract encryption queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/extracts.py b/tabcmd/commands/extracts/extracts.py index 60c63c00..50729dbc 100644 --- a/tabcmd/commands/extracts/extracts.py +++ b/tabcmd/commands/extracts/extracts.py @@ -18,12 +18,11 @@ def get_wb_or_ds_for_extracts(args, logger, server): return datasource elif args.workbook or args.url: + workbook_item: TSC.WorkbookItem if args.workbook: - workbook_item: TSC.WorkbookItem = Server.get_workbook_item(logger, server, args.workbook, container) + workbook_item = Server.get_workbook_item(logger, server, args.workbook, container) else: - workbook_item: TSC.WorkbookItem = DatasourcesAndWorkbooks.get_wb_by_content_url( - logger, server, args.url - ) + workbook_item = DatasourcesAndWorkbooks.get_wb_by_content_url(logger, server, args.url) logger.info(_("export.status").format(workbook_item.name)) return workbook_item diff --git a/tabcmd/commands/extracts/reencrypt_extracts_command.py b/tabcmd/commands/extracts/reencrypt_extracts_command.py index e8eb363a..3026fff9 100644 --- a/tabcmd/commands/extracts/reencrypt_extracts_command.py +++ b/tabcmd/commands/extracts/reencrypt_extracts_command.py @@ -21,9 +21,9 @@ def define_args(reencrypt_extract_parser): group = reencrypt_extract_parser.add_argument_group(title=ReencryptExtracts.name) group.add_argument("site_name", metavar="site-name", help=_("editsite.options.site-name")) - @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() server = session.create_session(args, logger) @@ -32,7 +32,7 @@ def run_command(args): logger.info(_("reencryptextracts.status").format(site_item.name)) job = server.sites.encrypt_extracts(site_item.id) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract re-encryption queued with JobID: {}".format(job.id)) diff --git a/tabcmd/commands/extracts/refresh_extracts_command.py b/tabcmd/commands/extracts/refresh_extracts_command.py index 4286e61f..ab978978 100644 --- a/tabcmd/commands/extracts/refresh_extracts_command.py +++ b/tabcmd/commands/extracts/refresh_extracts_command.py @@ -24,9 +24,9 @@ def define_args(refresh_extract_parser): set_parent_project_arg(group) set_sync_wait_options(group) - @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() server = session.create_session(args, logger) @@ -38,15 +38,16 @@ def run_command(args): try: item = Extracts.get_wb_or_ds_for_extracts(args, logger, server) + job: TSC.JobItem if args.datasource: logger.info(_("refreshextracts.status_refreshed").format(_("content_type.datasource"), args.datasource)) - job: TSC.JobItem = server.datasources.refresh(item.id, incremental_refresh) + job = server.datasources.refresh(item.id, incremental_refresh) else: - job: TSC.JobItem = server.workbooks.refresh(item.id, incremental_refresh) + job = server.workbooks.refresh(item.id, incremental_refresh) logger.info(_("refreshextracts.status_refreshed").format(_("content_type.workbook"), args.workbook)) except Exception as e: - Errors.exit_with_error(logger, _("refreshextracts.errors.error"), e) + Errors.exit_with_error(logger, _("refreshextracts.errors.error"), exception=e) logger.info(_("common.output.job_queued_success")) logger.debug("Extract refresh queued with JobID: {}".format(job.id)) @@ -59,4 +60,4 @@ def run_command(args): logger.info("Job completed: ") logger.info(job_done) except Exception as je: - Errors.exit_with_error(logger, je) + Errors.exit_with_error(logger, exception=je) diff --git a/tabcmd/commands/group/create_group_command.py b/tabcmd/commands/group/create_group_command.py index 36d7c5f8..ff04039f 100644 --- a/tabcmd/commands/group/create_group_command.py +++ b/tabcmd/commands/group/create_group_command.py @@ -20,9 +20,9 @@ def define_args(create_group_parser): args_group = create_group_parser.add_argument_group(title=CreateGroupCommand.name) args_group.add_argument("name") - @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() server = session.create_session(args, logger) diff --git a/tabcmd/commands/group/delete_group_command.py b/tabcmd/commands/group/delete_group_command.py index c1fca0b8..bc3f1f49 100644 --- a/tabcmd/commands/group/delete_group_command.py +++ b/tabcmd/commands/group/delete_group_command.py @@ -20,9 +20,9 @@ def define_args(delete_group_parser): args_group = delete_group_parser.add_argument_group(title=DeleteGroupCommand.name) args_group.add_argument("name") - @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() server = session.create_session(args, logger) @@ -33,4 +33,4 @@ def run_command(args): server.groups.delete(group_id) logger.info(_("common.output.succeeded")) except Exception as e: - Errors.exit_with_error(logger, _("errors.bad_request.detail.generic_delete_groups_error"), e) + Errors.exit_with_error(logger, _("errors.bad_request.detail.generic_delete_groups_error"), exception=e) diff --git a/tabcmd/commands/project/create_project_command.py b/tabcmd/commands/project/create_project_command.py index ef40875f..e5096061 100644 --- a/tabcmd/commands/project/create_project_command.py +++ b/tabcmd/commands/project/create_project_command.py @@ -25,9 +25,9 @@ def define_args(create_project_parser): set_parent_project_arg(create_project_parser) set_description_arg(create_project_parser) - @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() server = session.create_session(args, logger) @@ -36,9 +36,10 @@ def run_command(args): if args.parent_project_path: try: logger.debug(_("tabcmd.find.parent_project").format(args.parent_project_path)) + # use none for the readable name because we are just looking for the parent path parent = Server.get_project_by_name_and_parent_path(logger, server, None, args.parent_project_path) except Exception as exc: - Errors.exit_with_error(logger, exc) + Errors.exit_with_error(logger, exception=exc) readable_name = "{0}/{1}".format(args.parent_project_path, args.project_name) parent_id = parent.id logger.debug("parent project = `{0}`, id = {1}".format(args.parent_project_path, parent_id)) @@ -54,6 +55,6 @@ def run_command(args): logger.info(_("errors.xmlapi.already_exists").format(_("content_type.project"), args.project_name)) logger.info(_("common.output.succeeded")) else: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) return project_item diff --git a/tabcmd/commands/project/delete_project_command.py b/tabcmd/commands/project/delete_project_command.py index b107406c..550b15b1 100644 --- a/tabcmd/commands/project/delete_project_command.py +++ b/tabcmd/commands/project/delete_project_command.py @@ -22,9 +22,9 @@ def define_args(delete_project_parser): args_group.add_argument("project_name", metavar="project-name", help=_("createproject.options.name")) set_parent_project_arg(args_group) - @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() server = session.create_session(args, logger) @@ -37,7 +37,7 @@ def run_command(args): logger, server, args.project_name, args.parent_project_path ) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) project_id = project.id try: @@ -45,4 +45,4 @@ def run_command(args): server.projects.delete(project_id) logger.info(_("common.output.succeeded")) except Exception as e: - Errors.exit_with_error(logger, "tabcmd.result.failure.delete.project", e) + Errors.exit_with_error(logger, "tabcmd.result.failure.delete.project", exception=e) diff --git a/tabcmd/commands/project/publish_samples_command.py b/tabcmd/commands/project/publish_samples_command.py index a8532934..273575b2 100644 --- a/tabcmd/commands/project/publish_samples_command.py +++ b/tabcmd/commands/project/publish_samples_command.py @@ -26,9 +26,9 @@ def define_args(publish_samples_parser): ) set_parent_project_arg(args_group) # args.parent_project_name - @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() server = session.create_session(args, logger) diff --git a/tabcmd/commands/server.py b/tabcmd/commands/server.py index eb466183..76aba2f4 100644 --- a/tabcmd/commands/server.py +++ b/tabcmd/commands/server.py @@ -160,7 +160,9 @@ def get_filename_extension_if_tableau_type(logger, filename): ) @staticmethod - def get_project_by_name_and_parent_path(logger, server, project_name: str, parent_path: str) -> TSC.ProjectItem: + def get_project_by_name_and_parent_path( + logger, server, project_name: Optional[str], parent_path: Optional[str] + ) -> TSC.ProjectItem: logger.debug(_("content_type.project") + ":{0}, {1}".format(parent_path, project_name)) if not parent_path: if not project_name: diff --git a/tabcmd/commands/site/create_site_command.py b/tabcmd/commands/site/create_site_command.py index f684ba1f..947534d9 100644 --- a/tabcmd/commands/site/create_site_command.py +++ b/tabcmd/commands/site/create_site_command.py @@ -22,9 +22,9 @@ def define_args(create_site_parser): args_group.add_argument("new_site_name", metavar="site-name", help=_("editsite.options.site-name")) set_common_site_args(args_group) - @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() server = session.create_session(args, logger) @@ -46,4 +46,4 @@ def run_command(args): if Errors.is_resource_conflict(e) and args.continue_if_exists: logger.info(_("createsite.errors.site_name_already_exists").format(args.new_site_name)) return - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/commands/site/delete_site_command.py b/tabcmd/commands/site/delete_site_command.py index 672d0141..6404d4b2 100644 --- a/tabcmd/commands/site/delete_site_command.py +++ b/tabcmd/commands/site/delete_site_command.py @@ -20,9 +20,9 @@ def define_args(delete_site_parser): args_group = delete_site_parser.add_argument_group(title=DeleteSiteCommand.name) args_group.add_argument("site_name_to_delete", metavar="site-name", help=_("tabcmd.options.delete_site.name")) - @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() server = session.create_session(args, logger) @@ -33,4 +33,4 @@ def run_command(args): server.sites.delete(target_site_id) logger.info(_("tabcmd.deletesite.success").format(args.site_name_to_delete)) except Exception as e: - Errors.exit_with_error(logger, _("tabcmd.deletesite.error"), e) + Errors.exit_with_error(logger, _("tabcmd.deletesite.error"), exception=e) diff --git a/tabcmd/commands/site/edit_site_command.py b/tabcmd/commands/site/edit_site_command.py index ab6451ed..8b92a8ae 100644 --- a/tabcmd/commands/site/edit_site_command.py +++ b/tabcmd/commands/site/edit_site_command.py @@ -25,9 +25,9 @@ def define_args(edit_site_parser): set_common_site_args(args_group) set_site_status_arg(args_group) - @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() server = session.create_session(args, logger) @@ -47,4 +47,4 @@ def run_command(args): logger.info(_("common.output.succeeded")) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/commands/site/list_command.py b/tabcmd/commands/site/list_command.py index ef07a940..96a54bef 100644 --- a/tabcmd/commands/site/list_command.py +++ b/tabcmd/commands/site/list_command.py @@ -31,8 +31,8 @@ def define_args(list_parser): data_group.add_argument("-d", "--details", action="store_true", help=_("tabcmd.listing.help.details")) data_group.add_argument("-a", "--address", action="store_true", help=_("tabcmd.listing.help.address")) - @staticmethod - def run_command(args): + @classmethod + def run_command(cls, args): logger = log(__name__, args.logging_level) logger.debug(_("tabcmd.launching")) session = Session() @@ -87,7 +87,7 @@ def run_command(args): # TODO: do we want this line if it is csv output? logger.info(_("tabcmd.listing.summary").format(len(items), content_type)) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) @staticmethod def format_children_listing(args, server, content_type, item): diff --git a/tabcmd/commands/site/list_sites_command.py b/tabcmd/commands/site/list_sites_command.py index 646a41db..6f204483 100644 --- a/tabcmd/commands/site/list_sites_command.py +++ b/tabcmd/commands/site/list_sites_command.py @@ -21,9 +21,9 @@ def define_args(list_site_parser): group = list_site_parser.add_argument_group(title=ListSiteCommand.name) set_site_detail_option(group) - @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() server = session.create_session(args, logger) @@ -35,4 +35,4 @@ def run_command(args): if args.get_extract_encryption_mode: logger.info("EXTRACTENCRYPTION: {}".format(site.extract_encryption_mode)) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) diff --git a/tabcmd/commands/user/add_users_command.py b/tabcmd/commands/user/add_users_command.py index be3d5d58..ce6d5643 100644 --- a/tabcmd/commands/user/add_users_command.py +++ b/tabcmd/commands/user/add_users_command.py @@ -3,6 +3,7 @@ from tabcmd.execution.localize import _ from tabcmd.execution.logger_config import log from .user_data import UserCommand +import sys class AddUserCommand(UserCommand): @@ -20,13 +21,19 @@ def define_args(add_user_parser): set_users_file_arg(args_group) set_completeness_options(args_group) - @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() server = session.create_session(args, logger) logger.info(_("addusers.status").format(args.users.name, args.name)) - UserCommand.act_on_users(logger, server, "added", server.groups.add_user, args) + try: + UserCommand.act_on_users(logger, server, "added", server.groups.add_user, args) + except Exception as e: + logger.error("Error while adding users: %s", e) + if e.__cause__ is not None: + logger.error("Caused by: %s", e.__cause__) + sys.exit(1) diff --git a/tabcmd/commands/user/create_site_users.py b/tabcmd/commands/user/create_site_users.py index 98772b20..c63436b0 100644 --- a/tabcmd/commands/user/create_site_users.py +++ b/tabcmd/commands/user/create_site_users.py @@ -26,9 +26,9 @@ def define_args(create_site_users_parser): set_completeness_options(args_group) UserCommand.set_auth_arg(args_group) - @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() server = session.create_session(args, logger) @@ -59,9 +59,10 @@ def run_command(args): if Errors.is_resource_conflict(e) and args.continue_if_exists: logger.debug(_("createsite.errors.site_name_already_exists").format(user_obj.name)) else: + logger.debug(type(e)) number_of_errors += 1 logger.debug(number_of_errors) - error_list.append(e.summary + ": " + e.detail) + error_list.append(e.__class__.__name__) # + ": " + e.__cause__ or "Unknown") logger.debug(error_list) logger.info(_("session.monitorjob.percent_complete").format(100)) logger.info(_("importcsvsummary.line.processed").format(number_of_users_listed)) diff --git a/tabcmd/commands/user/create_users_command.py b/tabcmd/commands/user/create_users_command.py index f44ee333..ab2a92a2 100644 --- a/tabcmd/commands/user/create_users_command.py +++ b/tabcmd/commands/user/create_users_command.py @@ -26,9 +26,9 @@ def define_args(create_users_parser): set_completeness_options(args_group) UserCommand.set_auth_arg(args_group) - @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() server = session.create_session(args, logger) diff --git a/tabcmd/commands/user/delete_site_users_command.py b/tabcmd/commands/user/delete_site_users_command.py index 6c9c112c..4b23594b 100644 --- a/tabcmd/commands/user/delete_site_users_command.py +++ b/tabcmd/commands/user/delete_site_users_command.py @@ -23,9 +23,9 @@ def define_args(delete_site_users_parser): set_users_file_positional(args_group) set_completeness_options(args_group) - @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() server = session.create_session(args, logger) diff --git a/tabcmd/commands/user/remove_users_command.py b/tabcmd/commands/user/remove_users_command.py index d9b86adc..c4e9c558 100644 --- a/tabcmd/commands/user/remove_users_command.py +++ b/tabcmd/commands/user/remove_users_command.py @@ -20,9 +20,9 @@ def define_args(remove_users_parser): set_users_file_arg(args_group) set_completeness_options(args_group) - @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() server = session.create_session(args, logger) diff --git a/tabcmd/execution/logger_config.py b/tabcmd/execution/logger_config.py index 17fe6907..e20b10a1 100644 --- a/tabcmd/execution/logger_config.py +++ b/tabcmd/execution/logger_config.py @@ -1,4 +1,5 @@ import logging +import logging.handlers import os path = os.path.dirname(os.path.abspath(__file__)) @@ -48,14 +49,26 @@ def configure_log(name: str, logging_level_input: str): if logging_level is not logging.INFO: FORMATS[logging.INFO] = "%(filename)-10s: %(message)-30s" - logging.basicConfig( - level=logging_level, format=log_format, filename="tabcmd.log", filemode="a", datefmt="%Y-%m" "-%d " "%H:%M:%S" - ) - console = logging.StreamHandler() - console.setLevel(logging_level) - console.setFormatter(logging.Formatter(log_format)) - logging.getLogger(name).addHandler(console) - return logging.getLogger(name) + root_logger = logging.getLogger() + root_logger.setLevel(logging_level) + + # Only add handlers once to avoid duplicates on repeated configure_log calls + if not any(isinstance(h, logging.handlers.RotatingFileHandler) for h in root_logger.handlers): + file_handler = logging.handlers.RotatingFileHandler("tabcmd.log", maxBytes=1_000_000, backupCount=5) + file_handler.setLevel(logging_level) + file_handler.setFormatter(logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S")) + root_logger.addHandler(file_handler) + + named_logger = logging.getLogger(name) + if not any( + isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler) for h in named_logger.handlers + ): + console = logging.StreamHandler() + console.setLevel(logging_level) + console.setFormatter(logging.Formatter(log_format)) + named_logger.addHandler(console) + + return named_logger def log(file_name, logging_level): diff --git a/tabcmd/execution/parent_parser.py b/tabcmd/execution/parent_parser.py index 495f8ba2..14f55506 100644 --- a/tabcmd/execution/parent_parser.py +++ b/tabcmd/execution/parent_parser.py @@ -192,10 +192,9 @@ def include_help(self): class Help: - parser = None # This needs to have access to the parser when it gets called def __init__(self, _parser: ParentParser): - self.parser = _parser + self.parser: ParentParser = _parser def run_command(self, args): logger = log(__name__, "info") diff --git a/tabcmd/tabcmd.py b/tabcmd/tabcmd.py index 70b8b63d..c62dfb09 100644 --- a/tabcmd/tabcmd.py +++ b/tabcmd/tabcmd.py @@ -17,11 +17,14 @@ def main(): print("Keyboard Interrupt: exiting") sys.exit(1) except Exception as e: + traceback_info = ( + f"at line {e.__traceback__.tb_lineno} of {__file__}" if e.__traceback__ else "traceback unavailable" + ) sys.stderr.writelines( [ "ERROR\n", "Unhandled exception: {}\n".format(type(e).__name__), - f"at line {e.__traceback__.tb_lineno} of {__file__}: {e}\n", + f"{traceback_info}: {e}\n", ] ) sys.exit(1) diff --git a/tabcmd/version.py b/tabcmd/version.py index f7c977ca..3b540282 100644 --- a/tabcmd/version.py +++ b/tabcmd/version.py @@ -1,7 +1,12 @@ -from importlib.metadata import version as get_version, PackageNotFoundError +from importlib.metadata import PackageNotFoundError, version as get_version +version: str = "unknown" try: version = get_version("tabcmd") except PackageNotFoundError: - version = "2.0.0" - pass + # importlib.metadata is unavailable in PyInstaller bundles; fall back to the + # _version.py file that setuptools_scm writes at build time. + try: + from tabcmd._version import version + except ImportError: + version = "0.0" diff --git a/tests/commands/test_geturl_utils.py b/tests/commands/test_geturl_utils.py index b5e8dd54..60f5a868 100644 --- a/tests/commands/test_geturl_utils.py +++ b/tests/commands/test_geturl_utils.py @@ -178,7 +178,7 @@ def test_parse_get_url_to_view_parts_with_spaces(self): DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(mock_logger, url) def test_parse_get_url_to_view_parts_without_slashes(self): - url = "views\wb name\\view-name" + url = "views\\wb name\\view-name" with self.assertRaises(SystemExit): DatasourcesWorkbooksAndViewsUrlParser.parse_get_view_url_to_view_and_custom_view_parts(mock_logger, url) @@ -447,3 +447,48 @@ def test_save_to_data_file(self): mock_content = mock.MagicMock() filename = "test_out.csv" ExportCommand.save_to_data_file(mock_logger, mock_content, filename) + + +class FilenameExtensionTests(unittest.TestCase): + # get_file_type_from_filename(logger, url, file_name) + # file_name extension takes precedence; falls back to url extension; exits if neither recognized + + def test_filename_extension_overrides_url_extension(self): + mock_logger = mock.MagicMock() + result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename( + mock_logger, "view.png", "output.csv" + ) + assert result == "csv" + + def test_filename_without_extension_falls_back_to_url_extension(self): + mock_logger = mock.MagicMock() + result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, "view.png", "output") + assert result == "png" + + def test_no_filename_falls_back_to_url_extension(self): + mock_logger = mock.MagicMock() + result = DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, "report.pdf", None) + assert result == "pdf" + + def test_no_valid_extension_anywhere_exits(self): + mock_logger = mock.MagicMock() + with self.assertRaises(SystemExit): + DatasourcesWorkbooksAndViewsUrlParser.get_file_type_from_filename(mock_logger, "view.xyz", "output.abc") + + +class ImageFormatTests(unittest.TestCase): + """Tests for TSC 0.41 ImageRequestOptions.Format constants and format parameter.""" + + def test_image_format_png(self): + assert TSC.ImageRequestOptions.Format.PNG == "PNG" + + def test_image_format_svg(self): + assert TSC.ImageRequestOptions.Format.SVG == "SVG" + + def test_image_request_options_format_parameter(self): + opts = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + assert opts.format == TSC.ImageRequestOptions.Format.SVG + + def test_image_request_options_default_format(self): + opts = TSC.ImageRequestOptions() + assert opts.format is None diff --git a/tests/commands/test_listing_commands.py b/tests/commands/test_listing_commands.py index 766e12fb..5d638cb9 100644 --- a/tests/commands/test_listing_commands.py +++ b/tests/commands/test_listing_commands.py @@ -1,15 +1,13 @@ import argparse from unittest.mock import MagicMock, patch -import io -import sys from tabcmd.commands.site.list_command import ListCommand from tabcmd.commands.site.list_sites_command import ListSiteCommand -from tabcmd.execution.localize import set_client_locale import unittest from unittest import mock +mock_args = argparse.Namespace() mock_logger = mock.MagicMock() fake_item = mock.MagicMock() diff --git a/tests/commands/test_logger_config.py b/tests/commands/test_logger_config.py new file mode 100644 index 00000000..531453a3 --- /dev/null +++ b/tests/commands/test_logger_config.py @@ -0,0 +1,43 @@ +import logging +import logging.handlers +import unittest + +from tabcmd.execution.logger_config import configure_log + + +class LoggerConfigTests(unittest.TestCase): + def setUp(self): + root = logging.getLogger() + for handler in root.handlers[:]: + handler.close() + root.removeHandler(handler) + + def test_rotating_file_handler_present(self): + configure_log("test_rotate", "DEBUG") + root_handlers = logging.getLogger().handlers + rotating = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + self.assertEqual(len(rotating), 1, "Expected exactly one RotatingFileHandler on the root logger") + + def test_rotating_file_handler_settings(self): + configure_log("test_rotate_settings", "DEBUG") + root_handlers = logging.getLogger().handlers + rotating = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + self.assertEqual(len(rotating), 1, "Expected exactly one RotatingFileHandler on the root logger") + handler = rotating[0] + self.assertEqual(handler.maxBytes, 1_000_000) + self.assertEqual(handler.backupCount, 5) + + def test_no_duplicate_handlers_on_repeated_calls(self): + configure_log("test_dup", "DEBUG") + configure_log("test_dup", "DEBUG") + root_handlers = logging.getLogger().handlers + rotating = [h for h in root_handlers if isinstance(h, logging.handlers.RotatingFileHandler)] + self.assertEqual(len(rotating), 1, "Repeated configure_log calls must not add duplicate handlers") + + def test_root_logger_level_set(self): + configure_log("test_level", "WARNING") + self.assertEqual(logging.getLogger().level, logging.WARNING) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/commands/test_projects_utils.py b/tests/commands/test_projects_utils.py index 5e92a10d..ef434c5c 100644 --- a/tests/commands/test_projects_utils.py +++ b/tests/commands/test_projects_utils.py @@ -19,7 +19,6 @@ class ProjectsTest(unittest.TestCase): @staticmethod def test_parent_path_to_list(): - assert Server._parse_project_path_to_list(None) == [] assert Server._parse_project_path_to_list("") == [] assert Server._parse_project_path_to_list("parent") == ["parent"] assert Server._parse_project_path_to_list("parent/child") == ["parent", "child"] diff --git a/tests/commands/test_run_commands.py b/tests/commands/test_run_commands.py index 6960c8fe..f1777d13 100644 --- a/tests/commands/test_run_commands.py +++ b/tests/commands/test_run_commands.py @@ -4,7 +4,13 @@ import tableauserverclient as TSC from tabcmd.commands.auth import login_command, logout_command -from tabcmd.commands.datasources_and_workbooks import delete_command, export_command, get_url_command, publish_command +from tabcmd.commands.datasources_and_workbooks import ( + delete_command, + export_command, + get_url_command, + publish_command, + runschedule_command, +) from tabcmd.commands.extracts import ( create_extracts_command, delete_extracts_command, @@ -281,7 +287,7 @@ def test_create_group_already_exists(self, mock_session, mock_server): mock_args.continue_if_exists = True mock_args.name = "name" - mock_server.groups.create.return_value = TSC.ServerResponseError(409, "already exists", "detail") + mock_server.groups.create.return_value = TSC.ServerResponseError("409", "already exists", "detail") create_group_command.CreateGroupCommand.run_command(mock_args) mock_session.assert_called() @@ -291,7 +297,7 @@ def test_create_project_already_exists(self, mock_session, mock_server): mock_args.project_name = "repeat" mock_args.parent_project_path = "" mock_args.description = "none" - mock_server.projects.create.return_value = TSC.ServerResponseError(409, "already exists", "detail") + mock_server.projects.create.return_value = TSC.ServerResponseError("409", "already exists", "detail") create_project_command.CreateProjectCommand.run_command(mock_args) mock_session.assert_called() @@ -303,7 +309,7 @@ def test_create_site_already_exists(self, mock_session, mock_server): mock_args.site_admin_user_management = None mock_args.user_quota = None mock_args.storage_quota = None - mock_server.sites.create.return_value = TSC.ServerResponseError(409, "already exists", "detail") + mock_server.sites.create.return_value = TSC.ServerResponseError("409", "already exists", "detail") create_site_command.CreateSiteCommand.run_command(mock_args) mock_session.assert_called() diff --git a/tests/commands/test_server_item_selection.py b/tests/commands/test_server_item_selection.py index c9d76df8..20c5a8c4 100644 --- a/tests/commands/test_server_item_selection.py +++ b/tests/commands/test_server_item_selection.py @@ -1,3 +1,5 @@ +from typing import cast + import pytest import tableauserverclient as TSC @@ -48,7 +50,7 @@ def test_filters_datasources_by_exact_project_id_when_container_provided(): ] endpoint = _DatasourcesEndpoint(items) - results = Server.get_items_by_name(logger, endpoint, "Sales", container) + results = Server.get_items_by_name(logger, endpoint, "Sales", cast(TSC.ProjectItem, container)) assert len(results) == 1 assert results[0].project_id == "proj-A" @@ -64,7 +66,7 @@ def test_raises_not_found_when_no_items_match_container_after_disambiguation(): endpoint = _DatasourcesEndpoint(items) with pytest.raises(TSC.ServerResponseError): - Server.get_items_by_name(logger, endpoint, "Sales", container) + Server.get_items_by_name(logger, endpoint, "Sales", cast(TSC.ProjectItem, container)) def test_nested_projects_same_leaf_name_returns_correct_datasource_per_container(): @@ -84,11 +86,11 @@ def test_nested_projects_same_leaf_name_returns_correct_datasource_per_container endpoint = _DatasourcesEndpoint(items) # Each lookup should return exactly one item from the target project id - res_a = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_project_a) + res_a = Server.get_items_by_name(logger, endpoint, "my-datasource", cast(TSC.ProjectItem, cats_under_project_a)) assert len(res_a) == 1 and res_a[0].project_id == "cats-A" - res_b = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_project_b) + res_b = Server.get_items_by_name(logger, endpoint, "my-datasource", cast(TSC.ProjectItem, cats_under_project_b)) assert len(res_b) == 1 and res_b[0].project_id == "cats-B" - res_root = Server.get_items_by_name(logger, endpoint, "my-datasource", cats_under_root) + res_root = Server.get_items_by_name(logger, endpoint, "my-datasource", cast(TSC.ProjectItem, cats_under_root)) assert len(res_root) == 1 and res_root[0].project_id == "cats-root" diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 401f16ae..646e2a99 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -154,9 +154,9 @@ def test__create_new_token_credential_succeeds_new_token(self, mock_pass): def test__create_new_username_credential_succeeds_new_password(self, mock_pass): test_password = "pword1" - active_session = Session() + active_session: Session = Session() active_session.username = "user" - active_session.site = "" + active_session.site_id = "" auth = active_session._create_new_credential(test_password, Session.PASSWORD_CRED_TYPE) assert auth is not None @@ -175,7 +175,7 @@ def test__create_new_token_credential_succeeds_from_self(self, mock_pass): def test__create_new_username_credential_succeeds_from_self(self, mock_pass): active_session = Session() active_session.username = "user3" - active_session.site = "" + active_session.site_id = "" auth = active_session._create_new_credential(None, Session.PASSWORD_CRED_TYPE) assert mock_pass.has_been_called() assert auth is not None @@ -202,17 +202,23 @@ def test__create_new_username_credential_succeeds_from_args(self, mock_pass): class PromptingTests(unittest.TestCase): def test_show_prompt_if_user_didnt_say(self): test_args = Namespace(**vars(args_to_mock)) - assert Session._allow_prompt(test_args) is True, test_args + mock_session = Session() + mock_session._update_session_data(test_args) + assert mock_session._allow_prompt() is True, test_args def test_show_prompt_if_user_said_yes(self): test_args = Namespace(**vars(args_to_mock)) test_args.prompt = True - assert Session._allow_prompt(test_args) is True, test_args + mock_session = Session() + mock_session._update_session_data(test_args) + assert mock_session._allow_prompt() is True, test_args def test_dont_show_prompt_if_user_said_no(self): test_args = Namespace(**vars(args_to_mock)) test_args.no_prompt = True - assert Session._allow_prompt(test_args) is False, test_args + mock_session = Session() + mock_session._update_session_data(test_args) + assert mock_session._allow_prompt() is False, test_args """ @@ -397,7 +403,7 @@ def test_create_session_server_url_with_no_protocol_in_server_arg( args_server = "10ay.online.tableau.com/" test_args.server = args_server - expected_session_server_url = "http://" + args_server.rstrip("/") + expected_session_server_url = "https://" + args_server.rstrip("/") auth = new_session.create_session(test_args, None) assert auth is not None, auth @@ -419,7 +425,7 @@ def test_create_session_server_url_with_no_protocol_and_extra_path_in_server_arg args_server = "10ay.online.tableau.com/#/sitename/views/viewname" test_args.server = args_server - expected_session_server_url = "http://" + args_server.split("/")[0] + expected_session_server_url = "https://" + args_server.split("/")[0] auth = new_session.create_session(test_args, None) assert auth is not None, auth @@ -447,6 +453,82 @@ def test_create_session_server_url_with_extra_path_in_server_arg( assert auth is not None, auth assert new_session.server_url == expected_session_server_url + @mock.patch("tableauserverclient.Server") + def test_create_session_username_only_prompts_for_password( + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json + ): + """When username given, no password, no active session: should prompt via getpass.""" + name = "myuser" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + new_session.no_prompt = False + new_session.tableau_server = None # no active session + mock_pass.return_value = "prompted_password" + mock_tsc.return_value.auth.sign_in.return_value = mock.MagicMock() + + test_args = Namespace(**vars(args_to_mock)) + test_args.username = name + test_args.no_prompt = False + + auth = new_session.create_session(test_args, None) + assert auth is not None, auth + mock_pass.assert_called() + + @mock.patch("tableauserverclient.Server") + def test_create_session_username_only_reuses_existing_session( + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json + ): + """When username given with no password but an active session exists: reuse it, no prompt.""" + name = "myuser" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + new_session.tableau_server = mock_tsc() + new_session.user_id = "some-user-id" + _set_mock_signin_validation_succeeds(new_session.tableau_server, name) + + test_args = Namespace(**vars(args_to_mock)) + test_args.username = name + test_args.no_prompt = False + + auth = new_session.create_session(test_args, None) + assert auth is not None, auth + mock_pass.assert_not_called() + + @mock.patch("tableauserverclient.Server") + def test_create_session_username_only_no_prompt_exits(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): + """When username given, no password, --no-prompt set, no active session: exit with error.""" + name = "myuser" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + new_session.no_prompt = True + new_session.tableau_server = None # no active session + + test_args = Namespace(**vars(args_to_mock)) + test_args.username = name + test_args.no_prompt = True + + with self.assertRaises(SystemExit): + new_session.create_session(test_args, None) + mock_pass.assert_not_called() + + @mock.patch("tableauserverclient.Server") + def test_create_session_username_and_password_no_prompt(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): + """When both username and password are given, getpass should not be called.""" + name = "myuser" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + new_session.tableau_server = mock_tsc() + _set_mock_signin_validation_succeeds(new_session.tableau_server, name) + + test_args = Namespace(**vars(args_to_mock)) + test_args.username = name + test_args.password = "mypassword" + test_args.no_prompt = False + + auth = new_session.create_session(test_args, None) + assert auth is not None, auth + mock_pass.assert_not_called() + def _set_mock_tsc_not_signed_in(mock_tsc): tsc_in_test = mock.MagicMock(name="manually mocking tsc") @@ -533,6 +615,37 @@ def test_timeout(self): assert connection._http_options["timeout"] == 10 +class GetServerBaseUrlTests(unittest.TestCase): + """Unit tests for Session._get_server_base_url.""" + + def setUp(self): + self.session = Session() + + def test_no_scheme_defaults_to_https(self): + result = self.session._get_server_base_url("my-tableau-server.com") + assert result == "https://my-tableau-server.com", result + + def test_explicit_http_stays_http(self): + result = self.session._get_server_base_url("http://my-tableau-server.com") + assert result == "http://my-tableau-server.com", result + + def test_explicit_https_stays_https(self): + result = self.session._get_server_base_url("https://my-tableau-server.com") + assert result == "https://my-tableau-server.com", result + + def test_no_scheme_with_path_component(self): + result = self.session._get_server_base_url("my-tableau-server.com/some/path") + assert result == "https://my-tableau-server.com", result + + def test_no_scheme_with_trailing_slash(self): + result = self.session._get_server_base_url("my-tableau-server.com/") + assert result == "https://my-tableau-server.com", result + + def test_https_with_path_strips_path(self): + result = self.session._get_server_base_url("https://my-tableau-server.com/some/path") + assert result == "https://my-tableau-server.com", result + + """ This is too slow for unit tests. @@ -549,6 +662,7 @@ def test_connection_times_out(self): new_session.create_session(test_args, None) + class CookieTests(unittest.TestCase): def test_no_file_if_no_cookie(self): diff --git a/tests/commands/test_user_utils.py b/tests/commands/test_user_utils.py index b22f7ff1..516ba3de 100644 --- a/tests/commands/test_user_utils.py +++ b/tests/commands/test_user_utils.py @@ -3,7 +3,7 @@ from tabcmd.commands.user.user_data import UserCommand, Userdata from tabcmd.execution.logger_config import log -from typing import List +from typing import List, Optional import io import tableauserverclient as TSC @@ -73,7 +73,8 @@ def test_get_user_detail_empty_line(self): def test_get_user_detail_standard(self): test_line = "username, pword, fname, license, admin, pub, email" - test_user: TSC.UserItem = UserCommand._parse_line(test_line) + test_user: Optional[TSC.UserItem] = UserCommand._parse_line(test_line) + assert test_user is not None assert test_user.name == "username", test_user.name assert test_user.fullname == "fname", test_user.fullname assert test_user.site_role == "Unlicensed", test_user.site_role @@ -81,7 +82,8 @@ def test_get_user_detail_standard(self): def test_get_user_details_only_username(self): test_line = "username" - test_user: TSC.UserItem = UserCommand._parse_line(test_line) + test_user: Optional[TSC.UserItem] = UserCommand._parse_line(test_line) + assert test_user is not None def test_populate_user_details_only_some(self): values = ["username", "", "", "creator", "admin"] diff --git a/tests/commands/test_version.py b/tests/commands/test_version.py new file mode 100644 index 00000000..4c57ada5 --- /dev/null +++ b/tests/commands/test_version.py @@ -0,0 +1,33 @@ +import ast +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).parent.parent.parent + + +class VersionConsistencyTests(unittest.TestCase): + def test_version_py_fallback_matches_pyproject_write_to(self): + """The fallback import in version.py must match the write_to path in pyproject.toml.""" + pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'write_to\s*=\s*"([^"]+)"', pyproject) + self.assertIsNotNone(match, "write_to not found in [tool.setuptools_scm] in pyproject.toml") + assert match is not None + + write_to_path = match.group(1) # e.g. "tabcmd/_version.py" + expected_module = write_to_path.replace("/", ".").removesuffix(".py") # e.g. "tabcmd._version" + + version_py = (ROOT / "tabcmd" / "version.py").read_text(encoding="utf-8") + tree = ast.parse(version_py) + + fallback_modules = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + fallback_modules.append(node.module) + + self.assertIn( + expected_module, + fallback_modules, + "version.py does not import from '{}' (the write_to path in pyproject.toml). " + "If write_to was changed, update the fallback import in tabcmd/version.py.".format(expected_module), + ) diff --git a/tests/e2e/language_tests.py b/tests/e2e/language_tests.py index 6fced7a3..5a701416 100644 --- a/tests/e2e/language_tests.py +++ b/tests/e2e/language_tests.py @@ -95,6 +95,12 @@ def _get_view(self, wb_name_on_server, sheet_name): arguments = [command, server_file] _test_command(arguments) + def _get_workbook(self, wb_name_on_server): + server_file = "/workbooks/" + wb_name_on_server + command = "get" + arguments = [command, server_file] + _test_command(arguments) + def _get_datasource(self, server_file): command = "get" server_file = "/datasources/" + server_file diff --git a/tests/e2e/tests_integration.py b/tests/e2e/tests_integration.py index 935751ae..136866b3 100644 --- a/tests/e2e/tests_integration.py +++ b/tests/e2e/tests_integration.py @@ -14,6 +14,7 @@ fakeserver = "http://SRVR" logging.disable(logging.ERROR) +logger = log("tests_integration", "info") # these are integration tests because they don't just run a command, they call interior methods # pytest -v tests/e2e/integration_tests.py @@ -65,7 +66,6 @@ def test_log_in(): no_cookie=False, ) test_session = Session() - logger = log(__class__.__name__, "info") server = test_session.create_session(args, logger) assert test_session.auth_token is not None assert test_session.site_id is not None @@ -96,7 +96,6 @@ def test_reuse_session(self): timeout=None, no_cookie=False, ) - logger = log(__class__.__name__, "info") test_session = Session() test_session.create_session(args, logger) assert test_session.auth_token is not None @@ -132,7 +131,6 @@ def test_read_password_file(self): # test_session.create_session(args) def test_get_project(self): - logger = log(__class__.__name__, "info") server = E2EServerTests.test_log_in() Server.get_project_by_name_and_parent_path(logger, server, "Default", None) diff --git a/tests/parsers/common_setup.py b/tests/parsers/common_setup.py index 619d4805..5ad87dca 100644 --- a/tests/parsers/common_setup.py +++ b/tests/parsers/common_setup.py @@ -1,33 +1,43 @@ +import unittest +from unittest import mock from tabcmd.execution import parent_parser from collections import namedtuple +import argparse +from typing import Optional, Type, Any +__all__ = ["ParserTest", "initialize_test_pieces", "mock_command_action", "encoding"] encoding = "utf-8-sig" -def mock_command_action(): +def mock_command_action() -> None: print("a mockery!") # basically replaces tabcmd_controller:initialize_parsers -def initialize_test_pieces(commandname, command_object): +def initialize_test_pieces(commandname: str, command_object: Type[Any]) -> argparse.ArgumentParser: manager = parent_parser.ParentParser() - parser = manager.get_root_parser() - mock_command = namedtuple("TestObject", "name, run_command, description, define_args") - mock_command.name = commandname - mock_command.run_command = mock_command_action - mock_command.description = "mock help text" - mock_command.define_args = command_object.define_args + parser_under_test = manager.get_root_parser() + MockCommand = namedtuple("MockCommand", "name, run_command, description, define_args") + mock_command = MockCommand( + name=commandname, + run_command=mock_command_action, + description="mock help text", + define_args=command_object.define_args, + ) manager.include(mock_command) - return parser + return parser_under_test -""" - base test cases for each parser: - has_required_arguments - (maybe) missing required arguments - has optional arguments - bad mix of optional arguments - has unknown arguments - """ +class ParserTest(unittest.TestCase): + parser_under_test: argparse.ArgumentParser + + """ + base test cases for each parser: + has_required_arguments + (maybe) missing required arguments + has optional arguments + bad mix of optional arguments + has unknown arguments + """ diff --git a/tests/parsers/test_login_parser.py b/tests/parsers/test_login_parser.py index 2276ef45..5c7cef5d 100644 --- a/tests/parsers/test_login_parser.py +++ b/tests/parsers/test_login_parser.py @@ -4,11 +4,11 @@ from tabcmd.commands.auth.login_command import LoginCommand from .common_setup import * -commandname = "login" +commandname: str = "login" @mock.patch("sys.argv", None) -class LoginParserTest(unittest.TestCase): +class LoginParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, LoginCommand) diff --git a/tests/parsers/test_logout_parser.py b/tests/parsers/test_logout_parser.py index 5d59eab7..4c1aaee5 100644 --- a/tests/parsers/test_logout_parser.py +++ b/tests/parsers/test_logout_parser.py @@ -8,7 +8,7 @@ commandname = "logout" -class LogoutParserTest(unittest.TestCase): +class LogoutParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, LogoutCommand) diff --git a/tests/parsers/test_parser_add_user.py b/tests/parsers/test_parser_add_user.py index fe57f669..5b07b14f 100644 --- a/tests/parsers/test_parser_add_user.py +++ b/tests/parsers/test_parser_add_user.py @@ -7,7 +7,7 @@ commandname = "addusers" -class AddUsersParserTest(unittest.TestCase): +class AddUsersParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, AddUserCommand) @@ -26,8 +26,8 @@ def test_add_users_parser_users_file(self): with mock.patch("builtins.open", mock.mock_open(read_data="test")) as open_file: mock_args = [commandname, "group-name", "--users", "users.csv"] args = self.parser_under_test.parse_args(mock_args) - self.assertEqual(args.name, "group-name"), args - open_file.assert_called_with("users.csv", "r", -1, encoding, None), args + self.assertEqual(args.name, "group-name") + open_file.assert_called_with("users.csv", "r", -1, encoding, None) @mock.patch("builtins.open") def test_add_user_parser_missing_group_name(self, filereader): diff --git a/tests/parsers/test_parser_create_extracts.py b/tests/parsers/test_parser_create_extracts.py index 8a6d3f49..94ca7a7e 100644 --- a/tests/parsers/test_parser_create_extracts.py +++ b/tests/parsers/test_parser_create_extracts.py @@ -6,7 +6,7 @@ commandname = "createextracts" -class CreateExtractsParserTest(unittest.TestCase): +class CreateExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateExtracts) diff --git a/tests/parsers/test_parser_create_group.py b/tests/parsers/test_parser_create_group.py index de05934d..db9500aa 100644 --- a/tests/parsers/test_parser_create_group.py +++ b/tests/parsers/test_parser_create_group.py @@ -6,7 +6,7 @@ commandname = "creategroup" -class CreateGroupParserTest(unittest.TestCase): +class CreateGroupParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateGroupCommand) diff --git a/tests/parsers/test_parser_create_project.py b/tests/parsers/test_parser_create_project.py index b02319c6..1a7551f6 100644 --- a/tests/parsers/test_parser_create_project.py +++ b/tests/parsers/test_parser_create_project.py @@ -6,7 +6,7 @@ commandname = "createproject" -class CreateProjectParserTest(unittest.TestCase): +class CreateProjectParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateProjectCommand) diff --git a/tests/parsers/test_parser_create_site.py b/tests/parsers/test_parser_create_site.py index ebe08813..5d4a50fb 100644 --- a/tests/parsers/test_parser_create_site.py +++ b/tests/parsers/test_parser_create_site.py @@ -6,7 +6,7 @@ commandname = "createsite" -class CreateSiteParserTest(unittest.TestCase): +class CreateSiteParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateSiteCommand) diff --git a/tests/parsers/test_parser_create_site_users.py b/tests/parsers/test_parser_create_site_users.py index 3fa4dc04..93a51db2 100644 --- a/tests/parsers/test_parser_create_site_users.py +++ b/tests/parsers/test_parser_create_site_users.py @@ -7,7 +7,7 @@ commandname = "createsiteusers" -class CreateSiteUsersParserTest(unittest.TestCase): +class CreateSiteUsersParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateSiteUsersCommand) diff --git a/tests/parsers/test_parser_create_user.py b/tests/parsers/test_parser_create_user.py index 7b67f24f..d4a533a1 100644 --- a/tests/parsers/test_parser_create_user.py +++ b/tests/parsers/test_parser_create_user.py @@ -7,7 +7,7 @@ commandname = "createusers" -class CreateUsersTest(unittest.TestCase): +class CreateUsersTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, CreateUsersCommand) diff --git a/tests/parsers/test_parser_decrypt_extracts.py b/tests/parsers/test_parser_decrypt_extracts.py index 1fdd5dc2..f90558c7 100644 --- a/tests/parsers/test_parser_decrypt_extracts.py +++ b/tests/parsers/test_parser_decrypt_extracts.py @@ -6,7 +6,7 @@ commandname = "decryptextracts" -class DecryptExtractsParserTest(unittest.TestCase): +class DecryptExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DecryptExtracts) diff --git a/tests/parsers/test_parser_delete.py b/tests/parsers/test_parser_delete.py index 9d6900d0..edf93ea3 100644 --- a/tests/parsers/test_parser_delete.py +++ b/tests/parsers/test_parser_delete.py @@ -6,7 +6,7 @@ commandname = "delete" -class DeleteParserTest(unittest.TestCase): +class DeleteParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteCommand) diff --git a/tests/parsers/test_parser_delete_extracts.py b/tests/parsers/test_parser_delete_extracts.py index 1d71d187..2d12dbf8 100644 --- a/tests/parsers/test_parser_delete_extracts.py +++ b/tests/parsers/test_parser_delete_extracts.py @@ -6,7 +6,7 @@ commandname = "deleteextracts" -class DeleteExtractsParserTest(unittest.TestCase): +class DeleteExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteExtracts) diff --git a/tests/parsers/test_parser_delete_group.py b/tests/parsers/test_parser_delete_group.py index 368e3cce..865c7495 100644 --- a/tests/parsers/test_parser_delete_group.py +++ b/tests/parsers/test_parser_delete_group.py @@ -6,7 +6,7 @@ commandname = "deletegroup" -class DeleteGroupParserTestT(unittest.TestCase): +class DeleteGroupParserTestT(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteGroupCommand) diff --git a/tests/parsers/test_parser_delete_project.py b/tests/parsers/test_parser_delete_project.py index 5ecf43bc..3cb9e07c 100644 --- a/tests/parsers/test_parser_delete_project.py +++ b/tests/parsers/test_parser_delete_project.py @@ -6,7 +6,7 @@ commandname = "deleteproject" -class DeleteProjectParserTest(unittest.TestCase): +class DeleteProjectParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteProjectCommand) diff --git a/tests/parsers/test_parser_delete_site.py b/tests/parsers/test_parser_delete_site.py index 2d9562f7..26aa1f61 100644 --- a/tests/parsers/test_parser_delete_site.py +++ b/tests/parsers/test_parser_delete_site.py @@ -6,7 +6,7 @@ commandname = "deletesite" -class DeleteSiteParserTest(unittest.TestCase): +class DeleteSiteParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, DeleteSiteCommand) diff --git a/tests/parsers/test_parser_delete_site_user.py b/tests/parsers/test_parser_delete_site_user.py index c0d81b7e..e0de0c4f 100644 --- a/tests/parsers/test_parser_delete_site_user.py +++ b/tests/parsers/test_parser_delete_site_user.py @@ -7,7 +7,7 @@ commandname = "deletesiteusers" -class DeleteSiteUsersParserTest(unittest.TestCase): +class DeleteSiteUsersParserTest(ParserTest): csv = ("testname", "testpassword", "test", "test", "test", "test") @classmethod diff --git a/tests/parsers/test_parser_edit_site.py b/tests/parsers/test_parser_edit_site.py index 447deecf..98121e70 100644 --- a/tests/parsers/test_parser_edit_site.py +++ b/tests/parsers/test_parser_edit_site.py @@ -6,7 +6,7 @@ commandname = "editsites" -class EditSiteParserTest(unittest.TestCase): +class EditSiteParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, EditSiteCommand) diff --git a/tests/parsers/test_parser_encrypt_extracts.py b/tests/parsers/test_parser_encrypt_extracts.py index e87f8f2a..3162d688 100644 --- a/tests/parsers/test_parser_encrypt_extracts.py +++ b/tests/parsers/test_parser_encrypt_extracts.py @@ -6,7 +6,7 @@ commandname = "encryptextracts" -class EncryptExtractsParserTest(unittest.TestCase): +class EncryptExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, EncryptExtracts) diff --git a/tests/parsers/test_parser_export.py b/tests/parsers/test_parser_export.py index f90999a9..585f500c 100644 --- a/tests/parsers/test_parser_export.py +++ b/tests/parsers/test_parser_export.py @@ -6,7 +6,7 @@ commandname = "export" -class ExportParserTest(unittest.TestCase): +class ExportParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, ExportCommand) diff --git a/tests/parsers/test_parser_get_url.py b/tests/parsers/test_parser_get_url.py index 5467e236..3989cf7e 100644 --- a/tests/parsers/test_parser_get_url.py +++ b/tests/parsers/test_parser_get_url.py @@ -6,13 +6,13 @@ commandname = "listsites" -class GetUrlParserTest(unittest.TestCase): +class GetUrlParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, GetUrl) def test_get_url_parser_file(self): - mock_args = vars(argparse.Namespace(filename="helloworld")) + mock_args = [commandname, "--filename", "helloworld"] with self.assertRaises(SystemExit): args = self.parser_under_test.parse_args(mock_args) diff --git a/tests/parsers/test_parser_list_sites.py b/tests/parsers/test_parser_list_sites.py index 71b01234..03d86bc2 100644 --- a/tests/parsers/test_parser_list_sites.py +++ b/tests/parsers/test_parser_list_sites.py @@ -6,7 +6,7 @@ commandname = "listsites" -class ListSitesParserTest(unittest.TestCase): +class ListSitesParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, ListSiteCommand) diff --git a/tests/parsers/test_parser_publish.py b/tests/parsers/test_parser_publish.py index 65aaa20d..b26f911d 100644 --- a/tests/parsers/test_parser_publish.py +++ b/tests/parsers/test_parser_publish.py @@ -7,7 +7,7 @@ commandname = "Publish" -class PublishParserTest(unittest.TestCase): +class PublishParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, PublishCommand) diff --git a/tests/parsers/test_parser_publish_samples.py b/tests/parsers/test_parser_publish_samples.py index 43c4062a..e2b4a9c1 100644 --- a/tests/parsers/test_parser_publish_samples.py +++ b/tests/parsers/test_parser_publish_samples.py @@ -6,7 +6,7 @@ commandname = "publishsamples" -class PublishSamplesParserTest(unittest.TestCase): +class PublishSamplesParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, PublishSamplesCommand) diff --git a/tests/parsers/test_parser_reencrypt_extracts.py b/tests/parsers/test_parser_reencrypt_extracts.py index 79d82cda..01a91f6e 100644 --- a/tests/parsers/test_parser_reencrypt_extracts.py +++ b/tests/parsers/test_parser_reencrypt_extracts.py @@ -6,7 +6,7 @@ commandname = "reencryptextracts" -class ReencryptExtractsParserTest(unittest.TestCase): +class ReencryptExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, ReencryptExtracts) diff --git a/tests/parsers/test_parser_refresh_extracts.py b/tests/parsers/test_parser_refresh_extracts.py index a74ac760..2335fbfd 100644 --- a/tests/parsers/test_parser_refresh_extracts.py +++ b/tests/parsers/test_parser_refresh_extracts.py @@ -6,7 +6,7 @@ commandname = "refreshextracts" -class RefreshExtractsParserTest(unittest.TestCase): +class RefreshExtractsParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, RefreshExtracts) diff --git a/tests/parsers/test_parser_remove_user.py b/tests/parsers/test_parser_remove_user.py index 6441354a..f47666d6 100644 --- a/tests/parsers/test_parser_remove_user.py +++ b/tests/parsers/test_parser_remove_user.py @@ -7,7 +7,7 @@ commandname = "removeusers" -class RemoveUsersParserTest(unittest.TestCase): +class RemoveUsersParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, RemoveUserCommand) diff --git a/tests/parsers/test_parser_runschedule.py b/tests/parsers/test_parser_runschedule.py index 445dfb58..73a4bd90 100644 --- a/tests/parsers/test_parser_runschedule.py +++ b/tests/parsers/test_parser_runschedule.py @@ -6,7 +6,7 @@ commandname = "runschedule" -class RunScheduleParserTest(unittest.TestCase): +class RunScheduleParserTest(ParserTest): @classmethod def setUpClass(cls): cls.parser_under_test = initialize_test_pieces(commandname, RunSchedule)