genderize-offline 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ coverage.xml
12
+ htmlcov/
13
+ # Add here any generated outputs or data files that hold personal data (e.g. downloaded pages)
14
+ # Never commit credentials
15
+ .env
16
+ .pypirc
17
+ *apikey*
18
+ # Dictionaries and benchmark inputs (large; names of real people)
19
+ *.tsv.gz
20
+ wgnd*.csv
21
+ test_gender*.csv
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-11-03
10
+
11
+ ### Added
12
+
13
+ - `guess(names, country)` and `genderize(names, country_id)` (genderize.io-shaped answer),
14
+ `country_code`, `tokens`, `p_female`.
15
+ - Command `genderize-offline` with `name`, `csv`, `fetch` and `build`.
16
+ - Compact dictionary built from WGND 2.0, published as the GitHub release `wgnd-2.0-r1`
17
+ and downloaded on first use with a SHA-256 check; fallback build from Harvard Dataverse.
18
+ - Benchmarks against genderize.io (`benchmarks/benchmark.py`, README).
19
+
20
+ [Unreleased]: https://github.com/restrepo/genderize-offline/compare/v0.1.0...HEAD
21
+ [0.1.0]: https://github.com/restrepo/genderize-offline/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Diego Restrepo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.5
2
+ Name: genderize-offline
3
+ Version: 0.1.0
4
+ Summary: Offline gender inference from first names and country, a free drop-in for genderize.io, using the World Gender Name Dictionary 2.0.
5
+ Project-URL: Homepage, https://github.com/restrepo/genderize-offline
6
+ Project-URL: Repository, https://github.com/restrepo/genderize-offline
7
+ Project-URL: Issues, https://github.com/restrepo/genderize-offline/issues
8
+ Project-URL: Changelog, https://github.com/restrepo/genderize-offline/blob/main/CHANGELOG.md
9
+ Author-email: Diego Restrepo <restrepo@udea.edu.co>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: WGND,bibliometrics,first names,gender,genderize,names,offline
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.2; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Requires-Dist: ruff==0.17.3; extra == 'dev'
25
+ Requires-Dist: twine>=5; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # genderize-offline
29
+
30
+ [![CI](https://github.com/restrepo/genderize-offline/actions/workflows/ci.yml/badge.svg)](https://github.com/restrepo/genderize-offline/actions/workflows/ci.yml)
31
+ [![PyPI](https://img.shields.io/pypi/v/genderize-offline.svg)](https://pypi.org/project/genderize-offline/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/genderize-offline.svg)](https://pypi.org/project/genderize-offline/)
33
+
34
+ Infer gender from first names and country **offline**: a free drop-in for
35
+ [genderize.io](https://genderize.io), with no API key, no quota and no network
36
+ after the first use. It uses the
37
+ [World Gender Name Dictionary 2.0](https://doi.org/10.7910/DVN/MSEGSJ) (WGND, Raffo 2021,
38
+ CC0), which gives the share of women and men for 4.9 million name–country pairs.
39
+ Standard library only.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install genderize-offline
45
+ genderize-offline fetch # once: downloads the dictionary (14 MB) into the cache
46
+ ```
47
+
48
+ ## Use
49
+
50
+ ```python
51
+ from genderize_offline import guess, genderize
52
+
53
+ guess("Natalia Andrea", "CO") # ('female', 1.0, 58)
54
+ guess("Andrea", "Italy") # ('male', 1.0, 77) country names or ISO2 codes
55
+ guess("Andrea", "CO") # ('female', 0.96, 77)
56
+ genderize("Andrea", "IT") # {'count': 77, 'name': 'Andrea', 'gender': 'male', 'probability': 1.0, 'country_id': 'IT'}
57
+ ```
58
+
59
+ `guess(names, country)` returns `(gender, probability, count)`: gender `male`, `female` or
60
+ `unknown`; probability 0.5–1 (0 for unknown); count, the number of countries that list the
61
+ name (genderize.io's count is people). `genderize()` answers in genderize.io's shape, with
62
+ `gender` `None` when unknown. As with genderize.io, set your own threshold: the benchmarks
63
+ below count a gender only at probability ≥ 0.9.
64
+
65
+ ```bash
66
+ genderize-offline name "María José" ES
67
+ genderize-offline csv authors.csv out.csv --name-col first_names --country-col country
68
+ ```
69
+
70
+ ### Method
71
+
72
+ - Names are lowercased and split on spaces, hyphens and dots; initials are dropped.
73
+ - The first given name found in the dictionary decides (genderize.io also uses the first word).
74
+ - The share of women is WGND's for that name in the given country; if the country is missing
75
+ or doesn't list the name, the average over every country that lists it is used.
76
+ - Accents are optional: `andres` finds `andrés`, `josé` finds `jose`.
77
+
78
+ ## Benchmarks
79
+
80
+ Run with `python benchmarks/benchmark.py LABELLED.csv [AUTHORS.json]` (threshold 0.9). The inputs
81
+ hold names of real people and are not in this repository; they are files of
82
+ [restrepo/inspireauth](https://github.com/restrepo/inspireauth).
83
+
84
+ **Hand-labelled names** (`data/test_gender.csv`: 3,965 names, 3,820 of them Colombian, with
85
+ their true gender and genderize.io's answer):
86
+
87
+ | | genderize.io | genderize-offline |
88
+ |---|---|---|
89
+ | Names given a gender (coverage) | 98.1% | 93.6% |
90
+ | Accuracy where a gender is given | 99.38% | 98.98% |
91
+
92
+ **INSPIRE-HEP authors with Latin American affiliations** (`data/inspire_LA.json`: 29,979
93
+ physicists, genders from genderize.io):
94
+
95
+ | genderize.io ↓ / offline → | male | female | unknown |
96
+ |---|---|---|---|
97
+ | male | 14,479 | 39 | 686 |
98
+ | female | 23 | 2,635 | 155 |
99
+ | unknown | 677 | 176 | 11,109 |
100
+
101
+ | | genderize.io | genderize-offline |
102
+ |---|---|---|
103
+ | Authors with a gender | 18,017 | 18,029 |
104
+ | Female share | 15.6% | 15.8% |
105
+
106
+ Where both give a gender (17,176 authors) they agree on **99.64%**.
107
+
108
+ **Effect on published results.** We recomputed the female fractions of *Statistical Analysis
109
+ of Scientific Metrics in High Energy, Cosmology, and Astroparticle Physics in Latin America*
110
+ (Morales-Alvarado, Quevedo, Ramos-Hamud, Restrepo) with offline genders. Every value moves
111
+ by less than its 95% margin:
112
+
113
+ | Female share by rank (Table 8) | genderize.io | offline |
114
+ |---|---|---|
115
+ | All ranks | 11.9% | 12.3% |
116
+ | Undergraduate, Master, PhD | 14.3% | 14.7% |
117
+ | Postdoc | 13.4% | 14.0% |
118
+ | Junior or Staff | 12.6% | 12.2% |
119
+ | Senior | 7.9% | 8.5% |
120
+
121
+ The female share per year (Fig. 21, 45 years with at least 100 authors) differs by −0.04
122
+ points on average and by at most 1.2 points.
123
+
124
+ These are the genders used to reproduce the paper's results:
125
+ the package gives the same gender to every one of the 29,979 authors.
126
+
127
+ **Speed** (one core, laptop): the dictionary loads in about 1 s; lookups run at about
128
+ 140,000 names/s.
129
+
130
+ The genders are estimates from names, not self-reported gender. Report them as such, and
131
+ cite WGND 2.0 (Raffo, J. (2021). WGND 2.0. Harvard Dataverse. doi:10.7910/DVN/MSEGSJ).
132
+
133
+ ## The dictionary and this repository
134
+
135
+ The 91 MB WGND CSV is not stored in git. Instead:
136
+
137
+ 1. **Built reproducibly.** `genderize-offline build wgnd.csv wgnd2_compact.tsv.gz` turns
138
+ WGND's `wgnd_2_0_name-gender-code.csv` into a compact table (one sorted line per name: average
139
+ share of women, number of countries, and the countries that differ from the average). The
140
+ same input gives the same bytes, so anyone can check the SHA-256.
141
+ 2. **Published as a release asset.** The manual workflow [Dictionary](.github/workflows/dictionary.yml)
142
+ downloads WGND from Harvard Dataverse, builds the table and attaches it with its
143
+ `SHA256SUMS` to a GitHub release of its own, `wgnd-2.0-rN`. These tags don't start
144
+ with `v`, so they never publish to PyPI, and they aren't marked latest.
145
+ 3. **Pinned in the code.** `dictionary.py` names the release and the SHA-256. On first use,
146
+ `fetch()` downloads the file to `~/.cache/genderize-offline/` (or `$GENDERIZE_OFFLINE_CACHE`)
147
+ and refuses it if the hash differs. If the release can't be reached, it builds the table
148
+ from Dataverse instead (`genderize-offline fetch --from-wgnd`).
149
+
150
+ To change the dictionary (a new WGND version or a new build rule), run the workflow with the
151
+ next tag, update `DICTIONARY_RELEASE` and `DICTIONARY_SHA256`, and release a new version.
152
+ Old releases keep their assets, so older package versions keep working.
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ python -m pip install -e ".[dev]"
158
+ python -m pytest
159
+ ruff check . && ruff format --check .
160
+ ```
161
+
162
+ Tests use a synthetic dictionary and need no network. Releases are published to PyPI from
163
+ GitHub Actions when a `v*` tag is pushed; see [PUBLISHING.md](PUBLISHING.md) and
164
+ [CHANGELOG.md](CHANGELOG.md).
165
+
166
+ ## License
167
+
168
+ Code: MIT. Dictionary: derived from WGND 2.0, CC0 1.0.
@@ -0,0 +1,141 @@
1
+ # genderize-offline
2
+
3
+ [![CI](https://github.com/restrepo/genderize-offline/actions/workflows/ci.yml/badge.svg)](https://github.com/restrepo/genderize-offline/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/genderize-offline.svg)](https://pypi.org/project/genderize-offline/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/genderize-offline.svg)](https://pypi.org/project/genderize-offline/)
6
+
7
+ Infer gender from first names and country **offline**: a free drop-in for
8
+ [genderize.io](https://genderize.io), with no API key, no quota and no network
9
+ after the first use. It uses the
10
+ [World Gender Name Dictionary 2.0](https://doi.org/10.7910/DVN/MSEGSJ) (WGND, Raffo 2021,
11
+ CC0), which gives the share of women and men for 4.9 million name–country pairs.
12
+ Standard library only.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install genderize-offline
18
+ genderize-offline fetch # once: downloads the dictionary (14 MB) into the cache
19
+ ```
20
+
21
+ ## Use
22
+
23
+ ```python
24
+ from genderize_offline import guess, genderize
25
+
26
+ guess("Natalia Andrea", "CO") # ('female', 1.0, 58)
27
+ guess("Andrea", "Italy") # ('male', 1.0, 77) country names or ISO2 codes
28
+ guess("Andrea", "CO") # ('female', 0.96, 77)
29
+ genderize("Andrea", "IT") # {'count': 77, 'name': 'Andrea', 'gender': 'male', 'probability': 1.0, 'country_id': 'IT'}
30
+ ```
31
+
32
+ `guess(names, country)` returns `(gender, probability, count)`: gender `male`, `female` or
33
+ `unknown`; probability 0.5–1 (0 for unknown); count, the number of countries that list the
34
+ name (genderize.io's count is people). `genderize()` answers in genderize.io's shape, with
35
+ `gender` `None` when unknown. As with genderize.io, set your own threshold: the benchmarks
36
+ below count a gender only at probability ≥ 0.9.
37
+
38
+ ```bash
39
+ genderize-offline name "María José" ES
40
+ genderize-offline csv authors.csv out.csv --name-col first_names --country-col country
41
+ ```
42
+
43
+ ### Method
44
+
45
+ - Names are lowercased and split on spaces, hyphens and dots; initials are dropped.
46
+ - The first given name found in the dictionary decides (genderize.io also uses the first word).
47
+ - The share of women is WGND's for that name in the given country; if the country is missing
48
+ or doesn't list the name, the average over every country that lists it is used.
49
+ - Accents are optional: `andres` finds `andrés`, `josé` finds `jose`.
50
+
51
+ ## Benchmarks
52
+
53
+ Run with `python benchmarks/benchmark.py LABELLED.csv [AUTHORS.json]` (threshold 0.9). The inputs
54
+ hold names of real people and are not in this repository; they are files of
55
+ [restrepo/inspireauth](https://github.com/restrepo/inspireauth).
56
+
57
+ **Hand-labelled names** (`data/test_gender.csv`: 3,965 names, 3,820 of them Colombian, with
58
+ their true gender and genderize.io's answer):
59
+
60
+ | | genderize.io | genderize-offline |
61
+ |---|---|---|
62
+ | Names given a gender (coverage) | 98.1% | 93.6% |
63
+ | Accuracy where a gender is given | 99.38% | 98.98% |
64
+
65
+ **INSPIRE-HEP authors with Latin American affiliations** (`data/inspire_LA.json`: 29,979
66
+ physicists, genders from genderize.io):
67
+
68
+ | genderize.io ↓ / offline → | male | female | unknown |
69
+ |---|---|---|---|
70
+ | male | 14,479 | 39 | 686 |
71
+ | female | 23 | 2,635 | 155 |
72
+ | unknown | 677 | 176 | 11,109 |
73
+
74
+ | | genderize.io | genderize-offline |
75
+ |---|---|---|
76
+ | Authors with a gender | 18,017 | 18,029 |
77
+ | Female share | 15.6% | 15.8% |
78
+
79
+ Where both give a gender (17,176 authors) they agree on **99.64%**.
80
+
81
+ **Effect on published results.** We recomputed the female fractions of *Statistical Analysis
82
+ of Scientific Metrics in High Energy, Cosmology, and Astroparticle Physics in Latin America*
83
+ (Morales-Alvarado, Quevedo, Ramos-Hamud, Restrepo) with offline genders. Every value moves
84
+ by less than its 95% margin:
85
+
86
+ | Female share by rank (Table 8) | genderize.io | offline |
87
+ |---|---|---|
88
+ | All ranks | 11.9% | 12.3% |
89
+ | Undergraduate, Master, PhD | 14.3% | 14.7% |
90
+ | Postdoc | 13.4% | 14.0% |
91
+ | Junior or Staff | 12.6% | 12.2% |
92
+ | Senior | 7.9% | 8.5% |
93
+
94
+ The female share per year (Fig. 21, 45 years with at least 100 authors) differs by −0.04
95
+ points on average and by at most 1.2 points.
96
+
97
+ These are the genders used to reproduce the paper's results:
98
+ the package gives the same gender to every one of the 29,979 authors.
99
+
100
+ **Speed** (one core, laptop): the dictionary loads in about 1 s; lookups run at about
101
+ 140,000 names/s.
102
+
103
+ The genders are estimates from names, not self-reported gender. Report them as such, and
104
+ cite WGND 2.0 (Raffo, J. (2021). WGND 2.0. Harvard Dataverse. doi:10.7910/DVN/MSEGSJ).
105
+
106
+ ## The dictionary and this repository
107
+
108
+ The 91 MB WGND CSV is not stored in git. Instead:
109
+
110
+ 1. **Built reproducibly.** `genderize-offline build wgnd.csv wgnd2_compact.tsv.gz` turns
111
+ WGND's `wgnd_2_0_name-gender-code.csv` into a compact table (one sorted line per name: average
112
+ share of women, number of countries, and the countries that differ from the average). The
113
+ same input gives the same bytes, so anyone can check the SHA-256.
114
+ 2. **Published as a release asset.** The manual workflow [Dictionary](.github/workflows/dictionary.yml)
115
+ downloads WGND from Harvard Dataverse, builds the table and attaches it with its
116
+ `SHA256SUMS` to a GitHub release of its own, `wgnd-2.0-rN`. These tags don't start
117
+ with `v`, so they never publish to PyPI, and they aren't marked latest.
118
+ 3. **Pinned in the code.** `dictionary.py` names the release and the SHA-256. On first use,
119
+ `fetch()` downloads the file to `~/.cache/genderize-offline/` (or `$GENDERIZE_OFFLINE_CACHE`)
120
+ and refuses it if the hash differs. If the release can't be reached, it builds the table
121
+ from Dataverse instead (`genderize-offline fetch --from-wgnd`).
122
+
123
+ To change the dictionary (a new WGND version or a new build rule), run the workflow with the
124
+ next tag, update `DICTIONARY_RELEASE` and `DICTIONARY_SHA256`, and release a new version.
125
+ Old releases keep their assets, so older package versions keep working.
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ python -m pip install -e ".[dev]"
131
+ python -m pytest
132
+ ruff check . && ruff format --check .
133
+ ```
134
+
135
+ Tests use a synthetic dictionary and need no network. Releases are published to PyPI from
136
+ GitHub Actions when a `v*` tag is pushed; see [PUBLISHING.md](PUBLISHING.md) and
137
+ [CHANGELOG.md](CHANGELOG.md).
138
+
139
+ ## License
140
+
141
+ Code: MIT. Dictionary: derived from WGND 2.0, CC0 1.0.
@@ -0,0 +1,77 @@
1
+ """Benchmarks of genderize-offline against genderize.io (numbers in README.md).
2
+
3
+ Usage: python benchmarks/benchmark.py LABELLED.csv [AUTHORS.json] [--threshold 0.9]
4
+
5
+ LABELLED.csv: hand-labelled names with columns names, gender (the true one),
6
+ country_id, and genderize.io's answer in Gender and Gender Probability
7
+ (as `data/test_gender.csv` of restrepo/inspireauth).
8
+ AUTHORS.json: records with first_names, country, author_id and genderize.io's
9
+ gender and gender_probability (as `data/inspire_LA.json` of restrepo/inspireauth).
10
+ """
11
+
12
+ import argparse
13
+ import csv
14
+ import json
15
+ import time
16
+ from collections import Counter
17
+
18
+ from genderize_offline import core, dictionary, guess
19
+
20
+
21
+ def scores(rows, thr):
22
+ known = [(t, g) for t, g, p in rows if g in ("male", "female") and p >= thr]
23
+ ok = sum(t == g for t, g in known)
24
+ return f"coverage {100 * len(known) / len(rows):.1f}%, accuracy {100 * ok / len(known):.2f}% ({len(known)} names)"
25
+
26
+
27
+ def main(argv=None):
28
+ ap = argparse.ArgumentParser()
29
+ ap.add_argument("labelled")
30
+ ap.add_argument("authors", nargs="?")
31
+ ap.add_argument("--threshold", type=float, default=0.9)
32
+ a = ap.parse_args(argv)
33
+ thr = a.threshold
34
+
35
+ t0 = time.perf_counter()
36
+ core.use_table(dictionary.load())
37
+ print(f"load: {time.perf_counter() - t0:.1f} s, {len(core.table())} names and aliases")
38
+
39
+ with open(a.labelled, newline="", encoding="utf-8") as f:
40
+ rows = list(csv.DictReader(f))
41
+ gz = [(r["gender"], r["Gender"] or "unknown", float(r["Gender Probability"] or 0)) for r in rows]
42
+ t0 = time.perf_counter()
43
+ og = [(r["gender"], *guess(r["names"], r["country_id"])[:2]) for r in rows]
44
+ dt = time.perf_counter() - t0
45
+ print(f"\nhand-labelled names: {len(rows)}, threshold {thr} ({len(rows) / dt:,.0f} names/s)")
46
+ print(f" genderize.io {scores(gz, thr)}")
47
+ print(f" genderize-offline {scores(og, thr)}")
48
+
49
+ if not a.authors:
50
+ return 0
51
+ with open(a.authors, encoding="utf-8") as f:
52
+ records = json.load(f)
53
+ first = {}
54
+ for r in records:
55
+ first.setdefault(r["author_id"], r)
56
+ pairs = Counter()
57
+ for r in first.values():
58
+ g0 = r["gender"] if r["gender_probability"] >= thr else "unknown"
59
+ g, p, _ = guess(r.get("first_names", ""), r.get("country", ""))
60
+ pairs[g0, g if p >= thr else "unknown"] += 1
61
+ ks = ["male", "female", "unknown"]
62
+ print(f"\nauthors: {len(first)}; rows genderize.io, columns genderize-offline")
63
+ print(" " * 10 + "".join(f"{k:>10}" for k in ks))
64
+ for x in ks:
65
+ print(f"{x:>10}" + "".join(f"{pairs[x, y]:>10}" for y in ks))
66
+ both = sum(pairs[x, y] for x in ks[:2] for y in ks[:2])
67
+ agree = pairs["male", "male"] + pairs["female", "female"]
68
+ print(f"agreement where both give a gender: {100 * agree / both:.2f}% of {both}")
69
+ for name, i in [("genderize.io", 0), ("genderize-offline", 1)]:
70
+ fe = sum(v for k, v in pairs.items() if k[i] == "female")
71
+ ma = sum(v for k, v in pairs.items() if k[i] == "male")
72
+ print(f" {name:18s} {fe + ma} with a gender, female {100 * fe / (fe + ma):.1f}%")
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24", "hatch-vcs>=0.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "genderize-offline"
7
+ dynamic = ["version"]
8
+ description = "Offline gender inference from first names and country, a free drop-in for genderize.io, using the World Gender Name Dictionary 2.0."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Diego Restrepo", email = "restrepo@udea.edu.co" }]
14
+ keywords = ["gender", "genderize", "names", "first names", "WGND", "bibliometrics", "offline"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8", "pytest-cov>=5", "ruff==0.17.3", "build>=1.2", "twine>=5"]
27
+
28
+ [project.scripts]
29
+ genderize-offline = "genderize_offline.cli:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/restrepo/genderize-offline"
33
+ Repository = "https://github.com/restrepo/genderize-offline"
34
+ Issues = "https://github.com/restrepo/genderize-offline/issues"
35
+ Changelog = "https://github.com/restrepo/genderize-offline/blob/main/CHANGELOG.md"
36
+
37
+ # Version from the git tag (v0.1.0 -> 0.1.0); commits after a tag get 0.1.1.devN
38
+ [tool.hatch.version]
39
+ source = "vcs"
40
+
41
+ [tool.hatch.version.raw-options]
42
+ local_scheme = "no-local-version" # PyPI and TestPyPI reject local versions (+g1234abc)
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = ["src/", "tests/", "benchmarks/", "README.md", "CHANGELOG.md", "LICENSE"]
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/genderize_offline"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ addopts = "-ra --strict-markers"
53
+
54
+ [tool.coverage.run]
55
+ source = ["genderize_offline"]
56
+ branch = true
57
+
58
+ [tool.ruff]
59
+ line-length = 120
60
+ target-version = "py310"
61
+ src = ["src", "tests"]
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
@@ -0,0 +1,12 @@
1
+ """Offline gender inference from first names and country, a free drop-in for genderize.io (WGND 2.0)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .core import country_code, genderize, guess, p_female, tokens, use_table
6
+
7
+ try:
8
+ __version__ = version("genderize-offline")
9
+ except PackageNotFoundError: # running from a source tree that was never installed
10
+ __version__ = "0.0.0"
11
+
12
+ __all__ = ["__version__", "country_code", "genderize", "guess", "p_female", "tokens", "use_table"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,58 @@
1
+ """Command line entry point: `genderize-offline`."""
2
+
3
+ import argparse
4
+ import csv
5
+ import json
6
+ import sys
7
+
8
+ from . import __version__, dictionary
9
+ from .core import guess
10
+
11
+
12
+ def main(argv=None):
13
+ ap = argparse.ArgumentParser(
14
+ prog="genderize-offline",
15
+ description="Offline gender inference from first names and country, a free drop-in for genderize.io, "
16
+ "using the World Gender Name Dictionary 2.0.",
17
+ )
18
+ ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
19
+ sub = ap.add_subparsers(dest="cmd", required=True)
20
+ p = sub.add_parser("name", help="gender of one name, as JSON")
21
+ p.add_argument("names")
22
+ p.add_argument("country", nargs="?", default="", help="ISO2 code or country name")
23
+ p = sub.add_parser("csv", help="add gender columns to a CSV")
24
+ p.add_argument("src")
25
+ p.add_argument("dst")
26
+ p.add_argument("--name-col", default="name")
27
+ p.add_argument("--country-col", default="country_id")
28
+ p.add_argument("--prefix", default="", help="prefix of the added columns gender, probability, count")
29
+ p = sub.add_parser("fetch", help="download the dictionary into the cache")
30
+ p.add_argument("--from-wgnd", action="store_true", help="build it from the original WGND CSV instead")
31
+ p = sub.add_parser("build", help="build the compact dictionary from WGND's name-gender-code CSV")
32
+ p.add_argument("src")
33
+ p.add_argument("dst")
34
+ a = ap.parse_args(argv)
35
+
36
+ if a.cmd == "name":
37
+ g, p_, n = guess(a.names, a.country)
38
+ print(json.dumps({"name": a.names, "gender": g, "probability": p_, "count": n}, ensure_ascii=False))
39
+ elif a.cmd == "csv":
40
+ with open(a.src, newline="", encoding="utf-8") as f:
41
+ rows = list(csv.DictReader(f))
42
+ cols = [f"{a.prefix}gender", f"{a.prefix}probability", f"{a.prefix}count"]
43
+ with open(a.dst, "w", newline="", encoding="utf-8") as f:
44
+ w = csv.DictWriter(f, list(rows[0]) + cols if rows else cols)
45
+ w.writeheader()
46
+ for r in rows:
47
+ r.update(zip(cols, guess(r.get(a.name_col, ""), r.get(a.country_col, "")), strict=True))
48
+ w.writerow(r)
49
+ elif a.cmd == "fetch":
50
+ print(dictionary.fetch(from_wgnd=a.from_wgnd))
51
+ elif a.cmd == "build":
52
+ n = dictionary.build(a.src, a.dst)
53
+ print(f"{a.dst}: {n} names, sha256 {dictionary.sha256(a.dst)}", file=sys.stderr)
54
+ return 0
55
+
56
+
57
+ if __name__ == "__main__":
58
+ raise SystemExit(main())
@@ -0,0 +1,87 @@
1
+ """Gender of a first name in a country, like genderize.io."""
2
+
3
+ import json
4
+ import re
5
+ from importlib.resources import files
6
+
7
+ from . import dictionary
8
+
9
+ COUNTRIES = json.loads(files(__package__).joinpath("country_iso2.json").read_text(encoding="utf-8"))
10
+
11
+ _table = None
12
+
13
+
14
+ def table():
15
+ """The dictionary, loaded on first use."""
16
+ global _table
17
+ if _table is None:
18
+ _table = dictionary.load()
19
+ return _table
20
+
21
+
22
+ def use_table(t):
23
+ """Use another dictionary (`dictionary.load(path)` or a dict of the same shape), e.g. in tests."""
24
+ global _table
25
+ _table = t
26
+
27
+
28
+ def country_code(country):
29
+ """ISO2 code for an ISO2 code or a country name (INSPIRE/English spelling); '' if unknown."""
30
+ if not country:
31
+ return ""
32
+ c = country.strip()
33
+ if len(c) == 2 and c.isalpha():
34
+ return c.upper()
35
+ return COUNTRIES.get(c) or COUNTRIES.get(c.lower(), "")
36
+
37
+
38
+ def tokens(names):
39
+ """Lowercase given names, without initials, punctuation or hyphens."""
40
+ names = re.sub(r'[|†•()"]', " ", names or "").replace("-", " ").replace(".", " ")
41
+ return [t.lower() for t in names.split() if len(t) > 1]
42
+
43
+
44
+ def p_female(name, country=""):
45
+ """(share of women, countries listing the name) for one name, or None if it is not in the dictionary."""
46
+ t = table()
47
+ e = t.get(name) or t.get(dictionary.strip_accents(name))
48
+ if not e:
49
+ return None
50
+ world, n, extra = e
51
+ code = country_code(country)
52
+ if code and code + ":" in extra:
53
+ for item in extra.split(","):
54
+ c, p = item.split(":")
55
+ if c == code:
56
+ return float(p), n
57
+ return world, n
58
+
59
+
60
+ def guess(names, country=""):
61
+ """(gender, probability, count) for given names and a country (ISO2 or name).
62
+
63
+ The first given name found in the dictionary decides. gender is 'male',
64
+ 'female' or 'unknown'; probability is 0.5-1 (0 for unknown); count is the
65
+ number of countries listing the name.
66
+ """
67
+ for tok in tokens(names):
68
+ r = p_female(tok, country)
69
+ if r is None:
70
+ continue
71
+ pf, n = r
72
+ if pf == 0.5:
73
+ return "unknown", 0.0, n
74
+ return ("female", round(pf, 2), n) if pf > 0.5 else ("male", round(1 - pf, 2), n)
75
+ return "unknown", 0.0, 0
76
+
77
+
78
+ def genderize(names, country_id=None):
79
+ """Answer shaped like genderize.io's: {name, gender, probability, count, country_id}.
80
+
81
+ gender is None when unknown, as in genderize.io.
82
+ """
83
+ g, p, n = guess(names, country_id or "")
84
+ out = {"count": n, "name": names, "gender": None if g == "unknown" else g, "probability": p}
85
+ if country_id:
86
+ out["country_id"] = country_id
87
+ return out
@@ -0,0 +1,110 @@
1
+ {
2
+ "Venezuela": "VE",
3
+ "Chile": "CL",
4
+ "Rwanda": "RW",
5
+ "South Africa": "ZA",
6
+ "Spain": "ES",
7
+ "United States": "US",
8
+ "France": "FR",
9
+ "Switzerland": "CH",
10
+ "Italy": "IT",
11
+ "Germany": "DE",
12
+ "Peru": "PE",
13
+ "Argentina": "AR",
14
+ "Uruguay": "UY",
15
+ "Brazil": "BR",
16
+ "Ecuador": "EC",
17
+ "United Kingdom": "GB",
18
+ "Colombia": "CO",
19
+ "Israel": "IL",
20
+ "Mexico": "MX",
21
+ "Netherlands": "NL",
22
+ "Canada": "CA",
23
+ "Sweden": "SE",
24
+ "Iran, Islamic Republic of": "IR",
25
+ "India": "IN",
26
+ "Belarus": "BY",
27
+ "Russian Federation": "RU",
28
+ "Hungary": "HU",
29
+ "Slovakia": "SK",
30
+ "Greece": "GR",
31
+ "Cuba": "CU",
32
+ "Australia": "AU",
33
+ "Kuwait": "KW",
34
+ "Slovenia": "SI",
35
+ "Finland": "FI",
36
+ "Japan": "JP",
37
+ "Ukraine": "UA",
38
+ "Serbia and Montenegro": "RS",
39
+ "Turkey": "TR",
40
+ "Portugal": "PT",
41
+ "Armenia": "AM",
42
+ "Korea, Republic of": "KR",
43
+ "China": "CN",
44
+ "Belgium": "BE",
45
+ "Poland": "PL",
46
+ "Denmark": "DK",
47
+ "Czechia": "CZ",
48
+ "Romania": "RO",
49
+ "Croatia": "HR",
50
+ "Dominican Republic": "DO",
51
+ "Guatemala": "GT",
52
+ "Paraguay": "PY",
53
+ "Panama": "PA",
54
+ "Honduras": "HN",
55
+ "Philippines": "PH",
56
+ "El Salvador": "SV",
57
+ "Pakistan": "PK",
58
+ "Oman": "OM",
59
+ "Costa Rica": "CR",
60
+ "New Zealand": "NZ",
61
+ "Georgia": "GE",
62
+ "Bolivia": "BO",
63
+ "Malta": "MT",
64
+ "Austria": "AT",
65
+ "Norway": "NO",
66
+ "Egypt": "EG",
67
+ "Taiwan": "TW",
68
+ "Puerto Rico": "PR",
69
+ "United Arab Emirates": "AE",
70
+ "Jamaica": "JM",
71
+ "Lithuania": "LT",
72
+ "Indonesia": "ID",
73
+ "Luxembourg": "LU",
74
+ "Estonia": "EE",
75
+ "Ethiopia": "ET",
76
+ "Thailand": "TH",
77
+ "Cyprus": "CY",
78
+ "Bulgaria": "BG",
79
+ "Iceland": "IS",
80
+ "North Macedonia": "MK",
81
+ "Ireland": "IE",
82
+ "Lebanon": "LB",
83
+ "Vietnam": "VN",
84
+ "Bangladesh": "BD",
85
+ "Kazakhstan": "KZ",
86
+ "Morocco": "MA",
87
+ "Uzbekistan": "UZ",
88
+ "Benin": "BJ",
89
+ "Iraq": "IQ",
90
+ "Azerbaijan": "AZ",
91
+ "Nigeria": "NG",
92
+ "Jordan": "JO",
93
+ "Ghana": "GH",
94
+ "Algeria": "DZ",
95
+ "Singapore": "SG",
96
+ "Niger": "NE",
97
+ "Malaysia": "MY",
98
+ "Latvia": "LV",
99
+ "Saudi Arabia": "SA",
100
+ "Madagascar": "MG",
101
+ "Uganda": "UG",
102
+ "colombia": "CO",
103
+ "brazil": "BR",
104
+ "venezuela": "VE",
105
+ "mexico": "MX",
106
+ "cuba": "CU",
107
+ "chile": "CL",
108
+ "peru": "PE",
109
+ "argentina": "AR"
110
+ }
@@ -0,0 +1,134 @@
1
+ """The name dictionary: a compact table derived from WGND 2.0.
2
+
3
+ WGND 2.0 (World Gender Name Dictionary, Raffo 2021, Harvard Dataverse,
4
+ doi:10.7910/DVN/MSEGSJ, CC0) gives, for each (name, ISO2 country), the share
5
+ of women and men. The compact table keeps one line per name::
6
+
7
+ name <TAB> p_female averaged over its countries <TAB> number of countries <TAB> CC:p,CC:p,...
8
+
9
+ where only countries whose share differs from the average are listed. It is
10
+ built with `build()` from the original CSV and published as a GitHub release
11
+ asset of this repository (tag `wgnd-2.0-rN`); `load()` downloads it once,
12
+ checks its SHA-256 and caches it.
13
+ """
14
+
15
+ import bisect
16
+ import csv
17
+ import gzip
18
+ import hashlib
19
+ import os
20
+ import shutil
21
+ import tempfile
22
+ import unicodedata
23
+ import urllib.request
24
+ from collections import defaultdict
25
+ from pathlib import Path
26
+
27
+ WGND_URL = "https://dataverse.harvard.edu/api/access/datafile/4750348?format=original"
28
+
29
+ DICTIONARY_RELEASE = "wgnd-2.0-r1"
30
+ DICTIONARY_FILE = "wgnd2_compact.tsv.gz"
31
+ DICTIONARY_URL = (
32
+ f"https://github.com/restrepo/genderize-offline/releases/download/{DICTIONARY_RELEASE}/{DICTIONARY_FILE}"
33
+ )
34
+ DICTIONARY_SHA256 = "c9020732db57856a45f5bdb469b971a5ad3febf684eef0e6256757b61a07ffaf"
35
+
36
+
37
+ def cache_dir():
38
+ """`$GENDERIZE_OFFLINE_CACHE`, else `~/.cache/genderize-offline`."""
39
+ return Path(os.environ.get("GENDERIZE_OFFLINE_CACHE") or Path.home() / ".cache" / "genderize-offline")
40
+
41
+
42
+ def strip_accents(s):
43
+ return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
44
+
45
+
46
+ def sha256(path):
47
+ h = hashlib.sha256()
48
+ with open(path, "rb") as f:
49
+ for block in iter(lambda: f.read(1 << 20), b""):
50
+ h.update(block)
51
+ return h.hexdigest()
52
+
53
+
54
+ def build(src_csv, dst_gz):
55
+ """Write the compact table from WGND's `wgnd_2_0_name-gender-code.csv`. Returns the number of names."""
56
+ raw = defaultdict(lambda: defaultdict(lambda: [0.0, 0.0]))
57
+ with open(src_csv, newline="", encoding="utf-8") as f:
58
+ for r in csv.DictReader(f):
59
+ if r["gender"] in ("F", "M"):
60
+ raw[r["name"].strip().lower()][r["code"]][r["gender"] == "M"] += float(r["wgt"] or 0)
61
+ rows = {}
62
+ for name, by_code in raw.items():
63
+ by = {c: fm[0] / (fm[0] + fm[1]) for c, fm in by_code.items() if fm[0] + fm[1] > 0}
64
+ if not by or not name or "\t" in name or "\n" in name:
65
+ continue
66
+ world = round(sum(by.values()) / len(by), 6)
67
+ extra = ",".join(f"{c}:{round(p, 6):g}" for c, p in sorted(by.items()) if c and round(p, 6) != world)
68
+ rows[name] = f"{world:g}\t{sum(1 for c in by if c)}\t{extra}"
69
+ # an accent-free spelling not in WGND gets the entry of the first accented one (andres -> andrés)
70
+ for name in sorted(rows):
71
+ rows.setdefault(strip_accents(name), rows[name])
72
+ lines = sorted(f"{name}\t{v}" for name, v in rows.items())
73
+ # mtime=0 so the same input gives the same bytes (and SHA-256)
74
+ with open(dst_gz, "wb") as raw_out, gzip.GzipFile(filename="", fileobj=raw_out, mode="wb", mtime=0) as gz:
75
+ gz.write(("\n".join(lines) + "\n").encode("utf-8"))
76
+ return len(lines)
77
+
78
+
79
+ def download(url, dst):
80
+ dst.parent.mkdir(parents=True, exist_ok=True)
81
+ with tempfile.NamedTemporaryFile(dir=dst.parent, delete=False) as tmp, urllib.request.urlopen(url) as r:
82
+ shutil.copyfileobj(r, tmp)
83
+ os.replace(tmp.name, dst)
84
+
85
+
86
+ def fetch(from_wgnd=False):
87
+ """Make sure the compact table is in the cache and return its path.
88
+
89
+ By default it is downloaded from this repository's release and checked
90
+ against `DICTIONARY_SHA256`; with `from_wgnd=True` (or if the release is
91
+ unreachable) it is built from the original WGND CSV on Harvard Dataverse.
92
+ """
93
+ path = cache_dir() / DICTIONARY_FILE
94
+ if path.exists() and (from_wgnd or sha256(path) == DICTIONARY_SHA256):
95
+ return path
96
+ if not from_wgnd:
97
+ try:
98
+ download(DICTIONARY_URL, path)
99
+ if sha256(path) == DICTIONARY_SHA256:
100
+ return path
101
+ path.unlink()
102
+ except OSError:
103
+ pass
104
+ src = cache_dir() / "wgnd_2_0_name-gender-code.csv"
105
+ if not src.exists():
106
+ download(WGND_URL, src)
107
+ build(src, path)
108
+ return path
109
+
110
+
111
+ class Table:
112
+ """The compact table in memory: sorted lines, looked up by bisection."""
113
+
114
+ def __init__(self, lines):
115
+ self.lines = lines
116
+
117
+ def __len__(self):
118
+ return len(self.lines)
119
+
120
+ def get(self, name, default=None):
121
+ key = name + "\t"
122
+ i = bisect.bisect_left(self.lines, key)
123
+ if i < len(self.lines) and self.lines[i].startswith(key):
124
+ _, world, n, extra = self.lines[i].split("\t")
125
+ return float(world), int(n), extra
126
+ return default
127
+
128
+
129
+ def load(path=None):
130
+ """The dictionary as a `Table`: `.get(name)` → (p_female world, n_countries, "CC:p,...") or None."""
131
+ path = Path(path) if path else fetch()
132
+ with open(path, "rb") as f:
133
+ text = gzip.decompress(f.read()).decode("utf-8")
134
+ return Table(text.splitlines())
File without changes
@@ -0,0 +1,35 @@
1
+ import pytest
2
+
3
+ from genderize_offline import core, dictionary
4
+
5
+ # A synthetic WGND extract: made-up spellings and shares, not real data.
6
+ WGND = """name,code,gender,wgt
7
+ zorana,AA,F,1
8
+ zorana,BB,F,.8
9
+ zorana,BB,M,.2
10
+ tomislavo,AA,M,1
11
+ kelandre,AA,F,.6
12
+ kelandre,AA,M,.4
13
+ kelandre,BB,M,.9
14
+ kelandre,BB,F,.1
15
+ mirén,AA,F,1
16
+ ambo,AA,F,.5
17
+ ambo,AA,M,.5
18
+ """
19
+
20
+
21
+ @pytest.fixture(scope="session")
22
+ def compact(tmp_path_factory):
23
+ d = tmp_path_factory.mktemp("wgnd")
24
+ src = d / "wgnd.csv"
25
+ src.write_text(WGND, encoding="utf-8")
26
+ dst = d / "compact.tsv.gz"
27
+ dictionary.build(src, dst)
28
+ return dst
29
+
30
+
31
+ @pytest.fixture(autouse=True)
32
+ def table(compact):
33
+ core.use_table(dictionary.load(compact))
34
+ yield
35
+ core.use_table(None)
@@ -0,0 +1,38 @@
1
+ import csv
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from genderize_offline import __version__
7
+ from genderize_offline.cli import main
8
+
9
+
10
+ def test_version(capsys):
11
+ with pytest.raises(SystemExit) as e:
12
+ main(["--version"])
13
+ assert e.value.code == 0
14
+ assert __version__ in capsys.readouterr().out
15
+
16
+
17
+ def test_name(capsys):
18
+ assert main(["name", "Kelandre", "BB"]) == 0
19
+ assert json.loads(capsys.readouterr().out) == {"name": "Kelandre", "gender": "male", "probability": 0.9, "count": 2}
20
+
21
+
22
+ def test_csv(tmp_path):
23
+ src = tmp_path / "in.csv"
24
+ src.write_text("name,country_id\nZorana,BB\nXyzzy,AA\n", encoding="utf-8")
25
+ dst = tmp_path / "out.csv"
26
+ assert main(["csv", str(src), str(dst)]) == 0
27
+ with open(dst, newline="", encoding="utf-8") as f:
28
+ rows = list(csv.DictReader(f))
29
+ assert [(r["gender"], r["probability"]) for r in rows] == [("female", "0.8"), ("unknown", "0.0")]
30
+
31
+
32
+ def test_build(tmp_path, capsys):
33
+ from conftest import WGND
34
+
35
+ src = tmp_path / "w.csv"
36
+ src.write_text(WGND, encoding="utf-8")
37
+ assert main(["build", str(src), str(tmp_path / "c.tsv.gz")]) == 0
38
+ assert "sha256" in capsys.readouterr().err
@@ -0,0 +1,65 @@
1
+ import gzip
2
+
3
+ from genderize_offline import country_code, dictionary, genderize, guess, tokens
4
+
5
+
6
+ def test_build_is_reproducible(compact, tmp_path):
7
+ src = tmp_path / "w.csv"
8
+ from conftest import WGND
9
+
10
+ src.write_text(WGND, encoding="utf-8")
11
+ again = tmp_path / "again.tsv.gz"
12
+ dictionary.build(src, again)
13
+ assert dictionary.sha256(again) == dictionary.sha256(compact)
14
+
15
+
16
+ def test_compact_lines(compact):
17
+ lines = gzip.decompress(compact.read_bytes()).decode().splitlines()
18
+ assert lines == sorted(lines)
19
+ assert "zorana\t0.9\t2\tAA:1,BB:0.8" in lines
20
+ assert "miren\t1\t1\t" in lines # accent-free alias
21
+
22
+
23
+ def test_country_first_then_average():
24
+ assert guess("Zorana", "BB") == ("female", 0.8, 2)
25
+ assert guess("Zorana", "CC") == ("female", 0.9, 2)
26
+ assert guess("Zorana") == ("female", 0.9, 2)
27
+
28
+
29
+ def test_country_changes_gender():
30
+ assert guess("Kelandre", "AA") == ("female", 0.6, 2)
31
+ assert guess("Kelandre", "BB") == ("male", 0.9, 2)
32
+
33
+
34
+ def test_first_known_name_decides():
35
+ assert guess("Q. Xyzzy Tomislavo Zorana", "AA") == ("male", 1.0, 1)
36
+
37
+
38
+ def test_unknown_and_unisex():
39
+ assert guess("Xyzzy", "AA") == ("unknown", 0.0, 0)
40
+ assert guess("", "AA") == ("unknown", 0.0, 0)
41
+ assert guess("Ambo", "AA") == ("unknown", 0.0, 1)
42
+
43
+
44
+ def test_accents_both_ways():
45
+ assert guess("Mirén")[0] == "female"
46
+ assert guess("Miren")[0] == "female"
47
+
48
+
49
+ def test_tokens_and_country_code():
50
+ assert tokens("Jean-Pierre A. B.") == ["jean", "pierre"]
51
+ assert country_code("co") == "CO"
52
+ assert country_code("Colombia") == "CO"
53
+ assert country_code("Russian Federation") == "RU"
54
+ assert country_code("Atlantis") == ""
55
+
56
+
57
+ def test_genderize_shape():
58
+ assert genderize("Zorana", "BB") == {
59
+ "count": 2,
60
+ "name": "Zorana",
61
+ "gender": "female",
62
+ "probability": 0.8,
63
+ "country_id": "BB",
64
+ }
65
+ assert genderize("Xyzzy")["gender"] is None