A complete engineering log of building findmypylibrary with an AI pair-programmer (Claude Code): what broke, how we found out, what we tried, what we measured, and why each decision went the way it did. Nothing is left out, including the mistakes that were ours.
findmypylibrary answers one question: "I need to do X in Python. Which package?"
pip install findmypylibrary
findmypylibrary refresh
findmypylibrary "fuzzy string matching"
1. RapidFuzz (score 0.90)
rapid fuzzy string matching
downloads/30d: 163,835,611 last release: 2026-08-30
2. pfzy (score 0.81)
Python port of the fzy fuzzy string matching algorithm
downloads/30d: 27,024,226 last release: 2022-01-28
3. fuzzywuzzy (score 0.73)
Fuzzy string matching in python
downloads/30d: 14,789,165 last release: 2020-02-13
Three constraints were fixed on day one and never changed, and they explain most of the decisions below:
The project started as a 0.0.1 placeholder on PyPI: a name reservation with a README that described two commands that did not exist yet. This is the story of getting from there to something we were willing to release.
The plan needed, for the most-used packages on PyPI: name, description, download count and last release date.
The first obstacle: PyPI has no free bulk endpoint for "all packages with downloads". Download statistics live in a public BigQuery dataset, and BigQuery needs a Google Cloud account and key. That breaks constraint 3.
What we did instead: two public sources, neither needing a key.
https://pypi.org/pypi/<name>/json), one request per package, for the summary and release date.
A small first bug: the dataset's documented URL returned an HTML page, not JSON. It was a 301 redirect to a new domain that our first curl did not follow. We switched to the raw GitHub URL. Lesson filed for later: never assume a 200, and follow redirects (this came back to bite us in Step 12).
Decision: how many packages? The question was 200, 2,000 or 8,000. The answer from the product owner was "15,000, or whatever covers all actively downloaded libraries". When we opened the dataset it contained exactly 15,000 rows, down to packages with about 68,000 downloads a month. So "top 15,000 by 30-day downloads" became the definition of "active", for free.
The cost of that decision: 15,000 packages means 15,000 HTTP requests per snapshot. Sequentially that is over an hour. With httpx.AsyncClient and a semaphore of 25 concurrent requests it takes a few minutes. Hold that thought; it becomes the scaling problem in Step 5.
Architecture, deliberately boring:
fetch.py two sources -> list of package dicts (async, bounded concurrency, retries)
cache.py sqlite file in ~/.cache/findmypylibrary/
rank.py query -> ranked packages
cli.py click commands: refresh, search
Three details worth recording.
1. A bare query had to work. The README promised findmypylibrary "parse messy pdfs", with no search subcommand. Click groups reject unknown command names, so we subclassed the group:
class DefaultGroup(click.Group):
def resolve_command(self, ctx, args):
try:
return super().resolve_command(ctx, args)
except click.UsageError:
return super().resolve_command(ctx, ["search", *args])
This looked finished. It was not (Steps 12 and 15 found three more cases).
2. An ordering trap in the async crawl. We used asyncio.as_completed to drive a progress bar. It yields results in completion order, so you cannot zip them back onto the input list to attach download counts. We caught this at design time and made each task return its own complete record, download count included, instead of re-joining afterwards.
3. We did not hammer PyPI while developing. Every test of the pipeline during development used refresh --limit 40. The full 15,000-package crawl was run only when we needed real data. Result of the first full run: 14,999 of 15,000 packages (the missing one was a genuine 404, a delisted package).
The first ranking was textbook: a pure-Python BM25 over name + summary + keywords, then a weighted blend.
score = 0.60 * relevance + 0.25 * popularity + 0.15 * recency (each min-max normalised)
It produced sensible results for the first few queries we tried. That was the problem: we only tried a few.
openpyxl loses to a package nobody has heard of
Query: read and write excel spreadsheets.
1. numbers-parser Read and write Apple Numbers spreadsheets 924,605 downloads
2. tifffile Read and write TIFF files 27,744,478 downloads
3. openpyxl A Python library to read/write Excel ... 339,316,525 downloads
openpyxl is the right answer and it has 370 times the downloads of the winner.
Diagnosis (we looked at the numbers instead of guessing). openpyxl matched three of the four query words, so it was not a recall problem. Two things combined:
numbers-parser's whole summary is six words, three of which are in the query. BM25's length normalisation rewards that density.
The fix: stop blending, start gating. Relevance became a gate (keep anything within 50% of the best match), and the survivors were ranked mainly by popularity. This is how real package search engines work: text match for recall, authority for ordering. We also weighted matches in the package name above matches in the summary, and added a small synonym list (excel/xlsx/spreadsheet and a dozen more).
This was done test-first. The regression test asserts the outcome a user sees, not an internal number:
def test_popular_relevant_package_beats_short_keyword_dense_niche_one():
result = names("read and write excel spreadsheets", [openpyxl, numbers_parser, tifffile])
assert result[0] == "openpyxl"
We rejected one idea here on principle: a local embedding model. It would have pulled in a deep-learning runtime and a model download for a tool whose selling point is being light.
The product owner uses a strict audit methodology on a large TypeScript / Postgres / Playwright application, and asked for the same gates here:
Applied literally, half of that is meaningless for a Python CLI with no browser, no database server and no TypeScript. Copying the letter of a process into a different stack is how you end up with ceremonies nobody can explain. So we translated the intent:
Their gate What it became here
----------------------------- ---------------------------------------------------
TDD pytest, regression test written before each fix
>= 90% coverage pytest-cov, enforced in CI
0 TypeScript / lint errors ruff (lint + format) and mypy, zero findings
Playwright end-to-end subprocess tests that run the real installed binary
Leftover / twin host inventory grep every importer of anything we change or remove
"Ledger-green, product-red" assert the outcome users see, never a proxy for it
That last line is the core lesson of their methodology: a test that checks "the file exists" or "the score changed" can be green while the product is broken. It turned out to apply to us more than once.
To isolate test runs, a cleanup command removed ~/.cache/findmypylibrary before running pytest. That directory was not a test artefact. It was the real 15,000-package snapshot that had taken a full crawl to build.
Fix: rebuild it, then make the mistake impossible rather than merely avoided. Every test now runs with XDG_CACHE_HOME pointed at a temporary directory, through one autouse fixture:
@pytest.fixture(autouse=True)
def isolated_cache_dir(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg-cache"))
Lesson: "be careful" is not a control. A fixture that every test gets automatically is.
(We broke this rule again, through a different door, in Step 15.)
With a working tool, the obvious flaw surfaced: refresh makes 15,000 requests to PyPI. One developer doing that monthly is fine. A thousand users doing it is not, and it would get everyone rate-limited.
Decision: build the snapshot once, centrally, and let users download it. A scheduled GitHub Actions workflow builds the snapshot monthly and publishes it as a GitHub Release asset. findmypylibrary refresh downloads that file (one request, a couple of seconds). refresh --build-locally keeps the full crawl for anyone who wants a snapshot as of right now.
This is the same pattern the upstream dataset uses for itself, which is a good sign it is the right shape.
A guardrail worth mentioning: the AI assistant's permission system refused to create a public GitHub repository on its own. It stopped, explained, and asked. Creating a public surface is a human decision. We approved it explicitly, and only then did it create the repo and push.
We verified the loop end to end, not just the workflow: trigger it manually, confirm the release exists, then run refresh on a clean machine profile and confirm it downloads and answers queries.
Asked directly, the honest answer was a table of what had not been verified:
Gap Status after this step
---------------------------------- ---------------------------------------------
No CI; gates only ran by hand CI on every push and pull request
Only tested on macOS, Python 3.12 Matrix: Linux, macOS, Windows x 3 Pythons
Reader during a refresh? Concurrency test added
Real HTTP 429 from PyPI Still mock-only. We will not hammer a public
service to force a rate limit. Said so plainly.
Saying "this one we cannot test ethically" is part of being ready. Hiding it is not.
We ran 15 everyday queries against the real snapshot. About half were good. Five failed in a way that would embarrass the tool on day one:
Query Missing from the top results Why (confirmed in the data)
------------------------------ ----------------------------- ---------------------------------------
dataframes pandas its metadata never says "dataframe"
resize images Pillow summary says "Imaging", not "images"
plot charts matplotlib summary says "plotting", not "plot"
connect to postgres database psycopg2 (pymssql came first) "postgres" is not "PostgreSQL"
unit testing pytest matched one word; the gate dropped it
Two root causes. Matching was exact-token, with no stemming. And the text was tiny: summaries average 8 words, and 259 packages have none at all. A 13-entry synonym list cannot paper over that.
The same review found seven robustness gaps. Each is a story of "it works on the happy path":
refresh saved whatever it got. A network drop at 10% would replace 15,000 packages with 1,500, and in the workflow that would be published to everyone.releases/latest/.... The first time we published a We decided not to publish until the ranking and gaps 1 to 5 were fixed. A tool that cannot find pandas is not a release.
The design decision. We needed stemming and more text per package. More text kills the pure-Python BM25: building an index over 15,000 documents of ~250 tokens on every query would take seconds. The index had to be built once and shipped.
SQLite already has this. FTS5 is a full-text engine with a Porter stemmer and a BM25 ranking function, it is compiled into standard CPython on all three platforms (the CI matrix later proved it), and it adds zero dependencies:
CREATE VIRTUAL TABLE packages_fts USING fts5(
name, summary, keywords, topics, description,
content='', tokenize='porter unicode61'
);
content='' makes it contentless: README text is searchable but not stored, which keeps the file small. Result: 19.8 MB on disk, 10.9 MB gzipped, and queries in tens of milliseconds.
More text, carefully. We added each package's topic classifiers and a cleaned excerpt of its README. "Cleaned" matters: in pandas' README the word "DataFrame" first appears about 3,000 characters in, behind badges, links and HTML. We strip images, links, URLs, tags and reST directives before indexing.
The golden queries came first. Before touching a ranking constant we wrote 40 everyday queries, each with a list of acceptable right answers ("resize images" -> pillow, opencv-python, ...). Writing the expectations before seeing results is the only way to keep yourself honest. Baseline on the new index: 37 of 40.
The discovery that shaped the design: README noise. For unit testing, the results were boto3 and tqdm. Why? boto3's README has a section that says "run the unit tests". tqdm's talks about iterations-per-second "units". We had given the description column a weight of 1 against 8 for the name, and it made almost no difference. The reason is a property of BM25: term frequency saturates, so once a term appears at all, the column weight barely moves the score. boto3's README-only match scored 0.73 of the best real match.
Fix: score the core fields (name, summary, keywords, topics) and the description in two separate queries, and add the description score at a heavy discount. A parameter sweep showed the pass rate was flat at 39/40 across a wide band of settings, which is what you want to see: a plateau, not a knife-edge.
A better metric. Pass/fail at top 5 hides ordering. We added mean reciprocal rank (1 for a right answer in first place, 1/2 for second, and so on). It showed that scaling popularity by "log-downloads divided by the maximum" barely separated a package with a million downloads from one with a billion (the logs are 6 and 9). Min-max scaling over the surviving candidates lifted MRR from 0.69 to 0.88.
A test whose premise was wrong. We asserted that tqdm must not appear for "unit testing". It kept appearing. Instead of tuning until it vanished, we looked: tqdm's own PyPI classifiers include Education :: Testing. That is the package describing itself, not README noise. The test was wrong, so we fixed the test, and kept the assertion for boto3, which was the genuine case.
The concurrency test (searches running while a refresh rewrites the snapshot) was passing. Its output contained a warning: the writer thread had died with sqlite3.OperationalError: disk I/O error.
The test passed because it only collected errors from the reader thread. It was asserting a proxy.
We reproduced it in isolation and asked SQLite for the extended error: SQLITE_IOERR_LOCK. The cause was opening the snapshot through a read-only URI (file:...?mode=ro). On macOS, those connections made a concurrent writer's lock acquisition fail, three to five times per run. Plain connections never failed.
Fix: open normally and make the connection read-only with PRAGMA query_only = ON. And fix the test so it records writer failures too. A bug fix without the test fix would have left the same blind spot for the next bug.
Each with the reason, because the reason is what transfers to other projects.
releases/download/snapshot-latest/..., a tag that only the snapshot workflow writes to. Code releases can never change what that URL points at.snapshot-v2.sqlite.gz) and in a meta table. Old installs keep down a layout they understand. A mismatch produces a clear "run refresh" message.--build-locally. The user decides whether to make 15,000 requests. status command, and a warning after 45 days.
pandas still did not appear for dataframes (polars did, so the golden query passed, but the review had named pandas). Could we fix it?
pandas matches dataframes only through its README. boto3 matches unit testing only through its README. We swept the two relevant parameters and printed both positions side by side:
gate readme weight pandas for "dataframes" boto3 for "unit testing"
----- ------------- ----------------------- ------------------------
0.05 0.5 7th 6th
0.10 0.5 7th 6th
0.15 0.5 7th 6th
0.20 0.5 not shown not shown
0.25 0.5 not shown not shown
They move together, every time. To a lexical ranker the two cases are the same case. You cannot have one without the other.
Decision: precision wins. No boto3 for "unit testing", and pandas is found by the words its own metadata uses (data analysis puts it first). We wrote this limit into the README with those exact examples, instead of tuning until a demo looked good. The same sweep gave a free improvement (a README weight of 0.5 took the golden set to 40/40 with boto3 still excluded), so we took it.
We also tried weighting the coverage gate by term rarity. It removed one junk result and dropped Pillow and pytest. Rejected, with the numbers in the commit message.
Mistake 2. A commit was gated like this:
pytest -q | tail -3 && git commit ... && git push
pytest failed. The commit and push happened anyway, because the exit status of a pipeline is the exit status of its last command, and tail always succeeds. Red code reached the main branch.
Fix: never put a pipe between a gate and the thing it guards. Capture output to a file and test the real exit code:
fail=0
pytest -q > out.txt 2>&1 || fail=1
ruff check -q . || fail=1
mypy src > /dev/null || fail=1
if [ "$fail" -ne 0 ]; then echo "GATES RED - NOT COMMITTING"; exit 1; fi
Mistake 3, found while investigating mistake 2. The "failures" were not real. To prove a regression test had teeth, we had temporarily changed a constant in rank.py from 0.5 to 1.0, confirmed the test failed, and restored it. Both versions of the file had the same size and were written within the same second. Python validates cached bytecode by modification time and size, so it considered the stale .pyc valid and kept the 1.0 version. The source said one thing and the interpreter ran another.
Fix: after any mutation check, delete __pycache__. CI, which starts from a clean checkout, confirmed the pushed commit was green all along.
Two lessons: check exit codes, not output; and when results contradict the source code, suspect the cache.
Asked "is anything left?", re-running our own checklist would only confirm what we already believed. So we started a second AI reviewer with no edit rights and a specific brief: here is what the author believes is handled; find what they missed. In parallel we attacked the real CLI with odd inputs. Between the two:
ValueError: min() iterable argument is empty. This sat behind 98% coverage. refresh --build-locally --limit 0 wiped the snapshot.0 < 0.95 * 0, which is false, and saved an empty list. 14,999 packages became 0, exit code 0.-n -1 printed 389 results403 with an HTML body raised JSONDecodeError out of the whole run. It now costs one package. We also follow redirects now (remember Step 1).PRAGMA quick_check before swapping a download in.résumé parser was tokenised as r, sum, parser. findmypylibrary status bar widget``-n 2 parse pdf.
And two findings about the tests, which mattered more than any single bug:
"Traceback" not in output. Click's test runner keeps uncaught exceptions in result.exception and never writes them to output. Those assertions could not fail. That is exactly why the search crash survived. The test helper now fails on any uncaught exception:
crashed = result.exception is not None and not isinstance(result.exception, SystemExit)
assert not crashed, f"CLI crashed with {result.exception!r}"
Performance, measured rather than assumed. Every search took 0.30 seconds, and 0.14 of that was importing httpx, which search never uses. Importing the fetch module lazily halved the start-up time to 0.15 seconds. We also measured FTS5 optimize plus VACUUM: 5% smaller, no speed-up. Not worth a line of code, so we did not add it.
Piping results to head closed the pipe early. A new, broad except OSError handler caught the resulting error and told the user to "check permissions and free disk space". We fixed it by letting BrokenPipeError through.
Local tests passed. CI failed on all four Windows jobs. On Windows a closed pipe does not raise BrokenPipeError (EPIPE). It raises OSError with EINVAL.
The real problem was the design, not the missing case: a catch-all around the whole command could not tell "I cannot write the cache" from "nobody is reading my output". We removed it and did two precise things instead:
def run() -> None:
try:
main()
except OSError as exc:
if exc.errno not in (errno.EPIPE, errno.EINVAL):
raise
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
sys.exit(1)
No amount of local care would have found this. A cross-platform CI matrix is not a formality.
The golden set said 100%. But we had tuned on it. So we wrote 30 new queries, fixed their expected answers before running anything, and ran them once.
Tuned set (40 queries): 40/40 in the top 5 MRR 0.89
Held-out set (30 queries): 25/30 in the top 5 MRR 0.72
83%, not 100%. That gap is the price of tuning on your test set, and it is the number users actually experience.
The failures had one pattern. A package with a billion downloads that matched only the commonest word of the query floated to the top:
gui application -> idna, platformdirs, filelock (all match only "application")
geospatial data -> pandas, platformdirs, tzdata (all match only "data")
discord bot -> python-telegram-bot first (matches only "bot")
Experiment 1: multiply the whole score by how much of the query's rare vocabulary a package covers. The junk vanished, but so did the good partial matches (Pillow, pytest). Total passes fell from 65 to 61. Rejected.
Experiment 2: apply that factor to the popularity term only. A package keeps its relevance score, but it only earns popularity credit in proportion to the informative words it matches.
exponent tuned set held-out set junk entries in four probe queries
-------- ---------- ------------ ----------------------------------
0.0 40 (0.892) 25 (0.715) 10
0.25 39 (0.869) 25 (0.737) 5
0.5 39 (0.840) 25 (0.764) 2
1.0 38 (0.843) 25 (0.759) 1
Better on the held-out set. But the held-out set had now influenced a decision, so it was no longer held out. We wrote a third set of 25 fresh queries to validate:
Validation set (25 queries): MRR 0.755 -> 0.858
ldap authentication: google-auth, google-auth-oauthlib, ... -> django-auth-ldap, python-ldap, ldap3
currency conversion: pillow, pymupdf, forex-python -> forex-python, currency-symbols, ...
bloom filter: soupsieve, eth-bloom, Markdown -> eth-bloom, bloom-filter2, pybloom-live
Adopted. The one query it lost, graph algorithms, exposed a flaw in our own synonym list: "graph" had been grouped with "chart" and "plot", so matplotlib beat networkx. We removed it.
One more rejected idea, because rejections are results too. pytest dropped out of unit testing (its keywords say "unittest", one word). Adding unit <-> unittest as a synonym fixed it and immediately polluted unit conversion with testing tools. The "general" fix, treating every adjacent pair of query words as a possible compound, made things much worse: 84 of 95 instead of 89, because rare accidental compounds like "machinelearning" inflate the top relevance score and push good packages below the gate. What worked was a four-entry list of real compounds (unit test, time zone, data frame, web socket) that applies only when both words are in the query.
Final numbers. All 95 queries are now the permanent regression suite. 90 pass. Of the 55 that were never used for tuning, 49 passed when first run (89%). The README reports the 89% and explains why the 95% is flattering. The rebuilt offline fixture (2,743 real packages) reproduces the full-snapshot result exactly: same 90, same five misses.
A second independent review used a different lens: check every claim in the docs against the code, be a first-time user, think about supply chain. It found:
refresh --limit 5 (without --limit and downloaded the full snapshot. Now a usage error.status --json ran a search for the word "status". Now a usage error.id, relevance), used download_count where the JSON output said downloads_30d, did not validate top_n, and raised an exception it did not export. All settled trusted_schema = OFF.
And the reviewer made a mistake of its own. Told in plain words never to touch the real cache, it ran refresh --limit 5 expecting a usage error, without isolating the cache directory. The command ran and re-downloaded the snapshot over the real one. The content was identical and nothing was lost, and it reported the deviation itself, first, at the top of its report. But it is Mistake 1 again, through a different door. It also found a real bug in the process (--limit being ignored), which is a nice illustration that accidents are data.
Lesson: an instruction is not a sandbox. If something must not be touched, make it unreachable (an environment variable set for the whole process, a read-only mount), do not just ask nicely.
A workflow you edited and did not run is an untested program. Each time we changed the refresh workflow we triggered it and checked the result, four times in total, including the path that only runs the second time (release already exists, asset replaced in place, notes updated after the upload). The publish workflow refuses a tag that is not on the main branch, refuses a tag that does not match the package version, and refuses to publish if the snapshot that version downloads is not already on the release page, so a new install can never hit a 404.
Finally, the whole first-run journey from a wheel installed into a clean environment: search with no snapshot (clear message), contradictory options (usage error), refresh (real download), status, searches, JSON piped to head, the Python API, the typing marker, and proof that a search does not import the HTTP stack.
Tests 135, coverage 97%, ruff and mypy clean
CI 15 jobs: Linux, macOS, Windows x Python 3.10 to 3.14
Ranking 90 of 95 golden queries; 89% on queries never used for tuning
Snapshot 14,999 packages, 10.8 MB download, rebuilt monthly behind two gates
Search ~0.15 s per invocation, offline
Source distribution 18 KB (was 780 KB before we excluded the test corpus)
Known limits, stated in the README rather than discovered by users: matching is lexical, so numpy does not appear for "linear algebra"; about one search in ten will not show a package you would call right; a real PyPI rate limit has only ever been simulated; GitHub s scheduled workflows after 60 days without commits, and the 45-day staleness warning is the safety net for that.
On testing
On evaluation
On robustness
--limit 0, an empty crawl, an empty survivor list.
On process
__pycache__, in our case).
Decision Alternatives considered Why
---------------------------------------- --------------------------------- ------------------------------------------
Two keyless public data sources BigQuery no account or key for users or maintainers
Top 15,000 packages 200 / 2,000 / 8,000 matches the upstream dataset; covers the
long tail down to ~68k downloads a month
Central monthly snapshot + download every user crawls 15,000 requests per user does not scale
Fixed release tag "snapshot-latest" releases/latest, dated tags code releases must not move the URL;
re-runs in a month must not fail
SQLite FTS5 (porter, contentless) pure-Python BM25; embeddings stemming and a shipped index with zero new
dependencies; embeddings break "lightweight"
Core fields and README scored apart one weighted index BM25 term-frequency saturation makes column
weights nearly useless against README noise
Gate, then rank linear blend short-document bias swamped popularity
Popularity credit x informative coverage hard rarity gate; whole-score only variant that removed junk without
penalty losing the good partial matches
Curated compounds (4 entries) synonym; compound every pair synonym leaked; general rule scored 84/95
Precision over pandas-for-"dataframes" lower the gate inseparable from boto3-for-"unit testing"
No silent crawl fallback automatic fallback the user must opt in to 15,000 requests
Trusted Publishing on a version tag manual twine upload with a token no stored or pasted secrets
Lazy import of the HTTP stack eager import measured: 0.30 s -> 0.15 s per search
No FTS optimize / VACUUM add it measured: 5% smaller, no speed-up
If you take one thing from this log: most of the bugs above were invisible to the checks we already had. They were found by asking a different kind of question each time ("what happens on Windows?", "what if this is zero?", "what does it score on queries it has never seen?", "what would someone who wants me to be wrong find?"). Asking the same question twice, more carefully, would have found none of them.