From facdf5ec5051cdbc987ea9ade1aeae3e1483e5a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:50:53 -0700 Subject: [PATCH 1/3] Bump actions/upload-artifact from 4 to 7 (#392) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 2aea8b0b..2472dcb1 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -98,7 +98,7 @@ jobs: - name: Upload build artifact for ${{ matrix.TARGET }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: tabcmd-${{ matrix.TARGET }} path: ./dist/${{ matrix.TARGET }}/${{ matrix.UPLOAD_FILE_NAME }} From ed69d21125664a337b4ef9b4103efcd458e371e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:21:37 -0700 Subject: [PATCH 2/3] Bump actions/checkout from 6 to 7 (#394) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check-coverage.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/generate-metadata.yml | 2 +- .github/workflows/package.yml | 2 +- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-e2-tests.yml | 2 +- .github/workflows/run-tests.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/check-coverage.yml b/.github/workflows/check-coverage.yml index e55838ed..e910a675 100644 --- a/.github/workflows/check-coverage.yml +++ b/.github/workflows/check-coverage.yml @@ -18,7 +18,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} uses: actions/setup-python@v6 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5a337908..669a3240 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/generate-metadata.yml b/.github/workflows/generate-metadata.yml index 8b47b312..4bf2d6bf 100644 --- a/.github/workflows/generate-metadata.yml +++ b/.github/workflows/generate-metadata.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 2472dcb1..ba6c9fcc 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -61,7 +61,7 @@ jobs: UPLOAD_FILE_NAME: tabcmd steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 5f5d84bd..2c5b95c1 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -15,7 +15,7 @@ jobs: name: Build dist files for PyPi runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-python@v6 diff --git a/.github/workflows/run-e2-tests.yml b/.github/workflows/run-e2-tests.yml index e16e58fa..893232c2 100644 --- a/.github/workflows/run-e2-tests.yml +++ b/.github/workflows/run-e2-tests.yml @@ -23,7 +23,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} uses: actions/setup-python@v6 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 68434d9a..31b205df 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,7 +20,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} uses: actions/setup-python@v6 From fba9519382045b36246013fd41e2bf87ab28031d Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 8 Jul 2026 12:33:36 -0700 Subject: [PATCH 3/3] =?UTF-8?q?Release:=20development=20=E2=86=92=20main?= =?UTF-8?q?=20(version=20fix,=20TSC=200.41,=20mypy,=20bug=20fixes)=20(#415?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prompt for password when username given but no password provided (#301) When `tabcmd login -u myuser` is run with no `-p` flag, the session creation now correctly prompts for a password interactively (or exits with an error if `--no-prompt` is set), rather than falling through to the saved-session path and failing with a missing-arguments error. Co-Authored-By: Claude Sonnet 4.6 * fix: --filename extension takes precedence over URL extension (#256) When a user supplies -f output.csv with a URL that has a different extension (e.g. view.png), the file type is now determined from the user-supplied filename extension. If the filename has no recognized extension, or no filename is given, the URL extension is used as the fallback. Adds three tests covering the three cases. Co-Authored-By: Claude Sonnet 4.6 * fix: rotate log file using RotatingFileHandler (#211) Replace filename="tabcmd.log" in logging.basicConfig with a RotatingFileHandler (max 1 MB per file, 5 backups) so the log file no longer grows indefinitely. Co-Authored-By: Claude Sonnet 4.6 * fix: default to https when no protocol given in server URL (#331) Co-Authored-By: Claude Sonnet 4.6 * typing: from static methods to class methods * type fixes: session.py * typing: parser tests * Update test_session.py * Update pyproject.toml * type fixes c:\dev\tabcmd>mypy tests tests\commands\test_projects_utils.py:22: error: Argument 1 to "_parse_project_path_to_list" of "Server" has incompatible type "None"; expected "str" [arg-type] tests\commands\test_user_utils.py:76: error: Incompatible types in assignment (expression has type "UserItem | None", variable has type "UserItem") [assignment] tests\commands\test_user_utils.py:84: error: Incompatible types in assignment (expression has type "UserItem | None", variable has type "UserItem") [assignment] tests\e2e\language_tests.py:208: error: "OnlineCommandTest" has no attribute "_get_workbook" [attr-defined] tests\e2e\tests_integration.py:68: error: Name "logger" is not defined [name-defined] tests\e2e\tests_integration.py:99: error: Name "logger" is not defined [name-defined] tests\e2e\tests_integration.py:133: error: Name "__class__" is not defined [name-defined] tests\commands\test_session.py:206: error: Argument 1 to "_allow_prompt" has incompatible type "Namespace"; expected "Session" [arg-type] tests\commands\test_session.py:211: error: Argument 1 to "_allow_prompt" has incompatible type "Namespace"; expected "Session" [arg-type] tests\commands\test_session.py:216: error: Argument 1 to "_allow_prompt" has incompatible type "Namespace"; expected "Session" [arg-type] Found 10 errors in 5 files (checked 51 source files) * fix type errors in /tests output with fixes > mypy tests Success: no issues found in 51 source files * Refactor version retrieval to use importlib.metadata * Update tabcmd/commands/datasources_and_workbooks/export_command.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tabcmd/commands/user/add_users_command.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix typings * fix: set root logger level explicitly; guard against duplicate handlers - Replace basicConfig (no-op if root already has handlers) with an explicit setLevel on the root logger - Guard RotatingFileHandler and StreamHandler creation so repeated configure_log calls don't accumulate duplicate handlers - Add tests for no-duplicate and level-set behaviour Co-Authored-By: Claude Sonnet 4.6 * test: add error-path test for get_file_type_from_filename Add test covering the case where neither filename nor URL has a recognized extension, which should exit with an error. Co-Authored-By: Claude Sonnet 4.6 * fix: don't sign out existing session when only --username is given The elif branch now only fires when there is no active session. If a session already exists (self.tableau_server is set), fall through to the existing-session reuse path instead of unconditionally signing out and prompting for a password. Co-Authored-By: Claude Sonnet 4.6 * style: black formatting * style: black formatting * style: black formatting * ci: add workflow to check i18n string keys on PRs * ci: pin action versions to v6, note regex limitation * fix: black formatting and mypy errors for check_untyped_defs - Run black across all modified files - Convert PublishCommand.run_command from @staticmethod to @classmethod - Fix exit_with_error call passing list instead of str Co-Authored-By: Claude Sonnet 4.6 * fix: cast _ProjectItem stubs to TSC.ProjectItem in tests Satisfies mypy check_untyped_defs — local stub class doesn't structurally match the expected ProjectItem type, cast() avoids changing production code. Co-Authored-By: Claude Sonnet 4.6 * chore: upgrade tableauserverclient to 0.41, add SVG format tests (#1772) Co-Authored-By: Claude Sonnet 4.6 * fix: set user_id in test so _validate_existing_signin passes The session reuse path checks self.user_id before calling users.get_by_id; without it, validation always returns None and the test hits the missing- arguments error path. Co-Authored-By: Claude Sonnet 4.6 * fix: show correct version in PyInstaller exe (W-22831646) (#410) * fix: show correct version in PyInstaller exe (W-22831646) importlib.metadata is unavailable inside PyInstaller bundles, causing tabcmd --version to always display the hardcoded fallback. Configure setuptools_scm to write tabcmd/_version.py at build time so PyInstaller bundles it; version.py falls back to that file instead of a hardcoded string. Co-Authored-By: Claude Sonnet 4.6 * fix: ensure _version.py is written by doit version before pyinstaller runs * test: verify version.py fallback import matches pyproject write_to path * fix: use 0.0 as last-resort version fallback (clearly wrong, but parseable) * fix: add assert narrowing to fix mypy union-attr error in test_version.py Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 * ci: automate release pipeline (draft release, PyPI trigger, e2e skip fixes) (#416) * Bump actions/upload-artifact from 4 to 7 (#392) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/checkout from 6 to 7 (#394) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci: automate release pipeline - release-on-merge.yml: on push to main, auto-computes next patch version, pushes the git tag (which triggers package.yml to build binaries), and creates a draft GitHub release with auto-generated notes for human review before publishing - publish-pypi.yml: add release:published trigger so clicking "Publish release" in GitHub UI automatically uploads to PyPI; fix is_draft conditionals to work when triggered without inputs - run-e2-tests.yml: add workflow_call interface (inputs + secrets) so the e2e suite can be called from other workflows; move credentials to env vars to avoid shell injection - online_tests.py: set site_admin=False to match actual account permissions; add skip guard on test_delete_extract (requires extract encryption, not available on this site) Co-Authored-By: Claude Sonnet 4.6 * ci: remove promote:true from package.yml to preserve draft state Binaries are now uploaded to the draft release created by release-on-merge.yml without auto-publishing it. Release stays draft until manually published, which then triggers PyPI upload. Co-Authored-By: Claude Sonnet 4.6 * ci: address OSS security review findings - publish-pypi.yml: remove dead `push: tags: 'pypi'` trigger (secret publish path bypassing the draft-release gate); make publish conditionals explicit using github.event_name so the trigger surface is unambiguous rather than relying on null coercion - release-on-merge.yml: filter tags to strict semver regex before sorting (prevents arithmetic failure on malformed tags); fail loudly if no prior tag exists instead of silently defaulting to v2.0.1; align checkout to actions/checkout@v7 - online_tests.py: suppress argv printing when login args (including PAT token) are present, preventing credential leak in CI logs Tracking: #417 (PyPI trusted publishing / OIDC) Co-Authored-By: Claude Sonnet 4.6 * ci: allow coverage comment step to fail on fork PRs MishaKav/pytest-coverage-comment@main updated and now fails with permission denied on fork PRs (GITHUB_TOKEN is read-only for pull_request events from forks). Coverage is still collected and reported; only the PR comment step fails. Mark it continue-on-error so CI stays green. Co-Authored-By: Claude Sonnet 4.6 * ci: make e2e permission flags configurable via workflow inputs Previously server_admin/site_admin/project_admin/extract_encryption_enabled were hardcoded in online_tests.py. Now they are read from env vars (E2E_SITE_ADMIN, E2E_SERVER_ADMIN, E2E_PROJECT_ADMIN, E2E_EXTRACT_ENCRYPTION) so callers can enable the relevant test groups for their account permissions. workflow_dispatch: adds optional boolean choice inputs for each flag workflow_call: adds optional boolean inputs (default false) online_tests.py: reads flags from env via _env_bool(), defaults unchanged Co-Authored-By: Claude Sonnet 4.6 * test: skip e2e tests gracefully when credentials.py is absent Previously get_login_args() raised AttributeError when credentials.py was not present (ImportError silently set credentials = {}). Now returns None and _test_command skips with a clear message instead of crashing. Also removes redundant second call to get_login_args() in _test_command. Co-Authored-By: Claude Sonnet 4.6 * style: black formatting for e2e test files Co-Authored-By: Claude Sonnet 4.6 * fix: fall back to E2E_* env vars in get_login_args() when credentials.py is absent Without this, CI always skips the e2e suite because credentials.py is not committed — even when E2E_SERVER/SITE/PATNAME/PAT are set via workflow inputs. Co-Authored-By: Claude Sonnet 4.6 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --------- Signed-off-by: dependabot[bot] Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check-coverage.yml | 1 + .github/workflows/check-strings.yml | 28 ++++ .github/workflows/package.yml | 1 - .github/workflows/publish-pypi.yml | 8 +- .github/workflows/release-on-merge.yml | 60 ++++++++ .github/workflows/run-e2-tests.yml | 67 ++++++++- .gitignore | 4 + dodo.py | 8 ++ pyproject.toml | 4 +- tabcmd/commands/auth/login_command.py | 6 +- tabcmd/commands/auth/logout_command.py | 6 +- tabcmd/commands/auth/session.py | 82 ++++++----- .../datasources_and_workbooks_command.py | 12 +- .../datasources_workbooks_views_url_parser.py | 40 +++--- .../delete_command.py | 8 +- .../export_command.py | 15 +- .../get_url_command.py | 6 +- .../publish_command.py | 8 +- .../runschedule_command.py | 6 +- .../extracts/create_extracts_command.py | 13 +- .../extracts/decrypt_extracts_command.py | 8 +- .../extracts/delete_extracts_command.py | 13 +- .../extracts/encrypt_extracts_command.py | 8 +- tabcmd/commands/extracts/extracts.py | 7 +- .../extracts/reencrypt_extracts_command.py | 8 +- .../extracts/refresh_extracts_command.py | 15 +- tabcmd/commands/group/create_group_command.py | 6 +- tabcmd/commands/group/delete_group_command.py | 8 +- .../project/create_project_command.py | 11 +- .../project/delete_project_command.py | 10 +- .../project/publish_samples_command.py | 6 +- tabcmd/commands/server.py | 4 +- tabcmd/commands/site/create_site_command.py | 8 +- tabcmd/commands/site/delete_site_command.py | 8 +- tabcmd/commands/site/edit_site_command.py | 8 +- tabcmd/commands/site/list_command.py | 6 +- tabcmd/commands/site/list_sites_command.py | 8 +- tabcmd/commands/user/add_users_command.py | 15 +- tabcmd/commands/user/create_site_users.py | 9 +- tabcmd/commands/user/create_users_command.py | 6 +- .../user/delete_site_users_command.py | 6 +- tabcmd/commands/user/remove_users_command.py | 6 +- tabcmd/execution/logger_config.py | 29 ++-- tabcmd/execution/parent_parser.py | 3 +- tabcmd/tabcmd.py | 5 +- tabcmd/version.py | 11 +- tests/commands/test_geturl_utils.py | 47 ++++++- tests/commands/test_listing_commands.py | 4 +- tests/commands/test_logger_config.py | 43 ++++++ tests/commands/test_projects_utils.py | 1 - tests/commands/test_run_commands.py | 14 +- tests/commands/test_server_item_selection.py | 12 +- tests/commands/test_session.py | 130 ++++++++++++++++-- tests/commands/test_user_utils.py | 8 +- tests/commands/test_version.py | 33 +++++ tests/e2e/language_tests.py | 6 + tests/e2e/online_tests.py | 29 ++-- tests/e2e/setup_e2e.py | 31 +++-- tests/e2e/tests_integration.py | 4 +- tests/parsers/common_setup.py | 44 +++--- tests/parsers/test_login_parser.py | 4 +- tests/parsers/test_logout_parser.py | 2 +- tests/parsers/test_parser_add_user.py | 6 +- tests/parsers/test_parser_create_extracts.py | 2 +- tests/parsers/test_parser_create_group.py | 2 +- tests/parsers/test_parser_create_project.py | 2 +- tests/parsers/test_parser_create_site.py | 2 +- .../parsers/test_parser_create_site_users.py | 2 +- tests/parsers/test_parser_create_user.py | 2 +- tests/parsers/test_parser_decrypt_extracts.py | 2 +- tests/parsers/test_parser_delete.py | 2 +- tests/parsers/test_parser_delete_extracts.py | 2 +- tests/parsers/test_parser_delete_group.py | 2 +- tests/parsers/test_parser_delete_project.py | 2 +- tests/parsers/test_parser_delete_site.py | 2 +- tests/parsers/test_parser_delete_site_user.py | 2 +- tests/parsers/test_parser_edit_site.py | 2 +- tests/parsers/test_parser_encrypt_extracts.py | 2 +- tests/parsers/test_parser_export.py | 2 +- tests/parsers/test_parser_get_url.py | 4 +- tests/parsers/test_parser_list_sites.py | 2 +- tests/parsers/test_parser_publish.py | 2 +- tests/parsers/test_parser_publish_samples.py | 2 +- .../parsers/test_parser_reencrypt_extracts.py | 2 +- tests/parsers/test_parser_refresh_extracts.py | 2 +- tests/parsers/test_parser_remove_user.py | 2 +- tests/parsers/test_parser_runschedule.py | 2 +- 87 files changed, 792 insertions(+), 291 deletions(-) create mode 100644 .github/workflows/check-strings.yml create mode 100644 .github/workflows/release-on-merge.yml create mode 100644 tests/commands/test_logger_config.py create mode 100644 tests/commands/test_version.py diff --git a/.github/workflows/check-coverage.yml b/.github/workflows/check-coverage.yml index e910a675..efd2f6e6 100644 --- a/.github/workflows/check-coverage.yml +++ b/.github/workflows/check-coverage.yml @@ -46,6 +46,7 @@ jobs: - name: Comment on pull request with coverage uses: MishaKav/pytest-coverage-comment@main + continue-on-error: true with: pytest-coverage-path: ./pytest-coverage.txt # TODO: check if this has been fixed 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/.github/workflows/package.yml b/.github/workflows/package.yml index ba6c9fcc..6b560231 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -112,4 +112,3 @@ jobs: file: ./dist/${{ matrix.TARGET }}/${{ matrix.UPLOAD_FILE_NAME }} tag: ${{ github.ref_name }} overwrite: true - promote: true diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 2c5b95c1..5f06e8aa 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -7,8 +7,8 @@ on: description: 'Test PyPi' required: true type: boolean - push: - tags: 'pypi' + release: + types: [published] jobs: build-n-publish: @@ -32,13 +32,13 @@ jobs: python -m build - name: Publish distribution 📦 to Test PyPI - if: ${{ inputs.is_draft }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.is_draft == true }} uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI - if: ${{ !inputs.is_draft }} + if: ${{ github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.is_draft != true) }} uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml new file mode 100644 index 00000000..28c3dff1 --- /dev/null +++ b/.github/workflows/release-on-merge.yml @@ -0,0 +1,60 @@ +name: Draft release on merge to main + +# Triggers when a PR is merged into main (push to main covers all merge strategies). +# Creates a draft GitHub release with auto-generated notes and pushes the version tag. +# The tag push triggers package.yml to build and attach binaries. +# When you publish the draft release, publish-pypi.yml uploads to PyPI automatically. + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + draft-release: + name: Create draft release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Compute next patch version + id: version + run: | + LAST_TAG=$(git tag --list | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) + if [ -z "$LAST_TAG" ]; then + echo "No prior semver tag found; cannot auto-increment version." >&2 + exit 1 + fi + BASE="${LAST_TAG#v}" + MAJOR=$(echo "$BASE" | cut -d. -f1) + MINOR=$(echo "$BASE" | cut -d. -f2) + PATCH=$(echo "$BASE" | cut -d. -f3) + NEXT="v${MAJOR}.${MINOR}.$((PATCH + 1))" + echo "tag=$NEXT" >> "$GITHUB_OUTPUT" + echo "last_tag=$LAST_TAG" >> "$GITHUB_OUTPUT" + + - name: Push version tag + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$TAG" + git push origin "$TAG" + + - name: Create draft release with auto-generated notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + LAST_TAG: ${{ steps.version.outputs.last_tag }} + run: | + gh release create "$TAG" \ + --draft \ + --title "$TAG" \ + --generate-notes \ + --notes-start-tag "$LAST_TAG" diff --git a/.github/workflows/run-e2-tests.yml b/.github/workflows/run-e2-tests.yml index 893232c2..01a863c7 100644 --- a/.github/workflows/run-e2-tests.yml +++ b/.github/workflows/run-e2-tests.yml @@ -11,6 +11,60 @@ on: required: true pat: required: true + server_admin: + description: 'Account has server admin role' + required: false + default: 'false' + type: choice + options: ['false', 'true'] + site_admin: + description: 'Account has site admin role' + required: false + default: 'false' + type: choice + options: ['false', 'true'] + project_admin: + description: 'Account has project admin role' + required: false + default: 'false' + type: choice + options: ['false', 'true'] + extract_encryption_enabled: + description: 'Site has extract encryption enabled' + required: false + default: 'false' + type: choice + options: ['false', 'true'] + workflow_call: + inputs: + server: + required: true + type: string + site: + required: true + type: string + patname: + required: true + type: string + server_admin: + required: false + type: boolean + default: false + site_admin: + required: false + type: boolean + default: false + project_admin: + required: false + type: boolean + default: false + extract_encryption_enabled: + required: false + type: boolean + default: false + secrets: + pat: + required: true jobs: build: @@ -42,6 +96,15 @@ jobs: pip install .[test] - name: Run e2e tests - run: | - python -m tabcmd login --server "${{ github.event.inputs.server }}" --site "${{ github.event.inputs.site }}" --token-name "${{ github.event.inputs.patname }}" --token-value "${{ github.event.inputs.pat }}" + env: + E2E_SERVER: ${{ inputs.server }} + E2E_SITE: ${{ inputs.site }} + E2E_PATNAME: ${{ inputs.patname }} + E2E_PAT: ${{ secrets.pat || inputs.pat }} + E2E_SERVER_ADMIN: ${{ inputs.server_admin || 'false' }} + E2E_SITE_ADMIN: ${{ inputs.site_admin || 'false' }} + E2E_PROJECT_ADMIN: ${{ inputs.project_admin || 'false' }} + E2E_EXTRACT_ENCRYPTION: ${{ inputs.extract_encryption_enabled || 'false' }} + run: | + python -m tabcmd login --server "$E2E_SERVER" --site "$E2E_SITE" --token-name "$E2E_PATNAME" --token-value "$E2E_PAT" pytest -q tests/e2e/online_tests.py -r pfE 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/online_tests.py b/tests/e2e/online_tests.py index 226fbf72..8f1d50d6 100644 --- a/tests/e2e/online_tests.py +++ b/tests/e2e/online_tests.py @@ -24,11 +24,18 @@ # config variables for test run debug_log = "--logging-level=DEBUG" indexing_sleep_time = 1 # wait 1 second to confirm server has indexed updates -# Flags to let us skip tests if we know we don't have the required access -server_admin = False -site_admin = True -project_admin = False -extract_encryption_enabled = False + + +def _env_bool(name: str, default: bool = False) -> bool: + return os.environ.get(name, str(default)).lower() == "true" + + +# Flags to let us skip tests if we know we don't have the required access. +# Override by setting env vars (e.g. E2E_SITE_ADMIN=true) or via workflow inputs. +server_admin = _env_bool("E2E_SERVER_ADMIN") +site_admin = _env_bool("E2E_SITE_ADMIN") +project_admin = _env_bool("E2E_PROJECT_ADMIN") +extract_encryption_enabled = _env_bool("E2E_EXTRACT_ENCRYPTION") use_tabcmd_classic = False # toggle between testing using tabcmd 2 or tabcmd classic @@ -73,11 +80,12 @@ def get_publishable_name(file_value: str) -> str: def _test_command(test_args: list[str]): # this will raise an exception if it gets a non-zero return code # that will bubble up and fail the test + login_args = setup_e2e.get_login_args() + if login_args is None: + pytest.skip("No credentials available (credentials.py not found)") # default: run tests using tabcmd 2 - calling_args = ( - ["python", "-m", "tabcmd"] + test_args + setup_e2e.get_login_args() + [debug_log] + ["--no-certcheck"] - ) + calling_args = ["python", "-m", "tabcmd"] + test_args + login_args + [debug_log] + ["--no-certcheck"] # call the executable directly: lets us drop in classic tabcmd if use_tabcmd_classic: @@ -86,7 +94,8 @@ def _test_command(test_args: list[str]): + test_args + ["--no-certcheck"] ) - if database_password not in calling_args: + safe_to_print = not any(v in calling_args for v in login_args if v not in ("--server", "--site", "--token-name")) + if safe_to_print: print(calling_args) return subprocess.check_call(calling_args) @@ -486,6 +495,8 @@ def test_refresh_ds_extract(self): @pytest.mark.order(14) def test_delete_extract(self): + if not extract_encryption_enabled: + pytest.skip("delete-extract requires extract encryption to be enabled on the site") name_on_server = TestAssets.get_publishable_name(TestAssets.TDSX_FILE_WITH_EXTRACT) TabcmdCall._delete_extract(name_on_server, "-d") diff --git a/tests/e2e/setup_e2e.py b/tests/e2e/setup_e2e.py index eda6f791..488485e4 100644 --- a/tests/e2e/setup_e2e.py +++ b/tests/e2e/setup_e2e.py @@ -3,8 +3,11 @@ try: from tests.e2e import credentials # type: ignore + + _has_credentials = True except ImportError: credentials = {} # type: ignore + _has_credentials = False our_program = "tabcmd.exe" launch_path = os.path.join("dist", "tabcmd") @@ -46,13 +49,21 @@ def get_executable(): def get_login_args(): - return [ - "--server", - credentials.server, - "--site", - credentials.site, - "--token-value", - credentials.token, - "--token-name", - credentials.token_name, - ] + if _has_credentials: + return [ + "--server", + credentials.server, + "--site", + credentials.site, + "--token-value", + credentials.token, + "--token-name", + credentials.token_name, + ] + server = os.environ.get("E2E_SERVER") + site = os.environ.get("E2E_SITE") + token_name = os.environ.get("E2E_PATNAME") + token = os.environ.get("E2E_PAT") + if server and site and token_name and token: + return ["--server", server, "--site", site, "--token-value", token, "--token-name", token_name] + return None 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)