Skip to content

fix: let --version answer even when the update check fails - #309

Merged
mkb79 merged 2 commits into
masterfrom
fix/version-exits-zero
Aug 24, 2026
Merged

fix: let --version answer even when the update check fails#309
mkb79 merged 2 commits into
masterfrom
fix/version-exits-zero

Conversation

@mkb79

@mkb79 mkb79 commented Aug 24, 2026

Copy link
Copy Markdown
Owner

audible --version prints the version without a newline, then asks GitHub for the latest release so it can append (up-to-date) or (update available) to the same line. When that call fails, the error is appended instead and the run aborts:

$ audible --version          # no network
audible-cli, version 0.5.1error: no route
$ echo $?
1

So v=$(audible --version) comes back with the error glued to the number, and a non-zero exit, on any machine that cannot reach GitHub — an installation that is otherwise working perfectly.

The update check is a courtesy. It now finishes the version line, says on stderr why it could not look, and exits 0:

$ audible --version          # no network
audible-cli, version 0.5.1
$ echo $?
0
$ audible --version 2>&1 >/dev/null
Could not check for a newer release: no route

A successful check reads exactly as before, on stdout and on the same line.

tests/test_version_option.py covers both, including the failure being any exception the request or the response decoding can raise, since the except clause catches all of them.

Note

PR #306 touches these same lines: it gives the version a line of its own on stdout and moves both notices to stderr as part of the output-stream contract. This branch is the narrower fix and stands on its own, so whichever lands first leaves a small conflict in that one block for the other.

mkb79 added 2 commits August 24, 2026 10:00
`audible --version` printed the version without a newline, then asked
GitHub for the latest release. When that call failed the error was
appended to the same line and the run aborted, so

    v=$(audible --version)

came back as "audible-cli, version 0.5.1error: no route" with exit 1 on
any machine without a network.

The check is a courtesy. It finishes the version line, says on stderr
why it could not look, and exits 0.
@mkb79
mkb79 merged commit ca19c29 into master Aug 24, 2026
6 checks passed
@mkb79
mkb79 deleted the fix/version-exits-zero branch August 24, 2026 10:25
mkb79 added a commit that referenced this pull request Aug 24, 2026
The merge wove both versions of the callback together and left the bare
`click.echo()` from #309 standing. There it finished a version line
written with `nl=False`; here the line already ends, so it put a blank
line on the payload stream.

The failure notice goes through `say` like the other two, and the tests
that came with #309 now expect the version alone on stdout with the
notice on stderr.
mkb79 added a commit that referenced this pull request Aug 24, 2026
…roduces (#306)

* fix: send log output to stderr, where diagnostics belong

Every log line went to stdout, because ClickHandler called click.echo
without err=True. That put error messages into the same stream a command
uses for its result, so `audible api library | jq` was handed prose
instead of JSON, and a caller had no way to tell the two apart.

The module was small enough to rewrite rather than patch, and the rest of
it had collected its own problems:

- The echo_kwargs machinery existed to pass per-level arguments to
  click.echo and was never given any -- it was the hook err=True should
  have used all along. Gone, along with the "exception" style key, which
  named a level that does not exist.
- A record carrying a traceback skipped the level prefix and fell back to
  the verbose file layout. Now the prefix belongs to the message and the
  traceback keeps its own shape, by overriding formatMessage rather than
  format.
- capture_warnings() was a way of silencing warnings rather than of
  collecting them: captureWarnings hands them to the py.warnings logger,
  which carries a NullHandler and no route anywhere, so not even Python's
  lastResort saw them. It gets the console handler now.
- A handler set below its logger never sees the records it was lowered
  for. Saying so through the logger meant the message was dropped by the
  very level it was reporting; it goes through `warnings` instead.
- tqdm.external_write_mode() guarded against a progress bar nobody builds
  any more; the dock reserves its rows through the terminal, not through
  tqdm. The import goes with it, and the test that keeps tqdm out of the
  package tightens by one file.
- click.echo strips colour for a pipe but ignores NO_COLOR and
  FORCE_COLOR. Both are honoured now.
- Handlers are attached by name under a lock, so configuring twice no
  longer prints every line twice and a detailed console log replaces the
  terse one instead of doubling it. A file handler carries its
  destination in its name, so naming a second file adds a handler while
  naming the same file twice replaces one.
- The file log is written as UTF-8 rather than whatever the platform
  happens to prefer.

A plugin that fails to import is replaced by BrokenCommand, which printed
its warning and traceback to stdout as well. Same rule, same fix.

The module had no tests. It has twenty-four now, holding the stream
split, the prefixes, the traceback shape, colour under both environment
variables, the handler bookkeeping and the two traps above.

One habit breaks: `audible download ... > log.txt` collected the progress
notes and now collects nothing. It needs 2> or 2>&1.

* fix: finish moving the narration off stdout

The verbosity option came with a conversion of echo calls into log calls,
and nine of them were left behind. They sit on stdout, where a pipe
collects them, and `-v` cannot quiet them.

`manage profile remove` shows what happened. Its neighbour `profile add`
goes through `ConfigFile.add_profile`, which logs; `remove` deleted the
entry from the dictionary itself and kept its echoes. So the two halves of
one command group narrated in two different ways, and the save was
reported twice -- once by `write_config` through the logger and once by
the command through stdout. It now calls `ConfigFile.delete_profile` the
way `add` does, with the write held back until the loop is done, and the
"profile doesn't exist" message stops being a red line on stdout that no
verbosity level could silence.

The rest: the two confirmations in `manage auth-file remove`, and
`display_counter`, which narrates the end of a download the way the other
38 lines in that command already do.

Nothing that a command produces moved. Every result still goes out
through echo or into a file: the API response, the title lists, the
activation bytes, the profile table, the exports. What moved is the
talking about it.

Which leaves nowhere to redirect for anyone who wanted to keep a record,
so `--log-file PATH` writes the same log to a file, in the detailed
layout with timestamp, module and line. It follows --verbosity, and it is
a better record than the scraped stdout it replaces.

* fix: put the last narrations on the diagnostic channel

A second pass over every place the package writes, against one rule:
stdout carries what a command produces or asks, stderr carries what is
said about the work.

Found going the wrong way, out of the package:

- `--version` printed the version without a newline and then appended
  " (up-to-date)" or an update notice to the same line, so a script
  reading the version had to strip advice off the end -- and an update
  check that raised left the line unfinished. The version now goes out
  whole on stdout; the advice goes to stderr.
- "Successfully registered DEVICE." confirms a side effect after the
  login dialogue has ended, and moves to the logger.
- Quickstart's "Use existing auth file for new profile." reports the
  answer just given rather than explaining the next question, and moves
  with it. The line between those two is what keeps the captcha and OTP
  explanations on stdout: they precede a prompt, this one follows one.

Found going the wrong way, into the package -- the mirror-image mistake:

- "Auth file is encrypted but no/wrong password is provided" was logged,
  and sits directly in front of the password prompt it explains. On
  stderr with a raised verbosity the reason disappears while the question
  remains, leaving a bare password prompt. It travels with its prompt now.

Right channel, wrong mechanism:

- The two --bunch-size deprecation notices wrote to stderr with echo, so
  neither --verbosity nor --log-file reached them. They are warnings and
  say so now.
- BrokenCommand did the same with a plugin's load failure. It now logs a
  one-line summary and hands the stored exception to the logger as
  exc_info, which is what puts a traceback in its own shape rather than
  stamping "error: " down its left edge.

`--log-file` creates a missing parent directory, the way write_config
does, instead of failing on a path the user has every reason to expect
to work.

tests/test_output_channels.py holds the three cases that are easiest to
cross back over: the version split, the broken plugin, and the prompt
that must keep its explanation.

* docs: note the stream change in the changelog

* fix: hold the whole conversation on the diagnostic channel

The interactive dialogue was the last thing left on stdout, and it was
there for a reason that did not survive checking: two of the surfaces
belong to dependencies, so moving only the parts this package owns would
have split a conversation across two streams -- our explanations on one,
the library's questions on the other. Both surfaces turn out to be ours
to take.

`audible.login.default_login_url_callback` holds its exchange with print
and input. audible-cli passes its own callback anyway, so it now carries
the instructions itself and still tries the browser route first, which
has nothing to print.

questionary draws through prompt_toolkit, which renders to stdout unless
it is handed an output. Every call site hands it one now, and a test
checks that at the source rather than trusting the seven call sites to
stay that way.

With those two, the rest follows: the quickstart wizard, the captcha and
OTP exchanges, the encrypted-auth explanation, and the prompts belonging
to each of them. `audible quickstart > file` now leaves the file empty
and every question on the screen, where before it swallowed half the
wizard.

The new `_dialog` module is the counterpart to `_logging`: one place that
knows which stream a question goes to, so the next person to add a prompt
does not have to remember. `say`, `ask` and `confirm` for text,
`selection_output()` for questionary.

Deliberately not the logger, for any of it. A prompt's explanation must
not be suppressible while the question it explains stays on the screen --
which is the same reason the encrypted-auth notice came off the logger in
the last commit. It travels with its prompt, and now they travel together
to stderr rather than to stdout.

* docs: note the dialogue move in the changelog

* build: declare prompt_toolkit, which `_dialog` imports

It arrived only through questionary, which allows anything from 2.0 to
4.0 -- a range that includes versions without the entry point this
package now calls. What is imported is declared.

* test: pin the selection output to the diagnostic stream

* fix: bring our own callbacks for the last two login questions

`Authenticator.from_login` takes four callbacks and only two were being
passed. The other two fell back to the library's, which hold their half
of the conversation with print and input:

    default_cvf_callback              input("CVF Code: ")
    default_approval_alert_callback   print("Approval alert detected! ...")
                                      input("Please press ENTER ...")

So a login that hit a verification code or an approval alert still asked
on stdout, and asked without the prompt handling the rest of the wizard
gets. Both are ours now, and they say the same thing through `say` and
`ask`.

The test reads the expectation off the library's own signature rather
than listing the callbacks, so a fifth one in a future audible release
fails here instead of quietly falling back again.

* fix: teach the bundled plugins the same rule

They are examples, and an example that mixes its narration into the
stream carrying its result teaches that as the way to write a plugin.

`cmd_decrypt` had ten of them and no logger at all: what it separates,
what it skips, how many chapters it found, which file it overwrote,
whether the decryption worked. All on stdout, none reachable by
`--verbosity`. It has a logger now, named the way the two plugins that
already had one name theirs, so the records reach the handler
audible-cli configures.

`cmd_get-annotations` was the sharpest case, because both halves were on
one stream: "No annotations found for asin X" next to the annotations
themselves. Piping that command gave you either JSON or a sentence, with
nothing to tell them apart.

`convert_oa_cred` confirmed a file it had written.

What stays on stdout is what these commands produce: the annotations and
the image URLs. `cmd_goodreads-transform` and `cmd_listening-stats`
already did the right thing -- one logs and writes a file, the other only
writes a file.

No scanning test for these, unlike the questionary rule in the package
itself. They are meant to be copied and changed, and a test holding an
allowlist of which lines in an example may print would cost more than it
catches.

* fix: bring click's own option prompts onto the conversation channel

`@click.option(..., prompt="...")` asks through `Option.prompt_for_value`,
which reaches for `click.core.prompt` and `click.core.confirm` and takes
no `err` of its own. Eight options in `manage` did that, so

    audible manage profile add > payload.txt

hid "Please enter the profile name" in the file while the process waited
for an answer.

`DialogOption` binds those two names for the length of one call rather
than copying `prompt_for_value` here: that method grows features between
click releases and a copy would fall behind quietly.

Two more:

- A broken plugin went silent at `--verbosity critical`: exit 1, both
  streams empty, and no explanation of why the command that was typed
  could not run. It logs at CRITICAL, which is what it is.
- `capture_warnings(False)` removed the handler but left `propagate`
  off, so a later `captureWarnings(True)` by anything else routed every
  warning into silence. It puts back what it found.

`utils/update_chapter_titles.py` is a standalone script rather than part
of the package, so it gets `err=True` on its six narrating lines instead
of a dependency on audible-cli's logging.

The wizard test walks to the closing confirmation, so every question along
the way is covered rather than the first two. Each login callback is asked
and answered, and the external-login test forces the fallback rather than
assuming playwright is absent.

README gains the stream contract, `--log-file` and the colour variables.

* docs: shorten the comments, and correct three that were wrong

A pass over every comment and docstring the branch adds, measured against
what the repository already does: `progress.py`, `models.py` and
`test_progress.py` average 1.9 to 2.3 lines per comment block. This branch
was at 2.6 and is now at 2.2.

Three said something untrue, which is the expensive kind:

- `RECORD_FORMAT` was described as the layout for handlers writing
  somewhere durable. `set_console_logger` uses it too.
- `ColorFormatter` claimed to put the level in front of a record. It does
  that for the levels in `LEVEL_COLORS`, and INFO is deliberately not one.
- A test comment said every module does `getLogger(__name__)`. None do;
  they all name themselves.

Four were history rather than code: what the file handler naming was like
before handlers had names, what `manage profile remove` used to report
twice, what `> log.txt` used to do, and a note about what was left to
review.

Two more were imprecise: `click_basic_config` promised that everything
lands on stderr "whatever the level", which skips both the argument it
takes and the filtering that still applies; and `ClickEchoHandler` said
click drops colour escapes off a terminal, which `FORCE_COLOR` overrides.
`set_console_logger` now says that its handler writes straight to the
stream, so it carries no colour and misses colorama.

The rest is length: Args and Returns sections on one-line wrappers in
`_dialog`, four-paragraph module docstrings restating a rule that is
stated once, and the explanations that ran a line or two past their point.
The comments that explain a trap -- the live exception in the except
block, propagate after un-capturing, why the two click names are bound
rather than the method copied -- all stay.

* test: keep the suite away from the real config directory

A test that builds a `Session` without saying where gets the directory
the user works in, through `get_app_dir()`. A command that writes its
config then writes that one.

An autouse fixture points AUDIBLE_CONFIG_DIR and AUDIBLE_PLUGIN_DIR at
fresh directories for every test.

* docs: move the explanations out of the code and into the docstrings

A comment between two statements makes the reader stop, switch from code
to prose, and find their place again. Four of them said something about
the function as a whole and belong in its docstring; `version_option` had
none and now has one. Two said what the docstring above them already said,
or what the names `say` and `ask` say themselves.

What stays inline is the line that looks arbitrary without it: the live
exception handed to the logger as exc_info, and CRITICAL rather than ERROR
for a plugin that will not load.

The stream contract also gains a note that a question goes to stderr, where
the platform puts a prompt regardless of what click is told, so `2> log` on
an interactive command looks like a hang.

* refactor: let the version notice go through `say`

`version_option` wrote its update notice with its own
`click.echo(..., err=True)`. It is part of the conversation, not a log
record, so it goes through `_dialog` like the rest and the stream is
decided in one place.

That leaves two: `_dialog` for questions, `_logging` for diagnostics.

* refactor: close the handlers we detach

Three places removed a handler by name and one of them closed it, which
leaves the other two registered with the logging module. `_detach` takes
the logger to work on, so all three go the same way.

* fix: settle `--version` after taking master in

The merge wove both versions of the callback together and left the bare
`click.echo()` from #309 standing. There it finished a version line
written with `nl=False`; here the line already ends, so it put a blank
line on the payload stream.

The failure notice goes through `say` like the other two, and the tests
that came with #309 now expect the version alone on stdout with the
notice on stderr.

* docs: say what `--log-file` does not record

The note claimed `--log-file` keeps a record where redirecting stderr
would hide the questions. It does not: the conversation never reaches
the file, and must not -- a password or a captcha answer does not belong
in a file people attach to bug reports.
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.

1 participant