Skip to content

Release tabcmd 2.0.34 - #494

Draft
jacalata wants to merge 83 commits into
mainfrom
development
Draft

jacalata wants to merge 83 commits into
mainfrom
development

Conversation

@jacalata

@jacalata jacalata commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Releases tabcmd 2.0.34 from development.

Security updates

Changes in this release

Classic-parity and CLI fixes

Publishing

Localization

Packaging, dependencies and CI

🤖 Generated with Claude Code

jacalata and others added 30 commits June 29, 2026 15:22
…#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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)
output with fixes
> mypy tests
Success: no issues found in 51 source files
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
jacalata and others added 30 commits July 27, 2026 17:43
The committed .mo files were out of sync with their .properties sources:
~20 keys per locale were either orphan translations for removed code or
missing translations for currently-used code. Regenerated via the
check-mo-freshness workflow on Ubuntu and downloaded from the workflow
artifact so the bytes match what CI produces.
.po files are gitignored across the repo, so the workflow's
`git diff --exit-code -- '**/*.po'` was always a no-op and the
`.po` path filter never triggered a run. Trim both.

No behavior change; the .mo drift check is what has always been
doing the actual work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entials (#467)

* 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

* Require --db-server when publishing with embedded credentials

PR #458 fixed the two publish crashes when using --db-username /
--db-password / --save-db-password, but callers who omit --db-server
still hit a raw ValueError from tableauserverclient's
_add_connections_element. Fail fast in run_command with a clear,
localizable message before we build the ConnectionItem so users get
an actionable error instead of an internal stack trace.

Update the existing no-db-server test to assert the exit path and add
the parallel coverage for the --oauth-username branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add e2e coverage for --db-server requirement

Extend the online publish suite with:

- A positive assertion for the happy path: test_wb_publish_embedded now
  passes --db-server matching the connection host baked into
  EmbeddedCredentials.twb (see-internal-slack). Without a matching
  server_address, tableauserverclient silently drops the embedded
  credentials at publish time, so covering the match is what actually
  proves the fix.
- A negative assertion: test_wb_publish_embedded_missing_db_server_fails
  runs publish with --db-username but no --db-server and expects a
  non-zero exit. The check now fires in run_command before any network
  work, so hardcoded placeholder creds are sufficient.

Threads a db_server keyword through the _publish_creds_args helper and
records the workbook's connection host as a class constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address code-review findings on the --db-server guard

Scope the guard to workbook publishes only. Datasource publishes go
through _add_credentials_element on the TSC side, which never requires
server_address, so the previous unconditional check was a regression
for `tabcmd publish live_mysql.tds --db-username ...` and similar.
The guard now checks the target filename extension early (before auth,
for fast failure) and repeats per-file inside the workbook branch of
the loop for the folder-publish case.

Rename the `credentials` parameter of `publish_workbook_file` to
`connection` — it holds a single ConnectionItem, not a list.

Tests:
- Add test_publish_with_oauth_creds covering the oauth branch's
  server_address assignment (previously untested).
- Add test_publish_datasource_with_db_username_no_db_server verifying
  datasource publishes are not blocked when --db-server is omitted.
- Strengthen the e2e negative test to run tabcmd via subprocess.run,
  capture stdout/stderr, and assert on the localized guard message
  (or the raw key when .mo has not been regenerated). Any earlier
  unrelated failure — bad auth, missing asset, session expiry — now
  fails the test rather than passing it.
- Trim the misleading class-constant comment on TWB_FILE_EMBEDDED_
  CONNECTION_SERVER; reword the "before we touch the database" comment
  to reference the guard's contract instead of a specific location.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "Address code-review findings on the --db-server guard"

This reverts commit cfe52c1.

* Revert "Add e2e coverage for --db-server requirement"

This reverts commit 9581329.

* Revert "Require --db-server when publishing with embedded credentials"

This reverts commit d59ee65.

---------

Co-authored-by: Mauro Fujihara <fujihara@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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

* Require --db-server when publishing with embedded credentials

PR #458 fixed the two publish crashes when using --db-username /
--db-password / --save-db-password, but callers who omit --db-server
still hit a raw ValueError from tableauserverclient's
_add_connections_element. Fail fast in run_command with a clear,
localizable message before we build the ConnectionItem so users get
an actionable error instead of an internal stack trace.

Update the existing no-db-server test to assert the exit path and add
the parallel coverage for the --oauth-username branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add e2e coverage for --db-server requirement

Extend the online publish suite with:

- A positive assertion for the happy path: test_wb_publish_embedded now
  passes --db-server matching the connection host baked into
  EmbeddedCredentials.twb (see-internal-slack). Without a matching
  server_address, tableauserverclient silently drops the embedded
  credentials at publish time, so covering the match is what actually
  proves the fix.
- A negative assertion: test_wb_publish_embedded_missing_db_server_fails
  runs publish with --db-username but no --db-server and expects a
  non-zero exit. The check now fires in run_command before any network
  work, so hardcoded placeholder creds are sufficient.

Threads a db_server keyword through the _publish_creds_args helper and
records the workbook's connection host as a class constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address code-review findings on the --db-server guard

Scope the guard to workbook publishes only. Datasource publishes go
through _add_credentials_element on the TSC side, which never requires
server_address, so the previous unconditional check was a regression
for `tabcmd publish live_mysql.tds --db-username ...` and similar.
The guard now checks the target filename extension early (before auth,
for fast failure) and repeats per-file inside the workbook branch of
the loop for the folder-publish case.

Rename the `credentials` parameter of `publish_workbook_file` to
`connection` — it holds a single ConnectionItem, not a list.

Tests:
- Add test_publish_with_oauth_creds covering the oauth branch's
  server_address assignment (previously untested).
- Add test_publish_datasource_with_db_username_no_db_server verifying
  datasource publishes are not blocked when --db-server is omitted.
- Strengthen the e2e negative test to run tabcmd via subprocess.run,
  capture stdout/stderr, and assert on the localized guard message
  (or the raw key when .mo has not been regenerated). Any earlier
  unrelated failure — bad auth, missing asset, session expiry — now
  fails the test rather than passing it.
- Trim the misleading class-constant comment on TWB_FILE_EMBEDDED_
  CONNECTION_SERVER; reword the "before we touch the database" comment
  to reference the guard's contract instead of a specific location.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "Address code-review findings on the --db-server guard"

This reverts commit cfe52c1.

* Revert "Add e2e coverage for --db-server requirement"

This reverts commit 9581329.

* Revert "Require --db-server when publishing with embedded credentials"

This reverts commit d59ee65.

* Fix NameError in uniquify_file when no lines are discarded

The success-print branch referenced uniques instead of unique_lines,
breaking doit properties when input files contained no non-'=' non-
codestring lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Regenerate localized .mo files after doit localize

Compiles the new `publish.options.db-server` help-text key added by #458
(backported in PR A) into the runtime gettext catalogs. Without this,
English users see the raw msgid key in `tabcmd publish --help`.

Non-English locales fall back to English until translators supply their
versions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Mauro Fujihara <fujihara@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #458 fixed the two publish crashes when using --db-username /
--db-password / --save-db-password, but callers who omit --db-server
still hit a raw ValueError from tableauserverclient's
_add_connections_element. Fail fast in run_command with a clear,
localizable message before we build the ConnectionItem, so users get
an actionable error instead of an internal stack trace.

- Restore the --db-server required-when-embedded guard on the workbook
  publish path.
- Extract _require_db_server_for_workbook(args, filename, logger) helper
  so the duplicated guard cannot drift between the early pre-auth check
  and the in-loop per-file check.
- Extend the English publish.options.db-server help text to warn about
  the silent-drop-on-mismatch footgun.
- Fix Portuguese grammar: "banco de dado" -> "banco de dados" (plural);
  "associado a credenciais" -> "associado as credenciais" (crase).
- Regenerate .mo catalogs to pick up the new keys.
- Unit tests: db-username and oauth-username exit paths, plus
  datasource-not-blocked coverage.
- E2E test: friendly exit when --db-server is missing.

Comment on test_wb_publish_embedded clarifies it only proves
not-crashing (a mismatched --db-server would still exit 0 while
silently dropping the creds; tracked internally).

Re-cut from the closed PR #470 stack. Previously PR C in the
#467/#469/#470 backport chain; PR B's base branch was deleted after
merge, orphaning PR #470. This lands the same content on a fresh
branch off development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ll locales

Copilot flagged the new key as English-only, so users in de/es/fr/ga/
it/ja/ko/pt/sv/zh would see the raw msgid
(`publish.errors.db_server_required`) instead of any actual message.

Adds the English placeholder text to each of the 10 non-EN properties
catalogs and regenerates the .mo bundles via `doit localize` so the
rendered guidance appears in every locale until real translations
arrive.

Tightens the e2e assertion to require the rendered English text
(dropping the raw-msgid fallback path) so localization regressions
fail loudly instead of silently.
)

