Add admin2 and timezone datasets, expose featurecode, fix caching, index city names (closes #6) - #49
Open
cvl01 wants to merge 5 commits into
Open
Add admin2 and timezone datasets, expose featurecode, fix caching, index city names (closes #6)#49cvl01 wants to merge 5 commits into
cvl01 wants to merge 5 commits into
Conversation
Adds the GeoNames admin1CodesASCII.txt dataset as a new admin1.json data file, keyed by the composite code <countrycode>.<admin1code> (e. g. US.CA). Since city records store countrycode and admin1code separately, the composite key allows resolving those references to the admin1 name and geonameid.
Adds two GeoNames datasets and the helpers needed to join them to city records, plus fixes to the caching that made repeated lookups re-read their JSON files and could serve results from the wrong dataset. Datasets: - admin2Codes.txt becomes admin2.json, keyed by the concatenated code <countrycode>.<admin1code>.<admin2code> as described in the GeoNames readme, exposed through get_admin2_codes(). - timeZones.txt becomes timezones.json, keyed by IANA time zone id, exposed through get_timezones() and get_timezones_by_country(). City records gain admin2code, completing the composite key. The resolved admin1 name is deliberately not stored on city records: denormalising it grew cities500.json by 12 MB, so get_admin1_by_city() and get_admin2_by_city() do the join instead. Both return None rather than building a partial key such as 'NL.' when a city has no admin1code. Caching fixes: - _load_data() returned the parsed data without storing it, so every getter re-read and re-parsed its file. Repeated get_cities() calls on the 500 population dataset cost ~0.45 s each and are now free after the first. _load_data() is now only the file reader and its return type no longer claims dict, which was wrong for us_counties.json. - cities_by_names was a class attribute keyed by city name alone, while the results depend on min_city_population, so an instance created after one with a smaller dataset was served the other's results. It is now per instance. The README gains a data formats section documenting the return value of every method. Every example in it was run against the built data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes yaph#6. get_cities_by_name() scanned the whole cities dataset on every call for a name it had not seen, at 4 ms per name on the default dataset and 31 ms on the 500 population one. It now builds a name to records index on first call, so looking up many names costs one pass instead of one pass per name. Looking up 500 distinct names on the 500 population dataset went from 5.3 s to 0.08 s. The issue suggested generators. They do not help here: the results are memoised and a generator cannot be re-iterated, and the cost was the scan rather than building the result list. BREAKING: get_cities_by_name() returns a list of city records, where it returned a list of single-entry dictionaries keyed by geonameid before. Callers that did `list(d)[0]` or `next(iter(d))` to unwrap should read `city['geonameid']` instead, which the records already carry. Unknown names now return an empty list. The old shape was also what made an index expensive: building one single-key dictionary per city cost 68 MB on the 500 population dataset against 27 MB for plain references. The new get_cities_by_names() exposes the index itself, grouping every city record by name. Drops the cities_items attribute, which existed only to avoid re-listing the dataset's items on each scan and duplicated ~1.9 MB of tuples. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cuts the installed size of the package from 203 MB to 36 MB. Load time is unchanged: the first get_cities() call on the 500 population dataset takes 0.31 s either way, as the saved I/O offsets the decompression. Wheel and sdist size are unchanged at ~35 MB, because those were already deflate compressed. This is a disk win rather than a bandwidth one. The bin/ scripts keep writing plain JSON into datasets/, and the new bin/compress_data.py gzips it into geonamescache/data/ as the last step of `make json`, replacing the plain mv. It sets mtime=0 so identical input produces identical output and rebuilds don't churn the package data. _load_data() now takes a dataset name rather than a file name and appends the .json.gz suffix itself. Verified end to end by building a wheel and using it from a clean virtualenv. A test asserts no stale plain .json is left in the data directory, since it would be shipped and silently ignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GeoNames feature code distinguishes a capital (PPLC) or an administrative seat (PPLA through PPLA5) from an ordinary populated place (PPL). The cities datasets were already carrying it in column 8 and the build discarded it. It also explains a discrepancy that is otherwise invisible: cities15000.txt contains 45 places with a population below 15000, all of them PPLC or PPLG, because GeoNames includes seats of government regardless of size. The rule `population > threshold or featurecode in seats` reproduces each cities file from cities500.txt exactly, with the seat codes accumulating as the threshold drops. featureclass is deliberately not stored, as it is P for every record in these datasets and so carries no information. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds two GeoNames datasets, exposes the feature code, fixes two caching bugs and closes #6.
Data files are gitignored, so this needs
make dl && make jsonto build.New datasets
admin2Codes.txt→get_admin2_codes(), keyed by the concatenated code<countrycode>.<admin1code>.<admin2code>as the GeoNames readme describes, e. g.NL.11.0599. 47592 records.timeZones.txt→get_timezones()keyed by IANA id, plusget_timezones_by_country()which takes a case insensitive ISO alpha-2 code and returns the country's zones sorted by id. The header row names its offset columns after the current year, so the parser matches on the first column instead.City records gain
admin2codeto complete the composite key, andget_admin1_by_city()/get_admin2_by_city()do the join. Both returnNonerather than building a partial key such as'NL.'when a city has noadmin1code, which affects roughly 8700 of the 235156 cities in the 500 population dataset.The resolved admin1 name is deliberately not denormalised onto city records: it grew
cities500.jsonby 12 MB for something a dict lookup already gives you.Expose
featurecodeThe cities datasets carried it in column 8 and the build discarded it. It distinguishes a capital (
PPLC) or administrative seat (PPLA–PPLA5) from an ordinary populated place (PPL), and is searchable viasearch_cities(attribute='featurecode').It also explains something otherwise invisible:
cities15000.txtcontains 45 places with population below 15000, allPPLCorPPLG, because GeoNames includes seats of government regardless of size. The rulepopulation > threshold or featurecode in seatsreproduces every cities file fromcities500.txtexactly, with the seat codes accumulating as the threshold drops:pop > 15000or PPLC, PPLGpop > 5000or + PPLApop > 1000or + PPLA2, PPLA3featureclassis not stored, as it isPfor every record in these datasets.Two caching bugs
_load_data()never cached. It returned the parsed data without storing it, so every getter re-read and re-parsed its file. Three consecutiveget_cities()calls on the 500 population dataset cost ~0.45 s each andgc.citiesstayedNoneforever.search_cities()paid that on every search. It is now free after the first call.get_cities_by_name()could return the wrong dataset's results.cities_by_nameswas a class attribute keyed by city name alone, while the results depend onmin_city_population. An instance created after one with a smaller dataset was served the other's results:GeonamesCache(500).get_cities_by_name('Springfield')returned 8 records instead of 24. Now per instance.Both have regression tests that were verified to fail against the previous implementation.
Closes #6: index city names
get_cities_by_name()scanned the whole dataset for every unseen name, at 4 ms per name on the default dataset and 31 ms on the 500 population one. It now builds a name → records index on first call. Looking up 500 distinct names on the 500 population dataset went from 5.3 s to 0.08 s.The issue suggested generators. They do not help here: results are memoised and a generator cannot be re-iterated, and the cost was the scan, not building the result list.
Breaking:
get_cities_by_name()now returns a list of city records where it returned a list of single-entry dicts keyed by geonameid. Callers that unwrapped withlist(d)[0]should readcity['geonameid'], which the records already carry. Unknown names return[]instead of an empty list of wrappers. The old shape was also what made an index expensive, at 68 MB against 27 MB for plain references.get_cities_by_names()exposes the index itself.This wants a major version bump; I left
__version__alone as that is your release flow's call.Gzip the bundled data
Installed package size drops from 203 MB to 36 MB at no cost in load time, since the saved I/O offsets decompression (first
get_cities()on the 500 population dataset: 0.31 s either way). Wheel and sdist size are unchanged at ~35 MB because those were already deflate compressed, so this is a disk win rather than a bandwidth one.bin/scripts keep writing plain JSON intodatasets/; the newbin/compress_data.pygzips it intogeonamescache/data/as the last step ofmake json, withmtime=0so identical input gives identical output. Verified by building a wheel and using it from a clean virtualenv. A test asserts no stale plain.jsonis left in the data directory, since it would be shipped and silently ignored.I did look at deduplicating the four nested cities files using the rule above, which would take 36 MB down to ~15 MB, and decided against it: it would make a
min_city_population=15000user parse all 235k cities instead of 34k, a 5x slower first load, to save 21 MB of disk.Docs and tests
The README gains a data formats section documenting the return value of every method. Every example in it was executed against the built data rather than written from memory, which caught a wrong one:
search_cities('Rotterdam')returns Rotterdam and Hoogvliet, both NL, because it searchesalternatenamesby default and Hoogvliet's alternate name is "Hoogvliet Rotterdam". You needattribute='name'for the two actual Rotterdams.37 tests pass, mypy is clean. Two pre-existing issues are untouched:
tests/test_data.py::test_continentsfails becausecontinents.jsonneedsGEONAMES_USERand an API call, and ruff flags the import order ingeonamescache/mappers.py.Also fixed along the way:
get_us_counties()had no return annotation,_load_data()was annotated-> dictwhileus_counties.jsonis a list, andCitySearchAttributewas missingadmin2code.🤖 Generated with Claude Code