Skip to content

Migrate from Poetry to uv and adopt src layout - #302

Merged
honzajavorek merged 4 commits into
mainfrom
claude/migrate-to-uv
Aug 4, 2026
Merged

Migrate from Poetry to uv and adopt src layout#302
honzajavorek merged 4 commits into
mainfrom
claude/migrate-to-uv

Conversation

@honzajavorek

@honzajavorek honzajavorek commented Aug 4, 2026

Copy link
Copy Markdown
Owner

What

Switches dependency management from Poetry to uv and moves the package into a src/ layout.

Changes

  • src layout: film2trello/src/film2trello/ (tests stay in tests/, importing the installed package).
  • pyproject.toml: rewritten to PEP 621 — [project] metadata, [project.scripts], [project.urls], [dependency-groups] dev for pytest/ruff, and a hatchling build backend ([tool.hatch.build.targets.wheel] packages = ["src/film2trello"]). Same dependency versions as before.
  • Lockfile: poetry.lock removed, uv.lock generated.
  • .python-version: added, pinning 3.11.
  • Dockerfile: installs deps with uv sync --frozen --no-dev (deps layer cached separately from the project), builds the project from the src layout, runs film2trello bot.
  • CI (build.yml, scripts.yml): Poetry → astral-sh/setup-uv, using uv sync --frozen / uv run …. Fly.io deploy steps unchanged.
  • Ignore files: ignore uv's .venv; keep README.md in the Docker build context (hatchling reads project.readme when building the wheel).
  • pytest: testpaths narrowed from . to tests.
  • README: setup/development instructions updated to uv.

Verification

  • uv lock --check, uv sync --frozen, uv run pytest (64 passed), uv run ruff check, uv run ruff format --check all pass.
  • Docker image builds and runs: verified film2trello --help and film2trello inbox --help work inside the built image (the same steps Fly.io runs via flyctl deploy).

Notes

  • Fly.io deploy itself can't be exercised from CI here (needs FLY_API_TOKEN), but the image the deploy builds is verified to build and run.
  • pytest-asyncio is kept as a runtime dependency (as it was under Poetry) to keep this a faithful 1:1 migration; happy to move it to the dev group if you'd prefer.

🤖 Generated with Claude Code


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Telegram bot for submitting film links and receiving processing updates.
    • Added automatic film metadata retrieval, including posters, durations, TV status, and streaming links.
    • Added Trello synchronization with card creation, updates, labels, members, attachments, and archiving.
    • Added inbox processing with optional sorting by duration, availability, and title.
    • Added command-line options for running the bot and processing the inbox.
  • Documentation

    • Updated setup, testing, formatting, and development instructions.

- Move the package to src/film2trello/ (src layout)
- Convert pyproject.toml to PEP 621 with uv dependency groups and a
  hatchling build backend; replace poetry.lock with uv.lock
- Add .python-version pinning 3.11
- Rewrite the Dockerfile to install deps with uv (frozen, no dev),
  building the project from the src layout
- Switch both GitHub Actions workflows from Poetry to astral-sh/setup-uv
- Update .dockerignore/.gitignore for uv's .venv (keep README.md in the
  build context, required by hatchling) and point pytest testpaths at tests/
- Update README setup/development instructions to uv

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENNdCPoC2tTAdsnS2JSPK9
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@honzajavorek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a793c039-e622-45e1-a556-262e1139013a

📥 Commits

Reviewing files that changed from the base of the PR and between 0c43b9f and 6577779.

📒 Files selected for processing (3)
  • .dockerignore
  • Dockerfile
  • pyproject.toml
📝 Walkthrough

Walkthrough

The project migrates from Poetry to Hatchling and uv. It adds HTTP scraping, CSFD parsing, Trello synchronization, inbox processing, a Telegram bot, and a Click CLI. Docker and CI workflows now use uv.

Changes

Film processing and deployment stack