The two Tableau-owned Python packages (tabcmd and tableauserverclient)
were on divergent dev-tool versions:

           tabcmd         TSC
  black    >=22,<23       ==26.5.1
  mypy     unpinned       ==2.3.0

Anyone working on both repos (e.g. porting fixes back and forth) has
to constantly swap black versions in their local env. Match TSC's
pins on the tabcmd side; older repos of the two are the outlier here.

Consequential changes from the version bumps:

- black 26 reformats 9 files (whitespace around decorators, blank-line
  policy after imports). No behavior change.
- black 26 also requires bumping `target-version` -- switched to
  py310-py314 to match tabcmd's `requires-python` and `classifiers`
  list (was py310-py311 only).
- Dropped `required-version = 22` from [tool.black]; TSC doesn't use
  one and the versioned pin in [test] is enough.
- mypy 2.3 caught one pre-existing issue: tabcmd/version.py
  re-imported `version` under the same name inside a fallback branch
  (`from tabcmd._version import version`), which shadows the
  `version: str = "unknown"` annotation. Renamed the fallback import
  to `_pyinstaller_version` and assigned.

Verified:
- black . --check passes
- mypy tabcmd tests passes with 0 errors (was 1 with mypy 2.3)
- Full unit-test suite: 337 passed, 2 skipped
* Fix #433: accept Classic `delete --workbook Name` syntax

tabcmd Classic invokes delete as `tabcmd delete --workbook "Name"` where
--workbook takes the target name as its value. tabcmd 2 shipped
`tabcmd delete "Name" --workbook` (name positional, flag is a type
selector). This is a breaking CLI difference for anyone running Classic
scripts unchanged.

Accept both forms:
- Positional name stays optional (nargs="?").
- --workbook/--datasource take an optional value (nargs="?"). Bare form
  still means True (tabcmd 2 syntax); with a value, the value is the
  target name (Classic syntax).
- In run_command, resolve which form supplied the name and normalize
  args.workbook/args.datasource to True. Emit the same
  "requires workbook or datasource" error when neither form supplied a
  name.

Mutual exclusion between --workbook and --datasource still holds.

Fixes #433.

* Add missing parser tests for delete syntax variations

Covers gaps flagged in fresh-eyes review:
- `-w` / `-d` short flags on Classic form
- Ambiguous "positional Name + --workbook FlagValue" -- parser accepts
  both; run_command normalization discards flag value
- Bare `--workbook` with no value (parses because nargs="?" const=True)
- Classic form + -r project combined

The run_command normalization itself (`isinstance(args.workbook, str) ->
args.name = args.workbook; args.workbook = True`) is not covered here;
tracked in the follow-up run_command coverage issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the pin declared in tableauserverclient (our direct dependency),
so any Python environment installing tabcmd resolves the same urllib3
range TSC was tested with. Previously urllib3 was unpinned; pip
would then pick a version compatible with TSC's constraint by
transitive resolution, but the tabcmd distribution itself didn't
declare compatibility bounds.

No functional change — the resolved version doesn't move.
* Fix #437: add 10 editsite flags for tabcmd Classic parity

tabcmd Classic exposes 12 site-configuration flags on editsite that
tabcmd 2 was missing. This adds 10 of them (the 2 skipped items are
--metrics-level, which is Tableau-internal VizQL telemetry likely
Cloud-N/A pending VizQL team confirmation, and --cache-warmup-threshold,
which is server-only).

Flags added, with the SiteItem attribute each wires:
- --guest-access-enabled / -g       -> guest_access_enabled
- --cache-warmup / --no-cache-warmup -> cache_warmup_enabled
- --subscription-email / -e         -> custom_subscription_email + _enabled
- --subscription-footer / -f        -> custom_subscription_footer + _enabled
- --web-extraction-enabled          -> web_extraction_enabled
- --allow-subscriptions / --no-*    -> disable_subscriptions (inverted)
- --allow-web-authoring / --no-*    -> authoring_enabled
- --allow-mobile-snapshots / --no-* -> sheet_image_enabled
- --time-zone STRING                -> time_zone
- --use-default-time-zone           -> use_default_time_zone

Design notes:
- Paired positive/negative boolean flags default to None so an unpassed
  flag never touches the server setting (critical: a naive default=False
  would flip cache-warmup / subscriptions / etc. off on every editsite).
- --time-zone and --use-default-time-zone are argparse-enforced mutually
  exclusive.
- Flags live in a new set_edit_site_only_args helper (not
  set_common_site_args) because create_site_command doesn't wire these
  attributes; adding to the shared helper would advertise unwired flags
  in `createsite --help`.
- --allow-mobile-snapshots maps onto sheet_image_enabled, NOT
  mobile_biometrics_enabled (that's a separate biometric-auth feature).
- --allow-subscriptions is inverted onto disable_subscriptions because
  that's how the REST API expresses the setting.

Two upstream TSC bugs surfaced during implementation but not fixed here:
RequestFactory.py lowercases both customSubscriptionEmail and
customSubscriptionFooter on the wire, so `user@Domain.com` will be sent
as `user@domain.com`. To be filed separately.

Tests: 30 new parser tests in tests/parsers/test_parser_edit_site.py
cover each flag's arg form, mutual exclusion, and default None
behavior. Full parsers suite: 127 passed, 1 skipped (pre-existing).

Fixes #437.

* style: apply black to test_parser_edit_site.py

The initial commit predated a black run; CI's black check failed on this
file (repo pins black 22).

* Fill parser test gaps on new editsite flags

Fresh-eyes review flagged that the new-flag test set only checked
default=None for two of ten flags, and only exercised "true" (not
"false") on the string-value flags. Adds:

- default=None coverage for guest_access_enabled, web_extraction_enabled,
  allow_web_authoring, allow_mobile_snapshots, subscription_email,
  subscription_footer, and time_zone (previously only cache_warmup and
  allow_subscriptions were tested).
- "false" argument on --guest-access-enabled long form and
  --web-extraction-enabled (previously only "true" was tested).
- Empty-string acceptance on --subscription-email and
  --subscription-footer; the paired _enabled flip happens in run_command
  and is tracked in the run_command coverage issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clear use_default_time_zone when explicit --time-zone is given

A site currently using the server default returns from GET with
use_default_time_zone=True. TSC serializes both attributes on update
and the default flag wins server-side, so the explicit zone was
silently ignored. Clearing the flag when --time-zone is provided lets
the explicit value take effect.

Addresses Copilot review finding on PR #452.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com>
Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com>
All conflicts were .mo binaries; regenerated the full set via doit
localize from the merged .properties sources rather than picking a
side. Also folds in the Windows console UTF-8 fix for check_mo's
charmap errors on ja/ko/zh sample output.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Brings development up to date with main before the next release. Conflicts
resolved in favor of development, which already carries the same fixes in
newer form (release-on-merge.yml, check-strings.yml, publish --db-server
handling, logger stdout/stderr split, version fallback, string keys).
Taken from main: the saved auth_token reuse fix (#463) and its tests,
macos-15-large for the x86 Mac build, setuptools 83.0.0, and the CodeQL bump.
The .mo files are regenerated with doit localize.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…strust (#483)

Frozen PyInstaller builds embed certifi at build time, so the floor makes
the next release carry the updated trust bundle.

Co-authored-by: Claude Code <noreply@anthropic.com>
)

* Stop committing generated .mo files; build them with doit localize

The .mo bundles are generated from the .properties files, and every PR
that changed a string regenerated them, so they conflicted constantly.
Untrack them, ignore them, and run doit localize before building in CI
and packaging. Remove check-mo-freshness, which has nothing left to check.

* Keep LC_MESSAGES directories so doit localize can write into them

Untracking the .mo files left the LC_MESSAGES directories empty, and git does not
store empty directories, so doit localize failed on a fresh checkout.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…es (#489)

Drop the hidden imports PyInstaller 6 no longer needs (requests.packages.urllib3,
pkg_resources), which removes the reason for the setuptools==81 pin. Add a
Package-and-Upload step that runs -v and a failing login against a closed port,
to catch missing bundled modules (requests, urllib3, certifi).

Co-authored-by: Claude Code <noreply@anthropic.com>
Unblocks #455 (createsiteusers --nowait/--silent-progress), which
depends on JobItem.status_notes (tableau/server-client-python#1852),
shipped in TSC v0.42 (released 2026-10-15).

Co-authored-by: Claude Code <noreply@anthropic.com>
* Fix: accept yes/no on --encrypt for Classic parity

tabcmd Classic's `--encrypt` on `createextracts` takes an explicit
yes/no value; tabcmd 2 treated it as a bare boolean flag, silently
ignoring any following value. Scripts that pass `--encrypt no` on
Classic ended up encrypting on tabcmd 2 because argparse consumed the
"no" as a positional or bailed.

Make --encrypt accept an optional yes|no|true|false argument
(case-insensitive). Bare `--encrypt` still means True; omitted means
False; explicit `--encrypt no` means False. This preserves current
tabcmd 2 default behavior while matching Classic syntax.

* style: apply black to satisfy CI

* Accept Classic's y/n shortcuts on --encrypt in addition to yes/no

Classic tabcmd's CreateExtracts.java:79-101 accepts exactly `yes|y|no|n`
(case-insensitive) on --encrypt and rejects anything else. This PR was
dropping the `y`/`n` shortcuts, which breaks any ported Classic script
that used them. Keeping Classic's set intact is the standing rule —
tabcmd 2 may be more forgiving than Classic, but must not narrow.

Add `y` and `n` to the accepted set (both directions). tabcmd 2 now
accepts the union `yes|y|no|n|true|false|1|0`, case-insensitive.
Rewrite the source comment to cite the Classic source-of-truth path
in the monolith rather than the incorrect earlier claim that Classic
accepted "yes/no/true/false".

Add two tests pinning `y`→True and `n`→False so a future refactor
can't quietly drop them again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: expand --encrypt metavar to advertise full accepted alias set

Addresses Copilot review feedback on PR #450: the help metavar showed
only "yes|no" while _parse_yes_no accepts yes/y/no/n/true/false/1/0.
Updates metavar to reflect the full forgiving superset and adds two
tests locking in 1/0 numeric-alias acceptance so the docs contract and
the parser stay in sync.

* style: reformat global_options.py with black 22

Co-Authored-By: Claude Code <noreply@anthropic.com>
Resolve pyproject.toml: keep pyinstaller==6.22.3 from development (#489).

Co-Authored-By: Claude Code <noreply@anthropic.com>
* Fix #35: --nowait / --silent-progress on createsiteusers (needs TSC release)

tabcmd Classic accepts --nowait and --silent-progress on createsiteusers;
tabcmd 2 was missing both flags. Adding them meaningfully requires more
than argparse: Classic sends the CSV as a single bulk-import job and
polls the server-side JobItem, while tabcmd 2 has been walking the CSV
row by row and calling users.add per user. With no server-side job to
poll, --nowait had nothing to skip.

This PR switches createsiteusers to server.users.bulk_add + a single
JobItem, and wires the two flags.

Behavior changes:
- Default: submit bulk_add, wait for the JobItem to complete via
  server.jobs.wait_for_job, print a summary derived from the completed
  job's status_notes (CountOfUsersAddedToSite / CountOfUsersSkipped /
  CountOfUsersProcessed) plus any per-row error notes. Same shape as
  Classic's default output.
- --nowait: submit bulk_add and return immediately. Job ID is printed
  before return so callers can query it via a separate call.
- --silent-progress: still waits for the job, but suppresses the
  status header, the queued-job line, and the per-row summary. Framework
  logging (errors, debug) is unaffected.
- --complete / --no-complete: Classic used a ClientXML with_transaction
  param that the REST endpoint doesn't expose, so we approximate with
  client-side pre-flight validation:
  - --complete (default): validate_file_for_import(strict=True), any
    malformed row aborts the whole import before submission.
  - --no-complete: validate leniently, submit whatever parses.
  Doesn't cover server-side row failures mid-import; those fall through
  to job.status_notes on the completed job.

Flag spelling: Classic uses --nowait (one word). The existing
set_no_wait_option helper defined --no-wait but had zero callers, so
switched the spelling to Classic's without a compatibility alias.

Depends on TSC >= (next release), which will ship JobItem.status_notes
(tableau/server-client-python#1852, merged). Draft while awaiting a
tagged TSC release + tabcmd's TSC pin bump.

Fixes #35.

* fix: import JobFailedException from its module

TSC top-level doesn't re-export JobFailedException; import directly
from tableauserverclient.server.endpoint.exceptions. Fixes the mypy
attr-defined error in CI.

* style: apply black 22 to new test files

* Fail loudly on older TSC; document --continue-if-exists is a no-op

The status_notes fallback previously used getattr(..., None) or []
which meant an older TSC would print added=0, skipped=0,
processed=len(input) with no indication anything was wrong. Detect the
attribute missing and exit with a message pointing at the pinned TSC
version.

--continue-if-exists is a global flag used across the create-* commands
to downgrade 409 conflicts to INFO. bulk_add is inherently tolerant of
duplicate users (they get counted under CountOfUsersSkipped) so the
flag is a no-op here. Keep the flag on the parser for consistency, log
the no-op at DEBUG, and cover with a test so a future refactor doesn't
accidentally rely on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Drop redundant --continue-if-exists no-op test

test_continue_if_exists_is_a_noop_documented_in_debug just re-ran the
default happy path with the flag set. bulk_add is called and
wait_for_job is called either way, so this asserts nothing that the
base test doesn't. The behavioral comment lives on the code path in
create_site_users.py already.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Bump tableauserverclient to 0.42 and drop pre-status_notes guard in createsiteusers

0.42 exposes JobItem.status_notes, so the hasattr guard, its needs_newer_tsc string and the test for it are dead code. --continue-if-exists is silently ignored by createsiteusers (bulk_add tolerates existing users), so the debug-only no-op message is removed too.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: createsiteusers numeric timeout, failed-job diagnostics

- pass --timeout to wait_for_job as an int (it arrives as a string and
  TSC compares it with elapsed seconds)
- on JobFailedException, log status_notes/notes before exiting nonzero
- tests for both

Co-Authored-By: Claude Code <noreply@anthropic.com>

* localize: regenerate English catalog for createsiteusers messages

Co-Authored-By: Claude Code <noreply@anthropic.com>

* style: format test_create_site_users.py with black 26.5.1

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: createsiteusers timeout message, job id under --nowait, bulk_add errors

- on wait timeout, say the import is still running and print the job id
- always print the job id under --nowait, even with --silent-progress
- route any bulk_add failure (not just ServerResponseError) through exit_with_error
- tests for the first two

Co-Authored-By: Claude Code <noreply@anthropic.com>

* createsiteusers: save per-row import errors to a file (--errors-file, default per job id)

Under --silent-progress rejected rows are written to
createsiteusers-errors-<jobid>.txt instead of being dropped; --errors-file
overrides the path and also enables the file without --silent-progress.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* createsiteusers: fix rejected-row detail to use job.notes, not merged status_notes

The server does not emit one statusNote per rejected row for a UserImport job.
RestApiHelper.getStatusNotesErrors merges each error field (line/username/message)
across ALL failed rows into a single statusNote per field, joined by ";", and
omits the CountOf* aggregates entirely once there are errors. The actual
one-line-per-row detail is the legacy <notes> text built by
UserManagementService.getHumanReadableCSVImportSummary, which TSC surfaces as
job.notes.

_job_detail_lines now prefers job.notes and only falls back to non-CountOf*
status_notes if notes is empty. Added tests/assets/createsiteusers_job_with_errors.xml
as a fixture matching the real response shape, parsed via TSC.JobItem.from_response
in _rejected_job() instead of a hand-rolled status_notes mock.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prop2po wrote values into the .po unescaped, so a value containing " ended the
msgstr early. msgfmt.py's literal_eval then failed (ERROR (skipped)) and the .mo
stored the wrong text: editsite.options.subscription_email kept stray wrapping
quotes, and session.options.site lost its ("") in every locale. Escape bare
quotes in prop2po and regenerate the catalogs.

Co-authored-by: Claude Code <noreply@anthropic.com>
… 1 (#432)

* fix: correct output messages in listsites, deletegroup, export, createsiteusers, removeusers

- listsites: show site content_url instead of internal UUID for SITEID
- deletegroup: log group name instead of UUID in delete status message
- export/save_to_file: pass filename as content name to fix double-space in "Saved  to '...'" message
- createsiteusers: append str(e) to error_list instead of e.__class__.__name__ so error details show readable messages
- removeusers: use new tabcmd.removeusers.group string key that includes the group name in the status message

Fixes #429

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add ===== prefix to all INFO output, fix delete path, suppress debug noise

- logger_config.py: add ===== prefix to INFO format so all action lines match tabcmd 1 style in one place
- session.py: remove hardcoded ===== from _print_server_info lines (now added by formatter); demote "Fetched user details from server" to debug
- constants.py: demote tabcmd.debug.error_message log call from info to debug so it no longer appears in normal output
- delete_command.py: default content_type to "workbook" in status message to fix double-space when type is undetected
- tabcmd_messages_en.properties: remove hardcoded ===== from tabcmd.listing.header (would double-prefix with formatter)

Closes remaining items from #429

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: print version/help output directly instead of via logger.info

Avoids the ===== prefix being applied to the version/help display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add TABCMD_CLASSIC_OUTPUT env var to enable ===== prefix on output

Plain output is the default. Set TABCMD_CLASSIC_OUTPUT=true (or 1/yes) to
restore the ===== prefix on all INFO-level messages for backward compatibility
with tabcmd 1 output style.

Also moves the version banner in tabcmd_controller to print() so it is never
prefixed regardless of mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove spurious 'logging: INFO' printed on every command

The condition compared logging_level string 'INFO' to the integer
logging.INFO (20), which was always True, causing the print to fire
on every invocation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add 'Signing in...' message to match tabcmd 1 login output

session.login string already existed; add logger.info call at the
start of _sign_in() to surface it as a distinct step after connecting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Thread content name through save_to_file, default ===== prefix on

Two follow-up fixes for parity with tabcmd Classic output:

- save_to_file/save_to_data_file previously passed filename twice into
  the "Saved {0} to '{1}'" template, so both slots rendered the same
  path. Classic output was "Saved <content-name> to '<destination>'"
  where the two are distinct. Add a content_name parameter and thread
  export_item.name / get_url_item.name from all four callers.

- TABCMD_CLASSIC_OUTPUT default flipped to true. The stated goal of
  this PR is parity with tabcmd 1 output, and issue #429 item 8 lists
  the missing "=====" prefix as a bug. Users who prefer the plainer
  tabcmd 2 style can opt out with TABCMD_CLASSIC_OUTPUT=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot review findings in PR #432

- delete_command.py: initialize item_to_delete before conditional;
  move delete.status log after item resolution so content_type/name
  are populated.
- dodo.py: fix NameError in uniquify_file - reference unique_lines
  (the sorted list actually printed) instead of undefined uniques.
- en properties: strip stray leading space in tabcmd.listing.header
  format string.
- locales: regenerated .mo/.properties for all languages.

* Apply remaining changes

Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com>

* fix: stop configure_log from overwriting the INFO format at non-INFO levels

Keeps the TABCMD_CLASSIC_OUTPUT-derived prefix for INFO lines under
--logging-level DEBUG. Adds tests for the DEBUG case and the opt-out.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: keep classic INFO prefix on the stdout handler at DEBUG level

The stdout handler used the configured level's format for every record, so
INFO lines at --logging-level DEBUG rendered with the DEBUG format. Format
each record by its own level instead.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* chore: remove unused item_name in DeleteCommand

Co-Authored-By: Claude Code <noreply@anthropic.com>

* localize: regenerate compiled message catalogs

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Print listing data lines without the classic status prefix

The =====  prefix is for status lines. Data rows from listsites and list,
including list --machine output, now go through a PLAIN extra marker and
print unprefixed. The per-level formatter builds its formatters once
instead of rewriting the private _style._fmt on each record.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Keep the version banner on the logger so --logging-level and tabcmd.log apply

Routes the banner through logger.info with the PLAIN marker, so it stays
unprefixed on the console and still goes through the root handler.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com>
* fix: preserve '=' inside --filter values

Previously `apply_filter_value` called `value.split("=")`, so a filter
like `Notes=x=y` silently truncated to name=Notes, value=x. Split on the
first '=' only (maxsplit=1) so multi-'=' values round-trip intact, and
raise a clear error when the input isn't in name=value form.

Verified end-to-end against a real workbook: `Product Name=x=y Config Kit`
now filters correctly instead of returning empty results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add filter_test_data.csv fixture for e2e filter tests

Dataset with product names containing '=', '&', ',', backslashes, and
metacharacter samples for exercising --filter parsing edge cases against
a real server. Also includes columns whose names contain '&', '#', and
'\' so future tests can exercise special characters on both sides of
the name=value pair.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: don't hard-error on unparseable URL filter fragments (Classic parity)

The earlier tightening in this PR made apply_filter_value raise a clean
error when a clause isn't in name=value form. That's the right behavior
for the --filter flag, but URL-syntax exports inherited from tabcmd
Classic scripts often contain literal '&' inside filter values (e.g.
`?Product Name=AT&T 841000 Phone`), which get split into fragments the
parser can't understand.

Classic silently skipped such fragments. Verified end-to-end against a
Tableau Server today: with the strict path, tabcmd 2 errored out
("Filter clause 'T 841000 Phone' must be in name=value form") where
Classic silently continued and let the server return whatever the well-
formed portion matched.

Add strict: bool = True to apply_filter_value. --filter callers stay
strict; apply_encoded_filter_value (the URL path) passes strict=False,
which logs a WARNING and skips the fragment instead of exiting.

Adds three tests: strict path still exits, non-strict path skips
silently, and end-to-end apply_values_from_url_params tolerates a value
with '&'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: apply black to satisfy CI

Black 22 (pinned in pyproject.toml) reformats these two files;
the CI 'Check formatting with black' step was failing.

* Rejoin unencoded '&' inside URL-embedded filter values

The prior fix for `?Product Name=AT&T 841000 Phone` matched Classic by
skipping the "T 841000 Phone" fragment, but it also applied the truncated
first fragment (`Product Name=AT`) as a real filter. On any dataset with
a matching "AT" row that produces silent wrong data with only a WARNING
log the user might miss.

Detect the pattern: if a fragment after '&' has no '=' and isn't a
tabcmd options key (":..."), assume it's a continuation of the previous
value and rejoin. Warns loudly that the URL should encode '&' as '%26'
so the ambiguity is visible.

Tests cover the AT&T case, multiple '&' in a value, options mixed with
filters, and multiple legitimate filters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Log unparseable URL filter fragments at debug level

Classic skips these silently, and a literal '&' inside a URL filter value is a normal case, so a warning per fragment is noisy. The strict --filter path still errors on malformed clauses.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: don't absorb bare fragments into preceding option in URL filter parsing

Copilot review on #442 flagged that a bare fragment (no '=') following an
options key (':...') was being rejoined onto the option itself, corrupting
it -- e.g. '?:refresh=yes&' became ':refresh=yes&' and 'yes&' failed to
enable refresh. Require the fragment being extended to itself be a filter
(not an option) and ignore empty trailing fragments before rejoining.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Reject empty filter names and skip empty --filter fragments

- apply_filter_value treats an empty field name ("=x") like a missing '=':
  exit for --filter, skip for URL-embedded filters.
- apply_filters_from_args skips the empty fragment left by a trailing or
  doubled '&', so --filter "Region=West&" no longer exits.
- Add tests for the --filter path end to end, Parameters. stripping with
  rejoin, the rejoin warning, skipped URL fragments, and the known
  AT&T x=y ambiguity.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: allow repeated --filter flags for exports

Previously `--filter` accepted only one value, and multi-pair filtering
required joining pairs with '&' (e.g. `--filter "A=1&B=2"`). That
parsing scheme conflicts with values that legitimately contain '&'
(e.g. `Product Name=AT&T ...`).

Make `--filter` repeatable via argparse action="append". Each flag now
carries exactly one COLUMN=VALUE pair — no '&' splitting — so literal
'&' in a value passes through untouched.

Back-compat: when only one --filter flag is present, the value is still
split on '&' so existing scripts keep working. Users who need '&' in a
value should switch to the repeated-flag form.

Adds parser tests for the new syntax and unit tests for
apply_filters_from_args covering both forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove dead default=None on logger param

All call sites pass logger explicitly, so the default was unreachable.
Tightens the type from Optional[Logger] to Logger, drops any fallback
branch inside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Copilot findings on filter parsing for repeated --filter

- apply_filter_value now splits on the first '=' only, so values
  containing '=' (e.g. Notes=x=y) are no longer truncated.
- apply_filters_from_args only treats a literal '&' in a single
  --filter value as a back-compat pair delimiter when every resulting
  segment actually looks like COLUMN=VALUE; otherwise the '&' is
  treated as literal (e.g. Company=AT&T), avoiding the previous
  IndexError risk on ambiguous splits.
- Add regression tests for both cases.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Regenerate en tabcmd.mo after merging development

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): round-trip CSVImport bug coverage for TSC #1811 (#472)

* test(e2e): round-trip CSVImport bug coverage for #1811

Adds a two-row CSV fixture and one createsiteusers e2e test exercising
every UserItem.CSVImport bug fixed in TSC #1811 (issue #1809): AUTH
column readable at 8 columns, mixed-case usernames preserved,
TableauIDWithMFA in the auth allowlist, case-insensitive validation
of role/admin/publisher tokens, and the @property_is_enum guard
active on the parsed auth_setting.

Test runs under the same server-admin / site-admin gate as
test_users_create_site_users and shares its ordering slot; skips
cleanly when the caller lacks admin scope or a credentials file.

Related: #297 (license-name case sensitivity still surfacing after
partial fixes), #434 (Local auth accepted by the CLI but crashes on
TSC call).

* test(e2e): pass --no-complete + drop --role Publisher on CSVImport 1811 test

`createsiteusers` runs `--complete` by default, which invokes tabcmd's
duplicate strict validator (`UserCommand._validate_user_or_throw`).
That validator rejects `len(line) > 7` and validates license values
case-sensitively -- both of which the CSVImport #1811 fixture is
specifically meant to exercise. Under `--complete`, the CSV never
reached TSC's `UserItem.CSVImport` and the test fell through
tabcmd-side.

`--no-complete` skips tabcmd's validator so the fixture reaches TSC's
parser. Removing `--role Publisher` lets each row's own license field
be validated -- restoring coverage of the case-insensitive-license fix.

Also corrected the docstring: five of the six bugs, since the sixth
(unknown-AUTH rejection) is a negative-path check that doesn't fit a
single happy-path fixture and is already covered by TSC's unit tests.

* docs: correct test docstring -- this test doesn't reach TSC's CSVImport yet

createsiteusers parses the CSV itself via UserCommand.get_users_from_file()
/ _parse_line() and builds TSC.UserItem directly; it doesn't delegate to
TSC's UserItem.CSVImport. The docstring claimed the opposite.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(e2e): use a valid TSC auth value in the CSVImport fixture

TableauID is not a member of TSC.UserItem.Auth in the pinned tableauserverclient
(0.42), so the first row raised ValueError before the server call. Use
ServerDefault for that row.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>

* Bump github/codeql-action from 4.39.1 to 4.39.3

Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.39.1 to 4.39.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@v4.39.1...v4.39.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.39.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jac <jacalata@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ases (#459)

* fix: Package-and-Upload workflow attaches binaries to UI-created releases

Two bugs found while investigating why v2.0.21 shipped without
executables attached:

1. The "Upload binaries to release" step was gated on
   `github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')`.
   Releases created via the GitHub UI create a tag but do not fire the
   push:tags: event that this workflow depends on, so v2.0.21 (and v2.0.19)
   ended up as releases with no assets. The only successful run for
   v2.0.21 was a workflow_dispatch afterward that built the binaries as
   artifacts but skipped the upload-to-release step.
2. Both macOS build jobs used `name: tabcmd-macos` on
   `actions/upload-artifact`, so the two same-named artifacts collided in
   the artifact store and downloads clobbered each other.

Fixes:

- New `release_tag` workflow_dispatch input. Set it when dispatching from
  a branch to attach binaries to a UI-created release. Falls back to
  `github.ref_name` when the workflow runs on a tag ref (push or dispatch).
- Fix mac artifact collision: use `matrix.UPLOAD_FILE_NAME` (unique per
  platform) as the artifact name instead of `tabcmd-${{ matrix.TARGET }}`.
- Split the upload into a separate `upload_to_release` job that depends on
  `buildexe`, gated on `environment: release`. The `release` environment
  needs to be created in the repo's Settings -> Environments with
  required-reviewer protection (mirroring the existing `pypi` environment).
  Anyone with dispatch access can trigger a build, but only an approved
  reviewer can attach binaries to a public release.
- Upload gate: `if: startsWith(github.ref, 'refs/tags/') || inputs.release_tag != ''`
  so both the push:tags path and the workflow_dispatch path work.

Live-verified by using `gh release upload` today to fix v2.0.21
retroactively with the artifacts from the last workflow_dispatch run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Harden Package-and-Upload workflow per review

- Switch to release: types: [published] as the trigger (matches
  publish-pypi.yml pattern). Removes the manual-dispatch-with-input
  footgun; release payload provides the tag directly.
- Add explicit permissions: contents: write on the upload job so it
  survives future org-default hardening.
- Add concurrency group keyed on the release tag; prevents overlapping
  runs from stomping each other's approved uploads.
- Pin download-artifact back to v7 to match upload-artifact@v7 until
  v7/v8 interop is verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(package): restore workflow_dispatch + resolve release tag across event paths

Copilot flagged that removing workflow_dispatch broke the
release-on-merge.yml caller and the documented manual re-attach
path, and that github.event.release.tag_name is empty for anything
other than release:published events.

Restores workflow_dispatch alongside release:published, adds a
"Resolve release tag" step that falls back to github.ref_name, and
switches all four uploaders to the resolved output.

* docs(contributing): document release-publish reviewer gate + manual re-attach

Adds a Publishing-a-release subsection covering the release:published
path fired by publishing the draft release in the UI, the new
`environment: release` reviewer-approval gate that pauses upload_to_release
until a maintainer clicks "Review deployments", and the manual re-attach
command `gh workflow run package.yml --ref <tag>` for existing releases.

Requested during Copilot review of #459.

* fix(workflow): add release_tag dispatch input and fail fast on branch dispatch without one

Address Copilot review on #459: define the release_tag workflow_dispatch
input, include it in the concurrency group, and resolve the upload tag from
the release event, then release_tag, then ref_name only for tag refs. A branch
dispatch with no release_tag now errors instead of using the branch name.
Correct contributing.md to say publish-pypi.yml is gated on the pypi
environment, and document the branch-dispatch form.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Only build release binaries once a release is published

Drop the package.yml dispatch from release-on-merge.yml. The merge-time run
targeted a draft release, which upload-release-action cannot find by tag, and
each release needed two `release` approvals. Merging to main now only pushes
the tag and creates the draft; publishing it starts package.yml and
publish-pypi.yml.

Also rewrite the legacy release-process section of contributing.md and
correct the trigger and download-artifact comments in package.yml.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Limit buildexe job to contents: read

The buildexe job only checks out code and uploads artifacts, so it does not
need the default token permissions. Only upload_to_release needs write access.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix #434: map "Local" auth_setting to ServerDefault

tabcmd Classic accepts "Local" as an auth type on the CLI and CSV
column 7. tabcmd 2 kept "Local" in the accepted list but TSC's
UserItem.Auth enum has no Local value, so when the user reached
server.users.add() the wire representation raised ValueError.

Map Classic's "Local" -> TSC.UserItem.Auth.ServerDefault in
Userdata.to_tsc_user() so CSVs authored for Classic import cleanly.
Case-insensitive to match Classic behavior.

Fixes #434.

* Log a warning when Local auth is remapped to ServerDefault

A silent remap is surprising: an operator who typed Local (or imported a
Classic CSV that uses it) previously got a user with ServerDefault auth
and no signal that anything changed. Emit a WARNING that names the user
and states both the input and remapped auth so the operator can spot
unexpected remaps in a large batch. Adds a matching assertion in the
existing test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: extract normalize_auth_setting; apply CLI + fix nits

Addresses Copilot review on PR #446:
- Hoist "Local"->ServerDefault remap into UserCommand.normalize_auth_setting
  and reuse it for CLI --auth-type in create_site_users / create_users_command
  so CSV and CLI paths behave the same.
- Fix NameError in dodo.py uniquify_file success log.
- Single-quote the {0} placeholder in the local_auth_remapped warning
  so the properties->po converter doesn't emit malformed .mo entries;
  recompile .mo catalogs.
- Assert warning names the affected user instead of coupling to the
  localize key.

* Route Local auth remap warning through the command logger

The warning was emitted on the module logger, so it only reached tabcmd.log and not the console. normalize_auth_setting, to_tsc_user, _parse_line and get_users_from_file now take an optional logger; both create commands pass theirs. Callers that pass none keep the module logger.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Regenerate English catalog for the Local auth warning; unstub tests

Recompiled the English .mo so it carries tabcmd.user.warning.local_auth_remapped, and restored the warning tests to assert on the real translated text instead of a stubbed translator.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Suppress CSV-time Local-auth warning when a CLI --auth-type override wins

normalize_auth_setting/to_tsc_user/_parse_line/get_users_from_file now take
a warn flag; create_users_command and create_site_users pass
warn_on_local_remap=not args.auth_type when loading the CSV so the warning
only fires for the value that actually takes effect, instead of describing
a CSV-supplied "Local" that a --auth-type override is about to replace.

Addresses Copilot finding on PR #446 (id 4300828647).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Pass logger to get_users_from_file in act_on_users

addusers and removeusers fell back to the module logger when normalizing
auth settings, so warnings ignored the command's logging level. This matches
the createusers and create_site_users paths.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants