e621_export_downloader 0.0.17 → 0.0.18

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d58e880077e657ef28542f3873a57f6b2759c2dc5cc3ace7b39b0b016134b1d
4
- data.tar.gz: 93ba667cb1a85c203cf861aeedfc36ae4ba8dffeb9bc50c08df275a8ccd27844
3
+ metadata.gz: 7259f8077bf1df8805620f4efa3fbeac43c29616e496bea576518275794c5fef
4
+ data.tar.gz: 4b23216298e535aeed4ceaeb7f38f581bf6fc6fb68c1f7fa94975e9157f7bf71
5
5
  SHA512:
6
- metadata.gz: af0d7f73fc67b9c2a291ca5c8de1e200dcd09e42ddf31d4c40fb07938a1f25a13b428dff8ad6e3a44190b6b2c3a672991d8f5b9a40aed2e808286512bf60812e
7
- data.tar.gz: c012e57c2fc91e8d3991f47eb59842947a81acad6137a52f1ad768b8dcaeeb9990b13ffdad5d8d6c398a9dd6ba768928183163af14670398ce78b7874ccdf99b
6
+ metadata.gz: 50436457d2de9e91e8f938631484653a71d8102122ec58f77c8c6287454092c38ef6719215a4aa20cb676978d659ce936750338667d892045027b4ee0a0fa0ed
7
+ data.tar.gz: b8a0c19646cd5b7ed2076b023adabc016962700dcc4c755ef2b66d3f3b018349be45aafca1c4be1dc8c0be9c8f2dd6de5f1529f6ea27f45a04df5660d6f6f1a9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.18] - 2026-07-02
4
+
5
+ _0.0.18_
6
+
3
7
  ## [0.0.17] - 2026-06-29
4
8
 
5
9
  _0.0.17_
data/README.md CHANGED
@@ -23,12 +23,13 @@ client = E621ExportDownloader::Client.new
23
23
 
24
24
  # configure options after creation
25
25
  client.config do |c|
26
- c.cache = true # keep export files after reading, defaults to false
26
+ c.cache = true # keep export files after reading, defaults to false
27
+ c.validate_checksum = true # verify the downloaded file against the API-provided checksum, defaults to false
27
28
  end
28
29
 
29
30
  # or pass an Options struct directly
30
31
  client = E621ExportDownloader::Client.new(
31
- E621ExportDownloader::Client::Options.new(cache: true)
32
+ E621ExportDownloader::Client::Options.new(cache: true, validate_checksum: true)
32
33
  )
33
34
 
34
35
  # get the export for a type — resolves the latest available export from the e621 API
@@ -39,10 +40,10 @@ export = client.get("posts")
39
40
  deferred = client.get_deferred("posts")
40
41
 
41
42
  # get raw metadata for all exports from the e621 API
42
- # returns an Array of E621ExportDownloader::APIExportData: { file_name, file_size, name, updated_at, url }
43
+ # returns an Array of E621ExportDownloader::APIExportData: { file_name, file_size, name, checksum, updated_at, url }
43
44
  data = client.get_data
44
45
 
45
- # access the metadata for this specific export: { file_name, file_size, name, updated_at, url }
46
+ # access the metadata for this specific export: { file_name, file_size, name, checksum, updated_at, url }
46
47
  export.data
47
48
 
48
49
  # check if the export exists
@@ -50,6 +51,7 @@ exists = export.exists?
50
51
 
51
52
  # download the export, returns the file path — not required before reading
52
53
  # if you move or remove the file do not reuse the export object
54
+ # raises ResolveError if validate_checksum is enabled and the downloaded file's checksum does not match the API
53
55
  file = export.download
54
56
 
55
57
  # delete the downloaded file, if it exists
data/Rakefile CHANGED
@@ -8,11 +8,21 @@ Rake::TestTask.new(:test) do |t|
8
8
  t.pattern = "test/**/*_test.rb"
9
9
  end
10
10
 
11
- namespace(:test) do
12
- desc("Test Zeitwerk Auto-Importing")
13
- task(:zeitwerk) do
14
- exec(%(bundle exec ruby -e "require 'e621_export_downloader'; Zeitwerk::Loader.eager_load_all"))
15
- end
11
+ desc("Test Zeitwerk Auto-Importing")
12
+ task(:zeitwerk) do
13
+ ruby(%(-e "require 'e621_export_downloader'; Zeitwerk::Loader.eager_load_all"))
16
14
  end
