Skip to content

fix(pyinstaller): bundle fastmcp/mcp dist-info so importlib.metadata works in frozen binaries - #68

Merged
elkaix merged 9 commits into
mainfrom
fix/pyinstaller-fastmcp-metadata
Jun 3, 2026
Merged

fix(pyinstaller): bundle fastmcp/mcp dist-info so importlib.metadata works in frozen binaries#68
elkaix merged 9 commits into
mainfrom
fix/pyinstaller-fastmcp-metadata

Conversation

@elkaix

@elkaix elkaix commented Jun 3, 2026

Copy link
Copy Markdown
Member

Summary

  • fastmcp calls importlib.metadata.version("fastmcp") at module import time (__init__.py:27)
  • collect_data_files(pkg) only collects files inside the package directory — the fastmcp-*.dist-info/ directory lives alongside it in site-packages/ and was silently omitted from all three PyInstaller specs
  • This caused PackageNotFoundError: No package metadata was found for fastmcp on every pythinker mcp add invocation in Windows (and Linux) native builds

Changes

  • packages/windows-installer/pythinker.spec — add copy_metadata("fastmcp", "mcp") after the package loop
  • packages/linux-installer/pythinker.spec — same fix (identical structural gap)
  • src/pythinker_code/utils/pyinstaller.py — replace the fragile ../fastmcp-*.dist-info/* glob workaround with copy_metadata(), the PyInstaller-standard hook for this problem

Test plan

  • Build Windows installer and run pythinker mcp add context7 -- npx -y @upstash/context7-mcp — should succeed instead of crashing with PackageNotFoundError
  • Build Linux installer and confirm same command works
  • Verify pythinker mcp list shows the newly added server

Summary by CodeRabbit

  • Bug Fixes

    • Ensure package metadata is included in bundled Windows and Linux installers so frozen apps can resolve package versions correctly.
  • New Features

    • Automatically append local agent-state entries to .gitignore at startup to avoid persisting scratchpad state.
  • Tests

    • Updated packaging tests to assert presence of bundled package metadata and to more robustly filter expected bundled files.

…works in frozen binaries

fastmcp calls importlib.metadata.version("fastmcp") at module import time.
collect_data_files() only walks inside the package directory, so the
fastmcp-*.dist-info/ sibling directory was never bundled — causing
PackageNotFoundError on every `pythinker mcp add` invocation.

Replace the fragile ../glob workaround in pyinstaller.py and add the
missing fix to both installer specs (windows + linux) using copy_metadata(),
the PyInstaller-standard hook for making importlib.metadata work in frozen apps.
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds PyInstaller.copy_metadata usage to include fastmcp and mcp dist-info in the bundling utility and both installer specs (errors not suppressed). Tests assert each package's dist-info directory is present. Adds best-effort helpers to append .gitignore entries for local pythinker state and calls them at startup.

Changes

Distribution metadata bundling for frozen builds

Layer / File(s) Summary
Utility module: replace manual dist-info with copy_metadata
src/pythinker_code/utils/pyinstaller.py
Import copy_metadata and replace manual fastmcp dist-info handling with copy_metadata("fastmcp"); add copy_metadata("mcp").
Linux installer: import and apply copy_metadata
packages/linux-installer/pythinker.spec
Add copy_metadata import and append copy_metadata(pkg) results for fastmcp and mcp into datas; this loop does not suppress exceptions.
Windows installer: import and apply copy_metadata
packages/windows-installer/pythinker.spec
Add copy_metadata import and append copy_metadata(pkg) results for fastmcp and mcp into datas without exception suppression.
Tests: assert METADATA presence and adjust datas filtering
tests/utils/test_pyinstaller_utils.py
Assert {pkg}-{version}.dist-info directory entries exist for fastmcp and mcp in collected datas, then remove remaining fastmcp/mcp dist-info entries before comparing expected_datas.
Changelog
CHANGELOG.md
Add Unreleased bullets noting dist-info bundling fix and .gitignore auto-append behavior.

Scratchpad .gitignore auto-append

Layer / File(s) Summary
Constants and ensure_git_excluded update
src/pythinker_code/scratchpad.py
Add .gitignore section header and ignore patterns; call the async append helper from ensure_git_excluded().
Helpers: append .gitignore under lock with retries
src/pythinker_code/scratchpad.py
Introduce _append_gitignore_entries and _write_gitignore_entries to update .gitignore under an advisory lock, treating transient OSErrors as retryable and suppressing final failures.

🎯 3 (Moderate) | ⏱️ ~20 minutes


Suggested labels: bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 70.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title follows conventional commits format (fix scope with clear description) and accurately describes the main change: bundling fastmcp/mcp dist-info metadata.
Description check ✅ Passed Description provides context (why the issue occurred), specific changes (three file modifications), and test plan. Matches template structure with clear sections covering root cause and resolution.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pyinstaller-fastmcp-metadata

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@packages/linux-installer/pythinker.spec`:
- Around line 50-58: The current loop suppresses all exceptions when calling
copy_metadata(pkg), hiding PackageNotFoundError and producing silent build-time
failures; change the logic in the loop that iterates over pkg in
("fastmcp","mcp") to let errors surface (remove the broad try/except), or catch
only expected exceptions and re-raise or log-and-raise with context; ensure
calls to copy_metadata(pkg) append results to datas and that
PackageNotFoundError (or any exception from copy_metadata) is not swallowed so
the build fails fast and clearly.

In `@src/pythinker_code/utils/pyinstaller.py`:
- Around line 46-51: The test expectations in
tests/utils/test_pyinstaller_utils.py::test_pyinstaller_datas assume dist-info
dst paths like fastmcp/../{fastmcp_dist}, but the new calls
copy_metadata("fastmcp") and copy_metadata("mcp") produce a different
destination shape; update the expected_datas entries in that test to match the
actual dst produced by copy_metadata for both "fastmcp" and "mcp" (adjust the
dst strings for INSTALLER, METADATA, RECORD, etc. to the copy_metadata
destination form) while leaving the copy_metadata calls themselves unchanged.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e6aa0494-bf9d-44e3-b7ef-469c4a115f85

📥 Commits

Reviewing files that changed from the base of the PR and between fd80fb1 and 4312cf2.

📒 Files selected for processing (3)
  • packages/linux-installer/pythinker.spec
  • packages/windows-installer/pythinker.spec
  • src/pythinker_code/utils/pyinstaller.py

Comment thread packages/linux-installer/pythinker.spec Outdated
Comment thread src/pythinker_code/utils/pyinstaller.py
elkaix added 2 commits June 2, 2026 23:37
…pectations

Two issues flagged in review:
- copy_metadata() loops were wrapped in bare except Exception: pass, which
  would silently produce a broken binary if fastmcp/mcp are missing at build
  time. Let PackageNotFoundError surface so the build fails loudly.
- test_pyinstaller_datas pinned the old collect_data_files fastmcp/../dist-info
  path format and had no coverage for mcp. Switch to a presence assertion for
  METADATA (like the existing justext stoplists pattern) so the test verifies
  the right behavior without being brittle against version-dependent file lists.
…on agent startup

When the agent starts in a git repo, write .pythinker/, .pythinker-review/,
and .pythinker-review-flow/ to the project's .gitignore if missing, preventing
these local-only state directories from making the working tree dirty.

The update runs inside ensure_git_excluded() (called once at CLI startup),
is idempotent, uses the existing file lock, and never raises or blocks the
session on failure.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@tests/utils/test_pyinstaller_utils.py`:
- Around line 55-64: The long generator condition in the datas list
comprehension (the any(...) check that compares d to
f"{pkg}-{version(pkg)}.dist-info" or startswith that) exceeds 100 chars;
refactor by extracting that dist-info predicate into a helper function (e.g.,
def is_dist_info_dir(d): ... using version(pkg) and the pkg tuple) or by
breaking the any(...) condition across multiple lines so the line length is
under 100, and then call that helper (or the broken expression) inside the
comprehension to replace the long inline f-string checks.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a28daa8b-31a5-4b49-b44e-e3237efc7e56

📥 Commits

Reviewing files that changed from the base of the PR and between 4312cf2 and 77992ee.

📒 Files selected for processing (3)
  • packages/linux-installer/pythinker.spec
  • packages/windows-installer/pythinker.spec
  • tests/utils/test_pyinstaller_utils.py

Comment thread tests/utils/test_pyinstaller_utils.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@src/pythinker_code/scratchpad.py`:
- Around line 453-466: The async function _write_gitignore_entries performs
synchronous pathlib I/O (gitignore_path.read_text and open().write) which can
block the event loop; make _write_gitignore_entries a regular (synchronous)
function that uses _exclude_lock, _GITIGNORE_ENTRIES and
_GITIGNORE_SECTION_HEADER as before, and then update its callers (e.g.,
_append_gitignore_entries or ensure_git_excluded) to invoke it via await
asyncio.to_thread(_write_gitignore_entries, gitignore_path) so the filesystem
operations run off the event loop.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a41b2a57-70f8-40bb-a94c-e7ed0d632f39

📥 Commits

Reviewing files that changed from the base of the PR and between 77992ee and 8173233.

📒 Files selected for processing (2)
  • src/pythinker_code/scratchpad.py
  • tests/utils/test_pyinstaller_utils.py

Comment thread src/pythinker_code/scratchpad.py Outdated
elkaix added 5 commits June 2, 2026 23:47
…ction header

Two issues in _write_gitignore_entries:
- Was async def but only did blocking pathlib I/O, stalling the event loop.
  Made it a plain def and call it via asyncio.to_thread() so the filesystem
  work runs in a thread pool.
- Section header was written unconditionally whenever any entries were missing,
  so a partial previous write (some entries present, some not) would duplicate
  the header on the next run. Now only written when absent from existing_lines.
…gelog

- copy_metadata() returns one directory entry per package (the whole dist-info
  dir), not individual file entries. The test was asserting p.endswith("/METADATA")
  which can never match a directory path — fix to assert the dest dir name matches.
- Inline the asyncio.to_thread lambda to satisfy ruff line-length check.
- Add changelog entries for the mcp add fix and gitignore auto-exclusion feature.
…artup

Applies the best practice used by Claude Code (cleanupPeriodDays=30):
on every startup, remove accumulated state that is safe to discard.

- session_cleanup.py: two focused sweep functions
  - sweep_old_sessions: removes archived session dirs under
    ~/.pythinker/sessions/ whose archived_at/wire_mtime is older than
    the retention threshold; orphan dirs (no state.json) pruned by mtime
  - sweep_old_plans: removes hero-name plan files from ~/.pythinker/plans/
    older than the threshold; plans are ephemeral by nature
- config.py: adds session_retention_days (default 30, 0 = disabled)
- cli/__init__.py: calls both sweeps at startup via asyncio.to_thread
  (non-blocking, best-effort); reads session_retention_days from config
  when a Config object is available, otherwise falls back to 30

Active/unarchived sessions are never touched regardless of age.
Extends the startup cleanup to cover every accumulation point identified
in the AI-agent state-management research (Claude Code / Aider / Copilot CLI):

session_cleanup.py:
- sweep_old_sessions now cross-references pythinker.json to find each
  session's work_dir path and co-deletes the corresponding per-session
  scratchpad file (.pythinker/scratch/<id>-*.md) — no orphaned project-dir
  files left behind after a session bucket is reaped
- sweep_stale_work_dirs: prunes pythinker.json entries whose path no
  longer exists AND whose sessions bucket is absent/empty; conservative
  (keeps entries with surviving sessions even if path is gone)
- Bucket directories are rmdir'd when emptied; avoids ghost buckets

cli/__init__.py:
- Adds sweep_stale_work_dirs() to the startup sweep sequence

findings_store.py (_update_index):
- Collects run IDs that overflow the 200-entry index cap and immediately
  removes their physical run directories (.pythinker-review/runs/<id>/)
  so the on-disk state stays bounded; previously the index was capped
  but the directories accumulated indefinitely
- Add _safe_cwd() to pythinkersoul: falls back to session.work_dir when
  the process CWD has been deleted mid-session (FileNotFoundError)
- Replace readline() with read(65536) in Shell._read_stream to avoid
  asyncio's 64 KB per-line LimitOverrunError
- Isolate wire recorder _record() exceptions so a persist failure no
  longer silently drops the unprocessed message
- Downgrade LLMNotSet from logger.exception to logger.warning in session,
  print, and shell UIs (no stack trace needed for a config-level error)
- Gitignore .pythinker-review-flow/ (local agent state)
- CHANGELOG entry for session/plan sweeper (session_retention_days)
- Update tests: session_retention_days default, oversized-line shell test,
  FakeStream.read() stub for cancellation test
@elkaix
elkaix merged commit a552640 into main Jun 3, 2026
19 checks passed
@elkaix
elkaix deleted the fix/pyinstaller-fastmcp-metadata branch June 3, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant