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 01/15] 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 02/15] 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 03/15] =?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) From 6310b91ee8cf3215ced7dca86c02af9cb4bb0338 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:16:39 -0700 Subject: [PATCH 04/15] Bump github/codeql-action from 4 to 4.37.4 (#445) --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 669a3240..937973da 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.37.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,6 +53,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.37.4 with: category: "/language:${{matrix.language}}" From 73f21ec68b36d254a2f3958fb4d5df74ad4a693c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:22:06 -0700 Subject: [PATCH 05/15] Bump github/codeql-action from 4.37.4 to 4.37.8 (#462) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.8. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.8) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 937973da..865519b6 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.8 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,6 +53,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.8 with: category: "/language:${{matrix.language}}" From d1a7aadc8b0edee4346f61bd8821aee7a2ec3405 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 25 Aug 2026 01:00:59 -0700 Subject: [PATCH 06/15] fix: reuse saved auth_token from tabcmd login (#463) Without this, running any command after a successful `tabcmd login` falls through to _get_saved_credentials() -> getpass.getpass(), which hangs silently in every non-TTY context (CI, scripts, IDE terminals with no stdin). Reattach to the auth_token in tableau_auth.json instead, and validate it against a user-scoped endpoint so an expired token surfaces as a fallthrough rather than a later obscure error. Restores parity with tabcmd Classic's session-cookie reuse. Co-Authored-By: Claude Opus 4.7 (1M context) --- tabcmd/commands/auth/session.py | 39 +++++++++++ tests/commands/test_session.py | 116 ++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/tabcmd/commands/auth/session.py b/tabcmd/commands/auth/session.py index 088865f4..6ac18f22 100644 --- a/tabcmd/commands/auth/session.py +++ b/tabcmd/commands/auth/session.py @@ -304,6 +304,39 @@ def _get_saved_credentials(self): return credentials + def _restore_saved_session(self) -> Optional[TSC.Server]: + # Fixes #463: without this, tabcmd after a successful `tabcmd login` + # falls into _get_saved_credentials() -> getpass.getpass() and hangs silently + # in any non-TTY context. Reattach to the token saved in tableau_auth.json instead. + if not (self.auth_token and self.site_id and self.user_id and self.server_url): + return None + try: + server = self._open_connection_with_opts() + except Exception as e: + # Log the exception class only; the message can include the request URL and + # in some code paths downstream would echo request bodies. Class name is + # enough for a fallback-diagnostic breadcrumb. + self.logger.debug("Could not open connection to reuse saved session ({})".format(type(e).__name__)) + return None + server._set_auth(self.site_id, self.user_id, self.auth_token) + try: + # use_server_version negotiates the REST API version TSC hard-codes into + # subsequent endpoint URLs; required, not merely informational. + server.use_server_version() + # Probe a user-scoped endpoint so an expired token surfaces here rather + # than later inside a publish() body. + server.users.get_by_id(self.user_id) + except Exception as e: + # See note above: exception class only, no str(e). + self.logger.debug("Saved auth token no longer valid ({}); will re-authenticate".format(type(e).__name__)) + return None + # Match tabcmd classic's on-reuse banner (Continuing previous session + server info) + # so users retain visual confirmation of which server/site each subcommand hits. + self.logger.info(_("session.continuing_session")) + self._print_server_info() + self.tableau_server = server + return server + # external entry point: def create_session(self, args, logger): signed_in_object = None @@ -330,6 +363,12 @@ def create_session(self, args, logger): self.logger.info(_("session.continuing_session")) signed_in_object = self._validate_existing_signin() + # Reattach to the token saved on disk by an earlier `tabcmd login` + # before falling back to _get_saved_credentials, which would prompt + # for a password we don't have. Fixes #463. + if not signed_in_object: + signed_in_object = self._restore_saved_session() + if not signed_in_object: credentials = self._get_saved_credentials() diff --git a/tests/commands/test_session.py b/tests/commands/test_session.py index 646e2a99..5a70463d 100644 --- a/tests/commands/test_session.py +++ b/tests/commands/test_session.py @@ -529,6 +529,122 @@ def test_create_session_username_and_password_no_prompt(self, mock_tsc, mock_pas assert auth is not None, auth mock_pass.assert_not_called() + @mock.patch("tabcmd.commands.auth.session.Session._open_connection_with_opts") + def test_create_session_reuses_saved_auth_token(self, mock_open_conn, mock_pass, mock_file, mock_path, mock_json): + """After a prior `tabcmd login`, a subsequent no-args command should + reattach to the saved auth_token instead of prompting for a password + (regression test for #463).""" + _set_mocks_for_json_file_exists(mock_path, mock_json) + _set_mocks_for_json_file_saved_username(mock_json, "cookieee", "monster") + mock_auth = vars(mock_data_from_json) + mock_auth["site_id"] = "site-1" + mock_auth["user_id"] = "user-1" + + restored_server = mock.MagicMock(name="restored_tsc_server") + _set_mock_signin_validation_succeeds(restored_server, "monster") + mock_open_conn.return_value = restored_server + + test_args = Namespace(**vars(args_to_mock)) + new_session = Session() + auth = new_session.create_session(test_args, None) + + restored_server._set_auth.assert_called_once_with("site-1", "user-1", "cookieee") + restored_server.users.get_by_id.assert_called_once_with("user-1") + assert auth is restored_server + mock_pass.assert_not_called() + + @mock.patch("tabcmd.commands.auth.session.Session._open_connection_with_opts") + def test_create_session_falls_back_when_saved_token_expired( + self, mock_open_conn, mock_pass, mock_file, mock_path, mock_json + ): + """If the saved token is present but no longer accepted by the server, + we should fall through to the existing credential-recovery path rather + than pretending the reuse succeeded.""" + _set_mocks_for_json_file_exists(mock_path, mock_json) + _set_mocks_for_json_file_saved_username(mock_json, "stale-token", "monster") + mock_auth = vars(mock_data_from_json) + mock_auth["site_id"] = "site-1" + mock_auth["user_id"] = "user-1" + + restored_server = mock.MagicMock(name="restored_tsc_server") + restored_server.users.get_by_id.side_effect = Exception("401 Unauthorized") + mock_open_conn.return_value = restored_server + mock_pass.return_value = "prompted_password" + + test_args = Namespace(**vars(args_to_mock)) + new_session = Session() + # Expect fallback to _get_saved_credentials -> getpass -> fresh sign-in + with self.assertRaises(SystemExit): + # sign_in would try to talk to a real server through the mocked _open_connection_with_opts; + # we just care that we reached the credential path, which raises via TSC-less setup here. + new_session.create_session(test_args, None) + # The restore was actually attempted (proves _restore_saved_session ran; fails if the + # method is deleted in a future refactor, unlike a getpass-only assertion). + restored_server._set_auth.assert_called_once_with("site-1", "user-1", "stale-token") + mock_pass.assert_called() + + +class RestoreSavedSessionTests(unittest.TestCase): + """Direct unit tests for Session._restore_saved_session (issue #463). + + These exist as a hard regression guard: they fail if the method is deleted + or if TSC removes the private _set_auth API tabcmd depends on to reattach + a saved token.""" + + def test_method_exists_on_session(self): + # Guards the fix against accidental removal. If someone deletes the + # method during a refactor, the create_session flow falls back to + # getpass and hangs silently in non-TTY contexts (#463). + assert callable(getattr(Session, "_restore_saved_session", None)), ( + "Session._restore_saved_session was removed; login-then-publish " + "will silently hang on getpass again. See #463." + ) + + def test_tsc_server_still_exposes_set_auth(self): + # Guards the fix against a TSC upstream rename/removal of the private + # _set_auth attachment API. If TSC drops it, _restore_saved_session's + # broad `except Exception` would swallow the AttributeError at DEBUG + # level and reintroduce the silent-hang behavior. + import tableauserverclient as TSC + + assert callable(getattr(TSC.Server, "_set_auth", None)), ( + "tableauserverclient.Server._set_auth is gone; tabcmd's saved-" + "session reuse will silently fall back to a getpass prompt. See " + "the note in Session._restore_saved_session." + ) + + def test_returns_none_when_no_saved_token(self): + session = Session() + session.auth_token = None + session.site_id = "s" + session.user_id = "u" + session.server_url = "https://x" + assert session._restore_saved_session() is None + + def test_returns_none_when_no_site_id(self): + session = Session() + session.auth_token = "t" + session.site_id = None + session.user_id = "u" + session.server_url = "https://x" + assert session._restore_saved_session() is None + + def test_returns_none_when_no_user_id(self): + session = Session() + session.auth_token = "t" + session.site_id = "s" + session.user_id = None + session.server_url = "https://x" + assert session._restore_saved_session() is None + + def test_returns_none_when_no_server_url(self): + session = Session() + session.auth_token = "t" + session.site_id = "s" + session.user_id = "u" + session.server_url = None + assert session._restore_saved_session() is None + def _set_mock_tsc_not_signed_in(mock_tsc): tsc_in_test = mock.MagicMock(name="manually mocking tsc") From adb0efc4cd78718500a814f76c401a16fcce27ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:15:08 -0700 Subject: [PATCH 07/15] Bump github/codeql-action from 4.37.8 to 4.37.9 (#466) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.8 to 4.37.9. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.8...v4.37.9) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 865519b6..b89918f9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.8 + uses: github/codeql-action/init@v4.37.9 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,6 +53,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.8 + uses: github/codeql-action/analyze@v4.37.9 with: category: "/language:${{matrix.language}}" From ddd1b245f5a2a75860b96453a1841e25dc551c98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:39:26 -0700 Subject: [PATCH 08/15] Bump the pip group across 1 directory with 2 updates (#461) * Bump the pip group across 1 directory Updates `setuptools` from 81 to 83.0.0 - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v81.0.0...v83.0.0) --- Did update pyinstaller but then had copilot revert pyinstaller bump to 5.13.1 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jacalata <2009720+jacalata@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0456f3c7..0d4fa3a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ test = [ "types-setuptools" ] localize = ["doit", "ftfy"] -package = ["doit", "pyinstaller==5.13.1", "setuptools==81"] +package = ["doit", "pyinstaller==5.13.1", "setuptools==83.0.0"] versioning = [ "doit", "pyinstaller_versionfile", From f4f99837079207dd380faafa35dbf241e7423f1f Mon Sep 17 00:00:00 2001 From: Mauro Fujihara Date: Wed, 9 Sep 2026 08:42:48 +0900 Subject: [PATCH 09/15] Fix publish failures when using embedded db credentials (#458) * Require --db-server for embedded credentials/oauth workbook publishing, fix connections payload * Translations for publish.options.db-server message * Simplify changes * Adjust tests --- .../publish_command.py | 4 +- tabcmd/execution/global_options.py | 1 + tabcmd/locales/de/LC_MESSAGES/tabcmd.mo | Bin 19443 -> 20705 bytes .../locales/de/tabcmd_messages_de.properties | 1 + tabcmd/locales/en/LC_MESSAGES/tabcmd.mo | Bin 20483 -> 21916 bytes .../locales/en/tabcmd_messages_en.properties | 1 + tabcmd/locales/es/LC_MESSAGES/tabcmd.mo | Bin 19175 -> 20423 bytes .../locales/es/tabcmd_messages_es.properties | 1 + tabcmd/locales/fr/LC_MESSAGES/tabcmd.mo | Bin 19599 -> 20860 bytes .../locales/fr/tabcmd_messages_fr.properties | 1 + tabcmd/locales/ga/LC_MESSAGES/tabcmd.mo | Bin 19517 -> 20739 bytes .../locales/ga/tabcmd_messages_ga.properties | 1 + tabcmd/locales/it/LC_MESSAGES/tabcmd.mo | Bin 19329 -> 20566 bytes .../locales/it/tabcmd_messages_it.properties | 1 + tabcmd/locales/ja/LC_MESSAGES/tabcmd.mo | Bin 21856 -> 23120 bytes .../locales/ja/tabcmd_messages_ja.properties | 1 + tabcmd/locales/ko/LC_MESSAGES/tabcmd.mo | Bin 19611 -> 20852 bytes .../locales/ko/tabcmd_messages_ko.properties | 1 + tabcmd/locales/pt/LC_MESSAGES/tabcmd.mo | Bin 18559 -> 19726 bytes .../locales/pt/tabcmd_messages_pt.properties | 1 + tabcmd/locales/sv/LC_MESSAGES/tabcmd.mo | Bin 17966 -> 19192 bytes .../locales/sv/tabcmd_messages_sv.properties | 1 + tabcmd/locales/zh/LC_MESSAGES/tabcmd.mo | Bin 16694 -> 17876 bytes .../locales/zh/tabcmd_messages_zh.properties | 1 + tests/commands/test_publish_command.py | 39 ++++++++++++++++++ 25 files changed, 54 insertions(+), 1 deletion(-) diff --git a/tabcmd/commands/datasources_and_workbooks/publish_command.py b/tabcmd/commands/datasources_and_workbooks/publish_command.py index 8ec8871f..39c430f0 100644 --- a/tabcmd/commands/datasources_and_workbooks/publish_command.py +++ b/tabcmd/commands/datasources_and_workbooks/publish_command.py @@ -69,11 +69,13 @@ def run_command(cls, args): if args.db_username: creds = TSC.models.ConnectionCredentials(args.db_username, args.db_password, embed=args.save_db_password) workbook_connections = TSC.ConnectionItem() + workbook_connections.server_address = args.db_server workbook_connections.connection_credentials = creds datasource_credentials = creds elif args.oauth_username: creds = TSC.models.ConnectionCredentials(args.oauth_username, None, embed=False, oauth=args.save_oauth) workbook_connections = TSC.ConnectionItem() + workbook_connections.server_address = args.db_server workbook_connections.connection_credentials = creds datasource_credentials = creds else: @@ -183,7 +185,7 @@ def publish_workbook_file(args, logger, server, project_id, str_filename, publis publish_mode, # args.thumbnail_username, not yet implemented in tsc # args.thumbnail_group, - connections=credentials, + connections=[credentials] if credentials else None, as_job=False, skip_connection_check=args.skip_connection_check, ) diff --git a/tabcmd/execution/global_options.py b/tabcmd/execution/global_options.py index a0c1ee79..cf80b8e9 100644 --- a/tabcmd/execution/global_options.py +++ b/tabcmd/execution/global_options.py @@ -258,6 +258,7 @@ def set_publish_args(parser): creds.add_argument("--db-username", help=_("publish.options.db-username")) parser.add_argument("--save-oauth", action="store_true", help=_("publish.options.save-oauth")) + parser.add_argument("--db-server", help=_("publish.options.db-server")) parser.add_argument("--db-password", help=_("publish.options.db-password")) parser.add_argument("--save-db-password", action="store_true", help=_("publish.options.save-db-password")) diff --git a/tabcmd/locales/de/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/de/LC_MESSAGES/tabcmd.mo index cac49e59979b37c6f1ff8c94d05485f8d51a94ca..91b71d3f466b8beee63ab0fca808d4ff020665cd 100644 GIT binary patch delta 4245 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 b&0kblQs8);vOw{|{6Jt~X4Tn#qgwwLY#T7u delta 3647 zcmYM%3y_vo83*vAARxE`%f$tB`IZ%O#nea?a6=R@kThM>zy=2$A;;AQ5s}pV&=@Zv zrjnPEk|D3*xJ{cGOlCHb(?}x5l;e%EhEb`;35NzR+3(-?&b0IK{mywW&w0*s&in4N ze$xTp+;Bks&E~%M6hCkJcesCt^iS{qKOZ%fGFtaz4ra@tr5w+r_$e-68*k(Y{*bNQ z%o<c3K8`8M`1U5U~#U#3z zW4VeMXakStQ_KYa%o;!D5ymgA(#VNU`jiE}GNW!Lp|#8ao7ll$GKsy-TsPdwkvx$Z z=uEb7B^9mQ#l(4#H9o^6`eypZFZ-(tMh|NYG=-VLJZ1*nOoFRu%<_F^;2yfQ?BG%S z8*6NGQwBJeDgE54e-Rb0T+iHpJL}2(NsYAkt)yqs~3BJtikyVs)`4JQ6xym6>z&n{G+QUp>uu)R6Nz59b${NpMu3N?=elN46J=O90wyN|#oI zRpnjGTJ|uB{<`YF$|U#?);PdTiPOPMa2~U?mobU1q{Qmw7LAl}6Wv>$Wh(J{?%I#f<$_P`>hA}g4Zu4NM2$gKGm=Dt^%rQORU*4N3QdHyG8B=adupmUi@T*f4F zbL9pm@D|qia&>$!#Vkz@Ch#%LV|o@7cM%WabxZ=c(k}@fCrht zPcwmEVlscX>JPMlHT@%*Ju#aZU_P@%S21z#VkYufb^bR@yj|_;pT_>`#IPe9Cyr$j zn8Q5Biz;tn0zb*zw~fbgH+{+oZ?8PY)0pcnWfH!bN$ip8_;XBRyT+@3PV93a0f!nb zrJ2YFcnY(rUSr;P@39}pc-!#cnlxnK}v%{7%5 z^LW03z!$eG$!M@%nYuiNtT<~#3z_QTbRe|FU%`=FK=SI&JsStY}zJS%;E$N zVmE8NhMCy?oRsIkS0ihu zsbtHEjq@`&T>pF~Za4F$y@C1tQy$Pr0zYGo?{F9gTA(~et;|53OafhOV>h#z*77<& z&+L_%QyPCEUCvbW{>o>0iT-YG=e()v|0Rtv>VFZJG8eqe7M^r+V-uZC%Uo7413%0j zKF>|O#*R9T?VoF``4V2BzloV?KkKHbrJad8pEG!6>YvT?69=+cCR?zq*|okTS++21 zx0fk#(~L%G4`+_g;uv07c`a?DaxcHd=a~V!PHk-BZVuG{devWD*T~x3&8+>ST+0`j z%omu}VO+sH4R_F^Tz<}+|0~a9ndzCv`Mi;v*~XL2D%UUM!Mv4;b2qaW>c7y)47T$N z+{6By;;nHIXV9T?Iy2)n%o;z;B=91~a}O1<3_ZQ^$~~K-^_Oxa*Ki0oGJC0)5~-Kp zX$){+FAwBL?8{Gh5c_(T`fwmK;Gs++6PZM2GfVR|4(2!5k87D3e}^gg531vjGl~3| z6Y~5&r;!)T+id4PrV^v)H2f;Z>Hn0i{1cPNK0eK1bCsDda0dII;r{}h!z^8$oxG2^ ze{4#c)KQ?#|vr9AjSWM>QJdexx2|Kv#%*LB&_Y3())h%D}#D>x2`^<&(+J9 Q^jPIK@4>9Y>|FBqYSk^lez diff --git a/tabcmd/locales/en/tabcmd_messages_en.properties b/tabcmd/locales/en/tabcmd_messages_en.properties index 2fe0f59e..763cde97 100644 --- a/tabcmd/locales/en/tabcmd_messages_en.properties +++ b/tabcmd/locales/en/tabcmd_messages_en.properties @@ -204,6 +204,7 @@ publish.errors.server_resource_not_found=The resource you specified does not exi publish.options.append=Append extract file to existing data source publish.options.async=Publish asynchronously publish.options.db-password=Database password for all data sources +publish.options.db-server=Server address of the database to associate with the embedded connection credentials. Required when using --db-username or --oauth-username to publish a workbook. publish.options.db-username=Database username for all data sources publish.options.description=Description of the workbook or data source publish.options.encrypt_extracts=Encrypt extracts in the workbook or datasource being published to the server. [N/a on Tableau Cloud] diff --git a/tabcmd/locales/es/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/es/LC_MESSAGES/tabcmd.mo index db1cd9b874478be8c6aee87ea29b4edfa754f199..fdbbd2bfcd42143ddf7559980594a6b10e38f15f 100644 GIT binary patch delta 4249 zcmYM$3s9A19>?(q!_e>+0%9S&U|tg>6)&5v5Qr71>1wIv=F2%pJp~*)hvTi{%}Z9M zMG+BQBv+{{Z-rUr=5FKGPVTJZS~EHAy4Fl~nQ^Gb=(0`iet+kEaG&A-exBz!@B7@} zSHEaZ`fFQ~_wTgiW1Q0t(BS7Rxv!CK76t@ii*sQXW18g`-v znnWQNvr!#Q#nCtyHNkt(!B!kX|K=_G!)c@q(}kK*%0Rz^QAm}UiKB2aYG9jC_Z`4Y zJca7$E6l=ldhd$`sDWOK4lY6sbe+odZ<_55hfp1Th?>C{$e&4Jc{K8D)BvZTI<7?B zUyCF0K6G$5s)OUGr9WrOzo51-i_z-&k?3jUH*zUP7l&aB4#l@o9e;{y=vOSjObVK5 zIqD2Vke^HoYC!vJ`5mNBbK2H-T9a6w!PKW`vHqH2Ar)G(GA>%O+pr(rfm3lU@~k<8 z920W_)$m!=OuJBLWB{#RiDPj%R-%?ZidxY|TRwmeQXiXiIpcvbl*tS1gemqj2E&$Y_Pt7 zjKLhoJp3FT?8WHFno-u7sJ#xM2D-+U8&Lz?i4Gn`wR0BvGfC`&w%9=i>X||^8rd|| zQo5*-#ZfEqxOER|MUeB{)&u2t!No)U<;93%u3Yrji@c&jd_XppNw9s zf1yU+iCWs;3|sXMs(u{y!C9z}<6=|?%TNtBpa$H64!(+7p;M^m&!GmaeNy{5sPkuv zmBBfv8^gA|3Dv=N)M-9sJ%ef}nPt;+`B;ck@hZF%b(kCN??+K9dm5j?^QbL;kej{9 zWOk9c6we@So6qnDOyOg|y=E@zw8v40svUdb>$ZHOVp)@fp-}ou~okFpFPt z7HXnZBUt}!WR~+4sl+bS-j(t$>2@uOLP&2R-3TI#K+rQU@l_&2P=K78<}<052nOe+@QQB*@; zV+me0hF26rr~w~D`ZVvMCiV^TXL_7MmLnB_M{HAC0p=bJcc1GW&L!>Uc}$v zpHW-!GwS(N)>#uN#>7BT_qC!1{2@-kpRhNMXY@LR-ZV15B{LtFVH57f|6musbdA4v z+s7GGP5JM56Bdp4mvX(e4U4IN8|UH$)I?^mJPtOXCfJ5|;t}K)&y3(Hjr>;3MbFxX z11bLrHG>au68?zVs-lU$*JB3drRd-))FEy`&G;=;yWgP(kWM2yD@B;2_kST79g5Y+ zK{d@d50Bt#?8h{8CN|@>xEH75SNJ{-rRS@--T4lgdEyq1Sb@B`F+={&6d zZo$d)Z{lP$(tW7C{1Ub2Y14Q!a1ClnKfy{I$9_ez0d<(Z#d6Gbt?V`k%E+<@8mEYg-ajIZENn2GI7OV9ri`{O^X zpQ5(rTU0+OWgJ`_hFSP|8SAeO{!0bt$_!>2Y?GetRrrT& z0`{R?iyRNL4%NW{%*XdpTX7!MZpBRh1GH?W=U1$!f&*`MU<^;8PHDyU{s-qZ9FW8p z4f!*jH~42{66?w_Fk4V7aSS!EkMU9L%YJRd=TIw`SLOfT(ZzpK-sX{MA`_nF&-@qU z6*UuP>vS3u#6RFhT!O>r_@{gm=231(#-($dNLmG@7km<*b!D|GtB9Q{*z`Omp3`CJ zP7jdL5^K3=+pH%-#0sK=xP#DMrV`H*uMoS59>jb?=`o+gO0bZLEgXx#Ct3+jNQpD$ zCH|pca*4#jznF9Ym7XS^BcM60d6-4kh*yTZxgx z%|wazKbOqqM3{J)PVsb4HgjnZFv;FPTWpxBnA>wh&PF=i46qb zpNpxITzWB=wZse}i>M=RA+`|@5lY*Ma$+(uoZv-#I`#Y1OJkwvs!+6P>7ewE#zA-Y zx@5upijICxj|{Io91R5m;bR?jPB2vC=)Q0;5~bkOxK6bj3n?3P>mxDeu6T$W95)sT zgx%<|9Zn!p$6YZe5Df)GbyU`bV!ECB(3(V>;krOH9;N+dqv=DHkP0rBFjse9VW&96= Cu-=>i delta 3034 zcmYM$e~j049S89DS+FZE;P};X-g)24i%Ss|REShKf%6A6Pf%vG8Q_$3IwnNg$m<*7 z9LymWQ6Z_NU}R%icP+-$MU9C5Ku686kf!9CxlCYIv}HY>+-LK4_kFzIpC9k{>-~DY zKcDX%c%tF2TN~=f#(Z#V@w3NYo4=#O(tH2syCX_z)qRYkxQ7?>H|*wVcCxdnl<}O) zbGU>xeu*FDw#uJyoc=*Jmr^gsG;-rRZ0GQg4Bgnp$@;y_fUEd1K2n_@V6Oi?$M7T* z=&+Hcbg;%u)Wh?+m|5UgS>t1DH-CAdI&qXDDyNwhjj-HIFol_50ng*-n7}qL*9~$4 zUuGuyJ6qT?x)d>$smwUDS>r8CpkGPf{AF8p!Cq#fBg_ikVpcFhREfOC1bC&7Ox(v@ zzlxvW1FUg3Gr=Jy^>0@F1{aI1v@!QzzKqmwBtmz*fFaOz8ryR)2CRj!$n8ysXxazNB z0{9ve=%c)d1GK1eoGHP-m_1T+v1XaijI+G5pEdn$6m!{I*GS|qGAVhReoLcSVvRL^ zl3D2zX5ckUi5_AW@M9)phnX6`#u`sB*Nri_W|_{Ew2wJnzq@KY!bCdAPQJt%PckVS zYxX#msc|0@;H_1EE&Y~FK5E>>jB}7#;9E>-KWvf&+Cl-;OSeW+xPXalDa~2#ulyDh z`LmS=nLu8rs-3wh8JXbbq;v`s*c_(jpJnb}%anEt6W9(m=lTD&hTzH(W}s7NPKeYj zIo?L^rZR(Gc4Zzj!41s7cQ663V~yLH3_Zu(|04aCzxl}cjna|pJEHl^OpRQ4V|8LB zGrcv$=LM;|x*^ zm)0zB(WLrNs_%8cZ+X~94}Zo_^Pil}xn?nESOOb7fDl%cQ6aw$h)|cm#}$i_5Am0bU1KlzEbqV8%Vh1n?eJtF)Tk161bl%=iCt4f~?hxrjS?AOB6|FZU?)<@`3U;O{tx zqb)lV-WVHv$#9AOw&_EA;uLe;rJ_&9?qf2ul?m_=&#P-RI+|Kfifl5KpB z*;KFaN1S-&(B|3C3~-dAc%rhwj!H?I+0JhMg1v0vaD!$14ys0(IotCuOXY3{I`|Zq z@mIWuT|K_xxRtq~(=sHd+{7ksqO6xknF(HEGV&&uuu1+i?h3Z@!K(j#+J|M|oVx#B zG)_8@-MM1!(2M3UM>mx6Uta6@NO{fPxS0vy8RjuM&II-!ZswG$m4naI8p>7I4E?XT zj(^hM%`Mz$&@8-t#&tul%A0tO0}t>?KE+#jwL;dogPq);!Y;qQp5c7}(HuxY3B%qZ?G?ZX~G=zsJh#-hJ$px+^_lEnB@Vp?X zlv0gCgoscTi^d|vf>E5Q)uGl7s1>x8X;suI^)Z0XC|ak!zQ1!ebSMA&**)i;v%CM@ zy}7R~<-5mHlJ94vwi?PIB9HiYcVqsCdwcOg+1}5Xi?I#)GY9z?iGRkScn%9NzrQgT z;dmT`v(aK4v$56rG-lF%1urxvY5qw?51z(+ynw3~Ee^v8s24Wk5ZvtU??-+AP0YZv zsDY-?7>s$Sj>@qZXQC#!3N5x_KK+{)+>H~+JIp!MjCu_4J19afnJO&8d8mPHKz*+R zv+y{oqfaphGwHn#mY@cD4O*Ow8t4kO>EE=vFLa_hdK)!^kC8vqgY;P0_b31-pI zOedoDKm@sEwxI^J&$VAh`ZOop^|Q_t(v!>e%pCHs8J2QEDXZi|DVvXd@ir{S<;b(9 z6WJ!_4b+QIqGoyywMY8%)`2(zFUJ~G>Z7QPwzzf&TH0^ql7DTg&$*zPW$-q2P=R{U zOxJEi4PXt@mw6CJ;C^Je%}LY}e2Lm4c|5F)Oh&zD0UvZ>mb>;Aw6ympsVD<)qGoUk z^~F^3t_}w|N28@Z9qGeFP#tVSE!Cr_iM)c!+$X5D{st}fAPxFnA!-6sky|DibT{sF z?m@<2j$i@)2Q8*EI;y6~S%q5bAZnn^uHAwf;BK_|BI-RSkw25dI%tV4GSH+crJ|9I zL!~l+8d(CBi3gl}Q6vA0^CW6O|3&SEEbC`vI4Yx+sDa&te8n`Oo^L@daXS`x=bws> z)w`&XpGBp%C&Siti@JU#_Qq+bAIEv94i=$ayaqMkZD{csRECbDot>GBPYj6i@Y2HC)<}1_;(-@te&&6(NF&!_((O81&ryes> z*c+&cb`B-~Td5r5SEL#jafXzd4%G2_6}4HuL1m&lXG=f5`8Wlu@n+nLTH`NJKS=%g zb8c(2JIDoW_#jCLb zV>kg1;#B+^^}b5hfiammsP;P7ZbfDAPf02o@iA2DzeT+$j|KfT-hy}GXQ&J`k(SB0 z5w(l|h9t*)fEIg>^aoaq8gL!z{w~yvU&TI{c7=axl9^O=qZpT92p`Ae_&P4T(qF?# zKlk^MDnaPA4qS?$Pzq*n{g+uDEBvO&N%z>bk;$;^g3LPD{(!3i&Jnl!`9mW73;BIh2LQuHPh{=nZJ#d_#J8~D%enZ zeiP2ZzoRBld@cFc=4hfa5ZjQfnrBfnIgQ!apSSUoU<#4Rm}z(nH=*v&CoR1(?p%j` zXy1#u_%K@R#H08Q4#wS+$bUYS!wiRgW}r*H9f7krpj^b#>A|cJ*A)=DG))@DRqZn7j?aU3eB> z#_O=7hJP%W!ost9W*%zB+ffr# zT!Jx}bwrR@LL4A&BQ$_sgqGs>L_2W-q0OaozmK=1tdO~t(4=l5+6YZZg(XUQzhWf8 zE9Fk1qo|@ZK1jrgMnYvc@jI`^`NkcDHrfPYDA83`P|>oeJnnP;vzA)A=Fbv&Qk`A2xXNrCPjq)}PN%uu)$8#tSGV{F;!)SW7E6gCgqFOHSk|>})V(u%J!;)= zCOV0`36;x;0I^05%2uEApE~~siHnGp?rIYjySjFN!qwTs-f>=v$wPdwr@XR=Pwifn zRYa87Mzj(tza_GWdBhgt0b(tovX>|zRucn>4x+2{rScqc718W2?!+-#|8efZ4LFj> zCN{WhG2|qAM{^TiOza~T6Z;93dwmQC!7B^+?CI*9I`5}E11}+-CN>j=#EnFS)_*XS z3yBc%6ru7Yv7P9l2IUza^E1@BS7{(hh<<8Niil^4g~WPd08vIfPYfgOBqkGGrG`r8 zC_a`GlZYH5Ow1vEL;Q+R*+onw#uAqk9K?ruecdZ97L48zjBdF#JM%zG_R92bvu8~{ z(AV0mvI+IkU@R82^$F{JClR&vLE8|pwE9etG;e7e zQ;_6-9ruIHp-9+z5o9`G_aJBy4@Vir+5*c=R}_2SdJZ3$}rC1`MjPL zKf@8+Ub~wu`Ug0qltwwGkq1w5H2a;?`(QgK>0iYRxQ64nwZ7iN+<%D8JjDds_uNv( zuwo|a zA$usPa-3O$e=>WdaKr9b^|N@?G$s_+t5hlFEAxJLBG=9EU{w6OPNfsX9m8D zS)wgW0#7g%JIJi@%dGf!=DucwYnExul6EoY8@JVstxTkQIF`R<#ZycPhnhWhFl*e! z1o*MKzn*^O0f&l@GUFUz5`2SM+5sj>psf@@qfFFD2^TVveU#=bpR4^E6ZsEn4={oJ znO5!WL#fDc52vJ4n82=N)_ghh{CZ|-A7ldC#UXkBf36|8a)cS^^w|p{HA~KqqHj}~ zP9M86mziKOGw`iUz+Yg+olJ%HG0(q1zw)|6#_z8jxqnPFf4NK}H!i6!+`&w+nc2k; z*FMJ#bb=XRkn}F#1h#W2vx(O;*B@mn`z&|zF!Oq^txCC^UuUDA#)}%+M8|j?o787A zmvIK~r=pbo^erjJm<&%c&-d|g9}ZxDp37+*!Ax`wo15$nCeep2{5C!_Uj655EEaxB zvx9l9b~BshWu_8;V?Md>68-{CRQX6c?_#RL2$U*{P9sIzye57R1^e{`yUHqXS_y=%3C ztMwbahkxZmyy?o`@4~;SNM(|A2lE!@3wSq2aTiB(KX2q8nTcmwzmdF=dE4$`_SBBK zjZ!|X@hd0p01M zF%`Uxn|ZnQvAxQ3(>1(NUasM0VcFRjVExGPJd4&)_2i ad%n|lZ~s1b-?h4@y}cUV(>7z}@c#iT*GHrP diff --git a/tabcmd/locales/fr/tabcmd_messages_fr.properties b/tabcmd/locales/fr/tabcmd_messages_fr.properties index 1f1d772d..fbfe9a31 100644 --- a/tabcmd/locales/fr/tabcmd_messages_fr.properties +++ b/tabcmd/locales/fr/tabcmd_messages_fr.properties @@ -233,6 +233,7 @@ publish.errors.server_resource_not_found=La ressource spécifiée n’existe pas publish.options.append=Ajout d’un fichier d’extrait à une source de données existante publish.options.async=Publier de manière asynchrone publish.options.db-password=Mot de passe de base de données pour toutes les sources de données. +publish.options.db-server=Adresse du serveur de la base de données à associer aux informations d’identification de connexion intégrées. Obligatoire lors de l’utilisation de --db-username ou --oauth-username pour publier un classeur. publish.options.db-username=Nom d’utilisateur de base de données pour toutes les sources de données. publish.options.description=Description du classeur ou de la source de données publish.options.encrypt_extracts=Crypter les extraits sur le serveur diff --git a/tabcmd/locales/ga/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/ga/LC_MESSAGES/tabcmd.mo index ecc45de222bfa7f4ac973c88497140198c7e605d..27474b6b2ace2a884da58613bf07cf3246e12699 100644 GIT binary patch delta 4236 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 delta 3034 zcmYM#3v8Cv83*u#4$`em$E|i;b( zMkF8zcErWV#AIHuP3G8KHn*`gQz4A7W+P@ocEeycFx$FFvfrP+$(r{2o%4R@J?D9z zbKbY#e0JFD+lSTP8U2y1#m{U0Ci*)vJiY(_e7CWb@wz*CE??ti{0(PtkW)BiWGUmg zfah~5Yuv!|xTA6p$LjZUR4MiHjz(_$2PbjF$A@m5$_w>xVkTV63%IHJzLyz)h@*Lu z33T{5rA%gxS*VSl;u0poZ?MLloMip-)9Q;O6j2#uGHR4=7Py#MU=e5Wb4*~}%(y-_ z@i4Q{2{yC&6Qzi$T+YlhpEcgY1p4*#tzULj1NJft4KNwJ$7IkbszhF60=&*g7Vcoi zujNdBi#5K;Ebul{`oCBGVFrt>Ol0oAi1kE%hsGR!fz#N-7XF-B_|MElXN^$}j%PAm z$ee)(nR$9>XnD2j|BBh#Bh~ejmBVHAN!Odls(&(^?IR_-g(=zRX&1^G&f~*O0DGw@ zYqb(%7tXsB#kW4&P=qV>aS%2 zc$5kBX1PsrkJ;LfStNlrQvmfcLn9?z#6-57)+}GE+{Q%y zN@YJ2$Whw0GdHCoO>Ry}FJ=O}k=gTQ%>7-=)^1?}dyb>>{O{8cTp3^{I(6m?ky<6! zC(^sA%%zuIX=fJrG&Au^Cg4X{V-HiIpD_3Dr{8kiN9J!(j*OoitzWLz$iO?RFV-*% zbTg;;dzHUtCVHQlV5Iae;dD;r66O$hG2icID*Fa|c$mrjftqCaB4EmYO|HPDhklr!n9Ft^AYRm$yyq;?~z!#Z>zGLIN(r;1!c8!_B zH%D2`RzATR_c0m&l~-}Nx6@s`o{PDad0`#(A-U3E_Ed5vuiyfvBAv{OY8U75EoS`L zGwMSNwfHsz+I`IDDy9-Y;C1|v3G5mVc`{qeWcC7Y<=@!O*&cr9vUD;5Z($O6lh^P# z6W}zj&&<sCXNFy2a(D^98U?TksGw>`QnW&Zd{>#i+*~rO!g{oQ( zF!TJEIkXqJ$y()Z*0_<1T3%-6`2(lr`9G~;tJ|besCCTDej>$!rzx z|Ikfbj5x5Z5cJDxwr4!fyUnNPxxxX V*$=JzV(-+cKW^%M;@WlN{s*ShOjZB@ diff --git a/tabcmd/locales/ga/tabcmd_messages_ga.properties b/tabcmd/locales/ga/tabcmd_messages_ga.properties index 9c63a6e7..8ace3bcb 100644 --- a/tabcmd/locales/ga/tabcmd_messages_ga.properties +++ b/tabcmd/locales/ga/tabcmd_messages_ga.properties @@ -233,6 +233,7 @@ publish.errors.server_resource_not_found=b7e8-表:The resource you specified doe publish.options.append=6393-表:Append extract file to existing data source|桜 publish.options.async=7c1c-表:Publish asynchronously|桜 publish.options.db-password=ab47-表:Database password for all data sources|桜 +publish.options.db-server=3166-表:Server address of the database to associate with the embedded connection credentials. Required when using --db-username or --oauth-username to publish a workbook.|桜 publish.options.db-username=139f-表:Database username for all data sources|桜 publish.options.description=3bff-表:Description of the workbook or data source|桜 publish.options.encrypt_extracts=cfbc-表:Encrypt extracts on the server|桜 diff --git a/tabcmd/locales/it/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/it/LC_MESSAGES/tabcmd.mo index 50a2e9f7482bc20238163ed7a34ee46fb1377735..3535bc68479601021d43f43153c2d30ec4a2a17d 100644 GIT binary patch delta 4237 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 diff --git a/tabcmd/locales/it/tabcmd_messages_it.properties b/tabcmd/locales/it/tabcmd_messages_it.properties index ce944c81..7fa49826 100644 --- a/tabcmd/locales/it/tabcmd_messages_it.properties +++ b/tabcmd/locales/it/tabcmd_messages_it.properties @@ -233,6 +233,7 @@ publish.errors.server_resource_not_found=La risorsa specificata non esiste o non publish.options.append=Aggiungi un file di estrazione all’origine dati esistente publish.options.async=Pubblica in modo asincrono publish.options.db-password=Password del database per tutte le origini dati +publish.options.db-server=Indirizzo del server del database da associare alle credenziali di connessione incorporate. Obbligatorio quando si usa --db-username o --oauth-username per pubblicare una cartella di lavoro. publish.options.db-username=Nome utente del database per tutte le origini dati publish.options.description=Descrizione della cartella di lavoro o dell’origine dati publish.options.encrypt_extracts=Crittografare le estrazioni su un server diff --git a/tabcmd/locales/ja/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/ja/LC_MESSAGES/tabcmd.mo index ce6b79faf1cfce2aa07564598b25344c194db3db..7d06038c6d3d0505c10eda266bf184663cd3364d 100644 GIT binary patch delta 4262 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 diff --git a/tabcmd/locales/ja/tabcmd_messages_ja.properties b/tabcmd/locales/ja/tabcmd_messages_ja.properties index 275a7215..ca366ffb 100644 --- a/tabcmd/locales/ja/tabcmd_messages_ja.properties +++ b/tabcmd/locales/ja/tabcmd_messages_ja.properties @@ -233,6 +233,7 @@ publish.errors.server_resource_not_found=指定されたリソースは存在し publish.options.append=抽出ファイルを既存のデータ ソースに追加 publish.options.async=非同期でパブリッシュ publish.options.db-password=すべてのデータ ソースのデータベース パスワード +publish.options.db-server=埋め込まれた接続資格情報に関連付けるデータベースのサーバー アドレス。ワークブックの発行時に --db-username または --oauth-username を使用する場合は必須です。 publish.options.db-username=すべてのデータ ソースのデータベース ユーザー名 publish.options.description=ワークブックまたはデータ ソースの説明 publish.options.encrypt_extracts=サーバーで抽出を暗号化する diff --git a/tabcmd/locales/ko/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/ko/LC_MESSAGES/tabcmd.mo index 0b1ea7d1b37198615bba9ed3bd786b4c6803ae1e..54c8d41cffc8916eb2e18c5e5ddb8640a1b41025 100644 GIT binary patch delta 4259 zcmYM$3vgA%9mnwv#1KUuLP)|R$q6VBB!P-RXu`_`C@(Dqazr7XX`%|yjkfD4?3?Q!EXUxB`zAFbxMGs^8;bG*@oaQhRe~Clz4yI#D zPhi#dV z7v4e*G=@eR4nTF3i^FgZYJ%I*!NZtB|K_Z{a0PjWxr3Tfm)_A1GLTbd8fM^P)WH0x z`@)!rmr)&kgUOgc@7*vHHP9S%a1m;t+tj9i6S6lnqdNK>Y6gEn{!ACrqmd6l4R9Q) ziuoLOa9KsQJ3R!N`idurdqV~uDeyog4MLlOJ2f8p@ZTlcPv`_h{C<9-h zW^f&KV=Q@BhpE;v=+J%=>BAJGI@pI=s-vihTta2;CTgv}LkGK%2HiIlHGx^kCF3i! z7j|1uAY(9}VLIMG2jdwXRg+N%~*pNU}|w8RcFP@l=7 zqLGb9rLq7uvW=)r9I)1-M*fMl6*ZuLp!Pzd6V1qQR7Ueq16zpPVqQf3K8RZ4I!yQ8 ze=2&dzCw-s7Am#(Gi;rAQ0E`R2XQv)c8FL0NVGHgU#m>iE zrm+Nz@k#tOvTCOP=%{(fpIOU6*NqrPn4pziypwKof@>zSzU#{y*4ObN0tO)V-zpCEHJ*RZ$V|3ns6 z4;qGy$mF9kvIQIP46>;V8tF>ZNQ1Zrn{h3svD21g0B53$+Wpy+qPw{k^J!l|4KR-0Sbj4G z_1djQ`ZNtV16`bpW1k@ZDO3Vfv?=P4Wi@ATAiAjQJ@TUeCS;;AG#$_54s3 zWzl5?LjaF)R9^T7&E>j#j7|Nvl*pQy9{HHd3fbP>j`U|mABArB97U%s3pALv4QWEz0?AP zcCKc+izp=45KV;As3p_-RuS(Kbwnp(9-;EnDBgR|3Yq1ECiN_Fn9zh&*b_c)<1l%z z@W$LLT3?k);t)|n6cH-JiJyBl))vYwaLtEAw$8ai48#UDw+BO59`X4r*zS+V&HeMGPcLiRXyT9qUHjLk z&BSYj$|FPpu~QAon^Eo^bEusr^u>JHo_!IA+4>UPXzT1@@1Nh7(Dxw+_LNsvapY_4 zmF*nY6Xit6J7dhxFp*eH=rue*yh5nd6Y0bbqCXKPI!bpcXNbp%E%szJvf+K+mr3X5 z5+jKu!cVAdAb1nK@AW?HM>G(tiBp8iPoj9Qt*UxU?ziW7+YGO~_usHVc#`-#^Hl^POh3gM4PfBPCCcPZr zdH%eqP2HU@6MgM()VGJ;js&Y5w>;d|76|^^9;$UB`@?Sa z>2`lO(pXEy-FMuru5es`Rix4HxW~@9ua&tqdm@3-NZ@#u;~pt%-&G%J-sM*Nowmj@ zH&Eq0Bvj^PX0BY3xsfrg%PS}E@WGKK}znV-9@) delta 3034 zcmYM#4~*Ak9S88|P~6q22zMNZApFP!tUMG=&`uQO1OfGEMjI3dB7a;l76sT)`&BYy zG0F{f!%J6=5zQHNM@m#S-PCRkWu!(*G^JK#u|hXT>)-3`eK&7+zt{78zt8V^KF{a# ze82bZ;|;w#8|ueLoc&PobHLwNf5(QV_rcG1n@YJ*_fZbx0bb5uaWcE@C$LHP-kjFS34lrn+&AA}VK?9W~i*7U*CWSiq06hY4&eGj2ay z`5Lp(-`U2t3rZ1Fnaa#_J!@Rb1p3wVtzULl0}e6^9c6a#HnW2!Q6=&k6W|OVS-6`S zzn+(H3u}CmS>Pom^(U)-gTZ1eW0~(?%6cNdRpTnI;zaiHVm`|({3m9jAa;RO6cD=1d{|2b6exR9N-+C~=WVkTNt_17~2 ze1i$}5njfA+Eh8gl;Az)jMNO)Ec2OpZm+zTHT|6wb2(VoNaQawDLF;IrO_&}#u~3? zcDk6Ecmq?SZOjJtG8sF<)c8%-_)lis2$O4;X-rAGnd|knRpS9B(*2yk!>sW%lfq`J z$Ei$>yO{u2RQ=8LTekbCaSt=k0JFijnbLmPA_=sO0;reC8cE>-CbDI;X1S;GVJ7l@ zl>dQ%K2NT#0OwE@t-`~uXb_WyKE)LK0|AL0#%28&bciz7tQmf?p zSb8^=>GZNIUCaUtnThXY0{%K{>}4|abLRUm({K5!kIdgF9T`79TEASQk%6~WH`Xx= zY-LXI_bOjxCOXATaIWo5;UuOEoVeZfE@*jYAqaM6d7; zzQ;@XS$WDS-^!%!05g7|>K|q@^Cr*YDW=A!c`Y;P9L(UqxP=L{uJ8|Y*TwQbU!&92 z?D&38Zs1B@$=?lLub1=moQHA=b82s9p6k`LnQ{+ht^AOAv;Bq{ccSt?)%{WO z?a3{(n19bbbS%oZm<;Wsn956RW}+UQ=f6e69AyfVk`??Bzfb3+ER^Rw1$Qwi{sD7F zPBIf;rjVl=97rzKU*&{l+%K84)8JzqJ1Kc(2@~KpPPBe`TH{Xso?qY`1zgTPE?~2K zuHiD)_#(Ub7bdU{&srv4$~pWtQ}b6jpUpbAv4;t`k2wp6S(lA+TqBP|v+ZZVG>+k? znaq5Vzu+#`xWXfF0qr8-;FlS?LSKXth z@u~~=ak9c@f+zS#9%489xRl>c*GLVADRc&2&&S!rrEIu)@DMIzBEO&I_LVDl3~z4h-#_eVQ~&NUcQ>B3 Sal`8V_Vzbg`=9IV8~J}t$43GH diff --git a/tabcmd/locales/ko/tabcmd_messages_ko.properties b/tabcmd/locales/ko/tabcmd_messages_ko.properties index 175ab513..623c2caf 100644 --- a/tabcmd/locales/ko/tabcmd_messages_ko.properties +++ b/tabcmd/locales/ko/tabcmd_messages_ko.properties @@ -233,6 +233,7 @@ publish.errors.server_resource_not_found=지정한 리소스가 존재하지 않 publish.options.append=기존 데이터 원본에 추출 파일 추가 publish.options.async=비동기식으로 게시 publish.options.db-password=모든 데이터 원본에 대한 데이터베이스 비밀번호 +publish.options.db-server=포함된 연결 자격 증명과 연결할 데이터베이스의 서버 주소입니다. 워크북을 게시할 때 --db-username 또는 --oauth-username을 사용하는 경우 필수입니다. publish.options.db-username=모든 데이터 원본에 대한 데이터베이스 사용자 이름 publish.options.description=통합 문서 또는 데이터 원본의 설명 publish.options.encrypt_extracts=서버에서 추출 암호화 diff --git a/tabcmd/locales/pt/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/pt/LC_MESSAGES/tabcmd.mo index fa709d8ac9072a1809f9e6a84bcb9f9820df907a..2b6036af3e07644934115957dbfd020780d42deb 100644 GIT binary patch delta 4191 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 VwNYbUu6iz*p8l6FxO;A0!hdAHMn?bu diff --git a/tabcmd/locales/pt/tabcmd_messages_pt.properties b/tabcmd/locales/pt/tabcmd_messages_pt.properties index f1ecb23b..7c63823b 100644 --- a/tabcmd/locales/pt/tabcmd_messages_pt.properties +++ b/tabcmd/locales/pt/tabcmd_messages_pt.properties @@ -233,6 +233,7 @@ publish.errors.server_resource_not_found=O recurso especificado não existe ou v publish.options.append=Anexe o arquivo de extração à fonte de dados existente publish.options.async=Publicar de forma assíncrona publish.options.db-password=Senha do banco de dados para todas as fontes de dados +publish.options.db-server=Endereço do servidor do banco de dado associado as credenciais. Obrigatório ao usar --db-username ou --oauth-username. publish.options.db-username=Nome de usuário do banco de dados para todas as fontes de dados publish.options.description=Descrição da pasta de trabalho ou fonte de dados publish.options.encrypt_extracts=Criptografar extrações no servidor diff --git a/tabcmd/locales/sv/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/sv/LC_MESSAGES/tabcmd.mo index e329a6a88f01698761fa98826e41d1f6b652ae22..d854d2f15868faf65e9aad94521d9805e325b2f5 100644 GIT binary patch delta 4228 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 PH+%L+O?~@2&Tjr6SoT0M diff --git a/tabcmd/locales/sv/tabcmd_messages_sv.properties b/tabcmd/locales/sv/tabcmd_messages_sv.properties index 080a19c1..d02dfded 100644 --- a/tabcmd/locales/sv/tabcmd_messages_sv.properties +++ b/tabcmd/locales/sv/tabcmd_messages_sv.properties @@ -233,6 +233,7 @@ publish.errors.server_resource_not_found=Den resurs du angav finns inte, eller s publish.options.append=Lägg till extraktfil till befintlig datakälla publish.options.async=Publicera asynkront publish.options.db-password=Databaslösenord för alla datakällor +publish.options.db-server=Serveradress för databasen som ska associeras med de inbäddade anslutningsuppgifterna. Krävs när --db-username eller --oauth-username används för att publicera en arbetsbok. publish.options.db-username=Databasanvändarnamn för alla datakällor publish.options.description=Beskrivning av arbetsboken eller datakällan publish.options.encrypt_extracts=Kryptera extrakt på servern diff --git a/tabcmd/locales/zh/LC_MESSAGES/tabcmd.mo b/tabcmd/locales/zh/LC_MESSAGES/tabcmd.mo index 3c25206721c3c5bbd400a93c13294fed41fbd0e6..c9d0e60fcf79751f9f4c64884b70b4c0801b22b8 100644 GIT binary patch delta 4200 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 diff --git a/tabcmd/locales/zh/tabcmd_messages_zh.properties b/tabcmd/locales/zh/tabcmd_messages_zh.properties index 5a7a19ac..4f6f8d81 100644 --- a/tabcmd/locales/zh/tabcmd_messages_zh.properties +++ b/tabcmd/locales/zh/tabcmd_messages_zh.properties @@ -233,6 +233,7 @@ publish.errors.server_resource_not_found=指定的资源不存在,或者您没 publish.options.append=将数据提取文件追加到现有数据源 publish.options.async=异步发布 publish.options.db-password=用于所有数据源的数据库密码 +publish.options.db-server=要与嵌入式连接凭据关联的数据库的服务器地址。使用 --db-username 或 --oauth-username 发布工作簿时必需。 publish.options.db-username=用于所有数据源的数据库用户名 publish.options.description=工作簿或数据源的描述 publish.options.encrypt_extracts=在服务器上对数据提取进行加密 diff --git a/tests/commands/test_publish_command.py b/tests/commands/test_publish_command.py index 368fd997..df4b7115 100644 --- a/tests/commands/test_publish_command.py +++ b/tests/commands/test_publish_command.py @@ -55,6 +55,7 @@ def test_publish_with_creds(self, mock_path, mock_glob, mock_session): mock_args.db_username = "username" mock_args.db_password = "oauth_u" + mock_args.db_server = "db.example.com" mock_args.save_db_password = True mock_args.oauth_username = None mock_args.embed = False @@ -66,6 +67,44 @@ def test_publish_with_creds(self, mock_path, mock_glob, mock_session): PublishCommand.run_command(mock_args) mock_session.internal_server.workbooks.publish.assert_called() + call_kwargs = mock_session.internal_server.workbooks.publish.call_args.kwargs + connections = call_kwargs["connections"] + self.assertEqual(len(connections), 1) + self.assertEqual(connections[0].server_address, "db.example.com") + + def test_publish_with_creds_no_db_server(self, mock_path, mock_glob, mock_session): + set_up_mock_server(mock_session) + mock_path = set_up_mock_path(mock_path) + + mock_args.overwrite = False + mock_args.append = True + mock_args.replace = False + + mock_args.filename = "existing_file.twbx" + mock_args.project_name = "project-name" + mock_args.parent_project_path = "projects" + mock_args.name = "" + mock_args.tabbed = True + + mock_args.db_username = "username" + mock_args.db_password = "oauth_u" + mock_args.db_server = None + mock_args.save_db_password = True + mock_args.oauth_username = None + mock_args.embed = False + + mock_args.thumbnail_username = None + mock_args.thumbnail_group = None + mock_args.skip_connection_check = False + + PublishCommand.run_command(mock_args) + mock_session.internal_server.workbooks.publish.assert_called() + + call_kwargs = mock_session.internal_server.workbooks.publish.call_args.kwargs + connections = call_kwargs["connections"] + self.assertEqual(len(connections), 1) + self.assertIsNone(connections[0].server_address) + def test_get_files_to_publish_twbx(self, mock_path, mock_glob, mock_session): set_up_mock_server(mock_session) mock_path = set_up_mock_path(mock_path) From b4505a4d2b4f68b2bf4c296cdf0f38d39201e932 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:58:23 -0700 Subject: [PATCH 10/15] Bump github/codeql-action from 4.37.9 to 4.38.0 (#471) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.9 to 4.38.0. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.9...v4.38.0) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.38.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b89918f9..75077b86 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.9 + uses: github/codeql-action/init@v4.38.0 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,6 +53,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.9 + uses: github/codeql-action/analyze@v4.38.0 with: category: "/language:${{matrix.language}}" From 341772d506952a3af84ef3798424974ebca09048 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:12:33 -0700 Subject: [PATCH 11/15] Bump github/codeql-action from 4.38.0 to 4.38.2 (#475) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.38.0 to 4.38.2. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.38.0...v4.38.2) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.38.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 75077b86..e6260359 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.0 + uses: github/codeql-action/init@v4.38.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,6 +53,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.38.0 + uses: github/codeql-action/analyze@v4.38.2 with: category: "/language:${{matrix.language}}" From d04461f089c3b000c88cc11464ef00f6b585ba07 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Mon, 5 Oct 2026 11:17:17 -0700 Subject: [PATCH 12/15] Update x86 build to macos-15 image (macos-14 deprecated now) --- .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 6b560231..5977fa48 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -30,7 +30,7 @@ jobs: UPLOAD_FILE_NAME: tabcmd.exe OUT_FILE_NAME: tabcmd.exe ASSET_MIME: application/vnd.microsoft.portable-executable - - os: macos-14-large + - os: macos-15-large # This builds for x86 TARGET: macos CMD_BUILD: > From c4e876c03f4fff2573d5611d61db95b27431b5df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:35:25 -0700 Subject: [PATCH 13/15] Bump github/codeql-action from 4.38.2 to 4.38.3 (#478) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.38.2 to 4.38.3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.38.2...v4.38.3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e6260359..89e10877 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.2 + uses: github/codeql-action/init@v4.38.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,6 +53,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.38.2 + uses: github/codeql-action/analyze@v4.38.3 with: category: "/language:${{matrix.language}}" From 9a7438015b3e7ac6839b549146484d313eb86f1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:43:39 +0000 Subject: [PATCH 14/15] Bump github/codeql-action from 4.38.3 to 4.39.0 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.38.3 to 4.39.0. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.38.3...v4.39.0) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.39.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 89e10877..1b1d41fa 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.3 + uses: github/codeql-action/init@v4.39.0 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,6 +53,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.38.3 + uses: github/codeql-action/analyze@v4.39.0 with: category: "/language:${{matrix.language}}" From d1411ba805f1a0178b3fe738c6c2470c2f8962c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:43:36 +0000 Subject: [PATCH 15/15] Bump github/codeql-action from 4.39.0 to 4.39.1 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.39.0 to 4.39.1. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.39.0...v4.39.1) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 1b1d41fa..770fed93 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.39.0 + uses: github/codeql-action/init@v4.39.1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,6 +53,6 @@ jobs: # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.39.0 + uses: github/codeql-action/analyze@v4.39.1 with: category: "/language:${{matrix.language}}"