diff --git a/README.md b/README.md index e1703650..810f817c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ e.g * `tabcmd help` ###or -## Install on the command line (requires Python 3.7+) +## Install on the command line + +(Note: this requires Python 3.7+. Generally we will actively support versions of Python that are still in security support). ```shell pip install tabcmd @@ -83,21 +85,12 @@ see the user documentation at https://tableau.github.io/tabcmd/ ## Release Notes -Version 2.0 is the first version of tabcmd built in python. +Version 2 is the first version of tabcmd built in python. It is specifically targeted to support users of Tableau Online, who are required to have MFA enabled. -(MFA support is not available in tabcmd 2022.2). It does not yet fully replace the existing tabcmd client.\ -**Known gaps** -- handling custom views in get/export commands -- several commands that can only be run by a Server Admin: - - editdomain / listdomains - - initialuser - - reset_openid_sub - - runschedule - - set - - syncgroup - - upgradethumbnails - - validateidpmetadata +(MFA support is not available in the tabcmd program that ships with Tableau Server). +Version 2 does not yet fully replace the existing tabcmd client, in particular it **does not support most server admin actions**. +For known gaps in supported functionality, see the latest [release notes](https://github.com/tableau/tabcmd/releases) ## About diff --git a/contributing.md b/contributing.md index 723e8f86..417cd22c 100644 --- a/contributing.md +++ b/contributing.md @@ -1,26 +1,35 @@ # For developers * [Install tabcmd](#install-tabcmd) -* [Run tabcmd](#run-tabcmd) -* [Development commands](#development-commands) * [Contributing](#contributing) -* [To add a new command](#to-add-a-new-command) -* [Why Python\?](#why-python) -* [Project Structure](#project-structure) - +* [Development](#development) + * [Dev scripts](#dev-scripts) + * [Why Python\?](#why-python) + * [Project structure](#project-structure) + * [To add a new command](#to-add-a-new-command) + * [Localization](#localization) +* [Releases](#releases) + * [Versioning](#versioning) + * [Packaging](#packaging) + + +These instructions are for people who want to download the code and edit it directly. If you are interested in tabcmd but not the code, see [here](Readme.md). +####To work with tabcmd, you need to have **Python 3.7+** installed. -## Install tabcmd -These instructions are only necessary if you want to download the code and run it directly. If you are interested in tabcmd but not the code, see [here](Readme.md). -####To work with tabcmd, you need to have **Python 3.7+** installed. +## Contributing -### +Code contributions and improvements by the community are welcomed! +See the LICENSE file for current open-source licensing and use information. +Before we can accept pull requests from contributors, we require a signed [Contributor License Agreement (CLA)](http://tableau.github.io/contributing.html). + ## Development -To work on the tabcmd code, use these scripts. On Windows, +### Dev scripts +To work on the tabcmd code, use these scripts. _(note that running mypy and black is required for code being submitted to the repo)_ - build @@ -38,42 +47,14 @@ _(note that running mypy and black is required for code being submitted to the r - do test coverage calculation (https://coverage.readthedocs.io/en/6.3.2) > bin/coverage.sh -- To trigger publishing to pypi tag a commit on main with 'pypi'. Versioning is done with -- setuptools_scm so it will be a x.y.dev0 pre-release version unless you first tag the -- commit with a new version tag. e.g -> git tag -d pypi && git push --delete origin pypi -> git tag v2.0.4 && git tag pypi && git push --tags - -# Missing step: a job that is triggered by pypi tag and creates a release -# Missing trigger: when pypi-release is done, begin the app smoke test -- packaging to an exe is done with pyinstaller. You can only build an executable for -- the platform you build on. -- On github this job is triggered by creating a release (or manually) -> doit version <-- produce a current version and metadata file to package -> pyinstaller tabcmd-windows.spec .... # see package.yml for OS-specific arguments - - Packaging produces dist/tabcmd.exe (or equivalent). -- Run the package -> dist/tabcmd/tabcmd.exe --help - - -## Why Python? +### Why Python? * Cross-platform * Build on our existing Python [Tableau Server Client](https://github.com/tableau/server-client-python/) -## Contributing - -Code contributions and improvements by the community are welcomed! - -See the LICENSE file for current open-source licensing and use information. - -Before we can accept pull requests from contributors, we require a signed [Contributor License Agreement (CLA)](http://tableau.github.io/contributing.html). - - -## Project structure +### Project structure The core design principles for this app are - it must provide the functionality of the instance of tabcmd, with drop-in replacement CLI options - it should be able to call [tsc](https://github.com/tableau/server-client-python/) for all server actions @@ -84,12 +65,43 @@ The core design principles for this app are 3. the 'commands' module contains the logic required to translate the tabcmd CLI interface into calls to tsc. This is completely dissociated from the parsers, and could theoretically be called from a completely different interface. 4. The 'execution' module is the core logic. TabcmdController gets an argparse parser, then attaches all the defined parsers to it and associates one command with each parser. -## To add a new command +### To add a new command 0. choose the single word that will be used as your command. Let's call this one `dream` 1. add parsers/dream_parser.py, and use methods from parent_parser to define the arguments 2. add commands/dreams/dream_command.py. It must have a method run_command.py(args) and the args object must contain all information needed from the user. 3. in map_of_parsers.py, add an entry for your new parser, like "dreams": DreamParser.dream_parser 4. in map_of_commands.py, add an entry for your new command, like "dream": ("dream", DreamCommand, "Think about picnics")," -5. add tests! +5. add tests! + +### Localization + +Strings are stored in /tabcmd/locales/[language]/*.properties by id and referred to in code as +> string = _("string.id") + +For runtime execution these files must be converted to .mo via .po +> doit mo +## Releases +To trigger publishing to pypi tag a commit on main with 'pypi'. +When pypi-release is done, begin the app smoke test action. + +### Versioning +Versioning is done with setuptools_scm and based on git tags. +It will be a x.y.dev0 pre-release version except for commits with a new version tag. e.g +> git tag v2.0.4 && git push --tags + +A new tag is created with the name of each release on github. + +### Packaging +Before packaging, we produce a current metadata file to include in the bundle +> doit version + +Packaging is done with pyinstaller, which will build an executable for the platform it runs on. +A github action runs on Mac, Windows and Linux to generate each executable. + +> pyinstaller tabcmd-windows.spec .... + +Packaging produces executables in the dist folder. To run: +> dist/tabcmd/tabcmd.exe --help + diff --git a/pyproject.toml b/pyproject.toml index 44cc53f2..4ead88bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = ["tabcmd"] tabcmd = ["tabcmd.locales/**/*.mo"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] extend-exclude = '^/bin/*' [tool.mypy] disable_error_code = [ @@ -30,14 +30,16 @@ description="A command line client for working with Tableau Server." authors = [{name="Tableau", email="github@tableau.com"}] license = {file = "LICENSE"} readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.7" # https://devguide.python.org/versions/ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10" + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12" ] dependencies = [ 'argparse', @@ -56,7 +58,7 @@ dependencies = [ ] [project.optional-dependencies] test = [ - "black", + "black>=22,<23", "mock", "mypy", "pytest>=7.0", diff --git a/tabcmd/__main__.py b/tabcmd/__main__.py index 50eace8f..5bd5fb1f 100644 --- a/tabcmd/__main__.py +++ b/tabcmd/__main__.py @@ -3,9 +3,8 @@ try: from tabcmd.tabcmd import main except ImportError as e: - print(sys.stderr, e) - print(sys.stderr, "Tabcmd needs to be run as a module, it cannot be run as a script") - print(sys.stderr, "Try running python -m tabcmd") + print("Exception thrown running program: `{}`, `{}`".format(e, e.__context__), file=sys.stderr) + print("[Possible cause: Tabcmd needs to be run as a module, try running `python -m tabcmd`]", file=sys.stderr) sys.exit(1) if __name__ == "__main__": diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index 2e7107ef..ba824fca 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -34,6 +34,7 @@ def __init__(self): 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 @@ -43,7 +44,7 @@ def __init__(self): self.no_prompt = False self.certificate = None self.no_certcheck = False - self.no_proxy = True + self.no_proxy = False self.proxy = None self.timeout = None @@ -69,14 +70,15 @@ def _update_session_data(self, args): if self.site_name == "default": self.site_name = "" self.logging_level = args.logging_level or self.logging_level - self.password_file = args.password_file + self.password_file = args.password_file or self.password_file + self.token_file = args.token_file or self.token_file self.token_name = args.token_name or self.token_name self.token_value = args.token_value or self.token_value - self.no_prompt = args.no_prompt or self.no_prompt + self.no_prompt = args.no_prompt # have to set this on every call? self.certificate = args.certificate or self.certificate - self.no_certcheck = args.no_certcheck or self.no_certcheck - self.no_proxy = args.no_proxy or self.no_proxy + self.no_certcheck = args.no_certcheck # have to set this on every call? + self.no_proxy = args.no_proxy # have to set this on every call? self.proxy = args.proxy or self.proxy self.timeout = self.timeout_as_integer(self.logger, args.timeout, self.timeout) @@ -136,8 +138,8 @@ def _create_new_credential(self, password, credential_type): def _create_new_token_credential(self): if self.token_value: token = self.token_value - elif self.password_file: - token = Session._read_password_from_file(self.password_file) + elif self.token_file: + token = Session._read_password_from_file(self.token_file) elif self._allow_prompt(): token = getpass.getpass("Token:") else: @@ -150,28 +152,45 @@ def _create_new_token_credential(self): else: Errors.exit_with_error(self.logger, _("session.errors.missing_arguments").format("token name")) - def _set_connection_options(self) -> TSC.Server: + def _open_connection_with_opts(self) -> TSC.Server: self.logger.debug("Setting up request options") - # args still to be handled here: - # proxy, --no-proxy, - # cert http_options: Dict[str, Any] = {"headers": {"User-Agent": "Tabcmd/{}".format(version)}} + if self.no_certcheck: http_options["verify"] = False urllib3.disable_warnings(category=InsecureRequestWarning) + + """ + Do we want to do the same format check as old tabcmd? + For now I think we can trust requests to handle a bad proxy + Pattern pattern = Pattern.compile("([^:]*):([0-9]*)"); + if not matches: + throw new ReportableException(m_i18n.getString("sessionoptions.errors.bad_proxy_format", proxyArg)); + """ if self.proxy: - # do we catch this error? "sessionoptions.errors.bad_proxy_format" - self.logger.debug("Setting proxy: ", self.proxy) + self.logger.debug("Setting http proxy: {}".format(self.proxy)) + proxies = {"http": self.proxy} + http_options["proxies"] = proxies + if self.no_proxy: + # override any proxy that was set + http_options["proxies"] = None + if self.timeout: http_options["timeout"] = self.timeout + + if self.certificate: + http_options["cert"] = self.certificate + try: self.logger.debug(http_options) + # this is the only place we open a connection to the server + # so the request options are all set for the session now tableau_server = TSC.Server(self.server_url, http_options=http_options) except Exception as e: self.logger.debug( - "Connection args: server {}, site {}, proxy {}, cert {}".format( - self.server_url, self.site_name, self.proxy, self.certificate + "Connection args: server {}, site {}, proxy {}/no-proxy {}, cert {}".format( + self.server_url, self.site_name, self.proxy, self.no_proxy, self.certificate ) ) Errors.exit_with_error(self.logger, "Failed to connect to server", e) @@ -196,11 +215,10 @@ def _verify_server_connection_unauthed(self): Errors.exit_with_error(self.logger, exception=e) def _create_new_connection(self) -> TSC.Server: - self.logger.info(_("session.new_session")) self._print_server_info() self.logger.info(_("session.connecting")) try: - self.tableau_server = self._set_connection_options() + self.tableau_server = self._open_connection_with_opts() except Exception as e: Errors.exit_with_error(self.logger, "Failed to connect to server", e) return self.tableau_server @@ -211,22 +229,28 @@ def _read_existing_state(self): def _print_server_info(self): self.logger.info("===== Server: {}".format(self.server_url)) + if self.proxy: + self.logger.info("===== Proxy: {}".format(self.proxy)) if self.username: self.logger.info("===== Username: {}".format(self.username)) + if self.certificate: + self.logger.info("===== Certificate: {}".format(self.certificate)) else: self.logger.info("===== Token Name: {}".format(self.token_name)) site_display_name = self.site_name or "Default Site" self.logger.info(_("dataconnections.classes.tableau_server_site") + ": {}".format(site_display_name)) + # side-effect: sets self.username def _validate_existing_signin(self): - self.logger.info(_("session.continuing_session")) # 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(): - response = self.tableau_server.users.get_by_id(self.user_id) - self.logger.debug(response) - if response.status_code.startswith("200"): - return self.tableau_server + 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") + self.username = server_user + + return self.tableau_server except TSC.ServerResponseError as e: self.logger.info(_("publish.errors.unexpected_server_response"), e) except Exception as e: @@ -245,12 +269,14 @@ def _sign_in(self, tableau_auth) -> TSC.Server: self.site_id = self.tableau_server.site_id self.user_id = self.tableau_server.user_id self.auth_token = self.tableau_server._auth_token - if not self.username: - self.username = self.tableau_server.users.get_by_id(self.user_id).name - self.logger.info(_("common.output.succeeded")) + success = self._validate_existing_signin() except Exception as e: - Errors.exit_with_error(self.logger, e) - self.logger.debug("Signed into {0}{1} as {2}".format(self.server_url, self.site_name, self.username)) + Errors.exit_with_error(self.logger, exception=e) + if success: + self.logger.info(_("common.output.succeeded")) + else: + Errors.exit_with_error(self.logger, message="Sign in failed") + return self.tableau_server def _get_saved_credentials(self): @@ -260,8 +286,7 @@ def _get_saved_credentials(self): credentials = self._create_new_token_credential() else: return None - if credentials: - self.logger.info(_("session.options.password-file")) + return credentials # external entry point: @@ -274,30 +299,24 @@ def create_session(self, args, logger): self.logger = logger or log(__class__.__name__, self.logging_level) credentials = None - if args.password: + if args.password or args.password_file: self._end_session() # we don't save the password anywhere, so we pass it along directly credentials = self._create_new_credential(args.password, Session.PASSWORD_CRED_TYPE) - elif args.password_file: - self._end_session() - if args.username: - credentials = self._create_new_credential(args.password, Session.PASSWORD_CRED_TYPE) - else: - credentials = self._create_new_credential(args.password, Session.TOKEN_CRED_TYPE) - elif args.token_value: + elif args.token_value or args.token_file: self._end_session() credentials = self._create_new_token_credential() else: # no login arguments given - look for saved info # maybe we're already signed in! if self.tableau_server: + self.logger.info(_("session.continuing_session")) signed_in_object = self._validate_existing_signin() - self.logger.debug(signed_in_object) - # or maybe we at least have the credentials saved + if not signed_in_object: credentials = self._get_saved_credentials() if credentials and not signed_in_object: - # logging in, not using an existing session + self.logger.debug("Signin details found:") self.tableau_server = self._create_new_connection() self._verify_server_connection_unauthed() signed_in_object = self._sign_in(credentials) @@ -333,6 +352,7 @@ def _clear_data(self): self.server = None self.last_login_using = None self.password_file = None + self.token_file = None self.last_command = None self.tableau_server = None @@ -358,7 +378,12 @@ def _read_from_json(self): try: with open(str(file_path), "r") as file_contents: data = json.load(file_contents) + if data is None or data == {}: + return content = data["tableau_auth"] + if content is None: + return + self._save_data_from_json(content) except json.JSONDecodeError as e: self._wipe_bad_json(e, "Error reading data from session file") except IOError as e: @@ -368,8 +393,11 @@ def _read_from_json(self): except Exception as e: self._wipe_bad_json(e, "Unexpected error reading session details from file") + def _save_data_from_json(self, content): try: auth = content[0] + if auth is None: + self._wipe_bad_json(ValueError(), "Empty session file") self.auth_token = auth["auth_token"] self.server_url = auth["server"] self.site_name = auth["site_name"] @@ -380,6 +408,7 @@ def _read_from_json(self): self.token_value = auth["personal_access_token"] self.last_login_using = auth["last_login_using"] self.password_file = auth["password_file"] + self.token_file = auth["token_file"] self.no_prompt = auth["no_prompt"] self.no_certcheck = auth["no_certcheck"] self.certificate = auth["certificate"] @@ -428,6 +457,7 @@ def _serialize_for_save(self): "personal_access_token": self.token_value, "last_login_using": self.last_login_using, "password_file": self.password_file, + "token_file": self.token_file, "no_prompt": self.no_prompt, "no_certcheck": self.no_certcheck, "certificate": self.certificate, diff --git a/tabcmd/commands/constants.py b/tabcmd/commands/constants.py index c491a135..11cb58c3 100644 --- a/tabcmd/commands/constants.py +++ b/tabcmd/commands/constants.py @@ -1,5 +1,8 @@ import inspect import sys +from typing import Optional + +import tableauserverclient from tabcmd.execution.localize import _ @@ -44,7 +47,7 @@ def log_stack(logger): stack = inspect.stack() here = stack[0] file, line, func = here[1:4] - start = 0 + start = 1 n_lines = 5 logger.debug(HEADER_FMT % (file, func)) for frame in stack[start + 1 : n_lines]: @@ -54,15 +57,18 @@ def log_stack(logger): logger.info("Error printing stack trace:", e) @staticmethod - def exit_with_error(logger, message=None, exception=None): + def exit_with_error(logger, message: Optional[str] = None, exception: Optional[Exception] = None): try: - Errors.log_stack(logger) if message and not exception: logger.error(message) - if exception: + Errors.log_stack(logger) + elif exception: if message: - logger.debug("Error message: " + message) - Errors.check_common_error_codes_and_explain(logger, exception) + logger.info("Error message: " + message) + Errors.check_common_error_codes_and_explain(logger, exception) + else: + logger.info("No exception or message provided") + except Exception as exc: print(sys.stderr, "Error during log call from exception - {} {}".format(exc.__class__, message)) try: @@ -72,7 +78,7 @@ def exit_with_error(logger, message=None, exception=None): sys.exit(1) @staticmethod - def check_common_error_codes_and_explain(logger, exception): + def check_common_error_codes_and_explain(logger, exception: Exception): # most errors contain as much info in the message as we can get from the code # identify any that we can add useful detail for and include them here if Errors.is_expired_session(exception): @@ -83,5 +89,8 @@ def check_common_error_codes_and_explain(logger, exception): # "session.session_expired_login")) # session.renew_session() return - else: + if exception.__class__ == tableauserverclient.ServerResponseError: + logger.error(exception) + else: + logger.exception(exception) 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 1a2169f5..fb0d5daf 100644 --- a/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py +++ b/tabcmd/commands/datasources_and_workbooks/datasources_and_workbooks_command.py @@ -28,7 +28,7 @@ def get_view_by_content_url(logger, server, view_content_url) -> TSC.ViewItem: logger.debug(req_option.get_query_params()) matching_views, paging = server.views.get(req_option) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) if len(matching_views) < 1: Errors.exit_with_error(logger, message=_("errors.xmlapi.not_found")) return matching_views[0] @@ -42,7 +42,7 @@ def get_wb_by_content_url(logger, server, workbook_content_url) -> TSC.WorkbookI logger.debug(req_option.get_query_params()) matching_workbooks, paging = server.workbooks.get(req_option) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) if len(matching_workbooks) < 1: Errors.exit_with_error(logger, message=_("dataalerts.failure.error.workbookNotFound")) return matching_workbooks[0] @@ -56,7 +56,7 @@ def get_ds_by_content_url(logger, server, datasource_content_url) -> TSC.Datasou logger.debug(req_option.get_query_params()) matching_datasources, paging = server.datasources.get(req_option) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) if len(matching_datasources) < 1: Errors.exit_with_error(logger, message=_("dataalerts.failure.error.datasourceNotFound")) return matching_datasources[0] diff --git a/tabcmd/commands/datasources_and_workbooks/export_command.py b/tabcmd/commands/datasources_and_workbooks/export_command.py index 4ee355e0..23012b51 100644 --- a/tabcmd/commands/datasources_and_workbooks/export_command.py +++ b/tabcmd/commands/datasources_and_workbooks/export_command.py @@ -54,7 +54,9 @@ def define_args(export_parser): help="Set the page size of the exported PDF", ) - group.add_argument("--width", default=800, help="Set the width of the image in pixels. Default is 800 px") + group.add_argument( + "--width", default=800, help="[Not Yet Implemented] Set the width of the image in pixels. Default is 800 px" + ) group.add_argument("--filename", "-f", help="filename to store the exported data") group.add_argument("--height", default=600, help=_("export.options.height")) group.add_argument( @@ -121,6 +123,7 @@ def run_command(args): @staticmethod def apply_filters_from_args(request_options: TSC.PDFRequestOptions, args, logger=None) -> None: if args.filter: + logger.debug("filter = {}".format(args.filter)) params = args.filter.split("&") for value in params: ExportCommand.apply_filter_value(logger, request_options, value) @@ -130,6 +133,8 @@ def apply_filters_from_args(request_options: TSC.PDFRequestOptions, args, logger def download_wb_pdf(server, workbook_item, args, logger): logger.debug(args.url) pdf_options = TSC.PDFRequestOptions(maxage=1) + if args.filter or args.url.find("?") > 0: + logger.info("warning: Filter values will not be applied when exporting a complete workbook") ExportCommand.apply_values_from_url_params(logger, pdf_options, args.url) ExportCommand.apply_pdf_options(logger, pdf_options, args) logger.debug(pdf_options.get_query_params()) diff --git a/tabcmd/commands/datasources_and_workbooks/publish_command.py b/tabcmd/commands/datasources_and_workbooks/publish_command.py index ce04fa31..99c4ea25 100644 --- a/tabcmd/commands/datasources_and_workbooks/publish_command.py +++ b/tabcmd/commands/datasources_and_workbooks/publish_command.py @@ -29,6 +29,8 @@ def define_args(publish_parser): ) set_publish_args(group) set_project_r_arg(group) + set_overwrite_option(group) + set_append_replace_option(group) set_parent_project_arg(group) @staticmethod @@ -52,8 +54,7 @@ def run_command(args): args.project_name = "default" args.parent_project_path = "" - publish_mode = PublishCommand.get_publish_mode(args) # --overwrite, --replace - logger.info("Publishing as " + publish_mode) + publish_mode = PublishCommand.get_publish_mode(args, logger) if args.db_username: creds = TSC.models.ConnectionCredentials(args.db_username, args.db_password, embed=args.save_db_password) @@ -66,18 +67,28 @@ def run_command(args): source = PublishCommand.get_filename_extension_if_tableau_type(logger, args.filename) logger.info(_("publish.status").format(args.filename)) if source in ["twbx", "twb"]: + if args.thumbnail_group: + raise AttributeError("Generating thumbnails for a group is not yet implemented.") + if args.thumbnail_username and args.thumbnail_group: + raise AttributeError("Cannot specify both a user and group for thumbnails.") + new_workbook = TSC.WorkbookItem(project_id, name=args.name, show_tabs=args.tabbed) try: + if creds: + logger.debug("Workbook credentials object: " + creds) new_workbook = server.workbooks.publish( new_workbook, args.filename, publish_mode, + # args.thumbnail_username, not yet implemented in tsc + # args.thumbnail_group, connection_credentials=creds, as_job=False, skip_connection_check=False, ) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) + logger.info(_("publish.success") + "\n{}".format(new_workbook.webpage_url)) elif source in ["tds", "tdsx", "hyper"]: @@ -88,13 +99,34 @@ def run_command(args): new_datasource, args.filename, publish_mode, connection_credentials=creds ) except Exception as exc: - Errors.exit_with_error(logger, exc) + Errors.exit_with_error(logger, exception=exc) logger.info(_("publish.success") + "\n{}".format(new_datasource.webpage_url)) + # todo write tests for this method @staticmethod - def get_publish_mode(args): + def get_publish_mode(args, logger): + # default: fail if it already exists on the server + default_mode = TSC.Server.PublishMode.CreateNew + publish_mode = default_mode + + if args.replace: + raise AttributeError("Replacing an extract is not yet implemented") + + if args.append: + if publish_mode != default_mode: + publish_mode = None + else: + # only relevant for datasources, but tsc will throw an error for us if necessary + publish_mode = TSC.Server.PublishMode.Append + if args.overwrite: - publish_mode = TSC.Server.PublishMode.Overwrite - else: - publish_mode = TSC.Server.PublishMode.CreateNew + if publish_mode != default_mode: + publish_mode = None + else: + # Overwrites the workbook, data source, or data extract if it already exists on the server. + publish_mode = TSC.Server.PublishMode.Overwrite + + if not publish_mode: + Errors.exit_with_error(logger, "Invalid combination of publishing options (Append, Overwrite, Replace)") + logger.debug("Publish mode selected: " + publish_mode) return publish_mode diff --git a/tabcmd/commands/site/list_command.py b/tabcmd/commands/site/list_command.py index 762e73a2..76c67cbb 100644 --- a/tabcmd/commands/site/list_command.py +++ b/tabcmd/commands/site/list_command.py @@ -1,5 +1,3 @@ -import tableauserverclient as TSC - from tabcmd.commands.auth.session import Session from tabcmd.commands.constants import Errors from tabcmd.commands.server import Server @@ -13,8 +11,13 @@ class ListCommand(Server): """ # strings to move to string files - tabcmd_content_listing = "===== Listing {0} content for user {1}..." - tabcmd_listing_label_name = "NAME: {}" + local_strings = { + "tabcmd_content_listing": "===== Listing {0} content for user {1}...", + "tabcmd_listing_label_name": "\tNAME: {}", + "tabcmd_listing_label_id": "ID: {}", + "tabcmd_content_none": "No content found.", + } + name: str = "list" description: str = "List content items of a specified type" @@ -36,7 +39,7 @@ def run_command(args): content_type = args.content try: - logger.info(ListCommand.tabcmd_content_listing.format(content_type, session.username)) + logger.info(ListCommand.local_strings["tabcmd_content_listing"].format(content_type, session.username)) if content_type == "projects": items = server.projects.all() @@ -47,14 +50,18 @@ def run_command(args): elif content_type == "flows": items = server.flows.all() + if not items or len(items) == 0: + logger.info(ListCommand.local_strings["tabcmd_content_none"]) for item in items: - logger.info(ListCommand.tabcmd_listing_label_name.format(item.name)) if args.details: - logger.info(item) + logger.info("\t{}".format(item)) if content_type == "workbooks": server.workbooks.populate_views(item) for v in item.views: logger.info(v) + else: + logger.info(ListCommand.local_strings["tabcmd_listing_label_id"].format(item.id)) + logger.info(ListCommand.local_strings["tabcmd_listing_label_name"].format(item.name)) except Exception as e: Errors.exit_with_error(logger, e) diff --git a/tabcmd/commands/site/list_sites_command.py b/tabcmd/commands/site/list_sites_command.py index e077072e..f124fbe6 100644 --- a/tabcmd/commands/site/list_sites_command.py +++ b/tabcmd/commands/site/list_sites_command.py @@ -31,8 +31,8 @@ def run_command(args): sites, pagination = server.sites.get() logger.info(_("listsites.status").format(session.username)) for site in sites: - logger.info("NAME:".rjust(10), site.name) - logger.info("SITEID:".rjust(10), site.content_url) + logger.info("NAME: {}".format(site.name)) + logger.info("SITEID: {}".format(site.id)) if args.get_extract_encryption_mode: logger.info("EXTRACTENCRYPTION:", site.extract_encryption_mode) except Exception as e: diff --git a/tabcmd/commands/user/user_data.py b/tabcmd/commands/user/user_data.py index b9012078..5251aba4 100644 --- a/tabcmd/commands/user/user_data.py +++ b/tabcmd/commands/user/user_data.py @@ -260,7 +260,7 @@ def act_on_users( try: group = UserCommand.find_group(logger, server, args.name) except Exception as e: - Errors.exit_with_error(logger, e) + Errors.exit_with_error(logger, exception=e) n_users_handled: int = 0 number_of_errors: int = 0 diff --git a/tabcmd/execution/global_options.py b/tabcmd/execution/global_options.py index 60c72fd0..695820db 100644 --- a/tabcmd/execution/global_options.py +++ b/tabcmd/execution/global_options.py @@ -126,7 +126,7 @@ def set_encryption_option(parser): "--encrypt", dest="encrypt", action="store_true", # set to true IF user passes in option --encrypt - help="Encrypt the newly created extract.", + help="Encrypt the newly created extract. [N/a on Tableau Cloud: extract encryption is controlled by Site Admin]", ) return parser @@ -230,7 +230,8 @@ def set_common_site_args(parser): "--extract-encryption-mode", choices=encryption_modes, type=case_insensitive_string_type(encryption_modes), - help="The extract encryption mode for the site can be enforced, enabled or disabled. ", + help="The extract encryption mode for the site can be enforced, enabled or disabled. " + "[N/a on Tableau Cloud: encryption mode is always enforced] ", ) parser.add_argument( @@ -275,8 +276,6 @@ def set_filename_arg(parser, description=_("get.options.file")): def set_publish_args(parser): parser.add_argument("-n", "--name", help="Name to publish the new datasource or workbook by.") - set_overwrite_option(parser) - creds = parser.add_mutually_exclusive_group() creds.add_argument("--oauth-username", help="The email address of a preconfigured OAuth connection") creds.add_argument( @@ -302,34 +301,63 @@ def set_publish_args(parser): navigate through the workbook", ) parser.add_argument( - "--replace", action="store_true", help="Use the extract file to replace the existing data source." + "--disable-uploader", + action="store_true", + help="[DEPRECATED - has no effect] Disable the incremental file uploader.", ) - parser.add_argument("--disable-uploader", action="store_true", help="Disable the incremental file uploader.") - parser.add_argument("--restart", help="Restart the file upload.") + parser.add_argument("--restart", help="[DEPRECATED - has no effect] Restart the file upload.") parser.add_argument( "--encrypt-extracts", action="store_true", - help="Encrypt extracts in the workbook, datasource, or extract being published to the server", + help="Encrypt extracts in the workbook, datasource, or extract being published to the server. " + "[N/a on Tableau Cloud: extract encryption is controlled by Site Admin]", ) + + # These two only apply for a workbook, not a datasource thumbnails = parser.add_mutually_exclusive_group() - thumbnails.add_argument("--thumbnail-username", help="Not yet implemented") - thumbnails.add_argument("--thumbnail-group", help="Not yet implemented") # not implemented in the REST API + thumbnails.add_argument( + "--thumbnail-username", + help="If the workbook contains user filters, the thumbnails will be generated based on what the " + "specified user can see. Cannot be specified when --thumbnail-group option is set.", + ) + thumbnails.add_argument( + "--thumbnail-group", + help="[Not yet implemented] If the workbook contains user filters, the thumbnails will be generated based on what the " + "specified group can see. Cannot be specified when --thumbnail-username option is set.", + ) parser.add_argument("--use-tableau-bridge", action="store_true", help="Refresh datasource through Tableau Bridge") -def set_overwrite_option(parser): +# these two are used to publish an extract to an existing data source +def set_append_replace_option(parser): append_group = parser.add_mutually_exclusive_group() append_group.add_argument( - "-o", - "--overwrite", + "--append", action="store_true", - help="Overwrites the workbook, data source, or data extract if it already exists on the server.", + help="Set to true to append the data being published to an existing data source that has the same name. " + "The default behavior is to fail if the data source already exists. " + "If append is set to true but the data source doesn't already exist, the operation fails.", ) + + # what's the difference between this and 'overwrite'? + # This is meant for when a) the local file is an extract b) the server item is an existing data source append_group.add_argument( - "--append", + "--replace", + action="store_true", + help="Use the extract file being published to replace data in the existing data source. The default " + "behavior is to fail if the item already exists.", + ) + + +# this is meant to be like replacing like +def set_overwrite_option(parser): + parser.add_argument( + "-o", + "--overwrite", action="store_true", - help="Append the extract file to the existing data source.", + help="Overwrites the workbook, data source, or data extract if it already exists on the server. The default " + "behavior is to fail if the item already exists.", ) @@ -351,12 +379,12 @@ def set_calculations_options(parser): calc_group.add_argument( "--addcalculations", action="store_true", - help="DEPRECATED [has no effect] Add precalculated data operations in the extract data source.", + help="[Not implemented] Add precalculated data operations in the extract data source.", ) calc_group.add_argument( "--removecalculations", action="store_true", - help="DEPRECATED [has no effect] Remove precalculated data in the extract data source.", + help="[Not implemented] Remove precalculated data in the extract data source.", ) return calc_group diff --git a/tabcmd/execution/parent_parser.py b/tabcmd/execution/parent_parser.py index 21890728..bc81f59c 100644 --- a/tabcmd/execution/parent_parser.py +++ b/tabcmd/execution/parent_parser.py @@ -85,7 +85,7 @@ def parent_parser_with_global_options(): ) proxy_group.add_argument( "--no-proxy", - action="store_false", + action="store_true", # default to false help=_("session.options.no-proxy"), ) @@ -101,7 +101,7 @@ def parent_parser_with_global_options(): parser.add_argument( "--continue-if-exists", action="store_true", # default behavior matches old tabcmd - help=strings[9], + help=strings[9], # kind of equivalent to 'overwrite' in the publish command ) parser.add_argument( diff --git a/tabcmd/execution/tabcmd_controller.py b/tabcmd/execution/tabcmd_controller.py index 3798b30c..1a4544d2 100644 --- a/tabcmd/execution/tabcmd_controller.py +++ b/tabcmd/execution/tabcmd_controller.py @@ -5,6 +5,8 @@ from .logger_config import log from .parent_parser import ParentParser +from tabcmd.version import version + class TabcmdController: @staticmethod @@ -27,6 +29,7 @@ def run(parser, user_input=None): print("logging:", namespace.logging_level) logger = log(__name__, namespace.logging_level or logging.INFO) + logger.info("Tabcmd {}".format(version)) if (hasattr("namespace", "password") or hasattr("namespace", "token_value")) and hasattr("namespace", "func"): # don't print whole namespace because it has secrets logger.debug(namespace.func) diff --git a/tabcmd/tabcmd.py b/tabcmd/tabcmd.py index 7a03f692..70b8b63d 100644 --- a/tabcmd/tabcmd.py +++ b/tabcmd/tabcmd.py @@ -17,10 +17,12 @@ def main(): print("Keyboard Interrupt: exiting") sys.exit(1) except Exception as e: - print(sys.stderr, "Unhandled exception: {}".format(type(e).__name__)) - print( - sys.stderr, - f"{type(e).__name__} at line {e.__traceback__.tb_lineno} of {__file__}: {e}", + sys.stderr.writelines( + [ + "ERROR\n", + "Unhandled exception: {}\n".format(type(e).__name__), + f"at line {e.__traceback__.tb_lineno} of {__file__}: {e}\n", + ] ) sys.exit(1) diff --git a/tests/commands/test_listing_commands.py b/tests/commands/test_listing_commands.py new file mode 100644 index 00000000..5d45590f --- /dev/null +++ b/tests/commands/test_listing_commands.py @@ -0,0 +1,58 @@ +import argparse +from unittest.mock import MagicMock + +from tabcmd.commands.site.list_command import ListCommand +from tabcmd.commands.site.list_sites_command import ListSiteCommand + +import unittest +from unittest import mock + +mock_logger = mock.MagicMock() + +fake_item = mock.MagicMock() +fake_item.name = "fake-name" +fake_item.id = "fake-id" +fake_item.extract_encryption_mode = "ENFORCED" + +getter = MagicMock() +getter.get = MagicMock("get", return_value=([fake_item], 1)) + +mock_args = argparse.Namespace() +mock_args.logging_level = "INFO" + + +@mock.patch("tabcmd.commands.auth.session.Session.create_session") +@mock.patch("tableauserverclient.Server") +class ListingTests(unittest.TestCase): + def test_list_sites(self, mock_server, mock_session): + mock_server.sites = getter + mock_args.get_extract_encryption_mode = False + mock_session.return_value = mock_server + out_value = ListSiteCommand.run_command(mock_args) + + def test_list_content(self, mock_server, mock_session): + mock_server.flows = getter + mock_args.content = "flows" + mock_session.return_value = mock_server + out_value = ListCommand.run_command(mock_args) + + def test_list_wb_details(self, mock_server, mock_session): + mock_server.workbooks = getter + mock_args.content = "workbooks" + mock_session.return_value = mock_server + mock_args.details = True + out_value = ListCommand.run_command(mock_args) + + def test_list_datasources(self, mock_server, mock_session): + mock_server.datasources = getter + mock_args.content = "datasources" + mock_session.return_value = mock_server + mock_args.details = True + out_value = ListCommand.run_command(mock_args) + + def test_list_projects(self, mock_server, mock_session): + mock_server.projects = getter + mock_args.content = "projects" + mock_session.return_value = mock_server + mock_args.details = True + out_value = ListCommand.run_command(mock_args) diff --git a/tests/commands/test_run_commands.py b/tests/commands/test_run_commands.py index 5e6ed25a..dbb9bf55 100644 --- a/tests/commands/test_run_commands.py +++ b/tests/commands/test_run_commands.py @@ -26,6 +26,7 @@ delete_site_command, edit_site_command, list_sites_command, + list_command, ) from tabcmd.commands.user import ( add_users_command, @@ -148,6 +149,10 @@ def test_publish(self, mock_session, mock_server): mock_args.tabbed = True mock_args.db_username = None mock_args.oauth_username = None + mock_args.append = False + mock_args.replace = False + mock_args.thumbnail_username = None + mock_args.thumbnail_group = None mock_server.projects = getter publish_command.PublishCommand.run_command(mock_args) mock_session.assert_called() @@ -393,3 +398,13 @@ def test_create_user(self, mock_session, mock_server): mock_args.require_all_valid = False create_users_command.CreateUsersCommand.run_command(mock_args) mock_session.assert_called() + + def test_list_content(self, mock_session, mock_server): + RunCommandsTest._set_up_session(mock_session, mock_server) + mock_args.content = "workbooks" + list_command.ListCommand.run_command(mock_args) + mock_args.content = "projects" + list_command.ListCommand.run_command(mock_args) + mock_args.content = "flows" + list_command.ListCommand.run_command(mock_args) + # todo: details, filters diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 5f4c8297..44b20a8a 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -2,10 +2,9 @@ from argparse import Namespace import unittest from unittest import mock -from unittest.mock import patch, mock_open +from unittest.mock import MagicMock from tabcmd.commands.auth.session import Session -import os args_to_mock = Namespace( username=None, @@ -15,6 +14,7 @@ server=None, token_name=None, token_value=None, + token_file=None, logging_level=None, no_certcheck=None, no_prompt=False, @@ -50,9 +50,9 @@ logger = logging.getLogger("tests") -def _set_mocks_for_json_file_saved_username(mock_json_load, auth_token, username): +def _set_mocks_for_json_file_saved_username(mock_json_lib, auth_token, username): mock_auth = vars(mock_data_from_json) - mock_json_load.return_value = {"tableau_auth": [mock_auth]} + mock_json_lib.load.return_value = {"tableau_auth": [mock_auth]} mock_auth["auth_token"] = auth_token mock_auth["username"] = username mock_auth["server"] = fakeserver @@ -60,27 +60,32 @@ def _set_mocks_for_json_file_saved_username(mock_json_load, auth_token, username mock_auth["no_certcheck"] = True -def _set_mocks_for_json_file_exists(mock_path, does_it_exist=True): - os.path = mock_path - mock_path.expanduser.return_value = "" - mock_path.join.return_value = "" - mock_path.exists.return_value = does_it_exist - return mock_path +def _set_mocks_for_json_file_exists(mock_path, mock_json_lib, does_it_exist=True): + mock_json_lib.JSONDecodeError = ValueError + path = mock_path() + path.expanduser.return_value = "" + path.join.return_value = "" + path.exists.return_value = does_it_exist + mock_auth = vars(mock_data_from_json) + if does_it_exist: + mock_json_lib.load.return_value = [mock_auth] + else: + mock_json_lib.load.return_value = None + return path def _set_mock_file_content(mock_load, expected_content): mock_load.return_value = expected_content return mock_load -@mock.patch("json.dump") -@mock.patch("json.load") +@mock.patch("tabcmd.commands.auth.session.json") @mock.patch("os.path") @mock.patch("builtins.open") class JsonTests(unittest.TestCase): - def test_read_session_from_json(self, mock_open, mock_path, mock_load, mock_dump): - _set_mocks_for_json_file_exists(mock_path) - _set_mocks_for_json_file_saved_username(mock_load, "AUTHTOKEN", "USERNAME") + def test_read_session_from_json(self, mock_open, mock_path, mock_json): + _set_mocks_for_json_file_exists(mock_path, mock_json) + _set_mocks_for_json_file_saved_username(mock_json, "AUTHTOKEN", "USERNAME") test_session = Session() test_session._read_from_json() assert hasattr(test_session.auth_token, "AUTHTOKEN") is False, test_session @@ -88,16 +93,16 @@ def test_read_session_from_json(self, mock_open, mock_path, mock_load, mock_dump assert test_session.username == "USERNAME" assert test_session.server_url == fakeserver, test_session.server_url - def test_save_session_to_json(self, mock_open, mock_path, mock_load, mock_dump): - _set_mocks_for_json_file_exists(mock_path) + def test_save_session_to_json(self, mock_open, mock_path, mock_json): + _set_mocks_for_json_file_exists(mock_path, mock_json) test_session = Session() test_session.username = "USN" test_session.server = "SRVR" test_session._save_session_to_json() - assert mock_dump.was_called() + assert mock_json.dump.was_called() - def clear_session(self, mock_open, mock_path, mock_load, mock_dump): - _set_mocks_for_json_file_exists(mock_path) + def clear_session(self, mock_open, mock_path, mock_json): + _set_mocks_for_json_file_exists(mock_path, mock_json) test_session = Session() test_session.username = "USN" test_session.server = "SRVR" @@ -105,17 +110,16 @@ def clear_session(self, mock_open, mock_path, mock_load, mock_dump): assert test_session.username is None assert test_session.server is None - def test_json_not_present(self, mock_open, mock_path, mock_load, mock_dump): - _set_mocks_for_json_file_exists(mock_path, False) + def test_json_not_present(self, mock_open, mock_path, mock_json): + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) assert mock_open.was_not_called() - def test_json_invalid(self, mock_open, mock_path, mock_load, mock_dump): - _set_mocks_for_json_file_exists(mock_path) - _set_mock_file_content(mock_load, "just a string") + def test_json_invalid(self, mock_open, mock_path, mock_json): + _set_mocks_for_json_file_exists(mock_path, mock_json) + mock_json.load = "just a string" test_session = Session() assert test_session.username is None - @mock.patch("getpass.getpass") class BuildCredentialsTests(unittest.TestCase): @classmethod @@ -215,34 +219,30 @@ def test_dont_show_prompt_if_user_said_no(self): """ -@mock.patch("json.dump") -@mock.patch("json.load") +@mock.patch("tabcmd.commands.auth.session.json") @mock.patch("os.path") @mock.patch("builtins.open") @mock.patch("getpass.getpass") class CreateSessionTests(unittest.TestCase): @mock.patch("tableauserverclient.Server") - def test_create_session_first_time_no_args( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json_load, mock_json_dump - ): - mock_path = _set_mocks_for_json_file_exists(mock_path, False) - assert mock_path.exists("anything") is False + def test_create_session_first_time_no_args(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + test_args = Namespace(**vars(args_to_mock)) - mock_tsc().users.get_by_id.return_value = None new_session = Session() with self.assertRaises(SystemExit): auth = new_session.create_session(test_args, None) @mock.patch("tableauserverclient.Server") - def test_create_session_first_time_with_token_arg( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json_load, mock_json_dump - ): - mock_path = _set_mocks_for_json_file_exists(mock_path, False) - assert mock_path.exists("anything") is False + def test_create_session_first_time_with_token_arg(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): + _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, "u") + test_args = Namespace(**vars(args_to_mock)) test_args.token_name = "tn" test_args.token_value = "foo" - new_session = Session() auth = new_session.create_session(test_args, None) assert auth is not None, auth assert auth.auth_token is not None, auth.auth_token @@ -251,62 +251,68 @@ def test_create_session_first_time_with_token_arg( assert new_session.token_name == "tn", new_session @mock.patch("tableauserverclient.Server") - def test_create_session_first_time_with_password_arg( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json_load, mock_json_dump - ): - mock_path = _set_mocks_for_json_file_exists(mock_path, False) + def test_create_session_first_time_with_password_arg(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): + name = "uuuu" + new_session = Session() + new_session.tableau_server = mock_tsc() + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + _set_mock_signin_validation_succeeds(new_session.tableau_server, name) + test_args = Namespace(**vars(args_to_mock)) - test_args.username = "uuuu" + test_args.username = name test_args.password = "pppp" - new_session = Session() auth = new_session.create_session(test_args, None) assert auth is not None, auth assert auth.auth_token is not None, auth.auth_token - assert new_session.username == "uuuu", new_session + assert new_session.username == name, new_session assert mock_tsc.has_been_called() @mock.patch("tableauserverclient.Server") def test_create_session_first_time_with_password_file_as_password( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json_load, mock_json_dump + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json ): - mock_path = _set_mocks_for_json_file_exists(mock_path, False) + username = "uuuu" + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + new_session = Session() + _set_mock_signin_validation_succeeds(mock_tsc(), username) test_args = Namespace(**vars(args_to_mock)) - test_args.username = "uuuu" + test_args.username = username # filename = os.path.join(os.path.dirname(__file__),"test_credential_file.txt") # test_args.password_file = os.getcwd()+"/test_credential_file.txt" test_args.password_file = "filename" with mock.patch("builtins.open", mock.mock_open(read_data="my_password")): - new_session = Session() auth = new_session.create_session(test_args, None) assert auth is not None, auth assert auth.auth_token is not None, auth.auth_token - assert new_session.username == "uuuu", new_session + assert new_session.username == username, new_session assert new_session.password_file == "filename", new_session assert mock_tsc.has_been_called() @mock.patch("tableauserverclient.Server") def test_create_session_first_time_with_password_file_as_token( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json_load, mock_json_dump + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json ): - mock_path = _set_mocks_for_json_file_exists(mock_path, False) + _set_mocks_for_json_file_exists(mock_path, mock_json, does_it_exist=False) + server = mock_tsc() + _set_mock_signin_validation_succeeds(server, "testing") test_args = Namespace(**vars(args_to_mock)) test_args.token_name = "mytoken" - test_args.password_file = "filename" + test_args.token_file = "filename" with mock.patch("builtins.open", mock.mock_open(read_data="my_token")): new_session = Session() auth = new_session.create_session(test_args, None) assert auth is not None, auth assert auth.auth_token is not None, auth.auth_token - assert new_session.password_file == "filename", new_session + assert new_session.token_file == "filename", new_session assert mock_tsc.has_been_called() @mock.patch("tableauserverclient.Server") - def test_load_saved_session_data(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json_load, mock_json_dump): - _set_mocks_for_json_file_exists(mock_path, True) - _set_mocks_for_json_file_saved_username(mock_json_load, "auth_token", "username") + def test_load_saved_session_data(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): + _set_mocks_for_json_file_exists(mock_path, mock_json) + _set_mocks_for_json_file_saved_username(mock_json, "auth_token", "username") test_args = Namespace(**vars(args_to_mock)) new_session = Session() new_session._read_existing_state() @@ -317,11 +323,9 @@ def test_load_saved_session_data(self, mock_tsc, mock_pass, mock_file, mock_path assert mock_tsc.has_been_called() @mock.patch("tableauserverclient.Server") - def test_create_session_with_active_session_saved( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json_load, mock_json_dump - ): - _set_mocks_for_json_file_exists(mock_path, True) - _set_mocks_for_json_file_saved_username(mock_json_load, "auth_token", None) + def test_create_session_with_active_session_saved(self, mock_tsc, mock_pass, mock_file, mock_path, mock_json): + _set_mocks_for_json_file_exists(mock_path, mock_json) + _set_mocks_for_json_file_saved_username(mock_json, "auth_token", None) test_args = Namespace(**vars(args_to_mock)) test_args.token_value = "tn" test_args.token_name = "tnnnn" @@ -335,12 +339,14 @@ def test_create_session_with_active_session_saved( @mock.patch("tableauserverclient.Server") def test_create_session_with_saved_expired_username_session( - self, mock_tsc, mock_pass, mock_file, mock_path, mock_json_load, mock_json_dump + self, mock_tsc, mock_pass, mock_file, mock_path, mock_json ): - _set_mocks_for_json_file_saved_username(mock_json_load, "auth_token", "username") - _set_mocks_for_json_file_exists(mock_path, True) - tsc_under_test = CreateSessionTests._set_mock_tsc_not_signed_in(mock_tsc) - CreateSessionTests._set_mock_tsc_sign_in_succeeds(tsc_under_test) + test_username = "monster" + server = mock_tsc() + _set_mocks_for_json_file_exists(mock_path, mock_json) + _set_mocks_for_json_file_saved_username(mock_json, "auth_token", test_username) + _set_mock_tsc_sign_in_succeeds(server) + _set_mock_signin_validation_succeeds(server, test_username) test_args = Namespace(**vars(args_to_mock)) mock_pass.getpass.return_value = "success" test_args.password = "eqweqwe" @@ -350,24 +356,31 @@ def test_create_session_with_saved_expired_username_session( assert auth is not None, auth assert auth.auth_token is not None, auth.auth_token assert auth.auth_token == "cookiieeeee" - assert new_session.username == "username", new_session + assert new_session.username == test_username, new_session assert mock_tsc.use_server_version.has_been_called() - @staticmethod - def _set_mock_tsc_not_signed_in(mock_tsc): - tsc_in_test = mock.MagicMock(name="manually mocking tsc") - mock_tsc.return_value = tsc_in_test - tsc_in_test.is_signed_in.return_value = False # CreateSessionTests.return_False - tsc_in_test.server_info.get.return_value = Exception - return tsc_in_test - @staticmethod - def _set_mock_tsc_sign_in_succeeds(mock_tsc): - tscauth_mock = mock.MagicMock(name="tsc.auth") - mock_tsc.auth = tscauth_mock - mock_tsc.auth_token = "cookiieeeee" - mock_tsc.site_id = "1" - mock_tsc.user_id = "0" +def _set_mock_tsc_not_signed_in(mock_tsc): + tsc_in_test = mock.MagicMock(name="manually mocking tsc") + tsc_in_test.is_signed_in.return_value = False # CreateSessionTests.return_False + tsc_in_test.server_info.get.return_value = Exception + return tsc_in_test + + +def _set_mock_tsc_sign_in_succeeds(mock_tsc): + tscauth_mock = mock.MagicMock(name="tsc.auth") + mock_tsc.auth = tscauth_mock + mock_tsc.auth_token = "cookiieeeee" + mock_tsc.site_id = "1" + mock_tsc.user_id = "0" + + +def _set_mock_signin_validation_succeeds(mock_tsc, username): + mock_u_factory = MagicMock("user") + mock_u = mock_u_factory() + mock_tsc.users.get_by_id.return_value = mock_u + mock_u.name = username + mock_tsc.is_signed_in.return_value = True class TimeoutArgTests(unittest.TestCase): @@ -392,7 +405,7 @@ class TimeoutIntegrationTest(unittest.TestCase): def test_connection_times_out(self): test_args = Namespace(**vars(args_to_mock)) new_session = Session() - test_args.timeout = 10 + test_args.timeout = 2 test_args.username = "u" test_args.password = "p" @@ -403,36 +416,51 @@ def test_connection_times_out(self): # should test connection doesn't time out? -@mock.patch("tableauserverclient.Server") class ConnectionOptionsTest(unittest.TestCase): - def test_certcheck_on(self, mock_tsc): - mock_tsc.add_http_options = mock.MagicMock() - mock_session = mock.MagicMock() + def test_user_agent(self): + mock_session = Session() + mock_session.server_url = "fakehost" + connection = mock_session._open_connection_with_opts() + assert connection._http_options["headers"]["User-Agent"].startswith("Tabcmd/") + + def test_no_certcheck(self): + mock_session = Session() + mock_session.server_url = "fakehost" mock_session.no_certcheck = True mock_session.site_id = "s" mock_session.user_id = "u" - server = "anything" - mock_session._set_connection_options() - assert mock_tsc.add_http_options.has_been_called() + connection = mock_session._open_connection_with_opts() + assert connection._http_options["verify"] == False - def test_certcheck_off(self, mock_tsc): - mock_session = mock.MagicMock() - server = "anything" + def test_cert(self): + mock_session = Session() + mock_session.server_url = "fakehost" mock_session.site_id = "s" mock_session.user_id = "u" - mock_session._set_connection_options() - mock_tsc.add_http_options.assert_not_called() + mock_session.certificate = "my-cert-info" + connection = mock_session._open_connection_with_opts() + assert connection._http_options["cert"] == mock_session.certificate - """ - def test_cert(self, mock_tsc): - assert False, 'feature not implemented' + def test_proxy_stuff(self): + mock_session = Session() + mock_session.server_url = "fakehost" + mock_session.site_id = "s" + mock_session.user_id = "u" + mock_session.proxy = "proxy:port" + connection = mock_session._open_connection_with_opts() + assert connection._http_options["proxies"] == {"http": mock_session.proxy} - def test_proxy_stuff(self, mock_tsc): - assert False, 'feature not implemented' + def test_timeout(self): + mock_session = Session() + mock_session.server_url = "fakehost" + mock_session.site_id = "s" + mock_session.user_id = "u" + mock_session.timeout = 10 + connection = mock_session._open_connection_with_opts() + assert connection._http_options["timeout"] == 10 - def test_timeout(self, mock_tsc): - assert False, 'feature not implemented' +""" class CookieTests(unittest.TestCase): def test_no_file_if_no_cookie(self): diff --git a/tests/e2e/online_tests.py b/tests/e2e/online_tests.py index a695c665..22c1f993 100644 --- a/tests/e2e/online_tests.py +++ b/tests/e2e/online_tests.py @@ -257,6 +257,14 @@ def test_create_projects(self): def test_list_projects(self): self._list("projects") + @pytest.mark.order(8) + def test_list_flows(self): + self._list("flows") + + @pytest.mark.order(8) + def test_list_workbooks(self): + self._list("workbooks") + @pytest.mark.order(10) def test_delete_projects(self): if not project_admin: diff --git a/tests/e2e/setup_e2e.py b/tests/e2e/setup_e2e.py index 4edecf7c..29213fe7 100644 --- a/tests/e2e/setup_e2e.py +++ b/tests/e2e/setup_e2e.py @@ -24,7 +24,7 @@ def login(extra="--language", value="en"): credentials.server, "--site", credentials.site, - "--token", + "--token-value", credentials.token, "--token-name", credentials.token_name, diff --git a/tests/parsers/test_parser_publish.py b/tests/parsers/test_parser_publish.py index 261aeb6e..65aaa20d 100644 --- a/tests/parsers/test_parser_publish.py +++ b/tests/parsers/test_parser_publish.py @@ -1,4 +1,5 @@ import unittest +from unittest import skip from tabcmd.commands.datasources_and_workbooks.publish_command import PublishCommand from .common_setup import * @@ -38,3 +39,69 @@ def test_publish_parser_save_password(self): ] args = self.parser_under_test.parse_args(mock_args) assert args.save_db_password is True, args + + def test_publish_parser_save_oauth(self): + mock_args = [ + commandname, + "filename.twbx", + "--oauth-username", + "user", + "--save-oauth", + ] + args = self.parser_under_test.parse_args(mock_args) + assert args.save_oauth is True, args + assert args.oauth_username == "user", args + + def test_publish_parser_thumbnails(self): + mock_args = [commandname, "filename.twbx", "--thumbnail-username"] # no value for thumbnail-user + with self.assertRaises(SystemExit): + args = self.parser_under_test.parse_args(mock_args) + + mock_args = [commandname, "filename.twbx", "--thumbnail-username", "goofy"] + args = self.parser_under_test.parse_args(mock_args) + assert args.thumbnail_username == "goofy", args + + @skip("Not yet implemented") + def test_publish_parser_thumbnail_group(self): + mock_args = [commandname, "filename.twbx", "--thumbnail-group", "goofy"] + args = self.parser_under_test.parse_args(mock_args) + assert args.thumbnail_group == "goofy", args + + """ + append | replace | overwrite -> result + -------- + true | F/empty | F/empty -> append + true | F/empty | true -> ERROR + true | true | F/empty -> ERROR + .... basically, replace == overwrite, append != r/o + """ + + def test_publish_parser_append_options(self): + mock_args = [commandname, "filename.twbx", "--append"] + args = self.parser_under_test.parse_args(mock_args) + + def test_publish_parser_replace_and_append(self): + mock_args = [commandname, "filename.twbx", "--append", "--replace"] + with self.assertRaises(SystemExit): + args = self.parser_under_test.parse_args(mock_args) + + def test_publish_parser_replace_options(self): + mock_args = [commandname, "filename.twbx", "--overwrite"] + args = self.parser_under_test.parse_args(mock_args) + + mock_args = [commandname, "filename.twbx", "--replace"] + args = self.parser_under_test.parse_args(mock_args) + + mock_args = [commandname, "filename.twbx", "--replace", "--overwrite"] + args = self.parser_under_test.parse_args(mock_args) + + def test_publish_parser_deprecated_options(self): + # does nothing in new tabcmd, but shouldn't break anything + mock_args = [commandname, "filename.twbx", "--disable-uploader"] + args = self.parser_under_test.parse_args(mock_args) + mock_args = [commandname, "filename.twbx", "--restart", "argument"] + args = self.parser_under_test.parse_args(mock_args) + + def test_publish_parser_use_bridge_option(self): + mock_args = [commandname, "filename.twbx", "--use-tableau-bridge"] + args = self.parser_under_test.parse_args(mock_args)