Compare commits

...
37 Commits
Author SHA1 Message Date
CaliBrain ebb833a82c fix(bypass): discard rejected DDoS-Guard cookies instead of replaying them (#1221)
A cookie that has been rejected was kept and presented again on every
later
request, so a single bad clearance could re-arm the challenge
indefinitely.

Cookie storage:
- Enforce expiry for every stored cookie, not just cf_clearance.
DDoS-Guard
domains have no cf_clearance, so the existing check never fired for them
and
  expired cookies were replayed forever.
- Stop storing the per-check cookies __ddg8_/__ddg9_/__ddg10_ and
ddg_last_challenge. Captured live from Anna's Archive, these carry the
client
IP and the timestamp the check was issued (~40 min), versus ~1 year for
the
  __ddg1_/__ddg2_/__ddgid_ clearance. Replaying an IP-bound token stops
describing the caller as soon as the egress IP changes, which is routine
  behind a VPN.

Failure handling — every path that is rejected while carrying cookies
now
purges them, not just the redirect loop:
- 403 returned while presenting cookies.
- Cached-cookie attempt rejected, whether by status or by redirect loop.
- Factored the purge into _purge_clearance, guarded on a non-empty
hostname
since clear_cf_cookies("") means "every host" and would wipe clearance
for
  sites that are working fine.

Also fix the search warm-up switches shipped inert in v1.3.8:
SEARCH_WARMUP_ENABLED and SEARCH_WARMUP_QUERY are not in the settings
registry, and config.get only consults the environment for keys it
knows, so
both always returned their defaults — the warm-up could not be turned
off or
retargeted. Read os.environ first.

Refs #1220. Deliberately not "Fixes": the reported failure could not be
reproduced on v1.3.8 from a stable IP (the reporter's own queries all
returned
200 on both the pre- and post-change builds), and the new purge paths
did not
fire in live testing because the failures arrive as redirect loops,
which were
already purged. These are correctness fixes with no measured effect on
that
issue. The underlying problem remains that Chrome-obtained cookies never
satisfy DDoS-Guard when replayed by requests, so every search still
re-solves.

Verified: 2542 unit tests pass; ruff, basedpyright and vulture clean;
e2e
platform baseline (10), full (6) and bypasser-external (5) all pass;
five
sequential live searches against Anna's Archive all returned 200 with
zero
"Exceeded 30 redirects".
2026-08-15 17:08:11 -04:00
CaliBrain b7093f4594 Fix log to debug DNS (#1219) 2026-08-15 15:30:29 -04:00
CaliBrain b656f019be feat(download): add DoH wireformat support, mirror quarantine, and search warmup (#1218)
- Add RFC 8484 DNS wireformat codec and HTTP/2 support (httpx) for
Quad9/OpenDNS DoH providers.
- Quarantine dead, parked, or seized mirrors for the session on hard
failure (DNS errors, connection refused, 410/451, parked pages) while
preserving bypass clearance on live mirrors.
- Add background startup search warmup to prime DNS, elect mirrors, and
pre-solve protection challenges to eliminate cold-start search latency.
- Add comprehensive test suites for DoH wireformat, mirror quarantine,
parked domain detection, and search warmup.
2026-08-15 14:17:44 -04:00
CaliBrain 6e96ead519 Fix frontend timeout search (#1217) 2026-08-15 13:44:09 -04:00
CaliBrain 7345f6be1a Fix README and hints for audiobooks (#1215) 2026-08-15 12:19:58 -04:00
CaliBrain 2b8b35bb52 fix(newznab): make indexer book categories configurable (#1214)
Newznab searches hardcoded category 7000 for ebooks and 3030 for
audiobooks,
so indexers using custom IDs returned no results or the wrong ones. Add
NEWZNAB_EBOOK_CATEGORIES and NEWZNAB_AUDIOBOOK_CATEGORIES (tag lists,
defaulting to 7000 and 3030) and resolve the search categories from
config.

Values are parsed leniently — list or comma/whitespace separated,
non-numeric
entries skipped, duplicates dropped — and fall back to the standard IDs
when
empty, so a cleared field can't silently widen the search to every
category.
NEWZNAB_AUTO_EXPAND remains the way to do that on purpose.

Results carrying a custom ID outside the standard 7000-7999 / 3030
ranges were
typed as "other", which routed custom-category audiobooks as ebooks.
Trust the
searched content type when a result carries a category we explicitly
asked for.

Also drop the unused NEWZNAB_BOOKS / NEWZNAB_AUDIOBOOKS constants from
api.py —
a third copy of the same hardcoding.

Closes #1208
2026-08-15 11:48:10 -04:00
FlozeandCaliBrain 58a5b5ed27 fix: sync renamed CWA usernames safely (#1203)
## Summary

- sync an existing CWA-backed user's username when CWA renames it
- keep username collisions safe by assigning a stable `__cwa` alias
instead of overwriting a local account
- allow username updates through `UserDB` and cover
rename/collision/repeat-sync behavior

Fixes #1197.

## Testing

- `uv run ruff check shelfmark tests`
- `uv run ruff format --check shelfmark tests`
- `uv run vulture shelfmark`
- `uv run pytest tests/core/test_cwa_user_sync.py
tests/core/test_user_db.py tests/core/test_admin_users_api.py
tests/core/test_auth_api.py -k "cwa or update_user"` (36 passed)
- `uv run pytest tests/ -x --tb=short -m "not integration and not e2e"
--ignore=tests/config/test_entrypoint_permissions.py -q` (2445 passed, 5
skipped)

The entrypoint permission tests were excluded locally because macOS
ships Bash 3.2, which does not support the `${1,,}` expansion used by
`entrypoint.sh`; the same failure reproduces on an unchanged checkout.
`make python-typecheck` also currently reports the existing
`settings.py:147` callback return-type mismatch on the unchanged base.

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-08-15 11:40:32 -04:00
Sujeito OperatorandCaliBrain 52c1702419 docker: mount uv at build time instead of copying it into every image (#1200)
### What this PR does

`uv` stops being copied into the image and starts being mounted into the
three `RUN`s that
actually use it. The digest pin stays in exactly one place — it moves
from the `COPY` to a
stage declaration:

```dockerfile
FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c... AS uv
```
```dockerfile
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=from=uv,source=/uv,target=/usr/local/bin/uv \
    uv sync --locked --no-default-groups
```

A stage consumed only through `--mount=from=` contributes no layer to
anything published, so
`uv` never lands in `base`. The two `RUN rm -f /usr/bin/uv /usr/bin/uvx`
lines then have nothing
left to delete and go with it.

### Why

The `base` stage copies uv in, and both final stages try to take it back
out:

```dockerfile
# uv is only needed while building the image.
RUN rm -f /usr/bin/uv /usr/bin/uvx
```

That intent is exactly right. **The mechanism can't carry it out**: a
`RUN` adds a layer, it
does not rewrite the layer underneath. The `COPY` layer is still pushed
and still pulled by
everyone. What the `rm` produces is a whiteout on top of it.

### Measured, not assumed

Read off the published images over the registry API — `linux/amd64`,
both built
`2026-08-13T17:53Z`, pinned by digest so these numbers stay reproducible
after tonight's
scheduled rebuild:

```
ghcr.io/calibrain/shelfmark@sha256:9b6041f797cbcc1e5c50ab42bd010a8f747dfaac926080cb6269ddfae99cf820
  layer  COPY /uv /uvx /bin/                        24.3 MB   of 585 MB total   4.1% of the pull
  layer  RUN rm -f /usr/bin/uv /usr/bin/uvx              159 B

ghcr.io/calibrain/shelfmark-lite@sha256:2eae503d791cef685e10135aaaff77077cfb4a31d6911e31216704988ce28b02
  layer  COPY /uv /uvx /bin/                        24.3 MB   of 221 MB total  11.0% of the pull
```

The `rm` layer unpacks to exactly four tar entries:

```
usr/
usr/bin/
usr/bin/.wh.uv       0 bytes
usr/bin/.wh.uvx      0 bytes
```

Two zero-length overlayfs whiteouts. That is the deletion behaving
exactly as specified — and
removing nothing at all from what anyone downloads.

Same thing without the registry API:

```
$ docker manifest inspect ghcr.io/calibrain/shelfmark-lite:latest
```

and look for the ~24 MB layer; or `docker history` on a local build.

### To be clear about what the `rm` does and doesn't do

**It is not useless and I'm not claiming it is.** It removes `uv` from
the flattened filesystem,
which is what the container sees at runtime and what Trivy/Grype scan by
default — so the
"don't ship a stale installer" half of the intent is already working
today, the same way the
`pip` removal above it does. This PR is about the other half: the bytes.
After it, `uv` is
absent from the filesystem *and* absent from the layers, so nothing
regresses.

This is image size, not a vulnerability, and I would not have opened it
as anything else.

### Why this is safe

- **Nothing at runtime can depend on `uv` or `uvx` today**, and that is
read off your own
artifact rather than argued: both are already whiteouted out of both
published images. `uvx`
is never invoked anywhere in the repo — `entrypoint.sh` has no `uv` in
it, and the Makefile's
  `uv run` lines are the host-side dev workflow, outside the image.
- `/usr/local/bin` is already on `PATH` in `python:3.14.7-slim`, and
your `ENV PATH=/app/.venv/bin:$PATH`
prepends rather than replaces, so `uv` resolves the same way it does
now.
- The pin does not move. Same image, same `sha256`, same resolution per
target platform as
`COPY --from=<image>` does today, so the `linux/amd64` and `linux/arm64`
builds each keep
  getting their own `uv`.
- `RUN --mount=` is already used three times in this file, so the
frontend in use supports
  mounts; `from=` is part of the same feature.
- Your `docker-build-check` job builds `shelfmark-lite` on every PR, so
a build is the cheapest
possible review of this change. As a first-time contributor my workflow
runs sit at
`action_required` until someone approves them — approving is enough to
check the whole claim.

### Notes for reviewers

- I have **not** built these images locally. There is no Docker daemon
on the machine I run on.
Every figure above is read from the published images over the registry
API, and my own
  selftest re-reads them live on each run rather than trusting a note.
- I left the `pip` removal in `base` alone. It has the same shape, but
its stated goal — keeping
a stale installer out of what scanners see — is genuinely achieved by
the flattened
filesystem, and `pip` arrives in the `python:slim` base layer where a
Dockerfile change can't
  reach it anyway.
- Written by an automated agent; saying so plainly seemed better than
not.

Signed-off-by: Sujeito Operator <operator@sujeito.org>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-08-15 11:32:37 -04:00
3e2a7a48d5 fix: clear the DDoS-Guard cookie probe on AA search (#1209)
## Summary

Two failure modes on the same code path, both reported this week: Anna's
Archive `/search` is gated behind a DDoS-Guard cookie probe that the
manual redirect follower can never satisfy.

**#1202 — the cookie is dropped on every hop.** AA URLs set
`allow_redirects = False`, so `html_get_page` follows redirects by hand.
The 302 to `?check=1` carries a `Set-Cookie` (`__ddg*`) that has to come
back on the next request. Because cookies are passed per call and
`requests` keeps no jar across manual hops, it was discarded each time
and the server just re-issued the same redirect until `_MAX_REDIRECTS`
raised `TooManyRedirects`. The file already had the right helper —
`_new_cookies()` — but only the 503 Z-Library handshake branch called
it.

**#1204 — the loop never reaches the bypasser.** `TooManyRedirects`
isn't in `_is_retryable_error` and carries no status code, so the 403
rescue path (`status == _HTTP_STATUS_FORBIDDEN`) never fired and all
attempts repeated the identical failure — ~2.5 min, surfacing as the
misleading "Network restricted or mirrors are blocked".

These interact, which is why #1202's fix alone isn't enough. Requests
merge as `cookies={**handshake_cookies, **cookies}`, so **stale bypasser
cookies override the fresh handshake ones** — once `_cf_cookies` holds
an expired `__ddg*`, the probe can never clear no matter how faithfully
we echo. Hence one search per restart, exactly as #1204 describes.

## Changes

1. Harvest cookies in the same-host redirect branch, the way the 503
branch already does. `_new_cookies()` returns only *new* values, so a
server re-sending an identical cookie yields an empty dict and a genuine
redirect loop still terminates at `_MAX_REDIRECTS`.
2. Treat a redirect loop as a detected challenge: purge the stored
cookies for that host and switch to the bypasser, instead of burning the
retry budget. Gated on `allow_bypasser_fallback` and
`_is_cf_bypass_enabled()`, and skipped when already bypassing, so
AudiobookBay (`allow_bypasser_fallback=False`) and external-bypasser
setups are unaffected.

The broader point in #1204 stands — the fallback would be better gated
on "challenge detected" than on specific status codes, since DDoS-Guard
presents at least three faces (403 js-challenge, 429, and this redirect
loop). This PR fixes the two live exits without that refactor.

## Tests

Two regression tests, both failing before and passing after:

- `test_html_get_page_echoes_cookies_across_same_host_redirects` — the
fake server only returns results if `__ddg2_` comes back on the
`?check=1` hop.
- `test_html_get_page_redirect_loop_purges_cookies_and_bypasses` —
asserts the stored cookies are cleared, the bypasser runs, and the loop
is cut short rather than repeated per attempt.

`ruff check` and `ruff format` clean. `tests/download/` passes except
`test_download_url_ignores_zlib_cookie_refresh_failure`, which fails
identically on unmodified `main` in my environment (no `seleniumbase` —
the `browser` extra isn't installed).

## Verification

Applied on a live v1.3.7 install (Debian LXC, internal CDP bypasser).
Before: every search timed out through 10 retries with
`TooManyRedirects`, zero results. After:

```
http.py:455 - Redirect loop detected; switching to bypasser
internal_bypasser.py:756 - Bypass successful using _bypass_method_cdp_gui_click
internal_bypasser.py:322 - Extracted 9 protection cookies for annas-archive.pk
direct_download.py:1865 - Found 24 releases via ISBN
```

~25 s per search, results render. Note the second search still re-solves
the challenge, since the freshly stored cookies go stale immediately —
the design issue #1204 raises, left for the broader fix.

Fixes #1202
Fixes #1204

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012Ln3yVj3sWHG2c6T78W1we

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-08-15 11:27:03 -04:00
dependabot[bot] a178541561 Bump the python-deps group with 3 updates (#1205)
Bumps the python-deps group with 3 updates:
[gevent](https://github.com/gevent/gevent),
[seleniumbase](https://github.com/seleniumbase/SeleniumBase) and
[prek](https://github.com/j178/prek).

Updates `gevent` from 26.7.0 to 26.8.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/gevent/gevent/commit/4f684105f537eeceb9988adbfe81420a1f28d9a0"><code>4f68410</code></a>
Preparing release 26.8.0</li>
<li><a
href="https://github.com/gevent/gevent/commit/10489c56d3b03caae5be00bc46996a0180ab3d8c"><code>10489c5</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2199">#2199</a>
from florentinl/florentin.labelle/fix/hubless-thread...</li>
<li><a
href="https://github.com/gevent/gevent/commit/d4f5f098c5d6655e9700336ed6f15e924a7fd142"><code>d4f5f09</code></a>
Document the args[0] cross-thread wakeup mechanism</li>
<li><a
href="https://github.com/gevent/gevent/commit/9b049915e8e308e4286f922fd51baf9d92187a88"><code>9b04991</code></a>
Run test_cross_thread_callback_can_run_before_scheduling_returns on all
backends</li>
<li><a
href="https://github.com/gevent/gevent/commit/ce7996d7048d3555be2869cc6172d47e332efb58"><code>ce7996d</code></a>
Address review comments: document cross-thread race, drop unneeded
cpdef</li>
<li><a
href="https://github.com/gevent/gevent/commit/5288a6eec90ca63f9658b44d67f14fbdfe07259d"><code>5288a6e</code></a>
Add missing <a
href="https://github.com/ignores"><code>@​ignores</code></a>_leakcheck
to new cross-thread test</li>
<li><a
href="https://github.com/gevent/gevent/commit/c72eda095d99e82ca0c767afe56baecbc4c131a2"><code>c72eda0</code></a>
Fix cross-thread notifier scheduling race</li>
<li><a
href="https://github.com/gevent/gevent/commit/908b93730ffef5cee0594e4482b3a43b476c43a4"><code>908b937</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2195">#2195</a>
from ddorian/fix-popen-exit-reentrant-close</li>
<li><a
href="https://github.com/gevent/gevent/commit/b58795f8a659e767b0317cd199d68f273b742167"><code>b58795f</code></a>
Merge pull request <a
href="https://redirect.github.com/gevent/gevent/issues/2191">#2191</a>
from ddorian/fix-1865-global-shutdown-lock</li>
<li><a
href="https://github.com/gevent/gevent/commit/ede2e71a859198b96a22f4c683fa210e750406e6"><code>ede2e71</code></a>
Always use 'versionchanged:: NEXT' to let the release machinery fill in
the c...</li>
<li>Additional commits viewable in <a
href="https://github.com/gevent/gevent/compare/26.7.0...26.8.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.51.11 to 4.51.12
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.51.12 - CDP Mode: Patch 128</h2>
<h2>CDP Mode: Patch 128</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2322c11a532a43a00f7224bb02aba30f677457f5">Perform
no-op on duplicate quit() calls to avoid coroutine warnings</a>
--&gt; This resolves <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4458">seleniumbase/SeleniumBase#4458</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/117c9994dce50e134746580f3f73f62ed22449b2">Refresh
Python dependencies</a>
--&gt; <code>setuptools</code> and <code>platformdirs</code></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 128 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4459">seleniumbase/SeleniumBase#4459</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.11...v4.51.12">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.11...v4.51.12</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2b54219498fdda1a6c689be543f7777ed6c125a4"><code>2b54219</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4459">#4459</a>
from seleniumbase/cdp-mode-patch-128</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/6774cef0999b82c3b22dcd5a5a896a6299e8a69b"><code>6774cef</code></a>
Version 4.51.12</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/117c9994dce50e134746580f3f73f62ed22449b2"><code>117c999</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2322c11a532a43a00f7224bb02aba30f677457f5"><code>2322c11</code></a>
Perform no-op on duplicate quit() calls to avoid coroutine warnings</li>
<li>See full diff in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.11...v4.51.12">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.4.12 to 0.4.13
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/releases">prek's
releases</a>.</em></p>
<blockquote>
<h2>0.4.13</h2>
<h2>Release Notes</h2>
<p>Released on 2026-08-10.</p>
<h3>Highlights</h3>
<h4>Manage hook tools with mise</h4>
<p>The new <code>language: mise</code> support lets hooks install tools
using
<a href="https://mise.jdx.dev/"><code>mise</code></a> in an isolated
environment:</p>
<pre lang="yaml"><code>repos:
  - repo: local
    hooks:
      - id: golangci-lint
        name: golangci-lint
        language: mise
additional_dependencies: [&quot;aqua:golangci/golangci-lint@2&quot;]
        entry: golangci-lint run --fast-only ./...
        pass_filenames: false
</code></pre>
<h4>Run commands in hook environments</h4>
<p>The new <code>prek exec</code> subcommand can run an explicit command
in a configured
hook's prepared environment. For example, the hook above makes its
managed
binary available to this command:</p>
<pre lang="console"><code>$ prek exec golangci-lint -- golangci-lint
--version
</code></pre>
<h3>Enhancements</h3>
<ul>
<li>Add <code>mise</code> language support (<a
href="https://redirect.github.com/j178/prek/pull/2540">#2540</a>)</li>
<li>Add <code>deny-filename-pattern</code> and
<code>require-filename-pattern</code> hooks (<a
href="https://redirect.github.com/j178/prek/pull/2488">#2488</a>)</li>
<li>Add <code>prek exec</code> for running commands in a hook
environment (<a
href="https://redirect.github.com/j178/prek/pull/2478">#2478</a>)</li>
<li>Add <code>yaml-language-server:</code> comment to YAML sample config
(<a
href="https://redirect.github.com/j178/prek/pull/2486">#2486</a>)</li>
<li>Make <code>prek cache size</code> output terminal-aware (<a
href="https://redirect.github.com/j178/prek/pull/2508">#2508</a>)</li>
<li>Match file regexes against path bytes (<a
href="https://redirect.github.com/j178/prek/pull/2541">#2541</a>)</li>
<li>Show hook aliases in run output (<a
href="https://redirect.github.com/j178/prek/pull/2497">#2497</a>)</li>
<li>Show hook descriptions in run output (<a
href="https://redirect.github.com/j178/prek/pull/2490">#2490</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Avoid env cache scans for skipped hooks (<a
href="https://redirect.github.com/j178/prek/pull/2502">#2502</a>)</li>
<li>Cache Node version queries (<a
href="https://redirect.github.com/j178/prek/pull/2500">#2500</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/blob/master/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.4.13</h2>
<p>Released on 2026-08-10.</p>
<h3>Highlights</h3>
<h4>Manage hook tools with mise</h4>
<p>The new <code>language: mise</code> support lets hooks install tools
using
<a href="https://mise.jdx.dev/"><code>mise</code></a> in an isolated
environment:</p>
<pre lang="yaml"><code>repos:
  - repo: local
    hooks:
      - id: golangci-lint
        name: golangci-lint
        language: mise
additional_dependencies: [&quot;aqua:golangci/golangci-lint@2&quot;]
        entry: golangci-lint run --fast-only ./...
        pass_filenames: false
</code></pre>
<h4>Run commands in hook environments</h4>
<p>The new <code>prek exec</code> subcommand can run an explicit command
in a configured
hook's prepared environment. For example, the hook above makes its
managed
binary available to this command:</p>
<pre lang="console"><code>$ prek exec golangci-lint -- golangci-lint
--version
</code></pre>
<h3>Enhancements</h3>
<ul>
<li>Add <code>mise</code> language support (<a
href="https://redirect.github.com/j178/prek/pull/2540">#2540</a>)</li>
<li>Add <code>deny-filename-pattern</code> and
<code>require-filename-pattern</code> hooks (<a
href="https://redirect.github.com/j178/prek/pull/2488">#2488</a>)</li>
<li>Add <code>prek exec</code> for running commands in a hook
environment (<a
href="https://redirect.github.com/j178/prek/pull/2478">#2478</a>)</li>
<li>Add <code>yaml-language-server:</code> comment to YAML sample config
(<a
href="https://redirect.github.com/j178/prek/pull/2486">#2486</a>)</li>
<li>Make <code>prek cache size</code> output terminal-aware (<a
href="https://redirect.github.com/j178/prek/pull/2508">#2508</a>)</li>
<li>Match file regexes against path bytes (<a
href="https://redirect.github.com/j178/prek/pull/2541">#2541</a>)</li>
<li>Show hook aliases in run output (<a
href="https://redirect.github.com/j178/prek/pull/2497">#2497</a>)</li>
<li>Show hook descriptions in run output (<a
href="https://redirect.github.com/j178/prek/pull/2490">#2490</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Avoid env cache scans for skipped hooks (<a
href="https://redirect.github.com/j178/prek/pull/2502">#2502</a>)</li>
<li>Cache Node version queries (<a
href="https://redirect.github.com/j178/prek/pull/2500">#2500</a>)</li>
</ul>
<h3>Bug fixes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/6204a68bc591773d8a796e1b6c9898cd35aa520d"><code>6204a68</code></a>
Bump version to 0.4.13 (<a
href="https://redirect.github.com/j178/prek/issues/2543">#2543</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/f1c73c5f1780b1d3931d372016c2f9b244be1126"><code>f1c73c5</code></a>
Add mise language support (<a
href="https://redirect.github.com/j178/prek/issues/2540">#2540</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/3f08ed41fb440af8cc9430cda729f6f41a497c2d"><code>3f08ed4</code></a>
Match file regexes against path bytes (<a
href="https://redirect.github.com/j178/prek/issues/2541">#2541</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/6bc5f06f1f090ecf034109ce5b8cc5c773b096d2"><code>6bc5f06</code></a>
Update Rust crate serde-saphyr to v1 (<a
href="https://redirect.github.com/j178/prek/issues/2539">#2539</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/b8c5c69ea3452a77ebc2711ed3b2b8ad011f4791"><code>b8c5c69</code></a>
Update Rust crate fancy-regex to 0.19.0 (<a
href="https://redirect.github.com/j178/prek/issues/2537">#2537</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/2557af921bf5ed6a919385dc18e0177c05530c71"><code>2557af9</code></a>
Update prek hooks (<a
href="https://redirect.github.com/j178/prek/issues/2528">#2528</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/a9587311bc8aa8bf13de7d5d612b93e00f248500"><code>a958731</code></a>
Update Rust crate http to v1.5.0 (<a
href="https://redirect.github.com/j178/prek/issues/2538">#2538</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/fc8b9a45288e005291e1945792543da7a44a2f3a"><code>fc8b9a4</code></a>
Update Rust crate clap to v4.6.5 (<a
href="https://redirect.github.com/j178/prek/issues/2531">#2531</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/3bacaa18ad0b0ea67b15e7ea35d4175ad213bd86"><code>3bacaa1</code></a>
Update dependency uv to v0.12.1 (<a
href="https://redirect.github.com/j178/prek/issues/2536">#2536</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/222fc00edd3fb3b3b4443ea54bacc1e868e6e6a7"><code>222fc00</code></a>
Update Rust crate toml to v1.1.4 (<a
href="https://redirect.github.com/j178/prek/issues/2535">#2535</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.4.12...v0.4.13">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 11:16:22 -04:00
dependabot[bot] 78e1f4daba Bump python from 83c1ceb to ce40764 (#1206)
Bumps python from `83c1ceb` to `ce40764`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=python&package-manager=docker&previous-version=3.14.7-slim&new-version=3.14.7-slim)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 11:16:14 -04:00
dependabot[bot] eeea92280c Bump the npm-deps group in /src/frontend with 3 updates (#1207)
Bumps the npm-deps group in /src/frontend with 3 updates:
[knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip),
[oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) and
[oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint).

Updates `knip` from 6.32.0 to 6.32.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.32.1</h2>
<ul>
<li>Handle referenced config files in their own plugin (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1931">#1931</a>,
close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1932">#1932</a>)
(982c1d8e28cc62d3cba5ecde6dd8df2740c7c329)</li>
<li>Fix type-check against typescript@5.0.4
(2febefe44a8b39f74158916a2bc73933b4c281ae)</li>
<li>Update sentry snapshot
(0397bddbf809e2b24fe59a4bea8c0258526bb565)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/437b608ebc1e098506deb60842c6ce079ff6164e"><code>437b608</code></a>
Release knip@6.32.1</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/2febefe44a8b39f74158916a2bc73933b4c281ae"><code>2febefe</code></a>
Fix type-check against typescript@5.0.4</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/982c1d8e28cc62d3cba5ecde6dd8df2740c7c329"><code>982c1d8</code></a>
Handle referenced config files in their own plugin (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1931">#1931</a>,
close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1932">#1932</a>)</li>
<li>See full diff in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.32.1/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.62.0 to 0.63.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md">oxfmt's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable changes to this package will be documented in this
file.</p>
<p>The format is based on <a
href="https://keepachangelog.com/en/1.0.0">Keep a Changelog</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/c42d6397eab5b2d5bb2bd6746c57bc2a9cad21bd"><code>c42d639</code></a>
release(apps): oxlint v1.78.0 &amp;&amp; oxfmt v0.63.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25473">#25473</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/00f490d7f72d43ec88b1afe28de153e376caf8ed"><code>00f490d</code></a>
refactor(oxfmt,formatter): split <code>sortImports</code> validation and
use type enum (...</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.63.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.77.0 to 1.78.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md">oxlint's
changelog</a>.</em></p>
<blockquote>
<h2>[1.78.0] - 2026-08-10</h2>
<h3>🚀 Features</h3>
<ul>
<li>ccb8fe8 linter/jsdoc: Implement <code>no-blank-blocks</code> rule
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25207">#25207</a>)
(Mikhail Baev)</li>
<li>d4a897c linter/eslint: Implement <code>one-var</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24470">#24470</a>)
(Cole Ellison)</li>
<li>5ab9340 linter/jsx-a11y/anchor-has-content: Add options to match
eslint (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24571">#24571</a>)
(Cole Ellison)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>9573937 linter/typescript: Validate <code>ban-ts-comment</code>
description_format (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25320">#25320</a>)
(Mikhail Baev)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/c42d6397eab5b2d5bb2bd6746c57bc2a9cad21bd"><code>c42d639</code></a>
release(apps): oxlint v1.78.0 &amp;&amp; oxfmt v0.63.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25473">#25473</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/ccb8fe89db08123ff2b86d7fb2f39d0dd6c33df7"><code>ccb8fe8</code></a>
feat(linter/jsdoc): implement <code>no-blank-blocks</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25207">#25207</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/9573937df3cc01f29e1c65bc018ce378ec947e0e"><code>9573937</code></a>
fix(linter/typescript): validate <code>ban-ts-comment</code>
description_format (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25320">#25320</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/d4a897ce2290bf853720b4fbf371304bfea2c980"><code>d4a897c</code></a>
feat(linter/eslint): implement <code>one-var</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24470">#24470</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/5ab9340637eff80539bca89a494e162e94569358"><code>5ab9340</code></a>
feat(linter/jsx-a11y/anchor-has-content): add options to match eslint
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24571">#24571</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.78.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 11:16:09 -04:00
CaliBrain 0a5256ecbb fix(download): reconcile the two AA redirect-loop rescues (#1213)
#1210 and #1212 both added a DDoS-Guard `?check=1` rescue, and #1212 was
branched before #1210 landed, so the merged result had two of them with
identical guards. #1212's inline handoff returns before the raise that
#1210's exception handler keys on, so the handler was shadowed and its
stale-cookie purge — the substance of #1210 — never ran. Its regression
test has been failing on main since the merge.

Fold both into one path:

- `_redirect_loop_handoff()` purges the host's stale clearance cookies,
  then bypasses, so the inline AA handoff and the exception handler
  cannot drift apart again.
- The exception handler keeps its own reason to exist: non-AA hosts run
with allow_redirects=True, so `requests` raises the loop itself and the
  manual AA follower never sees it. It now invokes the bypasser directly
  rather than setting a flag and continuing, which was a no-op at
  MAX_RETRY=1 for the same reason the 403 handoff was.
- An unrescuable loop returns empty instead of raising TooManyRedirects
into the retry path. That error is not retryable and carries no status,
  so `/dyn/md5/summary` (allow_bypasser_fallback=False) re-ran the full
  6-redirect loop on all 10 attempts: 60 requests to AA and ~30s of
  backoff, measured. Every AA mirror shares the challenge, so there is
  nothing to rotate to.
- `allow_bypasser_fallback` docs now describe what the flag actually
  gates; the old text predated #1198 and named the wrong callers.
2026-08-15 11:14:13 -04:00
David YoungandD 6d2af0ac28 fix(download): hand AA challenges to the bypasser immediately (#1212)
## Problem

Two defects in `html_get_page`, either of which is enough to make an
Anna's Archive search fail *without the bypasser ever running*. Found
while chasing why AA search returned nothing on v1.3.7 even with
`USE_CF_BYPASS` on and a working bypasser.

### 1. An AA redirect loop is treated as a network fault

AA serves its DDoS-Guard handshake as a same-host redirect loop:
`/search?…` redirects to `/search?…&check=1`, which redirects back,
indefinitely. The manual redirect follower counts those against
`_MAX_REDIRECTS` and raises `TooManyRedirects`:

```python
redirects_followed += 1
if redirects_followed > _MAX_REDIRECTS:
    _raise_too_many_redirects(f"Too many redirects for {current_url}")
```

That lands in the retry path, so every one of the `MAX_RETRY` attempts
re-runs the same 6-redirect loop and the URL is never offered to the
bypasser — which is the only thing that can clear the challenge. With
the default `MAX_RETRY=10` that's ~60 requests to AA per search, all of
which can only fail:

```
Retry 5/10 for https://annas-archive.pk/search?…&check=1: TooManyRedirects
Retry 6/10 for https://annas-archive.pk/search?…&check=1: TooManyRedirects
…
Giving up after 10 attempts: https://annas-archive.pk/search?…&check=1
```

Surfaced to the user as `Unable to reach download source. Network
restricted or mirrors are blocked.`

### 2. Both bypasser handoffs are a no-op at `MAX_RETRY=1`

The existing 403 handoff — and the new redirect one — set a flag and
`continue`:

```python
logger.info("403 detected; switching to bypasser: %s", current_url)
use_bypasser_now = True
continue
```

The branch that acts on `use_bypasser_now` sits at the top of the
**next** retry attempt. With `MAX_RETRY=1` there is no next attempt, so
a 403 simply ends the search and the bypasser never runs. `MAX_RETRY` is
user-configurable down to 1, so this is reachable in normal use.

The redirect handoff had an additional problem: it sits inside the inner
redirect `while`, so a `continue` there re-enters *that* loop rather
than reaching the retry branch at all.

## Change

Both handoffs now invoke the bypasser directly, through a shared
`_run_bypasser()` closure extracted from the existing branch body. No
behaviour change to the bypass itself — same grace handling, same error
reporting, same `finally`.

The redirect handoff also honours `allow_bypasser_fallback`, for the
same reason the 403 path does: callers such as the `/dyn/md5/summary/…`
fetch behind the details modal pass `False` precisely so a best-effort
request fails fast instead of holding the UI open for a minutes-long
browser solve.

## Result

Measured against `/api/releases` for the same book, internal bypasser,
default `MAX_RETRY`:

| | searches returning results |
|---|---|
| before | 4 / 9 |
| after | 3 / 3, then 7 / 7 |

Zero `TooManyRedirects` give-ups after, and the new path is visible in
the logs:

```
redirect loop on https://annas-archive.gl/search?…&check=1; switching to bypasser
Bypass successful using _bypass_method_cdp_gui_click
```

The request volume drop is the other half of the win — a failing search
no longer emits ~60 requests to AA before giving up.

## Notes

- Only `shelfmark/download/http.py` changes; no config or API surface.
- `use_bypasser_now` is still set before each direct call, so the guard
against double-invocation is unchanged.
- Tested with the internal bypasser (seleniumbase). The
external-bypasser path goes through the same `get_bypassed_page()` call
and is unaffected by the control-flow change, though I have not measured
it against DDoS-Guard specifically — in my testing
FlareSolverr-compatible solvers do not clear that challenge regardless.

Co-authored-by: D <d@e>
2026-08-15 11:01:10 -04:00
Zoltán SzabóandKukkerem 056ddd372a Send DDoS-Guard's ?check=1 redirect loop to the bypasser (#1210)
Fixes #1204.

## Problem

#1198 sends a gated AA `/search` to the bypasser when the origin answers
403.
DDoS-Guard has a second response: when the clearance cookies from an
earlier
solve go stale, it serves an endless `?check=1` redirect instead.

`requests` follows that until `_raise_too_many_redirects`, and
`TooManyRedirects` carries no status code, so `status ==
_HTTP_STATUS_FORBIDDEN`
is false and the rescue never runs. All 10 retries re-send the same dead
cookies, then the search fails as `Unable to reach download source.
Network
restricted or mirrors are blocked.`

Direct-download search therefore works once per container start, and
stays dead
after the stored cookie ages out.

v1.3.7 (`sha256:520715f3…`), internal bypasser, mirrors `.gl/.pk/.gd`:

```
17:04:36 internal_bypasser.py:756 - Bypass successful using _bypass_method_cdp_gui_click
...
17:11:39 http.py:483 - Retry 1/10 for https://annas-archive.gl/search?...&check=1:
    TooManyRedirects: Too many redirects
17:12:12 http.py:493 - Giving up after 10 attempts
17:12:12 main.py:2870 - Release search failed for source direct_download:
    Unable to reach download source. Network restricted or mirrors are blocked.
```

The token is short-lived, which is what makes this reachable in normal
use:

```
$ curl -sD - 'https://annas-archive.gl/search?...&check=1'
HTTP/2 403
server: ddos-guard
set-cookie: __ddg8_=…; Expires=Fri, 14-Aug-2026 15:39:38 GMT   # issued 15:19:38, 20 min
```

## Fix

Handle the loop like the 403: drop the domain's stored cookies, then
retry
through the bypasser. The branch sits above the `status ==` ladder
because
`_get_status_code()` returns `None` for this exception.

Cookies are purged only for the internal bypasser; with an external one
`get_cf_cookies_for_domain()` already returns `{}`.

Related but not changed here: `get_cf_cookies_for_domain()` enforces
expiry for
`cf_clearance` only, so `__ddg*` cookies are never evicted on age, which
is why
they go stale. This patch makes the rescue fire whatever the reason the
cookies
stopped working.

## Verification

The regression test drives a real redirect loop through `html_get_page`
(302 to `&check=1`, exception raised by the production path rather than
faked)
and asserts the cookies are purged and the bypasser runs once.

- `pytest tests/download/test_http_bypasser_fallbacks.py`: 8 passed.
`test_download_url_ignores_zlib_cookie_refresh_failure` fails in my
checkout
  on a missing `seleniumbase`, unrelated to this change.
- `ruff check`, `ruff format --check`: clean.
- Running in production since 2026-08-14 on v1.3.7 with only this file
replaced:
six direct-download searches, five served, three books downloaded end to
end,
against one search per container start before. The rescue mid-download:

```
19:12:14 http.py:449 - Redirect loop detected; switching to bypasser:
    https://annas-archive.gl/md5/cb8fba7abae800ddbae1adfb8d7699d9?&check=1
19:12:38 internal_bypasser.py:756 - Bypass successful using _bypass_method_cdp_gui_click
19:14:36 direct_download.py:1142 - Resolved download URL [aa-slow-nowait]: …
19:14:47 orchestrator.py:735 - download finished; starting post-processing
```

## Separate issue this exposes

DDoS-Guard does not accept a solved cookie from plain `requests`
traffic, so
after this patch the rescue runs for nearly every AA URL.
`internal_bypasser.get()`
serializes all solves on one module-wide lock and builds a fresh Chrome
each
time: 11-16 s uncontended, 43-52 s under concurrent load, measured on
the host
above. Correctness is cheap here, latency is not. Happy to open a
separate PR
for a warm browser session if that direction is welcome.

Co-authored-by: Kukkerem <Kukkerem@users.noreply.github.com>
2026-08-15 10:59:41 -04:00
CaliBrain d0e008adde Stop dropping audiobook releases that are not m4b or mp3 (#1199)
An IRC audiobook search returned nothing while OpenBooks, reading the
same @search answer from the same channel, listed results. Three
separate defects were discarding them.

The audiobook format list was maintained by hand in four places and had
drifted. The settings UI offered only m4b/mp3/m4a/zip/rar, and that list
is the only one a user's config can be built from, so flac, opus, ogg,
aac, wav and wma were unreachable everywhere — even though the IRC
parser recognized them, the IRC sorter ranked them (dead code that could
never fire), archive extraction knew them and Prowlarr searched for
them. A FLAC audiobook was invisible in search and, if it arrived
anyway, rejected after download as "format not supported".
AUDIOBOOK_FORMATS and ARCHIVE_FORMATS now live once in core.utils and
every layer derives from them, which also restored the missing .opus in
the post-download scan's trackable extensions.

Widening the default alone would not have reached anyone already
affected: initialize_default_configs() writes field defaults only when a
tab has no config file yet, so an existing install keeps its persisted
m4b/mp3 list forever. migrate_audiobook_formats rewrites a list that
still matches the old default exactly and leaves every other value
alone — re-enabling formats someone had deliberately turned off would be
worse than leaving them narrow.

The IRC parser filtered by file extension alone. Multi-file audiobooks
ship as a .rar or .zip of MP3s, which matched neither SUPPORTED_FORMATS
nor SUPPORTED_AUDIOBOOK_FORMATS, so they fell out of the ebook bucket
and the audiobook bucket both. Results are now classified before the
format filter is applied: an audio extension means audiobook, an ebook
extension means ebook, and for a container — where the extension says
nothing about the contents — the release name decides. An ebook archive
stays out of audiobook results.

RESULT_LINE_REGEX matched \w+ after any dot, so a line carrying no file
extension parsed as format "5mb" out of "::INFO:: 620.5MB", taking the
title and the size down with it and guaranteeing every downstream filter
dropped it. Any decimal size did this. The extension is now matched
against the known formats, so such a line falls through to the simple
pattern and comes back as "unknown", which the rest of the parser
already handles. ALL_RECOGNIZED_FORMATS became an ordered tuple in the
process: it was a set, so which extension won for a line naming two of
them depended on set iteration order and could vary between restarts.

Refs #1129
2026-08-13 13:51:40 -04:00
CaliBrain 03e219eb43 Let the bypasser solve bot challenges on Anna's Archive search (#1198)
Anna's Archive put a DDoS-Guard JS challenge in front of /search: the
homepage still returns 200, but /search and /md5/<id> answer 403 on
every mirror (.gl, .pk, .gd all confirmed). Search fetched both with
allow_bypasser_fallback=False, which rotates mirrors on a 403 instead of
invoking the bypasser, so it walked the whole mirror list, exhausted it,
and surfaced "Unable to reach download source. Network restricted or
mirrors are blocked." as a 503 on every query. Adding mirrors could not
help — they sit behind the same gate — and neither could USE_CF_BYPASS,
since search never reached that branch.
Fetch search and the detail page with allow_bypasser_fallback=True so a
403 hands over to the bypasser, which already detects this challenge
(DDOS_GUARD_INDICATORS matches the live page). Echoing the __ddg cookies
back does not clear it; it needs real JS execution. The download-count
fetch keeps allow_bypasser_fallback=False: it is decoration on the
details modal and not worth holding the modal open for a browser solve.
Fixes #1196
2026-08-13 13:06:11 -04:00
dependabot[bot] 0e8608c427 Bump transmission-rpc from 7.0.11 to 7.0.12 in the python-deps group (#1195)
Bumps the python-deps group with 1 update:
[transmission-rpc](https://github.com/Trim21/transmission-rpc).

Updates `transmission-rpc` from 7.0.11 to 7.0.12
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/Trim21/transmission-rpc/releases">transmission-rpc's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.12</h2>
<h3>✨ New Features</h3>
<ul>
<li><a
href="https://github.com/trim21/transmission-rpc/commit/849cc3b9a357f903242915301fcd190d5ae932a3"><code>849cc3b</code></a>
- add v8 migration deprecations <em>(commit by <a
href="https://github.com/trim21"><code>@​trim21</code></a>)</em></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/trim21/transmission-rpc/commit/d05e9c6c2953d7f334b46df600af4b47d9ea65f5"><code>d05e9c6</code></a>
bump: 7.0.12</li>
<li><a
href="https://github.com/trim21/transmission-rpc/commit/2800e6f0a0a64c31f44935e5122cb7ad3d581dfd"><code>2800e6f</code></a>
ci: pin pyproject schema validation</li>
<li><a
href="https://github.com/trim21/transmission-rpc/commit/1bc5f0d51888f526ada2026409b00228287d2fc8"><code>1bc5f0d</code></a>
ci: run workflows on 7.x</li>
<li><a
href="https://github.com/trim21/transmission-rpc/commit/849cc3b9a357f903242915301fcd190d5ae932a3"><code>849cc3b</code></a>
feat: add v8 migration deprecations</li>
<li>See full diff in <a
href="https://github.com/Trim21/transmission-rpc/compare/v7.0.11...v7.0.12">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=transmission-rpc&package-manager=uv&previous-version=7.0.11&new-version=7.0.12)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 00:30:47 -04:00
dependabot[bot] 7f770f54fa Bump python from 3.14.6-slim to 3.14.7-slim (#1190)
Bumps python from 3.14.6-slim to 3.14.7-slim.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=python&package-manager=docker&previous-version=3.14.6-slim&new-version=3.14.7-slim)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 00:21:24 -04:00
dependabot[bot] 0eb8b78dc7 Bump the npm-deps group in /src/frontend with 3 updates (#1191)
Bumps the npm-deps group in /src/frontend with 3 updates:
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node),
[knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and
[vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).

Updates `@types/node` from 26.1.2 to 26.2.0
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />

Updates `knip` from 6.31.0 to 6.32.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.32.0</h2>
<ul>
<li>fix: attribute wildcard subpath-import aliases as dependency usage
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1918">#1918</a>)
(4890a2ad5317b9e3f0ab844631ad0f0592802c3e) - thanks <a
href="https://github.com/jsmecham"><code>@​jsmecham</code></a>!</li>
<li>Add Borp plugin (5eb9ad4cfb2ccb7770eac49f6109459d30590211)</li>
<li>Add tsd plugin (bd4ae7041f3fc6aef27b027644f80daefc432aca)</li>
<li>Resolve Rollup --config argument as plugin configuration
(8111f97981406f4327d5e6a655c13308a9dc4426)</li>
<li>Resolve babel-jest configFile from Jest transform options
(24794ccecb80fdaecfe4d46a26faff46c30d9015)</li>
<li>Add pre-commit plugin
(ac726b0918cb931a6d538797e342484812a6752e)</li>
<li>Thread script words through resolvers to preserve quoting
(89f9ada6ca81229bf8f7293439998acc5f87d457)</li>
<li>Resolve entries from Mocha script arguments
(353f860f8e32f05f7c87dc5501071befc0e5a293)</li>
<li>Detect Node test runner through c8
(fa44be722aa35c6e8b441cd8cd0f4cf644027c22)</li>
<li>Update dependencies (non-major) (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1922">#1922</a>)
(6b1e6f3cbb1749776e3798f46eb2b99cd95ed56a) - thanks <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot]!</li>
<li>fix(playwright): match config files with .cjs, .cts and .mts
extensions (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1919">#1919</a>)
(1c1073a1817fdb3bcd3dc00eda2b1d858e444156) - thanks <a
href="https://github.com/davidpavlovschi"><code>@​davidpavlovschi</code></a>!</li>
<li>Restart VS Code language server process (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1923">#1923</a>)
(d48eac5a2447567a33e835ce3e1648bf3f4ffb2b)</li>
<li>Classify built-ins from module specifiers (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1925">#1925</a>)
(3528c5d0f808b7b2155f69a1f98493ee0e3e06c1)</li>
<li>Don't turn absolute-path script tokens into entries (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1928">#1928</a>)
(e5608e77b434145c8dd5c2c2872bfb90ae622ce7)</li>
<li>Update sponsors data (025884bb53eede041732723197ac0415a1a50f71)</li>
<li>Update dependencies (d2c0a07c09cea70ab2cae5f97d12da272a6e43e6)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/f21bcbb653377682dd690fb99b0f02977ac524d2"><code>f21bcbb</code></a>
Release knip@6.32.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/d2c0a07c09cea70ab2cae5f97d12da272a6e43e6"><code>d2c0a07</code></a>
Update dependencies</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/e5608e77b434145c8dd5c2c2872bfb90ae622ce7"><code>e5608e7</code></a>
Don't turn absolute-path script tokens into entries (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1928">#1928</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/3528c5d0f808b7b2155f69a1f98493ee0e3e06c1"><code>3528c5d</code></a>
Classify built-ins from module specifiers (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1925">#1925</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/1c1073a1817fdb3bcd3dc00eda2b1d858e444156"><code>1c1073a</code></a>
fix(playwright): match config files with .cjs, .cts and .mts extensions
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1919">#1919</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/fa44be722aa35c6e8b441cd8cd0f4cf644027c22"><code>fa44be7</code></a>
Detect Node test runner through c8</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/353f860f8e32f05f7c87dc5501071befc0e5a293"><code>353f860</code></a>
Resolve entries from Mocha script arguments</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/89f9ada6ca81229bf8f7293439998acc5f87d457"><code>89f9ada</code></a>
Thread script words through resolvers to preserve quoting</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/ac726b0918cb931a6d538797e342484812a6752e"><code>ac726b0</code></a>
Add pre-commit plugin</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/24794ccecb80fdaecfe4d46a26faff46c30d9015"><code>24794cc</code></a>
Resolve babel-jest configFile from Jest transform options</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.32.0/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.2.0 to 8.2.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>plugin-legacy@8.2.1</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/plugin-legacy@8.2.1/packages/plugin-legacy/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v8.2.1</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.2.1/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><!-- raw HTML omitted --><a
href="https://github.com/vitejs/vite/compare/v8.2.0...v8.2.1">8.2.1</a>
(2026-08-06)<!-- raw HTML omitted --></h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> make client chunkImportMap work with
<code>sharedPlugins: true</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23184">#23184</a>)
(<a
href="https://github.com/vitejs/vite/commit/15f03073c915d6ffb9a1fda447ef66b02bf5cde8">15f0307</a>)</li>
<li><strong>bundled-dev:</strong> inject client script tag before chunk
scripts (<a
href="https://redirect.github.com/vitejs/vite/issues/23161">#23161</a>)
(<a
href="https://github.com/vitejs/vite/commit/eac0cc84aa2472a85a19ee84561c1ba71e381a55">eac0cc8</a>)</li>
<li><strong>css:</strong> don't re-run lightningcss visitor during
minify (fix <a
href="https://redirect.github.com/vitejs/vite/issues/23146">#23146</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/23147">#23147</a>)
(<a
href="https://github.com/vitejs/vite/commit/de041a79b05a0be965c874592fe2c1505bcd48df">de041a7</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23136">#23136</a>)
(<a
href="https://github.com/vitejs/vite/commit/14454fd8c9a399bc3fdc193e28465b6fcf001e4d">14454fd</a>)</li>
<li><strong>deps:</strong> update rolldown-related dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23070">#23070</a>)
(<a
href="https://github.com/vitejs/vite/commit/7ac6f7f590747bbdab9958e2c016e3dd04f10542">7ac6f7f</a>)</li>
<li>don't mutate the user config when resolving the lib entry from the
top-level <code>input</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23135">#23135</a>)
(<a
href="https://github.com/vitejs/vite/commit/b4bf59686a7ac238929e91a6e1708c739b843a2f">b4bf596</a>)</li>
<li>handle shebang ending with uncommon line terminators (<a
href="https://redirect.github.com/vitejs/vite/issues/23038">#23038</a>)
(<a
href="https://github.com/vitejs/vite/commit/17f7b2f193a110d0b47742ad296d182cb4666ce7">17f7b2f</a>)</li>
<li><strong>server:</strong> use a random port when port is 0 (<a
href="https://redirect.github.com/vitejs/vite/issues/23158">#23158</a>)
(<a
href="https://github.com/vitejs/vite/commit/fddf4ea41de5f7889037a2f957438857ac12a260">fddf4ea</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<ul>
<li><strong>css:</strong> look up pure CSS chunks through a Set (<a
href="https://redirect.github.com/vitejs/vite/issues/23114">#23114</a>)
(<a
href="https://github.com/vitejs/vite/commit/1331b0b438b1e7193effb7d2341660bccb9c3155">1331b0b</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li><strong>build:</strong> fix incomplete <code>@default</code> for
build.minify (<a
href="https://redirect.github.com/vitejs/vite/issues/23177">#23177</a>)
(<a
href="https://github.com/vitejs/vite/commit/ef02435114c57d0422028f0e6987f3df8db72969">ef02435</a>)</li>
</ul>
<h3>Miscellaneous Chores</h3>
<ul>
<li><strong>deps:</strong> update dependency rolldown-plugin-dts to
^0.28.0 (<a
href="https://redirect.github.com/vitejs/vite/issues/23137">#23137</a>)
(<a
href="https://github.com/vitejs/vite/commit/4adc1e7931d4beceb4e236d9a271d057c858a06f">4adc1e7</a>)</li>
<li><strong>deps:</strong> update dependency strip-literal to v4 (<a
href="https://redirect.github.com/vitejs/vite/issues/23140">#23140</a>)
(<a
href="https://github.com/vitejs/vite/commit/9db65ce63488ea8f08a3c98dcdc4282b17bd33ff">9db65ce</a>)</li>
</ul>
<h3>Code Refactoring</h3>
<ul>
<li><strong>bundled-dev:</strong> avoid injecting server values in the
bundle (<a
href="https://redirect.github.com/vitejs/vite/issues/22967">#22967</a>)
(<a
href="https://github.com/vitejs/vite/commit/23b8a088dec9dcc3f1c1353f2074f8644b3cc21f">23b8a08</a>)</li>
<li><strong>bundled-dev:</strong> remove rolldown lazy stub module
workaround (<a
href="https://redirect.github.com/vitejs/vite/issues/23129">#23129</a>)
(<a
href="https://github.com/vitejs/vite/commit/e72036eed2e28936ed824971b18aeaa3900857f6">e72036e</a>)</li>
</ul>
<h3>Tests</h3>
<ul>
<li><strong>bundled-dev:</strong> enable sourcemap playgrounds (<a
href="https://redirect.github.com/vitejs/vite/issues/23080">#23080</a>)
(<a
href="https://github.com/vitejs/vite/commit/c2155fe4d5c8d25fba3a7366d367e3296ae669fa">c2155fe</a>)</li>
<li>reduce logs (<a
href="https://redirect.github.com/vitejs/vite/issues/23138">#23138</a>)
(<a
href="https://github.com/vitejs/vite/commit/7673c02e53343ae9356c1f496c1c1da2eb732ac1">7673c02</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/421615865dad3ed39137d17281814fc78a41246c"><code>4216158</code></a>
release: v8.2.1</li>
<li><a
href="https://github.com/vitejs/vite/commit/fddf4ea41de5f7889037a2f957438857ac12a260"><code>fddf4ea</code></a>
fix(server): use a random port when port is 0 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23158">#23158</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/de041a79b05a0be965c874592fe2c1505bcd48df"><code>de041a7</code></a>
fix(css): don't re-run lightningcss visitor during minify (fix <a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23146">#23146</a>)
(<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23147">#23147</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/15f03073c915d6ffb9a1fda447ef66b02bf5cde8"><code>15f0307</code></a>
fix(build): make client chunkImportMap work with <code>sharedPlugins:
true</code> (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23184">#23184</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/c2155fe4d5c8d25fba3a7366d367e3296ae669fa"><code>c2155fe</code></a>
test(bundled-dev): enable sourcemap playgrounds (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23080">#23080</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/ef02435114c57d0422028f0e6987f3df8db72969"><code>ef02435</code></a>
docs(build): fix incomplete <code>@default</code> for build.minify (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23177">#23177</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/eac0cc84aa2472a85a19ee84561c1ba71e381a55"><code>eac0cc8</code></a>
fix(bundled-dev): inject client script tag before chunk scripts (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23161">#23161</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/23b8a088dec9dcc3f1c1353f2074f8644b3cc21f"><code>23b8a08</code></a>
refactor(bundled-dev): avoid injecting server values in the bundle (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/22967">#22967</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/e72036eed2e28936ed824971b18aeaa3900857f6"><code>e72036e</code></a>
refactor(bundled-dev): remove rolldown lazy stub module workaround (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23129">#23129</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/14454fd8c9a399bc3fdc193e28465b6fcf001e4d"><code>14454fd</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/23136">#23136</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/v8.2.1/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 00:14:04 -04:00
dependabot[bot] eb98b6a816 Bump the gh-actions group with 2 updates (#1192)
Bumps the gh-actions group with 2 updates:
[actions/attest-build-provenance](https://github.com/actions/attest-build-provenance)
and [dorny/paths-filter](https://github.com/dorny/paths-filter).

Updates `actions/attest-build-provenance` from 4.1.1 to 4.2.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/attest-build-provenance/releases">actions/attest-build-provenance's
releases</a>.</em></p>
<blockquote>
<h2>v4.2.2</h2>
<blockquote>
<p>[!NOTE]
As of version 4, <code>actions/attest-build-provenance</code> is simply
a wrapper on top of <a
href="https://github.com/actions/attest"><code>actions/attest</code></a>.</p>
<p>Existing applications may continue to use the
<code>attest-build-provenance</code> action, but new implementations
should use <code>actions/attest</code> instead.</p>
</blockquote>
<h2>What's Changed</h2>
<ul>
<li>Bump actions/attest from 4.2.0 to 4.2.1 in the actions-minor group
by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/attest-build-provenance/pull/862">actions/attest-build-provenance#862</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/attest-build-provenance/compare/v4.1.1...v4.2.2">https://github.com/actions/attest-build-provenance/compare/v4.1.1...v4.2.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/attest-build-provenance/commit/4d101475d8b20a2381f78447822ac1eab6504dd8"><code>4d10147</code></a>
Bump actions/attest from 4.2.0 to 4.2.1 in the actions-minor group (<a
href="https://redirect.github.com/actions/attest-build-provenance/issues/862">#862</a>)</li>
<li><a
href="https://github.com/actions/attest-build-provenance/commit/e3fe62ef559997059fe8380e7d2b4c909e2d65f4"><code>e3fe62e</code></a>
Bump the actions-minor group with 2 updates (<a
href="https://redirect.github.com/actions/attest-build-provenance/issues/860">#860</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/attest-build-provenance/compare/0f67c3f4856b2e3261c31976d6725780e5e4c373...4d101475d8b20a2381f78447822ac1eab6504dd8">compare
view</a></li>
</ul>
</details>
<br />

Updates `dorny/paths-filter` from 4.0.2 to 4.0.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/dorny/paths-filter/releases">dorny/paths-filter's
releases</a>.</em></p>
<blockquote>
<h2>v4.0.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Update Outputs in readme to account for the 'every'
predicate-quantifier by <a
href="https://github.com/hintron"><code>@​hintron</code></a> in <a
href="https://redirect.github.com/dorny/paths-filter/pull/247">dorny/paths-filter#247</a></li>
<li>fix: scope base-ignored warning to API path by <a
href="https://github.com/saschabratton"><code>@​saschabratton</code></a>
in <a
href="https://redirect.github.com/dorny/paths-filter/pull/319">dorny/paths-filter#319</a></li>
<li>docs: add contents permission to PR example by <a
href="https://github.com/134130"><code>@​134130</code></a> in <a
href="https://redirect.github.com/dorny/paths-filter/pull/248">dorny/paths-filter#248</a></li>
<li>feat: add 'some-with-excludes' predicate quantifier by <a
href="https://github.com/arxeiss"><code>@​arxeiss</code></a> in <a
href="https://redirect.github.com/dorny/paths-filter/pull/322">dorny/paths-filter#322</a></li>
<li>Document safe handling of file list outputs in workflows by <a
href="https://github.com/dorny"><code>@​dorny</code></a> in <a
href="https://redirect.github.com/dorny/paths-filter/pull/326">dorny/paths-filter#326</a></li>
</ul>
<h2>Security</h2>
<ul>
<li>Escape multi-line filenames in list-files shell and csv output] by
<a href="https://github.com/ken-matsui"><code>@​ken-matsui</code></a>
and <a href="https://github.com/tjswlsgg"><code>@​tjswlsgg</code></a> in
<a
href="https://github.com/advisories/GHSA-7hc6-8hq5-9q2m">https://github.com/advisories/GHSA-7hc6-8hq5-9q2m</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/hintron"><code>@​hintron</code></a> made
their first contribution in <a
href="https://redirect.github.com/dorny/paths-filter/pull/247">dorny/paths-filter#247</a></li>
<li><a href="https://github.com/134130"><code>@​134130</code></a> made
their first contribution in <a
href="https://redirect.github.com/dorny/paths-filter/pull/248">dorny/paths-filter#248</a></li>
<li><a href="https://github.com/arxeiss"><code>@​arxeiss</code></a> made
their first contribution in <a
href="https://redirect.github.com/dorny/paths-filter/pull/322">dorny/paths-filter#322</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/dorny/paths-filter/compare/v4...v4.0.3">https://github.com/dorny/paths-filter/compare/v4...v4.0.3</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md">dorny/paths-filter's
changelog</a>.</em></p>
<blockquote>
<h2>v4.0.3</h2>
<ul>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/326">Document
safe handling of file list outputs in workflows</a></li>
<li><a href="https://github.com/advisories/GHSA-7hc6-8hq5-9q2m">Escape
multi-line filenames in list-files shell and csv output</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/322">Add
'some-with-excludes' predicate quantifier</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/248">Add
contents permission to PR example</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/319">Scope
base-ignored warning to API path</a></li>
<li><a
href="https://redirect.github.com/dorny/paths-filter/pull/247">Update
outputs in readme to account for the 'every'
predicate-quantifier</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/dorny/paths-filter/commit/ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d"><code>ceb8a2b</code></a>
Update CHANGELOG.md for v4.0.3 and v3.0.4 (<a
href="https://redirect.github.com/dorny/paths-filter/issues/327">#327</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/ef09b88f3eacdbec6ce135a7c9a193a6849545c1"><code>ef09b88</code></a>
Document safe handling of file list outputs in workflows (<a
href="https://redirect.github.com/dorny/paths-filter/issues/326">#326</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/44adc5b06dc135dba334efce9bf3cf0624512d2d"><code>44adc5b</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/4711b7a31b4aa89103d8c6ffab2e3b8e7b6381c7"><code>4711b7a</code></a>
feat: add 'some-with-excludes' predicate quantifier (<a
href="https://redirect.github.com/dorny/paths-filter/issues/322">#322</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/93c889f9e58fca66f35a0c83d8673ac7e88bb70a"><code>93c889f</code></a>
fix: escape multi-line filenames in list-files shell and csv output</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/b41dfa943b1939b9b646f67753bfe35cf6e4de03"><code>b41dfa9</code></a>
docs: add contents permission to PR example (<a
href="https://redirect.github.com/dorny/paths-filter/issues/248">#248</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/9af6e5a9d010d1ae8ec570390b3d793e2b70a402"><code>9af6e5a</code></a>
fix: scope base-ignored warning to API path (<a
href="https://redirect.github.com/dorny/paths-filter/issues/319">#319</a>)</li>
<li><a
href="https://github.com/dorny/paths-filter/commit/cae9006b65a1a53044b518c68e13e835c54948a7"><code>cae9006</code></a>
docs: update outputs in readme to account for the 'every'
predicate-quantifie...</li>
<li>See full diff in <a
href="https://github.com/dorny/paths-filter/compare/v4.0.2...v4.0.3">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-12 00:13:45 -04:00
CaliBrain 29ce83e274 Stop dependabot proposing Python pre-releases, bound the e2e health wait (#1189)
PR #1169 (python:3.14.6-slim -> python:3.15.0b3-slim) ran for 6h before
GitHub's max job limit killed it, then did it again on re-run. Two
independent defects.

Dependabot proposed a beta at all: the config already excluded python
from the docker digest group for dependabot-core#9496, but the comment
claimed ungrouped python updates get their pre-release filtered. They
don't. dependabot-core#13815 rewrote the Docker pre-release heuristic to
catch PEP 440 tags (its tests cover 3.15.0a2 and 3.5.0b3), yet the
suffixed real tag still got through seven months later. CPython spells
pre-releases without a separator, so 3.15.0b3 parses as an ordinary
version sorting above 3.14.6. Ignore python semver-minor/major instead
of trusting the heuristic; patch and digest updates still flow.

The run took hours rather than failing: the health wait looked bounded
at 60 iterations x 2s, but bare `curl` has no timeout. The 3.15 image
booted a container that bound 8084 without ever serving (greenlet has no
3.15 wheel, so the gevent gunicorn worker was wedged), so curl blocked
on
read forever and the loop never reached iteration 2. Every job's orphan
process at cancellation was that curl. Bound each probe and switch to a
wall-clock deadline, and add timeout-minutes so a hang can never reach
6h
again.

Verified against a socket that accepts and never responds: the old loop
was still hung at 30s, the new one exits at 120s with HEALTHY=0 into the
existing log-dump path, and a responsive endpoint is still detected
immediately.
2026-08-11 23:58:37 -04:00
CaliBrain e320b7623d Fix LOG_LEVEL being ignored and Z-Library 503 cookie gate (#1188)
LOG_LEVEL never reached the app logger: env.py hardcoded the level to
DEBUG or INFO, so INFO lines kept appearing under LOG_LEVEL=error. Read
it from the env var and advanced settings, normalize unknown values to
INFO, and expose it as a setting. entrypoint.sh now normalizes
gunicorn's level too, so a typo falls back to info instead of stopping
the container from booting.

Z-Library gates the first hit on /md5/<hash> with a 503 whose only
payload is a Set-Cookie; echoing that cookie back returns the 302 to the
real page. html_get_page dropped it and re-ran the same rejected request
on every retry, ending in "No download URL resolved". Retry once with
the
cookies the 503 issued.

Fixes #1185
Fixes #1187
2026-08-11 23:27:44 -04:00
CaliBrain bb848f05bc fix/bypass stall watchdog (#1186)
- Fix protection bypass cancelled by stall detection at exactly 300s
- make fixes
- Try to fix Synology DELETE issues
2026-08-11 12:23:34 -04:00
CaliBrain cc1a95f965 Fix protection bypass cancelled by stall detection at exactly 300s (#1184)
A download that hits Cloudflare hung on "Bypassing protection..." for
five
minutes and then died, regardless of which bypasser was configured.

html_get_page() started a BypassHeartbeat thread to keep the download
marked
alive during a bypass, but the thread had no loop: it fired one status
event
and returned. Even with the loop restored it could not have worked,
because
update_download_status() dedupes identical (status, message) tuples and
returns before refreshing _last_activity, and the heartbeat re-sent the
byte-identical payload already emitted just above it.

So _last_activity was frozen for the whole bypass, while both bypassers
are
allowed to run longer than STALL_TIMEOUT (external FlareSolverr ~394s at
default settings, internal 420s per get() call). The watchdog always
won.
From a reporter's log: 403 at 07:04:33.390, cancelled at 07:09:33.987 -
exactly 300.000s, and 41s before the bypasser would have finished and
reported the real error, an HTTP 500 from FlareSolverr the user never
saw.

The regression is not one commit. 1f093de (#536) added the heartbeat and
the
dedup together and refreshed activity before the dedup return, so it
worked.
ff094be (#832) moved the refresh below that return while tightening
stall
detection for #823. 3a3a3ce (#845) then deleted the heartbeat's while
loop
to silence a B023 lint, removing the last evidence of intent.

The dedup itself is correct and stays: a keep-alive that ticks on a
timer
proves nothing about whether an operation is progressing, so letting it
refresh the stall clock would make a wedged download immortal. Split the
two
concerns instead.

Add shelfmark/download/activity.py. A long single-shot operation
declares its
own upper bound once, over a sentinel status carried on the existing
status_callback channel - so no new parameter has to be threaded through
every
handler, post-processor and output module. The orchestrator intercepts
the
sentinel in its per-task closure and records an absolute deadline in
_activity_grace, which stall detection honours alongside STALL_TIMEOUT.
The
grace never extends itself and is clamped to
_MAX_ACTIVITY_GRACE_SECONDS, so
an operation that overruns its own declared budget is still cancelled.

Each bypasser now reports max_duration_seconds() derived from its own
retry
and timeout settings, and http.py asks whichever is active, plus 30s of
slack
so the bypasser's own deadline expires first and the user sees its real
failure. On that path html_get_page() also emits
status_callback("error", ...)
rather than silently returning an empty page.

Three further fixes on the same code path:

- Extract the watchdog into _find_stalled_tasks() and
_cancel_stalled_task().
It was the only place holding _progress_lock across a call into
book_queue,
whose terminal-status hooks reach a sqlite write that gevent does not
patch,
blocking the hub and every download worker. It now holds the lock for
dict
  reads only.
- Bound _CDP_WORKER.run(), which waited with timeout=None while holding
the
module-wide LOCKED, so a single wedged in-process CDP session blocked
every
  subsequent bypass forever on non-Docker installs.
- Broaden the coordinator loop's except clause back to Exception, with
  escalating backoff. 8d98e12 (#868) narrowed it to a six-type tuple to
silence BLE001, which let gevent's LoopExit and similar kill the only
thread
driving the download queue - undoing #832's fix for #823 and resurfacing
it
  as #1166. GreenletExit and gevent.Timeout still propagate.

Fixes #1001
Refs #1166, #823
2026-08-11 02:25:28 -04:00
CaliBrain dfcd7c9b00 Fix silent Hardcover search failures on rejected sort values (#1183)
Hardcover forwards the `sort` argument to Typesense's `sort_by` and
rejects
the entire search if it dislikes the value -- an unknown field, a bare
field
name with no direction, or more than three sort keys. A rejected search
is
not a GraphQL error: it comes back as HTTP 200, no `errors` key, and a
null
`results` body.

_extract_typesense_hits() reads that null as `hits=[], found=0`, so a
failed
search was indistinguishable from one that matched nothing. Users saw
zero
results with a healthy container and no log line explaining why.

Add _execute_search_query(), used by the three sort-bearing call sites
(book
search, field typeahead, series resolution):

- Detect the rejection via the null `results` body. A search that
genuinely
matched nothing still returns a results object with `found: 0`, so empty
    result sets are not mistaken for failures.
- Retry once with an empty sort, which Hardcover always accepts, so
searches
    return results instead of nothing.
- Keep that fallback sticky for 15 minutes so every subsequent search
does
not pay for a request known to fail, and let it expire so sort order
comes
    back on its own if the index is fixed upstream.
- Log rejections that no sort can explain, and retries that also fail,
at
    ERROR instead of discarding them.

While the fallback is active, results fall back to Typesense's default
ordering regardless of the selected sort. Degraded ordering beats no
results,
and it is now logged rather than silent.

SORT_MAPPING itself is unchanged: all five of its values were verified
against
the live API and return results. The `sort: "relevance"` reported in
#1179 was
the raw SortOrder value sent by v1.3.5; the mapping already fixed that.
What
remained unfixed, and is fixed here, is that the failure was invisible.

Fixes #1179
2026-08-11 01:35:41 -04:00
dependabot[bot] bd9a22bd6c Bump node from a0b9bf0 to d32cdf6 in the docker-base-image-digests group (#1168)
Bumps the docker-base-image-digests group with 1 update: node.

Updates `node` from `a0b9bf0` to `d32cdf6`


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=node&package-manager=docker&previous-version=24-alpine&new-version=24-alpine)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 01:06:51 -04:00
dependabot[bot] ba4f7fb5e6 Bump the npm-deps group in /src/frontend with 8 updates (#1170)
Bumps the npm-deps group in /src/frontend with 8 updates:

| Package | From | To |
| --- | --- | --- |
|
[react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom)
| `7.18.1` | `7.18.2` |
|
[@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react)
| `19.2.17` | `19.2.18` |
|
[@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom)
| `19.2.3` | `19.2.4` |
|
[@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react)
| `6.0.4` | `6.0.5` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.29.0` | `6.31.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.61.0` | `0.62.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.76.0` | `1.77.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.1.5` | `8.2.0` |

Updates `react-router-dom` from 7.18.1 to 7.18.2
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/remix-run/react-router/blob/react-router-dom@7.18.2/packages/react-router-dom/CHANGELOG.md">react-router-dom's
changelog</a>.</em></p>
<blockquote>
<h2>v7.18.2</h2>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies:
<ul>
<li><a
href="https://github.com/remix-run/react-router/releases/tag/react-router@7.18.2"><code>react-router@7.18.2</code></a></li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/remix-run/react-router/commit/69a653ee6ab1ac95b13c917ec56c5f3dc17ca9c1"><code>69a653e</code></a>
Release v7.18.2 (<a
href="https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom/issues/15354">#15354</a>)</li>
<li>See full diff in <a
href="https://github.com/remix-run/react-router/commits/react-router-dom@7.18.2/packages/react-router-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `@types/react` from 19.2.17 to 19.2.18
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react">compare
view</a></li>
</ul>
</details>
<br />

Updates `@types/react-dom` from 19.2.3 to 19.2.4
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `@vitejs/plugin-react` from 6.0.4 to 6.0.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite-plugin-react/releases">@​vitejs/plugin-react's
releases</a>.</em></p>
<blockquote>
<h2>plugin-react@6.0.5</h2>
<h3>Fixed the react compiler preset filter to be linear (<a
href="https://redirect.github.com/vitejs/vite-plugin-react/pull/1353">#1353</a>)</h3>
<p>The improved filter in v6.0.3 was non-linear and caused a performance
regression (<a
href="https://redirect.github.com/vitejs/vite-plugin-react/issues/1349">#1349</a>).
The filter was changed to be linear to avoid that.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md">@​vitejs/plugin-react's
changelog</a>.</em></p>
<blockquote>
<h2>6.0.5 (2026-07-30)</h2>
<h3>Fixed the react compiler preset filter to be linear (<a
href="https://redirect.github.com/vitejs/vite-plugin-react/pull/1353">#1353</a>)</h3>
<p>The improved filter in v6.0.3 was non-linear and caused a performance
regression (<a
href="https://redirect.github.com/vitejs/vite-plugin-react/issues/1349">#1349</a>).
The filter was changed to be linear to avoid that.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/68c0cb8796ce18bd049c3d05c5210eaf0617eac0"><code>68c0cb8</code></a>
release: plugin-react@6.0.5 (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1362">#1362</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/555cdbc126506317b05404481374406771a41e70"><code>555cdbc</code></a>
fix(react): make the react compiler preset filter linear (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1353">#1353</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/a00a9f8240d5a7bb4062ee2a5bac68ea4f0defa6"><code>a00a9f8</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1327">#1327</a>)</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.5/packages/plugin-react">compare
view</a></li>
</ul>
</details>
<br />

Updates `knip` from 6.29.0 to 6.31.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.31.0</h2>
<ul>
<li>feat: Add basic support for Marko 6 and 5 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1914">#1914</a>)
(c6b151642701f9b4864ddb5ea8e40580623f59d1) - thanks <a
href="https://github.com/caseycarroll"><code>@​caseycarroll</code></a>!</li>
<li>Use Array.from in Marko compiler
(5aadf47a22bdfd43bb8ad4082768e07fe37fc5ca)</li>
<li>Update release-it and fix release script flags
(6fa9c269c65c0923965924226a5ccf0778842967)</li>
<li>Downgrade <code>@​types/vscode</code> to match extension engines
range (6d8e9bf5200cf982d0bc801469c4e2be5627e5b8)</li>
</ul>
<h2>Release 6.30.0</h2>
<ul>
<li>docs: migrate to Starlight Blog plugin (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1854">#1854</a>)
(c89e3e3c9) - thanks <a
href="https://github.com/trueberryless"><code>@​trueberryless</code></a>!</li>
<li>Include workspace dependencies in scoped runs (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1901">#1901</a>)
(a75309eda)</li>
<li>Simplify workspace assembly (b3ac16411)</li>
<li>Document export declaration safety (ef98a7682)</li>
<li>Read object jsPlugin specifiers from Vite+ config (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1879">#1879</a>)
(3ea6a92d4) - thanks <a
href="https://github.com/matchai"><code>@​matchai</code></a>!</li>
<li>Compile gitignore negations once (6884cdb20)</li>
<li>Cache Node test script detection (d970f67d8)</li>
<li>Track only enabled workspace plugins (67d48268a)</li>
<li>Skip unsupported Vite config command (545bb2d36)</li>
<li>Add SARIF reporter (ce11cf5d4)</li>
<li>Track catalog refs in pnpm dlx scripts (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1885">#1885</a>,
close <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1886">#1886</a>)
(102189a1a)</li>
<li>Report unresolved catalog references (b2b86c3e5)</li>
<li>Track catalog references across scoped runs (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1905">#1905</a>)
(f0365bae0)</li>
<li>Track script catalog references across scoped runs (9b3ab3778)</li>
<li>Remove tsc from ignoreBinaries (a0a283040)</li>
<li>fix: detect dynamic imports in Svelte component templates (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1908">#1908</a>)
(aa303fb04) - thanks <a
href="https://github.com/firatciftci"><code>@​firatciftci</code></a>!</li>
<li>fix(remark): support all types of dependency names (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1911">#1911</a>)
(3441bea3a) - thanks <a
href="https://github.com/voxpelli"><code>@​voxpelli</code></a>!</li>
<li>Update sanity snapshot (d04aa04e5)</li>
<li>feat(stryker): add ts extensions (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1910">#1910</a>)
(cfc50886e) - thanks <a
href="https://github.com/christopher-buss"><code>@​christopher-buss</code></a>!</li>
<li>Adopt <code>@​e18e/eslint-plugin</code> (adf2511ab)</li>
<li>Remove Workleap from sponsors (d4f6af9ff)</li>
<li>Update dependencies (3f61542f6)</li>
<li>Add ES2023 array types to TypeScript 5 CI (360a47157)</li>
<li>Update sentry snapshot (7defc1853)</li>
<li>Analyze published type dependencies (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1909">#1909</a>)
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1912">#1912</a>)
(87d713e52)</li>
<li>Update TanStack and Code Forge logos (39ed8c82b)</li>
<li>Track assets referenced from SCSS URLs (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1916">#1916</a>)
(cbe13bf87)</li>
<li>Consume only the default export of inline dynamic import loaders
(resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1915">#1915</a>)
(b90a299e2)</li>
<li>Update ecosystem snapshots (3f6cdee15)</li>
<li>Update dependencies (6cdb3a6ac)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/69c6006ee5938d7e2a6157222a24799eb639ec5c"><code>69c6006</code></a>
Release knip@6.31.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/5aadf47a22bdfd43bb8ad4082768e07fe37fc5ca"><code>5aadf47</code></a>
Use Array.from in Marko compiler</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/c6b151642701f9b4864ddb5ea8e40580623f59d1"><code>c6b1516</code></a>
feat: Add basic support for Marko 6 and 5 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1914">#1914</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/837e52ca8601813b6bbbae13f6e7a614449de58a"><code>837e52c</code></a>
Release knip@6.30.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/6cdb3a6ac1f221d594fb4e71da00e80e6c2a90e6"><code>6cdb3a6</code></a>
Update dependencies</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/b90a299e2461f618f065cf3cfcededb1b4a4abc0"><code>b90a299</code></a>
Consume only the default export of inline dynamic import loaders
(resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1915">#1915</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/cbe13bf87d80f1d458453b913e5408c22413c11f"><code>cbe13bf</code></a>
Track assets referenced from SCSS URLs (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1916">#1916</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/87d713e5288820096bb675a283d873fb3af486e8"><code>87d713e</code></a>
Analyze published type dependencies (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1909">#1909</a>)
(<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1912">#1912</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/360a47157ec850064da0f946ff233d00e07f87c7"><code>360a471</code></a>
Add ES2023 array types to TypeScript 5 CI</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/3f61542f6eaf07b526d76e5f4f0293ae059ad32f"><code>3f61542</code></a>
Update dependencies</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.31.0/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.61.0 to 0.62.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md">oxfmt's
changelog</a>.</em></p>
<blockquote>
<h2>[0.62.0] - 2026-08-03</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>e6e584b oxfmt: Type jsdoc. enum options (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25008">#25008</a>)
(leaysgur)</li>
</ul>
<h2>[0.59.0] - 2026-07-13</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>415fe1e oxfmt: Error on ignorePatterns that cannot match files
outside the config directory (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/24286">#24286</a>)
(leaysgur)</li>
</ul>
<h2>[0.55.0] - 2026-06-15</h2>
<h3>🚀 Features</h3>
<ul>
<li>9a2788b linter/unicorn: Implement <code>prefer-export-from</code>
rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22935">#22935</a>)
(AliceLanniste)</li>
</ul>
<h2>[0.54.0] - 2026-06-08</h2>
<h3>📚 Documentation</h3>
<ul>
<li>dadafe3 oxlint, oxfmt: Mention migrate skills in npm READMEs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22965">#22965</a>)
(Boshen)</li>
<li>f88961a oxfmt: Annotate each config option with supported languages
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22953">#22953</a>)
(leaysgur)</li>
</ul>
<h2>[0.52.0] - 2026-05-26</h2>
<h3>🚀 Features</h3>
<ul>
<li>16b8058 oxfmt: Support <code>vite-plus/resolveConfig</code> for
vite.config.ts (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22454">#22454</a>)
(leaysgur)</li>
</ul>
<h2>[0.50.0] - 2026-05-15</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>43b9978 formatter/sort_imports: Treat subpath imports as internal
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/22440">#22440</a>)
(leaysgur)</li>
</ul>
<h2>[0.49.0] - 2026-05-11</h2>
<h3>🚀 Features</h3>
<ul>
<li>6e8e818 oxfmt: Experimental .svelte support (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/21700">#21700</a>)
(leaysgur)</li>
</ul>
<h2>[0.45.0] - 2026-04-13</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>50c389b oxfmt: Support <code>.editorconfig</code>
<code>quote_type</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/20989">#20989</a>)
(leaysgur)</li>
</ul>
<h2>[0.44.0] - 2026-04-06</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/9a423f2f485b79c2353c49442c0c7f60f900261d"><code>9a423f2</code></a>
release(apps): oxlint v1.77.0 &amp;&amp; oxfmt v0.62.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25251">#25251</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/e6e584b9bee0f85f45874854c79dbf23ae6e527a"><code>e6e584b</code></a>
fix(oxfmt): type jsdoc. enum options (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/25008">#25008</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.62.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.76.0 to 1.77.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">oxlint's
releases</a>.</em></p>
<blockquote>
<h2>oxlint v1.27.0 &amp;&amp; oxfmt v0.12.0</h2>
<h1>Oxlint v1.27.0</h1>
<h3>🚀 Features</h3>
<ul>
<li>222a8f0 linter/plugins: Implement
<code>SourceCode#isSpaceBetween</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15498">#15498</a>)
(overlookmotel)</li>
<li>2f9735d linter/plugins: Implement
<code>context.languageOptions</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15486">#15486</a>)
(overlookmotel)</li>
<li>bc731ff linter/plugins: Stub out all <code>Context</code> APIs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15479">#15479</a>)
(overlookmotel)</li>
<li>5822cb4 linter/plugins: Add <code>extend</code> method to
<code>FILE_CONTEXT</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15477">#15477</a>)
(overlookmotel)</li>
<li>7b1e6f3 apps: Add pure rust binaries and release to github (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15469">#15469</a>)
(Boshen)</li>
<li>2a89b43 linter: Introduce debug assertions after fixes to assert
validity (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15389">#15389</a>)
(camc314)</li>
<li>ad3c45a editor: Add <code>oxc.path.node</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15040">#15040</a>)
(Sysix)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>6f3cd77 linter/no-var: Incorrect warning for blocks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15504">#15504</a>)
(Hamir Mahal)</li>
<li>6957fb9 linter/plugins: Do not allow access to
<code>Context#id</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15489">#15489</a>)
(overlookmotel)</li>
<li>7409630 linter/plugins: Allow access to <code>cwd</code> in
<code>createOnce</code> in ESLint interop mode (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15488">#15488</a>)
(overlookmotel)</li>
<li>732205e parser: Reject <code>using</code> / <code>await using</code>
in a switch <code>case</code> / <code>default</code> clause (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15225">#15225</a>)
(sapphi-red)</li>
<li>a17ca32 linter/plugins: Replace <code>Context</code> class (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15448">#15448</a>)
(overlookmotel)</li>
<li>ecf2f7b language_server: Fail gracefully when tsgolint executable
not found (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15436">#15436</a>)
(camc314)</li>
<li>3c8d3a7 lang-server: Improve logging in failure case for tsgolint
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15299">#15299</a>)
(camc314)</li>
<li>ef71410 linter: Use jsx if source type is JS in fix debug assertion
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15434">#15434</a>)
(camc314)</li>
<li>e32bbf6 linter/no-var: Handle TypeScript declare keyword in fixer
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15426">#15426</a>)
(camc314)</li>
<li>6565dbe linter/switch-case-braces: Skip comments when searching for
<code>:</code> token (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15425">#15425</a>)
(camc314)</li>
<li>85bd19a linter/prefer-class-fields: Insert value after type
annotation in fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15423">#15423</a>)
(camc314)</li>
<li>fde753e linter/plugins: Block access to
<code>context.settings</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15394">#15394</a>)
(overlookmotel)</li>
<li>ddd9f9f linter/forward-ref-uses-ref: Dont suggest removing wrapper
in invalid positions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15388">#15388</a>)
(camc314)</li>
<li>dac2a9c linter/no-template-curly-in-string: Remove fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15387">#15387</a>)
(camc314)</li>
<li>989b8e3 linter/no-var: Only fix to <code>const</code> if the var has
an initializer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15385">#15385</a>)
(camc314)</li>
<li>cc403f5 linter/plugins: Return empty object for unimplemented
parserServices (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15364">#15364</a>)
(magic-akari)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>25d577e language_server: Start tools in parallel (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15500">#15500</a>)
(Sysix)</li>
<li>3c57291 linter/plugins: Optimize loops (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15449">#15449</a>)
(overlookmotel)</li>
<li>3166233 linter/plugins: Remove <code>Arc</code>s (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15431">#15431</a>)
(overlookmotel)</li>
<li>9de1322 linter/plugins: Lazily deserialize settings JSON (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15395">#15395</a>)
(overlookmotel)</li>
<li>3049ec2 linter/plugins: Optimize <code>deepFreezeSettings</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15392">#15392</a>)
(overlookmotel)</li>
<li>444ebfd linter/plugins: Use single object for
<code>parserServices</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15378">#15378</a>)
(overlookmotel)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>97d2104 linter: Update comment in lint.rs about default value for
tsconfig path (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15530">#15530</a>)
(Connor Shea)</li>
<li>2c6bd9e linter: Always refer as &quot;ES2015&quot; instead of
&quot;ES6&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15411">#15411</a>)
(sapphi-red)</li>
<li>a0c5203 linter/import/named: Update &quot;ES7&quot; comment in
examples (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15410">#15410</a>)
(sapphi-red)</li>
<li>3dc24b5 linter,minifier: Always refer as &quot;ES Modules&quot;
instead of &quot;ES6 Modules&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15409">#15409</a>)
(sapphi-red)</li>
<li>2ad77fb linter/no-this-before-super: Correct &quot;Why is this
bad?&quot; section (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15408">#15408</a>)
(sapphi-red)</li>
<li>57f0ce1 linter: Add backquotes where appropriate (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15407">#15407</a>)
(sapphi-red)</li>
</ul>
<h1>Oxfmt v0.12.0</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md">oxlint's
changelog</a>.</em></p>
<blockquote>
<h2>[1.77.0] - 2026-08-03</h2>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>5c0fa61 linter/eslint/no-warning-comments: Unify config structs and
remove manual options docs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25151">#25151</a>)
(Mikhail Baev)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>9dc7756 linter/typescript/no-unnecessary-condition: Clarify options
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25110">#25110</a>)
(camc314)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/9a423f2f485b79c2353c49442c0c7f60f900261d"><code>9a423f2</code></a>
release(apps): oxlint v1.77.0 &amp;&amp; oxfmt v0.62.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25251">#25251</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/5c0fa61ddbd098fea5101dff356dc9b67adbee95"><code>5c0fa61</code></a>
fix(linter/eslint/no-warning-comments): unify config structs and remove
manua...</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/9dc77567291db6ebd0641284f80bf50163c6a257"><code>9dc7756</code></a>
docs(linter/typescript/no-unnecessary-condition): clarify options (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/25110">#25110</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.77.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />

Updates `vite` from 8.1.5 to 8.2.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/releases">vite's
releases</a>.</em></p>
<blockquote>
<h2>create-vite@8.2.0</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/create-vite@8.2.0/packages/create-vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>plugin-legacy@8.2.0</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/plugin-legacy@8.2.0/packages/plugin-legacy/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v8.2.0</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.2.0/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v8.2.0-beta.0</h2>
<p>Please refer to <a
href="https://github.com/vitejs/vite/blob/v8.2.0-beta.0/packages/vite/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md">vite's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/vitejs/vite/compare/v8.2.0-beta.0...v8.2.0">8.2.0</a>
(2026-07-30)</h2>
<h3>Features</h3>
<ul>
<li>add <code>input</code> to <code>server.fs.allow</code> (<a
href="https://redirect.github.com/vitejs/vite/issues/23035">#23035</a>)
(<a
href="https://github.com/vitejs/vite/commit/95a3cdab83e1125b03d2e8dd942fb6b64209e5fa">95a3cda</a>)</li>
<li><strong>bundled-dev:</strong> reload once after rebuild instead of
via the fallback page (<a
href="https://redirect.github.com/vitejs/vite/issues/23106">#23106</a>)
(<a
href="https://github.com/vitejs/vite/commit/b24381d741941b9ce2b1c07db62cc5f4d7bad981">b24381d</a>)</li>
<li><strong>bundled-dev:</strong> support worker file update accepted by
HMR (<a
href="https://redirect.github.com/vitejs/vite/issues/23068">#23068</a>)
(<a
href="https://github.com/vitejs/vite/commit/0d04351fdc12258c75b9f1cda5780fdb836ed0ef">0d04351</a>)</li>
<li><strong>config:</strong> include column in config incompatibility
location (<a
href="https://redirect.github.com/vitejs/vite/issues/23064">#23064</a>)
(<a
href="https://github.com/vitejs/vite/commit/8a245726944ed29225920d49be77c33c6e03afc8">8a24572</a>)</li>
<li><strong>dev:</strong> resolve interface name for explicit host in
network URLs (<a
href="https://redirect.github.com/vitejs/vite/issues/22965">#22965</a>)
(<a
href="https://github.com/vitejs/vite/commit/3ac77d9dd742968961af38a5a91ed6b061ceda7d">3ac77d9</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>bundledDev:</strong> print build errors to the terminal when
an HMR update fails (<a
href="https://redirect.github.com/vitejs/vite/issues/23024">#23024</a>)
(<a
href="https://github.com/vitejs/vite/commit/41c465896e8b11b1eb9c5fbdafbdcc528e189a2c">41c4658</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/23069">#23069</a>)
(<a
href="https://github.com/vitejs/vite/commit/4c07b74416f859d7e8bdace13409ef2d080edf76">4c07b74</a>)</li>
<li><strong>hmr:</strong> preserve environment snapshot during server
restart (<a
href="https://redirect.github.com/vitejs/vite/issues/22992">#22992</a>)
(<a
href="https://github.com/vitejs/vite/commit/b1186c36d06bb94941c58e8272fc4acb8512c93b">b1186c3</a>)</li>
<li><strong>importAnalysis:</strong> interop imports injected into
optimized dep files by plugins (<a
href="https://redirect.github.com/vitejs/vite/issues/23029">#23029</a>)
(<a
href="https://github.com/vitejs/vite/commit/8c2a87d41fb24536e59643351758084cde4d0dd7">8c2a87d</a>)</li>
<li><strong>module-runner:</strong> keep stack trace interception
working when <code>Object.prototype</code> is frozen (<a
href="https://redirect.github.com/vitejs/vite/issues/23073">#23073</a>)
(<a
href="https://github.com/vitejs/vite/commit/599c5b02a8b6879b05ede988020f1331e877aaea">599c5b0</a>)</li>
<li><strong>server:</strong> strip base in indexHtml module graph lookup
(<a
href="https://redirect.github.com/vitejs/vite/issues/22932">#22932</a>)
(<a
href="https://github.com/vitejs/vite/commit/fa005d19af5d847931c6dbefc63841c137383e6c">fa005d1</a>)</li>
<li>support resolving top-level input option with plugins (<a
href="https://redirect.github.com/vitejs/vite/issues/23101">#23101</a>)
(<a
href="https://github.com/vitejs/vite/commit/41df81a6a4c3eef08f7a9a8ac9530cd136c0eafa">41df81a</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li><strong>config:</strong> correct cacheDir default fallback
description (<a
href="https://redirect.github.com/vitejs/vite/issues/23060">#23060</a>)
(<a
href="https://github.com/vitejs/vite/commit/aafa103af5d71fb59d7c3dd617d0cbef3b222f1f">aafa103</a>)</li>
</ul>
<h3>Tests</h3>
<ul>
<li>config CJS module vars in ESM case (<a
href="https://redirect.github.com/vitejs/vite/issues/23010">#23010</a>)
(<a
href="https://github.com/vitejs/vite/commit/d8cd38830251b95fd7dddcd0eee0ce94cc61c2f4">d8cd388</a>)</li>
</ul>
<h2><a
href="https://github.com/vitejs/vite/compare/v8.1.5...v8.2.0-beta.0">8.2.0-beta.0</a>
(2026-07-22)</h2>
<h3>Features</h3>
<ul>
<li>add <code>input</code> option (<a
href="https://redirect.github.com/vitejs/vite/issues/22642">#22642</a>)
(<a
href="https://github.com/vitejs/vite/commit/9beae37d7221b25463a011feb40b0303ca328d87">9beae37</a>)</li>
<li><strong>config:</strong> warn features incompatible with native
loader in bundle loader (<a
href="https://redirect.github.com/vitejs/vite/issues/22850">#22850</a>)
(<a
href="https://github.com/vitejs/vite/commit/05302b07267f6b4f9dbeac5b1d73fcc3dc06d730">05302b0</a>)</li>
<li><strong>css:</strong> export PostCSS config type for type-safe
configs (<a
href="https://redirect.github.com/vitejs/vite/issues/22792">#22792</a>)
(<a
href="https://github.com/vitejs/vite/commit/302c755a8125b9a26214e3b413922b5513e41981">302c755</a>)</li>
<li><strong>dev:</strong> label network URLs with their interface name
(<a
href="https://redirect.github.com/vitejs/vite/issues/22830">#22830</a>)
(<a
href="https://github.com/vitejs/vite/commit/78accc42a5b8887d9df624f7d4a934d3ead677d1">78accc4</a>)</li>
<li><strong>optimizer:</strong> support aube lockfile (<a
href="https://redirect.github.com/vitejs/vite/issues/22813">#22813</a>)
(<a
href="https://github.com/vitejs/vite/commit/6319827116c5be2a19c1b91c84ba3d38ad26a41c">6319827</a>)</li>
<li><strong>optimizer:</strong> support nub lockfile (<a
href="https://redirect.github.com/vitejs/vite/issues/22891">#22891</a>)
(<a
href="https://github.com/vitejs/vite/commit/65d3604f6fdbfcf6e86244d7fe3c1ca86acae701">65d3604</a>)</li>
<li>update rolldown-related dependencies and use client-side HMR in
bundled-dev (<a
href="https://redirect.github.com/vitejs/vite/issues/22961">#22961</a>)
(<a
href="https://github.com/vitejs/vite/commit/960e9efbc1372000caac46cc2f123cef4824e2bb">960e9ef</a>)</li>
<li><strong>wasm:</strong> expand test suite, unwrap WebAssembly.Global
and enable js-string builtins (<a
href="https://redirect.github.com/vitejs/vite/issues/22674">#22674</a>)
(<a
href="https://github.com/vitejs/vite/commit/9e79b51579457a9af4fa623b68a0bfabbf38010b">9e79b51</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>build:</strong> map CSS chunks in chunk import maps (fix <a
href="https://redirect.github.com/vitejs/vite/issues/22946">#22946</a>)
(<a
href="https://redirect.github.com/vitejs/vite/issues/22947">#22947</a>)
(<a
href="https://github.com/vitejs/vite/commit/e16ff3a1199293ac9cdfa6132c08fdea162215f3">e16ff3a</a>)</li>
<li><strong>config:</strong> exclude virtual modules from native config
compat check (<a
href="https://redirect.github.com/vitejs/vite/issues/22979">#22979</a>)
(<a
href="https://github.com/vitejs/vite/commit/2ced1fe4e4e480ed78cb7aa5c78319e57bfa7783">2ced1fe</a>)</li>
<li><strong>css:</strong> rewrite urls in OnceExit-injected content (<a
href="https://redirect.github.com/vitejs/vite/issues/22983">#22983</a>)
(<a
href="https://github.com/vitejs/vite/commit/abb793e18c92592c21fbb8e1f3fc450b5839f04f">abb793e</a>)</li>
<li><strong>deps:</strong> update all non-major dependencies (<a
href="https://redirect.github.com/vitejs/vite/issues/22985">#22985</a>)
(<a
href="https://github.com/vitejs/vite/commit/04f345b37064cd0bba6447eb5c32be5c22162f3d">04f345b</a>)</li>
<li><strong>deps:</strong> update dependency magic-string to v1 (<a
href="https://redirect.github.com/vitejs/vite/issues/22998">#22998</a>)
(<a
href="https://github.com/vitejs/vite/commit/c60b4d7cdb85b7d4f78671cdcfb863e5f8b66bb7">c60b4d7</a>)</li>
<li><strong>hmr:</strong> remove hot data after prune (<a
href="https://redirect.github.com/vitejs/vite/issues/23002">#23002</a>)
(<a
href="https://github.com/vitejs/vite/commit/be9631658f5191ee5c5665e780239d42a330280a">be96316</a>)</li>
<li>resolve root to real path (<a
href="https://redirect.github.com/vitejs/vite/issues/22832">#22832</a>)
(<a
href="https://github.com/vitejs/vite/commit/55bba7bbd9de40d031360e4408fe91bff5b29ec9">55bba7b</a>)</li>
</ul>
<h3>Performance Improvements</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite/commit/24a611f1c83a976d32262628d42f683609746635"><code>24a611f</code></a>
release: v7.2.4</li>
<li><a
href="https://github.com/vitejs/vite/commit/2d66b7b14aa6dfd62f3d6a59ee8382ed5ca6fd32"><code>2d66b7b</code></a>
fix: revert &quot;perf(deps): replace debug with obug (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21107">#21107</a>)&quot;</li>
<li><a
href="https://github.com/vitejs/vite/commit/a668014dba377c2b82a32d8124f1761e9ea74f82"><code>a668014</code></a>
release: v7.2.3</li>
<li><a
href="https://github.com/vitejs/vite/commit/acfe939e1f7c303c34b0b39b883cc302da767fa2"><code>acfe939</code></a>
perf(deps): replace debug with obug (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21107">#21107</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/4f8171eb3046bd70c83964689897dab4c6b58bc0"><code>4f8171e</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21128">#21128</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/50297208452241061cb44d09a4bbdf77a11ac01e"><code>5029720</code></a>
chore(deps): update rolldown-related dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21127">#21127</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/5909efd8fbfd1bf1eab65427aea0613124b2797a"><code>5909efd</code></a>
fix: allow multiple <code>bindCLIShortcuts</code> calls with shortcut
merging (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21103">#21103</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/39a0a15fd24ed37257c48b795097a3794e54d255"><code>39a0a15</code></a>
chore(deps): update rolldown-related dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21095">#21095</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/6a34ac3422686e7cf7cc9a25d299cb8e5a8d92a0"><code>6a34ac3</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21096">#21096</a>)</li>
<li><a
href="https://github.com/vitejs/vite/commit/02ceaec45e17bef19159188a28d9196fed1761be"><code>02ceaec</code></a>
chore(deps): update dependency <code>@​rollup/plugin-commonjs</code> to
v29 (<a
href="https://github.com/vitejs/vite/tree/HEAD/packages/vite/issues/21099">#21099</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 01:06:37 -04:00
dependabot[bot] f1de248ed5 Bump the gh-actions group with 4 updates (#1171)
Bumps the gh-actions group with 4 updates:
[docker/login-action](https://github.com/docker/login-action),
[github/codeql-action/init](https://github.com/github/codeql-action),
[github/codeql-action/autobuild](https://github.com/github/codeql-action)
and
[github/codeql-action/analyze](https://github.com/github/codeql-action).

Updates `docker/login-action` from 4.5.2 to 4.6.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.6.0</h2>
<ul>
<li>Harden buildx scoped config path handling by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1059">docker/login-action#1059</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> and
<code>@​aws-sdk/client-ecr-public</code> to 3.1095.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1051">docker/login-action#1051</a></li>
<li>Bump js-yaml from 5.2.1 to 5.2.2 in <a
href="https://redirect.github.com/docker/login-action/pull/1057">docker/login-action#1057</a></li>
<li>Bump postcss from 8.5.10 to 8.5.22 in <a
href="https://redirect.github.com/docker/login-action/pull/1056">docker/login-action#1056</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.5.2...v4.6.0">https://github.com/docker/login-action/compare/v4.5.2...v4.6.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/dbcb813823bdd20940b903addbd779551569679f"><code>dbcb813</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1051">#1051</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependen...</li>
<li><a
href="https://github.com/docker/login-action/commit/5bcb015ee6ec720ecdeaef2dc1164122e9b209fc"><code>5bcb015</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/b30b2f2d3196c1714318ba0c3c3bec211d949752"><code>b30b2f2</code></a>
build(deps): bump the aws-sdk-dependencies group across 1 directory with
2 up...</li>
<li><a
href="https://github.com/docker/login-action/commit/9087f1e6d666fe0292409e3c819680c18526e108"><code>9087f1e</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1057">#1057</a>
from docker/dependabot/npm_and_yarn/js-yaml-5.2.2</li>
<li><a
href="https://github.com/docker/login-action/commit/0009830ea169ca16c24c0ea4cac1c325bfa3aee4"><code>0009830</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/23255232d3e43c8f0052d9a0dba82a515a88ce92"><code>2325523</code></a>
build(deps): bump js-yaml from 5.2.1 to 5.2.2</li>
<li><a
href="https://github.com/docker/login-action/commit/4ec1d4a769e8b05a89a7396551dc38b329211688"><code>4ec1d4a</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1056">#1056</a>
from docker/dependabot/npm_and_yarn/postcss-8.5.22</li>
<li><a
href="https://github.com/docker/login-action/commit/5fc99ba47bca274c5a499688f71c7ea79c0ea1b3"><code>5fc99ba</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1053">#1053</a>
from docker/dependabot/github_actions/aws-actions/co...</li>
<li><a
href="https://github.com/docker/login-action/commit/e512bd59d16c53d79ea5c0f0e345fe554453c4bb"><code>e512bd5</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1052">#1052</a>
from docker/dependabot/github_actions/codeql-actions...</li>
<li><a
href="https://github.com/docker/login-action/commit/a146c91b8f371700d323bae808af7cbdc2766ed5"><code>a146c91</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1059">#1059</a>
from crazy-max/harden-buildx-scope-paths</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/371161bbe7024a29a25c5e19bfcbc0804fe9ad2c...dbcb813823bdd20940b903addbd779551569679f">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/init` from 4.37.3 to 4.37.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.6</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>v4.37.5</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>v4.37.4</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/5595ccaf912efad79be6eef63a5619ff05969be3"><code>5595cca</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4071">#4071</a>
from github/update-v4.37.6-6a9359a1b</li>
<li><a
href="https://github.com/github/codeql-action/commit/ec9c75796a7f2cee5af0c5ffa0b81dc3bb58754b"><code>ec9c757</code></a>
Add change note for PR 4070</li>
<li><a
href="https://github.com/github/codeql-action/commit/45c8742e17cbd668814137f95e605d925b8722a2"><code>45c8742</code></a>
Update changelog for v4.37.6</li>
<li><a
href="https://github.com/github/codeql-action/commit/6a9359a1bd054c53cae7bb737bd8d796cfbf3014"><code>6a9359a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4070">#4070</a>
from github/mbg/remote-address/change-file-default</li>
<li><a
href="https://github.com/github/codeql-action/commit/065cdc0394d424981db720df63ebc570e41b775f"><code>065cdc0</code></a>
Change <code>DEFAULT_CONFIG_FILE_NAME</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/f99dd5aeee9cf92e92d0c700cb0aa7afd7bbf431"><code>f99dd5a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4066">#4066</a>
from github/dependabot/npm_and_yarn/js-yaml-5.2.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/1804b211a343d69a6584d26fb3a68a8fe6ca39d4"><code>1804b21</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4068">#4068</a>
from github/mergeback/v4.37.5-to-main-d1ba80a1</li>
<li><a
href="https://github.com/github/codeql-action/commit/3020a2f46286abb1704269b22ada83bd0e81c64f"><code>3020a2f</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/93c3a5a40b7affbf8ea6a480767ed0db8e8d3c5c"><code>93c3a5a</code></a>
Update changelog and version after v4.37.5</li>
<li><a
href="https://github.com/github/codeql-action/commit/d1ba80a13dd99fba24a470575428917156a28b43"><code>d1ba80a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4067">#4067</a>
from github/update-v4.37.5-1cd4d01d5</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...5595ccaf912efad79be6eef63a5619ff05969be3">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.37.3 to 4.37.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/autobuild's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.6</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>v4.37.5</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>v4.37.4</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/autobuild's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/5595ccaf912efad79be6eef63a5619ff05969be3"><code>5595cca</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4071">#4071</a>
from github/update-v4.37.6-6a9359a1b</li>
<li><a
href="https://github.com/github/codeql-action/commit/ec9c75796a7f2cee5af0c5ffa0b81dc3bb58754b"><code>ec9c757</code></a>
Add change note for PR 4070</li>
<li><a
href="https://github.com/github/codeql-action/commit/45c8742e17cbd668814137f95e605d925b8722a2"><code>45c8742</code></a>
Update changelog for v4.37.6</li>
<li><a
href="https://github.com/github/codeql-action/commit/6a9359a1bd054c53cae7bb737bd8d796cfbf3014"><code>6a9359a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4070">#4070</a>
from github/mbg/remote-address/change-file-default</li>
<li><a
href="https://github.com/github/codeql-action/commit/065cdc0394d424981db720df63ebc570e41b775f"><code>065cdc0</code></a>
Change <code>DEFAULT_CONFIG_FILE_NAME</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/f99dd5aeee9cf92e92d0c700cb0aa7afd7bbf431"><code>f99dd5a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4066">#4066</a>
from github/dependabot/npm_and_yarn/js-yaml-5.2.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/1804b211a343d69a6584d26fb3a68a8fe6ca39d4"><code>1804b21</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4068">#4068</a>
from github/mergeback/v4.37.5-to-main-d1ba80a1</li>
<li><a
href="https://github.com/github/codeql-action/commit/3020a2f46286abb1704269b22ada83bd0e81c64f"><code>3020a2f</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/93c3a5a40b7affbf8ea6a480767ed0db8e8d3c5c"><code>93c3a5a</code></a>
Update changelog and version after v4.37.5</li>
<li><a
href="https://github.com/github/codeql-action/commit/d1ba80a13dd99fba24a470575428917156a28b43"><code>d1ba80a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4067">#4067</a>
from github/update-v4.37.5-1cd4d01d5</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...5595ccaf912efad79be6eef63a5619ff05969be3">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.6</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>v4.37.5</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>v4.37.4</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.6 - 04 Aug 2026</h2>
<ul>
<li>Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
<code>.github/codeql-config.yml</code> to align it with the suggested
path that is used elsewhere. <a
href="https://redirect.github.com/github/codeql-action/pull/4070">#4070</a></li>
</ul>
<h2>4.37.5 - 03 Aug 2026</h2>
<ul>
<li>Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the <code>init</code> Action instead
of falling back to downloading the bundle before extracting it. <a
href="https://redirect.github.com/github/codeql-action/pull/4061">#4061</a></li>
</ul>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/5595ccaf912efad79be6eef63a5619ff05969be3"><code>5595cca</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4071">#4071</a>
from github/update-v4.37.6-6a9359a1b</li>
<li><a
href="https://github.com/github/codeql-action/commit/ec9c75796a7f2cee5af0c5ffa0b81dc3bb58754b"><code>ec9c757</code></a>
Add change note for PR 4070</li>
<li><a
href="https://github.com/github/codeql-action/commit/45c8742e17cbd668814137f95e605d925b8722a2"><code>45c8742</code></a>
Update changelog for v4.37.6</li>
<li><a
href="https://github.com/github/codeql-action/commit/6a9359a1bd054c53cae7bb737bd8d796cfbf3014"><code>6a9359a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4070">#4070</a>
from github/mbg/remote-address/change-file-default</li>
<li><a
href="https://github.com/github/codeql-action/commit/065cdc0394d424981db720df63ebc570e41b775f"><code>065cdc0</code></a>
Change <code>DEFAULT_CONFIG_FILE_NAME</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/f99dd5aeee9cf92e92d0c700cb0aa7afd7bbf431"><code>f99dd5a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4066">#4066</a>
from github/dependabot/npm_and_yarn/js-yaml-5.2.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/1804b211a343d69a6584d26fb3a68a8fe6ca39d4"><code>1804b21</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4068">#4068</a>
from github/mergeback/v4.37.5-to-main-d1ba80a1</li>
<li><a
href="https://github.com/github/codeql-action/commit/3020a2f46286abb1704269b22ada83bd0e81c64f"><code>3020a2f</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/93c3a5a40b7affbf8ea6a480767ed0db8e8d3c5c"><code>93c3a5a</code></a>
Update changelog and version after v4.37.5</li>
<li><a
href="https://github.com/github/codeql-action/commit/d1ba80a13dd99fba24a470575428917156a28b43"><code>d1ba80a</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4067">#4067</a>
from github/update-v4.37.5-1cd4d01d5</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...5595ccaf912efad79be6eef63a5619ff05969be3">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 01:06:28 -04:00
dependabot[bot] 78d5d0632a Bump the python-deps group across 1 directory with 5 updates (#1182)
Bumps the python-deps group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [python-socketio](https://github.com/miguelgrinberg/python-socketio) |
`5.16.3` | `5.16.4` |
| [rarfile](https://github.com/markokr/rarfile) | `4.4` | `4.5` |
| [seleniumbase](https://github.com/seleniumbase/SeleniumBase) |
`4.51.8` | `4.51.11` |
| [prek](https://github.com/j178/prek) | `0.4.11` | `0.4.12` |
| [ruff](https://github.com/astral-sh/ruff) | `0.16.1` | `0.16.2` |


Updates `python-socketio` from 5.16.3 to 5.16.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/miguelgrinberg/python-socketio/releases">python-socketio's
releases</a>.</em></p>
<blockquote>
<h2>Release 5.16.4</h2>
<p>See <a
href="https://github.com/miguelgrinberg/python-socketio/blob/main/CHANGES.md">CHANGES.md</a>
for release notes.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/miguelgrinberg/python-socketio/blob/main/CHANGES.md">python-socketio's
changelog</a>.</em></p>
<blockquote>
<h1>python-socketio change log</h1>
<p><strong>Release 5.16.4</strong> - 2026-08-07</p>
<ul>
<li>Delete incomplete binary packet when client disconnects (<a
href="https://github.com/miguelgrinberg/python-socketio/commit/4bec3ef87bcfd6ab5b94cd3ac09d873283a6960e">commit</a>)</li>
</ul>
<p><strong>Release 5.16.3</strong> - 2026-06-15</p>
<ul>
<li>Catch all exceptions in redis and rabbitmq client managers <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1581">#1581</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/80bb5c9b07e6b53e45f610ec29a24124c539d41b">commit</a>)</li>
</ul>
<p><strong>Release 5.16.2</strong> - 2026-05-21</p>
<ul>
<li>Prevent unnecessary resource allocations <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1574">#1574</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/ca140fe44d0ceb3004073645222abec182d8784b">commit</a>)</li>
<li>Add zizmor to CI builds <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1570">#1570</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/664dc27ec6f34179ed1724430eda3520627fc642">commit</a>)</li>
</ul>
<p><strong>Release 5.16.1</strong> - 2026-02-06</p>
<ul>
<li>Use configured JSON module in managers <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1549">#1549</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/6229261ae6e8c01e675097242e333ee84587a544">commit</a>)</li>
<li>Admin UI fixes: remove duplicate tasks, report transport upgrades
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/1c2eab13a92fac9e43663eb0b5f099eb1c40ea5b">commit</a>)</li>
<li>Switch to Furo documentation template (<a
href="https://github.com/miguelgrinberg/python-socketio/commit/add47d8c7abca697a2804141bbf29bfb095f7d5e">commit</a>)</li>
<li>Add Python free-threading to CI <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1554">#1554</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/ccdd2004a038ae4b8171a05120c5d0787332f7ee">commit</a>)</li>
</ul>
<p><strong>Release 5.16.0</strong> - 2025-12-24</p>
<ul>
<li>Address deprecation warnings (<a
href="https://github.com/miguelgrinberg/python-socketio/commit/b235699d9b06564753c570b76055997e9d62a938">commit</a>)</li>
<li>Drop Python 3.8 and 3.9 from CI builds (<a
href="https://github.com/miguelgrinberg/python-socketio/commit/d0728d2f74538762dd551fa9cd0cd1fd5aedfa37">commit</a>)</li>
</ul>
<p><strong>Release 5.15.1</strong> - 2025-12-16</p>
<ul>
<li>Restore support multiple arguments via pubsub emits <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1540">#1540</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/c279f26bb8c9887c4ca99d4d81ad331c4844438c">commit</a>)</li>
</ul>
<p><strong>Release 5.15.0</strong> - 2025-11-22</p>
<ul>
<li>Retry initial Redis connection <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1536">#1534</a>
([commit <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1">#1</a>](<a
href="https://github.com/miguelgrinberg/python-socketio/commit/1e903e173a2d7b04599c4f7f9630c1abbb531fad">https://github.com/miguelgrinberg/python-socketio/commit/1e903e173a2d7b04599c4f7f9630c1abbb531fad</a>)
[commit <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/2">#2</a>](<a
href="https://github.com/miguelgrinberg/python-socketio/commit/5e898a9b93526e6e667767e54c60f4c84589989d">https://github.com/miguelgrinberg/python-socketio/commit/5e898a9b93526e6e667767e54c60f4c84589989d</a>))</li>
<li>Correctly regenerate RabbitMQ binding after a connection failure <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1516">#1516</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/c52e93b4a328d98a968bfbdec0cfd598b73ee913">commit</a>)
(thanks <strong>Gritty_dev</strong>!)</li>
<li>Support <code>ext_type</code> in the <code>MsgPackPacket</code>
class <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1521">#1521</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/208925344a48485d2cd56e40eb74266c3bcb5311">commit</a>)</li>
<li>Support sending <code>bytesarray</code>s when using pub/sub managers
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/6c9b9974f72e2efdf62407ecab24ee6995448098">commit</a>)</li>
<li>Fix typos in documentation <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1520">#1520</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/db3f1c2a0105c30cb833ddfca8f05fe4320468fd">commit</a>)
(thanks <strong>Lê Nam Khánh</strong>!)</li>
<li>Improvements to the logging documentation (<a
href="https://github.com/miguelgrinberg/python-socketio/commit/b423d0e38eef559b7e81acb7e32059de305f982c">commit</a>)</li>
</ul>
<p><strong>Release 5.14.3</strong> - 2025-10-29</p>
<ul>
<li>Support Python's native <code>ConnectionRefusedError</code>
exception to reject a connection <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1515">#1515</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/f3b18bde3f16437b223491d4c3e440ea37105fe3">commit</a>)</li>
<li>Push binary data to the aiopika client manager <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1514">#1514</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/194e1b7f277b5f72e1de78d3f614e7b8b6c788ac">commit</a>)</li>
</ul>
<p><strong>Release 5.14.2</strong> - 2025-10-15</p>
<ul>
<li>Restore binary message support in message queue setups <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1509">#1509</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/bab4a10f48aaae11d7f832ebe5c30ad3f85d31b3">commit</a>)</li>
<li>Fix formatting of client connection error <a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1507">#1507</a>
(<a
href="https://github.com/miguelgrinberg/python-socketio/commit/f298c9b54d76ab09ff72935937e1b9575bc45ffd">commit</a>)</li>
<li>Add 3.14 and pypy-3.11 CI tasks (<a
href="https://github.com/miguelgrinberg/python-socketio/commit/1f4cd3b025c294f25208ec3c05b5f8df6209e403">commit</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/d0cf60952281c0b8f41a7a73f01da8129da89032"><code>d0cf609</code></a>
Release 5.16.4</li>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/4bec3ef87bcfd6ab5b94cd3ac09d873283a6960e"><code>4bec3ef</code></a>
Delete incomplete binary packet when client disconnects</li>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/1e44ea3be1258ca2344872a98a4840743738080e"><code>1e44ea3</code></a>
Bump socket.io-parser from 4.2.6 to 4.2.7 in /examples/client/javascript
(<a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/15">#15</a>...</li>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/a8568a449f28a81decb2f3d7adc7b0a2af88e12c"><code>a8568a4</code></a>
Bump aiohttp from 3.14.1 to 3.14.3 in /examples/server/aiohttp (<a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1591">#1591</a>)
#nolog</li>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/3826ad453ba50b7d6876180a149a5b2fb0a01e73"><code>3826ad4</code></a>
Bump body-parser from 1.20.5 to 1.20.6 in /examples/client/javascript
(<a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1590">#1590</a>)...</li>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/2d21903008b185b66ee644bfe231bb953298e9b1"><code>2d21903</code></a>
Remove failing codecov service</li>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/cb558519ffcc290974478902ceebd7d1a96a220e"><code>cb55851</code></a>
Bump aiohttp from 3.14.0 to 3.14.1 in /examples/server/aiohttp (<a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1584">#1584</a>)
#nolog</li>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/71585b1df83f36029b1ead9c1b4cb42da85192de"><code>71585b1</code></a>
Bump tornado from 6.5.5 to 6.5.7 in /examples/server/tornado (<a
href="https://redirect.github.com/miguelgrinberg/python-socketio/issues/1583">#1583</a>)
#nolog</li>
<li><a
href="https://github.com/miguelgrinberg/python-socketio/commit/02950be3756c3baedd63b5109de9ddac0af10a99"><code>02950be</code></a>
Version 5.16.4.dev0</li>
<li>See full diff in <a
href="https://github.com/miguelgrinberg/python-socketio/compare/v5.16.3...v5.16.4">compare
view</a></li>
</ul>
</details>
<br />

Updates `rarfile` from 4.4 to 4.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/markokr/rarfile/releases">rarfile's
releases</a>.</em></p>
<blockquote>
<h2>rarfile v4.5</h2>
<p>Security fixes:</p>
<ul>
<li>Skip comments that are larger than 256k.</li>
</ul>
<p>Fixes:</p>
<ul>
<li>Truncate filenames at NUL byte.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/markokr/rarfile/blob/master/doc/news.rst">rarfile's
changelog</a>.</em></p>
<blockquote>
<h2>Version 4.5 (2026-08-02)</h2>
<p>Security fixes:</p>
<ul>
<li>Skip comments that are larger than 256k.
[<code>GHSA-94vx-95fq-wwvp
&lt;https://github.com/markokr/rarfile/security/advisories/GHSA-94vx-95fq-wwvp&gt;</code>_]</li>
</ul>
<p>Fixes:</p>
<ul>
<li>Truncate filenames at NUL byte.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/markokr/rarfile/commit/60c8513276ea462c39822db487c10111b9039442"><code>60c8513</code></a>
Refresh apt index</li>
<li><a
href="https://github.com/markokr/rarfile/commit/d62647091dc24bb384788c75b966d9dd4f0deaaf"><code>d626470</code></a>
v4.4</li>
<li><a
href="https://github.com/markokr/rarfile/commit/8321daa5417a62adce0db160510a1cdc43744af0"><code>8321daa</code></a>
Limit comment size</li>
<li><a
href="https://github.com/markokr/rarfile/commit/be6a4a69069852dcc16af3f6ad180e0266cbf15a"><code>be6a4a6</code></a>
Truncate filenames at NUL</li>
<li>See full diff in <a
href="https://github.com/markokr/rarfile/compare/v4.4...v4.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `seleniumbase` from 4.51.8 to 4.51.11
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/seleniumbase/SeleniumBase/releases">seleniumbase's
releases</a>.</em></p>
<blockquote>
<h2>4.51.11 - CDP Mode: Patch 127</h2>
<h2>CDP Mode: Patch 127</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/3ba187938eca01c8582c0c832b5d00c44105b96f">Fix
memory leaks, fix bugs, and update timing</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8372dbc85d60c504c678a738df648791d712aec1">Refresh
Python dependencies</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/710ae0abd46732bd145e578b6a22fb5eecde27b9">Update
examples</a></li>
</ul>
<p>Special thanks to <a
href="https://github.com/crippledgeek"><code>@​crippledgeek</code></a>
(<a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4454">seleniumbase/SeleniumBase#4454</a>
and <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4455">seleniumbase/SeleniumBase#4455</a>)</p>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 127 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4457">seleniumbase/SeleniumBase#4457</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.10...v4.51.11">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.10...v4.51.11</a></p>
<h2>4.51.10 - CDP Mode: Patch 126</h2>
<h2>CDP Mode: Patch 126</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/a7e28ac407fc63afdcfbaadc642b5a97b77a1375">Patch
the vendored version of nest_asyncio for Python 3.14+</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/076956c14a944b25ff191189b32123220e490fff">Update
timing of stealthy CDP Mode clicks</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/0455ee62040c1b5185430035fd851518a9c83b84">Refresh
Python dependencies</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 126 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4453">seleniumbase/SeleniumBase#4453</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.9...v4.51.10">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.9...v4.51.10</a></p>
<h2>4.51.9 - CDP Mode: Patch 125</h2>
<h2>CDP Mode: Patch 125</h2>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9954e1df1c17fd33a95999a78e011506d3e7bcc5">Fix
non-authenticated proxy in the sb_cdp format</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/35bad5b60b1f47a54a129d8aeb01ac0dcabadccd">Refresh
Python dependencies</a></li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/2bac11ba394f8edff0fff591f34400c946c01c62">Update
CDP Mode examples</a></li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>CDP Mode: Patch 125 by <a
href="https://github.com/mdmintz"><code>@​mdmintz</code></a> in <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/pull/4450">seleniumbase/SeleniumBase#4450</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.8...v4.51.9">https://github.com/seleniumbase/SeleniumBase/compare/v4.51.8...v4.51.9</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/85ca8c17da2bb85ca584c19ccc1773f64f0e1f8d"><code>85ca8c1</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4457">#4457</a>
from seleniumbase/cdp-mode-patch-127</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/e5c180747c8fdc7da7990f6c8cf78fc166b5d801"><code>e5c1807</code></a>
Version 4.51.11</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/710ae0abd46732bd145e578b6a22fb5eecde27b9"><code>710ae0a</code></a>
Update examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/8372dbc85d60c504c678a738df648791d712aec1"><code>8372dbc</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/3ba187938eca01c8582c0c832b5d00c44105b96f"><code>3ba1879</code></a>
Fix memory leaks, fix bugs, and update timing</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/58995fc57a3bc2e6169fde7a4b210c557d768e52"><code>58995fc</code></a>
Merge pull request <a
href="https://redirect.github.com/seleniumbase/SeleniumBase/issues/4453">#4453</a>
from seleniumbase/cdp-mode-patch-126</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/9d98315c3989a4739d6e1b2aa1da7d0cb8a56732"><code>9d98315</code></a>
Version 4.51.10</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/b050714d848b370bd7500acfc01010607b6cb6d3"><code>b050714</code></a>
Update CDP Mode examples</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/0455ee62040c1b5185430035fd851518a9c83b84"><code>0455ee6</code></a>
Refresh Python dependencies</li>
<li><a
href="https://github.com/seleniumbase/SeleniumBase/commit/076956c14a944b25ff191189b32123220e490fff"><code>076956c</code></a>
Update timing of stealthy CDP Mode clicks</li>
<li>Additional commits viewable in <a
href="https://github.com/seleniumbase/SeleniumBase/compare/v4.51.8...v4.51.11">compare
view</a></li>
</ul>
</details>
<br />

Updates `prek` from 0.4.11 to 0.4.12
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/releases">prek's
releases</a>.</em></p>
<blockquote>
<h2>0.4.12</h2>
<h2>Release Notes</h2>
<p>Released on 2026-08-03.</p>
<h3>Enhancements</h3>
<ul>
<li>Add <code>--require-group</code> for hook group intersections (<a
href="https://redirect.github.com/j178/prek/pull/2472">#2472</a>)</li>
<li>Align fast-path and builtin pre-commit hooks (<a
href="https://redirect.github.com/j178/prek/pull/2433">#2433</a>)</li>
<li>Do not shuffle file list for verbose output (<a
href="https://redirect.github.com/j178/prek/pull/2431">#2431</a>)</li>
<li>Improve top-level command descriptions (<a
href="https://redirect.github.com/j178/prek/pull/2429">#2429</a>)</li>
<li>Install <code>uv</code> from Astral CDN and drop source racing (<a
href="https://redirect.github.com/j178/prek/pull/2455">#2455</a>)</li>
<li>Make <code>prek install --force</code> bypass external hooks paths
(<a
href="https://redirect.github.com/j178/prek/pull/2437">#2437</a>)</li>
<li>Show builtin hook flags in verbose list output (<a
href="https://redirect.github.com/j178/prek/pull/2427">#2427</a>)</li>
<li>Verify uv release archive checksums (<a
href="https://redirect.github.com/j178/prek/pull/2456">#2456</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Precompute file tags in parallel (<a
href="https://redirect.github.com/j178/prek/pull/2440">#2440</a>)</li>
<li>Skip diffs after known hook modifications (<a
href="https://redirect.github.com/j178/prek/pull/2447">#2447</a>)</li>
<li>Skip worktree diffs for read-only languages (<a
href="https://redirect.github.com/j178/prek/pull/2432">#2432</a>)</li>
<li>Track builtin hook file changes directly (<a
href="https://redirect.github.com/j178/prek/pull/2404">#2404</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Use full object IDs in diff snapshots (<a
href="https://redirect.github.com/j178/prek/pull/2448">#2448</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add a multi-repository configuration example (<a
href="https://redirect.github.com/j178/prek/pull/2434">#2434</a>)</li>
<li>Rewrite benchmark documentation (<a
href="https://redirect.github.com/j178/prek/pull/2469">#2469</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a
href="https://github.com/BitWeaverDev"><code>@​BitWeaverDev</code></a></li>
<li><a
href="https://github.com/allanlewis"><code>@​allanlewis</code></a></li>
</ul>
<h2>Install prek 0.4.12</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/j178/prek/releases/download/v0.4.12/prek-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://github.com/j178/prek/releases/download/v0.4.12/prek-installer.ps1
| iex&quot;
&lt;/tr&gt;&lt;/table&gt; 
</code></pre>
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/j178/prek/blob/master/CHANGELOG.md">prek's
changelog</a>.</em></p>
<blockquote>
<h2>0.4.12</h2>
<p>Released on 2026-08-03.</p>
<h3>Enhancements</h3>
<ul>
<li>Add <code>--require-group</code> for hook group intersections (<a
href="https://redirect.github.com/j178/prek/pull/2472">#2472</a>)</li>
<li>Align fast-path and builtin pre-commit hooks (<a
href="https://redirect.github.com/j178/prek/pull/2433">#2433</a>)</li>
<li>Do not shuffle file list for verbose output (<a
href="https://redirect.github.com/j178/prek/pull/2431">#2431</a>)</li>
<li>Improve top-level command descriptions (<a
href="https://redirect.github.com/j178/prek/pull/2429">#2429</a>)</li>
<li>Install <code>uv</code> from Astral CDN and drop source racing (<a
href="https://redirect.github.com/j178/prek/pull/2455">#2455</a>)</li>
<li>Make <code>prek install --force</code> bypass external hooks paths
(<a
href="https://redirect.github.com/j178/prek/pull/2437">#2437</a>)</li>
<li>Show builtin hook flags in verbose list output (<a
href="https://redirect.github.com/j178/prek/pull/2427">#2427</a>)</li>
<li>Verify uv release archive checksums (<a
href="https://redirect.github.com/j178/prek/pull/2456">#2456</a>)</li>
</ul>
<h3>Performance</h3>
<ul>
<li>Precompute file tags in parallel (<a
href="https://redirect.github.com/j178/prek/pull/2440">#2440</a>)</li>
<li>Skip diffs after known hook modifications (<a
href="https://redirect.github.com/j178/prek/pull/2447">#2447</a>)</li>
<li>Skip worktree diffs for read-only languages (<a
href="https://redirect.github.com/j178/prek/pull/2432">#2432</a>)</li>
<li>Track builtin hook file changes directly (<a
href="https://redirect.github.com/j178/prek/pull/2404">#2404</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Use full object IDs in diff snapshots (<a
href="https://redirect.github.com/j178/prek/pull/2448">#2448</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Add a multi-repository configuration example (<a
href="https://redirect.github.com/j178/prek/pull/2434">#2434</a>)</li>
<li>Rewrite benchmark documentation (<a
href="https://redirect.github.com/j178/prek/pull/2469">#2469</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/j178"><code>@​j178</code></a></li>
<li><a
href="https://github.com/BitWeaverDev"><code>@​BitWeaverDev</code></a></li>
<li><a
href="https://github.com/allanlewis"><code>@​allanlewis</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/j178/prek/commit/728238cf01e7b9e9ee8d1457f20c772fdc8e8426"><code>728238c</code></a>
Bump version to 0.4.12 (<a
href="https://redirect.github.com/j178/prek/issues/2473">#2473</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/e5403ca77cf44eb2d285328f0dd63386efddbddd"><code>e5403ca</code></a>
Add <code>--require-group</code> for hook group intersections (<a
href="https://redirect.github.com/j178/prek/issues/2472">#2472</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/370fc41b6b520b5fa0e34deb80d6415fc4417f7d"><code>370fc41</code></a>
Use ssl-certs to load custom certificates (<a
href="https://redirect.github.com/j178/prek/issues/2470">#2470</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/7cc64cac5f95c71b152406dd63658499ede597fe"><code>7cc64ca</code></a>
Rewrite benchmark documentation (<a
href="https://redirect.github.com/j178/prek/issues/2469">#2469</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/6a81c105d682e48bd5cff68533cf66ec150f19c1"><code>6a81c10</code></a>
Update ubuntu Docker tag to v26 (<a
href="https://redirect.github.com/j178/prek/issues/2466">#2466</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/500a9d4149febbe639b8ac01fe1f8065ba5204ec"><code>500a9d4</code></a>
Run Conda tests sequentially (<a
href="https://redirect.github.com/j178/prek/issues/2468">#2468</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/ebc1c38f41a44995394fb2c0f4ba971763d23be5"><code>ebc1c38</code></a>
Run .NET tests sequentially on Unix (<a
href="https://redirect.github.com/j178/prek/issues/2467">#2467</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/88fec177545361e7b2618b84defc8d24195ad048"><code>88fec17</code></a>
Update Rust crate tokio-util to v0.7.19 (<a
href="https://redirect.github.com/j178/prek/issues/2463">#2463</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/9d9c9f504a62b78c6b2154273d635b080c69e625"><code>9d9c9f5</code></a>
Use full object IDs in diff snapshots (<a
href="https://redirect.github.com/j178/prek/issues/2448">#2448</a>)</li>
<li><a
href="https://github.com/j178/prek/commit/0c7fc8474377fde1811eec879eb0a42010129d80"><code>0c7fc84</code></a>
Update Rust crate tokio to v1.53.1 (<a
href="https://redirect.github.com/j178/prek/issues/2462">#2462</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/j178/prek/compare/v0.4.11...v0.4.12">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.16.1 to 0.16.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/releases">ruff's
releases</a>.</em></p>
<blockquote>
<h2>0.16.2</h2>
<h2>Release Notes</h2>
<p>Released on 2026-08-06.</p>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-pyi</code>] Avoid false positives on
<code>singledispatch</code> functions (<code>PYI041</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27335">#27335</a>)</li>
</ul>
<h3>Server</h3>
<ul>
<li>Register formatting capabilities dynamically to exclude TOML files
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/27332">#27332</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/MeGaGiGaGon"><code>@​MeGaGiGaGon</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/epage"><code>@​epage</code></a></li>
<li><a href="https://github.com/sharkdp"><code>@​sharkdp</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
</ul>
<h2>Install ruff 0.16.2</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy Bypass -c &quot;irm
https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-installer.ps1
| iex&quot;
</code></pre>
<h2>Download ruff 0.16.2</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-aarch64-apple-darwin.tar.gz">ruff-aarch64-apple-darwin.tar.gz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-aarch64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-x86_64-apple-darwin.tar.gz">ruff-x86_64-apple-darwin.tar.gz</a></td>
<td>Intel macOS</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-x86_64-apple-darwin.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-aarch64-pc-windows-msvc.zip">ruff-aarch64-pc-windows-msvc.zip</a></td>
<td>ARM64 Windows</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-aarch64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-i686-pc-windows-msvc.zip">ruff-i686-pc-windows-msvc.zip</a></td>
<td>x86 Windows</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-i686-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-x86_64-pc-windows-msvc.zip">ruff-x86_64-pc-windows-msvc.zip</a></td>
<td>x64 Windows</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-x86_64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-aarch64-unknown-linux-gnu.tar.gz">ruff-aarch64-unknown-linux-gnu.tar.gz</a></td>
<td>ARM64 Linux</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-aarch64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-i686-unknown-linux-gnu.tar.gz">ruff-i686-unknown-linux-gnu.tar.gz</a></td>
<td>x86 Linux</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-i686-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-powerpc64-unknown-linux-gnu.tar.gz">ruff-powerpc64-unknown-linux-gnu.tar.gz</a></td>
<td>PPC64 Linux</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-powerpc64-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-powerpc64le-unknown-linux-gnu.tar.gz">ruff-powerpc64le-unknown-linux-gnu.tar.gz</a></td>
<td>PPC64LE Linux</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-powerpc64le-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-riscv64gc-unknown-linux-gnu.tar.gz">ruff-riscv64gc-unknown-linux-gnu.tar.gz</a></td>
<td>RISCV Linux</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-riscv64gc-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-s390x-unknown-linux-gnu.tar.gz">ruff-s390x-unknown-linux-gnu.tar.gz</a></td>
<td>S390x Linux</td>
<td><a
href="https://releases.astral.sh/github/ruff/releases/download/0.16.2/ruff-s390x-unknown-linux-gnu.tar.gz.sha256">checksum</a></td>
</tr>
</tbody>
</table>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.16.2</h2>
<p>Released on 2026-08-06.</p>
<h3>Bug fixes</h3>
<ul>
<li>[<code>flake8-pyi</code>] Avoid false positives on
<code>singledispatch</code> functions (<code>PYI041</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27335">#27335</a>)</li>
</ul>
<h3>Server</h3>
<ul>
<li>Register formatting capabilities dynamically to exclude TOML files
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/27332">#27332</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a
href="https://github.com/MeGaGiGaGon"><code>@​MeGaGiGaGon</code></a></li>
<li><a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a></li>
<li><a href="https://github.com/epage"><code>@​epage</code></a></li>
<li><a href="https://github.com/sharkdp"><code>@​sharkdp</code></a></li>
<li><a href="https://github.com/ntBre"><code>@​ntBre</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/5b48a040974781ba90b47c8df628f8fd9b6c95dd"><code>5b48a04</code></a>
Bump 0.16.2 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27555">#27555</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/1b9e5fc483b95a01fe02ff104820280b1b32e8ae"><code>1b9e5fc</code></a>
Update Swatinem/rust-cache action to v2.9.2 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27568">#27568</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c4e86fc0394c92a9334ba2eb026c77c21db403be"><code>c4e86fc</code></a>
[ty] Add helper extension methods for half-range and equality
constraints (<a
href="https://redirect.github.com/astral-sh/ruff/issues/2">#2</a>...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/17a00de2e298612201a8fe30790e9399204af1b9"><code>17a00de</code></a>
[ty] Reuse primer commands in memory reports (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27553">#27553</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/6ea296b96923e142eb13af2bc6ad261c280d8eb1"><code>6ea296b</code></a>
[ty] Normalize type labels in structured docstrings (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26923">#26923</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/2fc445f0053f4ec27c717fae0de3671d73c103be"><code>2fc445f</code></a>
[ty] Diagnose invalid <strong>getattr</strong> calls (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27502">#27502</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/22c7823c4e8bffcca97688d8438c9b567d6817d8"><code>22c7823</code></a>
[ty] Enable (but downrank) auto-import completion suggestions from
stub-only ...</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/05160d507f05345a72db9c28ab4edf7c92334819"><code>05160d5</code></a>
[ty] Diagnose invalid descriptor <code>__get__</code> calls (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27400">#27400</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/baea3d0dcec6d6f6d1659321940f3725771c5f45"><code>baea3d0</code></a>
[ty] Expose strict analysis options in the playground (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27543">#27543</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/c88946ebeb92be6d276087f0d528cd6471df4ead"><code>c88946e</code></a>
[ty] Bump ecosystem-analyzer for strict project settings (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27542">#27542</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 01:06:19 -04:00
Guflly 3c51b7cfaa Fix OIDC redirects on custom ports (#1180)
Fixes #1175

Preserves the forwarded host and port when Shelfmark builds OIDC
callback URLs. The reverse-proxy examples now retain custom ports as
well.

Tests:
- `uv run pytest -n 0 -q tests/core/test_proxy_headers.py
tests/core/test_oidc_routes.py`
- `uv run ruff check shelfmark/main.py tests/core/test_proxy_headers.py`
- `uv run ruff format --check shelfmark/main.py
tests/core/test_proxy_headers.py`
2026-08-11 01:05:23 -04:00
FlyinPancakeandCopilot Autofix powered by AI 3a9cff9816 feat(metadata): add Moly.hu metadata provider (#1172)
First of all, I don't know if you even want to merge a scraper-based
metadata provider. I made this just for my use-case. If you'd rather
not, I completely understand it.

An alternative would be adopting [Audiobookshelf's Metadata Provider
API](https://audiobookshelf.org/docs/documentation/community/community-providers)
which I contributed to it for exactly the reason to not have scrapers.

## What

Adds [Moly.hu](https://moly.hu) — the Hungarian community book catalog —
as a metadata provider, following the existing provider plugin
architecture (`@register_provider` + settings tab with enable checkbox
and Test Connection button, disabled by default).

## Why

None of the current providers cover Hungarian editions well: Hardcover
and Open Library rarely index them, and Google Books coverage is spotty.
Moly.hu is the de-facto catalog for Hungarian books (local editions
*and* Hungarian translations of foreign works). With this provider,
Universal mode works end-to-end for Hungarian titles: moly search →
localized title/author feed the release search → indexers that carry
Hungarian content can actually match. Related pain points: #595 (books
missing from metadata providers), #1035 (interest in niche sources).

## How

- HTML scraping with BeautifulSoup (already a dependency), no API key
needed
- Scraping approach (search URL, page structure, language-tag mapping)
adapted from the long-lived Calibre `Moly_hu` plugin (GPL v3, credited
in the module docstring), with fallback selector chains inherited from
it
- Sliding-window rate limit (30 req/min) to stay polite to a small
community site
- Standard `@cacheable` decorators; fetch failures return `None` so they
are not cached (same behavior as the Google Books provider)
- Search results carry cover thumbnails, rating and series info as
display fields; `get_book` parses title (zero-width chars stripped,
nested series link excluded), authors, ISBN-13/10, publisher, publish
year, description (spoiler-warning prefix stripped), tags/genres, cover,
and language (from moly's language tags, defaulting to `hu`)
- ISBN search resolves through moly's site search

## Testing

- `tests/metadata/test_moly_parse.py`: offline tests with fixture HTML
mirroring live moly.hu markup — search parsing/dedup, pagination guard,
failure-not-cached behavior, book-page parsing, ISBN resolution, ISBN
validation helper
- `uv run pytest tests/metadata` green (45 passed), `ruff check` / `ruff
format` clean
- Verified live against moly.hu (search, get_book, ISBN lookup) and
running in Docker alongside Hardcover

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-11 00:46:32 -04:00
dependabot[bot] 453d1f2b56 Bump oxlint-tsgolint from 0.25.0 to 7.0.2001 in /src/frontend (#1156)
Bumps [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) from
0.25.0 to 7.0.2001.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/tsgolint/releases">oxlint-tsgolint's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.2001</h2>
<h2>Highlights</h2>
<p><a
href="https://oxc.rs/blog/2026-07-22-type-aware-linting-stable">Announcing
Type-Aware Linting Stable</a></p>
<h2>What's Changed</h2>
<ul>
<li>fix(release): preserve binary executable permissions by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1104">oxc-project/tsgolint#1104</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oxc-project/tsgolint/compare/v7.0.2000...v7.0.2001">https://github.com/oxc-project/tsgolint/compare/v7.0.2000...v7.0.2001</a></p>
<h2>v7.0.2000</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(no-unnecessary-condition): improve diagnostics by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1092">oxc-project/tsgolint#1092</a></li>
<li>ci: use pnpm publish and drop global npm install by <a
href="https://github.com/Boshen"><code>@​Boshen</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1095">oxc-project/tsgolint#1095</a></li>
<li>feat(no-floating-promises): improve diagnostics by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1093">oxc-project/tsgolint#1093</a></li>
<li>fix(no-unnecessary-type-assertion): preserve type-changing
assertions by <a
href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1097">oxc-project/tsgolint#1097</a></li>
<li>chore(deps): update github actions by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1098">oxc-project/tsgolint#1098</a></li>
<li>chore(deps): update module github.com/dlclark/regexp2/v2 to v2.5.2
by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1099">oxc-project/tsgolint#1099</a></li>
<li>chore(deps): update npm packages by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1100">oxc-project/tsgolint#1100</a></li>
<li>fix(ci): disable pnpm git checks for generated release packages by
<a href="https://github.com/camc314"><code>@​camc314</code></a> in <a
href="https://redirect.github.com/oxc-project/tsgolint/pull/1103">oxc-project/tsgolint#1103</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/oxc-project/tsgolint/compare/v0.25.0...v7.0.2000">https://github.com/oxc-project/tsgolint/compare/v0.25.0...v7.0.2000</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/482dcf70bffce7ea56f63128c74beb67dec658a2"><code>482dcf7</code></a>
fix(release): preserve binary executable permissions (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1104">#1104</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/7ea9b74861b5249be86aa19f5d44518e1b2554be"><code>7ea9b74</code></a>
fix(ci): disable pnpm git checks for generated release packages (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1103">#1103</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/a90133b97edcda3458445c5bd676c6d20db74042"><code>a90133b</code></a>
chore(deps): update npm packages (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1100">#1100</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/f5a7520fe69ec59cc84efd096de9f5d66cda13c4"><code>f5a7520</code></a>
chore(deps): update module github.com/dlclark/regexp2/v2 to v2.5.2 (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1099">#1099</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/5cc766e7df4d9da6a12970c7788a65c55329b635"><code>5cc766e</code></a>
chore(deps): update github actions (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1098">#1098</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/4e9120aaefad311b2f959de053ce468967d06190"><code>4e9120a</code></a>
fix(no-unnecessary-type-assertion): preserve type-changing assertions
(<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1097">#1097</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/bbcafc7b10c33c092507f97b8f76488d5c79d9ba"><code>bbcafc7</code></a>
feat(no-floating-promises): improve diagnostics (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1093">#1093</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/305e137c155f012ddc89a9d62aab4866e09a8114"><code>305e137</code></a>
ci: use pnpm publish and drop global npm install (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1095">#1095</a>)</li>
<li><a
href="https://github.com/oxc-project/tsgolint/commit/1b97ad9b435269f251876a4f2932e494f6cc0127"><code>1b97ad9</code></a>
feat(no-unnecessary-condition): improve diagnostics (<a
href="https://redirect.github.com/oxc-project/tsgolint/issues/1092">#1092</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/tsgolint/compare/v0.25.0...v7.0.2001">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 09:25:21 -04:00
dependabot[bot] db11da5bda Bump the python-deps group across 1 directory with 2 updates (#1163)
Bumps the python-deps group with 2 updates in the / directory:
[qbittorrent-api](https://github.com/rmartin16/qbittorrent-api) and
[ruff](https://github.com/astral-sh/ruff).

Updates `qbittorrent-api` from 2026.7.0 to 2026.8.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rmartin16/qbittorrent-api/releases">qbittorrent-api's
releases</a>.</em></p>
<blockquote>
<h2>release-2026.8.0</h2>
<h2>What's Changed</h2>
<h3>Fixes</h3>
<ul>
<li>fix: preserve cached client when slicing or copying List objects by
<a href="https://github.com/Sanjays2402"><code>@​Sanjays2402</code></a>
in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/644">rmartin16/qbittorrent-api#644</a></li>
</ul>
<h3>CI</h3>
<ul>
<li>ignore conflict409 on adding torrents in tests by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/640">rmartin16/qbittorrent-api#640</a></li>
<li>make CI tests resilient to qBittorrent's eventual consistency by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/643">rmartin16/qbittorrent-api#643</a></li>
</ul>
<h3>Chores</h3>
<ul>
<li>Bump the github-actions group with 4 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/647">rmartin16/qbittorrent-api#647</a></li>
<li>Bump pre-commit hook for ruff-pre-commit from v0.15.20 to v0.16.1 by
<a href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in
<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/649">rmartin16/qbittorrent-api#649</a></li>
<li>Bump pre-commit hook for mirrors-mypy from v2.1.0 to v2.3.0 by <a
href="https://github.com/rmartin16"><code>@​rmartin16</code></a> in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/650">rmartin16/qbittorrent-api#650</a></li>
<li>Bump the uv group with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/648">rmartin16/qbittorrent-api#648</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/Sanjays2402"><code>@​Sanjays2402</code></a>
made their first contribution in <a
href="https://redirect.github.com/rmartin16/qbittorrent-api/pull/644">rmartin16/qbittorrent-api#644</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/rmartin16/qbittorrent-api/compare/v2026.7.0...v2026.8.0">https://github.com/rmartin16/qbittorrent-api/compare/v2026.7.0...v2026.8.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rmartin16/qbittorrent-api/blob/main/CHANGELOG.md">qbittorrent-api's
changelog</a>.</em></p>
<blockquote>
<h3>v2026.8.0 (01 aug 2026)</h3>
<ul>
<li>Fix slicing returned objects (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/644">#644</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/1c9f4f72367547e782e58a61be65c4db9df08006"><code>1c9f4f7</code></a>
bump to v2026.8.0</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/ca122da2b5af3bdd6434471072b15070380c282d"><code>ca122da</code></a>
fix: preserve cached client when slicing or copying List objects (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/644">#644</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/4b3cab11c7090e32e7341b7144d3dbcce933b07f"><code>4b3cab1</code></a>
Bump the uv group with 7 updates (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/648">#648</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/1bbc79b928ef11bec32360d17800695916a37c2a"><code>1bbc79b</code></a>
Bumped pre-commit hook for mirrors-mypy from v2.1.0 to v2.3.0 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/650">#650</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/97897ea6c39a1e86617212fdb073aa6e71efe53f"><code>97897ea</code></a>
Bumped pre-commit hook for ruff-pre-commit from v0.15.20 to v0.16.1 (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/649">#649</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/27ef5fa77a518d80a78a51d0d648c4af5aa75c3b"><code>27ef5fa</code></a>
Bump the github-actions group with 4 updates (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/647">#647</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/f76602bf71d302ae7e180d2d47b1c840f94eb1d8"><code>f76602b</code></a>
make CI tests resilient to qBittorrent's eventual consistency (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/643">#643</a>)</li>
<li><a
href="https://github.com/rmartin16/qbittorrent-api/commit/3006f83fa51abc9c5e3fadeff42e4c1ecd82e91c"><code>3006f83</code></a>
ignore conflict409 on adding torrents in tests (<a
href="https://redirect.github.com/rmartin16/qbittorrent-api/issues/640">#640</a>)</li>
<li>See full diff in <a
href="https://github.com/rmartin16/qbittorrent-api/compare/v2026.7.0...v2026.8.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `ruff` from 0.16.0 to 0.16.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/releases">ruff's
releases</a>.</em></p>
<blockquote>
<h2>0.16.1</h2>
<h2>Release Notes</h2>
<p>Released on 2026-07-30.</p>
<h3>Preview features</h3>
<ul>
<li>Add an option to opt out of human-readable names (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27160">#27160</a>)</li>
<li>[<code>flake8-pytest-style</code>] Make fixes safe by default and
unsafe only when comments are present (<code>PT018</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27201">#27201</a>)</li>
<li>[<code>pyupgrade</code>] Skip fix when a defaulted
<code>TypeVar</code> precedes a non-defaulted one (<code>UP040</code>,
<code>UP046</code>, <code>UP047</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27133">#27133</a>)</li>
<li>[<code>ruff</code>] Fix false positive with unpacked arguments
(<code>RUF065</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26959">#26959</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Bump <code>gen-lsp-types</code> to gracefully handle unknown
enumeration values in LSP messages (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27230">#27230</a>)</li>
<li>[<code>flake8-bugbear</code>] Mark <code>range</code> as immutable
(<code>B008</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27247">#27247</a>)</li>
<li>[<code>flake8-comprehensions</code>] NFKC-normalize keyword names in
<code>C408</code> fix (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26813">#26813</a>)</li>
<li>[<code>flake8-return</code>] Fix false positive when variable is
read in <code>finally</code> clause (<code>RET504</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/25441">#25441</a>)</li>
<li>[<code>pydocstyle</code>] Skip section detection inside RST
directive bodies (<code>D214</code>, <code>D405</code>,
<code>D413</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23635">#23635</a>)</li>
<li>[<code>refurb</code>] Parenthesize <code>yield</code> arguments in
the <code>FURB192</code> fix (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27192">#27192</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>flake8-pytest-style</code>] Mark <code>PT022</code> fixes as
unsafe (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26440">#26440</a>)</li>
<li>[<code>refurb</code>] Mark fixes that remove unknown separators as
unsafe (<code>FURB105</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27200">#27200</a>)</li>
</ul>
<h3>Server</h3>
<ul>
<li>Fix indexing of excluded nested Ruff workspaces (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27303">#27303</a>)</li>
<li>Lint TOML files in the LSP (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26862">#26862</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Cover <code>pycon</code> Markdown formatting (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27153">#27153</a>)</li>
<li>[<code>flake8-bandit</code>] Document <code>TYPE_CHECKING</code>
exception (<code>S101</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27004">#27004</a>)</li>
<li>[<code>flake8-import-conventions</code>] Document that
<code>extend-aliases</code> can override default aliases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27191">#27191</a>)</li>
<li>[<code>pylint</code>] Add missing fix safety gotchas for
<code>non-augmented-assignment</code> (<code>PLR6104</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27250">#27250</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Reduce syntax error noise by swallowing dedents like indents (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27170">#27170</a>)</li>
<li>Vendor latest annotate-snippets (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27033">#27033</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/bxff"><code>@​bxff</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a href="https://github.com/Avasam"><code>@​Avasam</code></a></li>
<li><a href="https://github.com/epage"><code>@​epage</code></a></li>
<li><a href="https://github.com/LHMQ878"><code>@​LHMQ878</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md">ruff's
changelog</a>.</em></p>
<blockquote>
<h2>0.16.1</h2>
<p>Released on 2026-07-30.</p>
<h3>Preview features</h3>
<ul>
<li>Add an option to opt out of human-readable names (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27160">#27160</a>)</li>
<li>[<code>flake8-pytest-style</code>] Make fixes safe by default and
unsafe only when comments are present (<code>PT018</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27201">#27201</a>)</li>
<li>[<code>pyupgrade</code>] Skip fix when a defaulted
<code>TypeVar</code> precedes a non-defaulted one (<code>UP040</code>,
<code>UP046</code>, <code>UP047</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27133">#27133</a>)</li>
<li>[<code>ruff</code>] Fix false positive with unpacked arguments
(<code>RUF065</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26959">#26959</a>)</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Bump <code>gen-lsp-types</code> to gracefully handle unknown
enumeration values in LSP messages (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27230">#27230</a>)</li>
<li>[<code>flake8-bugbear</code>] Mark <code>range</code> as immutable
(<code>B008</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27247">#27247</a>)</li>
<li>[<code>flake8-comprehensions</code>] NFKC-normalize keyword names in
<code>C408</code> fix (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26813">#26813</a>)</li>
<li>[<code>flake8-return</code>] Fix false positive when variable is
read in <code>finally</code> clause (<code>RET504</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/25441">#25441</a>)</li>
<li>[<code>pydocstyle</code>] Skip section detection inside RST
directive bodies (<code>D214</code>, <code>D405</code>,
<code>D413</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/23635">#23635</a>)</li>
<li>[<code>refurb</code>] Parenthesize <code>yield</code> arguments in
the <code>FURB192</code> fix (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27192">#27192</a>)</li>
</ul>
<h3>Rule changes</h3>
<ul>
<li>[<code>flake8-pytest-style</code>] Mark <code>PT022</code> fixes as
unsafe (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26440">#26440</a>)</li>
<li>[<code>refurb</code>] Mark fixes that remove unknown separators as
unsafe (<code>FURB105</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27200">#27200</a>)</li>
</ul>
<h3>Server</h3>
<ul>
<li>Fix indexing of excluded nested Ruff workspaces (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27303">#27303</a>)</li>
<li>Lint TOML files in the LSP (<a
href="https://redirect.github.com/astral-sh/ruff/pull/26862">#26862</a>)</li>
</ul>
<h3>Documentation</h3>
<ul>
<li>Cover <code>pycon</code> Markdown formatting (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27153">#27153</a>)</li>
<li>[<code>flake8-bandit</code>] Document <code>TYPE_CHECKING</code>
exception (<code>S101</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27004">#27004</a>)</li>
<li>[<code>flake8-import-conventions</code>] Document that
<code>extend-aliases</code> can override default aliases (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27191">#27191</a>)</li>
<li>[<code>pylint</code>] Add missing fix safety gotchas for
<code>non-augmented-assignment</code> (<code>PLR6104</code>) (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27250">#27250</a>)</li>
</ul>
<h3>Other changes</h3>
<ul>
<li>Reduce syntax error noise by swallowing dedents like indents (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27170">#27170</a>)</li>
<li>Vendor latest annotate-snippets (<a
href="https://redirect.github.com/astral-sh/ruff/pull/27033">#27033</a>)</li>
</ul>
<h3>Contributors</h3>
<ul>
<li><a href="https://github.com/bxff"><code>@​bxff</code></a></li>
<li><a
href="https://github.com/anishgirianish"><code>@​anishgirianish</code></a></li>
<li><a href="https://github.com/Avasam"><code>@​Avasam</code></a></li>
<li><a href="https://github.com/epage"><code>@​epage</code></a></li>
<li><a href="https://github.com/LHMQ878"><code>@​LHMQ878</code></a></li>
<li><a
href="https://github.com/MichaReiser"><code>@​MichaReiser</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ruff/commit/80790b348b5188e7fc253665540f442c6ec7dd05"><code>80790b3</code></a>
Bump 0.16.1 (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27330">#27330</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/63830f3e97b56ca3be0dd8f1092f76c4acc63213"><code>63830f3</code></a>
[ty] Borrow from constraint set storage less often (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27328">#27328</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/f40dca98a7f1b6fff0cee5ecf1e6364d5a8bdc15"><code>f40dca9</code></a>
[ty] Preserve forwarded expanded-variadic diagnostic sources (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27266">#27266</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/0d804975a2645d6e4a795cf501fd764b88aa47e3"><code>0d80497</code></a>
Lint TOML files in the LSP (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26862">#26862</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/d91586bd5b30f77f6614d57b73fc7dbea9051a0e"><code>d91586b</code></a>
Update prek dependencies (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27293">#27293</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/7da4b8b8d78fd6df2b3e06d8466d9cd49822900d"><code>7da4b8b</code></a>
[ty] Respect bounds and constraints in generic materializations (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27228">#27228</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/b20daf741241a6829ff8963a203f7847561deb62"><code>b20daf7</code></a>
[ty] refactor: add helper function to send partial results (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27249">#27249</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/4d4c8fa1c75b00561ea24eefefb13eb5ff80e01f"><code>4d4c8fa</code></a>
[ty] Emit diagnostic when specializing a non-generic class (<a
href="https://redirect.github.com/astral-sh/ruff/issues/26883">#26883</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/7c3e2db97deb5c5edd6e4303403ef6caf2bde8be"><code>7c3e2db</code></a>
[ty] Fix enum class container assignability (<a
href="https://redirect.github.com/astral-sh/ruff/issues/27318">#27318</a>)</li>
<li><a
href="https://github.com/astral-sh/ruff/commit/d5ef97fcd03e108f7510f84b8ed85bb4051311fe"><code>d5ef97f</code></a>
[<code>flake8-return</code>] Fix false positive when variable is read in
<code>finally</code> claus...</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ruff/compare/0.16.0...0.16.1">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 09:23:43 -04:00
dependabot[bot] 0b22618d96 Bump the gh-actions group with 5 updates (#1157)
Bumps the gh-actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [docker/login-action](https://github.com/docker/login-action) |
`4.4.0` | `4.5.2` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `8.3.2`
| `9.0.0` |
| [github/codeql-action/init](https://github.com/github/codeql-action) |
`4.37.0` | `4.37.3` |
|
[github/codeql-action/autobuild](https://github.com/github/codeql-action)
| `4.37.0` | `4.37.3` |
|
[github/codeql-action/analyze](https://github.com/github/codeql-action)
| `4.37.0` | `4.37.3` |

Updates `docker/login-action` from 4.4.0 to 4.5.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.5.2</h2>
<ul>
<li>Surface Docker Hub OIDC error responses by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1058">docker/login-action#1058</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.5.1...v4.5.2">https://github.com/docker/login-action/compare/v4.5.1...v4.5.2</a></p>
<h2>v4.5.1</h2>
<ul>
<li>Support <code>dhi.io</code> as Docker Hub OIDC registry by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1054">docker/login-action#1054</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.5.0...v4.5.1">https://github.com/docker/login-action/compare/v4.5.0...v4.5.1</a></p>
<h2>v4.5.0</h2>
<ul>
<li><a href="https://github.com/docker/login-action#docker-hub">Docker
Hub OIDC</a> login support by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1048">docker/login-action#1048</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> and
<code>@​aws-sdk/client-ecr-public</code> to 3.1091.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1037">docker/login-action#1037</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.92.0 to 0.94.0 in
<a
href="https://redirect.github.com/docker/login-action/pull/1044">docker/login-action#1044</a>
<a
href="https://redirect.github.com/docker/login-action/pull/1050">docker/login-action#1050</a></li>
<li>Bump brace-expansion from 1.1.13 to 1.1.16 in <a
href="https://redirect.github.com/docker/login-action/pull/1046">docker/login-action#1046</a></li>
<li>Bump js-yaml from 5.2.0 to 5.2.1 in <a
href="https://redirect.github.com/docker/login-action/pull/1038">docker/login-action#1038</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.4.0...v4.5.0">https://github.com/docker/login-action/compare/v4.4.0...v4.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/371161bbe7024a29a25c5e19bfcbc0804fe9ad2c"><code>371161b</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1058">#1058</a>
from crazy-max/fix-dockerhub-oidc-error-handling</li>
<li><a
href="https://github.com/docker/login-action/commit/5dc73df38ebcfa6f96479901e253d172c3e35849"><code>5dc73df</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/2aa1edee0b06c23880529064a4f7d7d3d2f9bc87"><code>2aa1ede</code></a>
surface Docker Hub OIDC error responses</li>
<li><a
href="https://github.com/docker/login-action/commit/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7"><code>abd2ef4</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1055">#1055</a>
from crazy-max/test-registry-auth-oidc</li>
<li><a
href="https://github.com/docker/login-action/commit/d49d3a9839fef51322fa44989a44fdc43fccfc22"><code>d49d3a9</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1054">#1054</a>
from crazy-max/oidc-missing-dhi</li>
<li><a
href="https://github.com/docker/login-action/commit/b58b17c30b4db92a4ed049b213cae512b12e460b"><code>b58b17c</code></a>
test: cover Docker Hub OIDC with registry-auth</li>
<li><a
href="https://github.com/docker/login-action/commit/be646c21cec26cea303e29290d5f6ba6fde8e606"><code>be646c2</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/d77c059cb9956cedaa427dc022d89f39acba678f"><code>d77c059</code></a>
support dhi.io as Docker Hub OIDC registry</li>
<li><a
href="https://github.com/docker/login-action/commit/06fb636fac595d6fb4b28a5dfcb21a6f5091859c"><code>06fb636</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1037">#1037</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependen...</li>
<li><a
href="https://github.com/docker/login-action/commit/a8bc9539118a762b0e5788b53a50907977cc1b8d"><code>a8bc953</code></a>
[dependabot skip] chore: update generated content</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...371161bbe7024a29a25c5e19bfcbc0804fe9ad2c">compare
view</a></li>
</ul>
</details>
<br />

Updates `astral-sh/setup-uv` from 8.3.2 to 9.0.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/setup-uv/releases">astral-sh/setup-uv's
releases</a>.</em></p>
<blockquote>
<h2>v9.0.0 🌈 Change <code>prune-cache</code> default to
<code>false</code></h2>
<h2>Changes</h2>
<p>This release disables the default cache cache pruning to ease the
load on the PyPi infrastructure.
Since users might experience more GitHub Actions cache usage which might
result in higher costs this is marked as a breaking change. To read more
on why we did this (now) you can read the detailed analysis and
reasoning in <a
href="https://redirect.github.com/astral-sh/setup-uv/issues/967">#967</a></p>
<p>Besides this big breaking change we also have a small bugfix while
building caches for linux distributions that behave a big different than
the &quot;big ones&quot; and a speed up in version resolution by only
reading the version manifest until a matching version is found saving
runtime and network bandwith.</p>
<h2>🚨 Breaking changes</h2>
<ul>
<li>Change <code>prune-cache</code> default to <code>false</code> <a
href="https://github.com/charliermarsh"><code>@​charliermarsh</code></a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/967">#967</a>)</li>
</ul>
<h2>🐛 Bug fixes</h2>
<ul>
<li>fix: fall back to distribution ID when os-release has no version
field <a href="https://github.com/cxzhong"><code>@​cxzhong</code></a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/961">#961</a>)</li>
</ul>
<h2>🚀 Enhancements</h2>
<ul>
<li>Speed up version client by partial response reads <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/807">#807</a>)</li>
</ul>
<h2>🧰 Maintenance</h2>
<ul>
<li>chore: update known checksums for 0.11.30 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/968">#968</a>)</li>
<li>chore: update known checksums for 0.11.29 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/960">#960</a>)</li>
</ul>
<h2>📚 Documentation</h2>
<ul>
<li>docs: update version references to v8.3.2 @<a
href="https://github.com/apps/github-actions">github-actions[bot]</a>
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/949">#949</a>)</li>
</ul>
<h2>⬆️ Dependency updates</h2>
<ul>
<li>chore(deps): roll up Dependabot updates <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/970">#970</a>)</li>
<li>chore(deps): roll up Dependabot updates <a
href="https://github.com/eifinger"><code>@​eifinger</code></a> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/962">#962</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/c771a70e6277c0a99b617c7a806ffedaca235ff9"><code>c771a70</code></a>
chore(deps): roll up Dependabot updates (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/970">#970</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/2f537ca87c1ffa233ca2a1b84815388e3e42d845"><code>2f537ca</code></a>
chore: update known checksums for 0.11.30 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/968">#968</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/2269552d547df6f50e57442326930d30d943afe3"><code>2269552</code></a>
Speed up version client by partial response reads (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/807">#807</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/47a7f4fb2e900d6c33a5b5f231fa21dbfaeba52f"><code>47a7f4f</code></a>
Change <code>prune-cache</code> default to <code>false</code> (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/967">#967</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/71966eff34a27b0a62ed4b9f6f6e383e071b1bb5"><code>71966ef</code></a>
chore(deps): roll up Dependabot updates (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/962">#962</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/f12b1f0a84bd6dc2331b36b2bbdbb1d1e617dbcc"><code>f12b1f0</code></a>
fix: fall back to distribution ID when os-release has no version field
(<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/961">#961</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/ecd24dd710f2fb0dca1693a67af11fc4a5c5ec84"><code>ecd24dd</code></a>
chore: update known checksums for 0.11.29 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/960">#960</a>)</li>
<li><a
href="https://github.com/astral-sh/setup-uv/commit/6a191366842ac1502ba6c07e9b5acd5c2d9d8db3"><code>6a19136</code></a>
docs: update version references to v8.3.2 (<a
href="https://redirect.github.com/astral-sh/setup-uv/issues/949">#949</a>)</li>
<li>See full diff in <a
href="https://github.com/astral-sh/setup-uv/compare/11f9893b081a58869d3b5fccaea48c9e9e46f990...c771a70e6277c0a99b617c7a806ffedaca235ff9">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/init` from 4.37.0 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/autobuild` from 4.37.0 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/autobuild's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/autobuild's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.4 - 29 Jul 2026</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.2">2.26.2</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4051">#4051</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 09:23:34 -04:00
dependabot[bot] 42f308bc89 Bump the npm-deps group in /src/frontend with 7 updates (#1155)
Bumps the npm-deps group in /src/frontend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/react/react/tree/HEAD/packages/react) |
`19.2.7` | `19.2.8` |
|
[react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom)
| `19.2.7` | `19.2.8` |
|
[@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node)
| `26.1.1` | `26.1.2` |
|
[@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react)
| `6.0.3` | `6.0.4` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.27.0` | `6.29.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) |
`0.59.0` | `0.61.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) |
`1.74.0` | `1.76.0` |

Updates `react` from 19.2.7 to 19.2.8
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/react/react/releases">react's
releases</a>.</em></p>
<blockquote>
<h2>19.2.8 (July 21st, 2026)</h2>
<h2>React Server Components</h2>
<ul>
<li>Performance improvements when decoding
(<a
href="https://redirect.github.com/facebook/react/pull/37087">#37087</a>
by <a href="https://github.com/eps1lon"><code>@​eps1lon</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/react/react/commit/1dd4ecbdabf826f527fc9a58c05ea70375b7d170"><code>1dd4ecb</code></a>
[FlightReply] Performance improvements when decoding (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/37087">#37087</a>)</li>
<li><a
href="https://github.com/react/react/commit/b0d2fdb78bdfae075a7fa02ddcebbf25f90952c2"><code>b0d2fdb</code></a>
[19.2.x] Update required references to GitHub repo (<a
href="https://github.com/react/react/tree/HEAD/packages/react/issues/36753">#36753</a>)</li>
<li>See full diff in <a
href="https://github.com/react/react/commits/v19.2.8/packages/react">compare
view</a></li>
</ul>
</details>
<br />

Updates `react-dom` from 19.2.7 to 19.2.8
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/react/react/releases">react-dom's
releases</a>.</em></p>
<blockquote>
<h2>19.2.8 (July 21st, 2026)</h2>
<h2>React Server Components</h2>
<ul>
<li>Performance improvements when decoding
(<a
href="https://redirect.github.com/facebook/react/pull/37087">#37087</a>
by <a href="https://github.com/eps1lon"><code>@​eps1lon</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/react/react/commit/1dd4ecbdabf826f527fc9a58c05ea70375b7d170"><code>1dd4ecb</code></a>
[FlightReply] Performance improvements when decoding (<a
href="https://github.com/react/react/tree/HEAD/packages/react-dom/issues/37087">#37087</a>)</li>
<li><a
href="https://github.com/react/react/commit/b0d2fdb78bdfae075a7fa02ddcebbf25f90952c2"><code>b0d2fdb</code></a>
[19.2.x] Update required references to GitHub repo (<a
href="https://github.com/react/react/tree/HEAD/packages/react-dom/issues/36753">#36753</a>)</li>
<li>See full diff in <a
href="https://github.com/react/react/commits/v19.2.8/packages/react-dom">compare
view</a></li>
</ul>
</details>
<br />

Updates `@types/node` from 26.1.1 to 26.1.2
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node">compare
view</a></li>
</ul>
</details>
<br />

Updates `@vitejs/plugin-react` from 6.0.3 to 6.0.4
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite-plugin-react/releases">@​vitejs/plugin-react's
releases</a>.</em></p>
<blockquote>
<h2>plugin-react@6.0.4</h2>
<h3>Fixed <code>$RefreshSig$ is not defined</code> error when running
<code>vite dev</code> with <code>NODE_ENV=production</code></h3>
<p>When running <code>vite dev</code> with
<code>NODE_ENV=production</code>, the app errored with
<code>$RefreshSig$ is not defined</code>.
This error is now fixed.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md">@​vitejs/plugin-react's
changelog</a>.</em></p>
<blockquote>
<h2>6.0.4 (2026-07-22)</h2>
<h3>Fixed <code>$RefreshSig$ is not defined</code> error when running
<code>vite dev</code> with <code>NODE_ENV=production</code></h3>
<p>When running <code>vite dev</code> with
<code>NODE_ENV=production</code>, the app errored with
<code>$RefreshSig$ is not defined</code>.
This error is now fixed.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/f4b549822ec239799d746c030abb0b9a7d8f0a04"><code>f4b5498</code></a>
release: plugin-react@6.0.4</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/7a4065906e07e616aee23c4fcc76a76b74a7d694"><code>7a40659</code></a>
fix(react): <code>$RefreshSig$ is not defined</code> with
NODE_ENV=production vite dev ...</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/98b32d47a3f2517d52e8a51d8e28ff2ed13678a2"><code>98b32d4</code></a>
fix(deps): update react 19.2.8 (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1298">#1298</a>)</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/8ae5449be23079dd17fdefc64064a3d94be6fc39"><code>8ae5449</code></a>
fix: babel-plugin-react-compiler cannot be imported when used in a
framework ...</li>
<li><a
href="https://github.com/vitejs/vite-plugin-react/commit/f09ea01b0b66a4900c3e42cef2aa0147e78b097c"><code>f09ea01</code></a>
fix(deps): update all non-major dependencies (<a
href="https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react/issues/1282">#1282</a>)</li>
<li>See full diff in <a
href="https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.4/packages/plugin-react">compare
view</a></li>
</ul>
</details>
<br />

Updates `knip` from 6.27.0 to 6.29.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webpro-nl/knip/releases">knip's
releases</a>.</em></p>
<blockquote>
<h2>Release 6.29.0</h2>
<ul>
<li>fix(glob): do not follow symbolic links in async glob (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1900">#1900</a>)
(b7d5ce2f545d91953a97733aa1567e8728b20644) - thanks <a
href="https://github.com/mogelbrod"><code>@​mogelbrod</code></a>!</li>
<li>Add built-in compiler for tsrx
(db05e0142a41fad2ad45d62ba5106e0d97c92b45)</li>
<li>Fix -u shortcut for --duration
(932b9262af275d209923a5efab1b6f23067bb006)</li>
</ul>
<h2>Release 6.28.0</h2>
<ul>
<li>Update oxc-resolver (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1889">#1889</a>)
(1444f6aaf04d42ad07ee0b88da4dfae0e116043a)</li>
<li>Keep entry-exported members public (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1890">#1890</a>)
(94967c13a589a7e83408b1495c31323ac126fcab)</li>
<li>Don't un-ignore sibling paths on unrelated gitignore negations
(resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1891">#1891</a>)
(5c026ba50c1e89804fc5094a52dc4c4c37d92bc2)</li>
<li>Memoize the gitignore shadowed-pattern check
(a5fa3b0ebb7f84c107dba1c1ca9a9b73995f668f)</li>
<li>Ignore nested files under a gitignore dir/* pattern
(404a53e3e49fa7c1f51b60bcb30ea50450f75aab)</li>
<li>Track types referenced by JSDoc imports (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1888">#1888</a>)
(6ae48aadbe03952690c8608e6df42bd039cf6624)</li>
<li>Update oxc-parser (02ca17a72c3f0afa7df32297d2dd2f5463ac6e00)</li>
<li>feat: markdownlint-cli2 (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1887">#1887</a>)
(323b96f55b38b53f5e4648d72fecd968fae35147) - thanks <a
href="https://github.com/Zamiell"><code>@​Zamiell</code></a>!</li>
<li>Update rolldown snapshot
(009e509dee6a5013fd3937abb4ec81684601b023)</li>
<li>Resolve $GITHUB_ACTION_PATH in GHA composite actions
(ead489f066733f701acfe5b81eac0a6c813fb5c5)</li>
<li>Don't report <code>node:</code> specifiers as unlisted dependencies
(1a7114f0a159fe82328041e11fc61c5c1730cc01)</li>
<li>Discover tsdown CLI entries
(0d47a45bfc518a7bf73a4b4878aeab236d6f9791)</li>
<li>Discover Convex function modules
(e01f21c44773186e7ad74e7badf9fc81b82edfb5)</li>
<li>Ignore common system binaries
(cd003065fda5422550a412bba07ebbb1f9bae126)</li>
<li>Add OpenClaw plugin (73a2d7dd45f65ea312fde38cb4fa4f5a4fc34bf2)</li>
<li>Discover Tailwind CLI input stylesheet (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1551">#1551</a>)
(9ae5feed922ecb7e8c436545e9b45125bd73b457)</li>
<li>Discover Angular global stylesheets from angular.json
(fb9e0488f6f21284714d0651fcf1dd2de02d425a)</li>
<li>Hint when project patterns exclude a compiled extension
(74de516d3d73839536fc2a43c2c0ec38a6299631)</li>
<li>Ignore common global binaries
(a99a206015fe414a4de12016cc62499910afaf6f)</li>
<li>Run multiple preprocessors in sequence
(844358367e9d1939bbf47f8dcb97d810741f56c1)</li>
<li>fix(nuxt): resolve nuxt module paths (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1896">#1896</a>)
(3c8594f5ffc641b00575b6d7455990db1277ac75) - thanks <a
href="https://github.com/danielroe"><code>@​danielroe</code></a>!</li>
<li>Report unused re-exports when ignoreExportsUsedInFile is set (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1895">#1895</a>)
(a113a5f4eedc81ef56b00a3e488cc94febefede5) - thanks <a
href="https://github.com/mlm20"><code>@​mlm20</code></a>!</li>
<li>Pass -w to pnpm add in sentry ecosystem test
(ea8c8ac6fe9519f00ca6a61131b345f5de521704)</li>
<li>Update sentry snapshot
(b75768d544d9f869ebce8f8c6dd8490f3db87023)</li>
<li>Record all traversed dirs in glob cache (resolve <a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1897">#1897</a>)
(81be1d436eae15518df764cbcb443899e11a9569)</li>
<li>Invalidate glob cache when .gitignore changes
(baff301f2dceda97bf8bf73809d811cc44c72c8c)</li>
<li>Improve Nuxt and Nitro plugin resolution
(86ffab7bcdd095631d6b0b17254e8acd6b5022bc)</li>
<li>fix: don't treat [-]-c[heck] as config flag for prettier (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1902">#1902</a>)
(b7266ee8240a9b8686e91a44a4706d3605d094c4) - thanks <a
href="https://github.com/davidkna-sap"><code>@​davidkna-sap</code></a>!</li>
<li>Use Entra ID to publish VS Code extension
(4236253482f884d92cfde2ccf85c7fb9aca17f7e)</li>
<li>Remove obsolete trust policy exclusions
(630d5f9835f4303ec9aa7d090ad40a177808c4fb)</li>
<li>Update dependencies (810147b5aa21bb57ad103fd19ba92844e42cd84c)</li>
<li>Migrate from typescript/native-preview to typescript
(140a25a577a64273a7a77f2ff2c8f224118ac298)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webpro-nl/knip/commit/d9a6fe096fe2711a54c62dbbe988fa9221267538"><code>d9a6fe0</code></a>
Release knip@6.29.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/932b9262af275d209923a5efab1b6f23067bb006"><code>932b926</code></a>
Fix -u shortcut for --duration</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/db05e0142a41fad2ad45d62ba5106e0d97c92b45"><code>db05e01</code></a>
Add built-in compiler for tsrx</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/b7d5ce2f545d91953a97733aa1567e8728b20644"><code>b7d5ce2</code></a>
fix(glob): do not follow symbolic links in async glob (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1900">#1900</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/fc16b3875eabd27315c46653056a4a5b9eca4366"><code>fc16b38</code></a>
Release knip@6.28.0</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/140a25a577a64273a7a77f2ff2c8f224118ac298"><code>140a25a</code></a>
Migrate from typescript/native-preview to typescript</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/810147b5aa21bb57ad103fd19ba92844e42cd84c"><code>810147b</code></a>
Update dependencies</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/b7266ee8240a9b8686e91a44a4706d3605d094c4"><code>b7266ee</code></a>
fix: don't treat [-]-c[heck] as config flag for prettier (<a
href="https://github.com/webpro-nl/knip/tree/HEAD/packages/knip/issues/1902">#1902</a>)</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/86ffab7bcdd095631d6b0b17254e8acd6b5022bc"><code>86ffab7</code></a>
Improve Nuxt and Nitro plugin resolution</li>
<li><a
href="https://github.com/webpro-nl/knip/commit/baff301f2dceda97bf8bf73809d811cc44c72c8c"><code>baff301</code></a>
Invalidate glob cache when .gitignore changes</li>
<li>Additional commits viewable in <a
href="https://github.com/webpro-nl/knip/commits/knip@6.29.0/packages/knip">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxfmt` from 0.59.0 to 0.61.0
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md">oxfmt's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable changes to this package will be documented in this
file.</p>
<p>The format is based on <a
href="https://keepachangelog.com/en/1.0.0">Keep a Changelog</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/65fe65d8429e1d1bdf86c517ff08bd119ee87660"><code>65fe65d</code></a>
release(apps): oxlint v1.76.0 &amp;&amp; oxfmt v0.61.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/24976">#24976</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/83abe3b49c0913b1a984a7eec5e433a59fd76eae"><code>83abe3b</code></a>
release(apps): oxlint v1.75.0 &amp;&amp; oxfmt v0.60.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/24713">#24713</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/e2e0f94253c5ce91a00394e2cba974bc152798db"><code>e2e0f94</code></a>
chore(docs): update sponsor section (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt/issues/24588">#24588</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/oxfmt_v0.61.0/npm/oxfmt">compare
view</a></li>
</ul>
</details>
<br />

Updates `oxlint` from 1.74.0 to 1.76.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">oxlint's
releases</a>.</em></p>
<blockquote>
<h2>oxlint v1.27.0 &amp;&amp; oxfmt v0.12.0</h2>
<h1>Oxlint v1.27.0</h1>
<h3>🚀 Features</h3>
<ul>
<li>222a8f0 linter/plugins: Implement
<code>SourceCode#isSpaceBetween</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15498">#15498</a>)
(overlookmotel)</li>
<li>2f9735d linter/plugins: Implement
<code>context.languageOptions</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15486">#15486</a>)
(overlookmotel)</li>
<li>bc731ff linter/plugins: Stub out all <code>Context</code> APIs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15479">#15479</a>)
(overlookmotel)</li>
<li>5822cb4 linter/plugins: Add <code>extend</code> method to
<code>FILE_CONTEXT</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15477">#15477</a>)
(overlookmotel)</li>
<li>7b1e6f3 apps: Add pure rust binaries and release to github (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15469">#15469</a>)
(Boshen)</li>
<li>2a89b43 linter: Introduce debug assertions after fixes to assert
validity (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15389">#15389</a>)
(camc314)</li>
<li>ad3c45a editor: Add <code>oxc.path.node</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15040">#15040</a>)
(Sysix)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>6f3cd77 linter/no-var: Incorrect warning for blocks (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15504">#15504</a>)
(Hamir Mahal)</li>
<li>6957fb9 linter/plugins: Do not allow access to
<code>Context#id</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15489">#15489</a>)
(overlookmotel)</li>
<li>7409630 linter/plugins: Allow access to <code>cwd</code> in
<code>createOnce</code> in ESLint interop mode (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15488">#15488</a>)
(overlookmotel)</li>
<li>732205e parser: Reject <code>using</code> / <code>await using</code>
in a switch <code>case</code> / <code>default</code> clause (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15225">#15225</a>)
(sapphi-red)</li>
<li>a17ca32 linter/plugins: Replace <code>Context</code> class (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15448">#15448</a>)
(overlookmotel)</li>
<li>ecf2f7b language_server: Fail gracefully when tsgolint executable
not found (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15436">#15436</a>)
(camc314)</li>
<li>3c8d3a7 lang-server: Improve logging in failure case for tsgolint
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15299">#15299</a>)
(camc314)</li>
<li>ef71410 linter: Use jsx if source type is JS in fix debug assertion
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15434">#15434</a>)
(camc314)</li>
<li>e32bbf6 linter/no-var: Handle TypeScript declare keyword in fixer
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15426">#15426</a>)
(camc314)</li>
<li>6565dbe linter/switch-case-braces: Skip comments when searching for
<code>:</code> token (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15425">#15425</a>)
(camc314)</li>
<li>85bd19a linter/prefer-class-fields: Insert value after type
annotation in fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15423">#15423</a>)
(camc314)</li>
<li>fde753e linter/plugins: Block access to
<code>context.settings</code> in <code>createOnce</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15394">#15394</a>)
(overlookmotel)</li>
<li>ddd9f9f linter/forward-ref-uses-ref: Dont suggest removing wrapper
in invalid positions (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15388">#15388</a>)
(camc314)</li>
<li>dac2a9c linter/no-template-curly-in-string: Remove fixer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15387">#15387</a>)
(camc314)</li>
<li>989b8e3 linter/no-var: Only fix to <code>const</code> if the var has
an initializer (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15385">#15385</a>)
(camc314)</li>
<li>cc403f5 linter/plugins: Return empty object for unimplemented
parserServices (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15364">#15364</a>)
(magic-akari)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>25d577e language_server: Start tools in parallel (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15500">#15500</a>)
(Sysix)</li>
<li>3c57291 linter/plugins: Optimize loops (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15449">#15449</a>)
(overlookmotel)</li>
<li>3166233 linter/plugins: Remove <code>Arc</code>s (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15431">#15431</a>)
(overlookmotel)</li>
<li>9de1322 linter/plugins: Lazily deserialize settings JSON (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15395">#15395</a>)
(overlookmotel)</li>
<li>3049ec2 linter/plugins: Optimize <code>deepFreezeSettings</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15392">#15392</a>)
(overlookmotel)</li>
<li>444ebfd linter/plugins: Use single object for
<code>parserServices</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15378">#15378</a>)
(overlookmotel)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>97d2104 linter: Update comment in lint.rs about default value for
tsconfig path (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15530">#15530</a>)
(Connor Shea)</li>
<li>2c6bd9e linter: Always refer as &quot;ES2015&quot; instead of
&quot;ES6&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15411">#15411</a>)
(sapphi-red)</li>
<li>a0c5203 linter/import/named: Update &quot;ES7&quot; comment in
examples (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15410">#15410</a>)
(sapphi-red)</li>
<li>3dc24b5 linter,minifier: Always refer as &quot;ES Modules&quot;
instead of &quot;ES6 Modules&quot; (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15409">#15409</a>)
(sapphi-red)</li>
<li>2ad77fb linter/no-this-before-super: Correct &quot;Why is this
bad?&quot; section (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15408">#15408</a>)
(sapphi-red)</li>
<li>57f0ce1 linter: Add backquotes where appropriate (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/15407">#15407</a>)
(sapphi-red)</li>
</ul>
<h1>Oxfmt v0.12.0</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md">oxlint's
changelog</a>.</em></p>
<blockquote>
<h2>[1.76.0] - 2026-07-27</h2>
<h3>🚀 Features</h3>
<ul>
<li>8d31dfa linter: Verify eslint/no-restricted-globals config schema
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24598">#24598</a>)
(vigneshwar)</li>
<li>7069621 linter: Verify jest/vitest prefer-lowercase-title config
schema (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24724">#24724</a>)
(Bartok)</li>
<li>016cf2a linter/oxc: Add bad-match-all-arg rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24900">#24900</a>)
(camc314)</li>
<li>cdc941e linter/n: Implement <code>exports-style</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24087">#24087</a>)
(Mikhail Baev)</li>
<li>1ad6f6c linter/eslint: Implement <code>id-denylist</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24632">#24632</a>)
(Mikhail Baev)</li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>3ff2e0e linter: Clarify config extends types (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24936">#24936</a>)
(Boshen)</li>
</ul>
<h2>[1.75.0] - 2026-07-20</h2>
<h3>🚀 Features</h3>
<ul>
<li>dd18383 linter/node: Implement no-top-level-await rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24634">#24634</a>)
(Connor Shea)</li>
<li>16a65f2 linter/react: Implement function-component-definition rule
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24471">#24471</a>)
(Cole Ellison)</li>
<li>7f1f585 linter: Reuse <code>jest/padding-around-test-blocks</code>
for <code>vitest/padding-around-test-blocks</code> (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24519">#24519</a>)
(Mikhail Baev)</li>
<li>99978a8 linter/import/consistent-type-specifier-style: Support
<code>prefer-top-level-if-only-type-imports</code> option (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24502">#24502</a>)
(camc314)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>8694167 linter/eslint/prefer-destructuring: Handle typed
declarations (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24616">#24616</a>)
(camc314)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/oxc-project/oxc/commit/65fe65d8429e1d1bdf86c517ff08bd119ee87660"><code>65fe65d</code></a>
release(apps): oxlint v1.76.0 &amp;&amp; oxfmt v0.61.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24976">#24976</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/3ff2e0e6fa1670d2300b2fb44dddc08c2df50a00"><code>3ff2e0e</code></a>
docs(linter): clarify config extends types (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24936">#24936</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/8d31dfa3934b0c8d276e022ad546836ee6d1dcd7"><code>8d31dfa</code></a>
feat(linter): verify eslint/no-restricted-globals config schema (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24598">#24598</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/7069621199a47158b853743ab6664126ec7b24b4"><code>7069621</code></a>
feat(linter): verify jest/vitest prefer-lowercase-title config schema
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24724">#24724</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/016cf2acd64e4f6d34226aecc84e69d342708858"><code>016cf2a</code></a>
feat(linter/oxc): add bad-match-all-arg rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24900">#24900</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/cdc941e5f81d00dfca63cc17cc39eb6d1462a9ed"><code>cdc941e</code></a>
feat(linter/n): implement <code>exports-style</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24087">#24087</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/1ad6f6c823da92a9b70a9f8c23d355d1b99f9e75"><code>1ad6f6c</code></a>
feat(linter/eslint): implement <code>id-denylist</code> rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24632">#24632</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/83abe3b49c0913b1a984a7eec5e433a59fd76eae"><code>83abe3b</code></a>
release(apps): oxlint v1.75.0 &amp;&amp; oxfmt v0.60.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24713">#24713</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/6ce51d3ff39d675bb3ce12abe31c5807f9683833"><code>6ce51d3</code></a>
chore(deps): update dependency oxlint-tsgolint to v7.0.2000 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24763">#24763</a>)</li>
<li><a
href="https://github.com/oxc-project/oxc/commit/dd18383ee1504b319aee45f832045bcd1a7285d5"><code>dd18383</code></a>
feat(linter/node): implement no-top-level-await rule (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint/issues/24634">#24634</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/oxc-project/oxc/commits/oxlint_v1.76.0/npm/oxlint">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-05 09:23:22 -04:00
CamGoed e93fbd2a9b Fix issue with stale v1 hash queries to qbittorrent (#1162)
## Problem

Shelfmark can lose track of hybrid v1/v2 torrents after qBittorrent
completes their metadata download.

Shelfmark initially identifies the torrent by its v1 infohash. Once
metadata resolves, qBittorrent may switch the torrent’s primary `hash`
to the truncated v2 hash, causing lookups using the original v1 hash to
return nothing.

For example:

- v1: `edf46c7f938a3c678081734d7bff8b9c652ba5e5`
- qBittorrent `hash`: `0bed5f40753b342cb143e83c2b21924cc8474731`
- full v2:
`0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f`

At that point, querying `/api/v2/torrents/info` with the original v1
hash returns no records. Querying with the new primary hash works, and
the returned record still contains the original hash in `infohash_v1`.

`find_existing()` also ignored its provided category, so audiobook
torrents fall back to the default ebook category instead. This means the
fallback method for a mismatched download ID never occurs for
audiobooks, leading to a "failed" download that is actually successful
in qBittorrent. As a result, the downloaded files are not automatically
transferred/hardlinked to the output directory.

## Fix

- Match torrents against `hash`, `infohash_v1`, and `infohash_v2`.
- Wait for magnet metadata to finish downloading before returning the
torrent ID.
- Return qBittorrent’s current primary `hash`.
- Search the provided category first, then the configured default, and
finally the full torrent list.


Logs showing the issue:

```
2026-08-03 22:42:30,262 - shelfmark.release_sources.audiobookbay.scraper - DEBUG - scraper.py:450 - Generated Magnet Link: magnet:?xt=urn:btih:EDF46C7F938A3C678081734D7BFF8B9C652BA5E5&tr=...

2026-08-03 22:42:30,376 - shelfmark.download.clients.qbittorrent - DEBUG - qbittorrent.py:521 - qBittorrent add result: TorrentsAddedMetadata({'added_torrent_ids': ['edf46c7f938a3c678081734d7bff8b9c652ba5e5'], 'failure_count': 0, 'pending_count': 0, 'success_count': 1})

2026-08-03 22:42:30,427 - shelfmark.download.clients.qbittorrent - INFO - qbittorrent.py:545 - Added torrent: edf46c7f938a3c678081734d7bff8b9c652ba5e5

2026-08-03 22:42:30,427 - shelfmark.download.clients.base_handler - INFO - base_handler.py:868 - Added to qbittorrent: edf46c7f938a3c678081734d7bff8b9c652ba5e5 for 'Pathogenesis: A History of the World in Eight Plagues'

2026-08-03 22:42:30,427 - shelfmark.download.clients.base_handler - DEBUG - base_handler.py:906 - Starting poll for edf46c7f938a3c678081734d7bff8b9c652ba5e5 (content_type=audiobook)

2026-08-03 22:42:32,590 - shelfmark.download.clients.base_handler - DEBUG - base_handler.py:958 - Download edf46c7f938a3c678081734d7bff8b9c652ba5e5 not yet visible in client (attempt 1/15)

2026-08-03 22:43:02,345 - shelfmark.download.clients.base_handler - ERROR - base_handler.py:969 - Download edf46c7f938a3c678081734d7bff8b9c652ba5e5 not found after 15 attempts

2026-08-03 22:43:02,345 - shelfmark.download.clients.base_handler - INFO - base_handler.py:426 - Skipping download client cleanup for protocol=torrent after download error (client=qbittorrent id=edf46c7f938a3c678081734d7bff8b9c652ba5e5)
```
<br>


The successful torrent: 
<br>


<img width="968" height="159" alt="image"
src="https://github.com/user-attachments/assets/d63c4444-6a4d-4214-97a0-732acc338970"
/>
<br>


v1 vs v2 hash: 
<br>


<img width="749" height="212" alt="image"
src="https://github.com/user-attachments/assets/d985b78a-84fe-4a26-9697-126c787e7303"
/>


I ran some python queries from the shelfmark container that show the
mismatch:

```
qBittorrent URL: http://gluetun-mam:8081
Tracked hash:    edf46c7f938a3c678081734d7bff8b9c652ba5e5

=== PROPERTIES LOOKUP USING SHELFMARK HASH ===
HTTP status: 404
Not Found

=== EXACT /torrents/info HASH LOOKUP ===
Returned torrents: 0
=== FIND VISIBLE PATHOGENESIS TORRENT ===
Matching visible torrents: 1

Name:         Pathogenesis: A History of the World in Eight Plagues
Primary hash: 0bed5f40753b342cb143e83c2b21924cc8474731
Category:     audiobooks
State:        stalledUP
Progress:     1
Properties HTTP status: 200
Infohash v1:  edf46c7f938a3c678081734d7bff8b9c652ba5e5
Infohash v2:  0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f
```
2026-08-05 09:19:25 -04:00
79 changed files with 6905 additions and 1231 deletions
+19 -3
View File
@@ -46,9 +46,7 @@ updates:
# pre-release filter is bypassed for *grouped* updates
# (dependabot-core#9496), so a grouped python update proposes pre-release
# tags like python:3.15.0b2 as if they were a normal stable minor bump.
# Updated individually, python is filtered correctly: alpha/beta/rc tags
# are skipped and only stable releases (e.g. 3.15.0 once final) are
# proposed. node + uv stay grouped into a single digest PR.
# node + uv stay grouped into a single digest PR.
patterns: ["*"]
exclude-patterns: ["python"]
ignore:
@@ -58,6 +56,24 @@ updates:
- dependency-name: "node"
update-types: ["version-update:semver-major"]
# Python: block minor/major bumps. Ungrouping python (above) is NOT enough
# to keep pre-releases out — dependabot-core#13815 rewrote the Docker
# pre-release heuristic to catch PEP 440 tags like 3.15.0a2 / 3.5.0b3, but
# the suffixed real tag still slipped through as PR #1169
# (python:3.14.6-slim -> python:3.15.0b3-slim). CPython spells
# pre-releases without a separator, so tag parsing reads 3.15.0b3 as an
# ordinary version that sorts above 3.14.6.
#
# A minor-version ignore blocks it regardless of spelling. Patch bumps
# (3.14.6 -> 3.14.7) and same-tag digest refreshes still land automatically.
# Moving the runtime to a new Python minor is a manual, deliberate change:
# bump the tag here and confirm C-extension wheels (greenlet/gevent) exist
# for it — a source build against a pre-release ABI boots an app that binds
# its port but never serves, which wedges e2e for the full 6h job limit.
- dependency-name: "python"
update-types:
["version-update:semver-major", "version-update:semver-minor"]
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
@@ -70,7 +70,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Log in to the Container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -115,7 +115,7 @@ jobs:
- name: Generate artifact attestation for ${{ matrix.target }} image
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
subject-digest: ${{ steps.push.outputs.digest }}
@@ -134,7 +134,7 @@ jobs:
LEGACY_NAME: calibre-web-automated-book-downloader
steps:
- name: Log in to registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.3"
python-version: "3.14"
@@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.3"
python-version: "3.14"
@@ -62,7 +62,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.3"
python-version: "3.14"
+3 -3
View File
@@ -25,14 +25,14 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v3
with:
category: "/language:${{ matrix.language }}"
+8 -3
View File
@@ -30,7 +30,7 @@ jobs:
relevant: ${{ steps.filter.outputs.relevant }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4.0.2
- uses: dorny/paths-filter@v4.0.3
id: filter
with:
filters: |
@@ -60,6 +60,9 @@ jobs:
e2e:
needs: select-profiles
runs-on: ubuntu-latest
# A wedged app under test must not burn GitHub's 6h max job limit. A healthy
# profile run finishes in ~3-5 min; anything past 25 is hung, not slow.
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
@@ -69,7 +72,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.14"
enable-cache: true
@@ -87,12 +90,14 @@ jobs:
needs: changes
if: needs.changes.outputs.relevant == 'true' || github.event_name != 'pull_request'
runs-on: ubuntu-latest
# Real Chrome + qBittorrent is the slowest profile; still nowhere near 40 min.
timeout-minutes: 40
name: e2e (full — real Chrome + qBittorrent)
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install uv and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.14"
enable-cache: true
+16 -10
View File
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
ARG BUILDARCH
# Frontend build stage.
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontend-builder
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS frontend-builder
# Helpful debug output to see what platforms BuildKit thinks it's using
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
@@ -24,10 +24,14 @@ COPY src/frontend/ ./
# Build the frontend
RUN npm run build
# Use python-slim as the base image
FROM python:3.14.6-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 AS base
# uv is a build-time tool only, so it is mounted into the RUNs that need it rather
# than copied into the image. A COPY here would land ~24 MB in a `base` layer that
# every published image inherits, and a later `rm` cannot take it back out again --
# a RUN adds a layer, it does not rewrite the one underneath.
FROM ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 AS uv
COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/
# Use python-slim as the base image
FROM python:3.14.7-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 AS base
# Add build argument for version
ARG BUILD_VERSION
@@ -59,6 +63,11 @@ ENV FLASK_PORT=8084
# Configure locale, timezone, and perform initial cleanup in a single layer
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# For building C-extensions (cffi, gevent, etc.)
gcc \
g++ \
libffi-dev \
python3-dev \
# For locale
locales tzdata \
# For healthcheck
@@ -106,6 +115,7 @@ WORKDIR /app
# Install core Python dependencies first for better layer caching
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
uv sync --locked --no-default-groups
# Runtime dependencies are installed into /app/.venv during the build. Remove the
@@ -194,6 +204,7 @@ RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-
# Install the browser automation stack used by the full image
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
uv sync --locked --no-default-groups --extra browser
# Deterministically resolve the Xlib namespace collision.
@@ -207,13 +218,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# and force python-xlib 0.33 to own the namespace. pyautogui runs fine against
# 0.33 (superset API).
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=from=uv,source=/uv,target=/usr/local/bin/uv \
uv pip uninstall --python /app/.venv/bin/python python3-xlib && \
uv pip install --python /app/.venv/bin/python --reinstall python-xlib==0.33 && \
/app/.venv/bin/python -c "import Xlib.X; assert hasattr(Xlib.X, 'FamilyServerInterpreted'), 'Xlib.X.FamilyServerInterpreted missing after fix'; print('Xlib namespace OK:', Xlib.__version__)"
# uv is only needed while building the image.
RUN rm -f /usr/bin/uv /usr/bin/uvx
# Keep SeleniumBase's bundled driver cache writable for the fixed non-root user.
RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')") && \
chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \
@@ -230,7 +239,4 @@ FROM base AS shelfmark-lite
ENV USING_EXTERNAL_BYPASSER=true
# uv is only needed while building the image.
RUN rm -f /usr/bin/uv /usr/bin/uvx
CMD ["/app/entrypoint.sh"]
+78 -5
View File
@@ -23,6 +23,7 @@ This document lists all configuration options that can be set via environment va
- [Hardcover](#metadata-providers-hardcover)
- [Open Library](#metadata-providers-open-library)
- [Google Books](#metadata-providers-google-books)
- [Moly.hu](#metadata-providers-moly.hu)
- [Direct Download](#direct-download)
- [Download Sources](#direct-download-download-sources)
- [Cloudflare Bypass](#direct-download-cloudflare-bypass)
@@ -246,7 +247,7 @@ Seconds since the last WireGuard handshake before the healthcheck bounces the tu
| `CALIBRE_WEB_URL` | Adds a navigation button to your book library (Calibre-Web Automated, Grimmory, etc). | string | _none_ |
| `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ |
| `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
| `SUPPORTED_AUDIOBOOK_FORMATS` | Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. | string (comma-separated) | `m4b,mp3` |
| `SUPPORTED_AUDIOBOOK_FORMATS` | Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. | string (comma-separated) | `m4b,mp3,m4a,flac,ogg,wma,aac,wav,opus,zip,rar` |
| `BOOK_LANGUAGE` | Default language filter for searches. | string (comma-separated) | `en` |
<details>
@@ -295,7 +296,7 @@ Book formats to include in search results. ZIP/RAR archives are extracted automa
Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.
- **Type:** string (comma-separated)
- **Default:** `m4b,mp3`
- **Default:** `m4b,mp3,m4a,flac,ogg,wma,aac,wav,opus,zip,rar`
#### `BOOK_LANGUAGE`
@@ -1046,6 +1047,7 @@ Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local)
|----------|-------------|------|---------|
| `URL_BASE` | Optional URL path prefix. Use a path like /shelfmark (no hostname). Leave blank for root. | string | _none_ |
| `DEBUG` | Enable verbose logging to console and file. Not recommended for normal use. | boolean | `false` |
| `LOG_LEVEL` | Lowest severity written to the console and log file. Ignored while Debug Mode is on, which forces Debug. | string (choice) | `INFO` |
| `MAIN_LOOP_SLEEP_TIME` | How often the download queue is checked for new items. | number | `5` |
| `DOWNLOAD_PROGRESS_UPDATE_INTERVAL` | How often download progress is broadcast to the UI. | number | `1` |
| `CUSTOM_SCRIPT` | Path to a script to run after each successful download. Must be executable. | string | _none_ |
@@ -1082,6 +1084,17 @@ Enable verbose logging to console and file. Not recommended for normal use.
- **Default:** `false`
- **Requires restart:** Yes
#### `LOG_LEVEL`
**Log Level**
Lowest severity written to the console and log file. Ignored while Debug Mode is on, which forces Debug.
- **Type:** string (choice)
- **Default:** `INFO`
- **Requires restart:** Yes
- **Options:** `DEBUG` (Debug), `INFO` (Info), `WARNING` (Warning), `ERROR` (Error), `CRITICAL` (Critical)
#### `MAIN_LOOP_SLEEP_TIME`
**Queue Check Interval (seconds)**
@@ -1291,6 +1304,8 @@ Apply per-indexer seed time and ratio preferences from Prowlarr when sending tor
| `NEWZNAB_ENABLED` | Enable searching for books via a Newznab-compatible indexer | boolean | `false` |
| `NEWZNAB_URL` | Base URL of your Newznab indexer or aggregator | string | _none_ |
| `NEWZNAB_API_KEY` | Your Newznab API key (leave blank if not required) | string (secret) | _none_ |
| `NEWZNAB_EBOOK_CATEGORIES` | Newznab category IDs searched for ebooks. Most indexers use the standard 7000, but some use custom IDs. Leave empty to use 7000. | string (comma-separated) | `7000` |
| `NEWZNAB_AUDIOBOOK_CATEGORIES` | Newznab category IDs searched for audiobooks. Most indexers use the standard 3030, but some use custom IDs. Leave empty to use 3030. | string (comma-separated) | `3030` |
| `NEWZNAB_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
<details>
@@ -1324,6 +1339,24 @@ Your Newznab API key (leave blank if not required)
- **Type:** string (secret)
- **Default:** _none_
#### `NEWZNAB_EBOOK_CATEGORIES`
**Ebook Categories**
Newznab category IDs searched for ebooks. Most indexers use the standard 7000, but some use custom IDs. Leave empty to use 7000.
- **Type:** string (comma-separated)
- **Default:** `7000`
#### `NEWZNAB_AUDIOBOOK_CATEGORIES`
**Audiobook Categories**
Newznab category IDs searched for audiobooks. Most indexers use the standard 3030, but some use custom IDs. Leave empty to use 3030.
- **Type:** string (comma-separated)
- **Default:** `3030`
#### `NEWZNAB_AUTO_EXPAND`
**Auto-expand search on no results**
@@ -1408,7 +1441,7 @@ Delay between requests in seconds to avoid rate limiting (0-10).
| `IRC_CHANNEL` | Channel name without the # prefix. Used for all searches unless a separate audiobook channel is configured below. | string | _none_ |
| `IRC_NICK` | Your IRC nickname (required). Must be unique on the IRC network. | string | _none_ |
| `IRC_SEARCH_BOT` | The search bot to address queries to (required). Searches are sent as "@<bot> <query>". | string | _none_ |
| `IRC_AUDIOBOOK_CHANNEL` | Optional. Channel name (without the # prefix) to use for audiobook searches. Leave blank to use the main channel above for audiobooks too. | string | _none_ |
| `IRC_AUDIOBOOK_CHANNEL` | Optional. Channel name (without the # prefix) for networks that index audiobooks separately, such as Undernet's bookz. Leave blank (the usual setting) to search the main channel above for audiobooks too. | string | _none_ |
| `IRC_AUDIOBOOK_SEARCH_BOT` | Optional. Search bot for the audiobook channel. Leave blank to reuse the main search bot above. Only used when an audiobook channel is set. | string | _none_ |
| `IRC_CACHE_TTL` | How long to keep cached search results before they expire. | string (choice) | `2592000` |
@@ -1477,7 +1510,7 @@ The search bot to address queries to (required). Searches are sent as "@<bot> <q
**Audiobook channel**
Optional. Channel name (without the # prefix) to use for audiobook searches. Leave blank to use the main channel above for audiobooks too.
Optional. Channel name (without the # prefix) for networks that index audiobooks separately, such as Undernet's bookz. Leave blank (the usual setting) to search the main channel above for audiobooks too.
- **Type:** string
- **Default:** _none_
@@ -1508,6 +1541,8 @@ How long to keep cached search results before they expire.
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `PROWLARR_TORRENT_CLIENT` | Choose which torrent client to use | string (choice) | _empty string_ |
| `ALLDEBRID_API_KEY` | AllDebrid API Key (apiv4) from your AllDebrid account settings | string (secret) | _none_ |
| `REALDEBRID_API_KEY` | Real-Debrid API Key (Secret Token) from your Real-Debrid account settings | string (secret) | _none_ |
| `QBITTORRENT_URL` | Web UI URL of your qBittorrent instance | string | _none_ |
| `QBITTORRENT_USERNAME` | qBittorrent Web UI username | string | _none_ |
| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ |
@@ -1559,7 +1594,25 @@ Choose which torrent client to use
- **Type:** string (choice)
- **Default:** _empty string_
- **Options:** `""` (None), `qbittorrent` (qBittorrent), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
- **Options:** `""` (None), `alldebrid` (AllDebrid), `qbittorrent` (qBittorrent), `realdebrid` (Real-Debrid), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent)
#### `ALLDEBRID_API_KEY`
**API Key**
AllDebrid API Key (apiv4) from your AllDebrid account settings
- **Type:** string (secret)
- **Default:** _none_
#### `REALDEBRID_API_KEY`
**API Key**
Real-Debrid API Key (Secret Token) from your Real-Debrid account settings
- **Type:** string (secret)
- **Default:** _none_
#### `QBITTORRENT_URL`
@@ -2064,6 +2117,26 @@ Default sort order for Google Books search results.
</details>
### Metadata Providers: Moly.hu
| Variable | Description | Type | Default |
|----------|-------------|------|---------|
| `MOLY_ENABLED` | Enable Moly.hu as a metadata provider for book searches | boolean | `false` |
<details>
<summary>Detailed descriptions</summary>
#### `MOLY_ENABLED`
**Enable Moly.hu**
Enable Moly.hu as a metadata provider for book searches
- **Type:** boolean
- **Default:** `false`
</details>
## Direct Download
### Direct Download: Download Sources
+1 -1
View File
@@ -12,7 +12,7 @@ With a subpath (`URL_BASE=/shelfmark/`):
https://<your-shelfmark-domain>/shelfmark/api/auth/oidc/callback
```
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly. PKCE (S256) is used automatically.
The callback URL is constructed from the incoming request, so your reverse proxy must forward `X-Forwarded-Proto` and `X-Forwarded-Host` correctly, including the external port when it is not the protocol default. PKCE (S256) is used automatically.
## Settings
+7 -5
View File
@@ -23,10 +23,11 @@ server {
location / {
proxy_pass http://shelfmark:8084;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
@@ -56,11 +57,11 @@ All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path.
location /shelfmark/ {
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
@@ -136,11 +137,11 @@ location /shelfmark/ {
proxy_pass http://shelfmark:8084/shelfmark/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
@@ -158,6 +159,7 @@ If login, settings saves, or downloads appear to fail in the browser but the act
- Do not force `Connection: upgrade` on every request. That can break normal `POST` and `PUT` responses while the backend still processes them.
- If your proxy UI does not support conditional websocket headers, remove the forced websocket headers entirely and let Shelfmark fall back to polling.
- Keep the standard forwarded headers: `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` when using a subpath or OIDC.
- Preserve the original port in `Host` and `X-Forwarded-Host` by using `$http_host` rather than `$host` when Shelfmark is exposed on a custom port.
This is especially relevant for Nginx Proxy Manager or custom advanced config snippets that add websocket headers globally.
+18 -2
View File
@@ -261,7 +261,12 @@ test_write() {
fi
FILE_CONTENT=$(cat "$test_file" 2>/dev/null || echo "")
rm -f "$test_file"
# A folder can be writable but not deletable (e.g. a Synology share without
# "Delete subfolders and files"). That is not a boot failure - the app writes
# files in place on such shares - so don't let a failed cleanup print an
# alarming error or fail the probe.
run_as_target_user rm -f "$test_file" 2>/dev/null || \
echo "Note: could not remove test file in $folder (folder is writable but not deletable)"
[ "$FILE_CONTENT" = "0123456789_TEST" ]
result=$?
if [ $result -eq 0 ]; then
@@ -479,7 +484,18 @@ fi
# Always run Gunicorn (even when DEBUG=true) to ensure Socket.IO WebSocket
# upgrades work reliably on customer machines.
# Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase).
gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
# Gunicorn rejects anything outside its own list, so normalize and fall back to
# info rather than letting a typo stop the container from booting.
if [ "$DEBUG" = "true" ]; then
gunicorn_loglevel=debug
else
gunicorn_loglevel=$(echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
[ "$gunicorn_loglevel" = "warn" ] && gunicorn_loglevel=warning
case "$gunicorn_loglevel" in
debug|info|warning|error|critical) ;;
*) gunicorn_loglevel=info ;;
esac
fi
command="${GUNICORN_BIN} --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
# If DEBUG and not using an external bypass
+6 -3
View File
@@ -19,17 +19,20 @@ dependencies = [
"psutil",
"emoji",
"rarfile",
"qbittorrent-api>=2026.5.3",
"qbittorrent-api>=2026.8.0",
"transmission-rpc",
"authlib>=1.7.2,<1.8",
"apprise>=1.12.0",
# HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which
# requests cannot speak. See shelfmark/download/doh_wireformat.py.
"httpx[http2]>=0.27",
]
[project.optional-dependencies]
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.51.8",
"seleniumbase==4.51.12",
"python-xlib",
]
@@ -40,7 +43,7 @@ dev = [
"pytest",
"pytest-cov",
"pytest-xdist>=3.8.0",
"ruff==0.16.0",
"ruff==0.16.2",
"vulture>=2.14",
]
+18 -2
View File
@@ -44,6 +44,7 @@ Works great alongside the following library tools, with support for automatic im
### Prerequisites
- Docker & Docker Compose
- At least 2 GB of RAM available to the container when using the standard image — see [Memory Requirements](#memory-requirements)
### Installation
@@ -122,7 +123,7 @@ See the full [Environment Variables Reference](docs/environment-variables.md) fo
Some of the additional options available in Settings:
- **Prowlarr** - Configure indexers and download clients to download books and audiobooks
- **Additional audiobook sources** - Configure additional sources for audiobook discovery
- **IRC** - Add details for IRC book sources and download directly from the UI
- **IRC** - Add details for IRC book sources and download directly from the UI. Most networks serve audiobooks from the same channel as ebooks (on `irc.irchighway.net` that's `#ebooks`, while `#bookz` is effectively inactive), so leave the separate audiobook channel blank unless your network actually indexes one. IRC audiobooks usually arrive as ZIP/RAR archives — keep those enabled under Supported Audiobook Formats or the releases are filtered out of results
- **Library Link** - Add a link to your Calibre-Web or Grimmory instance in the UI header
- **File processing** - Customiseable download paths, file renaming and directory creation with template-based renaming
- **Network Settings** - Custom proxy support (SOCKS5 + HTTP/S) and configurable DNS
@@ -138,6 +139,17 @@ docker compose up -d
The full-featured image with all network capabilities included.
#### Memory Requirements
The standard image ships a real Chromium browser, which it launches to solve Cloudflare challenges for Direct Download. Chromium needs room to run:
- **2 GB of RAM available to the container** is a safe minimum; 1 GB or less is where problems usually start
- Only relevant if you use Direct Download. Prowlarr, IRC and audiobook sources don't start the browser
When the container is starved of memory, Chromium fails to start and every Direct Download fails with unrelated-looking errors — repeated `403 detected; switching to bypasser` followed by `No download URL found`, and downloads that never complete. If you're seeing that, check the container's memory limit and the host's free memory before suspecting your ISP or DNS.
If you can't spare the memory, use the [Lite](#lite) image with an external resolver (e.g. FlareSolverr) running elsewhere.
#### Tor Routing
Optional Tor support for network privacy:
```bash
@@ -175,6 +187,7 @@ A lighter image without the built-in browser automation. Ideal for:
- **External services** - Already running FlareSolverr or similar for other applications
- **Alternative sources** - Using Prowlarr, IRC, or other configured sources
- **Audiobooks** - Using Shelfmark primarily for audiobooks
- **Constrained hosts** - No bundled browser, so it runs comfortably below the standard image's [memory requirements](#memory-requirements)
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
@@ -247,7 +260,10 @@ Logs are available via:
- `docker logs <container-name>`
- `/var/log/shelfmark/` inside the container (when `ENABLE_LOGGING=true`)
Log level is configurable via Settings or `LOG_LEVEL` environment variable.
Log level is configurable under Settings → Advanced or via the `LOG_LEVEL` environment
variable (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`; case-insensitive, defaults to
`INFO`). The environment variable wins over the setting, and `DEBUG=true` forces `DEBUG`
regardless of either. Changes take effect on restart.
## Development
+16
View File
@@ -47,6 +47,22 @@ def _coerce_timeout_ms(value: object, default: int) -> int:
return default
def max_duration_seconds() -> float:
"""Upper bound on how long get_bypassed_page() can take for one URL.
MAX_RETRY attempts at the configured read timeout, plus the exponential backoff waited
between them (jitter is < 1s per gap, counted as a full second to stay conservative).
Callers use this to declare a stall-detection grace; see shelfmark.download.activity.
"""
bypasser_timeout = _coerce_timeout_ms(config.get("EXT_BYPASSER_TIMEOUT", 60000), 60000)
read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT)
backoff_total = sum(
min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + 1.0
for attempt in range(1, MAX_RETRY)
)
return MAX_RETRY * read_timeout + backoff_total
def _fetch_via_bypasser(target_url: str) -> str | None:
"""Make a single request to the external bypasser service. Returns HTML or None."""
raw_bypasser_url = _coerce_config_str(
+91 -11
View File
@@ -50,6 +50,9 @@ _LOADING_BODY_LENGTH_MAX = 50
_PAGE_BODY_PREVIEW_CHARS = 500
_BROWSER_START_TIMEOUT_SECONDS = 45.0
_BYPASS_SUBPROCESS_TIMEOUT_SECONDS = 420.0
# Same budget as the Docker helper process, applied to the in-process CDP path so both
# branches of get() are bounded the same way.
_IN_PROCESS_BYPASS_TIMEOUT_SECONDS = _BYPASS_SUBPROCESS_TIMEOUT_SECONDS
_BYPASS_CHILD_ENV = "SHELFMARK_INTERNAL_BYPASSER_CHILD"
# Challenge detection indicators
@@ -217,7 +220,13 @@ class _CdpWorker:
msg = "CDP worker loop not available"
raise RuntimeError(msg)
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
return future.result(timeout=timeout)
try:
return future.result(timeout=timeout)
except TimeoutError:
# Otherwise the coroutine keeps running in the worker loop after we stop
# waiting, holding the browser and racing the next bypass.
future.cancel()
raise
_CDP_WORKER = _CdpWorker()
@@ -244,6 +253,26 @@ DDG_COOKIE_NAMES = {
"ddg_last_challenge",
}
# DDoS-Guard cookies that describe *one* check rather than granting clearance, and so
# must never be replayed on a later request. Observed live on Anna's Archive:
#
# __ddg9_ the client IP address
# __ddg10_ the unix timestamp the check was issued
# __ddg8_ an opaque token issued with them, same ~40 minute expiry
#
# Clearance itself lives in __ddg1_/__ddg2_/__ddgid_ (roughly a year) and __ddg5_.
# Replaying the trio is actively harmful: once the timestamp ages out - or the egress
# IP changes, which happens routinely behind a VPN - the values no longer describe the
# caller, DDoS-Guard re-arms its check and answers every request with a ?check=1
# redirect. That is the redirect loop, and it is self-inflicted. Dropping them simply
# lets DDoS-Guard issue a fresh set, exactly as it does for a browser.
DDG_EPHEMERAL_COOKIE_NAMES = {
"__ddg8_",
"__ddg9_",
"__ddg10_",
"ddg_last_challenge",
}
def _get_base_domain(domain: str) -> str:
"""Extract base domain from hostname (e.g., 'www.example.com' -> 'example.com')."""
@@ -259,6 +288,10 @@ def _get_full_cookie_domains() -> set[str]:
def _should_extract_cookie(name: str, *, extract_all: bool) -> bool:
"""Determine if a cookie should be extracted based on its name."""
# Checked before extract_all: a per-check token is wrong to replay for every
# domain, including the full-session ones.
if name in DDG_EPHEMERAL_COOKIE_NAMES:
return False
if extract_all:
return True
is_cf = name in CF_COOKIE_NAMES or name.startswith("cf_")
@@ -333,6 +366,16 @@ async def _extract_cookies_from_cdp(driver: Any, page: Any, url: str) -> None:
logger.debug("Failed to extract cookies: %s", e)
def _is_cookie_expired(cookie: dict[str, Any]) -> bool:
"""Whether a stored cookie's expiry has passed. Session cookies never expire here."""
expiry = cookie.get("expiry")
if expiry is None:
expiry = cookie.get("expires")
if not expiry or expiry <= 0:
return False
return time.time() > expiry
def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
"""Get stored cookies for a domain. Returns empty dict if none available."""
if not domain:
@@ -346,16 +389,25 @@ def get_cf_cookies_for_domain(domain: str) -> dict[str, str]:
return {}
cf_clearance = cookies.get("cf_clearance", {})
if cf_clearance:
expiry = cf_clearance.get("expiry")
if expiry is None:
expiry = cf_clearance.get("expires")
if expiry and expiry > 0 and time.time() > expiry:
logger.debug("CF cookies expired for %s", base_domain)
_cf_cookies.pop(base_domain, None)
return {}
if cf_clearance and _is_cookie_expired(cf_clearance):
logger.debug("CF cookies expired for %s", base_domain)
_cf_cookies.pop(base_domain, None)
return {}
return {name: c["value"] for name, c in cookies.items()}
# Expiry applies to every cookie, not just Cloudflare's. DDoS-Guard domains
# have no cf_clearance, so the check above never fired for them and dead
# cookies were replayed indefinitely - the server answers those with a
# challenge, which is indistinguishable from having sent nothing at all.
live = {name: c for name, c in cookies.items() if not _is_cookie_expired(c)}
if len(live) != len(cookies):
expired = sorted(set(cookies) - set(live))
logger.debug("Dropping expired cookies for %s: %s", base_domain, expired)
if live:
_cf_cookies[base_domain] = live
else:
_cf_cookies.pop(base_domain, None)
return {name: c["value"] for name, c in live.items()}
def has_valid_cf_cookies(domain: str) -> bool:
@@ -896,7 +948,11 @@ def _run_bypass_in_current_process(url: str, retry: int, cancel_flag: Event | No
if os.environ.get(_BYPASS_CHILD_ENV) == "1":
return asyncio.run(_run_bypass())
return _CDP_WORKER.run(_run_bypass())
# Bound the wait: this path runs in-process (non-Docker installs), holds the module-wide
# LOCKED for its whole duration, and neither page.get() nor page.wait() has a timeout of
# its own. Without a deadline here a single wedged CDP session blocks every subsequent
# bypass in the process forever.
return _CDP_WORKER.run(_run_bypass(), timeout=_IN_PROCESS_BYPASS_TIMEOUT_SECONDS)
def _store_child_bypass_state(payload: dict[str, Any]) -> None:
@@ -1250,12 +1306,36 @@ def _try_with_cached_cookies(url: str, hostname: str) -> str | None:
if response.status_code == HTTPStatus.OK:
logger.debug("Cached cookies worked, skipped Chrome bypass")
return response.text
logger.debug(
"Cached cookies rejected (%s) for %s; discarding them",
response.status_code,
url,
)
except _REQUEST_OPERATION_ERRORS as exc:
# A redirect loop lands here too: DDoS-Guard answers a dead clearance cookie
# with an endless ?check=1 bounce rather than a status we can read.
logger.debug("Cached cookie retry failed for %s: %s", url, exc)
# Reached only when the cached cookies did not produce a page, so they are no
# longer clearance. Dropping them now means the imminent Chrome solve starts from
# a clean slate and later requests cannot re-present the same rejected cookie.
# Guarded because clear_cf_cookies("") means "every host", which would wipe
# clearance for sites that are working fine.
if hostname:
clear_cf_cookies(hostname)
return None
def max_duration_seconds() -> float:
"""Upper bound on how long get_bypassed_page() can take for one URL.
Both branches of get() are capped at _BYPASS_SUBPROCESS_TIMEOUT_SECONDS, and
get_bypassed_page() may call it twice (once, then again after a mirror/DNS rotation).
Callers use this to declare a stall-detection grace; see shelfmark.download.activity.
"""
return 2 * _BYPASS_SUBPROCESS_TIMEOUT_SECONDS
def get_bypassed_page(
url: str, selector: network.AAMirrorSelector | None = None, cancel_flag: Event | None = None
) -> str | None:
+57 -13
View File
@@ -6,34 +6,78 @@ import shutil
import tempfile
from pathlib import Path
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
def string_to_bool(s: str) -> bool:
"""Convert string to boolean."""
return s.lower() in ["true", "yes", "1", "y"]
def _read_advanced_config(key: str) -> object | None:
"""Read a key from the advanced settings file (import-time safe)."""
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
config_file = config_dir / "plugins" / "advanced.json"
if config_file.exists():
try:
with config_file.open() as f:
config = json.load(f)
if key in config:
return config[key]
except json.JSONDecodeError, OSError:
pass
return None
def _read_debug_from_config() -> bool:
"""Read DEBUG from env var or config file (import-time safe)."""
env_debug = os.environ.get("DEBUG")
if env_debug is not None:
return string_to_bool(env_debug)
# Try to read from config file
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
config_file = config_dir / "plugins" / "advanced.json"
if config_file.exists():
try:
with config_file.open() as f:
config = json.load(f)
if "DEBUG" in config:
return bool(config["DEBUG"])
except json.JSONDecodeError, OSError:
pass
value = _read_advanced_config("DEBUG")
if value is not None:
return bool(value)
return False
def normalize_log_level(raw: str | None) -> str:
"""Normalize a log level name, falling back to INFO when unrecognized."""
if raw is None:
return "INFO"
normalized = raw.strip().upper()
# "WARN" is a logging alias, but gunicorn only accepts "warning".
if normalized == "WARN":
normalized = "WARNING"
if normalized not in LOG_LEVELS:
return "INFO"
return normalized
def _read_log_level_from_config(debug: bool) -> str:
"""Resolve the app log level from DEBUG, env var, or config file.
DEBUG wins when enabled, mirroring how entrypoint.sh picks gunicorn's level.
Otherwise LOG_LEVEL is read from the env var, then the settings file, and
falls back to INFO when unset or unrecognized.
"""
if debug:
return "DEBUG"
raw = os.environ.get("LOG_LEVEL")
if raw is None:
value = _read_advanced_config("LOG_LEVEL")
raw = value if isinstance(value, str) else None
return normalize_log_level(raw)
def _is_sqlite_file(path: Path) -> bool:
"""Check if a file is a valid SQLite database by reading magic bytes."""
try:
@@ -101,7 +145,7 @@ INGEST_DIR = Path(os.getenv("INGEST_DIR", "/books"))
# =============================================================================
DEBUG = _read_debug_from_config()
LOG_LEVEL = "DEBUG" if DEBUG else "INFO"
LOG_LEVEL = _read_log_level_from_config(DEBUG)
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
+56 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Sequence
from os import PathLike
_DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
@@ -16,6 +16,13 @@ _DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
"RESTRICT_SETTINGS_TO_ADMIN",
)
# The audiobook format list shipped as the default until the format sets were unified.
# It only covered m4b/mp3, so FLAC/OPUS/OGG/M4A releases were dropped from search results
# and rejected after download - and the wider default alone would never reach existing
# installs, because initialize_default_configs() only writes defaults when the config
# file does not exist yet.
_LEGACY_AUDIOBOOK_FORMATS_DEFAULT = ("m4b", "mp3")
class MigrationLogger(Protocol):
"""Logger surface used by config migration helpers."""
@@ -57,6 +64,54 @@ def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None:
return None
def migrate_audiobook_formats(
*,
load_general_config: Callable[[], dict[str, Any]],
# `object` rather than `None`: the result is discarded, and savers that report
# success (settings_registry.save_config_file returns bool) are not assignable to a
# `-> None` callable.
save_general_config: Callable[[dict[str, Any]], object],
widened_formats: Sequence[str],
logger: MigrationLogger,
) -> None:
"""Widen an untouched audiobook format list to the current, fuller default.
Only a list that still matches the old default exactly is rewritten. Any other value
means someone chose it deliberately, and a migration that "helpfully" re-enabled
formats a user had turned off would be worse than leaving them on the narrow list.
"""
try:
config = load_general_config()
if "SUPPORTED_AUDIOBOOK_FORMATS" not in config:
# Nothing persisted, so the field default already applies.
logger.debug("No persisted audiobook formats - the current default applies")
return
current = config.get("SUPPORTED_AUDIOBOOK_FORMATS")
if not isinstance(current, list):
return
normalized = {str(fmt).strip().lower() for fmt in current if str(fmt).strip()}
if normalized != set(_LEGACY_AUDIOBOOK_FORMATS_DEFAULT):
logger.debug(
"Audiobook formats were customized (%s) - left unchanged", sorted(normalized)
)
return
save_general_config({"SUPPORTED_AUDIOBOOK_FORMATS": list(widened_formats)})
logger.info(
"Widened audiobook formats from the legacy default %s to %s",
list(_LEGACY_AUDIOBOOK_FORMATS_DEFAULT),
list(widened_formats),
)
except FileNotFoundError:
logger.debug("No existing general config file found - nothing to migrate")
except Exception:
logger.exception("Failed to migrate audiobook formats")
def migrate_security_settings(
*,
load_security_config: Callable[[], dict[str, Any]],
+35 -6
View File
@@ -14,6 +14,7 @@ from shelfmark.config.download_settings_handlers import (
check_books_destination,
)
from shelfmark.config.email_settings import check_email_connection
from shelfmark.config.migrations import migrate_audiobook_formats
from shelfmark.core.languages import supported_book_languages
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
@@ -35,6 +36,7 @@ from shelfmark.core.settings_registry import (
register_on_save,
register_settings,
)
from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS
_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_DEFAULT = 60
_DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT_MAX = 3600
@@ -134,6 +136,20 @@ def _on_save_advanced(values: dict[str, Any]) -> dict[str, Any]:
logger = setup_logger(__name__)
def migrate_audiobook_format_settings() -> None:
"""Bring installs created before the audiobook format sets were unified up to date."""
from shelfmark.core.settings_registry import load_config_file, save_config_file
migrate_audiobook_formats(
load_general_config=lambda: load_config_file("general"),
save_general_config=lambda values: save_config_file("general", values),
widened_formats=[*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS],
logger=logger,
)
_SMTP_PORT_MAX = 65535
_EMAIL_ATTACHMENT_LIMIT_MB_MAX = 600
@@ -215,11 +231,7 @@ _FORMAT_OPTIONS = [
]
_AUDIOBOOK_FORMAT_OPTIONS = [
{"value": "m4b", "label": "M4B"},
{"value": "mp3", "label": "MP3"},
{"value": "m4a", "label": "M4A"},
{"value": "zip", "label": "ZIP"},
{"value": "rar", "label": "RAR"},
{"value": fmt, "label": fmt.upper()} for fmt in (*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS)
]
_DOWNLOAD_TO_BROWSER_CONTENT_TYPE_OPTIONS = [
@@ -416,7 +428,7 @@ def general_settings() -> list[SettingsField]:
label="Supported Audiobook Formats",
description="Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.",
options=_AUDIOBOOK_FORMAT_OPTIONS,
default=["m4b", "mp3"],
default=[*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS],
),
MultiSelectField(
key="BOOK_LANGUAGE",
@@ -1761,6 +1773,23 @@ def advanced_settings() -> list[SettingsField]:
default=False,
requires_restart=True,
),
SelectField(
key="LOG_LEVEL",
label="Log Level",
description=(
"Lowest severity written to the console and log file. "
"Ignored while Debug Mode is on, which forces Debug."
),
options=[
{"value": "DEBUG", "label": "Debug", "description": "Everything, very noisy."},
{"value": "INFO", "label": "Info", "description": "Normal activity (default)."},
{"value": "WARNING", "label": "Warning", "description": "Warnings and problems."},
{"value": "ERROR", "label": "Error", "description": "Failures only."},
{"value": "CRITICAL", "label": "Critical", "description": "Fatal errors only."},
],
default="INFO",
requires_restart=True,
),
NumberField(
key="MAIN_LOOP_SLEEP_TIME",
label="Queue Check Interval (seconds)",
+1
View File
@@ -39,6 +39,7 @@ def upsert_cwa_user(
email=normalized_email,
role=role,
allow_email_link=True,
sync_username=True,
collision_strategy=collision_strategy,
alias_suffix=_CWA_ALIAS_SUFFIX,
context=context,
+61 -3
View File
@@ -108,6 +108,7 @@ def _build_updates(
auth_source: str,
role: str,
sync_role: bool,
username: str | object,
email: str | None | object,
display_name: str | None | object,
subject_field: str | None,
@@ -116,6 +117,8 @@ def _build_updates(
updates: dict[str, Any] = {"auth_source": auth_source}
if sync_role:
updates["role"] = _normalize_role(role)
if username is not UNSET:
updates["username"] = _normalize_username(username)
if email is not UNSET:
updates["email"] = _normalize_email(email)
if display_name is not UNSET:
@@ -125,10 +128,17 @@ def _build_updates(
return updates
def _next_suffix_username(user_db: UserDB, base_username: str) -> str:
def _next_suffix_username(
user_db: UserDB,
base_username: str,
*,
exclude_user_id: int | None = None,
) -> str:
candidate = base_username
suffix = 1
while user_db.get_user(username=candidate):
while existing := user_db.get_user(username=candidate):
if exclude_user_id is not None and int(existing.get("id") or 0) == exclude_user_id:
return candidate
candidate = f"{base_username}_{suffix}"
suffix += 1
return candidate
@@ -185,6 +195,38 @@ def _resolve_create_username(
return _next_suffix_username(user_db, alias_base), None, "username_collision_alias"
def _resolve_update_username(
user_db: UserDB,
*,
current_user: dict[str, Any],
requested_username: str,
strategy: CollisionStrategy,
alias_suffix: str,
) -> str:
current_user_id = int(current_user["id"])
existing = user_db.get_user(username=requested_username)
if existing is None or int(existing.get("id") or 0) == current_user_id:
return requested_username
if strategy == "suffix":
return _next_suffix_username(
user_db,
requested_username,
exclude_user_id=current_user_id,
)
if strategy == "alias":
return _next_suffix_username(
user_db,
f"{requested_username}{alias_suffix}",
exclude_user_id=current_user_id,
)
# `takeover` can select an existing row during creation, but once an
# identity is already matched it must never replace a different username
# owner. Preserve the matched row's current collision-free name instead.
return str(current_user["username"])
def upsert_external_user(
user_db: UserDB,
*,
@@ -197,6 +239,7 @@ def upsert_external_user(
subject: str | None = None,
allow_email_link: bool = False,
sync_role: bool = True,
sync_username: bool = False,
allow_create: bool = True,
collision_strategy: CollisionStrategy = "takeover",
alias_suffix: str | None = None,
@@ -229,10 +272,26 @@ def upsert_external_user(
subject=subject,
allow_email_link=allow_email_link,
)
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
update_username: str | object = UNSET
if (
matched is not None
and sync_username
and normalize_auth_source(matched.get("auth_source"), matched.get("oidc_subject"))
== auth_source
):
update_username = _resolve_update_username(
user_db,
current_user=matched,
requested_username=normalized_username,
strategy=collision_strategy,
alias_suffix=resolved_alias_suffix,
)
updates = _build_updates(
auth_source=auth_source,
role=normalized_role,
sync_role=sync_role,
username=update_username,
email=normalized_email if email is not UNSET else UNSET,
display_name=normalized_display_name if display_name is not UNSET else UNSET,
subject_field=subject_field,
@@ -261,7 +320,6 @@ def upsert_external_user(
)
return None, "not_found"
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
create_username, takeover_target, create_reason = _resolve_create_username(
user_db,
auth_source=auth_source,
+4
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any
from werkzeug.utils import secure_filename
from shelfmark.config.env import normalize_log_level
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_bool, normalize_optional_text
@@ -898,6 +899,9 @@ def _get_env_value_for_field(field: FieldBase) -> tuple[bool, object | None]:
"WELIB_MIRROR_URLS",
} and isinstance(parsed, list):
parsed = _normalize_mirror_env_urls(parsed)
if field.key == "LOG_LEVEL" and isinstance(parsed, str):
# LOG_LEVEL is commonly set lowercase; the field options are uppercase.
parsed = normalize_log_level(parsed)
return True, parsed
if field.key == "AA_MIRROR_URLS":
+2
View File
@@ -344,6 +344,7 @@ class UserDB:
_ALLOWED_UPDATE_COLUMNS: ClassVar[frozenset[str]] = frozenset(
{
"username",
"email",
"display_name",
"password_hash",
@@ -353,6 +354,7 @@ class UserDB:
}
)
_USER_UPDATE_STATEMENTS: ClassVar[dict[str, str]] = {
"username": "UPDATE users SET username = ? WHERE id = ?",
"email": "UPDATE users SET email = ? WHERE id = ?",
"display_name": "UPDATE users SET display_name = ? WHERE id = ?",
"password_hash": "UPDATE users SET password_hash = ? WHERE id = ?",
+15
View File
@@ -115,6 +115,21 @@ def is_audiobook(content_type: str | None) -> bool:
return bool(content_type and "audiobook" in content_type.lower())
# Every audio format an audiobook can legitimately arrive in, and the single source of
# truth for that list. The settings UI, release-source parsing, archive extraction and
# post-download scanning all derive from it, so a format added here becomes selectable,
# searchable AND downloadable at once. These used to be four hand-maintained copies that
# had drifted apart: the settings UI only offered m4b/mp3/m4a, which meant a FLAC
# audiobook could never be enabled, was silently dropped from every search result, and
# was rejected after download as "format not supported".
AUDIOBOOK_FORMATS = ("m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "opus")
# Multi-file audiobooks are almost always distributed as an archive. These are containers
# rather than formats: they are what a *release* looks like, and the formats above are
# what comes out of one after extraction.
ARCHIVE_FORMATS = ("zip", "rar")
CONTENT_TYPES = [
"book (fiction)",
"book (non-fiction)",
+81
View File
@@ -0,0 +1,81 @@
"""Stall-detection grace signalling for long single-shot download operations.
The orchestrator cancels a download after `STALL_TIMEOUT` seconds without activity, where
"activity" means a *changed* status event or a *changed* progress value. That de-duplication
is deliberate - a keep-alive that repeats the same payload on a timer proves nothing about
whether the operation is still making progress, so letting it refresh the stall clock would
make a genuinely wedged download immortal.
Operations that legitimately take longer than `STALL_TIMEOUT` but cannot report incremental
progress therefore declare an explicit upper bound up front instead:
request_activity_grace(status_callback, my_worst_case_seconds)
try:
...one long blocking call...
finally:
release_activity_grace(status_callback)
The grace is a single absolute deadline. It is never extended, so the operation still dies
if it overruns its own declared budget - just at *its* bound rather than at a global 300s.
The signal rides on the existing `status_callback` channel using a sentinel status, which
avoids threading a new parameter through every handler, post-processor and output module.
`shelfmark.download.orchestrator`'s per-task `status_callback` closure intercepts the
sentinel and never forwards it to `update_download_status`.
Adopters should be operations that yield to the gevent hub while blocking (`requests`,
patched `subprocess`). An operation that blocks the hub outright - `shutil.copy2`, sqlite -
will still be killed by the gunicorn worker timeout regardless of any grace, and must go
through `shelfmark.download.fs.run_blocking_io` first.
Current adopters: `shelfmark.download.http.html_get_page` (protection bypass).
Candidates: `download.clients.base_handler._wait_for_completed_path`, archive extraction in
`download.postprocess.scan`, large-file copies in `download.outputs.folder`, email/BookLore
uploads, and the Anna's Archive countdown in `release_sources.direct_download` (which today
refreshes the stall clock on every tick of a loop that proves nothing about the remote).
"""
from collections.abc import Callable
# Not a QueueStatus value, so `update_download_status` would reject it anyway; the
# orchestrator's status_callback intercepts it before that point.
ACTIVITY_GRACE_STATUS = "__activity_grace__"
StatusCallback = Callable[[str, str | None], None]
# A status_callback is caller-supplied and may raise; a failed liveness hint must never
# break the operation it was protecting. Mirrors http._STATUS_CALLBACK_ERRORS.
_CALLBACK_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
def request_activity_grace(status_callback: StatusCallback | None, seconds: float) -> None:
"""Ask the orchestrator to suppress stall detection for up to `seconds` from now."""
_emit(status_callback, seconds)
def release_activity_grace(status_callback: StatusCallback | None) -> None:
"""Drop any outstanding grace and count now as activity."""
_emit(status_callback, 0)
def parse_activity_grace(status: str, message: str | None) -> float | None:
"""Return the requested grace in seconds, or None if this is not a grace event.
Never raises: a malformed sentinel is treated as "not a grace event" so a bad emitter
cannot take down the status pipeline.
"""
if status != ACTIVITY_GRACE_STATUS:
return None
try:
return max(float(message or 0), 0.0)
except TypeError, ValueError:
return 0.0
def _emit(status_callback: StatusCallback | None, seconds: float) -> None:
if status_callback is None:
return
try:
status_callback(ACTIVITY_GRACE_STATUS, str(float(seconds)))
except _CALLBACK_ERRORS:
return
+2 -1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, cast
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import AUDIOBOOK_FORMATS
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.fs import atomic_move
from shelfmark.download.postprocess.policy import (
@@ -98,7 +99,7 @@ ALL_EBOOK_EXTENSIONS = {
}
# All known audio extensions (superset of what user might enable for audiobooks)
ALL_AUDIO_EXTENSIONS = {".m4b", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".wma", ".wav", ".opus"}
ALL_AUDIO_EXTENSIONS = {f".{fmt}" for fmt in AUDIOBOOK_FORMATS}
def _filter_files(
+111 -173
View File
@@ -44,6 +44,7 @@ _HASH_LENGTH_40 = 40
_HASH_LENGTH_ED2K = 32
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
_METADATA_DOWNLOAD_STATES = {"forcedMetaDL", "metaDL"}
_ONE_WEEK_IN_SECONDS = 604800
@@ -94,6 +95,25 @@ def _hashes_match(hash1: str, hash2: str) -> bool:
return False
def _torrent_matches_download_id(torrent: object, download_id: str) -> bool:
"""Match an ID against every identity qBittorrent exposes.
For hybrid torrents, qBittorrent's primary `hash` can change from the v1
hash to the truncated v2 hash after metadata resolution. The full
`infohash_v1` and `infohash_v2` fields preserve the torrent's identities.
"""
identifiers = (
getattr(torrent, "hash", None),
getattr(torrent, "infohash_v1", None),
getattr(torrent, "infohash_v2", None),
)
return any(
isinstance(identifier, str) and identifier and _hashes_match(identifier, download_id)
for identifier in identifiers
)
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@@ -166,63 +186,6 @@ def _build_qbittorrent_child_path(base_path: object, child_path: object) -> str
class QBittorrentClient(DownloadClient):
"""qBittorrent download client."""
def _is_torrent_loaded(self, torrent_hash: str) -> tuple[bool, str | None]:
"""Check whether qBittorrent has registered a torrent yet.
Uses `/api/v2/torrents/properties?hash=<hash>`.
Returns:
(loaded, error_message)
Notes:
A false result with no error means "not loaded yet".
"""
url = f"{self._base_url}/api/v2/torrents/properties"
params = {"hash": torrent_hash}
try:
self._ensure_authenticated()
response = self._client._session.get(url, params=params, timeout=10)
# Re-authenticate and retry once on 403
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
logger.debug(
"qBittorrent returned 403 for properties; re-authenticating and retrying"
)
self._ensure_authenticated()
response = self._client._session.get(url, params=params, timeout=10)
if response.status_code == _HTTP_STATUS_FORBIDDEN:
return False, "qBittorrent authentication failed (HTTP 403)"
# qBittorrent returns 404/409-ish responses depending on version when missing.
if response.status_code == _HTTP_STATUS_NOT_FOUND:
return False, None
response.raise_for_status()
except requests.exceptions.HTTPError as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status == _HTTP_STATUS_NOT_FOUND:
return False, None
if status:
return False, f"qBittorrent API request failed (HTTP {status})"
return False, "qBittorrent API request failed"
except requests.exceptions.ConnectionError:
return False, f"Cannot connect to qBittorrent at {self._base_url}"
except requests.exceptions.Timeout:
return False, f"qBittorrent request timed out at {self._base_url}"
except requests.exceptions.InvalidSchema:
return (
False,
"qBittorrent URL is invalid (missing http:// or https://). "
f"Configured: {self._base_url}",
)
except _QBITTORRENT_CLIENT_ERRORS as e:
return False, f"qBittorrent API error: {type(e).__name__}: {e}"
else:
return True, None
protocol = "torrent"
name = "qbittorrent"
@@ -274,37 +237,18 @@ class QBittorrentClient(DownloadClient):
return
self._client.auth_log_in()
def _get_torrents_info(
self, torrent_hash: str | None = None, category: str | None = None
def _request_torrent_info_records(
self, params: dict[str, str]
) -> tuple[list[SimpleNamespace], str | None]:
"""Get torrent info using GET.
Behaviors:
- Retry once on HTTP 403 by re-authenticating.
- Keep "API/auth/connect" errors distinct from "torrent missing".
- If a hash-specific query returns empty, fall back to listing by category
and matching locally.
- Without a hash, `category` narrows the listing to that category.
Returns:
(torrents, error_message)
"""
"""Request torrent info records from qBittorrent."""
url = f"{self._base_url}/api/v2/torrents/info"
def do_request(params: dict[str, str]) -> requests.Response:
try:
self._ensure_authenticated()
return self._client._session.get(url, params=params, timeout=10)
def parse_response(
response: requests.Response,
*,
request_params: dict[str, str],
) -> tuple[list[SimpleNamespace], str | None]:
response = self._client._session.get(url, params=params, timeout=10)
if response.status_code == _HTTP_STATUS_FORBIDDEN and self._can_reauthenticate:
logger.debug("qBittorrent returned 403; re-authenticating and retrying")
self._ensure_authenticated()
response = self._client._session.get(url, params=request_params, timeout=10)
response = self._client._session.get(url, params=params, timeout=10)
if response.status_code == _HTTP_STATUS_FORBIDDEN:
logger.warning("qBittorrent authentication failed (HTTP 403)")
@@ -313,43 +257,6 @@ class QBittorrentClient(DownloadClient):
response.raise_for_status()
torrents = response.json()
return [SimpleNamespace(**t) for t in torrents], None
try:
primary_params: dict[str, str] = {}
if torrent_hash:
primary_params["hashes"] = torrent_hash
elif category:
primary_params["category"] = category
response = do_request(primary_params)
torrents, error = parse_response(response, request_params=primary_params)
if error:
return [], error
if torrent_hash and not torrents:
# Fallback 1: list by configured category
category_params: dict[str, str] = {}
if self._category:
category_params["category"] = self._category
category_response = do_request(category_params)
category_torrents, category_error = parse_response(
category_response, request_params=category_params
)
if category_error:
return [], category_error
if category_torrents:
return category_torrents, None
# Fallback 2: list everything (handles per-task categories like audiobooks)
all_response = do_request({})
all_torrents, all_error = parse_response(all_response, request_params={})
if all_error:
return [], all_error
return all_torrents, None
except requests.exceptions.HTTPError as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status:
@@ -374,12 +281,66 @@ class QBittorrentClient(DownloadClient):
except _QBITTORRENT_CLIENT_ERRORS as e:
logger.debug("Failed to get torrents info: %s", e)
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
else:
return torrents, None
def _get_torrent_info(self, download_id: str) -> tuple[SimpleNamespace | None, str | None]:
"""Get one torrent by its current qBittorrent hash."""
torrents, error = self._request_torrent_info_records({"hashes": download_id})
if error or not torrents:
return None, error
return (
next(
(
torrent
for torrent in torrents
if isinstance(getattr(torrent, "hash", None), str)
and _hashes_match(torrent.hash, download_id)
),
None,
),
None,
)
def _list_torrents_by_category(
self, category: str | None
) -> tuple[list[SimpleNamespace], str | None]:
"""List torrent records in a category, or all records when unset."""
params = {"category": category} if category else {}
return self._request_torrent_info_records(params)
def _resolve_torrent(
self, download_id: str, category: str | None = None
) -> tuple[SimpleNamespace | None, str | None]:
"""Resolve any known torrent identity to its current qBittorrent record."""
torrent, error = self._get_torrent_info(download_id)
if error or torrent:
return torrent, error
categories = [candidate for candidate in (category, self._category) if candidate]
for candidate in dict.fromkeys(categories):
torrents, error = self._list_torrents_by_category(candidate)
if error:
return None, error
torrent = next(
(item for item in torrents if _torrent_matches_download_id(item, download_id)),
None,
)
if torrent:
return torrent, None
torrents, error = self._list_torrents_by_category(None)
if error:
return None, error
return (
next(
(item for item in torrents if _torrent_matches_download_id(item, download_id)),
None,
),
None,
)
def _list_category_hashes(self, category: str | None) -> set[str] | None:
"""Snapshot the hashes qBittorrent currently reports for a category."""
torrents, error = self._get_torrents_info(category=category)
torrents, error = self._list_torrents_by_category(category)
if error:
logger.debug("Could not snapshot qBittorrent torrents: %s", error)
return None
@@ -397,7 +358,7 @@ class QBittorrentClient(DownloadClient):
torrent matching the requested rename can identify the new arrival.
"""
for _ in range(20):
torrents, error = self._get_torrents_info(category=category)
torrents, error = self._list_torrents_by_category(category)
if error:
logger.debug("qBittorrent hash discovery: %s", error)
else:
@@ -534,21 +495,22 @@ class QBittorrentClient(DownloadClient):
message = f"{message} (torrent file fetch failed: {torrent_info.fetch_error})"
_raise_runtime_error(message)
# Some qBittorrent-compatible clients return HTTP 200 with an empty body
# instead of qBittorrent's literal "Ok." response. Prefer verifying that
# the torrent becomes visible over trusting the response body alone.
for _ in range(10):
loaded, error = self._is_torrent_loaded(expected_hash)
# Wait until qBittorrent has resolved magnet metadata so the returned
# hash is its stable primary torrent ID, which may differ from the v1 hash.
for _ in range(20):
torrent, error = self._resolve_torrent(expected_hash, category)
if error:
logger.debug("qBittorrent add_download: %s", error)
if loaded:
logger.info("Added torrent: %s", expected_hash)
return expected_hash.lower()
elif torrent and getattr(torrent, "state", None) not in _METADATA_DOWNLOAD_STATES:
torrent_hash = getattr(torrent, "hash", None)
if isinstance(torrent_hash, str) and torrent_hash:
logger.info("Added torrent: %s", torrent_hash)
return torrent_hash.lower()
time.sleep(0.5)
logger.warning(
"Torrent add was not confirmed within the visibility grace period (response=%s), returning expected hash",
result_text,
_raise_runtime_error(
"Torrent metadata resolution was not confirmed within the visibility grace period "
f"(response={result_text})"
)
except _QBITTORRENT_CLIENT_ERRORS:
logger.exception("qBittorrent add failed")
@@ -567,19 +529,9 @@ class QBittorrentClient(DownloadClient):
"""
try:
torrents, error = self._get_torrents_info(download_id)
torrent, error = self._get_torrent_info(download_id)
if error:
return DownloadStatus.error(error)
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(t.hash, download_id)
),
None,
)
if not torrent:
return DownloadStatus.error("Torrent not found in qBittorrent")
@@ -705,20 +657,10 @@ class QBittorrentClient(DownloadClient):
- join `save_path` with the torrent's top-level directory
"""
try:
torrents, error = self._get_torrents_info(download_id)
torrent, error = self._get_torrent_info(download_id)
if error:
logger.debug("qBittorrent get_download_path: %s", error)
return None
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(t.hash, download_id)
),
None,
)
if not torrent:
return None
@@ -825,23 +767,19 @@ class QBittorrentClient(DownloadClient):
if not torrent_info.info_hash:
return None
torrents, error = self._get_torrents_info(torrent_info.info_hash)
if error:
logger.debug("qBittorrent find_existing: %s", error)
return None
torrent = next(
(
t
for t in torrents
if isinstance(getattr(t, "hash", None), str)
and _hashes_match(t.hash, torrent_info.info_hash)
),
None,
)
if torrent and isinstance(getattr(torrent, "hash", None), str):
torrent_hash = torrent.hash
return (torrent_hash.lower(), self.get_status(torrent_hash.lower()))
for _ in range(20):
torrent, error = self._resolve_torrent(torrent_info.info_hash, category)
if error:
logger.debug("qBittorrent find_existing: %s", error)
return None
if not torrent:
return None
if getattr(torrent, "state", None) not in _METADATA_DOWNLOAD_STATES:
torrent_hash = getattr(torrent, "hash", None)
if isinstance(torrent_hash, str) and torrent_hash:
torrent_hash = torrent_hash.lower()
return (torrent_hash, self.get_status(torrent_hash))
time.sleep(0.5)
except _QBITTORRENT_CLIENT_ERRORS as e:
logger.debug("Error checking for existing torrent: %s", e)
return None
+158
View File
@@ -0,0 +1,158 @@
"""RFC 8484 DNS wireformat encoding/decoding for DoH providers.
Providers split into two incompatible camps and the difference is not cosmetic:
* **JSON** (Cloudflare, Google) - ``?name=<host>&type=A`` returning a JSON body. A
convention, not a standard, and the only one Shelfmark used to speak.
* **Wireformat** (Quad9, OpenDNS) - RFC 8484 proper: a base64url-encoded DNS message
in ``?dns=``, answered with ``application/dns-message``. Quad9 additionally
*requires HTTP/2* per RFC 8484 section 5.2 and answers HTTP/1.1 with 505.
This module carries the codec only; the transport choice lives in the resolver.
Encoding a query is a handful of bytes, and parsing an answer needs message
compression support (RFC 1035 section 4.1.4) because answer names are almost always
pointers back into the question.
"""
from __future__ import annotations
import base64
import secrets
import struct
# Record types we resolve.
TYPE_A = 1
TYPE_AAAA = 28
_CLASS_IN = 1
_HEADER = struct.Struct(">HHHHHH")
_RR_FIXED = struct.Struct(">HHIH") # type, class, ttl, rdlength
_FLAG_RECURSION_DESIRED = 0x0100
_MAX_LABEL_JUMPS = 64 # cap pointer-following so a malicious answer cannot loop
_MAX_NAME_LENGTH = 255
class WireformatError(ValueError):
"""Raised when a DNS wireformat message cannot be parsed."""
def encode_query(hostname: str, record_type: int) -> bytes:
"""Build a DNS query message for ``hostname``.
The ID is zero because RFC 8484 section 4.1 requires it for cacheability, but the
caller may randomise it when not using a cache.
"""
if not hostname:
msg = "hostname must not be empty"
raise WireformatError(msg)
question = bytearray()
for label in hostname.rstrip(".").split("."):
encoded = label.encode("idna") if not label.isascii() else label.encode("ascii")
if not encoded or len(encoded) > 63:
msg = f"invalid DNS label in {hostname!r}"
raise WireformatError(msg)
question.append(len(encoded))
question.extend(encoded)
question.append(0)
question.extend(struct.pack(">HH", record_type, _CLASS_IN))
header = _HEADER.pack(0, _FLAG_RECURSION_DESIRED, 1, 0, 0, 0)
return header + bytes(question)
def encode_query_param(hostname: str, record_type: int) -> str:
"""Return the base64url ``dns=`` parameter value for a query (padding stripped)."""
return base64.urlsafe_b64encode(encode_query(hostname, record_type)).rstrip(b"=").decode()
def _read_name(message: bytes, offset: int) -> int:
"""Skip over a (possibly compressed) name, returning the offset after it."""
jumps = 0
length = 0
while True:
if offset >= len(message):
msg = "truncated DNS name"
raise WireformatError(msg)
label_len = message[offset]
if label_len == 0:
return offset + 1
if label_len & 0xC0 == 0xC0:
# A pointer ends this name; the rest of the record follows the 2 bytes.
if offset + 1 >= len(message):
msg = "truncated DNS name pointer"
raise WireformatError(msg)
return offset + 2
offset += 1 + label_len
length += 1 + label_len
jumps += 1
if jumps > _MAX_LABEL_JUMPS or length > _MAX_NAME_LENGTH:
msg = "malformed DNS name"
raise WireformatError(msg)
def decode_answer(message: bytes, record_type: int) -> list[str]:
"""Extract the IP addresses of ``record_type`` from a DNS response message.
Returns an empty list for a well-formed response that carries no matching record
(NXDOMAIN, or only CNAMEs), and raises WireformatError for a malformed one - the
caller treats those differently.
"""
if len(message) < _HEADER.size:
msg = "DNS response shorter than its header"
raise WireformatError(msg)
_id, _flags, qdcount, ancount, _ns, _ar = _HEADER.unpack_from(message, 0)
offset = _HEADER.size
for _ in range(qdcount):
offset = _read_name(message, offset)
offset += 4 # QTYPE + QCLASS
results: list[str] = []
for _ in range(ancount):
offset = _read_name(message, offset)
if offset + _RR_FIXED.size > len(message):
msg = "truncated resource record"
raise WireformatError(msg)
rtype, rclass, _ttl, rdlength = _RR_FIXED.unpack_from(message, offset)
offset += _RR_FIXED.size
rdata = message[offset : offset + rdlength]
if len(rdata) != rdlength:
msg = "truncated record data"
raise WireformatError(msg)
offset += rdlength
if rclass != _CLASS_IN or rtype != record_type:
continue
if rtype == TYPE_A and rdlength == 4:
results.append(".".join(str(b) for b in rdata))
elif rtype == TYPE_AAAA and rdlength == 16:
groups = struct.unpack(">8H", rdata)
results.append(_compress_ipv6(groups))
return results
def _compress_ipv6(groups: tuple[int, ...]) -> str:
"""Render an IPv6 address with the longest zero run collapsed to '::'."""
best_start = best_len = -1
run_start = -1
for i, group in enumerate([*list(groups), 1]): # sentinel closes a trailing run
if group == 0 and i < len(groups):
if run_start < 0:
run_start = i
elif run_start >= 0:
if i - run_start > best_len:
best_start, best_len = run_start, i - run_start
run_start = -1
parts = [format(g, "x") for g in groups]
if best_len > 1:
return ":".join(parts[:best_start]) + "::" + ":".join(parts[best_start + best_len :])
return ":".join(parts)
def random_query_id() -> int:
"""A random DNS message ID, for callers that do not want the RFC 8484 zero."""
return secrets.randbelow(0x10000)
+153 -6
View File
@@ -4,6 +4,7 @@ These utilities handle file collisions atomically, avoiding TOCTOU race conditio
when multiple workers may try to write to the same path simultaneously.
"""
import contextlib
import errno
import os
import shutil
@@ -104,6 +105,57 @@ _PUBLISH_VERIFY_RETRY_SECONDS = 0.25
_TEMPFILE_PREFIX = ".shelfmark."
_TEMPFILE_SUFFIX = ".tmp"
# Destinations that accept writes but reject unlink/rename, e.g. a Synology share
# with "Delete subfolders and files" unticked. Publishing a temp file into place
# removes a directory entry, so those paths must be written in place instead.
_DELETE_DENIED_DIRS: set[str] = set()
class _PublishDeniedError(Exception):
"""A fully-written temp file could not be renamed onto its final path."""
def _is_delete_denied_error(error: Exception) -> bool:
return isinstance(error, OSError) and error.errno in {errno.EACCES, errno.EPERM}
def mark_delete_denied(directory: Path) -> None:
"""Record that `directory` rejects deletes so later writes skip the temp file."""
key = str(directory)
if key in _DELETE_DENIED_DIRS:
return
_DELETE_DENIED_DIRS.add(key)
logger.warning(
"Destination %s rejects delete/rename; writing files in place instead of "
"publishing atomically. Grant delete permission to restore atomic writes.",
directory,
)
def clear_delete_denied(directory: Path) -> None:
"""Forget recorded denials for `directory` and anything beneath it.
Subdirectories get marked independently (an `organize` layout publishes into
per-author folders), so clearing only the exact key would leave a fixed
destination writing in place until restart.
"""
if not _DELETE_DENIED_DIRS:
return
key = str(directory)
prefix = f"{key}{os.sep}"
_DELETE_DENIED_DIRS.difference_update(
{marked for marked in _DELETE_DENIED_DIRS if marked == key or marked.startswith(prefix)}
)
def is_delete_denied(directory: Path) -> bool:
"""True if `directory` or one of its ancestors is known to reject deletes."""
if not _DELETE_DENIED_DIRS:
return False
if str(directory) in _DELETE_DENIED_DIRS:
return True
return any(str(parent) in _DELETE_DENIED_DIRS for parent in directory.parents)
def _verify_transfer_size(
dest: Path,
@@ -361,10 +413,40 @@ def _create_temp_path(dest_path: Path) -> Path:
return Path(temp_path)
def _discard_path(path: Path) -> None:
"""Best-effort unlink that tolerates destinations which reject deletes."""
try:
run_blocking_io(path.unlink, missing_ok=True)
except OSError as exc:
logger.warning("Could not remove %s: %s", path, exc)
def _copy_into_claimed(source_path: Path, dest_path: Path, expected_size: int) -> None:
"""Copy content straight into an already-claimed destination path.
Used when the destination rejects rename/unlink: there is no temp file to
publish, so the final name is written in place. This is not atomic - a
watcher can observe a partial file - but it is the only way to deliver on
such a share. `copyfile` (not `copy2`) because metadata copying needs chmod,
which those shares also tend to refuse.
"""
try:
run_blocking_io(shutil.copyfile, str(source_path), str(dest_path))
_verify_transfer_size(dest_path, expected_size, "copy")
except Exception:
with contextlib.suppress(OSError):
run_blocking_io(dest_path.unlink, missing_ok=True)
raise
def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
"""Publish a temp file to its final path without overwriting existing files.
Returns True on success, False if the destination already exists.
Raises `_PublishDeniedError` when the rename is refused for lack of delete
permission. The claimed destination is left in place so the caller can write
into it directly instead.
"""
claimed = _claim_destination(dest_path)
if not claimed:
@@ -374,7 +456,19 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
# Publish by renaming the fully-written temp file into place. This gives
# watchers an IN_MOVED_TO-style event on the final path instead of relying
# on hardlink support in the destination filesystem.
run_blocking_io(os.replace, str(temp_path), str(dest_path))
try:
run_blocking_io(os.replace, str(temp_path), str(dest_path))
except OSError as e:
if _is_delete_denied_error(e):
log_transfer_permission_context(
"publish_replace",
source=temp_path,
dest=dest_path,
error=e,
)
mark_delete_denied(dest_path.parent)
raise _PublishDeniedError(str(e)) from e
raise
# Best-effort nudge for watchers that only react to close-write on the
# final filename rather than rename/move events.
@@ -383,6 +477,8 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
run_blocking_io(os.close, fd)
except OSError:
pass
except _PublishDeniedError:
raise
except Exception as e:
if _is_permission_error(e):
log_transfer_permission_context(
@@ -391,12 +487,23 @@ def _publish_temp_file(temp_path: Path, dest_path: Path) -> bool:
dest=dest_path,
error=e,
)
run_blocking_io(dest_path.unlink, missing_ok=True)
_discard_path(dest_path)
raise
else:
return True
def _move_via_copy(source_path: Path, dest_path: Path, max_attempts: int) -> Path:
"""Deliver a move as copy + source unlink.
For destinations that reject rename. The source lives in TMP_DIR (which we
own and can delete), so only the destination-side semantics change.
"""
final_path = atomic_copy(source_path, dest_path, max_attempts=max_attempts)
_discard_path(source_path)
return final_path
def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
"""Move a file with collision detection.
@@ -423,6 +530,11 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
ext = dest_path.suffix
parent = dest_path.parent
# rename() removes a directory entry, so a destination that refuses deletes
# cannot be moved into. Deliver it as copy + source unlink instead.
if is_delete_denied(parent):
return _move_via_copy(source_path, dest_path, max_attempts)
for attempt in range(max_attempts):
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
@@ -449,6 +561,13 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
run_blocking_io(try_path.unlink, missing_ok=True)
continue
except OSError as e:
if _is_delete_denied_error(e):
# Destination refuses the rename; fall back to copy + unlink source.
mark_delete_denied(parent)
if claimed:
_discard_path(try_path)
return _move_via_copy(source_path, dest_path, max_attempts)
# Cross-filesystem - copy to temp and publish atomically.
if e.errno != errno.EXDEV:
if claimed:
@@ -497,7 +616,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
_verify_published_file(try_path, expected_size, "move")
except Exception:
run_blocking_io(try_path.unlink, missing_ok=True)
_discard_path(try_path)
raise
run_blocking_io(source_path.unlink)
@@ -508,9 +627,18 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
if temp_path:
run_blocking_io(temp_path.unlink, missing_ok=True)
continue
except _PublishDeniedError:
# Destination is claimed but unrenameable; write into it directly.
_copy_into_claimed(source_path, try_path, expected_size)
if temp_path:
_discard_path(temp_path)
_discard_path(source_path)
if attempt > 0:
logger.info("File collision resolved: %s", try_path.name)
return try_path
except Exception:
if temp_path:
run_blocking_io(temp_path.unlink, missing_ok=True)
_discard_path(temp_path)
raise
else:
return try_path
@@ -629,6 +757,17 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
if run_blocking_io(try_path.exists):
continue
# Known-undeletable destination: skip the temp file entirely, otherwise
# every transfer would strand a `.shelfmark.*.tmp` we cannot clean up.
if is_delete_denied(parent):
if not _claim_destination(try_path):
continue
_copy_into_claimed(source_path, try_path, expected_size)
if attempt > 0:
logger.info("File collision resolved: %s", try_path.name)
return try_path
temp_path: Path | None = None
try:
temp_path = _create_temp_path(try_path)
@@ -680,14 +819,22 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try:
_verify_published_file(try_path, expected_size, "copy")
except Exception:
run_blocking_io(try_path.unlink, missing_ok=True)
_discard_path(try_path)
raise
if attempt > 0:
logger.info("File collision resolved: %s", try_path.name)
except _PublishDeniedError:
# The destination is claimed but unrenameable; write into it directly.
_copy_into_claimed(source_path, try_path, expected_size)
if temp_path:
_discard_path(temp_path)
if attempt > 0:
logger.info("File collision resolved: %s", try_path.name)
return try_path
except Exception:
if temp_path:
run_blocking_io(temp_path.unlink, missing_ok=True)
_discard_path(temp_path)
raise
else:
return try_path
+236 -44
View File
@@ -4,7 +4,6 @@ import random
import time
from http import HTTPStatus
from io import BytesIO
from threading import Event, Thread
from typing import TYPE_CHECKING, NoReturn
from urllib.parse import urljoin, urlparse
@@ -16,24 +15,34 @@ from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_bool, normalize_positive_int
from shelfmark.download import network
from shelfmark.download.activity import release_activity_grace, request_activity_grace
from shelfmark.download.network import get_proxies, get_ssl_verify
if TYPE_CHECKING:
from collections.abc import Callable
from threading import Event
from types import ModuleType
logger = setup_logger(__name__)
_RNG = random.SystemRandom()
_MAX_REDIRECTS = 5
# Z-Library answers the first hit with a 503 whose only real payload is a Set-Cookie; echoing
# that cookie back returns the 302 to the real page. Two attempts cover the handshake without
# letting a server that keeps re-issuing cookies hold us in the loop.
_MAX_COOKIE_HANDSHAKE_RETRIES = 2
_HTTP_STATUS_FORBIDDEN = HTTPStatus.FORBIDDEN
_HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
_HTTP_STATUS_RATE_LIMITED = HTTPStatus.TOO_MANY_REQUESTS
_HTTP_STATUS_SERVICE_UNAVAILABLE = HTTPStatus.SERVICE_UNAVAILABLE
_HTTP_STATUS_OK = HTTPStatus.OK
_HTTP_STATUS_RANGE_NOT_SATISFIABLE = HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE
_HTTP_STATUS_PARTIAL_CONTENT = HTTPStatus.PARTIAL_CONTENT
_HTTP_STATUS_NON_RETRYABLE = (_HTTP_STATUS_FORBIDDEN, _HTTP_STATUS_NOT_FOUND)
_STATUS_CALLBACK_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
# Added on top of the active bypasser's own budget so it reports its real failure before
# stall detection cancels the download.
_BYPASS_GRACE_SLACK_SECONDS = 30.0
_BYPASSER_ERRORS = (
AttributeError,
BypassCancelledError,
@@ -54,6 +63,18 @@ def _raise_too_many_redirects(message: str) -> NoReturn:
raise requests.exceptions.TooManyRedirects(message)
def _new_cookies(response: requests.Response, already_sent: dict[str, str]) -> dict[str, str]:
"""Cookies a response set that we were not already echoing back.
Returning only the *new* ones is what makes the retry terminate: a server that keeps
re-issuing the same cookie yields nothing here, so we stop instead of spinning.
"""
jar = getattr(response, "cookies", None)
if not jar:
return {}
return {name: value for name, value in jar.items() if already_sent.get(name) != value}
def _get_internal_bypasser() -> ModuleType:
"""Lazy import of internal bypasser module."""
global _internal_bypasser
@@ -99,6 +120,19 @@ def _is_cf_bypass_enabled() -> bool:
return coerce_bool(app_config.get("USE_CF_BYPASS", True))
def _bypass_grace_seconds() -> float:
"""How long a bypass may block before stall detection should give up on it.
Each bypasser knows its own retry/timeout budget, so ask the active one rather than
duplicating the arithmetic here. The slack keeps the bypasser's own deadline expiring
first, so the user sees its real error instead of a generic "Download stalled".
"""
bypasser = (
_get_external_bypasser() if _is_using_external_bypasser() else _get_internal_bypasser()
)
return bypasser.max_duration_seconds() + _BYPASS_GRACE_SLACK_SECONDS
def get_bypassed_page(
url: str,
selector: network.AAMirrorSelector | None = None,
@@ -200,13 +234,50 @@ def _is_retryable_error(e: Exception) -> bool:
return status is not None and status in RETRYABLE_CODES
# Statuses that mean the host is gone rather than busy: 410 Gone and 451 Unavailable
# For Legal Reasons are what a seized domain answers with.
_DEAD_MIRROR_CODES = (410, 451)
def _fatal_mirror_reason(e: Exception) -> str | None:
"""Return why ``e`` proves the mirror is unusable, or None if it may recover.
Hard evidence only - the name does not resolve, nothing is listening, or the host
says it is gone for good. A timeout, a 5xx or a challenge all mean the mirror is
alive, and rotating off it discards the bypass clearance held for that domain.
"""
status = _get_status_code(e)
if status is not None and status in _DEAD_MIRROR_CODES:
return f"HTTP {status}"
# requests wraps the real cause; a read timeout subclasses ConnectionError for
# some adapters, so exclude timeouts explicitly before inspecting the message.
if isinstance(e, requests.exceptions.Timeout):
return None
if not isinstance(e, requests.exceptions.ConnectionError):
return None
text = str(e).lower()
if "nameresolutionerror" in text or "failed to resolve" in text or "name or service" in text:
return "DNS does not resolve"
if "connection refused" in text or "no route to host" in text:
return "connection refused"
return None
def _try_rotation(
original_url: str, current_url: str, selector: network.AAMirrorSelector
original_url: str,
current_url: str,
selector: network.AAMirrorSelector,
*,
fatal_reason: str | None = None,
) -> str | None:
"""Try mirror/DNS rotation. Returns new URL or None."""
aa_base_url = network.get_aa_base_url()
if aa_base_url and current_url.startswith(aa_base_url):
new_base, action = selector.next_mirror_or_rotate_dns()
new_base, action = selector.next_mirror_or_rotate_dns(
fatal=fatal_reason is not None, reason=fatal_reason or ""
)
if action in ("mirror", "dns") and new_base:
new_url = selector.rewrite(original_url)
logger.info("[%s] switching to: %s", action, new_url)
@@ -238,8 +309,11 @@ def html_get_page(
selector: Mirror selector used for AA mirror and DNS rotation.
cancel_flag: Optional event used to abort retries early.
status_callback: Optional callback for UI status updates.
allow_bypasser_fallback: If False, 403 errors will trigger mirror rotation
instead of switching to the bypasser. Use for search operations.
allow_bypasser_fallback: Whether a challenge may be handed to the bypasser.
If False, a 403 triggers mirror rotation instead, and an AA redirect loop
gives up immediately rather than waiting on a browser solve. Use False for
best-effort fetches whose result is optional (e.g. the download count on
the details modal); search and detail pages pass True.
use_bypasser: Whether to start with the bypasser instead of direct HTTP.
include_response_url: If True, return `(html, final_url)` to expose the
resolved response URL after redirects.
@@ -253,6 +327,73 @@ def html_get_page(
return html, response_url
return html
def _run_bypasser(bypass_url: str) -> str | tuple[str, str]:
"""Run the active bypasser for one URL and return its result.
Factored out so the redirect-loop handoff below can invoke it directly. That
call site sits inside the inner redirect `while`, so it cannot reach the
retry-loop branch above with `continue`, and with MAX_RETRY=1 there is no
later attempt for that branch to run on either.
"""
if status_callback:
status_callback("resolving", "Bypassing protection...")
try:
# A bypass is one long blocking call with no incremental progress, so
# tell the orchestrator up front how long it may legitimately take
# instead of trying to fake activity while it runs. Inside the try so a
# bypasser that fails to load is still reported as a bypasser error.
request_activity_grace(status_callback, _bypass_grace_seconds())
result = get_bypassed_page(bypass_url, selector, cancel_flag)
return _result(result or "", bypass_url)
except _BYPASSER_ERRORS as e:
logger.warning("Bypasser error: %s: %s", type(e).__name__, e)
# Surface the real reason. Without this the caller only sees an empty
# page and the download dies with a generic failure, hiding e.g. a
# FlareSolverr 500 behind a silent wait.
if status_callback and not isinstance(e, BypassCancelledError):
try:
status_callback("error", f"Bypass failed: {type(e).__name__}: {e}")
except _STATUS_CALLBACK_ERRORS:
logger.debug("Bypass error status callback failed", exc_info=True)
return _result("", bypass_url)
finally:
release_activity_grace(status_callback)
def _bypass_handoff_allowed() -> bool:
"""Whether a challenge on the current URL may be handed to the bypasser.
allow_bypasser_fallback is honoured for the same reason the 403 path honours it:
callers such as the /dyn/md5/summary fetch behind the details modal pass False
precisely so a best-effort request fails fast instead of holding the UI open for
a minutes-long browser solve.
"""
return allow_bypasser_fallback and _is_cf_bypass_enabled() and not use_bypasser_now
def _purge_clearance(target_url: str) -> None:
"""Drop the host's stored clearance cookies.
Called whenever the protection answered a request that *carried* cookies:
being challenged while presenting them proves they no longer work, so keeping
them only guarantees the same rejection on every later request. Purging is
internal-bypasser only; with an external one get_cf_cookies_for_domain()
already returns {}.
"""
hostname = urlparse(target_url).hostname or ""
# An empty domain means "clear every host" to the bypasser, so skip the purge
# rather than wipe clearance for sites that are working fine.
if hostname and not _is_using_external_bypasser():
_get_internal_bypasser().clear_cf_cookies(hostname)
def _redirect_loop_handoff(bypass_url: str) -> str | tuple[str, str]:
"""Drop the host's stale clearance cookies, then bypass `bypass_url`.
A `?check=1` loop is how DDoS-Guard answers a clearance cookie that has gone
stale, so the dead cookie has to go before the solve — otherwise it is merged
back over the fresh one on the next request and the loop simply resumes.
"""
_purge_clearance(bypass_url)
return _run_bypasser(bypass_url)
configured_retry = normalize_positive_int(app_config.MAX_RETRY)
retry_limit = (
retry if retry is not None else (configured_retry if configured_retry is not None else 1)
@@ -261,6 +402,9 @@ def html_get_page(
original_url = url
current_url = selector.rewrite(original_url)
use_bypasser_now = use_bypasser
# Survives across attempts so a cookie won once is still presented on later retries.
handshake_cookies: dict[str, str] = {}
handshake_retries = 0
for attempt in range(1, retry_limit + 1):
# Check for cancellation before each attempt
@@ -271,36 +415,7 @@ def html_get_page(
cookies: dict[str, str] = {}
try:
if use_bypasser_now and _is_cf_bypass_enabled():
if status_callback:
status_callback("resolving", "Bypassing protection...")
heartbeat_stop = Event()
heartbeat_thread: Thread | None = None
if status_callback:
def _heartbeat() -> None:
# Keep the download "alive" during long bypass operations so the orchestrator
# doesn't flag it as stalled.
if cancel_flag and cancel_flag.is_set():
return
try:
status_callback("resolving", "Bypassing protection...")
except _STATUS_CALLBACK_ERRORS:
return
heartbeat_thread = Thread(
target=_heartbeat, daemon=True, name="BypassHeartbeat"
)
heartbeat_thread.start()
try:
result = get_bypassed_page(current_url, selector, cancel_flag)
return _result(result or "", current_url)
except _BYPASSER_ERRORS as e:
logger.warning("Bypasser error: %s: %s", type(e).__name__, e)
return _result("", current_url)
finally:
heartbeat_stop.set()
if heartbeat_thread:
heartbeat_thread.join(timeout=1)
return _run_bypasser(current_url)
logger.debug("GET: %s", current_url)
@@ -322,12 +437,31 @@ def html_get_page(
current_url,
proxies=get_proxies(current_url),
timeout=REQUEST_TIMEOUT,
cookies=cookies,
# Bypasser-derived cookies win: they came from a real solved challenge.
cookies={**handshake_cookies, **cookies},
headers=headers,
allow_redirects=allow_redirects,
verify=get_ssl_verify(current_url),
)
# Z-Library gates the first hit with a 503 that carries nothing but a
# Set-Cookie; echoing it back yields the 302 to the real page. Without this
# the cookie is dropped and every retry re-runs the same rejected request.
if (
response.status_code == _HTTP_STATUS_SERVICE_UNAVAILABLE
and handshake_retries < _MAX_COOKIE_HANDSHAKE_RETRIES
):
issued = _new_cookies(response, handshake_cookies)
if issued:
handshake_cookies.update(issued)
handshake_retries += 1
logger.debug(
"503 set %s cookie(s); retrying with them: %s",
len(issued),
current_url,
)
continue
if is_aa_url and response.is_redirect:
location = response.headers.get("Location", "")
if not location:
@@ -356,6 +490,7 @@ def html_get_page(
current_url = new_url
# Reset per-request state for the new host.
headers = {"User-Agent": DOWNLOAD_HEADERS["User-Agent"]}
handshake_cookies.clear()
is_aa_url = network.should_rotate_dns_for_url(current_url)
allow_redirects = not is_aa_url
redirects_followed = 0
@@ -370,9 +505,35 @@ def html_get_page(
return _result("", current_url)
# Same-host redirect (relative or absolute) - follow manually.
# DDoS-Guard gates AA /search behind a cookie probe: the 302 to
# ?check=1 carries Set-Cookie (__ddg*) which must be echoed back on
# the next hop, or the server just re-issues the redirect forever.
issued = _new_cookies(response, handshake_cookies)
if issued:
handshake_cookies.update(issued)
redirects_followed += 1
if redirects_followed > _MAX_REDIRECTS:
_raise_too_many_redirects(f"Too many redirects for {current_url}")
# A same-host redirect loop on AA is not a network fault — it is
# how DDoS-Guard presents a handshake that is unsolved, or whose
# clearance cookie has gone stale: /search redirects to
# /search&check=1, which redirects back, indefinitely. Hand it
# straight to the bypasser rather than raising, which would send it
# down the retry path to re-run the whole loop on every attempt
# (10 x 6 = ~60 requests to AA) without ever offering the URL to the
# bypasser. `continue` is no use here either — it would target this
# inner redirect loop rather than the retry branch below.
if _bypass_handoff_allowed():
logger.info(
"Redirect loop detected; switching to bypasser: %s", current_url
)
return _redirect_loop_handoff(current_url)
# No bypasser to hand it to. Every AA mirror shares the challenge,
# so rotating only collects another loop — give up now instead of
# raising and burning the same ~60 requests over the retry budget.
logger.warning(
"Redirect loop and no bypasser available, giving up: %s", current_url
)
return _result("", current_url)
current_url = redirect_url
continue
@@ -384,6 +545,21 @@ def html_get_page(
except Exception as e:
status = _get_status_code(e)
# The same DDoS-Guard rescue, for the loops the manual AA follower above hands
# back rather than resolving inline — an AA redirect missing its Location
# header. TooManyRedirects carries no status, so the 403 rescue below never
# fires and every retry would re-send the dead cookies. Scoped to the hosts
# whose redirects we follow manually: elsewhere `requests` follows them itself,
# and a loop there is an ordinary misconfiguration that a cookie purge and a
# minutes-long browser solve would be the wrong answer to.
if (
isinstance(e, requests.exceptions.TooManyRedirects)
and network.should_rotate_dns_for_url(current_url)
and _bypass_handoff_allowed()
):
logger.info("Redirect loop detected; switching to bypasser: %s", current_url)
return _redirect_loop_handoff(current_url)
# 403 = Cloudflare/DDoS-Guard protection
if status == _HTTP_STATUS_FORBIDDEN:
# If bypasser fallback is disabled, try mirrors instead
@@ -407,11 +583,21 @@ def html_get_page(
current_url,
)
continue
if cookies:
# Challenged *while presenting* clearance: those cookies are
# dead. Without this they survive the solve and get merged back
# over the fresh ones, so every later request re-presents a
# known-rejected cookie and is challenged again - the stale
# retry that never ends.
logger.debug("403 with cookies presented; purging: %s", current_url)
_purge_clearance(current_url)
logger.info("403 detected; switching to bypasser: %s", current_url)
if status_callback:
status_callback("resolving", "Bypassing protection...")
use_bypasser_now = True
continue
# Invoke it here rather than setting use_bypasser_now and continuing.
# The branch that acts on that flag runs at the top of the *next* retry
# attempt, so under the supported MAX_RETRY=1 there is no next attempt
# and the bypasser was never reached — a 403 simply ended the search.
# Same reasoning as the redirect-loop handoffs.
return _run_bypasser(current_url)
logger.warning("403 error, giving up: %s", current_url)
return _result("", current_url)
@@ -420,11 +606,17 @@ def html_get_page(
logger.warning("404 error: %s", current_url)
return _result("", current_url)
# Try mirror/DNS rotation on retryable errors
if _is_retryable_error(e):
new_url = _try_rotation(original_url, current_url, selector)
# Try mirror/DNS rotation on retryable errors. A failure that proves the
# mirror is unusable also drops it from this process's rotation, so the
# next search does not pay for it again.
fatal_reason = _fatal_mirror_reason(e)
if fatal_reason or _is_retryable_error(e):
new_url = _try_rotation(
original_url, current_url, selector, fatal_reason=fatal_reason
)
if new_url:
current_url = new_url
handshake_cookies.clear()
continue
# Retry with backoff
+220 -54
View File
@@ -11,6 +11,7 @@ from socket import AddressFamily, SocketKind
from typing import TYPE_CHECKING, Any, cast
import dns.resolver
import httpx
import requests
from dns.exception import DNSException
@@ -277,6 +278,14 @@ _current_aa_url_index = 0
_aa_urls: list[str] = [] # Initialized lazily in _initialize_aa_state()
_aa_base_url: str = "" # Current active AA URL
# Mirrors quarantined for this process: domains that are not a working AA mirror at
# all (NXDOMAIN, refused, or a 200 that isn't AA - seized/parked/for-sale domains all
# land here). Kept separate from ordinary failures: a 403 challenge or a 5xx means the
# mirror is alive and rotating away from it only discards the DDoS-Guard clearance we
# hold for it. Deliberately in-memory only, so a restart re-probes everything.
_dead_aa_urls: set[str] = set()
_dead_aa_urls_lock = _RLock()
def _ensure_initialized() -> None:
"""Lazy guard so runtime setup happens once and late calls still work."""
@@ -298,6 +307,24 @@ DNS_PROVIDERS = [
("opendns", ["208.67.222.222", "208.67.220.220"], "https://doh.opendns.com/dns-query"),
]
# httpx raises its own hierarchy, which shares no base class with requests', so a
# wireformat failure would escape a requests-only except clause.
_DOH_REQUEST_ERRORS = (OSError, ValueError, requests.RequestException, httpx.HTTPError)
def _first_proxy(proxies: dict[str, str] | None) -> str | None:
"""Pick a single proxy URL from a requests-style mapping, for httpx."""
if not proxies:
return None
return proxies.get("https") or proxies.get("http") or None
# DoH providers that speak RFC 8484 wireformat rather than the (non-standard) JSON API
# Cloudflare and Google popularised. Verified against the live services: both reject a
# ?name=&type= query outright - Quad9 with 505 (it also mandates HTTP/2 per RFC 8484
# section 5.2, which requests cannot speak), OpenDNS with 400 "No valid query received".
_DOH_WIREFORMAT_HOSTS = frozenset({"dns.quad9.net", "doh.opendns.com"})
# Domain patterns that should trigger DNS rotation on failure
DNS_ROTATION_DOMAINS = [
"annas-archive",
@@ -462,8 +489,16 @@ class DoHResolver:
# DNS cache: {(hostname, record_type): (ip_list, timestamp)}
self._cache: dict[tuple[str, str], tuple[list[str], datetime]] = {}
# Different headers based on provider
if "google" in self.base_url:
# RFC 8484 providers get a separate transport: they need wireformat, and Quad9
# additionally refuses HTTP/1.1, which requests has no way to upgrade from.
self.use_wireformat = urllib.parse.urlparse(self.base_url).hostname in (
_DOH_WIREFORMAT_HOSTS
)
self._http2_client: Any | None = None
if self.use_wireformat:
self.session.headers.update({"Accept": "application/dns-message"})
elif "google" in self.base_url:
self.session.headers.update(
{
"Accept": "application/json",
@@ -476,6 +511,35 @@ class DoHResolver:
}
)
def _get_http2_client(self) -> Any:
"""Lazily build the HTTP/2 client used for RFC 8484 providers.
Built on first use so a resolver pointed at a JSON provider never opens an
HTTP/2 connection pool it will not use.
"""
if self._http2_client is None:
self._http2_client = httpx.Client(
http2=True,
timeout=10,
verify=get_ssl_verify(self.base_url),
proxy=_first_proxy(get_proxies(self.base_url)),
)
return self._http2_client
def _resolve_wireformat(self, hostname: str, record_type: str) -> list[str]:
"""Resolve via RFC 8484: base64url query in, DNS message out."""
from shelfmark.download import doh_wireformat
qtype = doh_wireformat.TYPE_AAAA if record_type == "AAAA" else doh_wireformat.TYPE_A
param = doh_wireformat.encode_query_param(hostname, qtype)
response = self._get_http2_client().get(
self.base_url,
params={"dns": param},
headers={"Accept": "application/dns-message"},
)
response.raise_for_status()
return doh_wireformat.decode_answer(response.content, qtype)
def _get_cached(self, hostname: str, record_type: str) -> list[str] | None:
"""Get cached DNS result if still valid."""
key = (hostname, record_type)
@@ -525,34 +589,37 @@ class DoHResolver:
return cached
try:
params = {"name": hostname, "type": "AAAA" if record_type == "AAAA" else "A"}
if self.use_wireformat:
answers = self._resolve_wireformat(hostname, record_type)
else:
params = {"name": hostname, "type": "AAAA" if record_type == "AAAA" else "A"}
response = self.session.get(
self.base_url,
params=params,
proxies=get_proxies(self.base_url),
timeout=10, # Increased from 5s to handle slow network conditions
verify=get_ssl_verify(self.base_url),
)
response.raise_for_status()
response = self.session.get(
self.base_url,
params=params,
proxies=get_proxies(self.base_url),
timeout=10, # Increased from 5s to handle slow network conditions
verify=get_ssl_verify(self.base_url),
)
response.raise_for_status()
data = response.json()
if "Answer" not in data:
logger.warning("DoH resolution failed for %s: %s", hostname, data)
return []
data = response.json()
if "Answer" not in data:
logger.warning("DoH resolution failed for %s: %s", hostname, data)
return []
# Extract IP addresses from the response
answers = [
answer["data"]
for answer in data["Answer"]
if answer.get("type") == (28 if record_type == "AAAA" else 1)
]
# Extract IP addresses from the response
answers = [
answer["data"]
for answer in data["Answer"]
if answer.get("type") == (28 if record_type == "AAAA" else 1)
]
# Cache the result
self._set_cached(hostname, record_type, answers)
# Don't log here - the caller (custom_getaddrinfo) will log the final result
except (OSError, ValueError, requests.RequestException) as e:
except _DOH_REQUEST_ERRORS as e:
logger.warning("DoH resolution failed for %s: %s", hostname, e)
return []
else:
@@ -616,8 +683,6 @@ def create_custom_getaddrinfo(
source: str,
provider_label: str,
res: Sequence[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]],
*,
is_bypass: bool = False,
) -> None:
"""Emit a unified resolver log with the IPs returned.
@@ -625,7 +690,6 @@ def create_custom_getaddrinfo(
source: Description of resolver source
provider_label: Label for the DNS provider
res: Resolution results
is_bypass: If True, log at DEBUG level (for local/IP addresses)
"""
# Skip logging entirely for localhost to reduce noise
@@ -641,11 +705,7 @@ def create_custom_getaddrinfo(
ip = sockaddr[0]
if isinstance(ip, str):
ips.append(ip)
msg = f"Resolved {host_str} via {source} [{provider_label}]: {ips}"
if is_bypass:
logger.debug(msg)
else:
logger.info(msg)
logger.debug("Resolved %s via %s [%s]: %s", host_str, source, provider_label, ips)
# Skip custom resolution for IP addresses, local addresses, or if skip check passes
if (
@@ -655,7 +715,7 @@ def create_custom_getaddrinfo(
):
# Quietly bypass custom resolution for IP/local targets
res = original_getaddrinfo(host, port, family, socket_type, proto, flags)
_log_results("system resolver (bypass)", "system", res, is_bypass=True)
_log_results("system resolver (bypass)", "system", res)
return res
results: list[tuple[AddressFamily, SocketKind, int, str, tuple[Any, ...]]] = []
@@ -1015,14 +1075,18 @@ def rotate_dns_and_reset_aa() -> bool:
configured_url = _get_configured_aa_url()
if configured_url == "auto":
# Auto mode always resets to the first mirror to restart the cascade
_current_aa_url_index = 0
if _aa_urls:
_aa_base_url = _aa_urls[0]
# Auto mode always resets to the first mirror to restart the cascade. Skip any
# quarantined ones: a new DNS provider cannot revive a parked or seized domain.
with _dead_aa_urls_lock:
restart_urls = [url for url in _aa_urls if url not in _dead_aa_urls] or _aa_urls
if restart_urls:
_aa_base_url = restart_urls[0]
_current_aa_url_index = _aa_urls.index(_aa_base_url)
logger.info("After DNS switch, resetting AA URL to: %s", _aa_base_url)
_save_state(aa_url=_aa_base_url)
else:
_aa_base_url = ""
_current_aa_url_index = 0
logger.info("After DNS switch, AA URL remains unconfigured")
else:
# Keep the user's configured primary mirror (if it exists in the list),
@@ -1192,8 +1256,17 @@ def _initialize_aa_state() -> None:
global _aa_base_url, _current_aa_url_index, _aa_urls
# Build URL list from config
previous_urls = _aa_urls
_aa_urls = _build_aa_urls()
# Drop quarantine decisions only when the mirror list itself changed - they were
# made about a list that no longer applies. This runs on every re-init (settings
# sync, DNS rotation, helper subprocess startup), and clearing unconditionally
# would resurrect a parked mirror mid-session.
if previous_urls != _aa_urls:
with _dead_aa_urls_lock:
_dead_aa_urls.clear()
# Get configured base URL from config
configured_url = _get_configured_aa_url()
@@ -1209,26 +1282,34 @@ def _initialize_aa_state() -> None:
return
if configured_url == "auto":
if state.get("aa_base_url") and state["aa_base_url"] in _aa_urls:
_current_aa_url_index = _aa_urls.index(state["aa_base_url"])
_aa_base_url = state["aa_base_url"]
# Never restore or probe a mirror quarantined this session: re-init happens
# often, and re-electing a parked domain costs a wasted request every time
# (its parking page answers 200, so the probe would happily pick it).
with _dead_aa_urls_lock:
candidates = [url for url in _aa_urls if url not in _dead_aa_urls]
restored = state.get("aa_base_url")
if restored and restored in candidates:
_current_aa_url_index = _aa_urls.index(restored)
_aa_base_url = restored
else:
logger.debug("AA_BASE_URL: auto, checking available urls %s", _aa_urls)
for i, url in enumerate(_aa_urls):
logger.debug("AA_BASE_URL: auto, checking available urls %s", candidates)
for url in candidates:
try:
response = requests.get(
url, proxies=get_proxies(url), timeout=3, verify=get_ssl_verify(url)
)
if response.status_code == HTTPStatus.OK:
_current_aa_url_index = i
_current_aa_url_index = _aa_urls.index(url)
_aa_base_url = url
_save_state(aa_url=_aa_base_url)
break
except (OSError, requests.RequestException) as exc:
logger.debug("Could not reach AA mirror candidate %s: %s", url, exc)
if not _aa_base_url or _aa_base_url == "auto":
_aa_base_url = _aa_urls[0]
_current_aa_url_index = 0
# Also covers the case where every probe failed and the previous base is
# itself quarantined - keeping it would aim the next search at a dead host.
if not _aa_base_url or _aa_base_url == "auto" or _aa_base_url not in candidates:
_aa_base_url = (candidates or _aa_urls)[0]
_current_aa_url_index = _aa_urls.index(_aa_base_url)
elif configured_url not in _aa_urls:
logger.info("AA_BASE_URL set to custom value %s; skipping auto-switch", configured_url)
_aa_base_url = configured_url
@@ -1326,24 +1407,77 @@ def is_aa_auto_mode() -> bool:
def get_available_aa_urls() -> list[str]:
"""Get list of configured AA URLs (copy)."""
"""Get configured AA URLs (copy), minus any quarantined this process.
Falls back to the full list when every mirror has been quarantined: a wrong
classification must not leave the app with nowhere to search.
"""
_ensure_initialized()
return _aa_urls.copy()
with _dead_aa_urls_lock:
alive = [url for url in _aa_urls if url not in _dead_aa_urls]
if not alive and _aa_urls:
logger.warning("All AA mirrors quarantined; retrying the full list")
_dead_aa_urls.clear()
return _aa_urls.copy()
return alive
def set_aa_url_index(new_index: int) -> bool:
"""Set AA base URL by index in available list; returns True if applied."""
def _aa_base_for_url(url: str) -> str:
"""Return the configured mirror base that ``url`` belongs to, if any."""
for base in _aa_urls:
if base and url.startswith(base):
return base
return ""
def mark_aa_url_dead(url: str, reason: str) -> bool:
"""Quarantine an AA mirror for the rest of this process.
Only for hard evidence that the host is not a working AA mirror. Transient
failures (403 challenge, 429, 5xx, timeouts) must never come through here -
quarantining a live mirror throws away its bypass clearance.
"""
_ensure_initialized()
base = _aa_base_for_url(url) or url
with _dead_aa_urls_lock:
if base not in _aa_urls or base in _dead_aa_urls:
return False
# Keep at least one mirror in play, even if it is the failing one.
if len([u for u in _aa_urls if u not in _dead_aa_urls]) <= 1:
logger.warning("Not quarantining last remaining AA mirror %s (%s)", base, reason)
return False
_dead_aa_urls.add(base)
logger.warning("Quarantined AA mirror %s for this session: %s", base, reason)
return True
def get_dead_aa_urls() -> set[str]:
"""Return the mirrors quarantined this process (copy)."""
with _dead_aa_urls_lock:
return set(_dead_aa_urls)
def set_aa_url(url: str) -> bool:
"""Set the active AA base URL; returns True if applied."""
_ensure_initialized()
global _aa_base_url, _current_aa_url_index
if new_index < 0 or new_index >= len(_aa_urls):
if url not in _aa_urls:
return False
_current_aa_url_index = new_index
_aa_base_url = _aa_urls[_current_aa_url_index]
_current_aa_url_index = _aa_urls.index(url)
_aa_base_url = url
logger.info("Set AA URL to: %s", _aa_base_url)
_save_state(aa_url=_aa_base_url)
return True
def set_aa_url_index(new_index: int) -> bool:
"""Set AA base URL by index in the full configured list; True if applied."""
_ensure_initialized()
if new_index < 0 or new_index >= len(_aa_urls):
return False
return set_aa_url(_aa_urls[new_index])
class AAMirrorSelector:
"""Keep AA mirror switching consistent across call sites.
@@ -1357,6 +1491,10 @@ class AAMirrorSelector:
def _ensure_fresh_state(self, *, reset_attempts: bool = False) -> None:
_ensure_initialized()
self.aa_urls = get_available_aa_urls()
# Rotation walks the live mirrors, but rewriting has to recognise every
# configured base: a URL built before a mirror was quarantined still points at
# it, and failing to rewrite would send the retry back to the dead host.
self.all_aa_urls = _aa_urls.copy()
self._index = self._safe_index(get_aa_base_url())
self.current_base = self.aa_urls[self._index] if self.aa_urls else ""
if reset_attempts:
@@ -1369,16 +1507,41 @@ class AAMirrorSelector:
def rewrite(self, url: str) -> str:
"""Replace any known AA base in url with current_base."""
for base in self.aa_urls:
for base in self.all_aa_urls:
if url.startswith(base):
return url.replace(base, self.current_base, 1)
return url
def next_mirror_or_rotate_dns(self, *, allow_dns: bool = True) -> tuple[str | None, str]:
def quarantine_current(self, reason: str) -> bool:
"""Quarantine the mirror this selector is on (hard failures only)."""
if not self.current_base:
return False
dropped = mark_aa_url_dead(self.current_base, reason)
if dropped:
# Rebuild from the surviving mirrors so the dead one is out of the cycle.
self._ensure_fresh_state(reset_attempts=False)
return dropped
def next_mirror_or_rotate_dns(
self, *, allow_dns: bool = True, fatal: bool = False, reason: str = ""
) -> tuple[str | None, str]:
"""Advance to the next mirror or rotate DNS if needed.
``fatal`` marks the current mirror as not-an-AA-mirror (NXDOMAIN, refused, a
200 that isn't AA) and drops it from this process's rotation. Leave it False
for anything the mirror can recover from - a challenge or a 5xx means the host
is alive, and quarantining it would discard its bypass clearance.
Returns (new_base, action) where action is 'mirror', 'dns', or 'exhausted'.
"""
if fatal and self.quarantine_current(reason or "unusable mirror"):
# Quarantining rebuilt the state onto a surviving mirror, so that mirror is
# the next one to try - advancing again here would skip straight past it.
self.attempts_this_dns += 1
if self.current_base and is_aa_auto_mode():
set_aa_url(self.current_base)
return self.current_base, "mirror"
self.attempts_this_dns += 1
max_attempts = len(self.aa_urls) if is_aa_auto_mode() else 1
if self.attempts_this_dns >= max_attempts:
@@ -1391,8 +1554,11 @@ class AAMirrorSelector:
# Mirror is explicitly configured; do not fail over to other mirrors.
return None, "exhausted"
if not self.aa_urls:
return None, "exhausted"
next_index = (self._index + 1) % len(self.aa_urls)
set_aa_url_index(next_index)
set_aa_url(self.aa_urls[next_index])
self._ensure_fresh_state(reset_attempts=False)
return self.current_base, "mirror"
+112 -16
View File
@@ -12,7 +12,7 @@ from concurrent.futures import Future, ThreadPoolExecutor
from email.utils import parseaddr
from pathlib import Path
from threading import Event, Lock
from typing import Any
from typing import TYPE_CHECKING, Any
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
@@ -24,6 +24,7 @@ from shelfmark.core.request_helpers import (
)
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.core.utils import transform_cover_url
from shelfmark.download.activity import parse_activity_grace
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
from shelfmark.download.postprocess.router import post_process_download
@@ -33,6 +34,9 @@ from shelfmark.release_sources import (
get_source_display_name,
)
if TYPE_CHECKING:
from collections.abc import Iterable
logger = setup_logger(__name__)
_RNG = random.SystemRandom()
@@ -65,7 +69,16 @@ _last_progress_value: dict[str, float] = {}
# De-duplicate status updates (keep-alive updates shouldn't spam clients)
_last_status_event: dict[str, tuple[str, str | None]] = {}
STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled
# Absolute deadlines (time.time()) until which stall detection is suppressed for a task.
# Long single-shot operations (protection bypass, etc.) declare their own upper bound via
# `shelfmark.download.activity` instead of faking progress. See set_activity_grace().
_activity_grace: dict[str, float] = {}
# A caller cannot buy immortality: the largest grace any operation may request. Must stay
# above the largest budget any caller can declare (see http._bypass_grace_seconds).
_MAX_ACTIVITY_GRACE_SECONDS = 960.0
COORDINATOR_LOOP_ERROR_RETRY_DELAY = 1.0
# Ceiling for the exponential backoff applied to repeated coordinator loop failures.
_COORDINATOR_LOOP_ERROR_MAX_DELAY = 30.0
_PROGRESS_BROADCAST_START_PERCENT = 1
_PROGRESS_BROADCAST_COMPLETE_PERCENT = 99
_PROGRESS_BROADCAST_MIN_DELTA = 10
@@ -649,6 +662,17 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
update_download_progress(task_id, progress)
def status_callback(status: str, message: str | None = None) -> None:
# Liveness hint from a long single-shot operation, not a user-visible status.
# Handled here so it never reaches update_download_status (which dedupes status
# transitions on purpose). See shelfmark.download.activity.
grace = parse_activity_grace(status, message)
if grace is not None:
if grace > 0:
set_activity_grace(task_id, grace)
else:
clear_activity_grace(task_id)
return
status_key = status.lower()
if status_key == "error":
_capture_task_error(
@@ -886,6 +910,7 @@ def _cleanup_progress_tracking(task_id: str) -> None:
_last_activity.pop(task_id, None)
_last_progress_value.pop(task_id, None)
_last_status_event.pop(task_id, None)
_activity_grace.pop(task_id, None)
def _finalize_download_failure(task_id: str) -> None:
@@ -933,6 +958,66 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None:
ws_manager.broadcast_status_update(queue_status())
def set_activity_grace(book_id: str, seconds: float) -> None:
"""Suppress stall detection for `book_id` for up to `seconds` from now.
For long single-shot operations that cannot report incremental progress (protection
bypass being the motivating case). The grace is a single absolute deadline computed
once, so it cannot be extended into immortality by a keep-alive that carries no real
liveness information - an operation that hangs forever is still cancelled once its
declared budget expires.
Deliberately touches neither the queue nor the WebSocket: this is a liveness
assertion, not a user-visible status transition.
"""
grace = _config_float(seconds, 0.0)
grace = min(max(grace, 0.0), _MAX_ACTIVITY_GRACE_SECONDS)
with _progress_lock:
_activity_grace[book_id] = time.time() + grace
def clear_activity_grace(book_id: str) -> None:
"""Drop any activity grace for `book_id` and count this moment as activity.
Resetting `_last_activity` means a nested or abandoned grace degrades to a fresh
full STALL_TIMEOUT window rather than an immediate stall.
"""
with _progress_lock:
_activity_grace.pop(book_id, None)
_last_activity[book_id] = time.time()
def _find_stalled_tasks(task_ids: Iterable[str], now: float) -> list[str]:
"""Return the task ids with no activity inside STALL_TIMEOUT and no active grace.
Holds `_progress_lock` for dict reads only - never call into `book_queue` from here,
see _cancel_stalled_task().
"""
stalled: list[str] = []
with _progress_lock:
for task_id in task_ids:
last_active = _last_activity.get(task_id, now)
deadline = max(last_active + STALL_TIMEOUT, _activity_grace.get(task_id, 0.0))
if now > deadline:
stalled.append(task_id)
return stalled
def _cancel_stalled_task(task_id: str) -> None:
"""Cancel a stalled download.
Must be called WITHOUT `_progress_lock` held. `book_queue.cancel_download` runs the
terminal-status hooks, which reach a sqlite write that gevent does not patch; holding
the progress lock across that blocks the hub and every other download worker.
"""
logger.warning("Download stalled for %s, cancelling", task_id)
book_queue.cancel_download(task_id)
book_queue.update_status_message(
task_id,
f"Download stalled (no activity for {STALL_TIMEOUT}s)",
)
def concurrent_download_loop() -> None:
"""Run the main concurrent download coordinator."""
max_workers = normalize_positive_int(config.MAX_CONCURRENT_DOWNLOADS) or 1
@@ -942,6 +1027,7 @@ def concurrent_download_loop() -> None:
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="Download") as executor:
active_futures: dict[Future, tuple[str, Event]] = {} # Track active download futures
stalled_tasks: set[str] = set() # Track tasks already cancelled due to stall
consecutive_errors = 0
while True:
try:
@@ -992,19 +1078,14 @@ def concurrent_download_loop() -> None:
# Check for stalled downloads (no activity in STALL_TIMEOUT seconds)
current_time = time.time()
with _progress_lock:
for _future, (task_id, _cancel_flag) in list(active_futures.items()):
if task_id in stalled_tasks:
continue
last_active = _last_activity.get(task_id, current_time)
if current_time - last_active > STALL_TIMEOUT:
logger.warning("Download stalled for %s, cancelling", task_id)
book_queue.cancel_download(task_id)
book_queue.update_status_message(
task_id,
f"Download stalled (no activity for {STALL_TIMEOUT}s)",
)
stalled_tasks.add(task_id)
candidates = [
task_id
for _future, (task_id, _cancel_flag) in list(active_futures.items())
if task_id not in stalled_tasks
]
for task_id in _find_stalled_tasks(candidates, current_time):
_cancel_stalled_task(task_id)
stalled_tasks.add(task_id)
# Start new downloads if we have capacity
while len(active_futures) < max_workers:
@@ -1027,9 +1108,24 @@ def concurrent_download_loop() -> None:
# Brief sleep to prevent busy waiting
time.sleep(main_loop_sleep_time)
except (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError) as e:
consecutive_errors = 0
# This loop is the only thing driving the download queue; if it exits, nothing
# is ever picked up again and the app looks healthy while doing nothing (#823,
# #1166). A narrow exception list let gevent's LoopExit and friends through, so
# catch everything short of BaseException - GreenletExit and gevent.Timeout must
# still propagate, and the tests' loop-stopping sentinels derive from
# BaseException for exactly this reason.
except Exception as e: # noqa: BLE001 - coordinator loop must never die
consecutive_errors += 1
logger.error_trace("Download coordinator loop error: %s", e)
time.sleep(COORDINATOR_LOOP_ERROR_RETRY_DELAY)
# Back off when the failure is persistent so we don't spin at 1Hz forever,
# but keep the first delay unchanged for a normal transient blip.
time.sleep(
min(
COORDINATOR_LOOP_ERROR_RETRY_DELAY * 2 ** min(consecutive_errors - 1, 5),
_COORDINATOR_LOOP_ERROR_MAX_DELAY,
)
)
# Download coordinator thread (started explicitly via start())
+27 -4
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import contextlib
import uuid
from typing import TYPE_CHECKING
from shelfmark.core.logger import setup_logger
@@ -13,7 +12,11 @@ from shelfmark.core.utils import (
from shelfmark.core.utils import (
is_audiobook as check_audiobook,
)
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.fs import (
clear_delete_denied,
mark_delete_denied,
run_blocking_io,
)
from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.release_sources import get_source
@@ -25,6 +28,8 @@ if TYPE_CHECKING:
logger = setup_logger("shelfmark.download.postprocess.pipeline")
_WRITE_PROBE_NAME = ".shelfmark_write_test.tmp"
def validate_destination(
destination: Path, status_callback: Callable[[str, str | None], None]
@@ -52,7 +57,9 @@ def validate_destination(
status_callback("error", f"Cannot create destination: {destination} ({exc})")
return False
test_path = destination / f".shelfmark_write_test_{uuid.uuid4().hex}.tmp"
# Stable name: on shares that refuse deletes the probe file cannot be cleaned
# up, so reusing one name bounds the leftovers at a single hidden file.
test_path = destination / _WRITE_PROBE_NAME
try:
test_content = (
@@ -60,7 +67,6 @@ def validate_destination(
"It should've been automatically deleted. Feel free to delete it.\n"
)
run_blocking_io(test_path.write_text, test_content)
run_blocking_io(test_path.unlink, missing_ok=True)
except OSError as exc:
logger.debug("Destination write probe path: %s", test_path)
log_path_permission_context("destination_write_probe", destination)
@@ -71,6 +77,23 @@ def validate_destination(
run_blocking_io(destination.rmdir)
return False
try:
run_blocking_io(test_path.unlink, missing_ok=True)
except OSError as exc:
# Writable but not deletable, e.g. a Synology share with "Delete
# subfolders and files" unticked. Not fatal: record it so transfers write
# files in place instead of publishing a temp file via rename.
mark_delete_denied(destination)
logger.warning(
"Destination %s is writable but refuses deletes (%s); leaving probe file %s "
"behind and writing files in place",
destination,
exc,
test_path.name,
)
else:
clear_delete_denied(destination)
return True
+3 -2
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import AUDIOBOOK_FORMATS
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.archive import ArchiveExtractionError, extract_archive, is_archive
from shelfmark.download.fs import run_blocking_io
@@ -142,7 +143,7 @@ def scan_directory_tree(
is_audiobook = check_audiobook(content_type)
if is_audiobook:
trackable_exts = {".m4b", ".mp3", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".wav"}
trackable_exts = {f".{fmt}" for fmt in AUDIOBOOK_FORMATS}
else:
trackable_exts = {
".pdf",
@@ -367,7 +368,7 @@ def collect_staged_files(
is_audiobook = check_audiobook(task.content_type)
if is_audiobook:
trackable_exts = {".m4b", ".mp3", ".m4a", ".flac", ".ogg", ".wma", ".aac", ".wav"}
trackable_exts = {f".{fmt}" for fmt in AUDIOBOOK_FORMATS}
else:
trackable_exts = {
".pdf",
+134
View File
@@ -0,0 +1,134 @@
"""Boot-time warm-up of the direct-download source.
The first AA search after a cold start pays for the whole cold path at once: DNS
resolution, electing a live mirror, spinning up headless Chrome and solving the
DDoS-Guard challenge. That is tens of seconds with the user sat at the search box.
Running one throwaway search shortly after boot moves that cost off the user's first
search. It primes the DNS cache, elects (and quarantines) mirrors, and leaves the
clearance cookie in the bypasser's per-domain cache, so the first real search reuses
it instead of solving from scratch.
Runs on a daemon thread and swallows every failure: this is an optimisation, and a
source that is down at boot must not affect startup or health.
"""
from __future__ import annotations
import os
import threading
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Delay before the warm-up fires. Long enough that it does not compete with the rest
# of startup (and with a container's own health probe) for the first request.
_DEFAULT_DELAY_SECONDS = 15.0
_DEFAULT_QUERY = "The Great Gatsby"
_warmup_thread: threading.Thread | None = None
_warmup_lock = threading.Lock()
def _as_bool(value: object, *, default: bool) -> bool:
"""Coerce a config value that may arrive as a string, bool or None."""
if value is None:
return default
if isinstance(value, str):
from shelfmark.config.env import string_to_bool
return string_to_bool(value)
return bool(value)
def _setting(key: str, default: object) -> object:
"""Read a warm-up setting, preferring the deployment environment.
These keys are not in the settings registry, and ``config.get`` only consults the
environment for keys it knows about - so reading config alone silently ignored
SEARCH_WARMUP_ENABLED and always returned the default. Check os.environ first so
the documented switches actually work.
"""
raw = os.environ.get(key)
if raw is not None and raw.strip():
return raw
return config.get(key, default)
def is_enabled() -> bool:
"""Whether the boot-time warm-up search should run."""
if not _as_bool(_setting("SEARCH_WARMUP_ENABLED", True), default=True):
return False
# Nothing to warm if the source is off, and no challenge to pre-solve without
# the bypasser - a plain search is fast enough not to need this.
if not _as_bool(_setting("DIRECT_DOWNLOAD_ENABLED", True), default=True):
logger.debug("Search warm-up skipped: direct download disabled")
return False
return True
def warmup_query() -> str:
"""The query used to warm the source."""
raw = _setting("SEARCH_WARMUP_QUERY", _DEFAULT_QUERY)
query = str(raw).strip() if raw else ""
return query or _DEFAULT_QUERY
def run_warmup() -> bool:
"""Run one warm-up search. Returns True if it produced results.
Never raises: every failure mode here is one the next real search would hit
anyway, and reporting it is the search path's job, not the warm-up's.
"""
from shelfmark.core.mirrors import has_aa_mirror_configuration
if not has_aa_mirror_configuration():
logger.debug("Search warm-up skipped: no Anna's Archive mirrors configured")
return False
query = warmup_query()
logger.info("Warming up direct download search (%r)", query)
try:
from shelfmark.core.models import SearchFilters
from shelfmark.release_sources.direct_download import search_books
results = search_books(query, SearchFilters())
except Exception:
# Broad by design: a warm-up must never take the app down, and the source
# raises everything from network errors to parse failures.
logger.warning("Search warm-up did not complete; first user search may be slow")
logger.debug("Search warm-up failure detail", exc_info=True)
return False
if results:
logger.info("Search warm-up complete: %s results, source is ready", len(results))
return True
logger.info("Search warm-up returned no results; source reachable but empty")
return False
def start(delay_seconds: float = _DEFAULT_DELAY_SECONDS) -> bool:
"""Schedule the warm-up on a daemon thread. Safe to call multiple times."""
global _warmup_thread
if not is_enabled():
return False
with _warmup_lock:
if _warmup_thread is not None and _warmup_thread.is_alive():
logger.debug("Search warm-up already scheduled")
return False
def _run() -> None:
run_warmup()
_warmup_thread = threading.Timer(delay_seconds, _run)
_warmup_thread.daemon = True
_warmup_thread.name = "SearchWarmup"
_warmup_thread.start()
logger.debug("Search warm-up scheduled in %ss", delay_seconds)
return True
+15 -16
View File
@@ -38,7 +38,10 @@ from shelfmark.config.env import (
string_to_bool,
)
from shelfmark.config.security import _migrate_security_settings
from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE
from shelfmark.config.settings import (
_SUPPORTED_BOOK_LANGUAGE,
migrate_audiobook_format_settings,
)
from shelfmark.core.activity_view_state_service import ActivityViewStateService
from shelfmark.core.auth_modes import (
get_auth_check_admin_status,
@@ -80,8 +83,9 @@ from shelfmark.core.requests_service import (
sync_delivery_states_from_queue_status,
)
from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import normalize_base_path
from shelfmark.core.utils import AUDIOBOOK_FORMATS, normalize_base_path
from shelfmark.download import orchestrator as backend
from shelfmark.download import warmup
from shelfmark.release_sources import (
BrowseRecord,
Release,
@@ -118,7 +122,7 @@ BASE_PATH = normalize_base_path(normalize_optional_text(app_config.get("URL_BASE
app = Flask(__name__)
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 # Disable caching
app.config["APPLICATION_ROOT"] = BASE_PATH or "/"
wsgi_app = cast(Any, ProxyFix(app.wsgi_app))
wsgi_app = cast(Any, ProxyFix(app.wsgi_app, x_host=1, x_port=1))
if BASE_PATH:
wsgi_app = cast(Any, PrefixMiddleware(wsgi_app, BASE_PATH, bypass_paths={"/api/health"}))
app.wsgi_app = wsgi_app
@@ -168,6 +172,9 @@ except ImportError as e:
# Migrate legacy security settings if needed
_migrate_security_settings()
# Widen audiobook formats for installs that still carry the old m4b/mp3-only default
migrate_audiobook_format_settings()
# Initialize user database and register multi-user routes
# If CONFIG_DIR doesn't exist or is read-only, multi-user features will be disabled
_user_db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db")
@@ -200,6 +207,10 @@ except (sqlite3.OperationalError, OSError) as e:
# Start download coordinator
backend.start()
# Pre-solve the direct-download source's protection challenge in the background so the
# first user search does not pay for a cold Chrome bypass. Never blocks startup.
warmup.start()
# Rate limiting for login attempts
# Map usernames to their failed-attempt counters and lockout timestamps.
failed_login_attempts: dict[str, dict[str, Any]] = {}
@@ -319,19 +330,7 @@ def get_auth_mode() -> str:
_AUDIOBOOK_CATEGORY_RANGE = (3030, 3049)
_AUDIOBOOK_FORMAT_HINTS = frozenset(
{
"m4b",
"mp3",
"m4a",
"flac",
"ogg",
"wma",
"aac",
"wav",
"opus",
}
)
_AUDIOBOOK_FORMAT_HINTS = frozenset(AUDIOBOOK_FORMATS)
def _contains_audiobook_format_hint(value: Any) -> bool:
+3
View File
@@ -709,3 +709,6 @@ with suppress(ImportError):
with suppress(ImportError):
from shelfmark.metadata_providers import googlebooks as googlebooks
with suppress(ImportError):
from shelfmark.metadata_providers import moly as moly
+68 -3
View File
@@ -1,6 +1,7 @@
"""Hardcover.app metadata provider. Requires API key."""
import re
import time
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
@@ -553,6 +554,31 @@ TITLE_SUGGESTION_FIELDS = "title,alternative_titles"
TITLE_SUGGESTION_WEIGHTS = "5,2"
TITLE_SUGGESTION_SORT = "_text_match:desc,users_count:desc"
# Hardcover forwards `sort` to Typesense's `sort_by` and rejects the whole search
# if it does not like the value -- an unknown field, a bare field name with no
# direction, more than three keys. A rejected search comes back as HTTP 200 with
# no GraphQL errors and a null `results` body, which is otherwise indistinguishable
# from "nothing matched". An empty sort is always accepted, so fall back to it and
# keep the fallback sticky for a while rather than paying for a doomed request on
# every search.
SORT_FALLBACK = ""
SORT_FALLBACK_TTL = 900.0
_sort_fallback_until = 0.0
def _search_payload_rejected(result: dict[str, Any] | None) -> bool:
"""Report whether Hardcover answered a search with a null results body.
A search that genuinely matched nothing still returns a results object with
``found: 0``; only a rejected search nulls it out entirely.
"""
if not isinstance(result, dict):
return False
root = result.get("search", result)
if not isinstance(root, dict) or "results" not in root:
return False
return root["results"] is None
def _combine_headline_description(headline: str | None, description: str | None) -> str | None:
"""Combine headline (tagline) and description into a single description."""
@@ -1200,7 +1226,7 @@ class HardcoverProvider(MetadataProvider):
if not self.api_key or len(normalized_query) < HARDCOVER_MIN_TYPEAHEAD_QUERY_LENGTH:
return []
result = self._execute_query(
result = self._execute_search_query(
SEARCH_FIELD_OPTIONS_QUERY,
{
"query": normalized_query,
@@ -1431,7 +1457,7 @@ class HardcoverProvider(MetadataProvider):
logger.debug("Invalid Hardcover series id field value: %s", normalized_value)
return None
result = self._execute_query(
result = self._execute_search_query(
SEARCH_FIELD_OPTIONS_QUERY,
{
"query": normalized_value,
@@ -2386,7 +2412,7 @@ class HardcoverProvider(MetadataProvider):
variables["weights"] = search_weights
try:
result = self._execute_query(graphql_query, variables)
result = self._execute_search_query(graphql_query, variables)
if not result:
logger.debug("Hardcover search: No result from API")
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
@@ -2654,6 +2680,45 @@ class HardcoverProvider(MetadataProvider):
raise RuntimeError(msg) from e
return None
def _execute_search_query(self, query: str, variables: dict[str, Any]) -> dict | None:
"""Execute a search query, retrying without ``sort`` if Hardcover rejects it.
Returns None when the search was rejected, so callers report an empty
result rather than silently treating a failure as "nothing matched".
"""
global _sort_fallback_until
sort = variables.get("sort")
if sort and time.monotonic() < _sort_fallback_until:
variables = {**variables, "sort": SORT_FALLBACK}
sort = None
result = self._execute_query(query, variables)
if not _search_payload_rejected(result):
return result
if not sort:
logger.error(
"Hardcover rejected this search (query_type=%s, fields=%s) and returned "
"no result body",
variables.get("queryType", "Book"),
variables.get("fields"),
)
return None
logger.warning(
"Hardcover rejected sort '%s'; retrying searches without a sort order for %ss",
sort,
int(SORT_FALLBACK_TTL),
)
_sort_fallback_until = time.monotonic() + SORT_FALLBACK_TTL
retry = self._execute_query(query, {**variables, "sort": SORT_FALLBACK})
if _search_payload_rejected(retry):
logger.error("Hardcover rejected this search even without a sort order")
return None
return retry
def _parse_search_result(self, item: dict) -> BookMetadata | None:
"""Parse a search result item into BookMetadata."""
try:
+493
View File
@@ -0,0 +1,493 @@
"""Moly.hu metadata provider. Hungarian book catalog, no API key required.
Scraping approach (search URL, book-page structure, language mapping) adapted
from the Calibre Moly_hu plugin by Hoffer Csaba, Kloon, otapi, Dezso, Hokutya,
seeder and contributors (GPL v3, mobileread.com).
"""
import re
import threading
import time
import unicodedata
from collections import deque
from typing import Any, ClassVar
from urllib.parse import quote
import requests
from bs4 import BeautifulSoup, Tag
from shelfmark.core.cache import cacheable
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
ActionButton,
CheckboxField,
HeadingField,
SettingsField,
register_settings,
)
from shelfmark.download.network import get_ssl_verify
from shelfmark.metadata_providers import (
BookMetadata,
DisplayField,
MetadataProvider,
MetadataSearchOptions,
SearchField,
SearchType,
SortOrder,
TextSearchField,
register_provider,
)
logger = setup_logger(__name__)
MOLY_BASE_URL = "https://moly.hu"
MOLY_BOOK_URL = f"{MOLY_BASE_URL}/konyvek/"
MOLY_SEARCH_URL = f"{MOLY_BASE_URL}/kereses?query="
# Be polite: moly.hu is a small community site
RATE_LIMIT_REQUESTS = 30
RATE_LIMIT_WINDOW_SECONDS = 60
REQUEST_HEADERS = {
"User-Agent": ("Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"),
"Accept-Language": "hu,en;q=0.7",
}
ISBN_13_LENGTH = 13
# Moly tags its foreign-language editions; everything else is Hungarian.
# Mapping from the Calibre Moly_hu plugin.
_LANGUAGE_TAG_MAP = {
"angol nyelvű": "en",
"n\xe9met nyelvű": "de",
"francia nyelvű": "fr",
"olasz nyelvű": "it",
"spanyol nyelvű": "es",
"orosz nyelvű": "ru",
"t\xf6r\xf6k nyelvű": "tr",
"g\xf6r\xf6g nyelvű": "el",
"k\xednai nyelvű": "zh",
"jap\xe1n nyelvű": "ja",
}
class RateLimiter:
"""Simple sliding window rate limiter."""
def __init__(self, max_requests: int, window_seconds: int) -> None:
"""Initialize rate limiter with max requests per time window."""
self.max_requests = max_requests
self.window_seconds = window_seconds
self.timestamps: deque[float] = deque()
self.lock = threading.Lock()
def wait_if_needed(self) -> None:
"""Block until a request is allowed (thread-safe)."""
wait_time = 0.0
with self.lock:
now = time.time()
cutoff = now - self.window_seconds
while self.timestamps and self.timestamps[0] < cutoff:
self.timestamps.popleft()
if len(self.timestamps) >= self.max_requests:
wait_time = self.timestamps[0] + self.window_seconds - now
if wait_time > 0:
logger.debug("Rate limited, waiting %0.2fs", wait_time)
time.sleep(wait_time)
with self.lock:
now = time.time()
cutoff = now - self.window_seconds
while self.timestamps and self.timestamps[0] < cutoff:
self.timestamps.popleft()
self.timestamps.append(time.time())
_rate_limiter = RateLimiter(RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
def _clean_text(value: str | None) -> str | None:
"""Strip zero-width characters and collapse whitespace."""
if value is None:
return None
value = value.replace("​", "").replace("", "")
return " ".join(value.split())
def _normalize_for_match(value: str | None) -> str:
"""Accent-insensitive, punctuation-insensitive comparison form."""
if not value:
return ""
value = unicodedata.normalize("NFKD", value)
value = "".join(char for char in value if not unicodedata.combining(char))
value = "".join(char if char.isalnum() else " " for char in value)
return " ".join(value.lower().split())
def _absolute_url(url: str | None) -> str | None:
if not url:
return None
if url.startswith(("http://", "https://")):
return url
return MOLY_BASE_URL + url
def _valid_isbn(candidate: str) -> str | None:
"""Return a normalized ISBN-10/13 (digits, with optional X check digit), else None."""
digits = candidate.replace("-", "").strip()
if len(digits) == ISBN_13_LENGTH and digits.isdigit():
return digits
if len(digits) == 10 and re.fullmatch(r"\d{9}[\dXx]", digits):
return digits.upper()
return None
@register_provider("moly")
class MolyProvider(MetadataProvider):
"""Moly.hu metadata provider (HTML scraping, Hungarian catalog)."""
name = "moly"
display_name = "Moly.hu"
requires_auth = False
supported_sorts: ClassVar[tuple[SortOrder, ...]] = (SortOrder.RELEVANCE,)
search_fields: ClassVar[tuple[SearchField, ...]] = (
TextSearchField(
key="author",
label="Author",
description="Search by author name",
),
TextSearchField(
key="title",
label="Title",
description="Search by book title",
),
)
def __init__(self) -> None:
"""Initialize provider."""
self.session = requests.Session()
self.session.headers.update(REQUEST_HEADERS)
def is_available(self) -> bool:
"""Moly.hu needs no authentication."""
return True
def _fetch(self, url: str, timeout: int = 15) -> str | None:
_rate_limiter.wait_if_needed()
try:
response = self.session.get(url, timeout=timeout, verify=get_ssl_verify(MOLY_BASE_URL))
response.raise_for_status()
except requests.Timeout:
logger.warning("Moly.hu request timed out: %s", url)
return None
except requests.RequestException:
logger.exception("Moly.hu request failed: %s", url)
return None
return response.text
def search(self, options: MetadataSearchOptions) -> list[BookMetadata]:
"""Search moly.hu's site search."""
if options.search_type == SearchType.ISBN:
result = self.search_by_isbn(options.query)
return [result] if result else []
# Moly's search is a single ranked page; no server-side pagination.
if options.page > 1:
return []
author_value = (options.fields.get("author") or "").strip()
title_value = (options.fields.get("title") or "").strip()
terms = " ".join(t for t in (author_value, title_value) if t)
query = terms or options.query.strip()
if not query:
return []
fields_key = ":".join(f"{k}={v}" for k, v in sorted(options.fields.items()))
cache_key = f"{query}:{options.search_type.value}:{options.limit}:{fields_key}"
return self._search_cached(cache_key, query, options.limit) or []
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="moly:search")
def _search_cached(self, cache_key: str, query: str, limit: int) -> list[BookMetadata] | None:
# Return None (not []) on fetch failure so the failure is not cached.
html = self._fetch(MOLY_SEARCH_URL + quote(query.encode("utf-8")))
if html is None:
return None
soup = BeautifulSoup(html, "html.parser")
books: list[BookMetadata] = []
seen: set[str] = set()
for anchor in soup.select("#content div.search_area a.book_selector"):
href = anchor.get("href") or ""
match = re.search(r"/konyvek/([^/?#]+)", str(href))
if not match:
continue
slug = match.group(1)
if slug in seen:
continue
# No separator: moly wraps matched search terms in <strong> even
# mid-word ("Lis<strong>a</strong> Jewell"), so inserting one
# would split words at highlight boundaries.
text = _clean_text(anchor.get_text()) or ""
author, _, title = text.partition(":")
if not title:
# Result rows are "Author: Title"; skip anything else.
continue
author = author.strip()
title = title.strip()
seen.add(slug)
books.append(
BookMetadata(
provider=self.name,
provider_id=slug,
provider_display_name=self.display_name,
title=title,
authors=[author] if author else [],
cover_url=self._cover_for_result(soup, text),
source_url=MOLY_BOOK_URL + slug,
language="hu",
search_title=title,
search_author=author or None,
display_fields=self._result_display_fields(anchor),
)
)
if len(books) >= limit:
break
logger.info("Moly.hu search '%s' returned %s results", query, len(books))
return books
def _cover_for_result(self, soup: BeautifulSoup, result_text: str) -> str | None:
"""Find the search-result thumbnail whose alt matches 'Author: Title'."""
target = _normalize_for_match(result_text)
if not target:
return None
for img in soup.select("#content img.tooltip[alt]"):
if _normalize_for_match(str(img.get("alt") or "")) == target:
return _absolute_url(str(img.get("src") or "")) or None
return None
def _result_display_fields(self, anchor: Tag) -> list[DisplayField]:
fields: list[DisplayField] = []
parent = anchor.parent
if parent is None:
return fields
like = parent.select_one("span.like_count")
if like:
fields.append(
DisplayField(label="Rating", value=like.get_text(strip=True), icon="star")
)
series = parent.select_one('a[href*="/sorozatok/"]')
if series:
fields.append(
DisplayField(
label="Series",
value=series.get_text(strip=True).strip("()"),
icon="editions",
)
)
return fields
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="moly:book")
def get_book(self, book_id: str) -> BookMetadata | None:
"""Get book details by moly.hu slug (e.g. 'mocsidzuki-mai-a-telihold-kavezo')."""
html = self._fetch(MOLY_BOOK_URL + quote(book_id))
if html is None:
return None
soup = BeautifulSoup(html, "html.parser")
title = self._parse_title(soup)
authors = [_clean_text(a.get_text()) or "" for a in soup.select("#content div.authors a")]
authors = [a for a in authors if a]
if not title or not authors:
logger.warning("Moly.hu book page missing title/authors: %s", book_id)
return None
isbn_13, isbn_10 = self._parse_isbns(soup)
series = self._parse_series(soup)
tags = [_clean_text(t.get_text()) or "" for t in soup.select("#book_tags a.tag")]
tags = [t for t in tags if t]
display_fields: list[DisplayField] = []
rating = soup.select_one("#content .rating .like_count")
if rating:
display_fields.append(
DisplayField(label="Rating", value=rating.get_text(strip=True), icon="star")
)
if series:
display_fields.append(DisplayField(label="Series", value=series, icon="editions"))
return BookMetadata(
provider=self.name,
provider_id=book_id,
provider_display_name=self.display_name,
title=title,
authors=authors,
isbn_13=isbn_13,
isbn_10=isbn_10,
cover_url=self._parse_cover(soup),
description=self._parse_description(soup),
publisher=self._parse_publisher(soup),
publish_year=self._parse_publish_year(soup),
language=self._parse_language(tags),
genres=tags,
source_url=MOLY_BOOK_URL + book_id,
search_title=title,
search_author=authors[0],
display_fields=display_fields,
)
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="moly:isbn")
def search_by_isbn(self, isbn: str) -> BookMetadata | None:
"""Moly's site search resolves ISBN queries directly."""
isbn = isbn.replace("-", "").strip()
if not isbn:
return None
html = self._fetch(MOLY_SEARCH_URL + quote(isbn))
if html is None:
return None
soup = BeautifulSoup(html, "html.parser")
anchor = soup.select_one("#content div.search_area a.book_selector[href]")
if not anchor:
return None
match = re.search(r"/konyvek/([^/?#]+)", str(anchor.get("href")))
if not match:
return None
return self.get_book(match.group(1))
def _parse_title(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one("#content .head_title h1 span.item")
if node:
# The series link is nested inside this span; only direct text
# belongs to the book title.
direct = "".join(node.find_all(string=True, recursive=False))
title = _clean_text(direct)
if title:
return title
node = soup.select_one("#content .book > span")
if node:
return _clean_text(node.get_text())
return None
def _parse_series(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one('#content h1 a[href*="/sorozatok/"]')
if not node:
return None
return (_clean_text(node.get_text()) or "").strip("()") or None
def _parse_isbns(self, soup: BeautifulSoup) -> tuple[str | None, str | None]:
isbn_13 = isbn_10 = None
editions = soup.select("#content .items .edition") or soup.select("#content .items > div")
for edition in editions:
text = edition.get_text(" ")
for candidate in re.findall(r"(?<!\d)[\d-]{10,17}(?!\d)", text):
isbn = _valid_isbn(candidate)
if not isbn:
continue
if len(isbn) == ISBN_13_LENGTH and not isbn_13:
isbn_13 = isbn
elif len(isbn) != ISBN_13_LENGTH and not isbn_10:
isbn_10 = isbn
if isbn_13:
break
return isbn_13, isbn_10
def _parse_cover(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one("#content .coverbox a.zoom[href]")
if node:
return _absolute_url(str(node.get("href")))
img = soup.select_one("#content .coverbox img[src]")
if img:
return _absolute_url(str(img.get("src")))
return None
def _parse_description(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one("#content #full_description")
if node is None:
node = soup.select_one("#content div.text")
if node is None:
return None
spoiler_warning = "Vigyázat! Cselekményleírást tartalmaz."
parts = []
for text in node.stripped_strings:
cleaned = _clean_text(text) or ""
if cleaned.startswith(spoiler_warning):
cleaned = cleaned[len(spoiler_warning) :].strip()
if cleaned:
parts.append(cleaned)
return "\n".join(parts) or None
def _parse_publisher(self, soup: BeautifulSoup) -> str | None:
node = soup.select_one('#content .items .edition a[href*="/kiadok/"]')
if node:
return _clean_text(node.get_text())
return None
def _parse_publish_year(self, soup: BeautifulSoup) -> int | None:
editions = soup.select("#content .items .edition") or soup.select("#content .items > div")
for edition in editions:
match = re.search(r"\b(\d{4})\b", edition.get_text(" "))
if match:
return int(match.group(1))
return None
def _parse_language(self, tags: list[str]) -> str:
for tag in tags:
code = _LANGUAGE_TAG_MAP.get(tag.lower().strip())
if code:
return code
return "hu"
def _test_moly_connection() -> dict[str, Any]:
"""Test connectivity to moly.hu."""
try:
provider = MolyProvider()
response = provider.session.get(
MOLY_SEARCH_URL + quote("teszt"),
timeout=10,
verify=get_ssl_verify(MOLY_BASE_URL),
)
response.raise_for_status()
except requests.Timeout:
return {"success": False, "message": "Connection timed out"}
except requests.RequestException as e:
return {"success": False, "message": f"Connection failed: {e}"}
if "moly" in response.text.lower():
return {"success": True, "message": "Successfully connected to moly.hu"}
return {"success": False, "message": "Unexpected response from moly.hu"}
@register_settings("moly", "Moly.hu", icon="library", order=54, group="metadata_providers")
def moly_settings() -> list[SettingsField]:
"""Moly.hu metadata provider settings."""
return [
HeadingField(
key="moly_heading",
title="Moly.hu",
description=(
"Hungarian community book catalog with excellent coverage of "
"Hungarian editions and translations. No API key required."
),
link_url="https://moly.hu",
link_text="moly.hu",
),
CheckboxField(
key="MOLY_ENABLED",
label="Enable Moly.hu",
description="Enable Moly.hu as a metadata provider for book searches",
default=False,
),
ActionButton(
key="test_connection",
label="Test Connection",
description="Verify moly.hu is accessible",
style="primary",
callback=_test_moly_connection,
),
]
+81 -10
View File
@@ -539,6 +539,79 @@ class SearchUnavailableError(SourceUnavailableError):
"""Raised when Anna's Archive cannot be reached via any mirror/DNS."""
# Markers that prove a 200 really came from Anna's Archive, and markers that mean we
# are looking at a protection interstitial rather than the site. A page with neither
# is a domain that answers but is not AA - seized, parked or for sale.
#
# Deliberately structural rather than the domain name: a parking page's whole job is
# to display the domain it is squatting on, so "annas-archive" matches the very pages
# this is meant to catch. These paths only exist on the real site.
_AA_PAGE_MARKERS = (
"/md5/",
"aarecord",
"anna's archive",
"/dyn/",
"/datasets",
"/fast_download",
"/slow_download",
)
_CHALLENGE_MARKERS = (
"ddos-guard",
"just a moment",
"cloudflare",
"checking your browser",
"cf-browser-verification",
)
def _looks_like_aa_page(html: str) -> bool:
"""Whether ``html`` is recognisably Anna's Archive, or a challenge in front of it."""
lowered = html.lower()
return any(marker in lowered for marker in (*_AA_PAGE_MARKERS, *_CHALLENGE_MARKERS))
def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[str, Tag | None]:
"""Fetch the AA search page, retrying past mirrors that are not actually AA.
A parked or seized domain answers 200 with a page that has no results table and no
"No files found." - indistinguishable from a broken search unless we check whether
the response looks like AA at all. Those mirrors are quarantined for the session so
later searches skip them instead of paying the timeout again.
"""
attempt_url = url
for _ in range(len(network.get_available_aa_urls()) or 1):
response = downloader.html_get_page(
attempt_url, selector=selector, allow_bypasser_fallback=True
)
if not response:
# Network/mirror exhaustion path bubbles up so API can notify clients
msg = "Unable to reach download source. Network restricted or mirrors are blocked."
raise SearchUnavailableError(msg)
html = _html_response_text(response)
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
if isinstance(table, Tag):
return html, table
if table is not None:
msg = f"Expected results table tag, got {type(table).__name__}"
raise TypeError(msg)
if "No files found." in html or _looks_like_aa_page(html):
# A real AA response - either genuinely empty, or a shape the caller
# should report as drift. Not the mirror's fault.
return html, None
new_base, action = selector.next_mirror_or_rotate_dns(
fatal=True, reason="responded without an Anna's Archive page"
)
if action not in ("mirror", "dns") or not new_base:
return html, None
attempt_url = selector.rewrite(url)
logger.info("Retrying search on %s", new_base)
return "", None
def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
"""Search for books matching the query.
@@ -601,15 +674,9 @@ def search_books(query: str, filters: SearchFilters) -> list[BrowseRecord]:
f"{filters_query}"
)
html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=False)
if not html:
# Network/mirror exhaustion path bubbles up so API can notify clients
msg = "Unable to reach download source. Network restricted or mirrors are blocked."
raise SearchUnavailableError(msg)
soup = BeautifulSoup(_html_response_text(html), "html.parser")
tbody = soup.find("table")
# AA gates /search behind a DDoS-Guard JS challenge, which every mirror shares. Rotating
# to another mirror only collects another 403, so let the bypasser solve it.
html, tbody = _fetch_search_table(url, selector)
if tbody is None:
if "No files found." in html:
logger.info("No books found for query: %s", query)
@@ -657,7 +724,8 @@ def get_book_info(book_id: str, *, fetch_download_count: bool = True) -> BrowseR
"""
url = f"{network.get_aa_base_url()}/md5/{book_id}"
selector = network.AAMirrorSelector()
html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=False)
# Same challenge as search: the detail page is gated on every mirror, so bypass it.
html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=True)
if not html:
msg = "Unable to reach download source. Network restricted or mirrors are blocked."
@@ -885,6 +953,9 @@ def _parse_book_info_page(
if fetch_download_count:
try:
summary_url = f"{network.get_aa_base_url()}/dyn/md5/summary/{book_id}"
# Unlike search and the detail page above, this one stays off the bypasser: a
# download count is decoration on the details modal, not worth holding the
# modal open for a browser solve. If it is gated, drop it and move on.
summary_response = downloader.html_get_page(
summary_url, selector=network.AAMirrorSelector(), allow_bypasser_fallback=False
)
+64 -23
View File
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS
from shelfmark.core.utils import is_audiobook as check_audiobook
if TYPE_CHECKING:
@@ -18,11 +19,8 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
# All recognized formats for parsing IRC result lines.
# This comprehensive list is used to identify file extensions in results.
# User-configured formats are used separately for filtering.
ALL_RECOGNIZED_FORMATS = {
# Ebook formats
# Ebook formats recognized in IRC result lines.
EBOOK_FORMATS = (
"epub",
"mobi",
"azw3",
@@ -41,19 +39,17 @@ ALL_RECOGNIZED_FORMATS = {
"cbz",
"cdr",
"jpg",
"rar",
"zip",
# Audiobook formats
"m4b",
"mp3",
"m4a",
"flac",
"ogg",
"wma",
"aac",
"wav",
"opus",
}
)
# All recognized formats for parsing IRC result lines.
# This comprehensive list is used to identify file extensions in results.
# User-configured formats are used separately for filtering.
# Ordered longest-first so that scanning a line matches "azw3" before "azw" and "docx"
# before "doc". It used to be a set, which made the winning format for a line naming more
# than one extension depend on set iteration order, and therefore vary between restarts.
ALL_RECOGNIZED_FORMATS = tuple(
sorted({*EBOOK_FORMATS, *ARCHIVE_FORMATS, *AUDIOBOOK_FORMATS}, key=len, reverse=True)
)
def _normalize_config_formats(raw_formats: object) -> set[str]:
@@ -84,13 +80,22 @@ def _get_supported_formats(content_type: str | None = None) -> set[str]:
# Regex to parse result lines
# Format: !Server Author - Title.format ::INFO:: size
#
# The extension is matched against the known formats rather than a bare \w+. A bare \w+
# happily matched the decimal point in the size, so a line with no file extension parsed
# as format="5mb" out of "::INFO:: 620.5MB" - taking the title and size down with it, and
# leaving the result to be discarded by every format filter downstream. Restricting the
# alternation makes such a line fall through to SIMPLE_RESULT_REGEX and come back as
# "unknown", which is what the rest of the parser already expects.
_FORMAT_ALTERNATION = "|".join(re.escape(fmt) for fmt in ALL_RECOGNIZED_FORMATS)
RESULT_LINE_REGEX = re.compile(
r"^!(\S+)\s+" # !ServerName
r"(.+?)\s+-\s+" # Author Name -
r"(.+?)\.(\w+)" # Title.format
rf"(.+?)\.({_FORMAT_ALTERNATION})\b" # Title.format
r"(?:\s+::INFO::\s*(.+?))?" # Optional ::INFO:: metadata
r"(?:\s+::HASH::\s*(\S+))?" # Optional ::HASH::
r"\s*$"
r"\s*$",
re.IGNORECASE,
)
# Simpler fallback pattern
@@ -187,18 +192,54 @@ def parse_result_line(line: str) -> SearchResult | None:
return None
# Words that mark an archive as holding an audiobook rather than an ebook. Multi-file
# audiobooks ship as .rar/.zip, so for those the extension says nothing about the content
# and the release name is the only evidence there is.
_AUDIOBOOK_MARKER_REGEX = re.compile(
r"\b(?:audio ?books?|unabridged|abridged|narrat(?:ed|or)|audible|\d+ ?kbps|"
+ "|".join(re.escape(fmt) for fmt in AUDIOBOOK_FORMATS)
+ r")\b",
re.IGNORECASE,
)
_AUDIOBOOK_FORMAT_SET = frozenset(AUDIOBOOK_FORMATS)
_EBOOK_FORMAT_SET = frozenset(EBOOK_FORMATS)
def detect_content_type(result: SearchResult) -> str:
"""Classify a parsed result as an audiobook or an ebook.
Extension alone is not enough. It settles the plain cases, but the common audiobook
release is a .rar or .zip of MP3s, which is indistinguishable by extension from an
ebook archive - so for containers (and for lines with no usable extension) the
release name decides.
"""
if result.format in _AUDIOBOOK_FORMAT_SET:
return "audiobook"
if result.format in _EBOOK_FORMAT_SET:
return "ebook"
return "audiobook" if _AUDIOBOOK_MARKER_REGEX.search(result.full_line) else "ebook"
def parse_results_file(content: str, content_type: str | None = None) -> list[SearchResult]:
"""Parse a search results file into SearchResult objects."""
results = []
supported = _get_supported_formats(content_type)
requested = "audiobook" if check_audiobook(content_type) else "ebook"
for line in content.splitlines():
result = parse_result_line(line)
if result and (result.format in supported or result.format == "unknown"):
# Filter to user's configured formats
if not result:
continue
# Classify first, then apply the user's format filter within that bucket. Doing it
# the other way round is what lost audiobooks entirely: an audiobook .rar matched
# neither the ebook nor the audiobook format list, so it fell out of both.
if detect_content_type(result) != requested:
continue
if result.format in supported or result.format == "unknown":
results.append(result)
logger.info("Parsed %s results from search file", len(results))
logger.info("Parsed %s %s results from search file", len(results), requested)
return results
+10 -6
View File
@@ -102,10 +102,13 @@ def irc_settings() -> list[SettingsField]:
key="audiobook_heading",
title="Audiobooks",
description=(
"Some networks index audiobooks in a separate channel from ebooks "
"(for example #ebooks for ebooks and #bookz for audiobooks). "
"Configure that channel here to search it for audiobook requests. "
"Leave these blank to search the main channel above for both."
"Most networks index audiobooks in the same channel as ebooks, so leaving "
"these blank is the right setting for almost everyone. On irc.irchighway.net "
"the audiobooks are in #ebooks and #bookz is effectively inactive — pointing "
"this at an empty channel just returns no results. Only fill these in when "
"your network really does index audiobooks elsewhere (Undernet's #bookz, for "
"example). Audiobooks are usually posted as archives, so keep ZIP and RAR "
"enabled under Supported Audiobook Formats or the releases are filtered out."
),
),
TextField(
@@ -113,8 +116,9 @@ def irc_settings() -> list[SettingsField]:
label="Audiobook channel",
placeholder="e.g. bookz",
description=(
"Optional. Channel name (without the # prefix) to use for audiobook "
"searches. Leave blank to use the main channel above for audiobooks too."
"Optional. Channel name (without the # prefix) for networks that index "
"audiobooks separately, such as Undernet's bookz. Leave blank (the usual "
"setting) to search the main channel above for audiobooks too."
),
required=False,
env_supported=True,
+5 -5
View File
@@ -240,11 +240,11 @@ class IRCReleaseSource(ReleaseSource):
nick = _config_text("IRC_NICK")
search_bot = _config_text("IRC_SEARCH_BOT")
# Audiobooks may be indexed in a separate channel from ebooks on some networks
# (e.g. #ebooks for ebooks, #bookz for audiobooks). When an audiobook channel is
# configured and an audiobook was requested, route the search there (with its own
# search bot if set). Otherwise fall back to the main channel/bot, which keeps the
# single-channel networks that index both formats working unchanged.
# A few networks index audiobooks in a separate channel from ebooks (Undernet's
# #bookz, say). When an audiobook channel is configured and an audiobook was
# requested, route the search there (with its own search bot if set). Otherwise
# fall back to the main channel/bot — that is the common case, since most networks
# (irchighway included) serve both formats from the one channel.
if is_audiobook(content_type):
audiobook_channel = _config_text("IRC_AUDIOBOOK_CHANNEL")
if audiobook_channel:
-4
View File
@@ -14,10 +14,6 @@ from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
logger = setup_logger(__name__)
# Newznab standard book category IDs
NEWZNAB_BOOKS = 7000
NEWZNAB_AUDIOBOOKS = 3030
class NewznabClient:
"""Client for any Newznab-compatible indexer API."""
@@ -8,6 +8,7 @@ from shelfmark.core.settings_registry import (
HeadingField,
PasswordField,
SettingsField,
TagListField,
TextField,
register_settings,
)
@@ -86,6 +87,30 @@ def newznab_config_settings() -> list[SettingsField]:
callback=_test_newznab_connection,
show_when={"field": "NEWZNAB_ENABLED", "value": True},
),
TagListField(
key="NEWZNAB_EBOOK_CATEGORIES",
label="Ebook Categories",
description=(
"Newznab category IDs searched for ebooks. Most indexers use the standard 7000, "
"but some use custom IDs. Leave empty to use 7000."
),
placeholder="7000",
default=["7000"],
normalize_urls=False,
show_when={"field": "NEWZNAB_ENABLED", "value": True},
),
TagListField(
key="NEWZNAB_AUDIOBOOK_CATEGORIES",
label="Audiobook Categories",
description=(
"Newznab category IDs searched for audiobooks. Most indexers use the standard "
"3030, but some use custom IDs. Leave empty to use 3030."
),
placeholder="3030",
default=["3030"],
normalize_urls=False,
show_when={"field": "NEWZNAB_ENABLED", "value": True},
),
CheckboxField(
key="NEWZNAB_AUTO_EXPAND",
label="Auto-expand search on no results",
+86 -12
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
import time
from typing import TYPE_CHECKING, ClassVar
@@ -39,15 +40,93 @@ from shelfmark.release_sources.prowlarr.source import (
logger = setup_logger(__name__)
# Newznab category IDs
_AUDIOBOOK_CATS = [3030]
_BOOK_CATS = [7000]
# Standard Newznab category IDs, used when the indexer's categories aren't configured.
_DEFAULT_AUDIOBOOK_CATS = [3030]
_DEFAULT_BOOK_CATS = [7000]
# Reuse the same timeout constant as Prowlarr.
NEWZNAB_SEARCH_TIMEOUT_SECONDS = _SEARCH_TIMEOUT
def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Release:
def _parse_category_ids(raw: object) -> list[int]:
"""Parse a configured category setting into Newznab category IDs.
Accepts a list of values or a comma/whitespace separated string. Entries that
aren't positive integers are skipped, and duplicates are dropped.
"""
if raw is None:
return []
values = list(raw) if isinstance(raw, (list, tuple)) else [raw]
category_ids: list[int] = []
for value in values:
for token in re.split(r"[,\s]+", str(value).strip()):
if not token:
continue
try:
category_id = int(token)
except ValueError:
logger.warning("Newznab: ignoring invalid category ID '%s'", token)
continue
if category_id > 0 and category_id not in category_ids:
category_ids.append(category_id)
return category_ids
def _configured_categories(content_type: str) -> list[int]:
"""Return the categories to search for a content type, falling back to defaults."""
if content_type == "audiobook":
key, defaults = "NEWZNAB_AUDIOBOOK_CATEGORIES", _DEFAULT_AUDIOBOOK_CATS
else:
key, defaults = "NEWZNAB_EBOOK_CATEGORIES", _DEFAULT_BOOK_CATS
return _parse_category_ids(config.get(key, None)) or list(defaults)
def _result_category_ids(categories: object) -> set[int]:
"""Extract numeric category IDs from a result's categories field."""
if not isinstance(categories, (list, tuple)):
return set()
category_ids: set[int] = set()
for cat in categories:
raw = cat.get("id") if isinstance(cat, dict) else cat
try:
category_ids.add(int(raw)) # type: ignore[arg-type]
except TypeError, ValueError:
continue
return category_ids
def _resolve_content_type(
categories: object,
content_type: str,
searched_categories: list[int] | None,
) -> str:
"""Resolve a result's content type, honouring custom indexer categories.
Indexers using non-standard IDs (e.g. 7100 for ebooks) fall outside the standard
ranges, so trust the searched content type when the result carries a category we
explicitly asked for.
"""
category_list = list(categories) if isinstance(categories, (list, tuple)) else []
detected = _detect_content_type_from_categories(category_list, content_type)
if (
detected == "other"
and searched_categories
and _result_category_ids(category_list) & set(searched_categories)
):
return "audiobook" if content_type == "audiobook" else "book"
return detected
def _newznab_result_to_release(
result: dict,
content_type: str = "ebook",
searched_categories: list[int] | None = None,
) -> Release:
"""Convert a parsed Newznab XML result dict to a Release object."""
raw_title = result.get("title", "Unknown")
size_bytes = result.get("size")
@@ -125,7 +204,7 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
indexer=indexer,
seeders=seeders if is_torrent else None,
peers=peers_display,
content_type=_detect_content_type_from_categories(categories, content_type),
content_type=_resolve_content_type(categories, content_type, searched_categories),
extra={
"publish_date": result.get("publishDate"),
"categories": categories,
@@ -230,12 +309,7 @@ class NewznabSource(ReleaseSource):
return []
# Category selection — omit categories when expanding search
if expand_search:
categories = None
elif content_type == "audiobook":
categories = [3030]
else:
categories = [7000]
categories = None if expand_search else _configured_categories(content_type)
auto_expand = config.get("NEWZNAB_AUTO_EXPAND", False)
deadline = time.monotonic() + NEWZNAB_SEARCH_TIMEOUT_SECONDS
@@ -283,7 +357,7 @@ class NewznabSource(ReleaseSource):
logger.exception("Newznab search failed")
return []
results = [_newznab_result_to_release(r, content_type) for r in all_results]
results = [_newznab_result_to_release(r, content_type, categories) for r in all_results]
if results:
nzb_count = sum(1 for r in results if r.protocol == ReleaseProtocol.NZB)
+2 -1
View File
@@ -16,6 +16,7 @@ from shelfmark.core.languages import normalize_language
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.core.search_plan import ReleaseSearchVariant
from shelfmark.core.utils import AUDIOBOOK_FORMATS as CORE_AUDIOBOOK_FORMATS
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources import (
ColumnAlign,
@@ -222,7 +223,7 @@ EBOOK_FORMATS = [
]
# Common audiobook formats
AUDIOBOOK_FORMATS = ["m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "opus"]
AUDIOBOOK_FORMATS = list(CORE_AUDIOBOOK_FORMATS)
# Combined list for format detection (audiobook formats first for priority)
ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS
+793 -520
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -18,23 +18,23 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"knip": "^6.27.0",
"oxfmt": "^0.59.0",
"oxlint": "^1.74.0",
"oxlint-tsgolint": "^0.25.0",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"knip": "^6.32.1",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"oxlint-tsgolint": "^7.0.2001",
"tailwindcss": "^4.2.2",
"typescript": "^7.0.2",
"vite": "^8.1.5",
"vite": "^8.2.1",
"vitest": "^4.1.10"
}
}
+7
View File
@@ -144,6 +144,11 @@ const mapApiErrorToActionResult = (error: unknown): ActionResult | null => {
// Default request timeout in milliseconds (30 seconds)
const DEFAULT_TIMEOUT_MS = 30000;
// Release searches can be long-running: a source behind Cloudflare/DDoS-Guard has
// to spin up the bypasser and solve the challenge before any results come back,
// which routinely takes well over the default timeout.
const SEARCH_TIMEOUT_MS = 180000;
// Utility function for JSON fetch with credentials and timeout
async function fetchJSON<T>(
url: string,
@@ -235,6 +240,8 @@ export const searchBooks = async (query: string): Promise<Book[]> => {
if (!query) return [];
const response = await fetchJSON<ReleasesResponse>(
`${API_BASE}/releases?source=direct_download&${query}`,
{},
SEARCH_TIMEOUT_MS,
);
return response.releases.map(transformReleaseToDirectBook);
};
+221
View File
@@ -0,0 +1,221 @@
"""DDoS-Guard cookie reuse between requests.
Anna's Archive issues nine cookies after a solve, and they are not equivalent:
__ddg1_/__ddg2_/__ddgid_ ~1 year clearance
__ddgmark_ ~1 day
__ddg5_ session
__ddg8_/__ddg9_/__ddg10_ ~40 min one check: token, CLIENT IP, TIMESTAMP
Replaying the last three is what produces the ?check=1 redirect loop. They describe a
single check, so once the timestamp ages out - or the egress IP changes, routine
behind a VPN - DDoS-Guard stops recognising the caller and re-arms the challenge on
every request. Storing an expired cookie and sending it forever has the same effect.
"""
import time
import pytest
import shelfmark.bypass.internal_bypasser as ib
@pytest.fixture(autouse=True)
def _clean_cookie_store(monkeypatch):
monkeypatch.setattr(ib, "_cf_cookies", {})
monkeypatch.setattr(ib, "_cf_user_agents", {})
class _Cookie:
"""Stand-in for the CDP cookie objects the bypasser extracts."""
def __init__(self, name, value="v", expires=None, domain="annas-archive.gl"):
self.name = name
self.value = value
self.expires = expires
self.domain = domain
self.path = "/"
self.secure = True
def _store(cookies, url="https://annas-archive.gl/search"):
ib._store_extracted_cookies(url=url, cookies=cookies, user_agent="UA/1.0")
# --------------------------------------------------------------------------- #
# Per-check cookies must not be persisted for replay
# --------------------------------------------------------------------------- #
def test_per_check_cookies_are_not_stored():
"""The IP/timestamp trio describes one check and must not outlive it."""
_store(
[
_Cookie("__ddg1_", "clearance"),
_Cookie("__ddg2_", "clearance2"),
_Cookie("__ddg8_", "opaque"),
_Cookie("__ddg9_", "203.0.113.7"),
_Cookie("__ddg10_", "1786826304"),
_Cookie("ddg_last_challenge", "1786826304"),
]
)
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert set(stored) == {"__ddg1_", "__ddg2_"}
for ephemeral in ("__ddg8_", "__ddg9_", "__ddg10_", "ddg_last_challenge"):
assert ephemeral not in stored
def test_clearance_cookies_survive():
_store([_Cookie("__ddg1_", "a"), _Cookie("__ddg2_", "b"), _Cookie("__ddgid_", "c")])
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert stored == {"__ddg1_": "a", "__ddg2_": "b", "__ddgid_": "c"}
def test_cloudflare_cookies_are_unaffected():
_store([_Cookie("cf_clearance", "token"), _Cookie("__cf_bm", "bm")])
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert stored == {"cf_clearance": "token", "__cf_bm": "bm"}
def test_per_check_cookies_are_excluded_even_for_full_session_domains(monkeypatch):
"""extract_all exists for Z-Library sessions; it must not resurrect the trio."""
monkeypatch.setattr(ib, "_get_full_cookie_domains", lambda: {"annas-archive.gl"})
_store([_Cookie("sessionid", "s"), _Cookie("__ddg9_", "203.0.113.7")])
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert "sessionid" in stored
assert "__ddg9_" not in stored
# --------------------------------------------------------------------------- #
# Expiry must be honoured for every cookie, not only cf_clearance
# --------------------------------------------------------------------------- #
def test_expired_ddg_cookies_are_dropped():
"""The old code only expiry-checked cf_clearance, so DDoS-Guard domains - which
have none - replayed dead cookies forever."""
past = int(time.time()) - 60
_store([_Cookie("__ddg1_", "live"), _Cookie("__ddgmark_", "dead", expires=past)])
stored = ib.get_cf_cookies_for_domain("annas-archive.gl")
assert stored == {"__ddg1_": "live"}
def test_all_cookies_expired_returns_empty_so_caller_re_solves():
past = int(time.time()) - 60
_store([_Cookie("__ddg1_", "dead", expires=past)])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
assert ib.has_valid_cf_cookies("annas-archive.gl") is False
def test_unexpired_cookies_are_kept():
future = int(time.time()) + 3600
_store([_Cookie("__ddg1_", "live", expires=future)])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {"__ddg1_": "live"}
def test_session_cookies_never_expire():
"""expires<=0 means a session cookie, not an already-expired one."""
_store([_Cookie("__ddg5_", "s", expires=0), _Cookie("__ddg1_", "a", expires=None)])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {"__ddg5_": "s", "__ddg1_": "a"}
def test_expired_cf_clearance_still_drops_the_whole_domain():
"""Pre-existing Cloudflare behaviour must not regress."""
past = int(time.time()) - 60
_store([_Cookie("cf_clearance", "dead", expires=past), _Cookie("__cf_bm", "bm")])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
def test_expired_cookies_are_pruned_from_the_store():
"""A dropped cookie must not linger and be re-evaluated on every request."""
past = int(time.time()) - 60
_store([_Cookie("__ddg1_", "live"), _Cookie("__ddgmark_", "dead", expires=past)])
ib.get_cf_cookies_for_domain("annas-archive.gl")
assert set(ib._cf_cookies["annas-archive.gl"]) == {"__ddg1_"}
def test_solve_that_yields_only_per_check_cookies_stores_nothing():
"""No clearance means no reuse - the caller must go back to the bypasser rather
than believe it holds a valid session."""
_store([_Cookie("__ddg9_", "203.0.113.7"), _Cookie("__ddg10_", "1786826304")])
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
# --------------------------------------------------------------------------- #
# Rejected cookies are discarded, never retried forever
# --------------------------------------------------------------------------- #
class _Resp:
def __init__(self, status_code, text="page"):
self.status_code = status_code
self.text = text
def _seed(monkeypatch):
_store([_Cookie("__ddg1_", "clearance"), _Cookie("__ddg2_", "c2")])
monkeypatch.setattr(ib, "get_proxies", lambda _url: None)
monkeypatch.setattr(ib, "get_ssl_verify", lambda _url: True)
assert ib.get_cf_cookies_for_domain("annas-archive.gl")
def test_rejected_cached_cookies_are_discarded(monkeypatch):
"""A 403 while presenting cookies proves they are dead - keep them and every
later request re-presents a known-rejected cookie."""
_seed(monkeypatch)
monkeypatch.setattr(ib.requests, "get", lambda *a, **k: _Resp(403))
assert (
ib._try_with_cached_cookies("https://annas-archive.gl/search", "annas-archive.gl") is None
)
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
def test_redirect_loop_on_cached_cookies_discards_them(monkeypatch):
"""DDoS-Guard answers dead clearance with an endless ?check=1 bounce, which
surfaces as an exception rather than a status code."""
_seed(monkeypatch)
def boom(*_a, **_k):
raise ib.requests.exceptions.TooManyRedirects("Exceeded 30 redirects")
monkeypatch.setattr(ib.requests, "get", boom)
assert (
ib._try_with_cached_cookies("https://annas-archive.gl/search", "annas-archive.gl") is None
)
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
def test_working_cookies_are_kept(monkeypatch):
_seed(monkeypatch)
monkeypatch.setattr(ib.requests, "get", lambda *a, **k: _Resp(200, "the page"))
result = ib._try_with_cached_cookies("https://annas-archive.gl/search", "annas-archive.gl")
assert result == "the page"
assert ib.get_cf_cookies_for_domain("annas-archive.gl") != {}
def test_failure_only_clears_the_failing_host(monkeypatch):
"""clear_cf_cookies('') means every host - a blank hostname must not wipe
clearance for sites that are working fine."""
_seed(monkeypatch)
_store([_Cookie("__ddg1_", "other")], url="https://other-site.test/x")
monkeypatch.setattr(ib.requests, "get", lambda *a, **k: _Resp(403))
ib._try_with_cached_cookies("https://annas-archive.gl/search", "annas-archive.gl")
assert ib.get_cf_cookies_for_domain("annas-archive.gl") == {}
assert ib.get_cf_cookies_for_domain("other-site.test") == {"__ddg1_": "other"}
+33
View File
@@ -101,3 +101,36 @@ def test_get_bypassed_page_retries_and_rotates_selector_between_attempts(monkeyp
]
assert selector.rotate_calls == 1
assert sleeps == [1.0]
def _stub_ext_bypasser_timeout(monkeypatch, external_bypasser, value: int) -> None:
"""Override only EXT_BYPASSER_TIMEOUT on the shared config singleton."""
real_get = external_bypasser.config.get
monkeypatch.setattr(
external_bypasser.config,
"get",
lambda key, default="": value if key == "EXT_BYPASSER_TIMEOUT" else real_get(key, default),
)
def test_max_duration_seconds_covers_every_attempt_and_backoff(monkeypatch):
"""The declared budget must not undercut what get_bypassed_page() can actually take."""
import shelfmark.bypass.external_bypasser as external_bypasser
_stub_ext_bypasser_timeout(monkeypatch, external_bypasser, 60000)
budget = external_bypasser.max_duration_seconds()
# 5 attempts at min(60 + 15, 120) = 75s, plus the 1+2+4+8 backoff and its jitter.
assert budget == 5 * 75.0 + (1 + 1) + (2 + 1) + (4 + 1) + (8 + 1)
def test_max_duration_seconds_respects_the_read_timeout_ceiling(monkeypatch):
import shelfmark.bypass.external_bypasser as external_bypasser
_stub_ext_bypasser_timeout(monkeypatch, external_bypasser, 300000)
budget = external_bypasser.max_duration_seconds()
# 300s + 15s buffer is clamped to MAX_READ_TIMEOUT, not used raw.
assert budget == 5 * external_bypasser.MAX_READ_TIMEOUT + 19.0
+52
View File
@@ -1,4 +1,5 @@
import asyncio
import threading
import pytest
@@ -430,3 +431,54 @@ def test_get_bypassed_page_retries_next_mirror_after_runtime_error(monkeypatch):
"https://mirror-one.example/book",
"https://mirror-two.example/book",
]
def test_max_duration_seconds_allows_for_a_mirror_rotation_retry():
"""get_bypassed_page() may call get() twice, so the budget must cover both."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
assert internal_bypasser.max_duration_seconds() == (
2 * internal_bypasser._BYPASS_SUBPROCESS_TIMEOUT_SECONDS
)
def test_cdp_worker_run_times_out_and_cancels_the_orphaned_coroutine(monkeypatch):
"""A wedged in-process bypass must not block forever holding LOCKED.
Regression guard: _CDP_WORKER.run() used to wait with timeout=None, so one hung CDP
session blocked every subsequent bypass in the process indefinitely.
"""
import shelfmark.bypass.internal_bypasser as internal_bypasser
started = threading.Event()
async def _never_finish():
started.set()
await asyncio.Event().wait()
coro = _never_finish()
with pytest.raises(TimeoutError):
internal_bypasser._CDP_WORKER.run(coro, timeout=0.05)
assert started.is_set(), "coroutine should have been scheduled before timing out"
def test_run_bypass_in_current_process_bounds_its_wait(monkeypatch):
"""The in-process path passes a deadline rather than waiting forever."""
import shelfmark.bypass.internal_bypasser as internal_bypasser
observed: dict[str, float | None] = {}
class _FakeWorker:
def run(self, coro, timeout=None):
observed["timeout"] = timeout
coro.close()
return "html"
monkeypatch.setattr(internal_bypasser, "_CDP_WORKER", _FakeWorker())
monkeypatch.delenv("SHELFMARK_INTERNAL_BYPASSER_CHILD", raising=False)
result = internal_bypasser._run_bypass_in_current_process("https://example.com", 1)
assert result == "html"
assert observed["timeout"] == internal_bypasser._IN_PROCESS_BYPASS_TIMEOUT_SECONDS
@@ -0,0 +1,91 @@
"""Tests for widening the audiobook format list on existing installs.
`initialize_default_configs()` only writes field defaults when a tab has no config file
yet, so widening the default alone would have reached fresh installs only - exactly not
the installs already carrying the narrow m4b/mp3 list that loses FLAC/OPUS releases.
"""
import logging
import pytest
from shelfmark.config.migrations import migrate_audiobook_formats
from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS
WIDENED = [*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS]
@pytest.fixture
def migrate():
"""Run the migration over an in-memory config, returning the resulting config."""
def run(config: dict | None) -> dict:
stored = {} if config is None else dict(config)
saved: dict = {}
def save(values: dict) -> None:
saved.update(values)
stored.update(values)
migrate_audiobook_formats(
load_general_config=lambda: stored,
save_general_config=save,
widened_formats=WIDENED,
logger=logging.getLogger("test"),
)
return stored
return run
def test_legacy_default_is_widened(migrate):
result = migrate({"SUPPORTED_AUDIOBOOK_FORMATS": ["m4b", "mp3"]})
assert result["SUPPORTED_AUDIOBOOK_FORMATS"] == WIDENED
def test_legacy_default_is_widened_regardless_of_order_or_case(migrate):
result = migrate({"SUPPORTED_AUDIOBOOK_FORMATS": ["MP3", " m4b "]})
assert result["SUPPORTED_AUDIOBOOK_FORMATS"] == WIDENED
def test_other_settings_are_preserved(migrate):
result = migrate({"SUPPORTED_AUDIOBOOK_FORMATS": ["m4b", "mp3"], "SUPPORTED_FORMATS": ["epub"]})
assert result["SUPPORTED_FORMATS"] == ["epub"]
@pytest.mark.parametrize(
"customized",
[
["m4b"], # deliberately narrowed - re-enabling formats would override the choice
["mp3", "flac"],
["m4b", "mp3", "zip"],
[],
],
)
def test_customized_lists_are_left_alone(migrate, customized):
result = migrate({"SUPPORTED_AUDIOBOOK_FORMATS": customized})
assert result["SUPPORTED_AUDIOBOOK_FORMATS"] == customized
def test_absent_key_is_left_alone(migrate):
"""Nothing persisted means the field default already applies - don't write one."""
result = migrate({"SUPPORTED_FORMATS": ["epub"]})
assert "SUPPORTED_AUDIOBOOK_FORMATS" not in result
def test_unexpected_type_is_left_alone(migrate):
result = migrate({"SUPPORTED_AUDIOBOOK_FORMATS": "m4b,mp3"})
assert result["SUPPORTED_AUDIOBOOK_FORMATS"] == "m4b,mp3"
def test_migration_is_idempotent(migrate):
once = migrate({"SUPPORTED_AUDIOBOOK_FORMATS": ["m4b", "mp3"]})
twice = migrate(once)
assert twice["SUPPORTED_AUDIOBOOK_FORMATS"] == WIDENED
+81
View File
@@ -8,6 +8,7 @@ Run with: uv run pytest tests/config/test_environment.py -v
"""
import importlib
import json
import os
import tempfile
from pathlib import Path
@@ -15,6 +16,15 @@ from unittest.mock import patch
import pytest
def _restore_env(monkeypatch, name: str, value: str | None) -> None:
"""Restore an env var to its pre-test value."""
if value is None:
monkeypatch.delenv(name, raising=False)
else:
monkeypatch.setenv(name, value)
# =============================================================================
# Directory Setup Tests
# =============================================================================
@@ -436,6 +446,7 @@ class TestDebugConfiguration:
original_debug = os.environ.get("DEBUG")
try:
monkeypatch.delenv("LOG_LEVEL", raising=False)
monkeypatch.setenv("DEBUG", "true")
importlib.reload(env_module)
assert env_module.DEBUG is True
@@ -452,6 +463,76 @@ class TestDebugConfiguration:
monkeypatch.setenv("DEBUG", original_debug)
importlib.reload(env_module)
@pytest.mark.parametrize(
("raw", "expected"),
[
("error", "ERROR"),
("ERROR", "ERROR"),
(" Warning ", "WARNING"),
("warn", "WARNING"),
("critical", "CRITICAL"),
("nonsense", "INFO"),
("", "INFO"),
(None, "INFO"),
],
)
def test_normalize_log_level(self, raw, expected):
"""Log level names are case-insensitive and fall back to INFO."""
from shelfmark.config.env import normalize_log_level
assert normalize_log_level(raw) == expected
def test_log_level_from_env_var(self, monkeypatch):
"""LOG_LEVEL env var should set the app log level when DEBUG is off."""
import shelfmark.config.env as env_module
original_debug = os.environ.get("DEBUG")
original_level = os.environ.get("LOG_LEVEL")
try:
monkeypatch.setenv("DEBUG", "false")
monkeypatch.setenv("LOG_LEVEL", "error")
importlib.reload(env_module)
assert env_module.LOG_LEVEL == "ERROR"
# DEBUG wins over LOG_LEVEL, matching entrypoint.sh.
monkeypatch.setenv("DEBUG", "true")
importlib.reload(env_module)
assert env_module.LOG_LEVEL == "DEBUG"
finally:
_restore_env(monkeypatch, "DEBUG", original_debug)
_restore_env(monkeypatch, "LOG_LEVEL", original_level)
importlib.reload(env_module)
def test_log_level_from_config_file(self, monkeypatch, tmp_path):
"""LOG_LEVEL should fall back to the advanced settings file."""
import shelfmark.config.env as env_module
original_debug = os.environ.get("DEBUG")
original_level = os.environ.get("LOG_LEVEL")
original_config_dir = os.environ.get("CONFIG_DIR")
advanced = tmp_path / "plugins" / "advanced.json"
advanced.parent.mkdir(parents=True)
advanced.write_text(json.dumps({"LOG_LEVEL": "WARNING"}))
try:
monkeypatch.setenv("DEBUG", "false")
monkeypatch.delenv("LOG_LEVEL", raising=False)
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
importlib.reload(env_module)
assert env_module.LOG_LEVEL == "WARNING"
# Env var takes precedence over the stored setting.
monkeypatch.setenv("LOG_LEVEL", "critical")
importlib.reload(env_module)
assert env_module.LOG_LEVEL == "CRITICAL"
finally:
_restore_env(monkeypatch, "DEBUG", original_debug)
_restore_env(monkeypatch, "LOG_LEVEL", original_level)
_restore_env(monkeypatch, "CONFIG_DIR", original_config_dir)
importlib.reload(env_module)
# =============================================================================
# Proxy and Network Configuration Tests
@@ -0,0 +1,38 @@
"""The audiobook format list must stay in agreement across every layer that gates on it.
These lists were maintained by hand in four places and drifted: the settings UI offered
only m4b/mp3/m4a, so FLAC/OPUS/OGG could never be enabled even though the parsers
recognized them, the sorter ranked them, and archive extraction knew them. The result was
a FLAC audiobook that was invisible in search and rejected after download. They now all
derive from `shelfmark.core.utils.AUDIOBOOK_FORMATS`; this test fails if one drifts again.
"""
from shelfmark.config.settings import _AUDIOBOOK_FORMAT_OPTIONS
from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS
from shelfmark.download.archive import ALL_AUDIO_EXTENSIONS
from shelfmark.release_sources.irc import parser
from shelfmark.release_sources.prowlarr.source import AUDIOBOOK_FORMATS as PROWLARR_FORMATS
def test_archive_extraction_knows_every_audiobook_format():
assert ALL_AUDIO_EXTENSIONS == {f".{fmt}" for fmt in AUDIOBOOK_FORMATS}
def test_prowlarr_knows_every_audiobook_format():
assert PROWLARR_FORMATS == list(AUDIOBOOK_FORMATS)
def test_irc_parser_knows_every_audiobook_format():
assert set(AUDIOBOOK_FORMATS) <= set(parser.ALL_RECOGNIZED_FORMATS)
def test_every_audiobook_format_is_selectable_in_settings():
"""The settings list is the only one a user's config can be built from."""
selectable = {option["value"] for option in _AUDIOBOOK_FORMAT_OPTIONS}
assert selectable == {*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS}
def test_audiobook_and_ebook_formats_do_not_overlap():
"""Overlap would make content-type classification by extension ambiguous."""
assert not set(AUDIOBOOK_FORMATS) & set(parser.EBOOK_FORMATS)
+7 -1
View File
@@ -28,6 +28,9 @@ _BOOTSTRAP_ENV_ACCESS_ALLOWLIST = {
}
_BOOTSTRAP_ENV_ACCESS_KEY_ALLOWLIST = {
(Path("shelfmark/config/settings.py"), "USING_TOR"),
# Loggers are configured while settings_registry itself is still importing,
# so the level has to come from the bootstrap env module.
(Path("shelfmark/core/logger.py"), "LOG_LEVEL"),
}
_RAW_CONFIG_READ_ALLOWLIST = {
Path("shelfmark/config/notifications_settings.py"),
@@ -205,7 +208,10 @@ class ConfigAccessVisitor(ast.NodeVisitor):
return
for alias in node.names:
imported_name = alias.name
if imported_name in self.registered_keys:
if (
imported_name in self.registered_keys
and imported_name not in self._bootstrap_env_key_allowlist
):
self._record_violation(
node,
"direct env-module import",
+58
View File
@@ -95,6 +95,64 @@ def test_upsert_updates_existing_cwa_user_by_username_before_email(user_db):
assert user["role"] == "admin"
def test_upsert_renames_existing_cwa_user_matched_by_email(user_db):
cwa_user = user_db.create_user(
username="old_reader",
email="reader@example.com",
role="user",
auth_source="cwa",
)
user, action = upsert_cwa_user(
user_db,
cwa_username="renamed_reader",
cwa_email="reader@example.com",
role="user",
)
assert action == "updated"
assert user["id"] == cwa_user["id"]
assert user["username"] == "renamed_reader"
assert user_db.get_user(username="old_reader") is None
def test_upsert_uses_stable_alias_when_renamed_cwa_username_is_taken(user_db):
cwa_user = user_db.create_user(
username="old_reader",
email="reader@example.com",
role="user",
auth_source="cwa",
)
local_user = user_db.create_user(
username="renamed_reader",
email="local@example.com",
role="user",
auth_source="builtin",
)
first, first_action = upsert_cwa_user(
user_db,
cwa_username="renamed_reader",
cwa_email="reader@example.com",
role="admin",
)
second, second_action = upsert_cwa_user(
user_db,
cwa_username="renamed_reader",
cwa_email="reader@example.com",
role="admin",
)
assert first_action == second_action == "updated"
assert first["id"] == second["id"] == cwa_user["id"]
assert first["username"] == second["username"] == "renamed_reader__cwa"
assert first["role"] == second["role"] == "admin"
local_after = user_db.get_user(user_id=local_user["id"])
assert local_after is not None
assert local_after["username"] == "renamed_reader"
assert local_after["email"] == "local@example.com"
def test_sync_prunes_cwa_users_missing_from_source(user_db):
active_cwa = user_db.create_user(
username="active_cwa",
+44 -2
View File
@@ -2,16 +2,27 @@ import errno
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from shelfmark.core.models import DownloadTask, SearchMode
from shelfmark.download import fs
from shelfmark.download.postprocess.pipeline import collect_directory_files, validate_destination
@pytest.fixture(autouse=True)
def _reset_delete_denied():
fs._DELETE_DENIED_DIRS.clear()
yield
fs._DELETE_DENIED_DIRS.clear()
def test_validate_destination_success_cleans_up_probe(tmp_path):
destination = tmp_path / "dest"
status_cb = MagicMock()
assert validate_destination(destination, status_cb) is True
assert list(destination.glob(".shelfmark_write_test_*")) == []
assert list(destination.glob(".shelfmark_write_test*")) == []
assert fs.is_delete_denied(destination) is False
def test_validate_destination_write_probe_permission_error(tmp_path):
@@ -22,7 +33,7 @@ def test_validate_destination_write_probe_permission_error(tmp_path):
real_write_text = Path.write_text
def fake_write_text(self, data, *args, **kwargs):
if ".shelfmark_write_test_" in self.name:
if ".shelfmark_write_test" in self.name:
raise PermissionError(errno.EACCES, "Permission denied", str(self))
return real_write_text(self, data, *args, **kwargs)
@@ -34,6 +45,37 @@ def test_validate_destination_write_probe_permission_error(tmp_path):
assert "Destination not writable" in status_cb.call_args[0][1]
def test_validate_destination_accepts_writable_but_undeletable_destination(tmp_path):
"""Synology-style share: creating files is allowed, deleting them is not."""
destination = tmp_path / "dest"
destination.mkdir()
status_cb = MagicMock()
real_unlink = Path.unlink
def fake_unlink(self, *args, **kwargs):
if ".shelfmark_write_test" in self.name:
raise PermissionError(errno.EACCES, "Permission denied", str(self))
return real_unlink(self, *args, **kwargs)
with patch("pathlib.Path.unlink", new=fake_unlink):
assert validate_destination(destination, status_cb) is True
# Not fatal: no error is surfaced, and the destination is flagged so
# transfers write in place instead of publishing via rename.
assert not any(call[0][0] == "error" for call in status_cb.call_args_list)
assert fs.is_delete_denied(destination) is True
def test_validate_destination_clears_stale_delete_denial(tmp_path):
destination = tmp_path / "dest"
destination.mkdir()
fs.mark_delete_denied(destination)
assert validate_destination(destination, MagicMock()) is True
assert fs.is_delete_denied(destination) is False
def test_collect_directory_files_ignores_permission_errors(tmp_path):
directory = tmp_path / "download"
directory.mkdir()
+46
View File
@@ -0,0 +1,46 @@
import importlib
from unittest.mock import Mock, patch
import pytest
@pytest.fixture(scope="module")
def main_module():
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.mark.parametrize(
"headers",
[
{
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "library.example.com:12345",
},
{
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "library.example.com",
"X-Forwarded-Port": "12345",
},
],
)
def test_oidc_redirect_uses_forwarded_external_port(main_module, headers):
oidc_client = Mock()
oidc_client.authorize_redirect.return_value = ("", 302)
with patch(
"shelfmark.core.oidc_routes._get_oidc_client",
return_value=(oidc_client, {}),
):
response = main_module.app.test_client().get(
"/api/auth/oidc/login",
headers=headers,
)
assert response.status_code == 302
oidc_client.authorize_redirect.assert_called_once_with(
"https://library.example.com:12345/api/auth/oidc/callback"
)
+2
View File
@@ -568,11 +568,13 @@ class TestUserCRUD:
user = user_db.create_user(username="john", role="user")
user_db.update_user(
user["id"],
username="jane",
role="admin",
email="new@example.com",
auth_source="proxy",
)
updated = user_db.get_user(user_id=user["id"])
assert updated["username"] == "jane"
assert updated["role"] == "admin"
assert updated["email"] == "new@example.com"
assert updated["auth_source"] == "proxy"
@@ -0,0 +1,107 @@
"""A mirror that answers 200 with a non-AA page is quarantined, not reported as empty.
Seized and for-sale domains keep serving 200. Without a look at *what* came back, a
parking page is indistinguishable from a broken search, so the mirror stays in
rotation and every later search pays for it again.
"""
from bs4 import Tag
PARKED_PAGE = """<!doctype html><html><head><title>annas-archive.li</title></head>
<body><h1>This domain is for sale</h1><p>Inquire now. Buy this domain.</p></body></html>"""
AA_RESULTS_PAGE = """<!doctype html><html><body><main><table><tbody>
<tr><td><a href="/md5/abc123"><img src="/c.jpg"></a></td><td><span>Dune</span></td></tr>
</tbody></table></main></body></html>"""
AA_EMPTY_PAGE = """<!doctype html><html><body><main>
<div>No files found.</div><a href="https://annas-archive.gl/about">about</a>
</main></body></html>"""
DDOS_GUARD_PAGE = """<!doctype html><html><head><title>DDoS-Guard</title></head>
<body><div id="ddos-guard">Checking your browser</div></body></html>"""
class _Selector:
def __init__(self, bases: list[str]) -> None:
self._bases = bases
self._index = 0
self.current_base = bases[0]
self.quarantined: list[str] = []
def rewrite(self, url: str) -> str:
for base in self._bases:
if url.startswith(base):
return url.replace(base, self.current_base, 1)
return url
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
if fatal:
self.quarantined.append(self.current_base)
self._index += 1
if self._index >= len(self._bases):
return None, "exhausted"
self.current_base = self._bases[self._index]
return self.current_base, "mirror"
def _patch_pages(monkeypatch, pages: list[str]):
"""Serve `pages` in order, recording the URL each call was made against."""
import shelfmark.release_sources.direct_download as dd
calls: list[str] = []
def fake_get(url, **_kwargs):
calls.append(url)
return pages[len(calls) - 1] if len(calls) <= len(pages) else ""
monkeypatch.setattr(dd.downloader, "html_get_page", fake_get)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a", "b", "c"])
return dd, calls
def test_parked_mirror_is_quarantined_and_search_retries_next_mirror(monkeypatch):
dd, calls = _patch_pages(monkeypatch, [PARKED_PAGE, AA_RESULTS_PAGE])
selector = _Selector(["https://parked.test", "https://real.test"])
html, table = dd._fetch_search_table("https://parked.test/search?q=dune", selector)
assert selector.quarantined == ["https://parked.test"]
assert isinstance(table, Tag)
assert "Dune" in html
# The retry went to the live mirror, not back to the parked one.
assert calls[1].startswith("https://real.test")
def test_genuinely_empty_aa_result_does_not_quarantine(monkeypatch):
"""'No files found.' is a real answer from a healthy mirror."""
dd, _calls = _patch_pages(monkeypatch, [AA_EMPTY_PAGE])
selector = _Selector(["https://real.test", "https://other.test"])
html, table = dd._fetch_search_table("https://real.test/search?q=zzz", selector)
assert selector.quarantined == []
assert table is None
assert "No files found." in html
def test_challenge_page_does_not_quarantine(monkeypatch):
"""A DDoS-Guard interstitial means the mirror is alive and holds our clearance."""
dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_PAGE])
selector = _Selector(["https://real.test", "https://other.test"])
_html, table = dd._fetch_search_table("https://real.test/search?q=dune", selector)
assert selector.quarantined == []
assert table is None
def test_unreachable_mirror_raises_search_unavailable(monkeypatch):
dd, _calls = _patch_pages(monkeypatch, [""])
selector = _Selector(["https://real.test"])
try:
dd._fetch_search_table("https://real.test/search?q=dune", selector)
except dd.SearchUnavailableError:
return
raise AssertionError("expected SearchUnavailableError")
+61
View File
@@ -0,0 +1,61 @@
"""Tests for the stall-detection activity grace signalling."""
import pytest
from shelfmark.core.models import QueueStatus
from shelfmark.download.activity import (
ACTIVITY_GRACE_STATUS,
parse_activity_grace,
release_activity_grace,
request_activity_grace,
)
def test_request_and_parse_round_trip():
calls: list[tuple[str, str | None]] = []
request_activity_grace(lambda status, message: calls.append((status, message)), 330)
assert len(calls) == 1
assert parse_activity_grace(*calls[0]) == 330.0
def test_release_emits_zero_grace():
calls: list[tuple[str, str | None]] = []
release_activity_grace(lambda status, message: calls.append((status, message)))
assert parse_activity_grace(*calls[0]) == 0.0
def test_parse_returns_none_for_real_status_events():
assert parse_activity_grace("resolving", "Bypassing protection...") is None
assert parse_activity_grace("downloading", None) is None
@pytest.mark.parametrize("status", [s.value for s in QueueStatus])
def test_sentinel_cannot_collide_with_a_queue_status(status):
"""The sentinel must never be mistaken for a real status, or vice versa."""
assert status != ACTIVITY_GRACE_STATUS
assert parse_activity_grace(status, "anything") is None
def test_parse_tolerates_a_malformed_sentinel():
"""A bad emitter must not take down the status pipeline."""
assert parse_activity_grace(ACTIVITY_GRACE_STATUS, "not-a-number") == 0.0
assert parse_activity_grace(ACTIVITY_GRACE_STATUS, None) == 0.0
def test_parse_clamps_negative_grace():
assert parse_activity_grace(ACTIVITY_GRACE_STATUS, "-5") == 0.0
def test_emitters_are_noops_without_a_callback():
request_activity_grace(None, 30)
release_activity_grace(None)
def test_emitters_swallow_a_raising_callback():
def boom(_status: str, _message: str | None) -> None:
raise RuntimeError("callback failed")
request_activity_grace(boom, 30)
release_activity_grace(boom)
+143
View File
@@ -0,0 +1,143 @@
"""RFC 8484 wireformat codec.
Quad9 and OpenDNS reject the JSON API that Cloudflare and Google popularised, so
these providers only work through wireformat. Quad9 additionally requires HTTP/2
(section 5.2) and answers HTTP/1.1 with 505.
"""
import base64
import struct
import pytest
from shelfmark.download import doh_wireformat as wf
def _decode_param(param: str) -> bytes:
padding = "=" * (-len(param) % 4)
return base64.urlsafe_b64decode(param + padding)
def _build_response(
*, qname: str = "example.com", answers: list[tuple[int, bytes]], qtype: int = wf.TYPE_A
) -> bytes:
"""Assemble a response whose answer names are compression pointers to the question."""
question = b""
for label in qname.split("."):
question += bytes([len(label)]) + label.encode()
question += b"\x00" + struct.pack(">HH", qtype, 1)
body = b""
for rtype, rdata in answers:
body += b"\xc0\x0c" # pointer to offset 12 (the question name)
body += struct.pack(">HHIH", rtype, 1, 300, len(rdata)) + rdata
header = struct.pack(">HHHHHH", 0, 0x8180, 1, len(answers), 0, 0)
return header + question + body
def test_encode_query_is_a_well_formed_dns_message():
raw = _decode_param(wf.encode_query_param("example.com", wf.TYPE_A))
msg_id, flags, qdcount, ancount, _ns, _ar = struct.unpack_from(">HHHHHH", raw, 0)
assert msg_id == 0 # RFC 8484 section 4.1: zero for cacheability
assert flags == 0x0100 # recursion desired
assert (qdcount, ancount) == (1, 0)
assert raw[12:] == b"\x07example\x03com\x00" + struct.pack(">HH", wf.TYPE_A, 1)
def test_encode_query_param_is_unpadded_base64url():
param = wf.encode_query_param("example.com", wf.TYPE_A)
assert "=" not in param
assert "+" not in param and "/" not in param
def test_encode_query_strips_trailing_dot():
assert _decode_param(wf.encode_query_param("example.com.", wf.TYPE_A)) == _decode_param(
wf.encode_query_param("example.com", wf.TYPE_A)
)
def test_encode_query_rejects_empty_hostname():
with pytest.raises(wf.WireformatError):
wf.encode_query("", wf.TYPE_A)
def test_encode_query_rejects_oversized_label():
with pytest.raises(wf.WireformatError):
wf.encode_query("a" * 64 + ".com", wf.TYPE_A)
def test_decode_a_records():
response = _build_response(answers=[(wf.TYPE_A, bytes([93, 184, 216, 34]))])
assert wf.decode_answer(response, wf.TYPE_A) == ["93.184.216.34"]
def test_decode_multiple_a_records_preserves_order():
response = _build_response(
answers=[(wf.TYPE_A, bytes([1, 1, 1, 1])), (wf.TYPE_A, bytes([8, 8, 8, 8]))]
)
assert wf.decode_answer(response, wf.TYPE_A) == ["1.1.1.1", "8.8.8.8"]
def test_decode_skips_cname_records_in_the_chain():
"""Answers routinely lead with a CNAME; only the requested type is an address."""
cname = b"\x03www\x07example\x03com\x00"
response = _build_response(answers=[(5, cname), (wf.TYPE_A, bytes([93, 184, 216, 34]))])
assert wf.decode_answer(response, wf.TYPE_A) == ["93.184.216.34"]
def test_decode_aaaa_compresses_zero_run():
# 2606:4700:0:0:0:0:6810:84e5 -> the middle zero run collapses to "::"
rdata = struct.pack(">8H", 0x2606, 0x4700, 0, 0, 0, 0, 0x6810, 0x84E5)
response = _build_response(answers=[(wf.TYPE_AAAA, rdata)], qtype=wf.TYPE_AAAA)
assert wf.decode_answer(response, wf.TYPE_AAAA) == ["2606:4700::6810:84e5"]
def test_decode_aaaa_collapses_only_the_longest_zero_run():
# 2001:0:0:1:0:0:0:1 - the second, longer run is the one that collapses.
rdata = struct.pack(">8H", 0x2001, 0, 0, 1, 0, 0, 0, 1)
response = _build_response(answers=[(wf.TYPE_AAAA, rdata)], qtype=wf.TYPE_AAAA)
assert wf.decode_answer(response, wf.TYPE_AAAA) == ["2001:0:0:1::1"]
def test_decode_aaaa_without_zero_run():
rdata = struct.pack(">8H", 0x2001, 0x0DB8, 1, 2, 3, 4, 5, 6)
response = _build_response(answers=[(wf.TYPE_AAAA, rdata)], qtype=wf.TYPE_AAAA)
assert wf.decode_answer(response, wf.TYPE_AAAA) == ["2001:db8:1:2:3:4:5:6"]
def test_decode_nxdomain_returns_empty_not_an_error():
"""An empty answer is a valid response; the caller falls back rather than retrying."""
response = _build_response(answers=[])
assert wf.decode_answer(response, wf.TYPE_A) == []
def test_decode_rejects_truncated_header():
with pytest.raises(wf.WireformatError):
wf.decode_answer(b"\x00\x01", wf.TYPE_A)
def test_decode_rejects_truncated_record():
response = _build_response(answers=[(wf.TYPE_A, bytes([1, 2, 3, 4]))])
with pytest.raises(wf.WireformatError):
wf.decode_answer(response[:-2], wf.TYPE_A)
def test_decode_does_not_hang_on_a_malicious_name():
"""A self-referential name must not loop forever."""
header = struct.pack(">HHHHHH", 0, 0x8180, 1, 0, 0, 0)
# A run of maximum-length labels that never terminates.
body = (b"\x3f" + b"a" * 63) * 8
with pytest.raises(wf.WireformatError):
wf.decode_answer(header + body, wf.TYPE_A)
def test_wireformat_providers_are_flagged(monkeypatch):
"""The provider table and the resolver must agree on who needs wireformat."""
import shelfmark.download.network as network
for name, servers, url in network.DNS_PROVIDERS:
resolver = network.DoHResolver(url, "x.invalid", servers[0])
expected = name in ("quad9", "opendns")
assert resolver.use_wireformat is expected, f"{name} wireformat flag wrong"
+170
View File
@@ -0,0 +1,170 @@
"""Transfers into destinations that accept writes but refuse deletes.
Reproduces the Synology case from issue #1174: a share where "Delete subfolders
and files" is unticked. Creating files is allowed, but `os.replace()` cannot
publish a temp file into place because renaming removes a directory entry.
"""
from __future__ import annotations
import errno
import os
from pathlib import Path
import pytest
from shelfmark.download import fs
from shelfmark.download.fs import atomic_copy, atomic_move
CONTENT = b"a book" * 1024
@pytest.fixture(autouse=True)
def _reset_delete_denied():
fs._DELETE_DENIED_DIRS.clear()
yield
fs._DELETE_DENIED_DIRS.clear()
@pytest.fixture
def source(tmp_path: Path) -> Path:
src = tmp_path / "tmp_dir" / "staged.epub"
src.parent.mkdir()
src.write_bytes(CONTENT)
return src
@pytest.fixture
def library(tmp_path: Path) -> Path:
lib = tmp_path / "library"
lib.mkdir()
return lib
def _leftover_temps(directory: Path) -> list[Path]:
return list(directory.glob(".shelfmark.*"))
def _deny_replace(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make os.replace fail the way a no-delete share does."""
def fake_replace(src, dst, *args, **kwargs):
raise PermissionError(errno.EACCES, "Permission denied", str(dst))
monkeypatch.setattr(fs.os, "replace", fake_replace)
def test_copy_writes_in_place_when_destination_is_known_undeletable(
source: Path, library: Path
) -> None:
fs.mark_delete_denied(library)
final_path = atomic_copy(source, library / "book.epub")
assert final_path == library / "book.epub"
assert final_path.read_bytes() == CONTENT
# The temp file is skipped entirely, so nothing is stranded in the library.
assert _leftover_temps(library) == []
assert source.exists()
def test_copy_falls_back_when_rename_is_refused(
source: Path, library: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Cold cache: the denial is discovered at publish time and recovered from."""
_deny_replace(monkeypatch)
final_path = atomic_copy(source, library / "book.epub")
assert final_path.read_bytes() == CONTENT
assert _leftover_temps(library) == []
# The destination is remembered so later transfers skip the temp file.
assert fs.is_delete_denied(library) is True
def test_move_falls_back_to_copy_and_removes_source(source: Path, library: Path) -> None:
fs.mark_delete_denied(library)
final_path = atomic_move(source, library / "book.epub")
assert final_path.read_bytes() == CONTENT
assert not source.exists()
assert _leftover_temps(library) == []
def test_move_falls_back_when_rename_is_refused(
source: Path, library: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
real_rename = fs.os.rename
def fake_rename(src, dst, *args, **kwargs):
if str(dst).startswith(str(library)):
raise PermissionError(errno.EACCES, "Permission denied", str(dst))
return real_rename(src, dst, *args, **kwargs)
monkeypatch.setattr(fs.os, "rename", fake_rename)
_deny_replace(monkeypatch)
final_path = atomic_move(source, library / "book.epub")
assert final_path.read_bytes() == CONTENT
assert not source.exists()
assert fs.is_delete_denied(library) is True
def test_in_place_copy_still_resolves_collisions(source: Path, library: Path) -> None:
fs.mark_delete_denied(library)
(library / "book.epub").write_bytes(b"existing")
final_path = atomic_copy(source, library / "book.epub")
assert final_path == library / "book_1.epub"
assert final_path.read_bytes() == CONTENT
# The pre-existing file is never overwritten.
assert (library / "book.epub").read_bytes() == b"existing"
def test_denial_applies_to_subdirectories(source: Path, library: Path) -> None:
"""`organize` mode writes into per-author subfolders under the library root."""
fs.mark_delete_denied(library)
nested = library / "Frank Herbert" / "Dune"
nested.mkdir(parents=True)
assert fs.is_delete_denied(nested) is True
final_path = atomic_copy(source, nested / "book.epub")
assert final_path.read_bytes() == CONTENT
assert _leftover_temps(nested) == []
def test_clearing_a_denial_also_clears_subdirectories(library: Path) -> None:
nested = library / "Frank Herbert"
fs.mark_delete_denied(library)
fs.mark_delete_denied(nested)
fs.clear_delete_denied(library)
assert fs.is_delete_denied(library) is False
assert fs.is_delete_denied(nested) is False
def test_normal_destination_still_publishes_atomically(
source: Path, library: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression guard: unaffected shares keep the temp-file + rename path."""
replaced: list[tuple[str, str]] = []
real_replace = os.replace
def spy_replace(src, dst, *args, **kwargs):
replaced.append((str(src), str(dst)))
return real_replace(src, dst, *args, **kwargs)
monkeypatch.setattr(fs.os, "replace", spy_replace)
final_path = atomic_copy(source, library / "book.epub")
assert final_path.read_bytes() == CONTENT
assert len(replaced) == 1
assert Path(replaced[0][0]).name.startswith(".shelfmark.")
assert fs.is_delete_denied(library) is False
+53 -1
View File
@@ -27,6 +27,7 @@ class _DummySelector:
self._index = 0
self.current_base = bases[0]
self.attempts_this_dns = 0
self.quarantined: list[tuple[str, str]] = []
def rewrite(self, url: str) -> str:
for base in self._bases:
@@ -34,7 +35,11 @@ class _DummySelector:
return url.replace(base, self.current_base, 1)
return url
def next_mirror_or_rotate_dns(self, allow_dns: bool = True) -> tuple[str | None, str]:
def next_mirror_or_rotate_dns(
self, allow_dns: bool = True, *, fatal: bool = False, reason: str = ""
) -> tuple[str | None, str]:
if fatal:
self.quarantined.append((self.current_base, reason))
self.attempts_this_dns += 1
self._index = (self._index + 1) % len(self._bases)
self.current_base = self._bases[self._index]
@@ -149,3 +154,50 @@ def test_html_get_page_locked_aa_does_not_fail_over_on_cross_host_redirect(monke
assert html == ""
assert calls == ["https://annas-archive.li/search?q=test"]
def test_html_get_page_echoes_cookies_across_same_host_redirects(monkeypatch):
"""DDoS-Guard's ?check=1 probe is cleared by echoing the Set-Cookie it issues.
Without this the __ddg* cookie is dropped on every hop, the server re-issues the same
redirect, and the request dies with TooManyRedirects.
"""
import shelfmark.download.http as http
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: False)
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http.time, "sleep", lambda _s: None)
monkeypatch.setattr(http.network, "get_aa_base_url", lambda: "https://annas-archive.li")
monkeypatch.setattr(http.network, "is_aa_auto_mode", lambda: True)
sent_cookies: list[dict[str, str]] = []
def fake_get(url: str, **kwargs):
sent_cookies.append(dict(kwargs["cookies"]))
if url == "https://annas-archive.li/search?q=test":
response = _FakeResponse(302, headers={"Location": "/search?q=test&check=1"}, url=url)
response.cookies = {"__ddg2_": "probe"}
return response
if url == "https://annas-archive.li/search?q=test&check=1":
# The probe only clears if the cookie comes back on this hop.
if kwargs["cookies"].get("__ddg2_") != "probe":
response = _FakeResponse(
302, headers={"Location": "/search?q=test&check=1"}, url=url
)
response.cookies = {"__ddg2_": "probe"}
return response
return _FakeResponse(200, text="RESULTS", url=url)
raise AssertionError(f"Unexpected URL: {url}")
monkeypatch.setattr(http.requests, "get", fake_get)
selector = _DummySelector(["https://annas-archive.li"])
html = http.html_get_page(
"https://annas-archive.li/search?q=test",
selector=selector,
retry=1,
allow_bypasser_fallback=False,
)
assert html == "RESULTS"
assert sent_cookies == [{}, {"__ddg2_": "probe"}]
+328 -14
View File
@@ -11,22 +11,13 @@ class _FakeResponse:
self.url = url
class _ImmediateThread:
def __init__(self, *args, **kwargs) -> None:
self._target = kwargs["target"]
def start(self) -> None:
self._target()
def join(self, timeout: float | None = None) -> None:
del timeout
def test_html_get_page_ignores_heartbeat_callback_failure(monkeypatch):
def test_html_get_page_ignores_status_callback_failure(monkeypatch):
"""A raising status_callback must not break the bypass it was reporting on."""
import shelfmark.download.http as http
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "Thread", _ImmediateThread)
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 330.0)
monkeypatch.setattr(http, "get_bypassed_page", lambda *_args, **_kwargs: "OK")
calls: list[tuple[str, str | None]] = []
@@ -46,10 +37,92 @@ def test_html_get_page_ignores_heartbeat_callback_failure(monkeypatch):
assert html == "OK"
assert calls == [
("resolving", "Bypassing protection..."),
("resolving", "Bypassing protection..."),
(ACTIVITY_GRACE_STATUS, "330.0"),
(ACTIVITY_GRACE_STATUS, "0.0"),
]
def test_html_get_page_requests_and_releases_activity_grace(monkeypatch):
"""The bypass declares its budget before blocking and releases it afterwards."""
import shelfmark.download.http as http
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 424.0)
calls: list[tuple[str, str | None]] = []
def fake_bypass(*_args, **_kwargs):
# The grace must already be in place before the long blocking call starts.
assert calls[-1] == (ACTIVITY_GRACE_STATUS, "424.0")
return "OK"
monkeypatch.setattr(http, "get_bypassed_page", fake_bypass)
html = http.html_get_page(
"https://example.com",
retry=1,
use_bypasser=True,
status_callback=lambda status, message: calls.append((status, message)),
)
assert html == "OK"
assert calls[-1] == (ACTIVITY_GRACE_STATUS, "0.0")
def test_html_get_page_releases_grace_and_reports_error_when_bypasser_fails(monkeypatch):
"""A failing bypasser surfaces its real error instead of a silent empty result."""
import shelfmark.download.http as http
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
def failing_bypasser(*_args, **_kwargs):
raise requests.exceptions.RequestException("500 Server Error")
monkeypatch.setattr(http, "get_bypassed_page", failing_bypasser)
calls: list[tuple[str, str | None]] = []
html = http.html_get_page(
"https://example.com",
retry=1,
use_bypasser=True,
status_callback=lambda status, message: calls.append((status, message)),
)
assert html == ""
errors = [message for status, message in calls if status == "error"]
assert len(errors) == 1
assert "500 Server Error" in (errors[0] or "")
# The grace is always released, even on the failure path.
assert calls[-1] == (ACTIVITY_GRACE_STATUS, "0.0")
def test_html_get_page_does_not_report_error_when_bypass_is_cancelled(monkeypatch):
"""Cancellation is a user action, not a failure worth surfacing as an error."""
import shelfmark.download.http as http
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
def cancelled_bypasser(*_args, **_kwargs):
raise BypassCancelledError("Bypass cancelled")
monkeypatch.setattr(http, "get_bypassed_page", cancelled_bypasser)
calls: list[tuple[str, str | None]] = []
html = http.html_get_page(
"https://example.com",
retry=1,
use_bypasser=True,
status_callback=lambda status, message: calls.append((status, message)),
)
assert html == ""
assert [status for status, _message in calls if status == "error"] == []
def test_html_get_page_returns_empty_on_bypass_cancellation(monkeypatch):
import shelfmark.download.http as http
@@ -65,6 +138,106 @@ def test_html_get_page_returns_empty_on_bypass_cancellation(monkeypatch):
assert html == ""
def test_challenged_search_switches_to_bypasser(monkeypatch):
"""AA gates /search behind DDoS-Guard; the 403 must reach the bypasser, not a 503.
Guards the regression where search passed allow_bypasser_fallback=False, so a
challenge on every mirror surfaced as "mirrors are blocked" with no solve attempted.
"""
import shelfmark.download.http as http
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
monkeypatch.setattr(http, "get_cf_cookies_for_domain", lambda _hostname: {})
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
def ddos_guarded(url: str, **_kwargs):
error = requests.exceptions.HTTPError("forbidden")
error.response = _FakeResponse(403, url=url)
raise error
bypassed: list[str] = []
monkeypatch.setattr(http.requests, "get", ddos_guarded)
monkeypatch.setattr(
http,
"get_bypassed_page",
lambda url, *_a, **_k: bypassed.append(url) or "<table>results</table>",
)
html = http.html_get_page(
"https://annas-archive.gl/search?q=dune",
retry=10,
allow_bypasser_fallback=True,
success_delay=0,
)
assert html == "<table>results</table>"
assert bypassed == ["https://annas-archive.gl/search?q=dune"]
def test_redirect_loop_purges_stale_cookies_and_switches_to_bypasser(monkeypatch):
"""A stale clearance cookie turns the gate into a `?check=1` redirect loop.
Guards the regression where TooManyRedirects carried no status code, so the
403-only rescue never fired and every retry re-sent the dead cookie.
"""
import shelfmark.download.http as http
stale = {"__ddg8_": "stale"}
cleared: list[str] = []
class _FakeInternalBypasser:
@staticmethod
def clear_cf_cookies(domain: str) -> None:
cleared.append(domain)
stale.clear()
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: False)
monkeypatch.setattr(http, "_get_internal_bypasser", lambda: _FakeInternalBypasser)
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 100.0)
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: dict(stale))
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
monkeypatch.setattr(http.network, "is_aa_auto_mode", lambda: True)
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
sent_cookies: list[dict[str, str]] = []
def check_redirect(url: str, **kwargs):
sent_cookies.append(kwargs["cookies"])
response = _FakeResponse(302, url=url)
response.is_redirect = True
response.headers = {"Location": f"{url}&check=1"}
return response
bypassed: list[str] = []
monkeypatch.setattr(http.requests, "get", check_redirect)
monkeypatch.setattr(
http,
"get_bypassed_page",
lambda url, *_a, **_k: bypassed.append(url) or "<table>results</table>",
)
html = http.html_get_page(
"https://annas-archive.gl/search?q=dune",
retry=10,
allow_bypasser_fallback=True,
success_delay=0,
)
assert html == "<table>results</table>"
assert cleared == ["annas-archive.gl"]
assert len(bypassed) == 1
# Escaped on the first exception, not retried with the dead cookie.
assert sent_cookies[0] == {"__ddg8_": "stale"}
def test_download_url_ignores_zlib_cookie_refresh_failure(monkeypatch):
import shelfmark.download.http as http
@@ -110,3 +283,144 @@ def test_get_bypassed_page_uses_external_bypasser_when_enabled(monkeypatch):
assert http.get_bypassed_page("https://example.com", selector, cancel_flag) == "EXT"
assert calls == [("https://example.com", selector, cancel_flag)]
def test_redirect_loop_gives_up_immediately_when_bypasser_not_allowed(monkeypatch):
"""A loop the bypasser may not rescue must fail fast, not burn the retry budget.
Guards the regression where the unrescued loop raised TooManyRedirects into the
retry path: that error is not retryable and carries no status, so every attempt
re-ran the full 6-redirect loop for ~60 requests to AA before giving up.
"""
import shelfmark.download.http as http
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
monkeypatch.setattr(http.network, "is_aa_auto_mode", lambda: True)
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
requested: list[str] = []
def check_redirect(url: str, **_kwargs):
requested.append(url)
response = _FakeResponse(302, url=url)
response.is_redirect = True
response.headers = {"Location": f"{url}&check=1"}
return response
def unreachable_bypasser(*_args, **_kwargs):
msg = "bypasser must not run when allow_bypasser_fallback is False"
raise AssertionError(msg)
monkeypatch.setattr(http.requests, "get", check_redirect)
monkeypatch.setattr(http, "get_bypassed_page", unreachable_bypasser)
html = http.html_get_page(
"https://annas-archive.gl/dyn/md5/summary/abc",
retry=10,
allow_bypasser_fallback=False,
success_delay=0,
)
assert html == ""
# One pass through the redirect cap, not one pass per retry attempt.
assert len(requested) == http._MAX_REDIRECTS + 1
def test_html_get_page_redirect_loop_purges_cookies_and_bypasses(monkeypatch):
"""A redirect loop is the challenge served against stale cookies, not a retryable error.
TooManyRedirects carries no status code, so without an explicit branch it falls through
to the generic retry path and repeats the identical failure for the whole retry budget.
"""
import shelfmark.download.http as http
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: False)
monkeypatch.setattr(http, "_bypass_grace_seconds", lambda: 330.0)
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http.time, "sleep", lambda _s: None)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
monkeypatch.setattr(http.network, "get_aa_base_url", lambda: "https://annas-archive.li")
monkeypatch.setattr(http.network, "is_aa_auto_mode", lambda: False)
cleared: list[str] = []
class FakeInternalBypasser:
def clear_cf_cookies(self, domain: str) -> None:
cleared.append(domain)
def get_cf_cookies_for_domain(self, _domain: str) -> dict[str, str]:
return {"__ddg2_": "stale"}
def get_cf_user_agent_for_domain(self, _domain: str) -> str | None:
return None
monkeypatch.setattr(http, "_get_internal_bypasser", lambda: FakeInternalBypasser())
monkeypatch.setattr(http, "get_bypassed_page", lambda *_args, **_kwargs: "SOLVED")
class _FakeRedirect:
"""A 302 that always points at the same ?check=1 URL, cookies unchanged."""
is_redirect = True
status_code = 302
cookies = {"__ddg2_": "stale"}
def __init__(self, url: str) -> None:
self.url = url
self.headers = {"Location": "https://annas-archive.li/search?q=test&check=1"}
hits: list[str] = []
def fake_get(url: str, **kwargs):
hits.append(url)
# Stale cookies: the server keeps re-issuing the same ?check=1 redirect.
return _FakeRedirect(url)
monkeypatch.setattr(http.requests, "get", fake_get)
html = http.html_get_page(
"https://annas-archive.li/search?q=test",
retry=2,
success_delay=0,
)
assert html == "SOLVED"
assert cleared == ["annas-archive.li"]
# The loop is cut short: no second attempt spent repeating the same redirects.
assert len(hits) == http._MAX_REDIRECTS + 1
def test_html_get_page_redirect_loop_on_non_aa_host_is_left_alone(monkeypatch):
"""Only hosts whose redirects we follow manually get the challenge treatment.
Elsewhere requests follows redirects itself, so a loop is an ordinary misconfiguration -
purging that host's cookies and forcing a bypass would be the wrong response.
"""
import shelfmark.download.http as http
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: False)
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(http.time, "sleep", lambda _s: None)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
bypassed: list[str] = []
monkeypatch.setattr(
http, "get_bypassed_page", lambda url, *_args, **_kwargs: bypassed.append(url) or "SOLVED"
)
def fake_get(_url: str, **_kwargs):
raise requests.exceptions.TooManyRedirects("Exceeded 30 redirects.")
monkeypatch.setattr(http.requests, "get", fake_get)
html = http.html_get_page("https://example.com/loop", retry=2, success_delay=0)
assert html == ""
assert bypassed == []
@@ -0,0 +1,103 @@
"""Tests for the Z-Library 503 cookie handshake."""
import requests
class _FakeResponse:
"""Minimal stand-in for requests.Response covering what html_get_page touches."""
def __init__(
self,
status_code: int,
*,
url: str = "https://z-lib.fm/md5/abc",
text: str = "",
cookies: dict[str, str] | None = None,
) -> None:
self.status_code = status_code
self.url = url
self.text = text
self.cookies = cookies or {}
self.headers = {"Content-Type": "text/html;charset=utf-8"}
self.is_redirect = False
def raise_for_status(self) -> None:
if self.status_code >= 400:
error = requests.exceptions.HTTPError(f"{self.status_code} Error")
error.response = self
raise error
def _neutralize_network(monkeypatch, http):
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
def test_html_get_page_echoes_503_cookie(monkeypatch):
"""A 503 that only sets a cookie is cleared by sending that cookie back."""
import shelfmark.download.http as http
_neutralize_network(monkeypatch, http)
sent_cookies: list[dict[str, str]] = []
def fake_get(_url: str, **kwargs):
sent_cookies.append(dict(kwargs["cookies"]))
if len(sent_cookies) == 1:
return _FakeResponse(503, cookies={"zlib_sid": "s3cr3t"})
return _FakeResponse(200, text="<html>real page</html>")
monkeypatch.setattr(http.requests, "get", fake_get)
html = http.html_get_page("https://z-lib.fm/md5/abc", retry=3, success_delay=0)
assert html == "<html>real page</html>"
# The first hit carries nothing; the retry echoes back exactly what the 503 issued.
assert sent_cookies == [{}, {"zlib_sid": "s3cr3t"}]
def test_html_get_page_stops_echoing_when_cookie_is_reissued(monkeypatch):
"""A server repeating the same cookie must not spin the request loop forever."""
import shelfmark.download.http as http
_neutralize_network(monkeypatch, http)
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: False)
sent_cookies: list[dict[str, str]] = []
def fake_get(_url: str, **kwargs):
sent_cookies.append(dict(kwargs["cookies"]))
return _FakeResponse(503, cookies={"zlib_sid": "same"})
monkeypatch.setattr(http.requests, "get", fake_get)
html = http.html_get_page("https://z-lib.fm/md5/abc", retry=1, success_delay=0)
assert html == ""
# One initial hit plus one echo; the reissued identical cookie yields no third request.
assert sent_cookies == [{}, {"zlib_sid": "same"}]
def test_html_get_page_leaves_cookieless_503_on_the_retry_path(monkeypatch):
"""A plain overloaded-server 503 keeps its existing retry behaviour."""
import shelfmark.download.http as http
_neutralize_network(monkeypatch, http)
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: False)
attempts: list[dict[str, str]] = []
def fake_get(_url: str, **kwargs):
attempts.append(dict(kwargs["cookies"]))
return _FakeResponse(503)
monkeypatch.setattr(http.requests, "get", fake_get)
html = http.html_get_page("https://z-lib.fm/md5/abc", retry=2, success_delay=0)
assert html == ""
# Two ordinary attempts, no extra in-place retry and no cookies invented.
assert attempts == [{}, {}]
@@ -0,0 +1,221 @@
"""Tests for AA mirror quarantine: dead mirrors leave the rotation, live ones stay.
The distinction these guard is the whole point of the feature. A mirror that answers
403 (DDoS-Guard) is alive and holds our bypass clearance, so rotating off it makes the
next search solve a fresh challenge on a domain we have no cookie for. A mirror that
NXDOMAINs, refuses the connection, or answers 200 with a parking page is not a mirror
at all and must never be tried again this session.
"""
import requests
def _fresh_network(monkeypatch, urls: list[str], *, auto: bool = True):
import shelfmark.download.network as network
monkeypatch.setattr(network, "_initialized", True)
monkeypatch.setattr(network, "_aa_urls", list(urls))
monkeypatch.setattr(network, "_aa_base_url", urls[0])
monkeypatch.setattr(network, "_current_aa_url_index", 0)
monkeypatch.setattr(network, "_dead_aa_urls", set())
monkeypatch.setattr(network, "_save_state", lambda **kwargs: None)
monkeypatch.setattr(network, "is_aa_auto_mode", lambda: auto)
return network
MIRRORS = ["https://aa-one.test", "https://aa-two.test", "https://aa-three.test"]
def test_quarantined_mirror_leaves_the_available_list(monkeypatch):
network = _fresh_network(monkeypatch, MIRRORS)
assert network.mark_aa_url_dead("https://aa-two.test", "NXDOMAIN") is True
assert network.get_available_aa_urls() == ["https://aa-one.test", "https://aa-three.test"]
assert network.get_dead_aa_urls() == {"https://aa-two.test"}
def test_quarantine_accepts_a_full_request_url(monkeypatch):
"""Callers hold the failing request URL, not the bare mirror base."""
network = _fresh_network(monkeypatch, MIRRORS)
assert network.mark_aa_url_dead("https://aa-two.test/search?q=dune", "parked") is True
assert "https://aa-two.test" in network.get_dead_aa_urls()
def test_quarantine_is_idempotent(monkeypatch):
network = _fresh_network(monkeypatch, MIRRORS)
assert network.mark_aa_url_dead("https://aa-two.test", "NXDOMAIN") is True
assert network.mark_aa_url_dead("https://aa-two.test", "NXDOMAIN") is False
assert network.get_available_aa_urls() == ["https://aa-one.test", "https://aa-three.test"]
def test_last_surviving_mirror_is_never_quarantined(monkeypatch):
"""Misclassification must not leave the app with nowhere to search."""
network = _fresh_network(monkeypatch, MIRRORS)
assert network.mark_aa_url_dead("https://aa-one.test", "NXDOMAIN") is True
assert network.mark_aa_url_dead("https://aa-two.test", "NXDOMAIN") is True
assert network.mark_aa_url_dead("https://aa-three.test", "NXDOMAIN") is False
assert network.get_available_aa_urls() == ["https://aa-three.test"]
def test_selector_skips_quarantined_mirror_when_rotating(monkeypatch):
network = _fresh_network(monkeypatch, MIRRORS)
selector = network.AAMirrorSelector()
new_base, action = selector.next_mirror_or_rotate_dns(fatal=True, reason="NXDOMAIN")
assert action == "mirror"
# Landed on the next live mirror, not skipped past it onto the third.
assert new_base == "https://aa-two.test"
assert "https://aa-one.test" in network.get_dead_aa_urls()
assert selector.rewrite("https://aa-one.test/search") == "https://aa-two.test/search"
def test_non_fatal_rotation_keeps_the_mirror(monkeypatch):
"""A 5xx or a challenge rotates but must not burn the mirror."""
network = _fresh_network(monkeypatch, MIRRORS)
selector = network.AAMirrorSelector()
selector.next_mirror_or_rotate_dns()
assert network.get_dead_aa_urls() == set()
assert network.get_available_aa_urls() == MIRRORS
def test_dns_reset_does_not_resurrect_quarantined_mirrors(monkeypatch):
"""A new DNS provider cannot revive a parked domain, so it stays skipped."""
network = _fresh_network(monkeypatch, MIRRORS)
monkeypatch.setattr(network, "rotate_dns_provider", lambda: True)
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
network.mark_aa_url_dead("https://aa-one.test", "parked")
assert network.rotate_dns_and_reset_aa() is True
assert network.get_aa_base_url() == "https://aa-two.test"
def test_editing_the_mirror_list_clears_quarantine(monkeypatch):
"""Quarantine decisions were made about a list the user has now changed."""
network = _fresh_network(monkeypatch, MIRRORS)
network.mark_aa_url_dead("https://aa-two.test", "parked")
monkeypatch.setattr(network, "_build_aa_urls", lambda: [*MIRRORS, "https://aa-four.test"])
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
monkeypatch.setattr(network, "state", {"aa_base_url": "https://aa-one.test"})
network._initialize_aa_state()
assert network.get_dead_aa_urls() == set()
def test_reinit_with_an_unchanged_list_keeps_quarantine(monkeypatch):
"""Re-init happens constantly (settings sync, DNS rotation, helper startup).
Clearing quarantine on every one of those resurrects a parked mirror mid-session,
which is exactly the bug this guards: the mirror gets re-elected and the next
search pays for it again.
"""
network = _fresh_network(monkeypatch, MIRRORS)
network.mark_aa_url_dead("https://aa-two.test", "parked")
monkeypatch.setattr(network, "_build_aa_urls", lambda: list(MIRRORS))
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
monkeypatch.setattr(network, "state", {"aa_base_url": "https://aa-one.test"})
network._initialize_aa_state()
assert network.get_dead_aa_urls() == {"https://aa-two.test"}
# --------------------------------------------------------------------------- #
# Failure classification
# --------------------------------------------------------------------------- #
def _http_error(status: int) -> requests.exceptions.HTTPError:
response = requests.Response()
response.status_code = status
return requests.exceptions.HTTPError(response=response)
def test_dns_failure_is_fatal_for_the_mirror():
import shelfmark.download.http as http
exc = requests.exceptions.ConnectionError(
"HTTPSConnectionPool(host='aa.test', port=443): Max retries exceeded "
"(Caused by NameResolutionError(\"Failed to resolve 'aa.test'\"))"
)
assert http._fatal_mirror_reason(exc) == "DNS does not resolve"
def test_connection_refused_is_fatal_for_the_mirror():
import shelfmark.download.http as http
exc = requests.exceptions.ConnectionError("Connection refused")
assert http._fatal_mirror_reason(exc) == "connection refused"
def test_gone_and_legal_block_are_fatal():
import shelfmark.download.http as http
assert http._fatal_mirror_reason(_http_error(410)) == "HTTP 410"
assert http._fatal_mirror_reason(_http_error(451)) == "HTTP 451"
def test_timeout_is_not_fatal():
"""A slow mirror is still a mirror - and may hold our bypass clearance."""
import shelfmark.download.http as http
assert http._fatal_mirror_reason(requests.exceptions.ConnectTimeout("timed out")) is None
assert http._fatal_mirror_reason(requests.exceptions.ReadTimeout("timed out")) is None
def test_challenge_and_server_errors_are_not_fatal():
import shelfmark.download.http as http
for status in (403, 429, 500, 502, 503):
assert http._fatal_mirror_reason(_http_error(status)) is None
def test_startup_probe_skips_quarantined_mirrors(monkeypatch):
"""Re-init must not re-probe (or re-elect) a mirror already known to be dead.
A parking page answers 200, so an unfiltered probe elects it every single time
the app re-initialises - one wasted request per re-init, forever.
"""
import requests
network = _fresh_network(monkeypatch, MIRRORS)
network.mark_aa_url_dead("https://aa-one.test", "parked")
probed: list[str] = []
def fake_get(url, **_kwargs):
probed.append(url)
response = requests.Response()
response.status_code = 200
return response
monkeypatch.setattr(network.requests, "get", fake_get)
monkeypatch.setattr(network, "_build_aa_urls", lambda: list(MIRRORS))
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
monkeypatch.setattr(network, "state", {})
monkeypatch.setattr(network, "get_proxies", lambda _url: None)
monkeypatch.setattr(network, "get_ssl_verify", lambda _url: True)
network._initialize_aa_state()
assert "https://aa-one.test" not in probed
assert network.get_aa_base_url() == "https://aa-two.test"
def test_startup_probe_does_not_restore_a_quarantined_mirror(monkeypatch):
"""Saved state can name a mirror that has since been quarantined."""
network = _fresh_network(monkeypatch, MIRRORS)
network.mark_aa_url_dead("https://aa-one.test", "parked")
monkeypatch.setattr(network, "_build_aa_urls", lambda: list(MIRRORS))
monkeypatch.setattr(network, "_get_configured_aa_url", lambda: "auto")
monkeypatch.setattr(network, "state", {"aa_base_url": "https://aa-one.test"})
monkeypatch.setattr(network, "get_proxies", lambda _url: None)
monkeypatch.setattr(network, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(network.requests, "get", lambda *a, **k: (_ for _ in ()).throw(OSError()))
network._initialize_aa_state()
assert network.get_aa_base_url() != "https://aa-one.test"
+73 -3
View File
@@ -9,6 +9,10 @@ class _StopLoop(BaseException):
"""Sentinel used to stop the infinite coordinator loop during tests."""
class _UnexpectedCall(BaseException):
"""Guard-rail failure that the coordinator's `except Exception` must not swallow."""
class _FakeExecutor:
def __init__(self, *args, **kwargs) -> None:
self.args = args
@@ -21,7 +25,7 @@ class _FakeExecutor:
return False
def submit(self, *args, **kwargs): # pragma: no cover - not expected in these tests
raise AssertionError("submit() should not be called in this test")
raise _UnexpectedCall("submit() should not be called in this test")
class _StopCoordinator(BaseException):
@@ -68,6 +72,69 @@ def test_concurrent_download_loop_logs_and_recovers_after_loop_error(monkeypatch
]
def test_concurrent_download_loop_survives_exceptions_outside_the_legacy_list(monkeypatch):
"""Regression for #823/#1166: the coordinator must not die on an unlisted exception.
`gevent.exceptions.LoopExit` and `StopIteration` are Exceptions that the old narrow
except tuple let through, silently killing the only thread that drives the queue.
"""
import shelfmark.download.orchestrator as orchestrator
raised: list[type[BaseException]] = []
escapes = [StopIteration, ZeroDivisionError, KeyboardInterrupt]
def fake_get_next():
if escapes:
exc = escapes.pop(0)
if exc is KeyboardInterrupt:
# BaseException: must still propagate and stop the loop.
raise KeyboardInterrupt
raised.append(exc)
raise exc("boom")
return None
mock_queue = MagicMock()
mock_queue.get_next.side_effect = fake_get_next
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "ThreadPoolExecutor", _FakeExecutor)
monkeypatch.setattr(orchestrator.time, "sleep", lambda _delay: None)
monkeypatch.setattr(orchestrator.logger, "error_trace", MagicMock())
with pytest.raises(KeyboardInterrupt):
orchestrator.concurrent_download_loop()
assert raised == [StopIteration, ZeroDivisionError]
def test_concurrent_download_loop_backs_off_on_repeated_errors(monkeypatch):
"""A persistent failure must not spin the loop at 1Hz forever."""
import shelfmark.download.orchestrator as orchestrator
sleep_delays: list[float] = []
def fake_sleep(delay: float) -> None:
sleep_delays.append(delay)
if len(sleep_delays) >= 4:
raise _StopLoop()
mock_queue = MagicMock()
mock_queue.get_next.side_effect = RuntimeError("persistent boom")
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "ThreadPoolExecutor", _FakeExecutor)
monkeypatch.setattr(orchestrator.time, "sleep", fake_sleep)
monkeypatch.setattr(orchestrator.logger, "error_trace", MagicMock())
with pytest.raises(_StopLoop):
orchestrator.concurrent_download_loop()
base = orchestrator.COORDINATOR_LOOP_ERROR_RETRY_DELAY
# First delay stays unchanged for a normal transient blip, then doubles.
assert sleep_delays == [base, base * 2, base * 4, base * 8]
assert max(sleep_delays) <= orchestrator._COORDINATOR_LOOP_ERROR_MAX_DELAY
def test_concurrent_download_loop_recovers_and_processes_task_after_transient_loop_error(
monkeypatch,
):
@@ -91,13 +158,16 @@ def test_concurrent_download_loop_recovers_and_processes_task_after_transient_lo
raise _StopCoordinator()
return None
# These raise _UnexpectedCall rather than AssertionError because the coordinator
# loop catches Exception: an AssertionError here would be swallowed and logged,
# letting the test pass vacuously instead of reporting the unexpected call.
def cancel_download(self, task_id: str) -> None: # pragma: no cover - unused
raise AssertionError(f"cancel_download unexpectedly called for {task_id}")
raise _UnexpectedCall(f"cancel_download unexpectedly called for {task_id}")
def update_status_message(
self, task_id: str, message: str
) -> None: # pragma: no cover - unused
raise AssertionError(
raise _UnexpectedCall(
f"update_status_message unexpectedly called for {task_id}: {message}"
)
+291
View File
@@ -0,0 +1,291 @@
"""Tests for stall detection and the activity-grace window.
Covers the regression behind issue #1001: a protection bypass is a single long blocking
call that emits no changing status or progress, so it used to be cancelled at exactly
STALL_TIMEOUT even though the bypasser itself was still working.
"""
from unittest.mock import MagicMock
import requests
def _stub_ext_bypasser_timeout(monkeypatch, external_bypasser, value: int) -> None:
"""Override only EXT_BYPASSER_TIMEOUT.
`external_bypasser.config` is the shared config singleton, so a blanket `get` stub
also answers unrelated lookups (DNS setup, etc.) with the wrong value.
"""
real_get = external_bypasser.config.get
monkeypatch.setattr(
external_bypasser.config,
"get",
lambda key, default="": value if key == "EXT_BYPASSER_TIMEOUT" else real_get(key, default),
)
def _reset(orchestrator) -> None:
orchestrator._last_activity.clear()
orchestrator._last_progress_value.clear()
orchestrator._last_status_event.clear()
orchestrator._activity_grace.clear()
def test_find_stalled_tasks_flags_task_past_stall_timeout():
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
orchestrator._last_activity["book"] = 1000.0
now = 1000.0 + orchestrator.STALL_TIMEOUT + 1
assert orchestrator._find_stalled_tasks(["book"], now) == ["book"]
def test_find_stalled_tasks_ignores_task_within_stall_timeout():
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
orchestrator._last_activity["book"] = 1000.0
now = 1000.0 + orchestrator.STALL_TIMEOUT - 1
assert orchestrator._find_stalled_tasks(["book"], now) == []
def test_find_stalled_tasks_ignores_unknown_task():
"""A task with no recorded activity yet is not considered stalled."""
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
assert orchestrator._find_stalled_tasks(["never-seen"], 99999.0) == []
def test_activity_grace_suppresses_stall_until_its_deadline(monkeypatch):
"""A bypass that legitimately outlives STALL_TIMEOUT is not cancelled."""
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
monkeypatch.setattr(orchestrator.time, "time", lambda: 1000.0)
orchestrator._last_activity["book"] = 1000.0
orchestrator.set_activity_grace("book", 400.0)
# Well past STALL_TIMEOUT, but inside the declared budget.
assert orchestrator._find_stalled_tasks(["book"], 1390.0) == []
def test_activity_grace_expires_and_task_is_flagged(monkeypatch):
"""A genuinely wedged operation still dies - the grace never extends itself."""
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
monkeypatch.setattr(orchestrator.time, "time", lambda: 1000.0)
orchestrator._last_activity["book"] = 1000.0
orchestrator.set_activity_grace("book", 400.0)
assert orchestrator._find_stalled_tasks(["book"], 1401.0) == ["book"]
def test_set_activity_grace_is_clamped(monkeypatch):
"""A caller cannot buy immortality by asking for an absurd grace."""
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
monkeypatch.setattr(orchestrator.time, "time", lambda: 1000.0)
orchestrator.set_activity_grace("book", 10_000_000.0)
assert orchestrator._activity_grace["book"] == 1000.0 + orchestrator._MAX_ACTIVITY_GRACE_SECONDS
def test_max_activity_grace_covers_the_largest_bypass_budget():
"""The clamp must not silently bite the one caller that exists."""
import shelfmark.download.http as http
import shelfmark.download.orchestrator as orchestrator
from shelfmark.bypass import external_bypasser, internal_bypasser
largest = (
max(
external_bypasser.max_duration_seconds(),
internal_bypasser.max_duration_seconds(),
)
+ http._BYPASS_GRACE_SLACK_SECONDS
)
assert largest <= orchestrator._MAX_ACTIVITY_GRACE_SECONDS
def test_set_activity_grace_touches_neither_queue_nor_websocket(monkeypatch):
"""A liveness hint is not a user-visible status transition."""
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
mock_queue = MagicMock()
mock_ws = MagicMock()
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "ws_manager", mock_ws)
orchestrator.set_activity_grace("book", 100.0)
orchestrator.clear_activity_grace("book")
assert mock_queue.mock_calls == []
assert mock_ws.mock_calls == []
def test_clear_activity_grace_refreshes_last_activity(monkeypatch):
"""Releasing a grace restarts the normal window rather than stalling instantly."""
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
orchestrator._last_activity["book"] = 1.0
monkeypatch.setattr(orchestrator.time, "time", lambda: 5000.0)
orchestrator.set_activity_grace("book", 100.0)
orchestrator.clear_activity_grace("book")
assert "book" not in orchestrator._activity_grace
assert orchestrator._last_activity["book"] == 5000.0
assert orchestrator._find_stalled_tasks(["book"], 5000.0) == []
def test_cleanup_progress_tracking_drops_activity_grace(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
monkeypatch.setattr(orchestrator, "ws_manager", None)
orchestrator.set_activity_grace("book", 100.0)
orchestrator._cleanup_progress_tracking("book")
assert "book" not in orchestrator._activity_grace
def test_update_download_status_rejects_the_grace_sentinel(monkeypatch):
"""Defence in depth: the sentinel is intercepted upstream, but must be inert here too."""
import shelfmark.download.orchestrator as orchestrator
from shelfmark.download.activity import ACTIVITY_GRACE_STATUS
_reset(orchestrator)
mock_queue = MagicMock()
mock_ws = MagicMock()
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
monkeypatch.setattr(orchestrator, "ws_manager", mock_ws)
monkeypatch.setattr(orchestrator, "queue_status", lambda: {})
orchestrator.update_download_status("book", ACTIVITY_GRACE_STATUS, "330.0")
assert mock_queue.mock_calls == []
assert mock_ws.mock_calls == []
assert "book" not in orchestrator._last_activity
def test_issue_1001_slow_bypass_is_not_cancelled_at_stall_timeout(monkeypatch):
"""End-to-end replay of the issue #1001 timeline.
From a reporter's debug log: a 403 switched to the external bypasser at 07:04:33, five
FlareSolverr attempts each took ~64s and returned HTTP 500, and the watchdog cancelled
the download at 07:09:33.987 - exactly STALL_TIMEOUT later and 41s before the bypasser
would have finished and reported the real error.
Now the bypass declares its own budget, so the watchdog holds off and the user sees the
actual failure instead of a five-minute silent hang.
"""
import shelfmark.download.http as http
import shelfmark.download.orchestrator as orchestrator
from shelfmark.bypass import external_bypasser
from shelfmark.download.activity import parse_activity_grace
_reset(orchestrator)
clock = [1000.0]
monkeypatch.setattr(orchestrator.time, "time", lambda: clock[0])
# Mirror the orchestrator's per-task status_callback closure.
events: list[tuple[str, str | None]] = []
def status_callback(status: str, message: str | None = None) -> None:
grace = parse_activity_grace(status, message)
if grace is not None:
if grace > 0:
orchestrator.set_activity_grace("book", grace)
else:
orchestrator.clear_activity_grace("book")
return
events.append((status, message))
orchestrator._last_activity["book"] = clock[0]
# The reporter was on external FlareSolverr with the default 60s timeout.
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http, "_is_using_external_bypasser", lambda: True)
monkeypatch.setattr(http, "_get_external_bypasser", lambda: external_bypasser)
_stub_ext_bypasser_timeout(monkeypatch, external_bypasser, 60000)
stall_checks: list[list[str]] = []
def slow_failing_bypasser(*_args, **_kwargs):
# Five attempts at ~64s, then give up - as in the log.
for _attempt in range(5):
clock[0] += 64.0
stall_checks.append(orchestrator._find_stalled_tasks(["book"], clock[0]))
raise requests.exceptions.RequestException("500 Server Error")
monkeypatch.setattr(http, "get_bypassed_page", slow_failing_bypasser)
html = http.html_get_page(
"https://annas-archive.gl/slow_download/abc/0/6",
retry=1,
use_bypasser=True,
status_callback=status_callback,
)
# The bypass ran 320s - past STALL_TIMEOUT - and was never flagged as stalled.
assert 320.0 > orchestrator.STALL_TIMEOUT
assert stall_checks[-1] == []
assert all(check == [] for check in stall_checks)
# And the real reason reached the user instead of a generic stall message.
assert html == ""
assert [status for status, _m in events if status == "error"]
assert "500 Server Error" in str(events[-1][1])
def test_issue_1001_bypass_overrunning_its_own_budget_is_still_cancelled(monkeypatch):
"""The other half: declaring a budget must not make a wedged bypass immortal."""
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
monkeypatch.setattr(orchestrator.time, "time", lambda: 1000.0)
orchestrator._last_activity["book"] = 1000.0
# External bypasser default budget (394s) plus http's 30s slack.
orchestrator.set_activity_grace("book", 424.0)
assert orchestrator._find_stalled_tasks(["book"], 1000.0 + 424.0) == []
assert orchestrator._find_stalled_tasks(["book"], 1000.0 + 425.0) == ["book"]
def test_cancel_stalled_task_does_not_hold_the_progress_lock(monkeypatch):
"""Regression guard: cancelling reaches a sqlite write that must not block the hub.
`book_queue.cancel_download` runs the terminal-status hooks, which are not
gevent-patched. Holding `_progress_lock` across that stalls every download worker.
"""
import shelfmark.download.orchestrator as orchestrator
_reset(orchestrator)
observed: list[bool] = []
def fake_cancel(_task_id: str) -> bool:
acquired = orchestrator._progress_lock.acquire(blocking=False)
observed.append(acquired)
if acquired:
orchestrator._progress_lock.release()
return True
mock_queue = MagicMock()
mock_queue.cancel_download.side_effect = fake_cancel
monkeypatch.setattr(orchestrator, "book_queue", mock_queue)
orchestrator._cancel_stalled_task("book")
assert observed == [True], "_progress_lock was held while cancelling a stalled task"
mock_queue.update_status_message.assert_called_once_with(
"book", f"Download stalled (no activity for {orchestrator.STALL_TIMEOUT}s)"
)
+170
View File
@@ -0,0 +1,170 @@
"""Boot-time search warm-up.
The warm-up exists to move the cold DDoS-Guard solve off the user's first search. It
is an optimisation, so the load-bearing property is that it can never affect startup:
a source that is down, misconfigured or raising must leave the app running.
"""
import pytest
@pytest.fixture
def warmup(monkeypatch):
import shelfmark.download.warmup as warmup_module
monkeypatch.setattr(warmup_module, "_warmup_thread", None)
return warmup_module
def _patch_config(monkeypatch, warmup, values: dict):
def fake_get(key, default=None):
return values.get(key, default)
monkeypatch.setattr(warmup.config, "get", fake_get)
def test_disabled_by_setting(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_ENABLED": False})
assert warmup.is_enabled() is False
assert warmup.start() is False
def test_disabled_by_string_false(monkeypatch, warmup):
"""Deployment ENV arrives as a string, not a bool."""
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_ENABLED": "false"})
assert warmup.is_enabled() is False
def test_skipped_when_direct_download_is_off(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {"DIRECT_DOWNLOAD_ENABLED": False})
assert warmup.is_enabled() is False
def test_enabled_by_default(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
assert warmup.is_enabled() is True
def test_query_defaults_and_is_configurable(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
assert warmup.warmup_query() == "The Great Gatsby"
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_QUERY": "Dune"})
assert warmup.warmup_query() == "Dune"
# A blank override must not send an empty query at the source.
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_QUERY": " "})
assert warmup.warmup_query() == "The Great Gatsby"
def test_skipped_when_no_mirrors_configured(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
import shelfmark.core.mirrors as mirrors
monkeypatch.setattr(mirrors, "has_aa_mirror_configuration", lambda: False)
called: list[str] = []
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(dd, "search_books", lambda q, f: called.append(q))
assert warmup.run_warmup() is False
assert called == []
def test_successful_warmup_reports_true(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
import shelfmark.core.mirrors as mirrors
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(mirrors, "has_aa_mirror_configuration", lambda: True)
seen: list[str] = []
def fake_search(query, _filters):
seen.append(query)
return ["a", "b"]
monkeypatch.setattr(dd, "search_books", fake_search)
assert warmup.run_warmup() is True
assert seen == ["The Great Gatsby"]
def test_empty_results_are_not_an_error(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
import shelfmark.core.mirrors as mirrors
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(mirrors, "has_aa_mirror_configuration", lambda: True)
monkeypatch.setattr(dd, "search_books", lambda q, f: [])
assert warmup.run_warmup() is False
def test_search_failure_is_swallowed(monkeypatch, warmup):
"""A source that is down at boot must not propagate out of the warm-up."""
_patch_config(monkeypatch, warmup, {})
import shelfmark.core.mirrors as mirrors
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(mirrors, "has_aa_mirror_configuration", lambda: True)
def boom(_query, _filters):
msg = "mirrors are blocked"
raise RuntimeError(msg)
monkeypatch.setattr(dd, "search_books", boom)
assert warmup.run_warmup() is False
def test_start_schedules_a_daemon_thread_and_is_idempotent(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
assert warmup.start(delay_seconds=30) is True
thread = warmup._warmup_thread
assert thread is not None
assert thread.daemon is True
# A second call must not stack up another timer.
assert warmup.start(delay_seconds=30) is False
assert warmup._warmup_thread is thread
thread.cancel()
def test_start_does_not_run_the_search_inline(monkeypatch, warmup):
"""Startup must not block on a search that can take a minute."""
_patch_config(monkeypatch, warmup, {})
ran: list[bool] = []
monkeypatch.setattr(warmup, "run_warmup", lambda: ran.append(True))
warmup.start(delay_seconds=30)
assert ran == []
if warmup._warmup_thread:
warmup._warmup_thread.cancel()
def test_env_var_can_disable_the_warmup(monkeypatch, warmup):
"""SEARCH_WARMUP_ENABLED is not in the settings registry, so config.get never
sees it - the documented off-switch only works if os.environ is consulted."""
_patch_config(monkeypatch, warmup, {}) # config knows nothing about the key
monkeypatch.setenv("SEARCH_WARMUP_ENABLED", "false")
assert warmup.is_enabled() is False
assert warmup.start() is False
def test_env_var_can_set_the_query(monkeypatch, warmup):
_patch_config(monkeypatch, warmup, {})
monkeypatch.setenv("SEARCH_WARMUP_QUERY", "Moby Dick")
assert warmup.warmup_query() == "Moby Dick"
def test_env_var_absent_falls_back_to_config(monkeypatch, warmup):
monkeypatch.delenv("SEARCH_WARMUP_QUERY", raising=False)
_patch_config(monkeypatch, warmup, {"SEARCH_WARMUP_QUERY": "From Config"})
assert warmup.warmup_query() == "From Config"
+8 -6
View File
@@ -87,12 +87,14 @@ end-to-end (`docker compose up` + suite + teardown) and passes.
| `full` | **real Chrome solves Cloudflare** + DoH + **real qBittorrent** download → /books (Moby-Dick) | 1,4,5 + DoH | **#284 #1030** #386 #1040 #214 | ✅ 6 passed |
| *(every profile)* | boots healthy under PUID/PGID, no perm errors | 6 entrypoint | #171 #447 #801 | ✅ |
> **The bypasser is download-time, not search-time.** Running the stack revealed
> that shelfmark fetches AA search/detail with `allow_bypasser_fallback=False`, so a
> search behind Cloudflare returns 503 **regardless** of the bypasser; the bypasser
> (internal Chrome or external FlareSolverr) only runs during a file *download*
> (`use_bypasser=True`). The bypasser profiles therefore assert a *clean*
> CF-gated-search failure, while the **`full` profile exercises the real end-to-end
> **The bypasser now runs for search too.** AA search/detail used to be fetched with
> `allow_bypasser_fallback=False`, so a search behind Cloudflare returned 503
> regardless of the bypasser — which left search dead when AA put DDoS-Guard in front
> of `/search`. Both now pass `allow_bypasser_fallback=True`, so a 403 switches
> straight to the bypasser rather than rotating to another mirror behind the same
> gate. The bypasser profiles therefore assert that a gated search *recovers* when the
> external bypasser is available and still fails cleanly when it is off, while the
> **`full` profile exercises the real end-to-end
> CF solve**: AA search/detail are reachable, but the AA slow-download link points
> at the gate, so downloading Moby-Dick forces the in-image headless Chromium to
> detect the challenge, solve it (`_bypass_method_cdp_solve`), and fetch the file —
+11 -2
View File
@@ -59,8 +59,17 @@ fi
echo "==> [$PROFILE] waiting for shelfmark health"
HEALTHY=0
for _ in $(seq 1 60); do
if curl -fsS http://localhost:8084/api/health >/dev/null 2>&1; then HEALTHY=1; break; fi
# The curl timeouts are load-bearing, not belt-and-braces. A container that
# binds 8084 but never answers (e.g. a broken C-extension wheel wedging the
# gunicorn worker) blocks a bare `curl` forever on read, so an iteration-counted
# loop never reaches iteration 2 and the wait becomes unbounded — that hung CI
# for the full 6h job limit on PR #1169. Bound each probe AND the whole wait.
HEALTH_DEADLINE=$((SECONDS + 120))
while ((SECONDS < HEALTH_DEADLINE)); do
if curl -fsS --connect-timeout 3 --max-time 5 http://localhost:8084/api/health >/dev/null 2>&1; then
HEALTHY=1
break
fi
sleep 2
done
@@ -1,20 +1,18 @@
"""Cluster 1 — Cloudflare bypasser wiring + clean-failure behavior.
"""Cluster 1 — Cloudflare bypasser wiring + gated-search behavior.
Reality discovered by running the stack: shelfmark's AA *search* and *detail*
fetches use ``html_get_page(allow_bypasser_fallback=False)``, so a search behind a
Cloudflare gate returns 503 **regardless** of the bypasser. The bypasser (internal
Chrome or external FlareSolverr) is a *download-time* mechanism
(``html_get_page(use_bypasser=True)``); it never runs for search.
AA search/detail used to be fetched with ``allow_bypasser_fallback=False``, so a
search behind a Cloudflare gate returned 503 no matter how the bypasser was
configured — which left search dead when AA put DDoS-Guard in front of ``/search``.
Both now pass ``allow_bypasser_fallback=True``: a 403 switches straight to the
bypasser instead of rotating to another mirror behind the same gate.
So these tests assert what is actually true and host-observable:
* the external bypasser is configured from env, and
* a CF-gated search fails *cleanly* (a 503 the client can act on, not a hang or
a crash) — both with the bypasser on (it isn't used for search) and off.
So these tests assert:
* the external bypasser is configured from env,
* a CF-gated search *recovers* once the external bypasser is available, and
* with the bypasser off it still fails *cleanly* (a 503 the client can act on,
not a hang or a crash) — the negative control that the gate is not ignored.
Exercising shelfmark's *use* of the bypasser end-to-end (a real CF solve during a
download) needs the AA slow-download HTML flow mocked — see the README roadmap.
The bypass *mechanism* itself is verified to work: the mock FlareSolverr solves
the gate (manually confirmed; see README). Guards: #284 #226 #202 #1030 #410 #369.
Guards: #284 #226 #202 #1030 #410 #369.
"""
from __future__ import annotations
@@ -70,10 +68,26 @@ def test_external_bypasser_is_configured(client) -> None:
@pytest.mark.profiles("bypasser-external")
def test_cf_gated_search_fails_cleanly_with_external_bypasser(client) -> None:
"""Even with the external bypasser configured, a CF-gated *search* yields no
releases (the bypasser is download-time) — but it must fail cleanly."""
assert _cf_gated_search_has_no_releases(client)
def test_cf_gated_search_recovers_via_external_bypasser(client) -> None:
"""A CF-gated search is solved through the bypasser instead of returning 503.
The mock FlareSolverr refetches with the clearance cookie, which the cloudflare
role then passes through to the AA origin — so the 403 that used to end the
search now turns into real results.
"""
resp = client.get(
"/api/releases",
params={"source": "direct_download", "query": "Mistborn"},
timeout=120,
)
assert resp.status_code == 200, (
f"CF-gated search did not recover through the external bypasser: "
f"{resp.status_code} {resp.text[:200]}"
)
assert client.releases_from(resp), (
"external bypasser was configured but the gated search returned no releases — "
"the 403 did not switch search over to the bypasser"
)
@pytest.mark.profiles("bypasser-disabled")
+91
View File
@@ -1,5 +1,96 @@
import pytest
from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS
from shelfmark.release_sources.irc import parser
# What a stock install actually filters with, so these tests fail if the defaults regress.
_DEFAULT_CONFIG = {
"SUPPORTED_FORMATS": ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"],
"SUPPORTED_AUDIOBOOK_FORMATS": [*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS],
}
@pytest.fixture
def default_formats(monkeypatch):
"""Filter with the shipped default format lists."""
monkeypatch.setattr(
parser.config, "get", lambda key, default=None: _DEFAULT_CONFIG.get(key, default)
)
def test_audiobook_archives_are_found_with_default_settings(default_formats):
"""Regression for #1129: audiobooks ship as .rar/.zip and were dropped by both buckets.
A single "@search" answers with one file holding every format. These are the lines an
audiobook actually occupies in it - the extension is a container, and the release name
is the only thing saying what is inside.
"""
content = "\n".join(
[
"!Oatmeal Andy Weir - Project Hail Mary (Audiobook) [MP3 64kbps].rar ::INFO:: 620.5MB",
"!DV8 Andy Weir - Project Hail Mary - Audiobook.zip ::INFO:: 700MB",
"!Horla Andy Weir - Project Hail Mary [Unabridged].m4b ::INFO:: 850.1MB",
]
)
results = parser.parse_results_file(content, content_type="audiobook")
assert [result.format for result in results] == ["rar", "zip", "m4b"]
def test_ebook_archive_does_not_leak_into_audiobook_results(default_formats):
"""An ebook .rar must not be offered as an audiobook just because it is an archive."""
content = "!bald Andy Weir - Project Hail Mary (retail).rar ::INFO:: 2.1MB"
assert parser.parse_results_file(content, content_type="audiobook") == []
def test_flac_audiobook_is_reachable_with_default_settings(default_formats):
"""FLAC was recognized by the parser and ranked by the sorter, but never selectable."""
content = "!Ook Andy Weir - Project Hail Mary.flac ::INFO:: 1.1GB"
results = parser.parse_results_file(content, content_type="audiobook")
assert [result.format for result in results] == ["flac"]
def test_decimal_size_is_not_mistaken_for_a_file_extension():
"""A line with no extension used to parse as format="5mb" out of "::INFO:: 620.5MB"."""
line = "!Ook Andy Weir - Project Hail Mary (2021) Audiobook ::INFO:: 620.5MB"
result = parser.parse_result_line(line)
assert result.format == "unknown"
assert result.title == "Project Hail Mary (2021) Audiobook"
assert result.size == "620.5MB"
@pytest.mark.parametrize(
("line", "expected"),
[
("!s A - T.epub ::INFO:: 1MB", "ebook"),
("!s A - T.mp3 ::INFO:: 1MB", "audiobook"),
("!s A - T.flac ::INFO:: 1MB", "audiobook"),
# Archives carry no format information, so the name has to decide.
("!s A - T (Audiobook).rar ::INFO:: 1MB", "audiobook"),
("!s A - T [Unabridged].zip ::INFO:: 1MB", "audiobook"),
("!s A - T (Narrated by Someone).rar ::INFO:: 1MB", "audiobook"),
("!s A - T [64kbps].zip ::INFO:: 1MB", "audiobook"),
("!s A - T (retail).rar ::INFO:: 1MB", "ebook"),
("!s A - T.zip ::INFO:: 1MB", "ebook"),
],
)
def test_detect_content_type(line, expected):
assert parser.detect_content_type(parser.parse_result_line(line)) == expected
def test_recognized_formats_order_is_deterministic():
"""This was a set, so which format won for a multi-extension line varied per restart."""
assert parser.ALL_RECOGNIZED_FORMATS == tuple(parser.ALL_RECOGNIZED_FORMATS)
# Longest-first, so ".azw3" cannot be truncated to "azw" (nor ".docx" to "doc").
lengths = [len(fmt) for fmt in parser.ALL_RECOGNIZED_FORMATS]
assert lengths == sorted(lengths, reverse=True)
def test_parse_results_file_uses_audiobook_format_settings(monkeypatch):
values = {
@@ -0,0 +1,113 @@
from typing import Any
import pytest
from shelfmark.metadata_providers import MetadataSearchOptions
from shelfmark.metadata_providers.hardcover import HardcoverProvider
# Hardcover answers a rejected search with HTTP 200, no GraphQL errors, and a
# null results body. A search that genuinely matched nothing still returns a
# results object with found: 0.
REJECTED = {"search": {"results": None}}
EMPTY = {"search": {"results": {"hits": [], "found": 0}}}
ONE_HIT = {"search": {"results": {"hits": [{"document": {"id": 7, "title": "Dune"}}], "found": 1}}}
@pytest.fixture(autouse=True)
def _reset_sort_fallback(monkeypatch):
"""Keep the process-wide sort fallback from leaking between tests."""
monkeypatch.setattr("shelfmark.metadata_providers.hardcover._sort_fallback_until", 0.0)
def _reject_sorted(calls: list[dict[str, Any]], *, success=ONE_HIT):
"""Build an _execute_query stand-in that rejects any request carrying a sort."""
def fake_execute(query: str, variables):
calls.append(dict(variables))
return REJECTED if variables.get("sort") else success
return fake_execute
class TestHardcoverSortFallback:
def test_retries_without_sort_when_hardcover_rejects_the_sort(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
calls: list[dict[str, Any]] = []
monkeypatch.setattr(provider, "_execute_query", _reject_sorted(calls))
result = provider._execute_search_query("query", {"query": "dune", "sort": "relevance"})
assert result == ONE_HIT
assert [call["sort"] for call in calls] == ["relevance", ""]
def test_treats_an_empty_result_set_as_success(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
calls: list[dict[str, Any]] = []
monkeypatch.setattr(
provider, "_execute_query", lambda query, variables: calls.append(variables) or EMPTY
)
result = provider._execute_search_query("query", {"query": "dune", "sort": "rating:desc"})
assert result == EMPTY
assert len(calls) == 1
def test_reports_failure_when_the_retry_is_also_rejected(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
calls: list[dict[str, Any]] = []
monkeypatch.setattr(
provider, "_execute_query", lambda query, variables: calls.append(variables) or REJECTED
)
result = provider._execute_search_query("query", {"query": "dune", "sort": "rating:desc"})
assert result is None
assert len(calls) == 2
def test_reports_failure_for_an_unsorted_rejection(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
calls: list[dict[str, Any]] = []
monkeypatch.setattr(
provider, "_execute_query", lambda query, variables: calls.append(variables) or REJECTED
)
result = provider._execute_search_query("query", {"query": "dune", "sort": ""})
assert result is None
assert len(calls) == 1
def test_skips_the_doomed_request_on_later_searches(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
calls: list[dict[str, Any]] = []
monkeypatch.setattr(provider, "_execute_query", _reject_sorted(calls))
provider._execute_search_query("query", {"query": "dune", "sort": "relevance"})
provider._execute_search_query("query", {"query": "hyperion", "sort": "relevance"})
assert [call["sort"] for call in calls] == ["relevance", "", ""]
def test_search_returns_results_despite_a_rejected_sort(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
calls: list[dict[str, Any]] = []
monkeypatch.setattr(provider, "_execute_query", _reject_sorted(calls))
result = provider.search_paginated(
MetadataSearchOptions(query="dune sort fallback", page=1, limit=25)
)
assert result.total_found == 1
assert [book.title for book in result.books] == ["Dune"]
assert [call["sort"] for call in calls] == ["_text_match:desc,users_count:desc", ""]
class TestSearchPayloadRejection:
def test_distinguishes_a_null_body_from_an_empty_result_set(self):
from shelfmark.metadata_providers.hardcover import _search_payload_rejected
assert _search_payload_rejected(REJECTED) is True
assert _search_payload_rejected(EMPTY) is False
assert _search_payload_rejected(ONE_HIT) is False
assert _search_payload_rejected(None) is False
assert _search_payload_rejected({}) is False
# Non-search payloads (list lookups, book fetches) must pass through.
assert _search_payload_rejected({"series": [{"id": 1}]}) is False
+202
View File
@@ -0,0 +1,202 @@
import requests
from shelfmark.core.cache import get_metadata_cache
from shelfmark.metadata_providers import MetadataSearchOptions, SearchType
from shelfmark.metadata_providers.moly import MolyProvider, _valid_isbn
SEARCH_HTML = """
<html><body>
<div id="content">
<div class="search_area">
<div class="book_with_shop">
<a href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">
<img alt="Mocsidzuki Mai: A Telihold kávézó" class="tooltip"
src="https://assets.moly.hu/system/covers/normal/covers_864003.jpg" />
</a>
</div>
<p>
<a class="book_selector" data-id="1"
href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">Mocsidzuki Mai:
<strong class="highlight">A Telihold</strong> kávézó</a>
<span class="like_count">82%</span>
<a rel="modal" class="action" href="/sorozatok/a-telihold-kavezo">(A Telihold kávézó 1.)</a>
</p>
<p>
<a class="book_selector" data-id="2"
href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">Mocsidzuki Mai: A Telihold kávézó</a>
</p>
<p>
<a class="book_selector" data-id="3"
href="/konyvek/mocsidzuki-mai-az-igazi-kivansag">Mocsidzuki Mai: Az igazi kívánság</a>
</p>
<p>
<a class="book_selector" data-id="4"
href="/konyvek/lisa-jewell-a-fold-nyelte-el">Lis<strong class="highlight">a</strong> Jew<strong class="highlight">el</strong>l: <strong class="highlight">A föld nyelte el</strong></a>
</p>
<p><a class="book_selector" data-id="5" href="/konyvek/broken">Not a book row</a></p>
</div>
</div>
</body></html>
"""
BOOK_HTML = """
<html><body>
<div id="content">
<div class="head_title">
<h1><span class="item">A ​Telihold kávézó
<a href="/sorozatok/a-telihold-kavezo">(A Telihold kávézó 1.)</a></span></h1>
<div class="rating"><span class="like_count">82%</span></div>
</div>
<div class="authors"><a href="/alkotok/mocsidzuki-mai">Mocsidzuki Mai</a></div>
<div class="coverbox">
<a rel="light" class="zoom" href="/system/covers/big/covers_864003.jpg?1712583436"></a>
</div>
<div id="full_description">
<p>Vigyázat! Cselekményleírást tartalmaz.</p>
<p>A japánok úgy tartják, hogy ha gondoskodsz egy macskáról,
az egy napon meghálálja.</p>
</div>
<div class="items">
<div class="edition">
<div><a href="/kiadok/athenaeum">Athenaeum</a>, Budapest, <abbr>2024</abbr></div>
<div>180 oldal · ISBN: 9789635434435 · Fordította: Nagy Anita</div>
</div>
</div>
<div id="book_tags">
<a class="tag" href="/cimkek/asztrologia">asztrológia</a>
<a class="tag" href="/cimkek/japan">japán</a>
</div>
</div>
</body></html>
"""
class _MolyResponse:
def __init__(self, text):
self.text = text
def raise_for_status(self):
return None
class _MolySession:
"""Serves canned HTML per URL substring."""
def __init__(self, pages):
self.pages = pages
self.calls = 0
self.headers = {}
def get(self, url, **kwargs):
self.calls += 1
for fragment, html in self.pages.items():
if fragment in url:
return _MolyResponse(html)
raise requests.HTTPError(f"unexpected URL: {url}")
class _FlakyMolySession(_MolySession):
def get(self, url, **kwargs):
if self.calls == 0:
self.calls += 1
raise requests.Timeout
return super().get(url, **kwargs)
def _provider(session):
provider = MolyProvider()
provider.session = session
return provider
class TestMolySearch:
def test_search_parses_results(self):
get_metadata_cache().clear()
provider = _provider(_MolySession({"/kereses": SEARCH_HTML}))
books = provider.search(MetadataSearchOptions(query="telihold kávézó"))
assert len(books) == 3 # duplicate slug deduped, colon-less row skipped
# Mid-word <strong class="highlight"> wrapping must not split words
highlighted = books[2]
assert highlighted.title == "A föld nyelte el"
assert highlighted.authors == ["Lisa Jewell"]
book = books[0]
assert book.provider == "moly"
assert book.provider_id == "mocsidzuki-mai-a-telihold-kavezo"
assert book.title == "A Telihold kávézó"
assert book.authors == ["Mocsidzuki Mai"]
assert book.cover_url == "https://assets.moly.hu/system/covers/normal/covers_864003.jpg"
assert book.source_url == "https://moly.hu/konyvek/mocsidzuki-mai-a-telihold-kavezo"
labels = {f.label: f.value for f in book.display_fields}
assert labels["Rating"] == "82%"
assert labels["Series"] == "A Telihold kávézó 1."
def test_search_second_page_is_empty(self):
provider = _provider(_MolySession({"/kereses": SEARCH_HTML}))
assert provider.search(MetadataSearchOptions(query="telihold", page=2)) == []
def test_search_does_not_cache_request_failures(self):
get_metadata_cache().clear()
session = _FlakyMolySession({"/kereses": SEARCH_HTML})
provider = _provider(session)
options = MetadataSearchOptions(query="telihold kávézó")
assert provider.search(options) == []
books = provider.search(options)
assert session.calls == 2
assert [book.provider_id for book in books] == [
"mocsidzuki-mai-a-telihold-kavezo",
"mocsidzuki-mai-az-igazi-kivansag",
"lisa-jewell-a-fold-nyelte-el",
]
class TestMolyGetBook:
def test_get_book_parses_details(self):
get_metadata_cache().clear()
provider = _provider(_MolySession({"/konyvek/": BOOK_HTML}))
book = provider.get_book("mocsidzuki-mai-a-telihold-kavezo")
assert book is not None
# Zero-width space stripped, nested series link excluded from title
assert book.title == "A Telihold kávézó"
assert book.authors == ["Mocsidzuki Mai"]
assert book.isbn_13 == "9789635434435"
assert book.publisher == "Athenaeum"
assert book.publish_year == 2024
assert book.language == "hu"
assert book.cover_url == "https://moly.hu/system/covers/big/covers_864003.jpg?1712583436"
assert "asztrológia" in book.genres
# Spoiler warning stripped from the description
assert book.description is not None
assert not book.description.startswith("Vigyázat!")
assert "japánok" in book.description
def test_isbn_search_resolves_first_result(self):
get_metadata_cache().clear()
provider = _provider(_MolySession({"/kereses": SEARCH_HTML, "/konyvek/": BOOK_HTML}))
book = provider.search(
MetadataSearchOptions(query="9789635434435", search_type=SearchType.ISBN)
)[0]
assert book.provider_id == "mocsidzuki-mai-a-telihold-kavezo"
assert book.isbn_13 == "9789635434435"
class TestValidIsbn:
def test_isbn_13(self):
assert _valid_isbn("978-963-543-443-5") == "9789635434435"
def test_isbn_10_with_check_x(self):
assert _valid_isbn("963-543-443-X") == "963543443X"
def test_rejects_page_counts_and_years(self):
assert _valid_isbn("2024") is None
assert _valid_isbn("1234567") is None
+76
View File
@@ -10,6 +10,7 @@ from shelfmark.release_sources import ReleaseProtocol
from shelfmark.release_sources.newznab.source import (
NewznabSource,
_newznab_result_to_release,
_parse_category_ids,
)
# ── fixtures / helpers ─────────────────────────────────────────────────────────
@@ -100,6 +101,18 @@ class TestResultToRelease:
r = _newznab_result_to_release(_make_result(categories=[]), "audiobook")
assert r.content_type == "audiobook"
def test_custom_searched_category_treated_as_book(self):
r = _newznab_result_to_release(_make_result(categories=[8010]), "ebook", [8010])
assert r.content_type == "book"
def test_custom_searched_category_treated_as_audiobook(self):
r = _newznab_result_to_release(_make_result(categories=[{"id": 3040}]), "audiobook", [3040])
assert r.content_type == "audiobook"
def test_unsearched_out_of_range_category_stays_other(self):
r = _newznab_result_to_release(_make_result(categories=[2000]), "ebook", [8010])
assert r.content_type == "other"
def test_freeleech_flag_detected_via_download_volume(self):
r = _newznab_result_to_release(_make_result(downloadVolumeFactor=0.0))
assert r.extra["freeleech"] is True
@@ -209,6 +222,31 @@ class TestIsAvailable:
assert NewznabSource().is_available() is False
# ── category parsing ───────────────────────────────────────────────────────────
class TestParseCategoryIds:
def test_parses_list_of_strings(self):
assert _parse_category_ids(["7100", "7120"]) == [7100, 7120]
def test_parses_comma_separated_string(self):
assert _parse_category_ids("7100, 7120") == [7100, 7120]
def test_parses_comma_separated_entry_inside_list(self):
assert _parse_category_ids(["7100,7120", "8010"]) == [7100, 7120, 8010]
def test_drops_duplicates_and_keeps_order(self):
assert _parse_category_ids(["7120", "7100", "7120"]) == [7120, 7100]
def test_skips_non_numeric_and_non_positive_values(self):
assert _parse_category_ids(["books", "0", "-7000", "7100"]) == [7100]
def test_returns_empty_for_unset_or_blank(self):
assert _parse_category_ids(None) == []
assert _parse_category_ids([]) == []
assert _parse_category_ids(" ") == []
# ── NewznabSource.search ───────────────────────────────────────────────────────
@@ -276,6 +314,44 @@ class TestSearch:
_, kwargs = client.search.call_args
assert kwargs["categories"] == [3030]
def test_searches_with_configured_ebook_categories(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
src = self._patched_source(
monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": ["7100", "7120"]}
)
book = _make_book()
src.search(book, _make_plan(book), content_type="ebook")
_, kwargs = client.search.call_args
assert kwargs["categories"] == [7100, 7120]
def test_searches_with_configured_audiobook_categories(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
src = self._patched_source(monkeypatch, client, {"NEWZNAB_AUDIOBOOK_CATEGORIES": "3040"})
book = _make_book()
src.search(book, _make_plan(book), content_type="audiobook")
_, kwargs = client.search.call_args
assert kwargs["categories"] == [3040]
def test_falls_back_to_default_when_categories_cleared(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
src = self._patched_source(monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": []})
book = _make_book()
src.search(book, _make_plan(book), content_type="ebook")
_, kwargs = client.search.call_args
assert kwargs["categories"] == [7000]
def test_ebook_categories_do_not_affect_audiobook_search(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
src = self._patched_source(monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": ["7100"]})
book = _make_book()
src.search(book, _make_plan(book), content_type="audiobook")
_, kwargs = client.search.call_args
assert kwargs["categories"] == [3030]
def test_expand_search_removes_categories(self, monkeypatch):
client = MagicMock()
client.search.return_value = []
+251 -18
View File
@@ -26,6 +26,8 @@ class MockTorrent:
dlspeed=1024000,
eta=3600,
content_path="/downloads/test.txt",
infohash_v1=None,
infohash_v2=None,
):
self.hash = hash_val
self.name = name
@@ -34,10 +36,12 @@ class MockTorrent:
self.dlspeed = dlspeed
self.eta = eta
self.content_path = content_path
self.infohash_v1 = infohash_v1
self.infohash_v2 = infohash_v2
def to_dict(self):
"""Convert to dict for JSON response mocking."""
return {
result = {
"hash": self.hash,
"name": self.name,
"progress": self.progress,
@@ -46,6 +50,11 @@ class MockTorrent:
"eta": self.eta,
"content_path": self.content_path,
}
if self.infohash_v1 is not None:
result["infohash_v1"] = self.infohash_v1
if self.infohash_v2 is not None:
result["infohash_v2"] = self.infohash_v2
return result
def create_mock_session_response(torrents, status_code=200):
@@ -657,12 +666,9 @@ class TestQBittorrentClientGetStatus:
)
mock_client_instance = MagicMock()
# hashes query empty -> category list empty -> full list empty
mock_client_instance._session.get.side_effect = [
create_mock_session_response([], status_code=200),
create_mock_session_response([], status_code=200),
create_mock_session_response([], status_code=200),
]
mock_client_instance._session.get.return_value = create_mock_session_response(
[], status_code=200
)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
@@ -679,6 +685,39 @@ class TestQBittorrentClientGetStatus:
assert status.message is not None
assert "not found" in status.message.lower()
def test_get_status_rejects_unrelated_exact_result(self, monkeypatch):
"""Reject an unrelated record when the client ignores the hash filter."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "test",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
mock_client_instance = MagicMock()
mock_client_instance._session.get.return_value = create_mock_session_response(
[MockTorrent(hash_val="different")]
)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
status = client.get_status("requested")
assert status.state_value == "error"
assert status.message is not None
assert "not found" in status.message.lower()
def test_get_status_stalled(self, monkeypatch):
"""Test status for stalled torrent."""
config_values = {
@@ -801,9 +840,8 @@ class TestQBittorrentClientAddDownload:
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance.torrents_info.return_value = [mock_torrent]
# Used by the properties check
mock_client_instance._session.get.return_value = create_mock_session_response(
{}, status_code=200
[mock_torrent], status_code=200
)
mock_client_class = MagicMock(return_value=mock_client_instance)
@@ -821,6 +859,107 @@ class TestQBittorrentClientAddDownload:
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
assert mock_client_instance._session.get.call_count >= 1
@pytest.mark.parametrize("metadata_state", ["metaDL", "forcedMetaDL"])
def test_add_waits_for_metadata_and_returns_current_hash(self, monkeypatch, metadata_state):
"""Return qBittorrent's current hash after hybrid metadata resolves."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.time.sleep", lambda _seconds: None
)
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
v2_hash = "0bed5f40753b342cb143e83c2b21924cc8474731"
full_v2_hash = "0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f"
metadata_torrent = MockTorrent(
hash_val=v1_hash,
state=metadata_state,
infohash_v1=v1_hash,
)
resolved_torrent = MockTorrent(
hash_val=v2_hash,
state="downloading",
infohash_v1=v1_hash,
infohash_v2=full_v2_hash,
)
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance._session.get.side_effect = [
create_mock_session_response([metadata_torrent]),
create_mock_session_response([]),
create_mock_session_response([resolved_torrent]),
]
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
result = client.add_download(magnet, "Test Download", category="audiobooks")
assert result == v2_hash
assert [
call.kwargs["params"] for call in mock_client_instance._session.get.call_args_list
] == [
{"hashes": v1_hash},
{"hashes": v1_hash},
{"category": "audiobooks"},
]
def test_add_fails_when_metadata_never_resolves(self, monkeypatch):
"""Fail rather than return a transitional hash after the metadata timeout."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.time.sleep", lambda _seconds: None
)
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
metadata_torrent = MockTorrent(
hash_val=v1_hash,
state="metaDL",
infohash_v1=v1_hash,
)
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance._session.get.return_value = create_mock_session_response(
[metadata_torrent]
)
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
with pytest.raises(RuntimeError, match="metadata resolution was not confirmed"):
client.add_download(magnet, "Test Download")
def test_add_download_uses_expected_hash_without_fetch(self, monkeypatch):
"""Skip proxy fetch when expected hash is provided for URL torrents."""
config_values = {
@@ -840,7 +979,7 @@ class TestQBittorrentClientAddDownload:
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance.torrents_info.return_value = [mock_torrent]
mock_client_instance._session.get.return_value = create_mock_session_response(
{}, status_code=200
[mock_torrent], status_code=200
)
mock_client_class = MagicMock(return_value=mock_client_instance)
@@ -904,11 +1043,10 @@ class TestQBittorrentClientAddDownload:
torrents_before_add = create_mock_session_response([existing])
torrents_after_add = create_mock_session_response([existing, added])
properties_ok = create_mock_session_response({}, status_code=200)
def session_get(request_url, params=None, timeout=None):
if request_url.endswith("/torrents/properties"):
return properties_ok
if params == {"hashes": discovered_hash}:
return create_mock_session_response([added])
if mock_client_instance.torrents_add.called:
return torrents_after_add
return torrents_before_add
@@ -1062,9 +1200,8 @@ class TestQBittorrentClientAddDownload:
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance.torrents_info.return_value = [mock_torrent]
# Used by the properties check
mock_client_instance._session.get.return_value = create_mock_session_response(
{}, status_code=200
[mock_torrent], status_code=200
)
mock_client_class = MagicMock(return_value=mock_client_instance)
@@ -1095,10 +1232,11 @@ class TestQBittorrentClientAddDownload:
)
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
mock_torrent = MockTorrent(hash_val=valid_hash)
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = ""
mock_client_instance._session.get.return_value = create_mock_session_response(
{}, status_code=200
[mock_torrent], status_code=200
)
mock_client_class = MagicMock(return_value=mock_client_instance)
@@ -1161,10 +1299,11 @@ class TestQBittorrentClientAddDownload:
)
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
mock_torrent = MockTorrent(hash_val=valid_hash)
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance._session.get.return_value = create_mock_session_response(
{}, status_code=200
[mock_torrent], status_code=200
)
mock_client_class = MagicMock(return_value=mock_client_instance)
@@ -1198,10 +1337,11 @@ class TestQBittorrentClientAddDownload:
)
valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
mock_torrent = MockTorrent(hash_val=valid_hash)
mock_client_instance = MagicMock()
mock_client_instance.torrents_add.return_value = "Ok."
mock_client_instance._session.get.return_value = create_mock_session_response(
{}, status_code=200
[mock_torrent], status_code=200
)
mock_client_class = MagicMock(return_value=mock_client_instance)
@@ -1536,6 +1676,74 @@ class TestQBittorrentClientFindExisting:
assert download_id == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
assert isinstance(status, DownloadStatus)
def test_find_existing_waits_for_metadata(self, monkeypatch):
"""Wait for an existing hybrid torrent to expose its current primary hash."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_CATEGORY": "books",
}
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.config.get",
lambda key, default="": config_values.get(key, default),
)
monkeypatch.setattr(
"shelfmark.download.clients.qbittorrent.time.sleep", lambda _seconds: None
)
v1_hash = "edf46c7f938a3c678081734d7bff8b9c652ba5e5"
v2_hash = "0bed5f40753b342cb143e83c2b21924cc8474731"
full_v2_hash = "0bed5f40753b342cb143e83c2b21924cc847473134e44d1bd300bdc58c13010f"
metadata_torrent = MockTorrent(
hash_val=v1_hash,
state="metaDL",
infohash_v1=v1_hash,
)
unrelated_torrent = MockTorrent(hash_val="a" * 40)
resolved_torrent = MockTorrent(
hash_val=v2_hash,
state="downloading",
infohash_v1=v1_hash,
infohash_v2=full_v2_hash,
)
mock_client_instance = MagicMock()
mock_client_instance._session.get.side_effect = [
create_mock_session_response([metadata_torrent]),
create_mock_session_response([]),
create_mock_session_response([unrelated_torrent]),
create_mock_session_response([unrelated_torrent]),
create_mock_session_response([resolved_torrent]),
create_mock_session_response([resolved_torrent]),
]
mock_client_class = MagicMock(return_value=mock_client_instance)
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
import importlib
import shelfmark.download.clients.qbittorrent as qb_module
importlib.reload(qb_module)
client = qb_module.QBittorrentClient()
magnet = f"magnet:?xt=urn:btih:{v1_hash}&dn=test"
result = client.find_existing(magnet, category="audiobooks")
assert result is not None
download_id, status = result
assert download_id == v2_hash
assert status.state_value == "downloading"
assert [
call.kwargs["params"] for call in mock_client_instance._session.get.call_args_list
] == [
{"hashes": v1_hash},
{"hashes": v1_hash},
{"category": "audiobooks"},
{"category": "books"},
{},
{"hashes": v2_hash},
]
def test_find_existing_not_found(self, monkeypatch):
"""Test finding non-existent torrent."""
config_values = {
@@ -1641,3 +1849,28 @@ class TestHashesMatch:
assert _hashes_match("a" * 40, "b" * 30) is False
assert _hashes_match("a" * 38, "b" * 32) is False
class TestTorrentMatchesId:
"""Tests for matching qBittorrent's available torrent identities."""
@pytest.mark.parametrize(
("torrent", "download_id"),
[
(MockTorrent(hash_val="current"), "current"),
(MockTorrent(hash_val="other", infohash_v1="v1"), "v1"),
(MockTorrent(hash_val="other", infohash_v2="v2"), "v2"),
],
)
def test_matches_each_identity(self, torrent, download_id):
"""Match IDs exposed through hash, infohash_v1, or infohash_v2."""
from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id
assert _torrent_matches_download_id(torrent, download_id) is True
def test_rejects_unrelated_or_missing_identities(self):
"""Reject unrelated IDs and records without identity fields."""
from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id
assert _torrent_matches_download_id(MockTorrent(hash_val="different"), "requested") is False
assert _torrent_matches_download_id(object(), "requested") is False
Generated
+309 -177
View File
@@ -1,7 +1,19 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.14"
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "apprise"
version = "1.12.0"
@@ -110,35 +122,61 @@ wheels = [
[[package]]
name = "cffi"
version = "2.0.0"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
@@ -291,11 +329,11 @@ wheels = [
[[package]]
name = "cssselect"
version = "1.4.0"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/5a/6d6fcf922709391fac986f0a03ad4546f4f45b94d10aeb6c1ee041599993/cssselect-1.5.0.tar.gz", hash = "sha256:3cbe82dd7acbee9ba9e5723b5f9e4749826912f1fb31cd7f92aabed5fde15b15", size = 47598, upload-time = "2026-07-27T09:17:34.189Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" },
{ url = "https://files.pythonhosted.org/packages/60/e9/6734502f67533a752ea8b1c8f7f227c94eecf300252ba8bf23e3e59d8a36/cssselect-1.5.0-py3-none-any.whl", hash = "sha256:1d1aded98e82bdde447ded990a191fd6916177c4f0c914fb62eccd58e2ffcdcc", size = 20797, upload-time = "2026-07-27T09:17:33.04Z" },
]
[[package]]
@@ -354,11 +392,11 @@ wheels = [
[[package]]
name = "filelock"
version = "3.32.0"
version = "3.32.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" },
{ url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" },
]
[[package]]
@@ -410,7 +448,7 @@ wheels = [
[[package]]
name = "gevent"
version = "26.7.0"
version = "26.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
@@ -418,24 +456,26 @@ dependencies = [
{ name = "zope-event" },
{ name = "zope-interface" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/eb/5f2db8013f1a4a6df2c23201f384a066f13ff5764a9f62a608c8a50ac8cc/gevent-26.8.0.tar.gz", hash = "sha256:96039f41bbde6dcd72559e5ffbd408a04f46774b47d991d4cf032da8fa79e5a0", size = 6625998, upload-time = "2026-08-10T18:02:28.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/bb/ab60d496cbdc0293ebbd6c2070b34da0632bd7a2ca20163c17e18d2d2dc9/gevent-26.7.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0e0e3bf7ae0f82dbc5c6be26b4781e86c97f1e28d516b7a9746ac8b04bcc6948", size = 2992503, upload-time = "2026-07-22T16:24:36.503Z" },
{ url = "https://files.pythonhosted.org/packages/5c/35/75f27c06a82a5b22600aaccbd9567d89bb4091be43e96c02981f10aff23d/gevent-26.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:740050b53048207b080a1e183a377c47809ad0b7b7b0cd7eab0dea1045f7e480", size = 1809173, upload-time = "2026-07-22T18:11:30.724Z" },
{ url = "https://files.pythonhosted.org/packages/5b/5f/a6b32b4db3fa76bd8a070f0f46f5306123bf6336e7a0ca0cd2f9b99473df/gevent-26.7.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:67983607eb6c7bafa362c5c43b69a27145b936c34a3d6441ed42413d62fae0a6", size = 1906630, upload-time = "2026-07-22T18:10:45.836Z" },
{ url = "https://files.pythonhosted.org/packages/e1/87/832495d8fcc05ff7432f038b7c4decbd5632425a2cd5da2ce73cb2d800c4/gevent-26.7.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:475848518d708e07d1987c3d94cb8ff53e2b3a69df32e39feda2779cafe400b0", size = 1855278, upload-time = "2026-07-22T18:29:11.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/8b/2f36c0fa389fa2b7ceb5a8972b0e7da7bc770f9135315cf4246c607ca5fc/gevent-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f8ed457dd616bfe6682569f92730f9ab45aafb1aeca5e80eb2f6b9a2ce26d11", size = 2136155, upload-time = "2026-07-22T16:48:33.865Z" },
{ url = "https://files.pythonhosted.org/packages/b5/98/09f2cfaa23dbce48e3271e95b0d003f93acece6b5cfd40f4cebe3850d79b/gevent-26.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15373c68cf1fa14114bec2f09b16e2c65374bd5309e897e0a28740b09ce329e0", size = 1822108, upload-time = "2026-07-22T18:07:15.397Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d8/05a294165c17569f04284ad3c889684c8780544885b4cdf77b1432947d0c/gevent-26.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73f3d53f2f390369e290c933b75bd87f1f2261f2f2f2175aa667c43ee3049bad", size = 2162814, upload-time = "2026-07-22T17:02:18.066Z" },
{ url = "https://files.pythonhosted.org/packages/59/89/58a545c4eda33e106d6887a0387adc2249abc14c779e3eb88bbfdf3768d6/gevent-26.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f11b558d544ad2249029ba023cd6519ec3a0eee54a3d027e6515c1eaa322422a", size = 1706971, upload-time = "2026-07-22T16:27:02.263Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cd/413f293e54961e5c89c54235370e3603ec0f561e7ace8357980410efbf78/gevent-26.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:3871f4ca59ec2328c3ef638a0fe01a28a825443a133368dc78eb5ceadcad7609", size = 1585078, upload-time = "2026-07-22T16:30:48.145Z" },
{ url = "https://files.pythonhosted.org/packages/21/3a/47f29f632aaa38aa12410f57f1732fc50bfd4d4006d2e7e022ce731cabc9/gevent-26.7.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3e3d6e20a94239ad353b776e72b8ce18c35dbe4e98c279aef3932651553d8404", size = 2996208, upload-time = "2026-07-22T16:23:23.859Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b3/4620f1ce81ecec9890229806c73f07dd022e40f76a552bd430391e7316c4/gevent-26.7.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ddbd3cc76b9bc69df651a216c2a62fc6415ad463b3ac9c6cbbbb8b7b8224af17", size = 1811545, upload-time = "2026-07-22T18:11:32.224Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/d285212ffd5585d511299e13e61d76262def8e826e9f20c92cb85df406f2/gevent-26.7.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:01ceab7e608dc1b9859d9511a0a29d7ce2e7d909ab19fddc860e70a2ed5b10ce", size = 1910418, upload-time = "2026-07-22T18:10:47.709Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e0/c5d666e6065652918cfb6e6a3cf8f721d0e152c57cec217ad81792a1323b/gevent-26.7.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2e6c917b2b8baeb6080797a6b25e35e1fd784319a05bb92b87c53546e5578eb2", size = 1857891, upload-time = "2026-07-22T18:29:13.13Z" },
{ url = "https://files.pythonhosted.org/packages/a1/67/e945ed458fa98b34572876bfd0d35fe4fa3f1159f43660b71d982b7cb63e/gevent-26.7.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:df75a1748b26030f2f7f10042cc45640b22954d9d0dc6b4b6f0dbe0b6751a2d4", size = 2138121, upload-time = "2026-07-22T16:48:35.358Z" },
{ url = "https://files.pythonhosted.org/packages/99/55/622468fa1a3c4cf51f20e14813f5cc1592fe6e44a42ccca9195a0b18c769/gevent-26.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ee1b389587e5d5c1eb19d0455b5b4d7a0fb5c5287af4e226ec66d9dfd2548107", size = 1825114, upload-time = "2026-07-22T18:07:16.667Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7a/151a2afcacf487ca25faf8b1bdd6c5b4ace2f7c1e6b4eaffe0a5e6e1df61/gevent-26.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2918641ba756f46aa01ab9dd82d6dfceec403c77c2787298746b411dcf0288e", size = 2165990, upload-time = "2026-07-22T17:02:19.817Z" },
{ url = "https://files.pythonhosted.org/packages/61/cb/1bed6675f6cba42bfe23c38eacf482e08dfaa7af251cb0cddfcbb084b757/gevent-26.8.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e0f6a96cd5f9ad8f1f91d5d56d3e5534e15682b4a0abecd8396a18b836296426", size = 3006150, upload-time = "2026-08-10T16:56:41.378Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f5/bb4f3272d419bc785ce6a39440f4d157a6f7c154fef93c77444579ee5f43/gevent-26.8.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:113d12a2d4276047980e491cc5856388954642eb555479c6962f52fb181eb1df", size = 1819363, upload-time = "2026-08-10T17:58:52.056Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c3/351a5c6890804e39a109c7df8aa8f17f953d2f0acf8215e040670a0e573a/gevent-26.8.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:3ea7d0ff714ac9c634bfaaa3cfb25a5fcf7a272df47cad2261642323b16a3266", size = 1916853, upload-time = "2026-08-10T17:48:12.677Z" },
{ url = "https://files.pythonhosted.org/packages/80/81/a37201ced7b96eeba4554a1e656d040ad808f254a9f3d7b94f8ac4cfa82a/gevent-26.8.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:1bee3c0cb1aa2cee3369de46f8d952d10310166d7b4a744ae64a32ee1e14a1f5", size = 1865982, upload-time = "2026-08-10T17:54:30.173Z" },
{ url = "https://files.pythonhosted.org/packages/30/6d/e70113648ee1041070e6190389796414dd70b8cb86ff5ec8a9762284cb1a/gevent-26.8.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:64bc5c3302b02f9ad012243173e13705711dbbcc7590443d974f2961154d7077", size = 2147279, upload-time = "2026-08-10T17:19:24.24Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/1af5f1910d5534bba338d954e77f401e167189c7af8b621104307e2e7e16/gevent-26.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e544cba5810ec64056d78f0d3c79ceeafc91ac612b4e9008134645bc437dd885", size = 1832674, upload-time = "2026-08-10T17:42:41.002Z" },
{ url = "https://files.pythonhosted.org/packages/c1/23/bc09e7f2a0dc699269d5dd03a4860afa80f8d29c1f28a18859ebd06d4ab1/gevent-26.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3445b3a8a51fcb7b881485b1089f7d64ca057aa921a78f146d702853650f958d", size = 2173988, upload-time = "2026-08-10T17:23:52.475Z" },
{ url = "https://files.pythonhosted.org/packages/3c/21/44ebdb32eb5050c367c4ccf6d43627bf875847ef65d8455beff52044846c/gevent-26.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fe56814f6d36d8fdfe0254909388fa58ccb61e7945d2f031a2dbd81f90ced4c", size = 1716724, upload-time = "2026-08-10T17:01:37.23Z" },
{ url = "https://files.pythonhosted.org/packages/8d/71/6708a3aae223a326b648a01beb5244caa562d75f6515528a8241260763f3/gevent-26.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:5914bab0ebe7fbd6077d703b61b2e74afec0436487f29b8154302c61f4d58502", size = 1594414, upload-time = "2026-08-10T17:00:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/a7/18/42a8129bdfe7285f35be21e6737a135462cb723a30952f06cb2203ecc8df/gevent-26.8.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:9afd9fbccbcea0b803d554b9e7bac75382e919aa07a5cb98d04edeba10c6cf77", size = 3009619, upload-time = "2026-08-10T16:59:31.344Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ff/f540c92d4c6ff3af5e60e990c7f417d1419a5a2aee5f5047612e708221c7/gevent-26.8.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:cd225e954d57e7d8a994a8d152f0d76609c73fa701a3e27293cc46675b2dce77", size = 1821950, upload-time = "2026-08-10T17:58:53.313Z" },
{ url = "https://files.pythonhosted.org/packages/ca/eb/b40a8b9df1d4955cae3f600db416d6a8abb73f5f31d8a315cf2acf225462/gevent-26.8.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:8cb1402c0c7bdbd6d772fd5eb700b98b31ce0d8f613f68823f32cce5ec956d8c", size = 1921096, upload-time = "2026-08-10T17:48:14.477Z" },
{ url = "https://files.pythonhosted.org/packages/74/e4/59fc824207ac7f63ac83af7a7bed2707d6768488f1ae2c6c85794d614570/gevent-26.8.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:242d5e3622a39236f57a4e740c5480e86b6bc15c3a790e997b8cc99bee34ef27", size = 1868863, upload-time = "2026-08-10T17:54:31.624Z" },
{ url = "https://files.pythonhosted.org/packages/1c/8b/cb001b1906ce78c63a252ad5baaebba745ca819bd7239ff60417fbc0d24a/gevent-26.8.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:b13173a992de43e92d15c6ac318f8ae019cf08eec15a78f99a0d4a917837052a", size = 2149188, upload-time = "2026-08-10T17:19:25.356Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c6/3085b52ec0ecc8173de40fca1f5faf8d02761288e1712ffb34628cfab214/gevent-26.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f431af3f2737ae01cf1c5a303f193cc2a8f726521e997ea8baf2d8567c6fe07c", size = 1835672, upload-time = "2026-08-10T17:42:42.296Z" },
{ url = "https://files.pythonhosted.org/packages/fa/99/ee5722f7d51ee4bd09396629802ae90c9e0bebef0e7938a4edf08eb92749/gevent-26.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1d8b76e525d7e301db83d8124a27700c55ad23754f3efbbf15c7051d6fa19853", size = 2177315, upload-time = "2026-08-10T17:23:53.854Z" },
{ url = "https://files.pythonhosted.org/packages/d4/cb/c2fea129f29ca114dc63f53178337845ba63532e07979c10fd70635a6dde/gevent-26.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:b16931069d0044a23a566d16dc5787122e858fa9d591c81500b6e6ad2148cfec", size = 1716882, upload-time = "2026-08-10T17:00:09.172Z" },
{ url = "https://files.pythonhosted.org/packages/29/f4/b4de17304026cbd6386d427bc5ece10ec65015e9bfd66e14672350ee5dfb/gevent-26.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:42b8ed34f7aff517fac448ab57084e5e39d6a84a2b6c2b709223d5751c85e657", size = 1594517, upload-time = "2026-08-10T17:00:47.046Z" },
]
[[package]]
@@ -452,29 +492,49 @@ wheels = [
[[package]]
name = "greenlet"
version = "3.4.0"
version = "3.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
{ url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
{ url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
{ url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" },
{ url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
{ url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" },
{ url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
{ url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
{ url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
{ url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
{ url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" },
{ url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
{ url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" },
{ url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
{ url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" },
{ url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" },
{ url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" },
{ url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" },
{ url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" },
{ url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" },
{ url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" },
{ url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" },
{ url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" },
{ url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" },
{ url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" },
{ url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" },
{ url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" },
{ url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" },
{ url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" },
{ url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" },
{ url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" },
{ url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" },
{ url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" },
{ url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" },
{ url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" },
{ url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" },
{ url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" },
{ url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" },
{ url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" },
{ url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" },
{ url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" },
{ url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
]
[[package]]
@@ -498,6 +558,70 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "idna"
version = "3.18"
@@ -668,11 +792,11 @@ wheels = [
[[package]]
name = "packaging"
version = "26.2"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
@@ -722,20 +846,20 @@ wheels = [
[[package]]
name = "pip"
version = "26.1.2"
version = "26.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" },
]
[[package]]
name = "platformdirs"
version = "4.11.0"
version = "4.11.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
{ url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" },
]
[[package]]
@@ -749,26 +873,26 @@ wheels = [
[[package]]
name = "prek"
version = "0.4.11"
version = "0.4.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/1a/73b6dae5ce7e997cb35a69bfe1d25a798e85fa3d2eabf95324563f461b30/prek-0.4.11.tar.gz", hash = "sha256:4a14cb9bbae850605ae3904fbdbb12f0e00c12455efaa2266da8fb8e5c0350d7", size = 516254, upload-time = "2026-07-24T17:05:35.107Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/79/19f47eeb4d6092d36f94f47a056e7ae7a421d60220c772e8513483521b63/prek-0.4.13.tar.gz", hash = "sha256:9bf3dce400ef38a281836e4fe6429aa5f1690848be77cd97cb53c93769db4681", size = 533200, upload-time = "2026-08-10T08:54:15.477Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/d7/a00b2de492a80e99b1698e72c1d196ac3ed544dc7ee0ada261ac066e78e0/prek-0.4.11-py3-none-linux_armv6l.whl", hash = "sha256:3830cb7cc47e837888b8b464ecb21355a69235cfacef0fd89101e17f09345d63", size = 5770511, upload-time = "2026-07-24T17:05:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/c1/1e/f97c74defcd5d5645888cb99fcdd9b8b48cc7247cf31e64414d106d56d66/prek-0.4.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:45facadf9c2332b28e6ab2744312ae0275a93ab5a437da8bcc53a8c7260cb4b0", size = 6118049, upload-time = "2026-07-24T17:05:14.451Z" },
{ url = "https://files.pythonhosted.org/packages/d9/92/8367d26421ee6fe6019a63928fb0ed31179cd0d6199879c524f89ef4c95c/prek-0.4.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f8a194b1d00d24dff8baff691c99e2668339da2da3482b4ee99c1a0f2409378", size = 5601478, upload-time = "2026-07-24T17:05:15.81Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6f/7617c9b87afaadede4167720aae7ef12d7e51167db903bc8fbac0cadef7b/prek-0.4.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:21d93e6d76bf3d7a9bb70c6ac86ef372adaee50068c45749e6b9e1c2ac4ec939", size = 5932071, upload-time = "2026-07-24T17:05:17.217Z" },
{ url = "https://files.pythonhosted.org/packages/ef/41/6796a4011b04212333259064aa885a71d03d9581ba9bff52db3ce58d1f06/prek-0.4.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7fb07cde2d2156efa6980122b3f13dc88f20c84b933c6205675d0f1e7be2cde8", size = 5677617, upload-time = "2026-07-24T17:05:18.658Z" },
{ url = "https://files.pythonhosted.org/packages/03/5f/3e339901f8460b6073313619b4fd9bf4135e48430695c53640b83ace88ea/prek-0.4.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b7f5e446d2aca739bd18b380578e9c78bb4c086299abc3e167d51c47840c56f", size = 6106370, upload-time = "2026-07-24T17:05:20.219Z" },
{ url = "https://files.pythonhosted.org/packages/8e/4e/94e24b5c1910ec15692ecaf33d0d8ef0d02a02a8b5e40d3c976396880cba/prek-0.4.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7059d640e595d098600e2d97af961f46292b4112670edbe632de84f6828389e", size = 6884342, upload-time = "2026-07-24T17:05:21.587Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7c/fc0daa033dcafe74990c00af2da1e16922790b9c1b8da7e8eebf1123838b/prek-0.4.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a4df33998fcac878bce3b2c624e1caeb9609f3d562c640702c1234ed815daf", size = 6331365, upload-time = "2026-07-24T17:05:22.87Z" },
{ url = "https://files.pythonhosted.org/packages/d7/36/49f152b8f539930e9685cff0509695ce4054abcecc22f4c16c4e1e5c23d0/prek-0.4.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866991f387527c5f880ce2cc3ddea9b33cc5b986881f8c7f92524cf0969c1350", size = 5939075, upload-time = "2026-07-24T17:05:24.249Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fe/7b097af9161edae7ddedcc9f5cda4a5f31346a492ce3f92583d96c46f628/prek-0.4.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:247e5d8740e137ebdf24fa96f01a95b2d2f1892e956a1ab00c7b1474a59ebcc0", size = 5799029, upload-time = "2026-07-24T17:05:25.652Z" },
{ url = "https://files.pythonhosted.org/packages/49/63/dc955ff99e1002d3cd375b21467c87046bc41deddfce86d898240757b151/prek-0.4.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:603ba9f2fd9d666dddb3ae190a25a5c55091b843cde90ed52e0a1116f50d4062", size = 5651211, upload-time = "2026-07-24T17:05:27.027Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/bf2139ec25eefb5afef43afac7620c5181f2c2661f220598beacb1771207/prek-0.4.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f2682ece3c5fc7201106c4fdd84b0587ccea4b8a2ecefa3e94d3074d2841f1df", size = 5954784, upload-time = "2026-07-24T17:05:28.391Z" },
{ url = "https://files.pythonhosted.org/packages/fc/29/3fe5990aee1bd7c4d50a03358ad867c5e912d9510aafb83a359c7347e5fa/prek-0.4.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:22721d30394192931fecc80d6fc6dd47e8ddf8db8b9693805aba8ec0f50087ec", size = 6448916, upload-time = "2026-07-24T17:05:29.837Z" },
{ url = "https://files.pythonhosted.org/packages/0f/fb/abddacf43738302242ecd237236c7c80cd7c1d27545e16e803770aee76e9/prek-0.4.11-py3-none-win32.whl", hash = "sha256:8b093e7624522146049e994d5cf283d01d71632b638620b79ae0f96afeaa2624", size = 5483539, upload-time = "2026-07-24T17:05:31.228Z" },
{ url = "https://files.pythonhosted.org/packages/00/1e/c293f7a15cb93963c4be36a02e144b93f43906bc02644a3f04e5708e7453/prek-0.4.11-py3-none-win_amd64.whl", hash = "sha256:5a3d7c80b970b456e5f1bcec8382008ee1ae6a3f324a6b9bb4ff7e666ab0f3c4", size = 5861119, upload-time = "2026-07-24T17:05:32.479Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0c/05fe6eb9d6a54d0e02dfa8cc5ad6f86869bf953419ec15909a32466a28ee/prek-0.4.11-py3-none-win_arm64.whl", hash = "sha256:e7b0df37ce05e45a14a9da39ab104691474d72f139bf4f6c860f754763a322cb", size = 5626386, upload-time = "2026-07-24T17:05:33.813Z" },
{ url = "https://files.pythonhosted.org/packages/c8/12/661dc1c63c322000580dffa8503d28d633cb5d4c3181e662cbb86eded12e/prek-0.4.13-py3-none-linux_armv6l.whl", hash = "sha256:6a313f5f041b2fcbd33bceb6b6e11ee9b8621c8c6ad8ef13787a6d90541b624d", size = 5801508, upload-time = "2026-08-10T08:53:52.18Z" },
{ url = "https://files.pythonhosted.org/packages/86/4a/c50597a45d08b22e5704a5d4d5c03269a581b46cf1f060861f9ba96cdade/prek-0.4.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:40436cd7247d2a2fc036ef07d7efcd828acf1aab9d648a8a3f21475e3ad3f789", size = 6141612, upload-time = "2026-08-10T08:53:53.86Z" },
{ url = "https://files.pythonhosted.org/packages/82/93/abc084bbd76bb6c34efbf7db441392f17b7bfc50a54c4d53d3e37c7ad6e0/prek-0.4.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:019a33b477b7b949fb6dcd6cb33ed494c4a28117e53c370c637ec0aba60211aa", size = 5625418, upload-time = "2026-08-10T08:53:55.39Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4d/a310ff9adcb4b822d935eb55f0f5cdcfe4c18a16610bc5e220a11215dc38/prek-0.4.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5a2851b6e60912e73be1bf2cf61fab5b314add15ba7bcf230f860282bdbaf16b", size = 5942132, upload-time = "2026-08-10T08:53:56.789Z" },
{ url = "https://files.pythonhosted.org/packages/f9/d8/811230ff285000abdc0fb98b56b9ef2d23b2dc530cf2da86fe1665f844ba/prek-0.4.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ba9b94bd3f47b5f94a4ae504c44f9529cb3258ebaf577a5a6070ea0c6a853d6", size = 5710181, upload-time = "2026-08-10T08:53:58.278Z" },
{ url = "https://files.pythonhosted.org/packages/74/84/01f7163cc4267daba6b01cfac726327c6d848d2bb7349bd2fd877d88f744/prek-0.4.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33fc9e0d435cb9e650ec108f840febe7a94fd266c77149860091cead212dc82d", size = 6157705, upload-time = "2026-08-10T08:53:59.586Z" },
{ url = "https://files.pythonhosted.org/packages/30/e3/061a0e9ebd7064edf70bb783afa575d045ac0cc9035c2143b76401ebcaf5/prek-0.4.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91066d8978eab83c111e7c34ee3046a003819d2df15ef7dc2bddeb83dce62bc0", size = 6898016, upload-time = "2026-08-10T08:54:00.927Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/5e8e2b9ff6a30b46bf35ca3b50e4d164c26406d9e7457d5b4633c6e1fbc0/prek-0.4.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85646fcc940f30bd946d63b6bbd1000d946994c88e154c5bdde7773a2c358dcf", size = 6365056, upload-time = "2026-08-10T08:54:02.704Z" },
{ url = "https://files.pythonhosted.org/packages/f9/23/afe543b69fb7f35016645eb4b766191e814cbcfa5545d695a2a7dd9b712e/prek-0.4.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:961859c3ddddb8e10367afcf93742da36fa213eda080f168fc1dcbe33e0b004d", size = 5952174, upload-time = "2026-08-10T08:54:04.079Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6e/9e651ce51aa0b03244277f5e0660cf1b946a270cb62635a8a100389a67a2/prek-0.4.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ccf4fcbfc686ad6b589f2908ed6012a315091c45163e1f4420648b9669651a9e", size = 5761875, upload-time = "2026-08-10T08:54:05.378Z" },
{ url = "https://files.pythonhosted.org/packages/cf/64/e70e18734d93df272476d2f1b30d92c71d41ee7141070f1dd13cfbb2eff8/prek-0.4.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:eb4b7edfe00ca73e58d7e26fb871abddc5854e8d21067a202e43418fb7f98ef2", size = 5685365, upload-time = "2026-08-10T08:54:06.729Z" },
{ url = "https://files.pythonhosted.org/packages/de/78/686fd5d3f12249368a0fdd8f7705e3ba540402a6157ef0b888e48734ff83/prek-0.4.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:c29449443f89da1647331742984b7046a55084759ad642373a9cc0a973494339", size = 5999013, upload-time = "2026-08-10T08:54:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/9e/aa/93ffac2460b44f6182dbbb3194db976d18332edcb8208c4a796f8f9955f8/prek-0.4.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:7a04d5ac901819b115ecd0bf79cee091f0034323767abcc4a53626eeb96c3be0", size = 6486759, upload-time = "2026-08-10T08:54:09.775Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/9f12c0d469ea345c249d5b0a027a1f2bf1ca05041d7785c34c455a9b605c/prek-0.4.13-py3-none-win32.whl", hash = "sha256:6d1bdcc1699ae18270f9bac9c4b4d29c6f1512b7a067e17ce5e220c30636f88c", size = 5515046, upload-time = "2026-08-10T08:54:11.456Z" },
{ url = "https://files.pythonhosted.org/packages/9e/3c/ef9ec67c560e60525d6a49b48a2a60434906f25ec2efa5e010fe2d42bbfa/prek-0.4.13-py3-none-win_amd64.whl", hash = "sha256:2d8fd796ed7944154fbee6d5a6d2490b9d4f14ce3626b1a0c9ca698455b25d9b", size = 5894683, upload-time = "2026-08-10T08:54:12.88Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ce/8fe8fdf8154108a552d5578400da44144697ed11e5bd71d5a4980d7e202e/prek-0.4.13-py3-none-win_arm64.whl", hash = "sha256:65d6811b0220444bcdf539e157d7d5cb8edab01a5ed89534c70d73d675266ecb", size = 5650616, upload-time = "2026-08-10T08:54:14.21Z" },
]
[[package]]
@@ -1047,15 +1171,15 @@ wheels = [
[[package]]
name = "python-socketio"
version = "5.16.3"
version = "5.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
{ name = "python-engineio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" }
sdist = { url = "https://files.pythonhosted.org/packages/06/5e/87d6b547c87c6d64f4a05f5bfaf6f42e9b786561216434290fdaa83f8667/python_socketio-5.16.4.tar.gz", hash = "sha256:f7fa4a43cc8e687930b5c6e44d6e2efc2071eca4bef49b8bb3dc0827f7f92235", size = 128140, upload-time = "2026-08-06T23:11:21.346Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" },
{ url = "https://files.pythonhosted.org/packages/94/d9/463feca73ec119a135d90c9f40c0172b4758150b5ed442f0ca1e8fed807a/python_socketio-5.16.4-py3-none-any.whl", hash = "sha256:0eb9c7687e7fbf59e60d714fd62afba77dfaf8ef8a06a0bff05a86c351accc2f", size = 82098, upload-time = "2026-08-06T23:11:19.851Z" },
]
[[package]]
@@ -1119,25 +1243,25 @@ wheels = [
[[package]]
name = "qbittorrent-api"
version = "2026.7.0"
version = "2026.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b4/49/73123503771d0610a27bf2e5c73995ddce15e8d8cac6babef8eef51097bb/qbittorrent_api-2026.7.0.tar.gz", hash = "sha256:9f0a6e33ee4dba290d155de3ad59c191de02e4d9639d424bf02ec4a00ecf3205", size = 1427366, upload-time = "2026-07-08T00:54:39.78Z" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/d2/4a6d6ae6aaca1ba83039d27f28e5bf30bacef27ff4eb635c729d2d582363/qbittorrent_api-2026.8.0.tar.gz", hash = "sha256:4127a9c1d5c7ad9cd4e8de16f83daeea2e8b2ed962f99f284d238f927479391a", size = 1430678, upload-time = "2026-08-01T21:05:55.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/79/e1ee523dd8b14a4e8b8ddd474a93803855b8170a10240a65997c21d69fe2/qbittorrent_api-2026.7.0-py3-none-any.whl", hash = "sha256:189ff96c2c08f999d82b3b4970c4d3e0da1c32ae9c36e9da2fdec7847e5f37a5", size = 69450, upload-time = "2026-07-08T00:54:38.377Z" },
{ url = "https://files.pythonhosted.org/packages/5b/81/b456688c20e75cae5964700b58712630930b9dfaf32ce7c6a8881658d0d0/qbittorrent_api-2026.8.0-py3-none-any.whl", hash = "sha256:03b3db598d351194fd3c65f1d3e53843f65f85bef1d5077791efaa2e26869d2b", size = 68196, upload-time = "2026-08-01T21:05:54.376Z" },
]
[[package]]
name = "rarfile"
version = "4.4"
version = "4.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/09/84/a00a5be12adbe5abb0094513ad6d3aefc80fe5dfa4a5604e22561d4b7d3b/rarfile-4.4.tar.gz", hash = "sha256:b7d29d671223cdc80e8b992df212d152ad88e6c961d4426c3fb2fefe8dd86fdf", size = 155437, upload-time = "2026-07-22T16:19:15.533Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/eb/33ea5625de4e84144069ea021a282ea3c44ff7bd341b740bcadff028bea7/rarfile-4.5.tar.gz", hash = "sha256:7425d0afa180f0092db903abb1526a130b36858980aad90b3694f48e41420155", size = 155541, upload-time = "2026-08-02T20:45:53.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/06/7135da7d7d662767aee9aeebaa360f4ad29f08d9198cc1b1632428114acd/rarfile-4.4-py3-none-any.whl", hash = "sha256:befb813c283fbcdd46dda2bad090f8272c8bb1de246d04cd570e9d890294d119", size = 29932, upload-time = "2026-07-22T16:19:14.559Z" },
{ url = "https://files.pythonhosted.org/packages/f5/36/ffd87452bf72310dfd1185ade33e5b8be374ab370d19573a8197ca25c95b/rarfile-4.5-py3-none-any.whl", hash = "sha256:c74341f4b9a3a3ebb35ef396d59daf059eb028f34995a7162950a41d97b84de9", size = 30035, upload-time = "2026-08-02T20:45:52.212Z" },
]
[[package]]
@@ -1197,27 +1321,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.16.0"
version = "0.16.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
{ url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" },
{ url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" },
{ url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" },
{ url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" },
{ url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" },
{ url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" },
{ url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" },
{ url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" },
{ url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" },
{ url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" },
{ url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" },
]
[[package]]
@@ -1248,7 +1372,7 @@ wheels = [
[[package]]
name = "seleniumbase"
version = "4.51.8"
version = "4.51.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -1312,18 +1436,18 @@ dependencies = [
{ name = "wheel" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/54/92/dac65663cbab903d7d2be831f94587df7fa907ca905c0215ae3a8ab96792/seleniumbase-4.51.8.tar.gz", hash = "sha256:92d71030b9ebca772924b5caed7c44c865a5e149a539b2d733664a4e5bfd05db", size = 670520, upload-time = "2026-07-26T04:51:09.923Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/6d/b852196360200892bc0efb8daeb5ad2278e09eec5b6aaee9f35fdf7b6860/seleniumbase-4.51.12.tar.gz", hash = "sha256:a914610bbe5c9084bce045243e3134987064bbe6e1f2725fe369ce4dbce313d1", size = 672929, upload-time = "2026-08-10T19:28:37.957Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/7c/6af2efd2f1edda88a0c4d83138586b9014bd155870bad90c4836200eb048/seleniumbase-4.51.8-py3-none-any.whl", hash = "sha256:1c240f6d6fe173c0e51828187a048f5907b1e3ff8c23a4d487f5c65e3730cb41", size = 676369, upload-time = "2026-07-26T04:51:07.017Z" },
{ url = "https://files.pythonhosted.org/packages/93/15/199f8ed68290e553a6bc3c10920817d41a006ab3d9aaac06b980256df966/seleniumbase-4.51.12-py3-none-any.whl", hash = "sha256:64ea9fa52b8c1ac8d1b8ccbb3dcf7daa02c1cb1ffdd7e1f358677e36cbbc071c", size = 677511, upload-time = "2026-08-10T19:28:34.24Z" },
]
[[package]]
name = "setuptools"
version = "83.0.0"
version = "84.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
]
[[package]]
@@ -1343,6 +1467,7 @@ dependencies = [
{ name = "gevent" },
{ name = "gevent-websocket" },
{ name = "gunicorn" },
{ name = "httpx", extra = ["http2"] },
{ name = "psutil" },
{ name = "python-socketio" },
{ name = "qbittorrent-api" },
@@ -1385,15 +1510,16 @@ requires-dist = [
{ name = "gevent" },
{ name = "gevent-websocket" },
{ name = "gunicorn" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.27" },
{ name = "psutil" },
{ name = "pyautogui", marker = "extra == 'browser'" },
{ name = "python-socketio" },
{ name = "python-xlib", marker = "extra == 'browser'" },
{ name = "pyvirtualdisplay", marker = "extra == 'browser'" },
{ name = "qbittorrent-api", specifier = ">=2026.5.3" },
{ name = "qbittorrent-api", specifier = ">=2026.8.0" },
{ name = "rarfile" },
{ name = "requests", extras = ["socks"] },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.8" },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.51.12" },
{ name = "tqdm" },
{ name = "transmission-rpc" },
]
@@ -1406,7 +1532,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-xdist", specifier = ">=3.8.0" },
{ name = "ruff", specifier = "==0.16.0" },
{ name = "ruff", specifier = "==0.16.2" },
{ name = "vulture", specifier = ">=2.14" },
]
@@ -1451,11 +1577,11 @@ wheels = [
[[package]]
name = "soupsieve"
version = "2.9.1"
version = "2.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" },
{ url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" },
]
[[package]]
@@ -1484,15 +1610,15 @@ wheels = [
[[package]]
name = "transmission-rpc"
version = "7.0.11"
version = "7.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/b8/dc4debf525c3bb8a676f4fd0ab8534845e3b067c78a81ad05ac39014d849/transmission_rpc-7.0.11.tar.gz", hash = "sha256:5872322e60b42e368bc9c4724773aea4593113cb19bd2da589f0ffcdabe57963", size = 113744, upload-time = "2024-08-20T22:41:07.485Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/9d/c4e24d5bd0a1166935ca146dfb99b7041e54a38a6eed600e6c9798e98f58/transmission_rpc-7.0.12.tar.gz", hash = "sha256:b65f195a26bf3db792f88ee93211929144321fbc3a4f018bf7029996304fcfa5", size = 26897, upload-time = "2026-08-09T14:37:41.519Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/4c/6319bcb1026e3f78c9cbcc9c24de77a76f09954e67ffc5ebfc29f7ce4b90/transmission_rpc-7.0.11-py3-none-any.whl", hash = "sha256:94fd008b54640dd9fff14d7ae26848f901e9d130a70950b8930f9b395988914f", size = 28231, upload-time = "2024-08-20T22:41:05.777Z" },
{ url = "https://files.pythonhosted.org/packages/80/9c/edc8ee0d9ab7a05f0cb334fc506ce3749ff869a8d41683108313096ce5ab/transmission_rpc-7.0.12-py3-none-any.whl", hash = "sha256:85e26b7ca13e1e102a695fe19f19f4236bd42bb4920b0edb9e35f6bb46bbe86e", size = 29103, upload-time = "2026-08-09T14:37:40.272Z" },
]
[[package]]
@@ -1578,45 +1704,51 @@ wheels = [
[[package]]
name = "websockets"
version = "16.1.1"
version = "17.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
{ url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" },
{ url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" },
{ url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" },
{ url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" },
{ url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" },
{ url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" },
{ url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" },
{ url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" },
{ url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" },
{ url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" },
{ url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" },
{ url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" },
{ url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" },
{ url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" },
{ url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" },
{ url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" },
{ url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" },
{ url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" },
{ url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" },
{ url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" },
{ url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" },
{ url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" },
{ url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" },
{ url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" },
{ url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" },
{ url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" },
{ url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" },
{ url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" },
{ url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" },
{ url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
]
[[package]]