gemchat 0.1.0

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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module Gemchat
6
+ # Indexes the gems in a lockfile. ri generation is the main path, because
7
+ # Bundler does not produce ri on install (§8.3); reading a pre-existing store
8
+ # is a fast path for `gem install` users and for anyone who ran `gem rdoc`.
9
+ class Indexer
10
+ # `status` is what the store did: :indexed, :updated or :unchanged. It is
11
+ # deliberately separate from `ri_source`, which describes where the ri came
12
+ # from. Cached ri plus an unchanged digest is a normal no-op, and conflating
13
+ # the two makes it look like work happened.
14
+ Result = Struct.new(:name, :version, :status, :chunks, :ri_source, :error) do
15
+ def ok?
16
+ error.nil?
17
+ end
18
+
19
+ def changed?
20
+ status == :indexed || status == :updated
21
+ end
22
+ end
23
+
24
+ def initialize(store:, lockfile_path: "Gemfile.lock", include_transitive: false,
25
+ all_languages: false)
26
+ @store = store
27
+ @lockfile_path = lockfile_path
28
+ @include_transitive = include_transitive
29
+ @all_languages = all_languages
30
+ end
31
+
32
+ def lockfile_specs
33
+ parser = ::Bundler::LockfileParser.new(File.read(@lockfile_path))
34
+ specs = parser.specs.reject { |s| s.name == "ruby-core-stdlib" }
35
+ unless @include_transitive
36
+ direct = parser.dependencies.keys - ["ruby-core-stdlib"]
37
+ specs = specs.select { |s| direct.include?(s.name) }
38
+ end
39
+ specs.map { |s| [s.name, s.version.to_s] }
40
+ end
41
+
42
+ def index_gem(name, version, prefer_existing: true)
43
+ gem_dir = gem_dir_for(name, version)
44
+ return Result.new(name:, version:, status: :missing, chunks: 0, ri_source: "missing") unless gem_dir
45
+
46
+ ri_source = nil
47
+ ri_path = prefer_existing ? Ri.existing_store_path(name, version) : nil
48
+ ri_source = "existing" if ri_path
49
+
50
+ if ri_path.nil?
51
+ ri_path = Ri.generated_store_path(name, version)
52
+ if File.directory?(ri_path)
53
+ ri_source ||= "generated"
54
+ else
55
+ begin
56
+ ri_path = Ri.generate(name, version, gem_dir:, output_dir: ri_path)
57
+ ri_source = "generated"
58
+ rescue Ri::GenerationError => e
59
+ return Result.new(name:, version:, status: :failed, chunks: 0, ri_source: "failed", error: e.message)
60
+ end
61
+ end
62
+ end
63
+
64
+ chunks = Chunker.from_ri(name, version, Ri.read(ri_path, name))
65
+ attach_sources(chunks, Symbols.for(gem_dir))
66
+ outcome = @store.replace_gem(name, version, "ri", chunks)
67
+
68
+ # The prose half, indexed separately and labelled by source_type. Stored
69
+ # under its own source_type so a gem that gains or loses a README does not
70
+ # force the much larger ri row to be rewritten.
71
+ prose = Prose.chunks(name, version, gem_dir, all_languages: @all_languages)
72
+ prose_outcome = write_prose(name, version, prose)
73
+
74
+ Result.new(name:, version:, status: outcome.status,
75
+ chunks: outcome.chunks + prose_outcome.sum(&:chunks), ri_source: ri_source)
76
+ rescue => e
77
+ Result.new(name:, version:, status: :error, chunks: 0, ri_source: "error", error: "#{e.class}: #{e.message}")
78
+ end
79
+
80
+ # Each prose source is written under its own source_type so a gem that gains
81
+ # or loses a README never rewrites its much larger ri row.
82
+ def write_prose(name, version, chunks)
83
+ chunks.group_by(&:source_type).map do |source_type, group|
84
+ @store.replace_gem(name, version, source_type, group)
85
+ end
86
+ end
87
+
88
+ # Stamps each ri chunk with the file and line its definition came from, using
89
+ # the Prism pass's table.
90
+ #
91
+ # Matching is on (class_name, method_name, method_type) because that is all an
92
+ # ri chunk knows and all a definition carries. It is deliberately not
93
+ # fuzzy: a chunk that cannot be matched keeps a nil location, and an invented
94
+ # path is worse than no path for a citation.
95
+ def attach_sources(chunks, table)
96
+ return chunks if table.empty?
97
+
98
+ chunks.each do |chunk|
99
+ definition = table[[chunk.class_name.to_s, chunk.method_name.to_s, chunk.method_type.to_s]]
100
+ next if definition.nil?
101
+
102
+ chunk.source_path = definition.source_path
103
+ chunk.source_line = definition.source_line
104
+ end
105
+ end
106
+
107
+ def gem_dir_for(name, version)
108
+ spec = Gem::Specification.find_by_name(name, version)
109
+ spec&.gem_dir
110
+ rescue Gem::MissingSpecError
111
+ nil
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Gemchat
6
+ # `gemchat init` — the once-per-project setup, and the only command that may
7
+ # modify a Gemfile.
8
+ #
9
+ # Separate from `index` on purpose. A command called `index` that edits your
10
+ # Gemfile is a surprise, and in CI or a pre-commit hook it would either block
11
+ # on a prompt — hanging the build — or silently rewrite a file it has no
12
+ # business touching. So `index` stays non-interactive and read-only, and this
13
+ # one is explicit about being the mutating one.
14
+ #
15
+ # Idempotent: running it twice changes nothing and says so. That matters
16
+ # because the two lines it adds are easy to get wrong by hand, and a
17
+ # half-configured plugin fails *silently* — the hook appears installed and
18
+ # never fires.
19
+ class Init
20
+ GEM_LINE = 'gem "gemchat", group: :development'
21
+ PLUGIN_LINE = 'plugin "gemchat"'
22
+ GEMFILE = "Gemfile"
23
+ COMMENT = "# gemchat: local documentation index"
24
+
25
+ Result = Struct.new(:added, :already_present, :indexed) do
26
+ def changed? = added.any?
27
+ end
28
+
29
+ def initialize(dir: Dir.pwd, stdout: $stdout, stderr: $stderr, assume_yes: false)
30
+ @dir = dir
31
+ @stdout = stdout
32
+ @stderr = stderr
33
+ @assume_yes = assume_yes
34
+ end
35
+
36
+ def gemfile_path
37
+ File.join(@dir, GEMFILE)
38
+ end
39
+
40
+ def run
41
+ unless File.file?(gemfile_path)
42
+ raise Error, "no #{GEMFILE} in #{@dir} — run `bundle init` first"
43
+ end
44
+
45
+ unless Lockfile.exist?(@dir)
46
+ raise Error, "no Gemfile.lock in #{@dir} — run `bundle install` first, " \
47
+ "so gemchat has a bundle to index"
48
+ end
49
+
50
+ result = Result.new(added: [], already_present: [], indexed: false)
51
+
52
+ pending = [GEM_LINE, PLUGIN_LINE].reject do |line|
53
+ present = declared?(line)
54
+ result.already_present << line if present
55
+ present
56
+ end
57
+
58
+ unless pending.empty?
59
+ confirm!(pending)
60
+ append(pending)
61
+ result.added.concat(pending)
62
+ end
63
+
64
+ # Opting in by running init is deliberate, so trust is granted here. It
65
+ # also writes .gemchat.yml, which is what lets the hook skip the next
66
+ # bundle install for free.
67
+ Trust.grant!(@dir, via: "gemchat init")
68
+ result.indexed = true
69
+ result
70
+ end
71
+
72
+ # Matches the *directive* as well as the name, so a user's own
73
+ # `gem "gemchat", path: "..."` counts as declaring the dependency while a
74
+ # `plugin "gemchat"` counts for the plugin. Keying on the name alone
75
+ # reported both as present the moment either was written, and the second
76
+ # line was silently never added -- the plugin then never fires, which is the
77
+ # exact silent failure this command exists to prevent.
78
+ def declared?(line)
79
+ return false unless File.file?(gemfile_path)
80
+
81
+ directive = line[/\A(gem|plugin)\b/, 1]
82
+ gem_name = line[/"([^"]+)"/, 1]
83
+ return false unless directive && gem_name
84
+
85
+ File.readlines(gemfile_path).any? do |candidate|
86
+ candidate.match?(/^\s*#{Regexp.escape(directive)}\s+["']#{Regexp.escape(gem_name)}["']/)
87
+ end
88
+ end
89
+
90
+ def append(lines)
91
+ body = File.read(gemfile_path)
92
+ body += "\n" unless body.empty? || body.end_with?("\n")
93
+ File.write(gemfile_path, "#{body}\n#{COMMENT}\n#{lines.join("\n")}\n")
94
+ end
95
+
96
+ def confirm!(lines)
97
+ return if @assume_yes || !@stdout.tty?
98
+
99
+ lines.each { |line| @stdout.puts("add to #{GEMFILE}: #{line}") }
100
+ @stdout.print " ok? [y/N] "
101
+ answer = $stdin.gets.to_s.strip.downcase
102
+ return if ["y", "yes"].include?(answer)
103
+
104
+ raise Error, "aborted; #{GEMFILE} unchanged"
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Gemchat
6
+ # The lockfile digest, which is the whole basis of the staleness check.
7
+ #
8
+ # Every command that changes a bundle — `install`, `add`, `update` — rewrites
9
+ # Gemfile.lock, so comparing one sha256 covers all three. That is why the
10
+ # plugin never has to work out which command ran.
11
+ module Lockfile
12
+ FILENAME = "Gemfile.lock"
13
+
14
+ module_function
15
+
16
+ def path(dir = Dir.pwd)
17
+ File.join(dir, FILENAME)
18
+ end
19
+
20
+ def exist?(dir = Dir.pwd)
21
+ File.file?(path(dir))
22
+ end
23
+
24
+ # nil when there is no lockfile, which callers must treat as "unknown"
25
+ # rather than "unchanged" — otherwise a project without a lockfile would
26
+ # silently report an up-to-date index forever.
27
+ def digest(dir = Dir.pwd)
28
+ file = path(dir)
29
+ return nil unless File.file?(file)
30
+
31
+ Digest::SHA256.file(file).hexdigest
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Gemchat
6
+ # `.gemchat.yml` — the per-project record of what the index was built from.
7
+ #
8
+ # This exists so the staleness check can answer "is anything to do?" without
9
+ # opening SQLite or reading ri. A plugin hook fires on every bundle install in
10
+ # a project, and the overwhelming majority of those change nothing; the digest
11
+ # is a 64-byte file read that keeps them free.
12
+ #
13
+ # It is also checked-in code-adjacent state, so it is covered by the same
14
+ # trust gate as the plugin itself (§17): a manifest arriving in a repository
15
+ # you cloned is not evidence of anything.
16
+ class Manifest
17
+ FILENAME = ".gemchat.yml"
18
+
19
+ attr_reader :dir, :lockfile_sha256, :gems, :hosted
20
+
21
+ def initialize(dir = Dir.pwd)
22
+ @dir = dir
23
+ @lockfile_sha256 = nil
24
+ @gems = {}
25
+ @hosted = false
26
+ load
27
+ end
28
+
29
+ def self.path(dir = Dir.pwd)
30
+ File.join(dir, FILENAME)
31
+ end
32
+
33
+ def exist?
34
+ File.file?(self.class.path(dir))
35
+ end
36
+
37
+ def load
38
+ file = self.class.path(dir)
39
+ return self unless File.file?(file)
40
+
41
+ data = YAML.safe_load_file(file, permitted_classes: [Symbol], aliases: true)
42
+ return self unless data.is_a?(Hash)
43
+
44
+ @lockfile_sha256 = data["lockfile_sha256"]
45
+ @gems = data["gems"].is_a?(Hash) ? data["gems"] : {}
46
+ @hosted = data["hosted"] == true
47
+ self
48
+ rescue
49
+ # A corrupt manifest must not be fatal. Treating it as absent means the
50
+ # next run re-indexes, which is slower but correct; refusing to start
51
+ # would leave the project permanently un-indexed.
52
+ @lockfile_sha256 = nil
53
+ @gems = {}
54
+ @hosted = false
55
+ self
56
+ end
57
+
58
+ # True only when the lockfile is byte-identical to the one the index was
59
+ # built from. A missing manifest, a missing lockfile, or a corrupt manifest
60
+ # all mean "do the work".
61
+ def up_to_date?(current_digest)
62
+ return false if current_digest.nil?
63
+ return false if lockfile_sha256.nil?
64
+
65
+ lockfile_sha256 == current_digest
66
+ end
67
+
68
+ def record(lockfile_digest, gem_versions = {})
69
+ @lockfile_sha256 = lockfile_digest
70
+ @gems = gem_versions
71
+ self
72
+ end
73
+
74
+ def write
75
+ File.write(self.class.path(dir), to_yaml)
76
+ self
77
+ end
78
+
79
+ def to_yaml
80
+ data = {"lockfile_sha256" => lockfile_sha256, "gems" => gems}
81
+ # Only written when true. A `hosted: false` in every project would churn
82
+ # every .gemchat.yml in the world to record a default nobody set.
83
+ data["hosted"] = true if @hosted
84
+ data.to_yaml
85
+ end
86
+
87
+ # The project-level default set by `gemchat init --hosted`. Separate from
88
+ # GEMCHAT_HOST and from a key being present: those mean the tier is
89
+ # *available*, this means this project asked for it.
90
+ def hosted=(value)
91
+ @hosted = value == true
92
+ end
93
+
94
+ def hosted? = @hosted
95
+
96
+ def version_for(name)
97
+ gems.dig(name, "version")
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemchat
4
+ # Splits a Markdown or RDoc document into heading-scoped sections.
5
+ #
6
+ # Heading scope is the right unit for prose, the same way a method is the right
7
+ # unit for ri. A README's value is concentrated in its sections — "how to
8
+ # configure the thread pool" lives under a heading, not smeared across the
9
+ # file — and a chunk that carries its heading path can be found by the words
10
+ # that heading uses.
11
+ #
12
+ # Handles all three heading styles found in gem READMEs: ATX (`## Title`),
13
+ # setext (a title underlined with `===`), and RDoc (`== Title`).
14
+ module Markdown
15
+ Section = Struct.new(:path, :body) do
16
+ # Breadcrumb, e.g. "Configuration > Rack handlers".
17
+ def title
18
+ path.join(" > ")
19
+ end
20
+ end
21
+
22
+ ATX = /\A(\#{1,6})\s+(.+?)\s*\#*\s*\z/
23
+ RDOC = /\A(={1,6})\s+(.+?)\s*\z/
24
+ SETEXT = /\A(={3,}|-{3,})\s*\z/
25
+ FENCE = /\A\s*(```+|~~~+)/
26
+
27
+ # A section under construction: the heading path plus the lines seen so far.
28
+ Pending = Struct.new(:path, :lines)
29
+
30
+ module_function
31
+
32
+ def sections(text)
33
+ finished = []
34
+ pending = nil
35
+ stack = []
36
+ previous = nil
37
+ fenced = false
38
+
39
+ text.to_s.each_line do |line|
40
+ if line.match?(FENCE)
41
+ # Inside a fence a `#` is code, not a heading. Splitting a section at a
42
+ # comment inside an example is both wrong and confusing: the example
43
+ # and the prose introducing it become separate chunks.
44
+ fenced = !fenced
45
+ pending&.lines&.<< line
46
+ previous = line
47
+ next
48
+ end
49
+
50
+ if fenced
51
+ pending&.lines&.<< line
52
+ previous = line
53
+ next
54
+ end
55
+
56
+ # A setext underline applies to the line *before* it, so that line has to
57
+ # be pulled back out of the body it was already collected into.
58
+ if setext?(line, previous)
59
+ pending&.lines&.pop
60
+ finished, pending, stack = flush(finished, pending, stack,
61
+ [line.start_with?("=") ? 1 : 2, previous.strip])
62
+ elsif (heading = heading_for(line))
63
+ finished, pending, stack = flush(finished, pending, stack, heading)
64
+ elsif pending
65
+ pending.lines << line
66
+ end
67
+
68
+ previous = line
69
+ end
70
+
71
+ finished << pending if pending
72
+ finished.filter_map { |p| to_section(p) }
73
+ end
74
+
75
+ def heading_for(line)
76
+ if (m = line.match(ATX))
77
+ [m[1].length, m[2].strip]
78
+ elsif (m = line.match(RDOC))
79
+ [m[1].length, m[2].strip]
80
+ end
81
+ end
82
+
83
+ def setext?(line, previous)
84
+ line.match?(SETEXT) && previous && !previous.strip.empty? &&
85
+ !previous.start_with?("#", "=")
86
+ end
87
+
88
+ def flush(finished, pending, stack, heading)
89
+ finished << pending if pending
90
+ level, title = heading
91
+ stack.pop while stack.any? && stack.last[0] >= level
92
+ stack.push([level, title])
93
+ [finished, Pending.new(stack.map(&:last), []), stack]
94
+ end
95
+
96
+ def to_section(pending)
97
+ body = pending.lines.join.strip
98
+ return nil if pending.path.empty? && body.empty?
99
+
100
+ Section.new(pending.path, body)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Gemchat
9
+ # The embedder model: where it comes from, how it is fetched, and how it is
10
+ # verified.
11
+ #
12
+ # The plan originally specified `embeddinggemma-300M-Q8_0` as the default. That
13
+ # model is **gated: manual** on HuggingFace, so fetching it needs a license
14
+ # click and a personal token. An unattended `gemchat embed` cannot do that, and
15
+ # a default that fails for every user is not a default. `nomic-embed-text-v1.5`
16
+ # is ungated, Apache-2.0, 768-dimensional (matching gemchat_app's vectors, so
17
+ # the two agree on what a good retrieval looks like), and its Q8_0 build is
18
+ # 146MB rather than 300MB.
19
+ module Models
20
+ REGISTRY = {
21
+ "nomic-embed-text-v1.5" => {
22
+ file: "nomic-embed-text-v1.5.Q8_0.gguf",
23
+ url: "https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q8_0.gguf",
24
+ # Measured, not assumed: the sha256 of the bytes this build actually
25
+ # serves. A pinned digest is what makes "checksum verification" in the
26
+ # plan mean something.
27
+ sha256: "3e24342164b3d94991ba9692fdc0dd08e3fd7362e0aacc396a9a5c54a544c3b7",
28
+ bytes: 146_146_432,
29
+ dimensions: 768,
30
+ license: "Apache-2.0",
31
+ # Nomic models are trained with task prefixes. Without them the query and
32
+ # document embeddings sit in slightly different regions and retrieval
33
+ # degrades: measured on this corpus, the correct hit's similarity rose
34
+ # from 0.515 to 0.580 once both prefixes were applied.
35
+ #
36
+ # Modelled per model rather than hardcoded, because a different default
37
+ # would have its own convention or none.
38
+ query_prefix: "search_query: ",
39
+ document_prefix: "search_document: "
40
+ }
41
+ }.freeze
42
+
43
+ DEFAULT = "nomic-embed-text-v1.5"
44
+
45
+ class Error < Gemchat::Error; end
46
+
47
+ module_function
48
+
49
+ def default
50
+ ENV["GEMCHAT_EMBED_MODEL"] || DEFAULT
51
+ end
52
+
53
+ def spec(name = default)
54
+ REGISTRY.fetch(name) { raise Error, "unknown embedding model: #{name.inspect}" }
55
+ end
56
+
57
+ def dir
58
+ File.join(Gemchat.root, "models")
59
+ end
60
+
61
+ def path(name = default)
62
+ File.join(dir, spec(name)[:file])
63
+ end
64
+
65
+ # Identifies vectors in the store. Versioned, because swapping the file
66
+ # behind the same name would silently mix incompatible vectors. The prefixes
67
+ # are part of the identity for the same reason: changing them changes what
68
+ # the vectors mean, so an existing index must be rebuilt, not extended.
69
+ def identity(name = default)
70
+ "#{name}@q8_0+prefixed"
71
+ end
72
+
73
+ def prefix(kind, name = default)
74
+ info = spec(name)
75
+ (kind.to_sym == :query) ? info[:query_prefix].to_s : info[:document_prefix].to_s
76
+ end
77
+
78
+ # Size only, not the digest: SHA-256 over 146MB costs real time and this is
79
+ # called on every `status` and every `search`. `download` verifies the digest
80
+ # before the file is ever moved into place, so a wrong file cannot be
81
+ # installed by this path -- only by something writing there directly.
82
+ def installed?(name = default)
83
+ File.file?(path(name)) && size_of(name) == spec(name)[:bytes]
84
+ end
85
+
86
+ def size_of(name = default)
87
+ File.size(path(name))
88
+ rescue Errno::ENOENT
89
+ nil
90
+ end
91
+
92
+ # Explains what to do when the model is absent. Returns true when it is
93
+ # there. This never blocks a download: `gemchat embed` downloads on demand,
94
+ # and the only thing standing between a missing model and a fetched one is
95
+ # this line, so anything that treats a false return as "give up" silently
96
+ # disables embedding forever.
97
+ def present(name = default)
98
+ return true if installed?(name)
99
+
100
+ warn <<~MSG
101
+ #{name} is not downloaded yet (#{megabytes(name)}MB).
102
+
103
+ gemchat embed # downloads it, then embeds the index
104
+ gemchat status # what is present
105
+
106
+ Until then, `gemchat search` uses BM25 only, which works offline with no
107
+ model at all. It finds exact identifiers well; it will not match a
108
+ question whose words never appear in the documentation.
109
+ MSG
110
+ false
111
+ end
112
+
113
+ def megabytes(name = default)
114
+ (spec(name)[:bytes] / 1_000_000.0).round
115
+ end
116
+
117
+ # Streams to a .part file and renames on success, so an interrupted download
118
+ # can never leave a truncated file that later looks installed. Resumes with a
119
+ # Range request when a partial file is already there.
120
+ def download(name = default, stdout: $stdout, force: false)
121
+ info = spec(name)
122
+ target = path(name)
123
+ partial = "#{target}.part"
124
+
125
+ FileUtils.rm_f(target) if force && File.exist?(target)
126
+ return target if installed?(name)
127
+
128
+ FileUtils.mkdir_p(dir)
129
+ resume_from = File.exist?(partial) ? File.size(partial) : 0
130
+ if resume_from.positive? && resume_from >= info[:bytes]
131
+ FileUtils.rm_f(partial)
132
+ resume_from = 0
133
+ end
134
+
135
+ uri = URI.parse(info[:url])
136
+ downloaded = resume_from
137
+ stream(uri, info, partial, resume_from) do |_chunk, total|
138
+ downloaded = total
139
+ report_progress(stdout, downloaded, info[:bytes])
140
+ end
141
+
142
+ unless File.size(partial) == info[:bytes]
143
+ raise Error, "incomplete download: got #{File.size(partial)} of #{info[:bytes]} bytes (kept #{File.basename(partial)} to resume)"
144
+ end
145
+
146
+ actual = Digest::SHA256.file(partial).hexdigest
147
+ unless actual == info[:sha256]
148
+ FileUtils.rm_f(partial)
149
+ raise Error, "checksum mismatch for #{info[:file]}\n expected #{info[:sha256]}\n actual #{actual}"
150
+ end
151
+
152
+ FileUtils.mv(partial, target)
153
+ stdout.puts(" verified sha256 #{actual[0, 16]}…")
154
+ target
155
+ end
156
+
157
+ # Streams the body into `partial`, following redirects.
158
+ #
159
+ # Following them is not optional. HuggingFace answers a model URL with a 302
160
+ # to a CDN host, and Net::HTTP does not follow redirects, so a request that
161
+ # only accepts Net::HTTPSuccess fails on the very first response -- reported
162
+ # as "HTTP 302", which reads like a broken link rather than a normal,
163
+ # expected part of the fetch.
164
+ #
165
+ # The Authorization header is dropped once a redirect crosses to another
166
+ # host: the CDN does not need it, and resending a token to a host the user
167
+ # did not name is not something to do by accident.
168
+ def stream(uri, info, partial, resume_from, max_hops = 5)
169
+ File.open(partial, resume_from.positive? ? "ab" : "wb") do |file|
170
+ total = resume_from
171
+
172
+ loop do
173
+ request = Net::HTTP::Get.new(uri)
174
+ request["Range"] = "bytes=#{resume_from}-" if resume_from.positive?
175
+ request["Authorization"] = "Bearer #{ENV["HF_TOKEN"]}" if token?
176
+
177
+ redirect = nil
178
+
179
+ # A connection per hop, never one connection reused across a redirect.
180
+ # The redirect target is a different host, and replaying a request for
181
+ # a second host down the first host's TLS session is what turns a
182
+ # perfectly good signed CDN URL into a 403.
183
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
184
+ http.request(request) do |res|
185
+ if res.is_a?(Net::HTTPRedirection) && res["location"]
186
+ redirect = URI.join(uri, res["location"])
187
+ next
188
+ end
189
+
190
+ unless res.is_a?(Net::HTTPSuccess)
191
+ raise Error, "download failed: HTTP #{res.code} for #{info[:file]}"
192
+ end
193
+
194
+ # A server that ignores Range answers 200 with the whole file, so
195
+ # appending to an existing partial would corrupt it. Rewind instead.
196
+ if resume_from.positive? && res.code == "200"
197
+ file.truncate(0)
198
+ file.rewind
199
+ total = 0
200
+ end
201
+
202
+ res.read_body do |chunk|
203
+ file.write(chunk)
204
+ total += chunk.bytesize
205
+ yield(chunk, total)
206
+ end
207
+ end
208
+ end
209
+
210
+ break if redirect.nil?
211
+
212
+ max_hops -= 1
213
+ if max_hops <= 0
214
+ raise Error, "too many redirects fetching #{info[:file]}"
215
+ end
216
+
217
+ uri = redirect
218
+ end
219
+ end
220
+ end
221
+
222
+ # Only the origin gets the token. The CDN URL is already signed, and
223
+ # resending a bearer token to a host the user never named is not something
224
+ # to do by accident.
225
+ def token?
226
+ ENV["HF_TOKEN"] && !ENV["HF_TOKEN"].empty?
227
+ end
228
+
229
+ def report_progress(io, done, total)
230
+ return unless io.tty?
231
+
232
+ pct = (done * 100.0 / total).round
233
+ io.print("\r #{megabytes_of(done)}/#{megabytes_of(total)}MB #{pct}%")
234
+ io.flush
235
+ end
236
+
237
+ def megabytes_of(bytes)
238
+ (bytes / 1_000_000.0).round(1)
239
+ end
240
+ end
241
+ end