Contributing to polars-pycountry¶
Thanks for taking the time. This is a small project with one unusual property worth understanding before you start: its contract is another library's behavior. Almost every design decision here follows from "must match pycountry", so a change that looks like an improvement can be a bug — including fixing what looks like a bug. The parity requirement section explains where that bites.
By contributing you agree that your work is dual licensed under MIT and Apache-2.0, matching the project — see LICENSE-MIT and LICENSE-APACHE.
Read NOTICE before touching src/data/ or the table-loading code. The vendored ISO tables are LGPL-2.1-or-later and are compiled into every wheel, which puts real obligations on this project. The runtime replacement mechanism (POLARS_COUNTRY_DATA, load_iso_data) is how those obligations are met, so weakening it is a licensing change rather than an API change.
What's most useful¶
- Parity bugs. An input where this package and
pycountrydisagree is the highest-value report there is. Include the input string and the keywords you used; a failing case added toMESSYintests/test_parity.pyis even better. - Polars version support. The plugin FFI ABI has been stable, but each new Polars release should be checked.
- Performance, as long as parity holds and the benchmark shows it.
If you are planning something large, open an issue first so nobody duplicates work.
Getting set up¶
You need Rust (the pinned toolchain in rust-toolchain.toml installs automatically), uv, and just — the last one comes from uv sync, so uv run just ... works if you don't have it globally.
git clone https://github.com/andrewadlof/polars-pycountry
cd polars-pycountry
uv sync # creates .venv, installs dev tools, builds the extension
just dev # rebuild the extension in-place after Rust changes
uv sync builds the Rust extension into the venv. Any time you touch Rust, re-run just dev or your tests will silently exercise the previous build.
If a bare cargo command fails with cannot set a minimum Python version 3.10 higher than the interpreter version, it found an older python3 on PATH. The justfile exports PYO3_PYTHON=.venv/bin/python, so just test and just check are unaffected; for bare cargo, export it yourself:
This deliberately lives in the justfile rather than .cargo/config.toml — a repo-level cargo config would also apply inside the release workflow's cross-compilation containers, where .venv does not exist and every build would fail with "failed to run the Python interpreter".
The development loop¶
just test # cargo test --lib + pytest
just check # the full gate: fmt, clippy, cargo test, ruff, pytest
just bench # throughput vs. pycountry through map_elements
just precommit # every pre-commit hook (ruff, ty, pydoclint, cargo, mdformat, taplo)
just docs-serve # the documentation site, with live reload
just docs # build the site, failing on broken links
just check is what CI runs. Run it before opening a PR.
Rust code is formatted with cargo fmt (see rustfmt.toml) and linted with cargo clippy -- -D warnings. Python is formatted and linted with ruff, type-checked with ty, and docstrings are checked with pydoclint in NumPy style. Configuration for all of it lives in pyproject.toml.
Layout¶
| Path | What lives there |
|---|---|
src/fold.rs | Port of pycountry.remove_accents, plus the two normalizations the indices and fuzzy use |
src/db.rs | The four tables, their field indices in pycountry's order, and swapping them at runtime |
src/fuzzy.rs | Port of ExistingCountries.search_fuzzy — the four scoring passes |
src/resolve.rs | Exact → historic → fuzzy ordering, subdivision scoping, and the per-call memo |
src/lib.rs | Polars kernels, the rayon fan-out, the scalar Python functions |
src/data/ | The vendored ISO tables (LGPL-2.1-or-later — see NOTICE) and their VERSION stamp |
python/polars_pycountry/ | Expression wrappers, the .country namespace, and the table-refresh helpers |
tests/test_parity.py | The differential suite against pycountry |
tests/test_expr.py | Polars-level behavior: nulls, dtypes, scoping, composition, parallelism |
tests/test_data.py | Table provenance, replacing them in a running process, and rejecting bad ones |
docs/architecture/overview.md explains how this implementation maps onto pycountry's — including which of its surprising behaviors are load-bearing. Read it before touching src/db.rs or src/fuzzy.rs; none of it is obvious from the code alone.
The parity rule¶
Any change to matching behavior must keep tests/test_parity.py green, and that suite is not a formality: it sweeps every value in all four vendored tables and compares against pycountry itself, with both sides reading the same files.
This means some things you might expect to be welcome are not:
- "Fixing" a case where
pycountrylooks wrong.search_fuzzy("Republic of Korea")returns KP;search_fuzzyscans subdivision types and country codes, so"state"matches hundreds of records;lookupis accent-sensitive while fuzzy is not. All of that is reproduced on purpose. Report it upstream; if upstream changes, we follow. - Adding accent folding, punctuation stripping, or "the"/"republic of" removal to the exact path. The exact indices are
str.lower()and nothing else, because that is whatpycountry's are. Normalization belongs behindfuzzy=True. - Inventing a fuzzy search for currencies or the historic table.
pycountryhas neither, so there would be no reference to check the result against — which is exactly what this package exists to avoid.
Two divergences already exist and are asserted in the parity suite so they stay deliberate: blank input is null rather than "every country", and fuzzy covers countries and subdivisions only. Adding a third needs a strong argument, a test_parity.py assertion pinning it, and a README entry.
New behavior that pycountry has no opinion on (new convenience expressions, new output shapes) is fine and does not need a parity case — but it does need tests in tests/test_expr.py.
Refreshing the ISO tables¶
There are two refreshes, and they are not the same thing. This section is about the vendored snapshot — the tables compiled into the binary, which change what a released wheel returns:
just refresh-iso # downloads the current tables into src/data/
just check # the parity corpus re-derives itself from the new tables
The tables come from the Debian iso-codes project, which is also where pycountry gets them — it ships those bytes unchanged. Keep it that way. Taking both sides from the same source is what makes the parity suite mean anything; tests/test_data.py asserts the vendored files are byte-identical to the installed pycountry's, and that test failing is the signal to run just refresh-iso.
The script validates the shape and record count of every table before writing any of them, so a partial or failed download cannot leave the crate holding a mix of two snapshots. It also rewrites src/data/VERSION with the upstream release.
Users refreshing the tables in their running process is a separate mechanism — pc.refresh_iso_data() and pc.load_iso_data(), implemented in src/db.rs and python/polars_pycountry/_data.py. That path does not touch the vendored files and needs no rebuild. Both go through Db::from_texts, so the parse and record-floor checks cover them together.
A table refresh changes what the package returns, so it warrants a CHANGELOG entry and a version bump.
Documentation¶
The site is MkDocs + Material, published to https://andrewadlof.github.io/polars-pycountry/ from main by .github/workflows/docs.yml. Pull requests targeting main build it without deploying.
Two rules keep it from drifting out of date, and both are worth respecting:
- Prose is not duplicated. The narrative pages pull their content out of
README.mdbetween<!--usage-start-->-style markers, so the README a PyPI visitor reads and the page a site visitor reads are the same bytes. Edit the README; the site follows. If you move a marker,just docsfails. - The API reference is generated from the NumPy docstrings pydoclint already enforces, so it cannot describe a signature the code does not have. A new public expression needs no reference page — just an entry in
docs/reference.md.
mkdocstrings imports the package to read its docstrings, so the compiled extension must be present. uv sync builds it; after a Rust change run just dev first or the reference documents the previous build.
docs/*.md is excluded from mdformat (see .pre-commit-config.yaml). mdformat does not understand mkdocstrings' options: blocks or autorefs [text][target] links and silently breaks both. docs/architecture/ is plain Markdown and stays covered.
Cross-reference API objects with autorefs rather than a hand-written anchor, so the link survives a page being renamed:
Diagrams use Material's built-in mermaid support — a plain ```mermaid fence. The mkdocs-mermaid2 plugin is deliberately not installed; it conflicts with Material's own handling.
Branching model¶
Two long-lived branches:
| Branch | What it is |
|---|---|
development | Where work lands. The default branch, and the base for every PR. |
main | Released state. Only ever receives a promotion PR from development, and only tags cut from it are published. |
Everything else is short-lived and branches from development:
git switch development && git pull
git switch -c feature/subdivision-cache # or fix/…, docs/…, chore/…
Open the PR against development. Both branches are covered by a repository ruleset: a pull request is required, so neither can be pushed to directly, and commits must be signed — set up commit signing before your first PR or the merge will be blocked no matter how green the tests are.
A release promotes development to main — see Releasing.
Pull requests¶
Opening a PR pre-fills the pull request template — it is the checklist below in long form, including the parity questions. Delete any section that doesn't apply.
- Branch from
developmentand targetdevelopment. PRs againstmainare for releases only. - Add a bullet to the
## [Unreleased]section ofCHANGELOG.mdfor anything user-visible. Skip it for internal refactors, test-only changes, and CI tweaks. - Don't bump the version in your PR — that happens once, at release.
- Run
just checkand keep it green. CI does not run on PRs intodevelopment— it runs at the promotion boundary, on PRs intomain— so your local run is the only thing standing between a broken commit and the release branch. If you want a CI tick anyway, dispatch theCIworkflow against your branch from the Actions tab. - Explain why in the PR description. The what is visible in the diff.
Releasing¶
For maintainers. A release is a promotion of development to main, then a tag on main:
- On
development,just bump patch(orminor/major) and move the## [Unreleased]bullets inCHANGELOG.mdunder a new dated version heading. That is the only PR that touches the version.just bumpmoves all three places the version is recorded —pyproject.toml,Cargo.toml, andCargo.lock— and verifies they agree afterwards. - Rehearse the wheel matrix: run
release.ymlviaworkflow_dispatch. It builds and smoke-tests every platform and skips publishing, so the matrix can fail without burning a version number. - Open a PR from
developmenttomain, titled for the version. Merging it is the promotion. - Tag
main—just tagrefuses to run from any other branch, and re-runsjust version-checkfirst — which triggers the publishing workflow.
Tagging is the irreversible step: PyPI releases are immutable, and a broken wheel can only be yanked, never replaced. Everything before step 4 is reversible, which is why the rehearsal is worth the wait.
The local equivalent, for a wheel you do not intend to publish:
just bump patch # or minor / major
just build # release wheel + sdist into dist/
just publish # upload to PyPI
That builds a wheel for the host platform only. The real release is cut by CI: push a v* tag and .github/workflows/release.yml builds the full matrix — Linux glibc and musl on x86_64 and aarch64, macOS on Intel and Apple Silicon, Windows on x64 and arm64, plus an sdist — then uploads it to PyPI.
Run that workflow manually (workflow_dispatch) first. It builds and smoke-tests everything but skips publishing, so the matrix can be rehearsed without burning a version number — PyPI releases are immutable, and a broken wheel can only be yanked, never replaced.
Publishing credentials¶
CI publishes with Trusted Publishing (OIDC), so there is no stored token to leak, rotate, or forget to revoke. PyPI holds a publisher registered against four things, and the upload fails unless all four match:
| Owner | andrewadlof |
| Repository | polars-pycountry |
| Workflow filename | release.yml |
| Environment | pypi |
Register or edit it at https://pypi.org/manage/project/polars-pycountry/settings/publishing/. Renaming the repo, renaming release.yml, or renaming the environment breaks the match until the publisher is updated to agree — the failure surfaces at upload time as Trusted publishing exchange failure, after the whole wheel matrix has built.
The publish job needs permissions: id-token: write to mint the OIDC token, and must not pass a password: to pypa/gh-action-pypi-publish. An explicit password silently disables both Trusted Publishing and the PEP 740 attestations the action otherwise produces by default — which is what happened through 0.2.0.
Any reviewers or wait timer configured on the pypi environment still gate the upload; the environment is part of what PyPI authenticates, not just a place to keep things.
For a manual just publish, uv publish reads UV_PUBLISH_TOKEN from the environment:
read -rs UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN # not `export X=...`, again for history
just publish
just build uses --zig to link against an old glibc so the local wheel runs on manylinux2014 hosts without a container; CI builds inside the manylinux containers instead and does not need it. Run it through uv run (the justfile already does) — maturin locates zig via python -m ziglang, which only resolves with the project venv on PATH.
Code of conduct¶
Be decent to each other. Assume good faith, keep criticism about the code, and take heated disagreements to a cooling period rather than another comment. Maintainers may edit, lock, or remove contributions that don't meet that bar.
Questions¶
Open a discussion or issue. Bug reports that include the exact input string are answered fastest.