Layer / File(s) Summary
Packaging and uv automation
.dockerignore, .github/workflows/*, .gitignore, .python-version, Dockerfile, README.md, pyproject.toml
Project packaging, Docker builds, CI workflows, ignore rules, and development commands now use Hatchling and uv.
HTTP scraping and CSFD parsing
src/film2trello/http.py, src/film2trello/csfd.py
Added retrying HTTP clients, anti-bot detection, HTML page handling, and CSFD metadata and URL parsers.
Trello synchronization and inbox processing
src/film2trello/trello.py, src/film2trello/core.py
Added film processing, Trello card synchronization, attachment and label updates, inbox refreshes, archiving, and sorting.
Telegram bot and CLI entry points
src/film2trello/bot.py, src/film2trello/cli.py
Added Telegram commands and message handling, user filtering, secret redaction, logging, and bot and inbox CLI commands.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TelegramUser
  participant TelegramBot
  participant FilmProcessor
  participant TrelloAPI
  TelegramUser->>TelegramBot: send film link
  TelegramBot->>FilmProcessor: process message
  FilmProcessor->>TrelloAPI: create or update film card
  FilmProcessor-->>TelegramBot: return progress or error
  TelegramBot-->>TelegramUser: send status response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two primary changes: migrating from Poetry to uv and adopting the src layout.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/migrate-to-uv

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (8)
src/film2trello/bot.py (1)

140-152: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename help and annotate sanitize.

help shadows the builtin for the whole module. get_help_text states the purpose and removes the shadowing. sanitize also has no return annotation; add -> str.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/bot.py` around lines 140 - 152, Rename the help function to
get_help_text and update all references to it so the module no longer shadows
the builtin. Add a -> str return annotation to sanitize while preserving its
existing behavior.
src/film2trello/core.py (2)

34-43: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Check board membership before scraping.

check_username runs after get_csfd_pages. Scraping several CSFD pages with retries is the slowest step in this flow. If the user is not a board member, that work is discarded. Move the check_username call ahead of the scraping step so the cheap authorization check fails fast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/core.py` around lines 34 - 43, Move the check_username
authorization call in the main film-processing flow to immediately after the
Trello/API setup and before get_csfd_pages and get_film scraping. Preserve the
existing username, trello_api, and board_id arguments and keep the subsequent
scraping and list-analysis behavior unchanged.

142-150: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Duplicate duration labels reach Trello.

prepare_duration_labels maps each duration to a bracket, so durations in one bracket produce identical label dicts. For example [100, 105] yields two 2h entries. get_missing_labels only removes labels already on the card, so update_card_labels posts the same label twice concurrently. The duplicate then relies on the "label is already on the card" text match to be swallowed. Deduplicate the labels here.

♻️ Proposed change
 def get_labels(film: Film) -> list[dict[str, str]]:
     labels = trello.prepare_duration_labels(film["durations"])
     if film.get("kvifftv_url"):
         labels.append(trello.KVIFFTV_LABEL)
     if film.get("netflix_url"):
         labels.append(trello.NETFLIX_LABEL)
     if film["is_tvshow"]:
         labels.append(trello.TVSHOW_LABEL)
-    return labels
+    return list({label["name"]: label for label in labels}.values())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/core.py` around lines 142 - 150, The get_labels function
returns duplicate duration label dictionaries when multiple durations map to the
same bracket. Deduplicate the labels produced by trello.prepare_duration_labels
before appending the optional KVIFFTV_LABEL, NETFLIX_LABEL, and TVSHOW_LABEL
entries, while preserving the existing label contents and order.
src/film2trello/trello.py (1)

49-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the return annotation of the decorator wrapper.

wrapper is async def, so -> Coroutine states that awaiting it yields a coroutine. The wrapper returns the value of await fn(...). src/film2trello/http.py lines 126-132 repeat this pattern. Use a TypeVar for the result, or annotate the wrapper result as the awaited type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/trello.py` around lines 49 - 57, Update the return annotation
of the async wrapper inside with_trello_api to represent the decorated
function’s awaited result rather than Coroutine; use a TypeVar to preserve the
generic result type, and apply the same correction to the repeated wrapper
pattern in http.py.
src/film2trello/cli.py (1)

19-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

required=True has no effect together with default.

Click satisfies the requirement with the default value, so this option can never be reported as missing. Choose one behavior: keep default="zmyDOaFL" and drop required=True, or keep required=True and move the board ID to an envvar.

♻️ Proposed change
 board_id_option = click.option(
     "-b",
     "--board",
     "board_id",
-    required=True,
     help="Trello board ID",
     default="zmyDOaFL",
+    show_default=True,
+    envvar="TRELLO_BOARD_ID",
 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/cli.py` around lines 19 - 26, The board_id_option
configuration marks the option as required while also supplying a default,
making the requirement ineffective. Choose the intended behavior: remove
required=True if retaining default="zmyDOaFL", or remove the default and provide
the board ID through an envvar while preserving required=True.
src/film2trello/http.py (3)

146-152: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove or enable the commented-out retry parameters.

Lines 149-151 are debug artifacts. Without them, stamina uses its default wait schedule, which starts near one second and can add several seconds per page. If the shorter waits are the intended behavior, enable them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/http.py` around lines 146 - 152, Clean up the retry
configuration on the AntiBotError handler by removing the commented-out
wait_initial, wait_max, and wait_jitter artifacts or enabling them if the
shorter retry schedule is intended. Keep the existing retry attempts and
exception behavior unchanged.

111-117: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Header composition is split between the client and the request, so profiles mix.

The root cause is one design choice: BROWSER_PROFILES entries are partial overlays on BASE_HEADERS, and headers are applied in two places. httpx merges client headers with request headers per name, so any key a profile omits keeps the value of the previously selected profile or of the Chrome-oriented base set. A Firefox request therefore carries a Chrome Sec-Ch-Ua value and Sec-Ch-Ua-Mobile: ?0, which real Firefox never sends.

  • src/film2trello/http.py#L111-L117: select the profile in one place. Either drop headers=get_default_headers() here and always send a complete header set per request, or select the profile once per client and stop overriding it in get_html.
  • src/film2trello/http.py#L76-L84: make each profile a complete header set, or let a profile declare which base headers to remove, so the Firefox profile sends no Sec-Ch-Ua* header.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/http.py` around lines 111 - 117, Unify header profile
selection so client-level defaults cannot leak into requests: in
src/film2trello/http.py lines 111-117, update get_scraper and the related
get_html flow to apply one complete profile per request or client without
conflicting overrides; in lines 76-84, update BROWSER_PROFILES so each profile
is complete or explicitly removes unsupported base headers, ensuring Firefox
sends no Sec-Ch-Ua* headers and profiles cannot retain values from another
profile.

156-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consolidate the Anubis selector in one helper.

csfd.is_antibot_page defines a CSFD-only selector (head script#anubis_challenge), while http.get_html defines a different selector (script#anubis_challenge). Move one shared selector into http.py and use it for the detection here, or delete the unused CSFD helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/http.py` around lines 156 - 161, Consolidate Anubis detection
around a single shared selector: define or reuse one helper in http.py, update
get_html’s page_html challenge check to use it, and remove the redundant
CSFD-specific helper if it becomes unused. Preserve detection of the intended
Anubis challenge script without maintaining divergent selectors.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Dockerfile`:
- Around line 17-19: Create a dedicated non-root service user in the Dockerfile,
grant it ownership or write access only to the runtime paths required by
film2trello, then switch to that user with USER before the existing CMD
["film2trello", "bot"].

In `@src/film2trello/bot.py`:
- Around line 128-137: Escape exc_text for HTML after sanitization and before
interpolating it into the reply_html call in the exception handler, preserving
the existing error text and help output. Also HTML-escape username wherever it
is interpolated into the generated help markup, using the existing help function
or its caller as the nearest relevant symbol.

In `@src/film2trello/cli.py`:
- Around line 110-112: Update the HTTP error handler around HTTPStatusError to
sanitize the exception and response details before passing them to
logger.exception, reusing the existing sanitize helper from bot.py or its shared
equivalent; ensure Trello key and token values never appear in CLI logs. Prefer
moving get_trello_api authentication from query parameters into an Authorization
header so credentials are also excluded from exception messages and request
URLs.
- Around line 62-70: Update the Click users option in the CLI definition to
remove the hardcoded default assignments and make the option explicitly
required. Load the default user list from the designated environment-variable
configuration instead of embedding Telegram IDs or Trello usernames in the
source, while preserving parse_user handling and multiple-value support.

In `@src/film2trello/core.py`:
- Around line 175-204: Wrap each card’s processing body in the loop around the
visible per-card work, including CSFD lookup, film parsing, Trello updates, and
attachment handling, with a try/except Exception boundary. In the exception
handler, log the failure together with trello.get_card_url(card["id"]) and allow
execution to continue to the next card so the later sorting step still runs.

In `@src/film2trello/csfd.py`:
- Around line 38-42: Update get_base_url to validate that the canonical or
og:url element exists and contains a non-empty URL attribute; when neither valid
source is available, raise a descriptive error instead of propagating IndexError
or returning None. Preserve the canonical-first fallback to og:url and ensure
the function always returns str.
- Around line 153-157: Guard the href check in the overview URL selection logic
so a missing href on tabs[0] does not call startswith on None. Update the code
around ensure_overview_url and the tabs lookup to apply the same missing-href
filtering used by the earlier loop, while preserving the fallback to base_url.
- Around line 87-92: Update parse_poster_url to safely handle srcset entries
without valid numeric descriptors: ignore invalid descriptor tokens, avoid
calling max on an empty parsed mapping, and return an appropriate fallback or no
URL. Build each URL without blindly prepending https:, preserving absolute URLs
while adding the scheme only for protocol-relative entries so
update_card_attachments can skip unusable posters instead of failing.

In `@src/film2trello/http.py`:
- Around line 88-100: Remove the "Connection": "keep-alive" entry from the
BASE_HEADERS default header mapping, leaving all other browser-emulation headers
unchanged.

In `@src/film2trello/trello.py`:
- Around line 283-287: Update has_poster to access each attachment’s previews
defensively with attachment.get("previews"), treating a missing or empty value
as false so one attachment without the field cannot abort the card update.
- Around line 154-163: Update the poster-update branch around has_poster,
scraper.get, and trello_api.post so poster HTTP failures from scraper.get are
caught within the poster step, including raise_on_error-generated
httpx.HTTPStatusError. Make all poster retrieval, thumbnail creation, and
attachment-update failures fail-soft by returning the existing error result
instead of propagating into process_inbox, while preserving normal processing of
subsequent cards.

---

Nitpick comments:
In `@src/film2trello/bot.py`:
- Around line 140-152: Rename the help function to get_help_text and update all
references to it so the module no longer shadows the builtin. Add a -> str
return annotation to sanitize while preserving its existing behavior.

In `@src/film2trello/cli.py`:
- Around line 19-26: The board_id_option configuration marks the option as
required while also supplying a default, making the requirement ineffective.
Choose the intended behavior: remove required=True if retaining
default="zmyDOaFL", or remove the default and provide the board ID through an
envvar while preserving required=True.

In `@src/film2trello/core.py`:
- Around line 34-43: Move the check_username authorization call in the main
film-processing flow to immediately after the Trello/API setup and before
get_csfd_pages and get_film scraping. Preserve the existing username,
trello_api, and board_id arguments and keep the subsequent scraping and
list-analysis behavior unchanged.
- Around line 142-150: The get_labels function returns duplicate duration label
dictionaries when multiple durations map to the same bracket. Deduplicate the
labels produced by trello.prepare_duration_labels before appending the optional
KVIFFTV_LABEL, NETFLIX_LABEL, and TVSHOW_LABEL entries, while preserving the
existing label contents and order.

In `@src/film2trello/http.py`:
- Around line 146-152: Clean up the retry configuration on the AntiBotError
handler by removing the commented-out wait_initial, wait_max, and wait_jitter
artifacts or enabling them if the shorter retry schedule is intended. Keep the
existing retry attempts and exception behavior unchanged.
- Around line 111-117: Unify header profile selection so client-level defaults
cannot leak into requests: in src/film2trello/http.py lines 111-117, update
get_scraper and the related get_html flow to apply one complete profile per
request or client without conflicting overrides; in lines 76-84, update
BROWSER_PROFILES so each profile is complete or explicitly removes unsupported
base headers, ensuring Firefox sends no Sec-Ch-Ua* headers and profiles cannot
retain values from another profile.
- Around line 156-161: Consolidate Anubis detection around a single shared
selector: define or reuse one helper in http.py, update get_html’s page_html
challenge check to use it, and remove the redundant CSFD-specific helper if it
becomes unused. Preserve detection of the intended Anubis challenge script
without maintaining divergent selectors.

In `@src/film2trello/trello.py`:
- Around line 49-57: Update the return annotation of the async wrapper inside
with_trello_api to represent the decorated function’s awaited result rather than
Coroutine; use a TypeVar to preserve the generic result type, and apply the same
correction to the repeated wrapper pattern in http.py.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19e9d809-5931-4fcb-8803-f11dd449ce45

📥 Commits

Reviewing files that changed from the base of the PR and between fdb057f and 0c43b9f.

⛔ Files ignored due to path filters (2)
  • poetry.lock is excluded by !**/*.lock
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • .dockerignore
  • .github/workflows/build.yml
  • .github/workflows/scripts.yml
  • .gitignore
  • .python-version
  • Dockerfile
  • README.md
  • pyproject.toml
  • src/film2trello/__init__.py
  • src/film2trello/bot.py
  • src/film2trello/cli.py
  • src/film2trello/core.py
  • src/film2trello/csfd.py
  • src/film2trello/http.py
  • src/film2trello/trello.py