17
15
 
18
- task(default: :rubocop)
16
+ desc("Run all tests")
17
+ task(:all) do
18
+ puts("=== rubocop ===")
19
+ sh("bundle exec rubocop")
20
+ puts("=== zeitwerk ===")
21
+ sh("bundle exec rake zeitwerk")
22
+ puts("=== test ===")
23
+ sh("bundle exec rake test")
24
+ puts("=== srb ===")
25
+ sh("bundle exec srb tc")
26
+ end
27
+
28
+ task(default: :all)
@@ -29,7 +29,7 @@ module E621
29
29
  approver_id: record.approver_id,
30
30
  consequent_name: record.consequent_name,
31
31
  created_at: record.created_at,
32
- # creator_id: record.creator_id,
32
+ creator_id: record.creator_id,
33
33
  down_votes: record.down_votes,
34
34
  forum_post_id: record.forum_post_id,
35
35
  forum_topic_id: record.forum_topic_id,
@@ -29,7 +29,7 @@ module E621
29
29
  approver_id: record.approver_id,
30
30
  consequent_name: record.consequent_name,
31
31
  created_at: record.created_at,
32
- # creator_id: record.creator_id,
32
+ creator_id: record.creator_id,
33
33
  descendant_names: record.descendant_names,
34
34
  down_votes: record.down_votes,
35
35
  forum_post_id: record.forum_post_id,
@@ -3,9 +3,10 @@
3
3
 
4
4
  module E621ExportDownloader
5
5
  class APIExportData < T::Struct
6
+ const(:name, Types)
6
7
  const(:file_name, String)
7
8
  const(:file_size, Integer)
8
- const(:name, Types)
9
+ const(:checksum, String)
9
10
  const(:updated_at, String)
10
11
  const(:url, String)
11
12
  end
@@ -10,13 +10,17 @@ module E621ExportDownloader
10
10
  sig { returns(T::Boolean) }
11
11
  attr_accessor(:cache)
12
12
 
13
+ sig { returns(T::Boolean) }
14
+ attr_accessor(:validate_checksum)
15
+
13
16
  sig { params(parsers: Options::Parsers).returns(Options::Parsers) }
14
17
  attr_writer(:parsers)
15
18
 
16
19
  sig { params(defaults: Options).void }
17
20
  def initialize(defaults = Options.new)
18
- @cache = T.let(defaults.cache, T::Boolean)
19
- @parsers = T.let(defaults.parsers, Options::Parsers)
21
+ @cache = T.let(defaults.cache, T::Boolean)
22
+ @validate_checksum = T.let(defaults.validate_checksum, T::Boolean)
23
+ @parsers = T.let(defaults.parsers, Options::Parsers)
20
24
  end
21
25
 
22
26
  sig { params(block: T.nilable(T.proc.params(arg0: Builder::Parsers).void)).returns(Options::Parsers) }
@@ -38,6 +38,7 @@ module E621ExportDownloader
38
38
  end
39
39
 
40
40
  const(:cache, T::Boolean, default: false)
41
+ const(:validate_checksum, T::Boolean, default: false)
41
42
  const(:parsers, Parsers, default: Parsers.defaults)
42
43
  end
43
44
  end
@@ -21,7 +21,9 @@ module E621ExportDownloader
21
21
 
22
22
  sig { params(block: T.proc.params(arg0: Options::Builder).void).void }
23
23
  def config(&block)
24
- block.call(Options::Builder.new(@options))
24
+ builder = Options::Builder.new(@options)
25
+ block.call(builder)
26
+ @options = Options.new(cache: builder.cache, validate_checksum: builder.validate_checksum, parsers: builder.parsers)
25
27
  end
26
28
 
27
29
  sig { returns(Faraday::Connection) }
@@ -61,9 +63,10 @@ module E621ExportDownloader
61
63
  data = JSON.parse(T.unsafe(res).body)
