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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "time"
5
+
6
+ module ZipCodes
7
+ module PL
8
+ module Sources
9
+ class PocztaPolska
10
+ class Client
11
+ MAX_ATTEMPTS = 5
12
+ MAX_RETRY_DELAY = 30.0
13
+
14
+ def initialize(
15
+ config:,
16
+ sleeper: ->(seconds) { sleep(seconds) },
17
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
18
+ transport: nil
19
+ )
20
+ @request_interval = config.poczta_request_interval
21
+ @sleeper = sleeper
22
+ @clock = clock
23
+ @transport = transport || ->(uri, params) { post(uri, params, config) }
24
+ @last_request_at = nil
25
+ end
26
+
27
+ # Retries rate limiting a bounded number of times. An unbounded loop here
28
+ # would turn sustained throttling into a refresh that never finishes and
29
+ # never says why.
30
+ def post_form(uri, params)
31
+ MAX_ATTEMPTS.times do
32
+ throttle
33
+ response = transport.call(uri, params)
34
+ @last_request_at = clock.call
35
+
36
+ return response if response.is_a?(Net::HTTPSuccess)
37
+
38
+ raise DownloadError, failure_message(response, uri) unless response.is_a?(Net::HTTPTooManyRequests)
39
+
40
+ sleeper.call(retry_after(response))
41
+ end
42
+
43
+ raise DownloadError, "Poczta Polska is rate limiting - gave up after #{MAX_ATTEMPTS} attempts for #{uri}"
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :clock, :request_interval, :sleeper, :transport
49
+
50
+ def throttle
51
+ return if @last_request_at.nil?
52
+
53
+ remaining = request_interval - (clock.call - @last_request_at)
54
+ sleeper.call(remaining) if remaining.positive?
55
+ end
56
+
57
+ # Clamped at both ends: a hostile or broken header must not make the
58
+ # build sleep backwards, which raises, nor park it for an hour.
59
+ def retry_after(response)
60
+ value = response["retry-after"]
61
+ seconds = Float(value, exception: false) || (Time.httpdate(value.to_s) - Time.now)
62
+ seconds.clamp(0.0, MAX_RETRY_DELAY)
63
+ rescue ArgumentError
64
+ [request_interval, 1.0].max
65
+ end
66
+
67
+ # A moved endpoint is reported rather than followed: this is a POST to a
68
+ # form, and replaying it against an unknown location is not a safe guess.
69
+ def failure_message(response, uri)
70
+ message = "Poczta Polska answered #{response.code} for #{uri}"
71
+ location = response["location"] if response.is_a?(Net::HTTPRedirection)
72
+ location ? "#{message} (redirected to #{location})" : message
73
+ end
74
+
75
+ def post(uri, params, config)
76
+ request = Net::HTTP::Post.new(uri)
77
+ request["user-agent"] = config.user_agent
78
+ request.set_form_data(params)
79
+
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
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require_relative "poczta_polska/client"
6
+
7
+ module ZipCodes
8
+ module PL
9
+ module Sources
10
+ class PocztaPolska
11
+ DISTRICTS_URL = "https://www.poczta-polska.pl/wp-content/themes/pp/inc/pna-form/pna-search-district.php"
12
+ COMMUNES_URL = "https://www.poczta-polska.pl/wp-content/themes/pp/inc/pna-form/pna-search-commune.php"
13
+ ATTRIBUTION = "county and commune names: Poczta Polska (https://www.poczta-polska.pl)"
14
+ DEFAULT_REQUEST_INTERVAL = 0.25
15
+ LEGACY_COMMUNES = { "320304" => "Ostrowice" }.freeze
16
+ Names = Data.define(:counties, :communes)
17
+
18
+ def initialize(config: ZipCodes::PL.config, client: nil, voivodeships: Voivodeship.all)
19
+ @client = client || Client.new(config: config)
20
+ @voivodeships = voivodeships
21
+ end
22
+
23
+ def fetch
24
+ counties, districts = fetch_counties
25
+ communes = fetch_communes(districts)
26
+
27
+ Names.new(counties: counties.freeze, communes: communes.freeze)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :client, :voivodeships
33
+
34
+ def fetch_counties
35
+ counties = {}
36
+ districts = []
37
+
38
+ voivodeships.each do |voivodeship|
39
+ entries(DISTRICTS_URL, province: voivodeship.teryt_code).each do |entry|
40
+ code, name = entry.values_at("value", "name")
41
+ validate_code!(code, 4, voivodeship.teryt_code)
42
+ add!(counties, code, name)
43
+ districts << [voivodeship.teryt_code, code]
44
+ end
45
+ end
46
+
47
+ [counties, districts]
48
+ end
49
+
50
+ def fetch_communes(districts)
51
+ communes = LEGACY_COMMUNES.dup
52
+ districts.each do |province, district|
53
+ entries(COMMUNES_URL, province: province, district: district).each do |entry|
54
+ terc, name = entry.values_at("value", "name")
55
+ validate_commune_code!(terc, district)
56
+ add!(communes, terc[0, 6], normalize_commune_name(name))
57
+ end
58
+ end
59
+
60
+ communes
61
+ end
62
+
63
+ def entries(url, params)
64
+ response = client.post_form(URI(url), params)
65
+ # A success with no body is still a success to Net::HTTP, and JSON.parse
66
+ # raises TypeError rather than ParserError on nil.
67
+ payload = JSON.parse(response.body.to_s)
68
+ unless payload.is_a?(Array) && payload.all? { |entry| valid_entry?(entry) }
69
+ raise DownloadError, "malformed Poczta Polska response from #{url}"
70
+ end
71
+
72
+ payload
73
+ rescue JSON::ParserError
74
+ raise DownloadError, "malformed Poczta Polska JSON from #{url}"
75
+ end
76
+
77
+ # Strings are demanded rather than coerced, so a number where a code should
78
+ # be is refused here instead of reaching String methods further down and
79
+ # surfacing as NoMethodError instead of a controlled failure.
80
+ def valid_entry?(entry)
81
+ entry.is_a?(Hash) &&
82
+ entry["name"].is_a?(String) && !entry["name"].empty? &&
83
+ entry["value"].is_a?(String) && !entry["value"].empty?
84
+ end
85
+
86
+ def validate_code!(code, length, prefix)
87
+ return if code.match?(/\A\d{#{length}}\z/) && code.start_with?(prefix)
88
+
89
+ raise DownloadError, "malformed Poczta Polska TERYT code: #{code.inspect}"
90
+ end
91
+
92
+ def validate_commune_code!(code, district)
93
+ return if code.match?(/\A\d{6,7}\z/) && code.start_with?(district)
94
+
95
+ raise DownloadError, "malformed Poczta Polska TERYT code: #{code.inspect}"
96
+ end
97
+
98
+ def add!(names, code, name)
99
+ normalized = name.strip
100
+ previous = names[code]
101
+ if previous && previous != normalized
102
+ raise DownloadError,
103
+ "conflicting Poczta Polska names for TERYT #{code}: #{previous.inspect} and #{normalized.inspect}"
104
+ end
105
+
106
+ names[code] = normalized
107
+ end
108
+
109
+ def normalize_commune_name(name)
110
+ name.sub(/ \((?:miejska|wiejska|miejsko-wiejska)\)\z/, "")
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :zip_codes do
4
+ namespace :pl do
5
+ desc "Download and refresh the postal code dataset (rake zip_codes:pl:update[directory])"
6
+ task :update, [:output_dir] do |_task, args|
7
+ require "zip_codes/pl"
8
+
9
+ result = ZipCodes::PL.update(output_dir: args[:output_dir])
10
+ manifest = result.manifest
11
+
12
+ if result.up_to_date?
13
+ puts "Source unchanged - the dataset is already current: #{result.data_path}"
14
+ else
15
+ puts "Wrote #{manifest.row_count} rows to #{result.data_path}"
16
+ end
17
+ puts "Source: #{manifest.attribution}" if manifest
18
+ end
19
+
20
+ desc "Check the bundled dataset against its manifest and the record columns"
21
+ task :verify, [:output_dir] do |_task, args|
22
+ require "zip_codes/pl"
23
+
24
+ ZipCodes::PL.config.output_dir = args[:output_dir] if args[:output_dir]
25
+ path = ZipCodes::PL.config.readable_data_path
26
+ manifest = ZipCodes::PL.manifest
27
+ abort("No dataset at #{path}") unless File.exist?(path)
28
+ abort("No manifest next to #{path}") if manifest.nil?
29
+
30
+ header = File.open(path, &:gets).to_s.chomp.split("\t")
31
+ expected = ZipCodes::PL::Record::COLUMNS.map(&:to_s)
32
+ abort("Header is #{header.inspect}, expected #{expected.inspect}") unless header == expected
33
+
34
+ rows = ZipCodes::PL.reader.count
35
+ abort("Manifest claims #{manifest.row_count} rows, the file holds #{rows}") unless rows == manifest.row_count
36
+
37
+ known = ZipCodes::PL::Voivodeship.all.to_h { |voivodeship| [voivodeship.teryt_code, true] }
38
+ unknown = ZipCodes::PL.reader.reject { |record| known.key?(record.voivodeship_teryt) }
39
+ abort("Unknown voivodeship codes: #{unknown.first(5).map(&:voivodeship_teryt).uniq.inspect}") if unknown.any?
40
+
41
+ puts "Dataset consistent: #{rows} rows, #{known.size} voivodeships, built #{manifest.built_at}"
42
+ end
43
+
44
+ desc "Show the state of the built dataset"
45
+ task :info, [:output_dir] do |_task, args|
46
+ require "zip_codes/pl"
47
+
48
+ ZipCodes::PL.config.output_dir = args[:output_dir] if args[:output_dir]
49
+ manifest = ZipCodes::PL.manifest
50
+
51
+ if manifest.nil?
52
+ puts "No dataset in #{ZipCodes::PL.config.output_dir} - run rake zip_codes:pl:update"
53
+ next
54
+ end
55
+
56
+ puts "File: #{ZipCodes::PL.config.data_path}"
57
+ puts "Rows: #{manifest.row_count}"
58
+ puts "Built: #{manifest.built_at}"
59
+ puts "Source: #{manifest.source_url}"
60
+ puts "Modified: #{manifest.last_modified}"
61
+ puts "Attribution: #{manifest.attribution}"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ VERSION = "0.1.1"
6
+ end
7
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZipCodes
4
+ module PL
5
+ # The sixteen voivodeships, pairing the GeoNames admin1 code that appears in
6
+ # the source export with the official TERYT code and the Polish name.
7
+ #
8
+ # GeoNames ships English and inconsistent labels for every one of them
9
+ # ("Lower Silesia", "Warmia-Masuria", "Łódź Voivodeship"), so the name used by
10
+ # this gem is defined here rather than read from the download. The
11
+ # geonames_code => teryt_code pairing is verified against the source data by
12
+ # the test suite, because it is the join key everything else hangs off.
13
+ class Voivodeship < Data.define(:teryt_code, :geonames_code, :name, :slug)
14
+ ALL = [
15
+ new(teryt_code: "02", geonames_code: "72", name: "dolnośląskie", slug: "dolnoslaskie"),
16
+ new(teryt_code: "04", geonames_code: "73", name: "kujawsko-pomorskie", slug: "kujawsko-pomorskie"),
17
+ new(teryt_code: "06", geonames_code: "75", name: "lubelskie", slug: "lubelskie"),
18
+ new(teryt_code: "08", geonames_code: "76", name: "lubuskie", slug: "lubuskie"),
19
+ new(teryt_code: "10", geonames_code: "74", name: "łódzkie", slug: "lodzkie"),
20
+ new(teryt_code: "12", geonames_code: "77", name: "małopolskie", slug: "malopolskie"),
21
+ new(teryt_code: "14", geonames_code: "78", name: "mazowieckie", slug: "mazowieckie"),
22
+ new(teryt_code: "16", geonames_code: "79", name: "opolskie", slug: "opolskie"),
23
+ new(teryt_code: "18", geonames_code: "80", name: "podkarpackie", slug: "podkarpackie"),
24
+ new(teryt_code: "20", geonames_code: "81", name: "podlaskie", slug: "podlaskie"),
25
+ new(teryt_code: "22", geonames_code: "82", name: "pomorskie", slug: "pomorskie"),
26
+ new(teryt_code: "24", geonames_code: "83", name: "śląskie", slug: "slaskie"),
27
+ new(teryt_code: "26", geonames_code: "84", name: "świętokrzyskie", slug: "swietokrzyskie"),
28
+ new(teryt_code: "28", geonames_code: "85", name: "warmińsko-mazurskie", slug: "warminsko-mazurskie"),
29
+ new(teryt_code: "30", geonames_code: "86", name: "wielkopolskie", slug: "wielkopolskie"),
30
+ new(teryt_code: "32", geonames_code: "87", name: "zachodniopomorskie", slug: "zachodniopomorskie")
31
+ ].freeze
32
+
33
+ BY_GEONAMES_CODE = ALL.to_h { |voivodeship| [voivodeship.geonames_code, voivodeship] }.freeze
34
+ BY_TERYT_CODE = ALL.to_h { |voivodeship| [voivodeship.teryt_code, voivodeship] }.freeze
35
+ BY_LOOKUP_NAME = ALL.flat_map { |v| [[v.name, v], [v.slug, v]] }.to_h.freeze
36
+
37
+ class << self
38
+ def all = ALL
39
+
40
+ def find_by_geonames_code(code) = BY_GEONAMES_CODE[code.to_s]
41
+
42
+ def find_by_teryt_code(code) = BY_TERYT_CODE[code.to_s]
43
+
44
+ def find_by_name(name) = BY_LOOKUP_NAME[name.to_s.strip.downcase]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pl/errors"
4
+ require_relative "pl/version"
5
+ require_relative "pl/normalize"
6
+ require_relative "pl/voivodeship"
7
+ require_relative "pl/record"
8
+ require_relative "pl/city"
9
+ require_relative "pl/sources/geonames"
10
+ require_relative "pl/sources/poczta_polska"
11
+ require_relative "pl/configuration"
12
+ require_relative "pl/manifest"
13
+ require_relative "pl/builder"
14
+ require_relative "pl/reader"
15
+ require_relative "pl/railtie" if defined?(Rails::Railtie)
16
+
17
+ module ZipCodes
18
+ module PL
19
+ class << self
20
+ def config
21
+ @config ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield config
26
+ reset!
27
+ config
28
+ end
29
+
30
+ # Drops the loaded dataset so the next read picks up a freshly built file.
31
+ def reset!
32
+ @reader = nil
33
+ self
34
+ end
35
+
36
+ # Streams the file. Holds nothing, costs about 15 ms per call.
37
+ def reader
38
+ @reader ||= Reader.new(config.readable_data_path)
39
+ end
40
+
41
+ def update(output_dir: nil)
42
+ config.output_dir = output_dir if output_dir
43
+ Builder.new(config: config).call.tap { reset! }
44
+ end
45
+
46
+ def manifest = Manifest.read(config.readable_manifest_path)
47
+
48
+ def find_by_postal_code(code) = reader.find_by_postal_code(code)
49
+
50
+ def find_by_city(name, voivodeship: nil) = reader.find_by_city(name, voivodeship: voivodeship)
51
+
52
+ def search_by_city(fragment, voivodeship: nil) = reader.search_by_city(fragment, voivodeship: voivodeship)
53
+
54
+ def cities = reader.cities
55
+
56
+ def each_record(&) = reader.each(&)
57
+
58
+ def voivodeships = Voivodeship.all
59
+ end
60
+ end
61
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zip-codes-pl
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Łukasz Kerl
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rubyzip
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.3'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.3'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
32
+ description: Builds and refreshes a local dataset of Polish postal codes (PNA), including
33
+ locality, voivodeship, county and commune TERYT codes, and coordinates. PNA and
34
+ coordinates come from GeoNames (CC BY 4.0), while administrative names come from
35
+ Poczta Polska.
36
+ executables: []
37
+ extensions: []
38
+ extra_rdoc_files: []
39
+ files:
40
+ - LICENSE.txt
41
+ - README.md
42
+ - README.pl.md
43
+ - data/zip-codes-pl.manifest.json
44
+ - data/zip-codes-pl.tsv
45
+ - lib/zip-codes-pl.rb
46
+ - lib/zip_codes/pl.rb
47
+ - lib/zip_codes/pl/builder.rb
48
+ - lib/zip_codes/pl/city.rb
49
+ - lib/zip_codes/pl/configuration.rb
50
+ - lib/zip_codes/pl/errors.rb
51
+ - lib/zip_codes/pl/manifest.rb
52
+ - lib/zip_codes/pl/normalize.rb
53
+ - lib/zip_codes/pl/railtie.rb
54
+ - lib/zip_codes/pl/reader.rb
55
+ - lib/zip_codes/pl/record.rb
56
+ - lib/zip_codes/pl/sources/geonames.rb
57
+ - lib/zip_codes/pl/sources/poczta_polska.rb
58
+ - lib/zip_codes/pl/sources/poczta_polska/client.rb
59
+ - lib/zip_codes/pl/tasks/zip_codes_pl.rake
60
+ - lib/zip_codes/pl/version.rb
61
+ - lib/zip_codes/pl/voivodeship.rb
62
+ homepage: https://github.com/carlos-1410/zip-codes-pl
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ homepage_uri: https://github.com/carlos-1410/zip-codes-pl
67
+ source_code_uri: https://github.com/carlos-1410/zip-codes-pl
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.2.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.6.9
84
+ specification_version: 4
85
+ summary: Polish postal codes, localities, voivodeships, and coordinates
86
+ test_files: []