Comment thread Dockerfile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

🛑 Comments failed to post (10)
src/film2trello/bot.py (1)

128-137: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Escape the exception text before sending it as HTML.

exc_text is interpolated into a message sent with reply_html. Exception messages routinely contain <, >, and &, for example an lxml parser error that quotes markup, or a URL with & between query parameters. Telegram then rejects the message with BadRequest: Can't parse entities. That exception is raised inside the except block, so it propagates out of the handler and the user receives no error report at all.

🐛 Proposed fix
+from html import escape
+
...
     except Exception as exc:
         logger.exception(exc)
         exc_text = str(exc)
         if secrets:
             exc_text = sanitize(exc_text, secrets)
         await update.message.reply_html(
             f"Stala se nějaká chyba 😢\n\n"
-            f"<pre>{exc_text}</pre>\n\n"
+            f"<pre>{escape(exc_text)}</pre>\n\n"
             f"{help(board_id, username)}"
         )

Note that username at line 143 is also interpolated inside <code> without escaping. It comes from operator configuration, so escaping it is defensive only.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    except Exception as exc:
        logger.exception(exc)
        exc_text = str(exc)
        if secrets:
            exc_text = sanitize(exc_text, secrets)
        await update.message.reply_html(
            f"Stala se nějaká chyba 😢\n\n"
            f"<pre>{escape(exc_text)}</pre>\n\n"
            f"{help(board_id, username)}"
        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/bot.py` around lines 128 - 137, Escape exc_text for HTML
after sanitization and before interpolating it into the reply_html call in the
exception handler, preserving the existing error text and help output. Also
HTML-escape username wherever it is interpolated into the generated help markup,
using the existing help function or its caller as the nearest relevant symbol.
src/film2trello/cli.py (2)

62-70: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether these identifiers already appear elsewhere and whether the repository is public.
rg -n --hidden -g '!.git' '119318534|175995069|honzajavorek:|zmyDOaFL' || true
gh repo view --json visibility,nameWithOwner 2>/dev/null || echo "gh unavailable"

Repository: honzajavorek/film2trello

Length of output: 162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline src/film2trello/cli.py || true

echo "== cli.py relevant content =="
wc -l src/film2trello/cli.py
cat -n src/film2trello/cli.py | sed -n '1,140p'

echo "== bot.py run caller context =="
rg -n --hidden -g '!.git' 'from .*cli import|\.run\(|def cli|`@click`\.command|click' src/film2trello || true

echo "== repo git status / remote public-ish files =="
git ls-files | grep -E '(^|/)(README|setup\.py|pyproject\.toml|poetry\.lock|uv\.lock|requirements|\.github)' || true

Repository: honzajavorek/film2trello

Length of output: 4470


🌐 Web query:

click option required True envvar how it parses required option envvar

💡 Result:

In the Click library for Python, configuring an option with both required=True and an envvar allows the option to be satisfied by either a command-line argument or the specified environment variable [1][2]. If neither the command-line argument nor the environment variable is provided, Click will raise a missing parameter error, effectively enforcing the requirement [3]. Key details on how this interaction works: 1. Satisfaction Priority: Click treats the environment variable as a valid source for the parameter value [1][4]. When required=True is set, Click checks if a value has been provided through any of the available input methods (command-line, environment variable, or default) before proceeding [1][2]. If no value is found after checking these, it raises a usage error [3]. 2. Environment Variable Parsing: Environment variables are always read as strings [1][5]. If the option requires specific types (like integers, files, or paths), Click will attempt to convert the environment variable string into the appropriate type using the defined type parameter [1][5]. 3. Multiple Values: For options defined with multiple=True or nargs > 1, Click uses the ParamType.split_envvar_value method to parse the environment variable string [1][6]. The default behavior is to split the string based on whitespace, though specific types like Path or File have their own splitting rules based on the operating system (e.g., splitting on colons on Unix or semicolons on Windows) [7][8]. 4. Flag Options: Parsing for flags can be more complex. If an option is a boolean flag, Click looks for specific values (e.g., "true", "1", "yes", "on", "t", "y" for activation) to determine the flag's state [5][6]. If an envvar is provided for a flag, it must be set to one of these recognized values to activate the flag [5]. For debugging purposes, you can use show_envvar=True in your @click.option definition to display the associated environment variable name in help pages and error messages, which clarifies for the user that an environment variable can be used to satisfy the requirement [4][3].

Citations:


Sensitive Data Exposure (CWE-359)

Move the default user assignments out of the source.

The users default hardcodes Telegram IDs and Trello usernames. Read this list from an environment variable and require the option explicitly.

🛡️ Proposed change
 `@click.option`(
     "-U",
     "--user",
     "users",
     help="User in format <telegram_id>:<trello_username>",
     type=parse_user,
     multiple=True,
-    default=["119318534:honzajavorek", "175995069:zuzka"],
+    required=True,
+    envvar="FILM2TRELLO_USERS",
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`@click.option`(
    "-U",
    "--user",
    "users",
    help="User in format <telegram_id>:<trello_username>",
    type=parse_user,
    multiple=True,
    required=True,
    envvar="FILM2TRELLO_USERS",
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/cli.py` around lines 62 - 70, Update the Click users option
in the CLI definition to remove the hardcoded default assignments and make the
option explicitly required. Load the default user list from the designated
environment-variable configuration instead of embedding Telegram IDs or Trello
usernames in the source, while preserving parse_user handling and multiple-value
support.

110-112: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Sensitive Data Exposure (CWE-532): Insertion of Sensitive Information into Log File

Reachability: External

The Trello key and token are written to the log on any API error.

get_trello_api in src/film2trello/trello.py lines 38-46 attaches key and token as query parameters on every request. httpx builds the HTTPStatusError message from the request URL, so str(exc) contains both secrets verbatim. This handler logs that message, and exc.response.text may repeat request details. Any 4xx or 5xx response, for example a 401 from an expired token, writes live credentials into the log.

src/film2trello/bot.py line 149 already defines sanitize for this purpose. The CLI path does not use it.

🛡️ Proposed fix
+from film2trello.bot import sanitize
+
...
     except HTTPStatusError as exc:
-        logger.exception(f"{exc}:\n\n{exc.response.text}\n\n")
-        raise click.Abort()
+        secrets = [trello_key, trello_token]
+        message = sanitize(f"{exc}:\n\n{exc.response.text}\n\n", secrets)
+        logger.error(message)
+        raise click.Abort() from exc

A stronger fix is to send the credentials in an Authorization header instead of the query string, so they never appear in an exception message or in server-side access logs.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    except HTTPStatusError as exc:
        secrets = [trello_key, trello_token]
        message = sanitize(f"{exc}:\n\n{exc.response.text}\n\n", secrets)
        logger.error(message)
        raise click.Abort() from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/cli.py` around lines 110 - 112, Update the HTTP error handler
around HTTPStatusError to sanitize the exception and response details before
passing them to logger.exception, reusing the existing sanitize helper from
bot.py or its shared equivalent; ensure Trello key and token values never appear
in CLI logs. Prefer moving get_trello_api authentication from query parameters
into an Authorization header so credentials are also excluded from exception
messages and request URLs.
src/film2trello/core.py (1)

175-204: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

One bad card aborts the whole inbox run.

The loop has no error isolation. A single card can raise from several places: parse_title raises ValueError when the year is missing, get_base_url and parse_durations raise IndexError on unexpected markup, and http.get_html raises AntiBotError after the retries are exhausted. Any of these ends the run. Cards later in the list are never refreshed, and the sorting step at lines 206-212 never runs, so the inbox is left partially updated.

Wrap the per-card work in try/except Exception, log the failure with the card URL, and continue with the next card.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/core.py` around lines 175 - 204, Wrap each card’s processing
body in the loop around the visible per-card work, including CSFD lookup, film
parsing, Trello updates, and attachment handling, with a try/except Exception
boundary. In the exception handler, log the failure together with
trello.get_card_url(card["id"]) and allow execution to continue to the next card
so the later sorting step still runs.
src/film2trello/csfd.py (3)

38-42: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

get_base_url can return None or raise an opaque IndexError.

The declared return type is str. Two paths break it. If the page has no og:url element either, cssselect(...)[0] raises IndexError. If the element exists without the attribute, .get() returns None, and parse_target_url then calls make_links_absolute(None), which fails with a TypeError. Raise a descriptive error instead.

♻️ Proposed change
 def get_base_url(csfd_html: html.HtmlElement) -> str:
-    try:
-        return csfd_html.cssselect("link[rel='canonical']")[0].get("href")
-    except IndexError:
-        return csfd_html.cssselect("meta[property='og:url']")[0].get("content")
+    for selector, attribute in (
+        ("link[rel='canonical']", "href"),
+        ("meta[property='og:url']", "content"),
+    ):
+        for element in csfd_html.cssselect(selector):
+            if value := element.get(attribute):
+                return value
+    raise ValueError("Could not determine the base URL of the page")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

def get_base_url(csfd_html: html.HtmlElement) -> str:
    for selector, attribute in (
        ("link[rel='canonical']", "href"),
        ("meta[property='og:url']", "content"),
    ):
        for element in csfd_html.cssselect(selector):
            if value := element.get(attribute):
                return value
    raise ValueError("Could not determine the base URL of the page")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/csfd.py` around lines 38 - 42, Update get_base_url to
validate that the canonical or og:url element exists and contains a non-empty
URL attribute; when neither valid source is available, raise a descriptive error
instead of propagating IndexError or returning None. Preserve the
canonical-first fallback to og:url and ensure the function always returns str.

87-92: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

parse_poster_url crashes on srcset shapes that carry no descriptor.

Two failure modes exist. If the srcset value has no width or density descriptor, zoom is empty, srcset_parsed is empty, and max(srcset_parsed.keys()) raises ValueError: max() arg is an empty sequence. If a descriptor token contains no digits, int(re.sub(r"\D", "", z)) raises ValueError as well.

The f"https:{url}" prefix also assumes every entry is protocol-relative. An absolute URL becomes https:https://…, and scraper.get(poster_url) in src/film2trello/trello.py line 155 then raises httpx.UnsupportedProtocol, which update_card_attachments does not catch. The whole card update fails instead of skipping the poster.

♻️ Proposed change
 def parse_poster_url(csfd_html: html.HtmlElement) -> str | None:
     if poster_images := csfd_html.cssselect(".film-posters img"):
         if srcset := poster_images[0].get("srcset"):
-            srcset_list = re.split(r"\s+", srcset)
-            urls = [f"https:{url}" for url in srcset_list[::2]]
-            zoom = [int(re.sub(r"\D", "", z)) for z in srcset_list[1::2]]
-            srcset_parsed = dict(zip(zoom, urls))
-            return srcset_parsed[max(srcset_parsed.keys())]
+            srcset_list = re.split(r"\s+", srcset.strip())
+            srcset_parsed = {}
+            for url, descriptor in zip(srcset_list[::2], srcset_list[1::2]):
+                digits = re.sub(r"\D", "", descriptor)
+                if not digits:
+                    continue
+                if url.startswith("//"):
+                    url = f"https:{url}"
+                srcset_parsed[int(digits)] = url
+            if srcset_parsed:
+                return srcset_parsed[max(srcset_parsed)]
+            return None
         return None
     return None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        if srcset := poster_images[0].get("srcset"):
            srcset_list = re.split(r"\s+", srcset.strip())
            srcset_parsed = {}
            for url, descriptor in zip(srcset_list[::2], srcset_list[1::2]):
                digits = re.sub(r"\D", "", descriptor)
                if not digits:
                    continue
                if url.startswith("//"):
                    url = f"https:{url}"
                srcset_parsed[int(digits)] = url
            if srcset_parsed:
                return srcset_parsed[max(srcset_parsed)]
            return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/csfd.py` around lines 87 - 92, Update parse_poster_url to
safely handle srcset entries without valid numeric descriptors: ignore invalid
descriptor tokens, avoid calling max on an empty parsed mapping, and return an
appropriate fallback or no URL. Build each URL without blindly prepending
https:, preserving absolute URLs while adding the scheme only for
protocol-relative entries so update_card_attachments can skip unusable posters
instead of failing.

153-157: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against a tab element without an href.

tabs[0].get("href") returns None when the first tab anchor has no href. None.startswith("http") then raises AttributeError. The loop at lines 144-148 already filters missing href values; apply the same guard here.

♻️ Proposed change
     if tabs := csfd_html.cssselect(".main-movie-profile .tabs a"):
         overview_url = tabs[0].get("href")
-        if overview_url.startswith("http"):
+        if overview_url and overview_url.startswith("http"):
             return ensure_overview_url(overview_url)
     return ensure_overview_url(base_url)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    if tabs := csfd_html.cssselect(".main-movie-profile .tabs a"):
        overview_url = tabs[0].get("href")
        if overview_url and overview_url.startswith("http"):
            return ensure_overview_url(overview_url)
    return ensure_overview_url(base_url)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/csfd.py` around lines 153 - 157, Guard the href check in the
overview URL selection logic so a missing href on tabs[0] does not call
startswith on None. Update the code around ensure_overview_url and the tabs
lookup to apply the same missing-href filtering used by the earlier loop, while
preserving the fallback to base_url.
src/film2trello/http.py (1)

88-100: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

httpx http2 Connection keep-alive header forbidden HTTP/2 h2 ProtocolError

💡 Result:

The Connection header is explicitly prohibited in HTTP/2 (and HTTP/3) because these protocols manage connection lifecycles through their own framing layers rather than through hop-by-hop HTTP headers [1][2][3]. According to the HTTP/2 specification (RFC 7540), any message containing connection-specific header fields—such as Connection or Keep-Alive—must be treated as malformed, typically resulting in a protocol error [3][4]. Regarding your query about httpx and HTTP/2: 1. Why it occurs: In some cases, httpx may include the Connection: keep-alive header in requests—often due to internal logic or automatic header population [5]. If a server strictly adheres to the HTTP/2 specification, it will reject these requests with a PROTOCOL_ERROR [3][4][6]. 2. Visibility vs. Reality: You might observe a Connection header in response.request.headers when debugging httpx [5]. It is important to note that this reflects the header set by the client-side logic; however, if the transport layer correctly implements HTTP/2, it should not be sending this header over the wire [7]. If you are encountering a ProtocolError, it indicates that the header was actually sent and subsequently rejected by the server or a proxy [3][6]. 3. Resolution: - Ensure you are not manually adding the Connection header to your requests. - If the header is being added automatically by httpx, you can try to clear or override it by modifying the client's default headers (e.g., client.headers.clear() or explicitly setting the header to an empty value if supported, though clearing is often more effective) [5]. - If you are using a proxy, ensure the proxy is configured to strip connection-specific headers before forwarding the request to the upstream HTTP/2 server, as many proxies do this automatically to avoid protocol violations [2][8]. If you are seeing this error, it is a sign that the request is effectively violating the HTTP/2 protocol, and the server is correctly refusing to process it [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate http.py and inspect relevant sections =="
fd -a 'http\.py$' . | sed 's#^\./##'
echo

if [ -f src/film2trello/http.py ]; then
  sed -n '1,140p' src/film2trello/http.py | cat -n
fi

echo
echo "== Search for httpx/http2 usage and get_default_headers =="
rg -n "httpx|http2|Client|AsyncClient|get_default_headers|BASE_HEADERS|BROWSER_PROFILES" .

Repository: honzajavorek/film2trello

Length of output: 12114


Remove Connection: keep-alive from the default headers.

The scraper uses HTTP/2, and Connection is a prohibited connection-specific header there. Depending on strictness, this can cause protocol errors or be dropped while still hurting the browser-emulation profile.

♻️ Proposed change
     "Cache-Control": "max-age=0",
-    "Connection": "keep-alive",
     "Sec-Ch-Ua-Mobile": "?0",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

BASE_HEADERS = {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
    "Accept-Language": "cs-CZ,cs;q=0.9,en-US;q=0.8,en;q=0.7",
    "Accept-Encoding": "gzip, deflate, br",
    "Cache-Control": "max-age=0",
    "Sec-Ch-Ua-Mobile": "?0",
    "Sec-Fetch-Dest": "document",
    "Sec-Fetch-Mode": "navigate",
    "Sec-Fetch-Site": "none",
    "Sec-Fetch-User": "?1",
    "Upgrade-Insecure-Requests": "1",
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/http.py` around lines 88 - 100, Remove the "Connection":
"keep-alive" entry from the BASE_HEADERS default header mapping, leaving all
other browser-emulation headers unchanged.
src/film2trello/trello.py (2)

154-163: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the declared Pillow and httpx versions used by the project.
fd -H -t f 'pyproject.toml' --exec rg -n -C2 'pillow|Pillow|httpx'

Repository: honzajavorek/film2trello

Length of output: 388


🌐 Web query:

Pillow UnidentifiedImageError base class OSError Image.open exceptions

💡 Result:

In the Pillow library, the UnidentifiedImageError exception is a custom exception class that inherits from OSError [1][2][3]. It is specifically raised by the PIL.Image.open function when an image file cannot be opened and identified—typically because the file format is unsupported, the file is corrupted, or it is not a valid image file [1][4][5]. Because UnidentifiedImageError is a subclass of OSError, code that catches OSError will also catch UnidentifiedImageError [6]. However, it is generally recommended to catch the specific UnidentifiedImageError to avoid masking other unrelated OS-related errors [6]. If you encounter this error while working with PNG images, it may be caused by minor data issues, such as missing chunks or checksum failures [1][7][8]. In such cases, you can attempt to work around the error by setting ImageFile.LOAD_TRUNCATED_IMAGES to True [1][7][8]. Example of handling the exception: from PIL import Image, UnidentifiedImageError try: with Image.open("path/to/image.jpg") as img: img.show except UnidentifiedImageError: print("The file could not be identified as an image.") except OSError as e: print(f"An OS error occurred: {e}")

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate trello.py =="
fd -a 'trello.py' . | sed 's#^\./##'

echo "== relevant trello.py lines =="
sed -n '1,230p' src/film2trello/trello.py

echo "== process_inbox usages =="
rg -n "process_inbox|update_card_attachments|update_card" src

Repository: honzajavorek/film2trello

Length of output: 7524


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== core.py relevant sections =="
sed -n '40,90p' src/film2trello/core.py
sed -n '140,220p' src/film2trello/core.py

echo "== raise_on_error implementation =="
fd -a 'http.py' . | while read -r f; do
  echo "--- $f"
  sed -n '1,120p' "$f"
done

echo "== tests around update_card_attachments =="
sed -n '1,260p' tests/test_trello.py | sed -n '1,260p'

echo "== Python exception inheritance probe for installed Pillow =="
python3 - <<'PY'
import sys
try:
    import PIL
    from PIL import Image
    print("PIL installed:", getattr(PIL, "__version__", "<unknown>"))
except Exception as exc:
    print("PIL import failed:", type(exc).__name__, exc)
else:
    import inspect
    # Probe whether Open / Unsupported type exists in installed PIL.Image.
    for name in ["UnidentifiedImageError", "ImageFileDecodeError"]:
        obj = inspect.getattr_static(Image, name, None)
        print(f"Image.{name}:", type(obj).__name__, getattr(obj, "__name__", None))
        if obj is not None:
            print(f"  bases:", tuplegetattr = getattr(obj, "__bases__", None), bases=tuplegetattr)
PY

Repository: honzajavorek/film2trello

Length of output: 17385


🌐 Web query:

Pillow 12.1.1 UnidentifiedImageError source OSError

💡 Result:

In Pillow (including version 12.1.1), UnidentifiedImageError is an exception class that inherits from OSError [1][2]. It is raised by PIL.Image.open when the library cannot identify or open the provided file [1][3]. Key technical details regarding this error: 1. Inheritance: Because UnidentifiedImageError is a subclass of OSError, code that catches OSError will also catch UnidentifiedImageError [4][5]. For more precise error handling, you should catch UnidentifiedImageError specifically by importing it from PIL [5]. 2. Common Causes: - File Corruption: The file may be truncated, have invalid headers, or contain checksum errors (common in PNG files) [6][7]. - Unsupported/Unknown Format: The file may be in a format that Pillow does not support or cannot automatically identify [1][4]. - Not an Image: The provided file path or stream may not point to a valid image file at all [6][8]. 3. Troubleshooting and Workarounds: - Truncated Files: If you are dealing with potentially truncated or partially corrupted images (especially PNGs), you can often allow Pillow to attempt to open them by setting ImageFile.LOAD_TRUNCATED_IMAGES to True [1][6][7]: from PIL import Image, ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True img = Image.open('your_image.png') - Debugging: If you need to understand why an image is failing, you can check if your specific Pillow version supports extra debugging parameters (such as warn_possible_formats, though this feature availability can vary by release) or use external tools like pngcheck to verify file integrity [7][9]. Always ensure you are using a compatible version of Pillow for your environment, as installation issues can sometimes lead to reports of version mismatches or unexpected behavior [8].

Citations:


🌐 Web query:

Pillow 12.1.1 UnsupportedProtocol base class ValueError ImageFileDecodeError

💡 Result:

The error message sequence you described is not a native behavior of the Pillow library. Specifically, UnsupportedProtocol is not an exception or base class within Pillow [1][2][3][4]. The UnsupportedProtocol exception is a known component of the httpx library [5][6][7], where it signifies an attempt to make a network request using a protocol that is not supported (e.g., trying to use ftp:// instead of http://) [5][7][8]. If you are encountering this combination of errors in your application, it is highly likely that your code is conflating two different libraries: 1. Pillow (PIL) is likely being used for image processing [9]. When it encounters corrupt or unidentifiable image files, it typically raises PIL.UnidentifiedImageError or OSError [2][4][10]. 2. A separate HTTP client (such as httpx) is being used to fetch the image data. If the URL passed to the downloader is invalid or uses an unsupported protocol, the httpx library will raise UnsupportedProtocol [5][7]. If your stack trace shows these errors together, check if your image-loading logic is attempting to download an image from a URL and subsequently passing that result to Pillow. Ensure that the URL passed to your HTTP client is valid and that you have appropriate error handling to catch httpx.UnsupportedProtocol separately from Pillow's image decoding exceptions [10].

Citations:


🌐 Web query:

Pillow version history ImageFileDecodeError introduced OSError UnidentifiedImageError

💡 Result:

In the Pillow library, the UnidentifiedImageError was introduced in version 7.0.0 [1][2]. Before the introduction of UnidentifiedImageError, Pillow typically raised an OSError (or IOError in older Python versions) when it could not identify or open an image file [3][4]. This change was implemented to provide a specific, catchable exception for cases where image identification fails, replacing the ambiguous use of OSError for these situations [4][1]. Regarding ImageFileDecodeError, there is no public-facing exception by that specific name in the current Pillow API. Pillow continues to use OSError for errors occurring during the image decoding process [5]. Historically, the internal helper function ImageFile.raise_oserror was used to translate codec error codes into OSError instances; this function was deprecated in Pillow 10.2.0 and removed in Pillow 12.0.0, as the translation is now handled automatically [6][7][8].

Citations:


🌐 Web query:

Pillow 12.1.1 source Image.open OSError raise UnsupportedProtocol ImageFileDecodeError

💡 Result:

There is no native Pillow exception named UnsupportedProtocol. If you are encountering an UnsupportedProtocol error while using Pillow 12.1.1, it is almost certainly being raised by a different library in your environment, most commonly the HTTP client library httpx or its dependency httpcore [1][2][3]. Pillow's own Image.open method typically raises an OSError when it fails to identify or decode an image [4], or occasionally an UnidentifiedImageError (a subclass of OSError) if the file format cannot be determined. If you are seeing UnsupportedProtocol, check your code for these common scenarios: 1. Misuse of HTTP Libraries: If you are attempting to open an image from a URL, ensure you are using a proper request/response flow. For example, if you pass a string that starts with a protocol scheme (like file://) to a function expecting a file path or a raw HTTP response, libraries like httpx will raise UnsupportedProtocol because they do not support that specific scheme [2][3]. 2. Dependency Conflicts: Since many Python projects use both Pillow and HTTP libraries (like httpx or requests), ensure that the data being passed to Image.open is a valid file-like object (e.g., io.BytesIO or an open file pointer) and not a raw URL string or an incompatible object returned by an HTTP client [5]. To debug this: - Verify the exact traceback: Look closely at the file path in the traceback to identify which library is raising the UnsupportedProtocol exception [3]. - Inspect the input: Check the object being passed to Image.open. If it is a string or a URL, you must first download the content into a buffer (e.g., using requests.get(url).content) and pass that buffer to Image.open(io.BytesIO(data)). Pillow 12.1.1, released on February 11, 2026 [6], does not contain changes that would introduce a new UnsupportedProtocol error in Image.open [7][8]. The error is likely coming from an external library interacting with your image loading process.

Citations:


🌐 Web query:

httpx 0.25.0 UnsupportedProtocol base class RequestError Exception

💡 Result:

In the httpx exception hierarchy, UnsupportedProtocol is a subclass of TransportError, which is in turn a subclass of RequestError [1][2][3]. The full inheritance chain is as follows: HTTPError -> RequestError -> TransportError -> UnsupportedProtocol [1][3][4] - HTTPError: The base class for all exceptions in the library [3][5]. - RequestError: The base class for all exceptions that occur during a.request call [2][3]. - TransportError: The base class for errors that occur at the transport layer [1][4]. - UnsupportedProtocol: Specifically raised when an attempt is made to use a protocol not supported by httpx (e.g., using 'ftp://' instead of 'http://' or 'https://') [2][6][3]. If you are encountering this error, it typically indicates that the URL passed to the client uses a scheme (protocol) that httpx does not recognize [2][3]. If you see an unexpected AttributeError related to UnsupportedProtocol and httpcore, it is often a sign of a version mismatch between httpx and its dependency httpcore [7]. Ensuring your packages are compatible (e.g., matching the versions expected by the libraries you are using) usually resolves this issue [7].

Citations:


🌐 Web query:

Pillow 12.1.1 Source GitHub PIL/Image.py "UnidentifiedImageError" "class ImageFileDecodeError"

💡 Result:

In Pillow 12.1.1, the UnidentifiedImageError is a standard exception class defined in PIL/Image.py [1][2]. It is raised by Image.open() when the library cannot identify the format of an image file [3][4]. Regarding ImageFileDecodeError, there is no class by this name in the Pillow source code. Pillow typically uses standard Python exceptions (such as SyntaxError, ValueError, or OSError) or specific internal error handling mechanisms for decoding issues [5][6][7]. For example, PIL.ImageFile contains a dictionary of error codes in PIL.ImageFile.ERRORS (such as buffer overrun or decoding errors) that are handled by the library, often resulting in OSError or SyntaxError when surfacing errors during image operations [5][6][7]. If you are encountering difficulty opening an image, you can use the warn_possible_formats argument in Image.open() to see warnings for each format driver that attempted and failed to identify the file, which can provide more insight into the underlying cause [3][4].

Citations:


🌐 Web query:

Pillow 12.1.0 Image.py source Image.save mode error raise ValueError SyntaxError

💡 Result:

In Pillow 12.1.0, exceptions such as ValueError and SyntaxError raised during image saving or opening are often related to validation failures, particularly when dealing with invalid, zero-dimension, or corrupted image data [1][2][3]. Key technical context: - Zero-dimension images: In Pillow 12.1.0, attempting to save images with zero width or height can trigger various confusing exceptions (such as ValueError: max arg is an empty sequence or SystemError: tile cannot extend outside image) depending on the specific file format plugin [1][2][4]. This led to discussions and subsequent fixes to raise more descriptive ValueError messages (e.g., Cannot write empty image as [FORMAT]) instead of generic errors [1][2][4]. - SyntaxError: This error is frequently raised in Pillow's ImageFile module during the file opening process (within _open) if the image file cannot be identified, contains corrupted headers, or fails to meet expected structural requirements [3]. - ValueError: Beyond empty image validation, ValueError is commonly raised in the Image.save flow if the requested mode is unsupported by the encoder, if invalid configuration parameters are passed to the plugin, or if an operation is attempted on a closed image object (using DeferredError) [5][6][7]. If you are encountering these errors, check your image dimensions (ensure width and height are > 0) [1][2] and verify that the target file format supports the current image mode [7][8]. If opening a file, ensure the file pointer is valid and the file format is correctly identified [3].

Citations:


🌐 Web query:

Pillow 12.1.0 Image.py Open response.content bytes UnidentifiedImageError

💡 Result:

The UnidentifiedImageError in Pillow 12.1.0, when using Image.open with BytesIO(response.content) from a network request, occurs because Pillow cannot detect a valid image format signature (magic number) in the provided byte data [1][2]. This error is almost always caused by issues with the source content rather than a bug in Pillow itself [3][4]. To diagnose and resolve this issue, consider the following common causes: 1. The Request Returned Non-Image Data: The most frequent cause is that the request did not return an image file. Instead, it often returns HTML (e.g., an error page, a login page, or a 404 page) [5][6]. You can verify the content by inspecting the first few bytes of the response: print(response.content[:50]) If the output starts with b'<!doctype html>' or similar text, you have received an HTML page instead of an image [6]. 2. Failed Authentication or Blocked Requests: The server might be blocking your request (e.g., if you lack a User-Agent header) or requiring authentication, resulting in the server returning a redirection or an error page [3]. Ensure your request includes headers that mimic a browser: requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}) 3. Invalid or Corrupted URL: Ensure the URL directly points to the image file. If the URL points to a web page containing an image or a repository viewing page (e.g., a GitHub file viewer page), the response will be HTML, not the image binary [7]. 4. Proper Use of BytesIO: When passing raw bytes to Pillow, you must wrap them in an io.BytesIO object [8][2]: from PIL import Image import io import requests response = requests.get(url) # Check status code to ensure successful download if response.status_code == 200: img = Image.open(io.BytesIO(response.content)) else: print(f"Request failed with status: {response.status_code}") If the issue persists, save the raw content to a file on your local disk and attempt to open it manually to verify if the file itself is corrupted or incorrectly formatted: with open('debug_image.jpg', 'wb') as f: f.write(response.content)

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== fetch Pillow 12.1.1 src/PIL for relevant exception definitions =="
python3 - <<'PY'
import urllib.request
urls = {
    "Image.py": "https://raw.githubusercontent.com/python-pillow/Pillow/12.1.1/src/PIL/Image.py",
    "ImageFile.py": "https://raw.githubusercontent.com/python-pillow/Pillow/12.1.1/src/PIL/ImageFile.py",
}
for name, url in urls.items():
    text = urllib.request.urlopen(url, timeout=20).read().decode()
    print(f"### {url}")
    for needle in ["class Exception", "class UnidentifiedImageError", "class ImageFileDecodeError", "raise OSError", "UnsupportedProtocol", "raise ValueError"]:
        idx = text.find(needle)
        if idx != -1:
            start = max(0, text.rfind("\n", 0, idx-300))
            end = min(len(text), text.find("\n", idx+300, idx+1500))
            print(f"-- around {needle}:")
            print(text[start:end])