62
64
  result = T.let(data.map do |d|
63
65
  APIExportData.new(
66
+ name: Types.deserialize(d["name"]),
64
67
  file_name: d["file_name"],
65
68
  file_size: d["file_size"].to_i,
66
- name: Types.deserialize(d["name"]),
69
+ checksum: d["checksum"],
67
70
  updated_at: d["updated_at"],
68
71
  url: d["url"],
69
72
  )
@@ -2,6 +2,10 @@
2
2
  # typed: strict
3
3
 
4
4
  require("csv")
5
+ require("date")
6
+ require("digest")
7
+ require("fileutils")
8
+ require("zlib")
5
9
 
6
10
  module E621ExportDownloader
7
11
  class Export
@@ -51,11 +55,13 @@ module E621ExportDownloader
51
55
  client.debug("downloading export", header: ["export:#{type.serialize}"])
52
56
 
53
57
  FileUtils.mkdir_p(Constants::TEMP_DIR)
58
+ digest = T.let(client.options.validate_checksum ? Digest::MD5.new : nil, T.nilable(Digest::MD5))
54
59
  File.open(file_path, "wb") do |file|
55
60
  inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
56
61
 
57
62
  res = client.connection.get(data.url) do |req|
58
63
  req.options.on_data = proc do |chunk, received|
64
+ digest&.update(chunk)
59
65
  decompressed = inflater.inflate(chunk)
60
66
  file.write(decompressed) if decompressed && !decompressed.empty?
61
67
  block.call(received, data.file_size) if block
@@ -69,6 +75,10 @@ module E621ExportDownloader
69
75
  unless res.success?
70
76
  raise(ResolveError, "Failed to download export #{type.serialize}: #{res.status} #{res.reason_phrase}")
71
77
  end
78
+
79
+ if digest && digest.hexdigest != data.checksum
80
+ raise(ResolveError, "Checksum mismatch for export #{type.serialize}")
81
+ end
72
82
  rescue # rubocop:disable Style/RescueStandardError
73
83
  FileUtils.rm_f(file_path)
74
84
  raise
@@ -18,8 +18,8 @@ module E621ExportDownloader
18
18
  sig { returns(T.nilable(DateTime)) }
19
19
  attr_reader(:created_at)
20
20
 
21
- # sig { returns(T.nilable(Integer)) }
22
- # attr_reader(:creator_id)
21
+ sig { returns(T.nilable(Integer)) }
22
+ attr_reader(:creator_id)
23
23
 
24
24
  sig { returns(Integer) }
25
25
  attr_reader(:down_votes)
@@ -58,7 +58,7 @@ module E621ExportDownloader
58
58
  @approver_id = T.let(T.must(record["approver_id"]).empty? ? nil : record["approver_id"].to_i, T.nilable(Integer))
59
59
  @consequent_name = T.let(T.must(record["consequent_name"]), String)
60
60
  @created_at = T.let(T.must(record["created_at"]).empty? ? nil : DateTime.parse(record["created_at"]), T.nilable(DateTime))
61
- # @creator_id = T.let(T.must(record["creator_id"]).empty? ? nil : record["creator_id"].to_i, T.nilable(Integer))
61
+ @creator_id = T.let(T.must(record["creator_id"]).empty? ? nil : record["creator_id"].to_i, T.nilable(Integer))
62
62
  @down_votes = T.let(record["down_votes"].to_i, Integer)
63
63
  @forum_post_id = T.let(T.must(record["forum_post_id"]).empty? ? nil : record["forum_post_id"].to_i, T.nilable(Integer))
64
64
  @forum_topic_id = T.let(T.must(record["forum_topic_id"]).empty? ? nil : record["forum_topic_id"].to_i, T.nilable(Integer))
@@ -78,7 +78,7 @@ module E621ExportDownloader
78
78
  approver_id: @approver_id,
79
79
  consequent_name: @consequent_name,
80
80
  created_at: @created_at,
81
- # creator_id: @creator_id,
81
+ creator_id: @creator_id,
82
82
  down_votes: @down_votes,
83
83
  forum_post_id: @forum_post_id,
84
84
  forum_topic_id: @forum_topic_id,
@@ -18,8 +18,8 @@ module E621ExportDownloader
18
18
  sig { returns(T.nilable(DateTime)) }
19
19
  attr_reader(:created_at)
20
20
 
21
- # sig { returns(T.nilable(Integer)) }
22
- # attr_reader(:creator_id)
21
+ sig { returns(T.nilable(Integer)) }
22
+ attr_reader(:creator_id)
23
23
 
24
24
  sig { returns(T::Array[String]) }
25
25
  attr_reader(:descendant_names)
@@ -58,7 +58,7 @@ module E621ExportDownloader
58
58
  @approver_id = T.let(T.must(record["approver_id"]).empty? ? nil : record["approver_id"].to_i, T.nilable(Integer))
59
59
  @consequent_name = T.let(T.must(record["consequent_name"]), String)
60
60
  @created_at = T.let(T.must(record["created_at"]).empty? ? nil : DateTime.parse(record["created_at"]), T.nilable(DateTime))
61
- # @creator_id = T.let(T.must(record["creator_id"]).empty? ? nil : record["creator_id"].to_i, T.nilable(Integer))
61
+ @creator_id = T.let(T.must(record["creator_id"]).empty? ? nil : record["creator_id"].to_i, T.nilable(Integer))
62
62
  inner = T.must(T.must(record["descendant_names"])[1..-2])
63
63
  @descendant_names = T.let(inner.empty? ? [] : inner.split(","), T::Array[String])
64
64
  @down_votes = T.let(record["down_votes"].to_i, Integer)
@@ -79,7 +79,7 @@ module E621ExportDownloader
79
79
  approver_id: @approver_id,
80
80
  consequent_name: @consequent_name,
81
81
  created_at: @created_at,
82
- # creator_id: @creator_id,
82
+ creator_id: @creator_id,
83
83
  descendant_names: @descendant_names,
84
84
  down_votes: @down_votes,
85
85
  forum_post_id: @forum_post_id,
@@ -4,7 +4,7 @@
4
4
  # loaded by bundler
5
5
  module E621ExportDownloader
6
6
  module Constants
7
- VERSION = "0.0.17"
7
+ VERSION = "0.0.18"
8
8
  WEBSITE = "https://github.com/DonovanDMC/E621ExportDownloader.rb"
9
9
  end
10
10
  end
@@ -3,9 +3,16 @@
3
3
  require("zeitwerk")
4
4
  require("sorbet-runtime")
5
5
 
6
+ # Loaded eagerly (rather than left to Zeitwerk) because Bundler's gemspec evaluation already
7
+ # requires version.rb directly to read the gem version, which predefines E621ExportDownloader::Constants
8
+ # before Zeitwerk::Loader#setup runs below. That premature definition makes Zeitwerk skip autoloading
9
+ # constants.rb entirely, since it only autoloads namespaces it hasn't already seen defined.
10
+ require_relative("e621_export_downloader/constants")
11
+
6
12
  loader = Zeitwerk::Loader.for_gem
7
13
  loader.inflector.inflect({ "api_export_data" => "APIExportData" })
8
14
  loader.ignore("#{__dir__}/e621_export_downloader/version.rb")
15
+ loader.ignore("#{__dir__}/e621_export_downloader/constants.rb")
9
16
  loader.ignore("#{__dir__}/e621_export_downloader/railtie.rb")
10
17
  loader.ignore("#{__dir__}/e621_export_downloader/serializers/active_job.rb")
11
18
  loader.ignore("#{__dir__}/e621_export_downloader/active_record_models.rb")
@@ -68,7 +68,7 @@ class CreateE621Tables < ActiveRecord::Migration<%= migration_version %>
68
68
  t.boolean(:is_pending, null: false)
69
69
  t.boolean(:is_rating_locked, null: false)
70
70
  t.boolean(:is_status_locked, null: false)
71
- t.text(:locked_tags, null: false)
71
+ t.text(:locked_tags)
72
72
  t.string(:md5, null: false)
73
73
  t.bigint(:parent_id)
74
74
  t.string(:rating, null: false)
@@ -136,6 +136,7 @@ class CreateE621Tables < ActiveRecord::Migration<%= migration_version %>
136
136
  t.integer(:down_votes, null: false)
137
137
  t.integer(:meh_votes, null: false)
138
138
  t.integer(:up_votes, null: false)
139
+ t.integer(:creator_id, null: false)
139
140
  end
140
141
 
141
142
  create_table(<%= table_ref("tag_implications") %>, id: :bigint, default: nil, force: :cascade) do |t|
@@ -152,6 +153,7 @@ class CreateE621Tables < ActiveRecord::Migration<%= migration_version %>
152
153
  t.integer(:down_votes, null: false)
153
154
  t.integer(:meh_votes, null: false)
154
155
  t.integer(:up_votes, null: false)
156
+ t.integer(:creator_id, null: false)
155
157
  end
156
158
 
157
159
  create_table(<%= table_ref("tags") %>, id: :bigint, default: nil, force: :cascade) do |t|
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require("minitest/autorun")
4
+ require("digest")
5
+ require("fileutils")
6
+ require("zlib")
7
+ require("e621_export_downloader")
8
+
9
+ module ExportTestHelpers
10
+ # Stands in for a Faraday::Connection, driving the same #get(url) { |req| req.options.on_data = ... }
11
+ # streaming contract that Export#download relies on, without touching the network.
12
+ class FakeConnection
13
+ def initialize(chunks)
14
+ @chunks = chunks
15
+ end
16
+
17
+ def get(_url)
18
+ req = Struct.new(:options).new(Struct.new(:on_data).new)
19
+ yield(req)
20
+
21
+ received = 0
22
+ @chunks.each do |chunk|
23
+ received += chunk.bytesize
24
+ req.options.on_data.call(chunk, received)
25
+ end
26
+
27
+ Struct.new(:status, :reason_phrase, :success?).new(200, "OK", true)
28
+ end
29
+
30
+ def head(_url)
31
+ Struct.new(:success?).new(true)
32
+ end
33
+ end
34
+ end
35
+
36
+ class ExportChecksumTest < Minitest::Test
37
+ include(ExportTestHelpers)
38
+
39
+ def setup
40
+ @csv_content = "id,name\n1,foo\n"
41
+ @compressed = Zlib.gzip(@csv_content)
42
+ @checksum = Digest::MD5.hexdigest(@compressed)
43
+ # split across chunks to exercise streaming, matching how a real download arrives
44
+ midpoint = @compressed.bytesize / 2
45
+ @chunks = [@compressed.byteslice(0, midpoint), @compressed.byteslice(midpoint..)]
46
+ end
47
+
48
+ def teardown
49
+ FileUtils.rm_rf(E621ExportDownloader::Constants::TEMP_DIR)
50
+ end
51
+
52
+ def build_export(checksum:, validate_checksum:)
53
+ data = E621ExportDownloader::APIExportData.new(
54
+ name: E621ExportDownloader::Types::Posts,
55
+ file_name: "posts-checksum-test.csv",
56
+ file_size: @compressed.bytesize,
57
+ checksum: checksum,
58
+ updated_at: "2026-01-01",
59
+ url: "https://example.com/posts.csv.gz",
60
+ )
61
+ client = E621ExportDownloader::Client.new(
62
+ E621ExportDownloader::Client::Options.new(validate_checksum: validate_checksum),
63
+ )
64
+ chunks = @chunks
65
+ client.define_singleton_method(:connection) { FakeConnection.new(chunks) }
66
+ E621ExportDownloader::Export.new(
67
+ data: data,
68
+ client: client,
69
+ type: E621ExportDownloader::Types::Posts,
70
+ parser: ->(record) { record },
71
+ )
72
+ end
73
+
74
+ def test_download_succeeds_when_checksum_matches_and_validation_is_enabled
75
+ export = build_export(checksum: @checksum, validate_checksum: true)
76
+ path = export.download
77
+ assert(File.exist?(path))
78
+ assert_equal(@csv_content, File.read(path))
79
+ end
80
+
81
+ def test_download_raises_and_removes_the_file_when_checksum_does_not_match
82
+ export = build_export(checksum: "0" * 32, validate_checksum: true)
83
+ error = assert_raises(E621ExportDownloader::ResolveError) { export.download }
84
+ assert_match(/Checksum mismatch/, error.message)
85
+ assert_empty(Dir.children(E621ExportDownloader::Constants::TEMP_DIR))
86
+ end
87
+
88
+ def test_download_ignores_checksum_mismatch_when_validation_is_disabled
89
+ export = build_export(checksum: "0" * 32, validate_checksum: false)
90
+ path = export.download
91
+ assert(File.exist?(path))
92
+ end
93
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: e621_export_downloader
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.17
4
+ version: 0.0.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Donovan_DMC
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-29 00:00:00.000000000 Z
10
+ date: 2026-07-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: csv
@@ -223,6 +223,7 @@ files:
223
223
  - sorbet/rbi/todo.rbi
224
224
  - sorbet/tapioca/config.yml
225
225
  - sorbet/tapioca/require.rb
226
+ - test/export_test.rb
226
227
  - test/generators/install_generator_test.rb
227
228
  homepage: https://github.com/DonovanDMC/E621ExportDownloader.rb
228
229
  licenses: