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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +106 -2
- data/lib/zer0_image_generator/abc/style_pack.rb +145 -0
- data/lib/zer0_image_generator/abc.rb +75 -0
- data/lib/zer0_image_generator/all.rb +57 -0
- data/lib/zer0_image_generator/claude/client.rb +291 -0
- data/lib/zer0_image_generator/claude/orchestration.rb +128 -0
- data/lib/zer0_image_generator/cli.rb +318 -0
- data/lib/zer0_image_generator/config.rb +192 -0
- data/lib/zer0_image_generator/constants.rb +177 -0
- data/lib/zer0_image_generator/content.rb +379 -0
- data/lib/zer0_image_generator/engine.rb +21 -0
- data/lib/zer0_image_generator/freesvg/cache.rb +167 -0
- data/lib/zer0_image_generator/freesvg/client.rb +467 -0
- data/lib/zer0_image_generator/http.rb +264 -0
- data/lib/zer0_image_generator/library.rb +201 -0
- data/lib/zer0_image_generator/logging.rb +236 -0
- data/lib/zer0_image_generator/preview_generator.py +1072 -100
- data/lib/zer0_image_generator/prompt.rb +48 -0
- data/lib/zer0_image_generator/providers/base.rb +152 -0
- data/lib/zer0_image_generator/providers/gemini.rb +60 -0
- data/lib/zer0_image_generator/providers/local.rb +90 -0
- data/lib/zer0_image_generator/providers/openai.rb +102 -0
- data/lib/zer0_image_generator/providers/stability.rb +64 -0
- data/lib/zer0_image_generator/providers/xai.rb +81 -0
- data/lib/zer0_image_generator/providers/xai_auth.rb +270 -0
- data/lib/zer0_image_generator/providers.rb +22 -0
- data/lib/zer0_image_generator/runner.rb +573 -0
- data/lib/zer0_image_generator/settings.rb +386 -0
- data/lib/zer0_image_generator/stats.rb +61 -0
- data/lib/zer0_image_generator/support/py_random.rb +189 -0
- data/lib/zer0_image_generator/svg/banner_seed.rb +241 -0
- data/lib/zer0_image_generator/svg/generators/flowfield.rb +154 -0
- data/lib/zer0_image_generator/svg/generators/invaders.rb +125 -0
- data/lib/zer0_image_generator/svg/generators/lowpoly.rb +254 -0
- data/lib/zer0_image_generator/svg/generators/lsystem.rb +228 -0
- data/lib/zer0_image_generator/svg/generators/mandala.rb +155 -0
- data/lib/zer0_image_generator/svg/generators/pixelquest.rb +144 -0
- data/lib/zer0_image_generator/svg/generators/starmap.rb +179 -0
- data/lib/zer0_image_generator/svg/lint.rb +400 -0
- data/lib/zer0_image_generator/svg/local_renderer.rb +606 -0
- data/lib/zer0_image_generator/svg/pixel_kit.rb +167 -0
- data/lib/zer0_image_generator/svg/rasterizer.rb +196 -0
- data/lib/zer0_image_generator/svg/sanitizer.rb +159 -0
- data/lib/zer0_image_generator/version.rb +1 -1
- 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
|