PY

echo "== fetch httpx 0.25.0 exception definitions =="
python3 - <<'PY'
import urllib.request
urls = {
    "_exceptions.py": "https://raw.githubusercontent.com/encode/httpx/0.25.0/httpx/_exceptions.py",
}
for name, url in urls.items():
    text = urllib.request.urlopen(url, timeout=20).read().decode()
    print(f"### {url}")
    for needle in ["class UnsupportedProtocol", "class HTTPError", "class TransportError", "class RequestError"]:
        idx = text.find(needle)
        if idx != -1:
            start = max(0, text.rfind("\n", 0, idx-200))
            end = min(len(text), text.find("\n", idx+600, idx+2000))
            print(f"-- around {needle}:")
            print(text[start:end])
PY

Repository: honzajavorek/film2trello

Length of output: 2517


Isolate poster updates from page URL uploads.

raise_on_error turns poster 404/5xx responses into httpx.HTTPStatusError before the poster try block runs. Catch the exception directly there, and make the whole poster step fail-soft so poster failures do not abort process_inbox before remaining cards are processed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/trello.py` around lines 154 - 163, Update the poster-update
branch around has_poster, scraper.get, and trello_api.post so poster HTTP
failures from scraper.get are caught within the poster step, including
raise_on_error-generated httpx.HTTPStatusError. Make all poster retrieval,
thumbnail creation, and attachment-update failures fail-soft by returning the
existing error result instead of propagating into process_inbox, while
preserving normal processing of subsequent cards.

283-287: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Read previews defensively.

attachment["previews"] raises KeyError when Trello omits the field for an attachment. Use attachment.get("previews") so a single attachment shape cannot abort the card update.

♻️ Proposed change
 def has_poster(attachments) -> bool:
     for attachment in attachments:
-        if len(attachment["previews"]):
+        if attachment.get("previews"):
             return True
     return False
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

def has_poster(attachments) -> bool:
    for attachment in attachments:
        if attachment.get("previews"):
            return True
    return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/film2trello/trello.py` around lines 283 - 287, Update has_poster to
