Lightweight value normalizers (emails, phones, US TINs, usernames) usable in any DataFrame or plain Python.
Project description
fyc-normalize
See a string, normalize a string. Lightweight value normalizers for cleaning data consistently across projects.
Each data type has one pure function that takes a single value and returns its
canonical string form, or None if the value is missing or unparseable. Because
the unit of work is a single value, the functions drop into pandas, polars, or
plain Python with no special integration. The only core dependency is PyYAML
(it loads the bundled email provider profiles); DataFrame and phone-parsing
support are optional extras.
Install
pip install fyc-normalize # core (PyYAML only)
pip install "fyc-normalize[phone]" # + phonenumbers, for E.164 phone parsing
pip install "fyc-normalize[domain]" # + idna, for full IDNA2008 domain Punycode
pip install "fyc-normalize[polars]" # + polars column helpers
pip install "fyc-normalize[pandas]" # + pandas column helpers
Usage
Every normalizer is a plain function: pass one value, get back its canonical
string form or None. Import what you need:
from fyc_normalize import (
normalize_email,
normalize_ani, # phone numbers (needs the [phone] extra)
normalize_us_tin, # SSN / ITIN / ATIN
normalize_ip,
normalize_cidr,
normalize_domain, # needs the [domain] extra for non-ASCII domains
normalize_username,
)
normalize_email("Foo.Bar+promo@Gmail.com") # "foobar@gmail.com"
normalize_ani("(415) 555-0123") # "+14155550123" (E.164)
normalize_us_tin("123-45-6789") # "123456789" (9-digit)
normalize_ip("2001:0DB8::0001") # "2001:db8::1" (RFC 5952)
normalize_cidr("192.168.1.5/24") # "192.168.1.0/24" (host bits masked)
normalize_domain("WWW.Café.COM.") # "xn--caf-dma.com" (lowercase Punycode)
normalize_username(" Admin ") # "admin" (NFKC + lower + trim)
Anything missing or unparseable returns None, so you can filter cleanly:
normalize_email("") # None
normalize_ip("999.1.1.1") # None
normalize_cidr("10.0.0.1") # None (a /prefix is required; use normalize_ip for bare addresses)
Try it from the Python REPL
$ uv run python
>>> from fyc_normalize import normalize_email, normalize_domain, normalize_cidr
>>> normalize_email(" Jane.Doe+news@GMAIL.com ")
'janedoe@gmail.com'
>>> normalize_domain("_dmarc.Example.com.")
'_dmarc.example.com'
>>> normalize_cidr("2001:db8::1/64")
'2001:db8::/64'
Or run the bundled demo, which exercises every normalizer on messy input:
uv run python examples/demo.py
In a DataFrame
Because each function takes a single value, it drops straight into pandas or polars:
df["email"] = df["email"].map(normalize_email) # pandas
df = df.with_columns(pl.col("email").map_elements(normalize_email)) # polars
Or use the optional column helpers (they auto-detect polars vs pandas):
from fyc_normalize.frames import normalize_email_column, normalize_ip_column
df = normalize_email_column(df, "email") # rewrites the "email" column in place
df = normalize_ip_column(df, "client_ip")
Scope: one job — see a string, normalize a string. Each function takes a single value and returns its canonical form. Nothing more.
Design
src/fyc_normalize/
├── base.py # BaseNormalizer + blank_to_none() shared pattern
├── emails.py # EmailNormalizer + normalize_email (profile-based)
├── anis.py # AniNormalizer + normalize_ani (E.164)
├── us_tins.py # UsTinNormalizer + normalize_us_tin (9-digit SSN/ITIN/ATIN)
├── ips.py # IpNormalizer/CidrNormalizer + normalize_ip / normalize_cidr
├── domains.py # DomainNormalizer + normalize_domain (lowercase Punycode)
├── usernames.py # UsernameNormalizer + normalize_username (NFKC)
├── frames.py # optional DataFrame column helpers (lazy polars/pandas)
├── configs/ # email_profiles.yaml — provider profiles (source of truth)
└── rules/ # the actual transformation logic, one class per type
Each type follows the same three-part shape: a Normalizer class, a
module-level singleton, and a pure normalize_* function. The substantive
logic lives in rules/ so behavior is easy to read, test, and change
independently of the wrappers.
Adding a new normalizer
- Add a rule class in
rules/<type>_rules.py. - Add
<type>s.pywith aNormalizersubclass, singleton, andnormalize_<type>function. - Export it in
__init__.pyand (optionally) add a column helper inframes.py.
Email profiles
Normalization rules are selected per provider by domain, defined in
configs/email_profiles.yaml (loaded automatically). A provider with no domains
is the generic fallback. Each rule name must exist on EmailNormalizingRules and
be registered in emails.RULE_REGISTRY. To customize, edit the bundled YAML or
point at your own copy:
from fyc_normalize.emails import EmailNormalizer
norm = EmailNormalizer(config_path="my_profiles.yaml")
norm.apply("Foo.Bar+promo@gmail.com") # "foobar@gmail.com"
Currently configured providers: gmail, protonmail, yahoo, fastmail (with its full alias-domain list), and a generic fallback for everything else.
Develop & test (uv)
This project uses uv. It manages the virtual environment, the Python version, and dependency installation for you — there is no venv to create or activate by hand.
Install uv if you
don't have it (curl -LsSf https://astral.sh/uv/install.sh | sh on macOS/Linux),
then from the project root:
uv sync # create .venv + install the package and the dev tooling
uv run pytest # run the test suite — expect "40 passed"
uv sync installs fyc-normalize itself (editable) plus the dev dependency
group (pytest, phonenumbers, idna). uv run executes the command inside that
managed environment, so you never source .../activate yourself.
Common commands:
uv sync --all-extras # also install polars + pandas (for the frame helpers)
uv run pytest -q # quiet test run
uv run python examples/demo.py # see every normalizer on sample input
uv run python # open a REPL with the package importable
uv pip list | grep fyc # confirm the project installed: fyc-normalize 0.1.0 (editable)
Troubleshooting — if
uv run pytestreportsModuleNotFoundError: No module named 'fyc_normalize', you almost certainly have an old, manually-activated virtual environment shadowing uv's. Reset it:deactivate # leave any activated venv (ignore "command not found") rm -rf .venv # drop the stale environment uv sync # uv recreates .venv AND installs the project uv run pytestWith uv you don't activate anything — always go through
uv run.
(Plain pip install -e ".[dev]" still works if you'd rather not use uv.)
Every push and pull request runs the test suite across Python 3.10–3.13 via
GitHub Actions (.github/workflows/ci.yml).
Releasing
Publishing is automated through GitHub Actions using PyPI Trusted Publishing (OIDC) — no API tokens or stored secrets.
Two separate things to keep straight:
- The publish workflow runs on every merge to
main(i.e. anydevelop→mainPR). It builds the sdist + wheel, runstwine check, and calls the upload step every time. - A new version is only released when you bump
versioninpyproject.toml. The upload usesskip-existing, so if the version already exists on PyPI (you merged without changing it), the upload is skipped and nothing else happens — the job still succeeds. If the version is new, that version is uploaded to PyPI and the workflow automatically creates the matchingv<version>git tag and a GitHub Release, with notes pulled from the matching section ofCHANGELOG.md.
In short: merging the PR is what runs the job; bumping the version number is what makes an actual release (PyPI + tag + GitHub Release) go out. You don't create tags or releases by hand — the workflow does it for you.
One-time setup (before the first publish):
- On PyPI, go to your account → Publishing → Add a pending publisher, and register:
- PyPI Project Name:
fyc-normalize - Owner / Repository: your GitHub org/repo
- Workflow name:
publish.yml - Environment name:
pypi
- PyPI Project Name:
- In the GitHub repo, create an environment named
pypi(Settings → Environments → New environment). Optionally add a required reviewer so each publish needs approval. - Protect
main(Settings → Branches) so changes land only via pull request. - Ensure Actions can create releases: Settings → Actions → General → Workflow
permissions should allow read and write (the workflow also requests
contents: writeexplicitly for the release job).
To ship a new version (work on develop, release via PR):
- Bump
versioninpyproject.toml. This is the one step that makes a new release happen — if you skip it, merging still runs the workflow but publishes nothing new. (pyproject.tomlis the single source of truth;fyc_normalize.__version__reads it from the installed package metadata.) - Move the
[Unreleased]notes into a new version section inCHANGELOG.md. - Open a PR from
developtomain; CI runs the test matrix on it. - Merge the PR. The push to
mainrunspublish.yml, which uploads the new version to PyPI and creates thev<version>tag + GitHub Release automatically.
Merges that don't touch the version (docs, refactors, etc.) are fine — the workflow runs, skips the upload, and skips the release because the tag already exists. No manual tagging needed either way.
To dry-run the build without publishing, trigger the workflow manually
(Actions → Publish to PyPI → Run workflow); the publish job is skipped on
manual runs.
Note: normalized US TINs (SSNs, ITINs, ...) are sensitive plaintext PII. Treat any table that stores them as sensitive.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fyc_normalize-0.1.0.tar.gz.
File metadata
- Download URL: fyc_normalize-0.1.0.tar.gz
- Upload date:
- Size: 60.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
90989fe4d0d314140df8409aa183f9188019ff78aeee4857e6cfdaf9140a6c06
|
|
| MD5 |
b620ce03c67015ac7f40700e534b333e
|
|
| BLAKE2b-256 |
928267e5615f92ea38234a2fa5c7a1957180ddd96d8f2576f32923f3ebc67a63
|
Provenance
The following attestation bundles were made for fyc_normalize-0.1.0.tar.gz:
Publisher:
publish.yml on finleyh/normalization-library
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fyc_normalize-0.1.0.tar.gz -
Subject digest:
90989fe4d0d314140df8409aa183f9188019ff78aeee4857e6cfdaf9140a6c06 - Sigstore transparency entry: 1720652098
- Sigstore integration time:
-
Permalink:
finleyh/normalization-library@6cf0e9a9bbf042fb65180912e6224465f69dea77 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/finleyh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6cf0e9a9bbf042fb65180912e6224465f69dea77 -
Trigger Event:
push
-
Statement type:
File details
Details for the file fyc_normalize-0.1.0-py3-none-any.whl.
File metadata
- Download URL: fyc_normalize-0.1.0-py3-none-any.whl
- Upload date:
- Size: 23.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ec3337d1cb461f2a81c6c63dc09eb2baca2c2cb8db97164f100bbf82fcdc7852
|
|
| MD5 |
dc36c1a7303f8fdc0b426afc0dd596d9
|
|
| BLAKE2b-256 |
33c646410cf4111de6b551ded6ba6f8d64625a8be5b794f79fbbe9be0b15bef0
|
Provenance
The following attestation bundles were made for fyc_normalize-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on finleyh/normalization-library
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fyc_normalize-0.1.0-py3-none-any.whl -
Subject digest:
ec3337d1cb461f2a81c6c63dc09eb2baca2c2cb8db97164f100bbf82fcdc7852 - Sigstore transparency entry: 1720652274
- Sigstore integration time:
-
Permalink:
finleyh/normalization-library@6cf0e9a9bbf042fb65180912e6224465f69dea77 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/finleyh
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6cf0e9a9bbf042fb65180912e6224465f69dea77 -
Trigger Event:
push
-
Statement type: