zip-codes-pl 0.1.1

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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "zip_codes/pl"
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module ZipCodes
7
+ module PL
8
+ # Downloads the source and writes the dataset into the configured directory.
9
+ class Builder
10
+ Result = Data.define(:status, :data_path, :manifest) do
11
+ def built? = status == :built
12
+ def up_to_date? = status == :up_to_date
13
+ end
14
+
15
+ # `administrative_names_source: false` builds with the raw upstream labels.
16
+ # The two sources default independently: tying the names to whether a custom
17
+ # row source was injected silently dropped the enrichment, and the manifest
18
+ # attribution with it.
19
+ def initialize(config: ZipCodes::PL.config, source: nil, administrative_names_source: nil)
20
+ @config = config
21
+ @source = source || Sources::Geonames.new(config: config)
22
+ @administrative_names_source =
23
+ case administrative_names_source
24
+ when nil then Sources::PocztaPolska.new(config: config)
25
+ when false then nil
26
+ else administrative_names_source
27
+ end
28
+ end
29
+
30
+ def call
31
+ previous = Manifest.read(config.manifest_path)
32
+ download = source.download(etag: reusable_etag(previous))
33
+ return Result.new(status: :up_to_date, data_path: config.data_path, manifest: previous) if download.nil?
34
+
35
+ names = administrative_names_source&.fetch
36
+ write(download, names)
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :administrative_names_source, :config, :source
42
+
43
+ # Only claim a cached copy when the data file it describes is still there;
44
+ # a 304 with no file on disk would leave the caller with nothing.
45
+ def reusable_etag(previous)
46
+ return nil if previous.nil? || !File.exist?(config.data_path)
47
+
48
+ previous.etag
49
+ end
50
+
51
+ def write(download, names)
52
+ FileUtils.mkdir_p(config.output_dir)
53
+ row_count = write_data(download, names)
54
+ manifest = build_manifest(download, row_count, names).write(config.manifest_path)
55
+
56
+ Result.new(status: :built, data_path: config.data_path, manifest: manifest)
57
+ end
58
+
59
+ # Written to a temporary file and renamed, so an interrupted run never
60
+ # leaves a half-written dataset where a complete one used to be.
61
+ def write_data(download, names)
62
+ temporary_path = "#{config.data_path}.tmp"
63
+ row_count = 0
64
+
65
+ File.open(temporary_path, "w") do |file|
66
+ file.puts(Record::COLUMNS.join("\t"))
67
+ source.each_record(download.body) do |record|
68
+ record = apply_administrative_names(record, names) if names
69
+ file.puts(record.to_row.join("\t"))
70
+ row_count += 1
71
+ end
72
+ end
73
+
74
+ File.rename(temporary_path, config.data_path)
75
+ row_count
76
+ ensure
77
+ FileUtils.rm_f(temporary_path) if temporary_path && File.exist?(temporary_path)
78
+ end
79
+
80
+ def apply_administrative_names(record, names)
81
+ county = name_for!(names.counties, record.county_teryt, "county")
82
+ commune = name_for!(names.communes, record.commune_teryt, "commune")
83
+ county = "powiat #{county}" if county && record.county_teryt[2, 2].to_i < 60
84
+
85
+ Record.new(**record.to_h, county: county, commune: commune)
86
+ end
87
+
88
+ def name_for!(names, code, level)
89
+ return nil if code.nil?
90
+
91
+ names.fetch(code) do
92
+ raise DownloadError, "no #{level} name for TERYT code #{code} in the Poczta Polska response"
93
+ end
94
+ end
95
+
96
+ def build_manifest(download, row_count, names)
97
+ attribution = Sources::Geonames::ATTRIBUTION
98
+ attribution = "#{attribution}; #{Sources::PocztaPolska::ATTRIBUTION}" if names
99
+
100
+ Manifest.new(
101
+ source_url: config.source_url,
102
+ attribution: attribution,
103
+ etag: download.etag,
104
+ last_modified: download.last_modified,
105
+ row_count: row_count,
106
+ built_at: Time.now.utc.iso8601,
107
+ gem_version: VERSION,
108
+ format_version: Manifest::FORMAT_VERSION
109
+ )
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ # A place rather than a postal code: what a "cities" table wants.
6
+ class City < Data.define(
7
+ :name, :voivodeship, :voivodeship_teryt, :commune, :commune_teryt,
8
+ :latitude, :longitude, :postal_codes
9
+ )
10
+ class << self
11
+ # Keyed by commune as well as name, because a voivodeship can hold dozens
12
+ # of distinct villages sharing one - mazowieckie has 28 called "Nowa Wieś".
13
+ # Collapsing them by name alone puts the averaged point in a field, up to
14
+ # 158 km from the farthest place it claims to be.
15
+ def group_key(record)
16
+ [record.city, record.voivodeship_teryt, record.commune_teryt]
17
+ end
18
+
19
+ def sort_key(city)
20
+ [Normalize.key(city.name), city.voivodeship_teryt, city.commune_teryt.to_s]
21
+ end
22
+ end
23
+
24
+ # Holds running sums rather than the rows behind them, so building every
25
+ # place in the country does not mean holding every postal code first. The
26
+ # coordinate ends up the mean of the group's rows, because the source gives
27
+ # one point per postal code and Bydgoszcz has 679 of them.
28
+ class Accumulator
29
+ def initialize(record)
30
+ # Copies the identity fields instead of holding the row, so the rest of
31
+ # each Record can be collected while the country is being aggregated.
32
+ @name = record.city
33
+ @voivodeship = record.voivodeship
34
+ @voivodeship_teryt = record.voivodeship_teryt
35
+ @commune = record.commune
36
+ @commune_teryt = record.commune_teryt
37
+ @latitude = 0.0
38
+ @longitude = 0.0
39
+ @count = 0
40
+ @postal_codes = {}
41
+ end
42
+
43
+ def add(record)
44
+ @latitude += record.latitude
45
+ @longitude += record.longitude
46
+ @count += 1
47
+ @postal_codes[record.postal_code] = true
48
+ self
49
+ end
50
+
51
+ def to_city
52
+ City.new(
53
+ name: @name,
54
+ voivodeship: @voivodeship,
55
+ voivodeship_teryt: @voivodeship_teryt,
56
+ commune: @commune,
57
+ commune_teryt: @commune_teryt,
58
+ latitude: (@latitude / @count).round(6),
59
+ longitude: (@longitude / @count).round(6),
60
+ postal_codes: @postal_codes.keys.sort.freeze
61
+ )
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ class Configuration
6
+ DEFAULT_OUTPUT_DIR = "data"
7
+ DATA_FILENAME = "zip-codes-pl.tsv"
8
+ MANIFEST_FILENAME = "zip-codes-pl.manifest.json"
9
+ BUNDLED_DIR = File.expand_path("../../../data", __dir__)
10
+
11
+ attr_accessor :output_dir, :source_url, :user_agent, :open_timeout, :read_timeout, :poczta_request_interval
12
+
13
+ def initialize
14
+ @output_dir = ENV.fetch("ZIP_CODES_PL_DIR", DEFAULT_OUTPUT_DIR)
15
+ @source_url = Sources::Geonames::URL
16
+ @user_agent = "zip-codes-pl/#{VERSION} (+https://github.com/carlos-1410/zip-codes-pl)"
17
+ @open_timeout = 10
18
+ @read_timeout = 60
19
+ @poczta_request_interval = Sources::PocztaPolska::DEFAULT_REQUEST_INTERVAL
20
+ end
21
+
22
+ # Where a refresh writes.
23
+ def data_path
24
+ File.join(output_dir, DATA_FILENAME)
25
+ end
26
+
27
+ def manifest_path
28
+ File.join(output_dir, MANIFEST_FILENAME)
29
+ end
30
+
31
+ # Where a read looks. Your own refreshed copy wins; without one the dataset
32
+ # shipped inside the gem answers, so nothing has to be built before first use.
33
+ def readable_data_path
34
+ File.exist?(data_path) ? data_path : bundled_data_path
35
+ end
36
+
37
+ def readable_manifest_path
38
+ File.exist?(data_path) ? manifest_path : File.join(BUNDLED_DIR, MANIFEST_FILENAME)
39
+ end
40
+
41
+ def bundled_data_path
42
+ File.join(BUNDLED_DIR, DATA_FILENAME)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ class Error < StandardError; end
6
+
7
+ class DownloadError < Error; end
8
+
9
+ class DatasetError < Error; end
10
+ end
11
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module ZipCodes
7
+ module PL
8
+ # Sits next to the dataset and records where it came from. The etag is what
9
+ # lets a later run ask the server "changed?" instead of downloading again.
10
+ class Manifest < Data.define(
11
+ :source_url, :attribution, :etag, :last_modified,
12
+ :row_count, :built_at, :gem_version, :format_version
13
+ )
14
+ FORMAT_VERSION = 1
15
+
16
+ def self.read(path)
17
+ return nil unless File.exist?(path)
18
+
19
+ payload = JSON.parse(File.read(path), symbolize_names: true)
20
+ return nil unless payload[:format_version] == FORMAT_VERSION
21
+
22
+ new(**payload.slice(*members))
23
+ rescue JSON::ParserError, ArgumentError
24
+ nil
25
+ end
26
+
27
+ def write(path)
28
+ File.write(path, "#{JSON.pretty_generate(to_h)}\n")
29
+ self
30
+ end
31
+
32
+ def built_at_time
33
+ Time.iso8601(built_at)
34
+ rescue ArgumentError, TypeError
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ # Folding used for lookups only. Stored names keep their diacritics; this is
6
+ # what makes "zlotow", "Złotów" and "ZŁOTÓW" find the same rows.
7
+ module Normalize
8
+ DIACRITICS = {
9
+ "ą" => "a", "ć" => "c", "ę" => "e", "ł" => "l", "ń" => "n",
10
+ "ó" => "o", "ś" => "s", "ź" => "z", "ż" => "z"
11
+ }.freeze
12
+
13
+ POSTAL_CODE_PATTERN = /\A(\d{2})-?(\d{3})\z/
14
+
15
+ module_function
16
+
17
+ def key(value)
18
+ value.to_s.strip.downcase.gsub(/[ąćęłńóśźż]/, DIACRITICS)
19
+ end
20
+
21
+ # Accepts "86-010" and "86010"; returns nil for anything that is not a PNA.
22
+ def postal_code(value)
23
+ match = POSTAL_CODE_PATTERN.match(value.to_s.strip)
24
+ return nil if match.nil?
25
+
26
+ "#{match[1]}-#{match[2]}"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ class Railtie < Rails::Railtie
6
+ rake_tasks do
7
+ load File.expand_path("tasks/zip_codes_pl.rake", __dir__)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ # Reads the dataset by streaming the file, holding nothing between calls.
6
+ #
7
+ # One scan of 72 899 rows costs about 15 ms and 1.6 MB, against 222 ms and
8
+ # 99 MB to hold the same rows in indexed memory. The break-even is around
9
+ # fifteen lookups and the ordinary use of this gem is three, so there is no
10
+ # in-memory mode: past that point the rows belong in the caller's own database,
11
+ # and `cities` is the shape to import.
12
+ class Reader
13
+ HEADER = Record::COLUMNS.map(&:to_s).freeze
14
+ MIN_CITY_FRAGMENT_LENGTH = 3
15
+
16
+ attr_reader :path
17
+
18
+ def initialize(path)
19
+ @path = path
20
+ end
21
+
22
+ def each(&block)
23
+ return enum_for(:each) unless block
24
+
25
+ open_data do |file|
26
+ file.each_line { |line| block.call(Record.from_row(line.chomp.split("\t", -1))) }
27
+ end
28
+ end
29
+
30
+ include Enumerable
31
+
32
+ def find_by_postal_code(code)
33
+ normalized = Normalize.postal_code(code)
34
+ return [] if normalized.nil?
35
+
36
+ prefix = "#{normalized}\t"
37
+ scan { |line| line.start_with?(prefix) }
38
+ end
39
+
40
+ def find_by_city(name, voivodeship: nil)
41
+ wanted_name = Normalize.key(name)
42
+ wanted_teryt = resolve_voivodeship(voivodeship)
43
+ # Asked to narrow to a voivodeship that does not exist: nothing matches.
44
+ # Treating it as "no filter" would answer with every place of that name.
45
+ return [] if wanted_teryt == :unknown
46
+
47
+ scan do |line|
48
+ fields = line.split("\t", 5)
49
+ # Folding every name costs more than reading the file, and the folding
50
+ # used here is length preserving (ł->l, ó->o, NFD plus mark stripping),
51
+ # so a length mismatch rules a row out before any of that work.
52
+ fields[1].length == wanted_name.length &&
53
+ Normalize.key(fields[1]) == wanted_name &&
54
+ (wanted_teryt.nil? || fields[3] == wanted_teryt)
55
+ end
56
+ end
57
+
58
+ def search_by_city(fragment, voivodeship: nil)
59
+ wanted_fragment = Normalize.key(fragment)
60
+ return [] if wanted_fragment.length < 2
61
+
62
+ wanted_teryt = resolve_voivodeship(voivodeship)
63
+ return [] if wanted_teryt == :unknown
64
+
65
+ scan do |line|
66
+ fields = line.split("\t", 5)
67
+ name = Normalize.key(fields[1])
68
+ matches = if wanted_fragment.length < MIN_CITY_FRAGMENT_LENGTH
69
+ name == wanted_fragment
70
+ else
71
+ name.include?(wanted_fragment)
72
+ end
73
+
74
+ matches && (wanted_teryt.nil? || fields[3] == wanted_teryt)
75
+ end
76
+ end
77
+
78
+ # Accumulates as it streams, so the 72 899 source rows are never all resident
79
+ # at once - only the 52 325 places they collapse into.
80
+ def cities
81
+ accumulators = {}
82
+ each { |record| (accumulators[City.group_key(record)] ||= City::Accumulator.new(record)).add(record) }
83
+ accumulators.each_value.map(&:to_city).sort_by { |city| City.sort_key(city) }
84
+ end
85
+
86
+ private
87
+
88
+ def resolve_voivodeship(name)
89
+ return nil if name.nil?
90
+
91
+ Voivodeship.find_by_name(name)&.teryt_code || :unknown
92
+ end
93
+
94
+ def scan
95
+ found = []
96
+ open_data do |file|
97
+ file.each_line do |line|
98
+ found << Record.from_row(line.chomp.split("\t", -1)) if yield(line)
99
+ end
100
+ end
101
+ found
102
+ end
103
+
104
+ def open_data
105
+ raise DatasetError, "dataset missing: #{path} (run `rake zip_codes:pl:update`)" unless File.exist?(path)
106
+
107
+ File.open(path) do |file|
108
+ header = file.gets&.chomp&.split("\t")
109
+ raise DatasetError, "unexpected header in #{path}" unless header == HEADER
110
+
111
+ yield file
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ # One postal code in one place. A place with several codes has several
6
+ # records, and so does a code shared by several places.
7
+ #
8
+ # county and commune carry the source's own labels, which are uneven for
9
+ # Poland ("Powiat bydgoski", "Leszno County"). The *_teryt codes are the
10
+ # dependable identifiers and the join key for official names.
11
+ class Record < Data.define(
12
+ :postal_code, :city,
13
+ :voivodeship, :voivodeship_teryt,
14
+ :county, :county_teryt,
15
+ :commune, :commune_teryt,
16
+ :latitude, :longitude, :accuracy
17
+ )
18
+ COLUMNS = members.freeze
19
+
20
+ def self.from_row(row)
21
+ new(
22
+ postal_code: row[0],
23
+ city: row[1],
24
+ voivodeship: row[2],
25
+ voivodeship_teryt: row[3],
26
+ county: presence(row[4]),
27
+ county_teryt: presence(row[5]),
28
+ commune: presence(row[6]),
29
+ commune_teryt: presence(row[7]),
30
+ latitude: row[8].to_f,
31
+ longitude: row[9].to_f,
32
+ accuracy: presence(row[10])&.to_i
33
+ )
34
+ end
35
+
36
+ def self.presence(value)
37
+ value.nil? || value.empty? ? nil : value
38
+ end
39
+ private_class_method :presence
40
+
41
+ def to_row
42
+ COLUMNS.map { |column| public_send(column) }
43
+ end
44
+
45
+ def coordinates
46
+ [latitude, longitude]
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "stringio"
5
+ require "uri"
6
+ require "zip"
7
+
8
+ module ZipCodes
9
+ module PL
10
+ module Sources
11
+ # The GeoNames postal-code export for Poland: one tab separated row per
12
+ # (postal code, place), carrying WGS84 coordinates and the TERYT county and
13
+ # commune codes.
14
+ class Geonames
15
+ URL = "https://download.geonames.org/export/zip/PL.zip"
16
+ ENTRY = "PL.txt"
17
+ ATTRIBUTION = "GeoNames (https://www.geonames.org), CC BY 4.0"
18
+ MAX_REDIRECTS = 3
19
+ EXPECTED_FIELDS = 12
20
+
21
+ Download = Data.define(:body, :etag, :last_modified)
22
+
23
+ def initialize(config: ZipCodes::PL.config)
24
+ @config = config
25
+ end
26
+
27
+ # Returns nil when the server reports the file is unchanged, which is what
28
+ # makes re-running the rake task cheap.
29
+ def download(etag: nil)
30
+ response = get(URI.parse(config.source_url), etag: etag)
31
+ return nil if response.is_a?(Net::HTTPNotModified)
32
+
33
+ raise DownloadError, "#{config.source_url} answered #{response.code}" unless response.is_a?(Net::HTTPSuccess)
34
+
35
+ Download.new(
36
+ body: response.body,
37
+ etag: response["etag"],
38
+ last_modified: response["last-modified"]
39
+ )
40
+ end
41
+
42
+ def each_record(archive, &block)
43
+ return enum_for(:each_record, archive) unless block
44
+
45
+ Zip::File.open_buffer(StringIO.new(archive)) do |zip|
46
+ entry = zip.find_entry(ENTRY)
47
+ raise DownloadError, "the archive has no #{ENTRY}" if entry.nil?
48
+
49
+ entry.get_input_stream do |stream|
50
+ # The entry stream yields ASCII-8BIT, and Polish names have to survive it.
51
+ stream.each_line { |line| parse_line(line.force_encoding(Encoding::UTF_8), &block) }
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :config
59
+
60
+ def get(uri, etag:, redirects: 0)
61
+ response = perform(uri, build_request(uri, etag))
62
+
63
+ # 304 is a subclass of Net::HTTPRedirection, and it carries no Location.
64
+ # Following it as a redirect is how the second run used to die.
65
+ return response if response.is_a?(Net::HTTPNotModified)
66
+ return response unless response.is_a?(Net::HTTPRedirection)
67
+ raise DownloadError, "too many redirects from #{config.source_url}" if redirects >= MAX_REDIRECTS
68
+
69
+ get(URI.parse(response["location"]), etag: etag, redirects: redirects + 1)
70
+ end
71
+
72
+ def build_request(uri, etag)
73
+ request = Net::HTTP::Get.new(uri)
74
+ request["user-agent"] = config.user_agent
75
+ request["if-none-match"] = etag if etag
76
+ request
77
+ end
78
+
79
+ def perform(uri, request)
80
+ Net::HTTP.start(
81
+ uri.host, uri.port,
82
+ use_ssl: uri.scheme == "https",
83
+ open_timeout: config.open_timeout,
84
+ read_timeout: config.read_timeout
85
+ ) { |http| http.request(request) }
86
+ end
87
+
88
+ def parse_line(line)
89
+ return if line.strip.empty?
90
+
91
+ fields = line.chomp.split("\t", -1)
92
+ unless fields.length == EXPECTED_FIELDS
93
+ raise DownloadError,
94
+ "malformed GeoNames row: expected #{EXPECTED_FIELDS} fields, got #{fields.length}"
95
+ end
96
+
97
+ voivodeship = Voivodeship.find_by_geonames_code(fields[4])
98
+ # An unknown admin1 code means the canonical table above is stale, and a
99
+ # silently dropped row would quietly corrupt the output.
100
+ raise DownloadError, "unknown GeoNames voivodeship code: #{fields[4].inspect}" if voivodeship.nil?
101
+
102
+ yield build_record(fields, voivodeship)
103
+ end
104
+
105
+ def build_record(fields, voivodeship)
106
+ Record.new(
107
+ postal_code: fields[1],
108
+ city: fields[2],
109
+ voivodeship: voivodeship.name,
110
+ voivodeship_teryt: voivodeship.teryt_code,
111
+ county: blank_to_nil(fields[5]&.tr("_", " ")),
112
+ county_teryt: blank_to_nil(fields[6]),
113
+ commune: blank_to_nil(fields[7]&.tr("_", " ")),
114
+ commune_teryt: blank_to_nil(fields[8]),
115
+ latitude: fields[9].to_f,
116
+ longitude: fields[10].to_f,
117
+ accuracy: blank_to_nil(fields[11])&.to_i
118
+ )
119
+ end
120
+
121
+ def blank_to_nil(value)
122
+ value.nil? || value.strip.empty? ? nil : value.strip
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end