access each attachment’s previews defensively with attachment.get("previews"),
treating a missing or empty value as false so one attachment without the field
cannot abort the card update.

Create a dedicated `app` user and drop privileges before CMD, addressing
the security review on the new uv-based Dockerfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENNdCPoC2tTAdsnS2JSPK9

Copy link
Copy Markdown
Owner Author

On the rest of CodeRabbit's review

This PR is a packaging migration (Poetry → uv, flat → src/ layout). Every source file under src/film2trello/ was moved verbatimgit records them as pure renames (R100) — so the only file with changed logic here is the Dockerfile (already addressed: non-root user).

CodeRabbit reviews moved files as if newly authored, so all its other findings are about pre-existing code that predates this PR. I've intentionally left them out of scope to keep the migration small and reviewable — mixing behavior changes into a packaging PR makes both harder to review and to revert.

That said, several look like genuine pre-existing bugs worth a dedicated follow-up (not introduced here):

  • cli.py — Trello key/token logged on API error (logger.exception(f"{exc}…"); secrets are in the request URL). The most impactful one.
  • core.py — one bad card aborts the whole inbox run (no per-card try/except; later cards + sorting are skipped).
  • core.py — duplicate duration labels when two durations fall in the same bracket.
  • trello.pyhas_poster uses attachment["previews"] (KeyError if the field is absent); poster fetch failures can abort a card update.
  • csfd.pyget_base_url / parse_poster_url / overview-URL can raise IndexError/ValueError/AttributeError on unexpected markup.
  • bot.py — error text isn't HTML-escaped before reply_html (can itself raise BadRequest).
  • http.pyConnection: keep-alive in the HTTP/2 default headers; header profiles partially overlay and can mix.

Happy to open a separate PR (or issue) for any of these if you'd like — just say the word. Keeping this one scoped to the migration.


Generated by Claude Code

Switch [build-system] to uv_build. README.md must stay in the Docker build
context — verified that excluding it makes the wheel build fail
("failed to open file /app/README.md"), since project.readme points at it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENNdCPoC2tTAdsnS2JSPK9
Comment thread .dockerignore Outdated
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ENNdCPoC2tTAdsnS2JSPK9
@honzajavorek
honzajavorek merged commit 66382aa into main Aug 4, 2026
3 checks passed
@honzajavorek
honzajavorek deleted the claude/migrate-to-uv branch August 4, 2026 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants