zer0-image-generator 0.6.0 → 0.8.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +106 -2
  4. data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
  5. data/lib/zer0_image_generator/abc.rb +75 -0
  6. data/lib/zer0_image_generator/all.rb +57 -0
  7. data/lib/zer0_image_generator/claude/client.rb +291 -0
  8. data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
  9. data/lib/zer0_image_generator/cli.rb +318 -0
  10. data/lib/zer0_image_generator/config.rb +192 -0
  11. data/lib/zer0_image_generator/constants.rb +177 -0
  12. data/lib/zer0_image_generator/content.rb +379 -0
  13. data/lib/zer0_image_generator/engine.rb +21 -0
  14. data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
  15. data/lib/zer0_image_generator/freesvg/client.rb +467 -0
  16. data/lib/zer0_image_generator/http.rb +264 -0
  17. data/lib/zer0_image_generator/library.rb +201 -0
  18. data/lib/zer0_image_generator/logging.rb +236 -0
  19. data/lib/zer0_image_generator/preview_generator.py +1072 -100
  20. data/lib/zer0_image_generator/prompt.rb +48 -0
  21. data/lib/zer0_image_generator/providers/base.rb +152 -0
  22. data/lib/zer0_image_generator/providers/gemini.rb +60 -0
  23. data/lib/zer0_image_generator/providers/local.rb +90 -0
  24. data/lib/zer0_image_generator/providers/openai.rb +102 -0
  25. data/lib/zer0_image_generator/providers/stability.rb +64 -0
  26. data/lib/zer0_image_generator/providers/xai.rb +81 -0
  27. data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
  28. data/lib/zer0_image_generator/providers.rb +22 -0
  29. data/lib/zer0_image_generator/runner.rb +573 -0
  30. data/lib/zer0_image_generator/settings.rb +386 -0
  31. data/lib/zer0_image_generator/stats.rb +61 -0
  32. data/lib/zer0_image_generator/support/py_random.rb +189 -0
  33. data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
  34. data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
  35. data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
  36. data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
  37. data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
  38. data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
  39. data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
  40. data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
  41. data/lib/zer0_image_generator/svg/lint.rb +400 -0
  42. data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
  43. data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
  44. data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
  45. data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
  46. data/lib/zer0_image_generator/version.rb +1 -1
  47. metadata +43 -2
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "securerandom"
7
+ require "timeout"
8
+
9
+ require_relative "logging"
10
+
11
+ module Zer0ImageGenerator
12
+ # Non-2xx HTTP response, with parsed body + headers when possible.
13
+ #
14
+ # Ported from preview_generator.py `HttpStatusError`. One naming caveat: the
15
+ # Python class exposes BOTH `str(exc)` (the constructor summary) and a
16
+ # separate `exc.message()` method (the parsed provider error). Ruby's
17
+ # `Exception#message` is already the constructor summary, so the parsed
18
+ # provider error lives under `#error_message` here — renaming `#message`
19
+ # would break every `raise`/log site that expects the summary string.
20
+ class HttpStatusError < StandardError
21
+ attr_reader :status, :body, :url, :headers
22
+
23
+ def initialize(status, body, url, headers = nil)
24
+ @status = status
25
+ # Keep the body as raw bytes: providers sometimes send non-UTF-8 error
26
+ # payloads and we must not lose them to an eager transcode.
27
+ @body = (body || "").b
28
+ @url = url
29
+ # Header keys are lower-cased so `retry-after` is found regardless of the
30
+ # server's casing (mirrors the Python dict comprehension).
31
+ @headers = {}
32
+ (headers || {}).each { |key, value| @headers[key.to_s.downcase] = value }
33
+ super("HTTP #{status} from #{url}: #{error_message[0, 300]}")
34
+ end
35
+
36
+ # Server-requested backoff: the Retry-After header (what Anthropic/OpenAI/
37
+ # xAI actually send on 429), falling back to a JSON body field.
38
+ def retry_after
39
+ header = @headers["retry-after"]
40
+ unless header.nil? || header.to_s.empty?
41
+ value = to_float(header)
42
+ return value unless value.nil?
43
+ end
44
+ data = json
45
+ if data.is_a?(Hash)
46
+ # Python's `data.get("retry_after", 0) or 0` collapses every falsy value
47
+ # (missing, null, 0, "") to 0 before the float() call.
48
+ raw = data["retry_after"]
49
+ raw = 0 if py_falsy?(raw)
50
+ value = to_float(raw)
51
+ return value unless value.nil?
52
+ end
53
+ 0.0
54
+ end
55
+
56
+ # Parsed JSON body, or nil when the body is not valid JSON. `scrub` gives
57
+ # us CPython's `bytes.decode("utf-8", "replace")` — invalid bytes become
58
+ # U+FFFD rather than raising.
59
+ def json
60
+ JSON.parse(decoded_body)
61
+ rescue JSON::ParserError
62
+ nil
63
+ end
64
+
65
+ # The human-readable provider error. This is Python's `HttpStatusError.
66
+ # message()`, deliberately NOT named `message` (see the class comment).
67
+ def error_message
68
+ data = json
69
+ if data.is_a?(Hash)
70
+ err = data["error"]
71
+ return py_str(err["message"]) if err.is_a?(Hash) && py_truthy?(err["message"])
72
+ # A bare string `error` is returned even when empty — no truthiness
73
+ # gate here, matching the oracle.
74
+ return err if err.is_a?(String)
75
+ return py_str(data["detail"]) if py_truthy?(data["detail"])
76
+ end
77
+ decoded_body[0, 500]
78
+ end
79
+
80
+ private
81
+
82
+ def decoded_body
83
+ @body.dup.force_encoding(Encoding::UTF_8).scrub
84
+ end
85
+
86
+ def to_float(value)
87
+ Float(value)
88
+ rescue ArgumentError, TypeError
89
+ nil
90
+ end
91
+
92
+ # CPython truthiness over the scalar/collection shapes a JSON value takes.
93
+ def py_truthy?(value)
94
+ case value
95
+ when nil, false then false
96
+ when String, Array, Hash then !value.empty?
97
+ when Numeric then value != 0
98
+ else true
99
+ end
100
+ end
101
+
102
+ def py_falsy?(value)
103
+ !py_truthy?(value)
104
+ end
105
+
106
+ # Python f-string interpolation goes through str(): booleans and nil print
107
+ # capitalized, everything else via to_s.
108
+ def py_str(value)
109
+ case value
110
+ when String then value
111
+ when true then "True"
112
+ when false then "False"
113
+ when nil then "None"
114
+ else value.to_s
115
+ end
116
+ end
117
+ end
118
+
119
+ # HTTP layer — Net::HTTP only, no third-party deps (parity with the Python
120
+ # engine's urllib-only stance). The AnthropicClient and the image providers
121
+ # call these as an injectable transport seam; a Rails app fakes `.json` /
122
+ # `.multipart` the same way the tests do.
123
+ module Http
124
+ module_function
125
+
126
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 529].freeze
127
+
128
+ # urllib.error.URLError + OSError (which covers ConnectionError and
129
+ # TimeoutError) map onto this set: DNS/socket failures, Errno::*, broken
130
+ # streams, and read/open timeouts. HttpStatusError is handled separately.
131
+ TRANSPORT_ERRORS = [
132
+ SocketError, SystemCallError, IOError, EOFError, Timeout::Error,
133
+ Net::OpenTimeout, Net::ReadTimeout, Net::HTTPBadResponse, Net::ProtocolError
134
+ ].freeze
135
+
136
+ # urllib follows up to 10 redirects by default; matching that keeps
137
+ # `download_to` behavior identical for providers that return 3xx image URLs.
138
+ MAX_REDIRECTS = 10
139
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
140
+
141
+ def request(url, method: "GET", headers: nil, data: nil, timeout: 120)
142
+ uri = URI(url)
143
+ redirects = 0
144
+ loop do
145
+ response = perform(uri, method, headers, data, timeout)
146
+ code = response.code.to_i
147
+ location = response["location"]
148
+ if REDIRECT_STATUSES.include?(code) && location && method.to_s.upcase == "GET" &&
149
+ redirects < MAX_REDIRECTS
150
+ redirects += 1
151
+ uri = URI.join(uri.to_s, location)
152
+ next
153
+ end
154
+ return [code, header_hash(response), (response.body || "").b] if code >= 200 && code < 300
155
+
156
+ raise HttpStatusError.new(code, response.body, url, header_hash(response))
157
+ end
158
+ end
159
+
160
+ def json(url, payload, headers, timeout: 900)
161
+ body = JSON.generate(payload)
162
+ # Caller-supplied headers win on conflict, exactly like `{**base, **headers}`.
163
+ hdrs = { "Content-Type" => "application/json" }.merge(headers)
164
+ _, _, raw = request(url, method: "POST", headers: hdrs, data: body, timeout: timeout)
165
+ JSON.parse(raw)
166
+ end
167
+
168
+ # Encode multipart/form-data. `files` entries are [field, filename, content,
169
+ # mime]. The body is assembled as raw bytes so binary image content survives
170
+ # untouched; the boundary and layout match the oracle byte for byte.
171
+ def build_multipart(fields, files)
172
+ boundary = "----zer0-#{SecureRandom.hex(16)}"
173
+ buf = +"".b
174
+ fields.each do |name, value|
175
+ buf << "--#{boundary}\r\n".b
176
+ buf << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n".b
177
+ buf << value.to_s.b
178
+ buf << "\r\n".b
179
+ end
180
+ files.each do |name, filename, content, mime|
181
+ buf << "--#{boundary}\r\n".b
182
+ buf << "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"\r\n".b
183
+ buf << "Content-Type: #{mime}\r\n\r\n".b
184
+ buf << content.b
185
+ buf << "\r\n".b
186
+ end
187
+ buf << "--#{boundary}--\r\n".b
188
+ [buf, "multipart/form-data; boundary=#{boundary}"]
189
+ end
190
+
191
+ def multipart(url, fields, files, headers, timeout: 900)
192
+ body, content_type = build_multipart(fields, files)
193
+ hdrs = { "Content-Type" => content_type }.merge(headers)
194
+ _, _, raw = request(url, method: "POST", headers: hdrs, data: body, timeout: timeout)
195
+ JSON.parse(raw)
196
+ end
197
+
198
+ def download_to(url, dest, timeout: 120)
199
+ _, _, raw = request(url, method: "GET", headers: {}, data: nil, timeout: timeout)
200
+ File.binwrite(dest.to_s, raw)
201
+ nil
202
+ end
203
+
204
+ # Run the block; retry transport errors and retryable HTTP statuses.
205
+ # Backoff 2s/4s/8s (or the server's Retry-After when larger, capped 60s).
206
+ # `sleeper` is injectable so tests observe the wait ladder without sleeping;
207
+ # it defaults to a real sleep for production callers.
208
+ def with_retries(what, attempts: 4, sleeper: nil)
209
+ sleeper ||= ->(seconds) { sleep(seconds) }
210
+ delay = 2.0
211
+ 1.upto(attempts) do |attempt|
212
+ begin
213
+ return yield
214
+ rescue HttpStatusError => e
215
+ raise if !RETRYABLE_STATUSES.include?(e.status) || attempt == attempts
216
+
217
+ wait = [[delay, e.retry_after].max, 60.0].min
218
+ Logging.warn("#{what}: HTTP #{e.status}, retrying in #{format('%.0f', wait)}s " \
219
+ "(attempt #{attempt}/#{attempts})")
220
+ rescue *TRANSPORT_ERRORS => e
221
+ raise if attempt == attempts
222
+
223
+ wait = delay
224
+ Logging.warn("#{what}: #{e.class.name}: #{e.message}; retrying in #{format('%.0f', wait)}s " \
225
+ "(attempt #{attempt}/#{attempts})")
226
+ end
227
+ sleeper.call(wait)
228
+ delay *= 2
229
+ end
230
+ end
231
+
232
+ # --- internals ------------------------------------------------------------
233
+
234
+ def perform(uri, method, headers, data, timeout)
235
+ http = Net::HTTP.new(uri.host, uri.port)
236
+ http.use_ssl = (uri.scheme == "https")
237
+ http.open_timeout = timeout
238
+ http.read_timeout = timeout
239
+ request = request_class(method).new(uri.request_uri)
240
+ (headers || {}).each { |key, value| request[key] = value }
241
+ request.body = data unless data.nil?
242
+ http.start { |conn| conn.request(request) }
243
+ end
244
+ private_class_method :perform
245
+
246
+ def request_class(method)
247
+ case method.to_s.upcase
248
+ when "GET" then Net::HTTP::Get
249
+ when "POST" then Net::HTTP::Post
250
+ when "PUT" then Net::HTTP::Put
251
+ when "DELETE" then Net::HTTP::Delete
252
+ else Net::HTTP::Get
253
+ end
254
+ end
255
+ private_class_method :request_class
256
+
257
+ def header_hash(response)
258
+ hash = {}
259
+ response.each_header { |key, value| hash[key] = value }
260
+ hash
261
+ end
262
+ private_class_method :header_hash
263
+ end
264
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Zer0ImageGenerator
7
+ # Byte-exact re-implementation of CPython's `json.dumps(obj, indent=2)`.
8
+ #
9
+ # The example library's MANIFEST.json is a committed artifact produced by
10
+ # `tools/svg/freesvg.py`'s `_write_json` (json.dump indent=2 + a trailing
11
+ # newline). Promoting from Ruby has to yield a byte-for-byte identical file so
12
+ # the git diff is only the new entry — which rules out Ruby's own
13
+ # JSON.pretty_generate: its empty-container layout, its float rendering and,
14
+ # above all, its non-ASCII handling all differ from CPython. This encoder
15
+ # pins every one of those to CPython's, so the manifest round-trips cleanly no
16
+ # matter which language wrote it last.
17
+ module PyJson
18
+ module_function
19
+
20
+ def dump(obj)
21
+ out = +""
22
+ encode(obj, 0, out)
23
+ out
24
+ end
25
+
26
+ # `indent` is the column the CURRENT value's closing bracket sits at; child
27
+ # members are written at indent + 2, matching json.dumps(indent=2).
28
+ def encode(obj, indent, out)
29
+ case obj
30
+ when Hash then encode_object(obj, indent, out)
31
+ when Array then encode_array(obj, indent, out)
32
+ when String then out << encode_string(obj)
33
+ when Integer then out << obj.to_s
34
+ when Float then out << float_repr(obj)
35
+ when true then out << "true"
36
+ when false then out << "false"
37
+ when nil then out << "null"
38
+ else
39
+ # json.dumps would raise TypeError here; keep the failure explicit
40
+ # rather than silently coercing something the manifest never contains.
41
+ raise ArgumentError, "PyJson cannot encode #{obj.class}"
42
+ end
43
+ end
44
+
45
+ def encode_object(hash, indent, out)
46
+ # json.dumps(indent=2) collapses empty containers to `{}` / `[]` with no
47
+ # inner newline; every non-empty container opens a newline-delimited block.
48
+ if hash.empty?
49
+ out << "{}"
50
+ return
51
+ end
52
+ inner = indent + 2
53
+ pad = " " * inner
54
+ out << "{\n"
55
+ first = true
56
+ hash.each do |key, value|
57
+ out << ",\n" unless first
58
+ first = false
59
+ out << pad << encode_string(key.to_s) << ": "
60
+ encode(value, inner, out)
61
+ end
62
+ out << "\n" << (" " * indent) << "}"
63
+ end
64
+
65
+ def encode_array(array, indent, out)
66
+ if array.empty?
67
+ out << "[]"
68
+ return
69
+ end
70
+ inner = indent + 2
71
+ pad = " " * inner
72
+ out << "[\n"
73
+ first = true
74
+ array.each do |value|
75
+ out << ",\n" unless first
76
+ first = false
77
+ out << pad
78
+ encode(value, inner, out)
79
+ end
80
+ out << "\n" << (" " * indent) << "]"
81
+ end
82
+
83
+ # The named short escapes json.dumps emits; everything else in the control
84
+ # range falls through to \u00xx below.
85
+ ESCAPES = {
86
+ "\"" => '\\"', "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f",
87
+ "\n" => "\\n", "\r" => "\\r", "\t" => "\\t"
88
+ }.freeze
89
+
90
+ # Mirrors json.encoder.py_encode_basestring_ascii (ensure_ascii=True): only
91
+ # 0x20..0x7e survive literally; 0x7f and every non-ASCII codepoint is
92
+ # escaped, astral characters as a UTF-16 surrogate pair.
93
+ def encode_string(str)
94
+ out = +'"'
95
+ str.each_char do |ch|
96
+ if (esc = ESCAPES[ch])
97
+ out << esc
98
+ next
99
+ end
100
+ cp = ch.ord
101
+ if cp < 0x20
102
+ out << format("\\u%04x", cp)
103
+ elsif cp < 0x7f
104
+ out << ch
105
+ elsif cp > 0xffff
106
+ value = cp - 0x10000
107
+ out << format("\\u%04x\\u%04x", 0xd800 + (value >> 10), 0xdc00 + (value & 0x3ff))
108
+ else
109
+ out << format("\\u%04x", cp)
110
+ end
111
+ end
112
+ out << '"'
113
+ out
114
+ end
115
+
116
+ # json.dumps renders floats via float.__repr__ (shortest round-tripping
117
+ # form). Ruby's Float#to_s is also shortest-round-trip and agrees with
118
+ # CPython across the whole non-exponent range the manifest uses (size_kb is
119
+ # always a small positive value like 0.1 or 148.1). The two disagree only on
120
+ # exponent formatting (Ruby "1.0e+20" vs CPython "1e+20") and the special
121
+ # values, which manifest data never contains — guarded here for honesty.
122
+ def float_repr(value)
123
+ return "NaN" if value.nan?
124
+ return value.positive? ? "Infinity" : "-Infinity" if value.infinite?
125
+
126
+ value.to_s
127
+ end
128
+ end
129
+
130
+ # Reader/writer for the SVG example library manifest (tools/svg/library/
131
+ # MANIFEST.json). One entry per example — first-party samples and vetted
132
+ # external art alike — each carrying provenance, license and lint status so a
133
+ # tag search covers the whole library from one place (see library/README.md).
134
+ #
135
+ # Writes go through PyJson so a Ruby-authored manifest is byte-identical to
136
+ # what freesvg.py's `_write_json` produces; reads use stdlib JSON, which is a
137
+ # faithful stand-in for Python's json.load (both preserve key insertion order
138
+ # and the Integer/Float distinction that the manifest's size_kb relies on).
139
+ class Library
140
+ attr_reader :path
141
+
142
+ def initialize(path)
143
+ @path = path
144
+ end
145
+
146
+ # Parsed manifest, or nil when the file is missing or corrupt — mirroring
147
+ # freesvg.py's `_read_json`, which swallows exactly OSError/ValueError and
148
+ # returns None so a damaged manifest degrades to "empty" rather than
149
+ # crashing the caller.
150
+ def read
151
+ JSON.parse(File.read(@path))
152
+ rescue JSON::ParserError, SystemCallError, IOError
153
+ nil
154
+ end
155
+
156
+ # The `_read_json(...) or {"entries": []}` default freesvg.py falls back to.
157
+ def load
158
+ read || { "entries" => [] }
159
+ end
160
+
161
+ def entries
162
+ Array(load["entries"])
163
+ end
164
+
165
+ def has_file?(file)
166
+ entries.any? { |entry| entry["file"] == file }
167
+ end
168
+
169
+ def find(file)
170
+ entries.find { |entry| entry["file"] == file }
171
+ end
172
+
173
+ # Tag search is the library's primary lookup (README: "Search MANIFEST.json
174
+ # by tags"). Case-insensitive, since manifest tags are stored lowercased.
175
+ def by_tag(tag)
176
+ needle = tag.to_s.downcase
177
+ entries.select do |entry|
178
+ Array(entry["tags"]).any? { |t| t.to_s.downcase == needle }
179
+ end
180
+ end
181
+
182
+ # Append one entry, re-sort by file, and rewrite byte-identically. Matches
183
+ # freesvg.py's promote tail: it reads the manifest fresh, appends, sorts by
184
+ # the "file" key, and writes with indent=2 + trailing newline. Returns the
185
+ # entry. Callers guard against duplicates via #has_file? first.
186
+ def add_entry(entry)
187
+ manifest = load
188
+ manifest["entries"] ||= []
189
+ manifest["entries"] << entry
190
+ manifest["entries"] = manifest["entries"].sort_by { |e| e["file"] }
191
+ write(manifest)
192
+ entry
193
+ end
194
+
195
+ def write(manifest)
196
+ FileUtils.mkdir_p(File.dirname(@path))
197
+ # _write_json: json.dump(..., indent=2) then a single trailing newline.
198
+ File.write(@path, PyJson.dump(manifest) + "\n")
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zer0ImageGenerator
4
+ # Stands in for the Python engine's `sys.exit(1)`. The engine is now driven
5
+ # by a Rails app as well as a CLI, and a web request must not be able to
6
+ # take the process down — CLI.main is the only place that turns this back
7
+ # into a process exit status, so the code path (and the status) is unchanged
8
+ # for CLI users.
9
+ class ExitError < StandardError
10
+ attr_reader :status
11
+
12
+ def initialize(message, status = 1)
13
+ super(message)
14
+ @status = status
15
+ end
16
+ end
17
+
18
+ module Colors
19
+ RED = "\e[0;31m"
20
+ GREEN = "\e[0;32m"
21
+ YELLOW = "\e[1;33m"
22
+ BLUE = "\e[0;34m"
23
+ CYAN = "\e[0;36m"
24
+ PURPLE = "\e[0;35m"
25
+ BOLD = "\e[1m"
26
+ NC = "\e[0m"
27
+ end
28
+
29
+ # Default log destination: reproduces the Python engine's terminal output
30
+ # byte for byte (same colors, same stream split, same blank lines). Nothing
31
+ # in the Runner or the CLI touches $stdout directly, so a web UI can hand in
32
+ # a sink that streams the very same run to a browser instead.
33
+ #
34
+ # Sink contract — three messages:
35
+ # log(message, level) level is one of :info :step :success :warning
36
+ # :error :debug
37
+ # say(text) a bare line (the engine's plain `print` calls)
38
+ # header(title) the boxed section banner
39
+ class StreamSink
40
+ LEVEL_COLORS = {
41
+ info: Colors::BLUE,
42
+ step: Colors::CYAN,
43
+ success: Colors::GREEN,
44
+ warning: Colors::YELLOW,
45
+ error: Colors::RED,
46
+ debug: Colors::PURPLE
47
+ }.freeze
48
+
49
+ # The levels the Python engine routes to sys.stderr.
50
+ STDERR_LEVELS = %i[warning error debug].freeze
51
+
52
+ RULE = ("=" * 64).freeze
53
+
54
+ def initialize(out: $stdout, err: $stderr)
55
+ @out = out
56
+ @err = err
57
+ @lock = Mutex.new
58
+ end
59
+
60
+ def log(message, level)
61
+ color = LEVEL_COLORS.fetch(level, Colors::NC)
62
+ stream = STDERR_LEVELS.include?(level) ? @err : @out
63
+ emit(stream, "#{color}[#{level.to_s.upcase}]#{Colors::NC} #{message}\n")
64
+ end
65
+
66
+ def say(text)
67
+ emit(@out, "#{text}\n")
68
+ end
69
+
70
+ def header(title)
71
+ emit(@out, "\n#{Colors::CYAN}#{RULE}#{Colors::NC}\n" \
72
+ " #{Colors::GREEN}#{title}#{Colors::NC}\n" \
73
+ "#{Colors::CYAN}#{RULE}#{Colors::NC}\n\n")
74
+ end
75
+
76
+ private
77
+
78
+ # One write per line. Python's GIL gave `print` de-facto line atomicity
79
+ # that worker threads relied on; Ruby has to ask for it explicitly or
80
+ # parallel runs shred each other's output.
81
+ def emit(stream, text)
82
+ @lock.synchronize { stream.write(text) }
83
+ end
84
+ end
85
+
86
+ # Holds what the Python engine kept in module globals: the active sink, the
87
+ # VERBOSE switch that gates debug(), and the optional --log-file mirror.
88
+ class Logger
89
+ # Matches the Python's time.strftime("%Y-%m-%d %H:%M:%S").
90
+ STAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
91
+
92
+ attr_accessor :sink, :verbose
93
+
94
+ def initialize(sink: nil, verbose: false)
95
+ @sink = sink || StreamSink.new
96
+ @verbose = verbose
97
+ @log_file = nil
98
+ @file_lock = Mutex.new
99
+ end
100
+
101
+ def log(message, level = :info)
102
+ text = message.to_s
103
+ key = level.to_sym
104
+ @sink.log(text, key)
105
+ mirror(text, key)
106
+ nil
107
+ end
108
+
109
+ def info(message)
110
+ log(message, :info)
111
+ end
112
+
113
+ def step(message)
114
+ log(message, :step)
115
+ end
116
+
117
+ def success(message)
118
+ log(message, :success)
119
+ end
120
+
121
+ def warn(message)
122
+ log(message, :warning)
123
+ end
124
+
125
+ def debug(message)
126
+ log(message, :debug) if @verbose
127
+ end
128
+
129
+ def error_exit(message, status = 1)
130
+ log(message, :error)
131
+ raise ExitError.new(message.to_s, status)
132
+ end
133
+
134
+ # The engine's bare `print(...)` / `print()` calls: plain stdout lines that
135
+ # deliberately bypass the --log-file mirror.
136
+ def say(text = "")
137
+ @sink.say(text.to_s)
138
+ nil
139
+ end
140
+
141
+ def print_header(title)
142
+ @sink.header(title.to_s)
143
+ nil
144
+ end
145
+
146
+ # Returns false after warning (never raises) — the Python catches OSError
147
+ # here so an unwritable --log-file downgrades to a warning instead of
148
+ # aborting a run that may already have cost money.
149
+ def open_log_file(path)
150
+ @file_lock.synchronize { @log_file = File.open(path, "w:UTF-8") }
151
+ true
152
+ rescue SystemCallError => e
153
+ warn("Cannot open log file: #{e}")
154
+ false
155
+ end
156
+
157
+ def close_log_file
158
+ @file_lock.synchronize do
159
+ return nil if @log_file.nil?
160
+
161
+ @log_file.close
162
+ @log_file = nil
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def mirror(message, level)
169
+ @file_lock.synchronize do
170
+ return nil if @log_file.nil?
171
+
172
+ @log_file.write("#{Time.now.strftime(STAMP_FORMAT)} [#{level.to_s.upcase}] #{message}\n")
173
+ @log_file.flush
174
+ end
175
+ end
176
+ end
177
+
178
+ # Process-wide logger, the direct analogue of the engine's module-level
179
+ # log()/info()/warn()/VERBOSE. Runner and CLI take an injected logger and
180
+ # only fall back to this one, so a Rails process can run several jobs with
181
+ # separate sinks without touching global state.
182
+ module Logging
183
+ class << self
184
+ attr_writer :logger
185
+
186
+ def logger
187
+ @logger ||= Logger.new
188
+ end
189
+
190
+ # The engine's VERBOSE global.
191
+ def verbose
192
+ logger.verbose
193
+ end
194
+
195
+ def verbose=(value)
196
+ logger.verbose = value
197
+ end
198
+
199
+ def log(message, level = :info)
200
+ logger.log(message, level)
201
+ end
202
+
203
+ def info(message)
204
+ logger.info(message)
205
+ end
206
+
207
+ def step(message)
208
+ logger.step(message)
209
+ end
210
+
211
+ def success(message)
212
+ logger.success(message)
213
+ end
214
+
215
+ def warn(message)
216
+ logger.warn(message)
217
+ end
218
+
219
+ def debug(message)
220
+ logger.debug(message)
221
+ end
222
+
223
+ def error_exit(message, status = 1)
224
+ logger.error_exit(message, status)
225
+ end
226
+
227
+ def say(text = "")
228
+ logger.say(text)
229
+ end
230
+
231
+ def print_header(title)
232
+ logger.print_header(title)
233
+ end
234
+ end
235
+ end
236
+ end