tep 0.11.3 → 0.11.5

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +42 -2
  3. data/README.md +4 -4
  4. data/SINATRA_COMPAT.md +20 -20
  5. data/bin/tep +47 -10
  6. data/examples/api_gateway/app.rb +1 -1
  7. data/examples/blog/app.rb +17 -17
  8. data/examples/chat/app.rb +12 -12
  9. data/examples/chatbot/README.md +2 -2
  10. data/examples/chatbot/app.rb +24 -24
  11. data/examples/llm_gateway/app.rb +4 -4
  12. data/examples/pg_hello.rb +11 -1
  13. data/lib/spinel_kit/hex.rb +65 -0
  14. data/lib/spinel_kit/json.rb +151 -0
  15. data/lib/spinel_kit/json_decoder.rb +396 -0
  16. data/lib/{tep/logger.rb → spinel_kit/log.rb} +25 -21
  17. data/lib/spinel_kit/url.rb +166 -0
  18. data/lib/tep/auth_bearer_token.rb +6 -6
  19. data/lib/tep/auth_oauth2.rb +4 -4
  20. data/lib/tep/broadcast.rb +18 -80
  21. data/lib/tep/events.rb +37 -37
  22. data/lib/tep/http.rb +3 -3
  23. data/lib/tep/job.rb +2 -2
  24. data/lib/tep/jwt.rb +4 -4
  25. data/lib/tep/live_view.rb +4 -4
  26. data/lib/tep/llm.rb +13 -45
  27. data/lib/tep/mcp.rb +12 -12
  28. data/lib/tep/multipart.rb +1 -1
  29. data/lib/tep/net.rb +8 -3
  30. data/lib/tep/openai_server.rb +102 -94
  31. data/lib/tep/parser.rb +2 -2
  32. data/lib/tep/pg.rb +468 -14
  33. data/lib/tep/presence.rb +33 -329
  34. data/lib/tep/proxy.rb +7 -7
  35. data/lib/tep/request.rb +1 -1
  36. data/lib/tep/response.rb +1 -1
  37. data/lib/tep/router.rb +1 -1
  38. data/lib/tep/session.rb +2 -2
  39. data/lib/tep/version.rb +1 -1
  40. data/lib/tep.rb +57 -137
  41. data/spinel-ext.json +6 -0
  42. data/test/helper.rb +95 -8
  43. data/test/run_parallel.rb +44 -7
  44. data/test/test_auth.rb +17 -17
  45. data/test/test_auth_oauth2.rb +5 -5
  46. data/test/test_broadcast_pg.rb +1 -0
  47. data/test/test_http_pool.rb +4 -4
  48. data/test/test_http_pool_send.rb +3 -3
  49. data/test/test_json.rb +12 -12
  50. data/test/test_jwt.rb +4 -4
  51. data/test/test_live_view.rb +3 -3
  52. data/test/test_llm.rb +12 -9
  53. data/test/test_llm_gateway.rb +2 -2
  54. data/test/test_logger.rb +2 -2
  55. data/test/test_openai_server.rb +10 -1
  56. data/test/test_password.rb +3 -3
  57. data/test/test_pg.rb +1 -0
  58. data/test/test_presence_pg.rb +1 -0
  59. data/test/test_real_world.rb +6 -1
  60. data/test/test_shutdown.rb +40 -0
  61. metadata +23 -8
  62. data/lib/tep/json.rb +0 -572
  63. data/lib/tep/url.rb +0 -161
@@ -51,10 +51,10 @@ class TestHttpPool < TepTest
51
51
  res.headers["Content-Type"] = "application/json"
52
52
  s = Tep::Http::Pool.stats
53
53
  "{" +
54
- Tep::Json.encode_pair_int("checkouts", s["checkouts"].to_i) + "," +
55
- Tep::Json.encode_pair_int("checkins", s["checkins"].to_i) + "," +
56
- Tep::Json.encode_pair_int("hits", s["hits"].to_i) + "," +
57
- Tep::Json.encode_pair_int("misses", s["misses"].to_i) +
54
+ SpinelKit::Json.encode_pair_int("checkouts", s["checkouts"].to_i) + "," +
55
+ SpinelKit::Json.encode_pair_int("checkins", s["checkins"].to_i) + "," +
56
+ SpinelKit::Json.encode_pair_int("hits", s["hits"].to_i) + "," +
57
+ SpinelKit::Json.encode_pair_int("misses", s["misses"].to_i) +
58
58
  "}"
59
59
  end
60
60
 
@@ -33,9 +33,9 @@ class TestHttpPoolSend < TepTest
33
33
  r2 = Tep::Http.get(base + "/ping")
34
34
  h1 = Tep::Http::Pool.stats["hits"].to_i
35
35
  "{" +
36
- Tep::Json.encode_pair_int("hits_delta", h1 - h0) + "," +
37
- Tep::Json.encode_pair_str("b1", r1.body) + "," +
38
- Tep::Json.encode_pair_str("b2", r2.body) +
36
+ SpinelKit::Json.encode_pair_int("hits_delta", h1 - h0) + "," +
37
+ SpinelKit::Json.encode_pair_str("b1", r1.body) + "," +
38
+ SpinelKit::Json.encode_pair_str("b2", r2.body) +
39
39
  "}"
40
40
  end
41
41
  RB
data/test/test_json.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  require_relative "helper"
2
2
 
3
- # Tep::Json -- pure-Ruby JSON encode primitives + flat-key decode.
3
+ # SpinelKit::Json -- pure-Ruby JSON encode primitives + flat-key decode.
4
4
  class TestJson < TepTest
5
5
  app_source <<~RB
6
6
  require 'sinatra'
@@ -8,56 +8,56 @@ class TestJson < TepTest
8
8
  # ---- encode side ----
9
9
  get '/escape' do
10
10
  res.headers["Content-Type"] = "application/json"
11
- Tep::Json.quote("a\\"b\\nc")
11
+ SpinelKit::Json.quote("a\\"b\\nc")
12
12
  end
13
13
 
14
14
  get '/object' do
15
15
  res.headers["Content-Type"] = "application/json"
16
- "{" + Tep::Json.encode_pair_str("name", "alice") + "," +
17
- Tep::Json.encode_pair_int("age", 30) + "}"
16
+ "{" + SpinelKit::Json.encode_pair_str("name", "alice") + "," +
17
+ SpinelKit::Json.encode_pair_int("age", 30) + "}"
18
18
  end
19
19
 
20
20
  get '/array' do
21
21
  res.headers["Content-Type"] = "application/json"
22
- Tep::Json.from_str_array(["a", "b", "c"])
22
+ SpinelKit::Json.from_str_array(["a", "b", "c"])
23
23
  end
24
24
 
25
25
  get '/int_array' do
26
26
  res.headers["Content-Type"] = "application/json"
27
- Tep::Json.from_int_array([1, 2, 3])
27
+ SpinelKit::Json.from_int_array([1, 2, 3])
28
28
  end
29
29
 
30
30
  get '/echo_html' do
31
31
  res.headers["Content-Type"] = "application/json"
32
- "{" + Tep::Json.encode_pair_str("payload", "<script>alert(1)</script>") + "}"
32
+ "{" + SpinelKit::Json.encode_pair_str("payload", "<script>alert(1)</script>") + "}"
33
33
  end
34
34
 
35
35
  # ---- decode side ----
36
36
  post '/parse_str' do
37
37
  res.headers["Content-Type"] = "text/plain"
38
- Tep::Json.get_str(req.raw_body, "name")
38
+ SpinelKit::Json.get_str(req.raw_body, "name")
39
39
  end
40
40
 
41
41
  post '/parse_int' do
42
42
  res.headers["Content-Type"] = "text/plain"
43
- Tep::Json.get_int(req.raw_body, "n").to_s
43
+ SpinelKit::Json.get_int(req.raw_body, "n").to_s
44
44
  end
45
45
 
46
46
  post '/has_key' do
47
47
  res.headers["Content-Type"] = "text/plain"
48
- Tep::Json.has_key?(req.raw_body, "x") ? "yes" : "no"
48
+ SpinelKit::Json.has_key?(req.raw_body, "x") ? "yes" : "no"
49
49
  end
50
50
 
51
51
  post '/skip_nested' do
52
52
  # Read a top-level key past a nested object (skip_value should
53
53
  # walk the nested object correctly).
54
54
  res.headers["Content-Type"] = "text/plain"
55
- Tep::Json.get_str(req.raw_body, "after")
55
+ SpinelKit::Json.get_str(req.raw_body, "after")
56
56
  end
57
57
 
58
58
  post '/parse_float' do
59
59
  res.headers["Content-Type"] = "text/plain"
60
- Tep::Json.get_float(req.raw_body, "x").to_s
60
+ SpinelKit::Json.get_float(req.raw_body, "x").to_s
61
61
  end
62
62
  RB
63
63
 
data/test/test_jwt.rb CHANGED
@@ -10,8 +10,8 @@ class TestJwt < TepTest
10
10
 
11
11
  post '/issue' do
12
12
  res.headers["Content-Type"] = "text/plain"
13
- user = Tep::Json.get_str(req.raw_body, "user")
14
- payload = "{" + Tep::Json.encode_pair_str("sub", user) + "}"
13
+ user = SpinelKit::Json.get_str(req.raw_body, "user")
14
+ payload = "{" + SpinelKit::Json.encode_pair_str("sub", user) + "}"
15
15
  Tep::Jwt.encode_hs256(payload, SECRET)
16
16
  end
17
17
 
@@ -42,8 +42,8 @@ class TestJwt < TepTest
42
42
 
43
43
  post '/timing_eq' do
44
44
  res.headers["Content-Type"] = "text/plain"
45
- a = Tep::Json.get_str(req.raw_body, "a")
46
- b = Tep::Json.get_str(req.raw_body, "b")
45
+ a = SpinelKit::Json.get_str(req.raw_body, "a")
46
+ b = SpinelKit::Json.get_str(req.raw_body, "b")
47
47
  Tep::Jwt.timing_safe_eq(a, b) ? "yes" : "no"
48
48
  end
49
49
  RB
@@ -150,9 +150,9 @@ class TestLiveView < TepTest
150
150
  "<div id='tep-live-root'>" + @last_principal + ":" + @last_state + "</div>"
151
151
  end
152
152
  def handle_presence_diff(diff_json)
153
- @last_principal = Tep::Json.get_str(diff_json, "principal")
154
- @last_kind = Tep::Json.get_str(diff_json, "kind")
155
- @last_state = Tep::Json.get_str(diff_json, "state")
153
+ @last_principal = SpinelKit::Json.get_str(diff_json, "principal")
154
+ @last_kind = SpinelKit::Json.get_str(diff_json, "kind")
155
+ @last_state = SpinelKit::Json.get_str(diff_json, "state")
156
156
  0
157
157
  end
158
158
  end
data/test/test_llm.rb CHANGED
@@ -82,14 +82,15 @@ class TestLlm < TepTest
82
82
  # --- Phase B: chunked decode + SSE event consume ---
83
83
 
84
84
  get "/hex_to_int_valid" do
85
- Tep::Llm.hex_to_int("ff").to_s + "|" +
86
- Tep::Llm.hex_to_int("a").to_s + "|" +
87
- Tep::Llm.hex_to_int("100").to_s
85
+ SpinelKit::Hex.to_int("ff").to_s + "|" +
86
+ SpinelKit::Hex.to_int("a").to_s + "|" +
87
+ SpinelKit::Hex.to_int("100").to_s
88
88
  end
89
89
 
90
- get "/hex_to_int_invalid" do
91
- Tep::Llm.hex_to_int("zz").to_s + "|" +
92
- Tep::Llm.hex_to_int("").to_s
90
+ get "/hex_to_int_leading" do
91
+ SpinelKit::Hex.to_int("zz").to_s + "|" + # no leading hex digit -> 0
92
+ SpinelKit::Hex.to_int("").to_s + "|" + # empty -> 0
93
+ SpinelKit::Hex.to_int("ff;ext").to_s # leading hex, stops at ext -> 255
93
94
  end
94
95
 
95
96
  # One chunked body: 5 bytes "Hello", then last-chunk 0.
@@ -212,9 +213,11 @@ class TestLlm < TepTest
212
213
  assert_equal "255|10|256", res.body
213
214
  end
214
215
 
215
- def test_hex_to_int_malformed_returns_neg_one
216
- res = get("/hex_to_int_invalid")
217
- assert_equal "-1|-1", res.body
216
+ # SpinelKit::Hex.to_int parses LEADING hex (>= 0), unlike the old strict
217
+ # Tep::Llm.hex_to_int that returned -1 on any malformation. See dechunk_*.
218
+ def test_hex_to_int_leading_parse
219
+ res = get("/hex_to_int_leading")
220
+ assert_equal "0|0|255", res.body
218
221
  end
219
222
 
220
223
  def test_dechunk_complete_single_chunk
@@ -51,8 +51,8 @@ class TestLlmGateway < TepTest
51
51
  0
52
52
  end
53
53
  def on_stream_end(req, out, stats)
54
- model = Tep::Json.get_str(req.raw_body, "model")
55
- extra = "{" + Tep::Json.encode_pair_str("request_id", "req-1") + "}"
54
+ model = SpinelKit::Json.get_str(req.raw_body, "model")
55
+ extra = "{" + SpinelKit::Json.encode_pair_str("request_id", "req-1") + "}"
56
56
  EVENTS.inference(model, 0, stats.chunk_count, 1000000, extra)
57
57
  0
58
58
  end
data/test/test_logger.rb CHANGED
@@ -1,13 +1,13 @@
1
1
  require_relative "helper"
2
2
 
3
- # Tep::Logger -- levelled logger with stderr / file output.
3
+ # SpinelKit::Log -- levelled logger with stderr / file output.
4
4
  class TestLogger < TepTest
5
5
  TMP_LOG = "/tmp/tep_logger_test_#{$$}.log"
6
6
 
7
7
  app_source <<~RB
8
8
  require 'sinatra'
9
9
 
10
- LOGGER = Tep::Logger.new
10
+ LOGGER = SpinelKit::Log.new
11
11
  LOGGER.set_level("debug")
12
12
  LOGGER.to_file("#{TMP_LOG}")
13
13
 
@@ -89,6 +89,8 @@ class TestOpenAIServer < TepTest
89
89
  assert_equal "200", res.code
90
90
  body = JSON.parse(res.body)
91
91
  assert_equal "text_completion", body["object"]
92
+ # Backend leaves Completion#id default -> "cmpl-tep" (back-compat).
93
+ assert_equal "cmpl-tep", body["id"]
92
94
  assert_equal "echo-1", body["model"]
93
95
  assert_equal "echoed 3 tokens t=1.0 p=1.0", body["choices"][0]["text"]
94
96
  assert_equal "stop", body["choices"][0]["finish_reason"]
@@ -131,6 +133,9 @@ class TestOpenAIServerEvents < TepTest
131
133
  c.text = "echoed " + token_ids.length.to_s + " tokens"
132
134
  c.prompt_tokens = token_ids.length
133
135
  c.completion_tokens = sampling.max_tokens
136
+ # Backend-minted per-request id (#209): must surface as the
137
+ # response `id` AND the inference event's request_id.
138
+ c.id = "cmpl-evt-" + token_ids.length.to_s
134
139
  c
135
140
  end
136
141
  end
@@ -168,6 +173,8 @@ class TestOpenAIServerEvents < TepTest
168
173
  res = post("/v1/completions",
169
174
  "{\"model\":\"echo-1\",\"prompt\":[1,2,3,4],\"max_tokens\":7}")
170
175
  assert_equal "200", res.code
176
+ # Backend-minted id (#209) surfaces as the response `id`.
177
+ assert_equal "cmpl-evt-4", JSON.parse(res.body)["id"]
171
178
 
172
179
  lines2 = File.readlines(EVENTS_PATH).map { |l| JSON.parse(l) }
173
180
  # #136: inference events are kind:"eval"+name:"request"; per-request
@@ -182,7 +189,9 @@ class TestOpenAIServerEvents < TepTest
182
189
  assert_equal 7, extra["completion_tokens"]
183
190
  assert_kind_of Integer, extra["latency_us"]
184
191
  assert extra["latency_us"] >= 0
185
- assert_equal "cmpl-tep", extra["request_id"]
192
+ # request_id tracks the backend-minted Completion#id, same value as
193
+ # the response `id` above (the request_id == response id invariant).
194
+ assert_equal "cmpl-evt-4", extra["request_id"]
186
195
  assert_match(/\Auser:/, extra["principal_id"])
187
196
  end
188
197
  end
@@ -7,14 +7,14 @@ class TestPassword < TepTest
7
7
 
8
8
  post '/hash' do
9
9
  res.headers["Content-Type"] = "text/plain"
10
- pwd = Tep::Json.get_str(req.raw_body, "password")
10
+ pwd = SpinelKit::Json.get_str(req.raw_body, "password")
11
11
  Tep::Password.hash(pwd)
12
12
  end
13
13
 
14
14
  post '/verify' do
15
15
  res.headers["Content-Type"] = "text/plain"
16
- pwd = Tep::Json.get_str(req.raw_body, "password")
17
- hash = Tep::Json.get_str(req.raw_body, "hash")
16
+ pwd = SpinelKit::Json.get_str(req.raw_body, "password")
17
+ hash = SpinelKit::Json.get_str(req.raw_body, "hash")
18
18
  Tep::Password.verify(pwd, hash) ? "ok" : "bad"
19
19
  end
20
20
 
data/test/test_pg.rb CHANGED
@@ -42,6 +42,7 @@ class TestPg < TepTest
42
42
  # in the compiled binary.
43
43
  app_source <<~RB
44
44
  require 'sinatra'
45
+ require "tep/pg" # opt-in PG backend (#216)
45
46
 
46
47
  # The PG test app runs under the default prefork server. We
47
48
  # exercise the async surface explicitly via /async_exec and
@@ -29,6 +29,7 @@ class TestPresencePg < TepTest
29
29
 
30
30
  app_source <<~RB
31
31
  require 'sinatra'
32
+ require "tep/pg" # opt-in PG backend (#216)
32
33
 
33
34
  PG_URL = "#{PG_URL}"
34
35
 
@@ -245,7 +245,12 @@ class TestRealWorld < TepTest
245
245
  # With a successful login + cookie jar...
246
246
  uri = URI("http://127.0.0.1:#{@port}/login")
247
247
  net = Net::HTTP.new(uri.host, uri.port)
248
- r_login = net.post(uri.path, "user=alice&password=hunter2")
248
+ # Explicit form Content-Type: this direct net.post bypasses the
249
+ # harness req() helper, and Ruby 4.0's Net::HTTP no longer auto-sets
250
+ # it for a bodied request (3.x did), so without it the login form
251
+ # isn't parsed -> 401 instead of 302.
252
+ r_login = net.post(uri.path, "user=alice&password=hunter2",
253
+ {"Content-Type" => "application/x-www-form-urlencoded"})
249
254
  assert_equal "302", r_login.code
250
255
  cookie = r_login["Set-Cookie"]
251
256
  refute_nil cookie
@@ -0,0 +1,40 @@
1
+ require_relative "helper"
2
+
3
+ # #188 regression guard for the #186 fix.
4
+ #
5
+ # A server that does NOT mount the OpenAI events surface (the common case)
6
+ # must not SIGSEGV on SIGTERM. The #186 bug: App#initialize never set
7
+ # @openai_events, so Tep.on_shutdown's unconditional `openai_events.enabled?`
8
+ # was a null-receiver deref -- a hard SIGSEGV under Spinel (exit 139) on a
9
+ # clean `kill -TERM`. #186 (0.11.2) initialises @openai_events to a disabled
10
+ # default, so on_shutdown is a safe no-op for apps that never call
11
+ # Tep::Llm::OpenAI::Server.serve!.
12
+ #
13
+ # The events-mounted path is already covered by
14
+ # test_openai_server#test_sigterm_emits_run_end; this is the no-events path
15
+ # that the original bug actually hit. We assert the exit is NOT a SEGV.
16
+ # Whether the clean exit is 143 (signal-terminated) or 0 (graceful return)
17
+ # is the build/timing-sensitive residual tracked in #188 and is acceptable
18
+ # here -- only a SIGSEGV is the regression.
19
+ class TestShutdownNoEvents < TepTest
20
+ app_source <<~RB
21
+ require 'sinatra'
22
+
23
+ get '/ping' do
24
+ "pong"
25
+ end
26
+ RB
27
+
28
+ def test_sigterm_on_no_events_app_does_not_segv
29
+ assert_equal "pong", get("/ping").body, "server should be live before SIGTERM"
30
+
31
+ status = TepHarness.terminate_status(@port)
32
+ refute_nil status, "spawned server not found / never reaped"
33
+
34
+ segv = Signal.list["SEGV"] # 11 -> exit 139
35
+ crashed = status.signaled? && status.termsig == segv
36
+ refute crashed,
37
+ "no-events app SIGSEGV'd on SIGTERM (the #186 regression) -- " \
38
+ "termsig=#{status.termsig.inspect} exitstatus=#{status.exitstatus.inspect}"
39
+ end
40
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tep
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.3
4
+ version: 0.11.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ori Pekelman
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-02 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: prism
@@ -24,6 +23,20 @@ dependencies:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
25
  version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: spinel_kit
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
27
40
  description: |-
28
41
  tep is a small Sinatra-style DSL targeting the Spinel AOT Ruby
29
42
  compiler. The translator turns a Sinatra-classic source file into
@@ -88,6 +101,11 @@ files:
88
101
  - examples/qdrant/README.md
89
102
  - examples/sinatra_style.rb
90
103
  - examples/websocket_echo.rb
104
+ - lib/spinel_kit/hex.rb
105
+ - lib/spinel_kit/json.rb
106
+ - lib/spinel_kit/json_decoder.rb
107
+ - lib/spinel_kit/log.rb
108
+ - lib/spinel_kit/url.rb
91
109
  - lib/tep.rb
92
110
  - lib/tep/agent_delegation.rb
93
111
  - lib/tep/app.rb
@@ -107,11 +125,9 @@ files:
107
125
  - lib/tep/http.rb
108
126
  - lib/tep/identity.rb
109
127
  - lib/tep/job.rb
110
- - lib/tep/json.rb
111
128
  - lib/tep/jwt.rb
112
129
  - lib/tep/live_view.rb
113
130
  - lib/tep/llm.rb
114
- - lib/tep/logger.rb
115
131
  - lib/tep/mcp.rb
116
132
  - lib/tep/multipart.rb
117
133
  - lib/tep/net.rb
@@ -137,7 +153,6 @@ files:
137
153
  - lib/tep/streamer.rb
138
154
  - lib/tep/tep_pg.c
139
155
  - lib/tep/tep_sqlite.c
140
- - lib/tep/url.rb
141
156
  - lib/tep/version.rb
142
157
  - lib/tep/websocket.rb
143
158
  - lib/tep/websocket/connection.rb
@@ -215,6 +230,7 @@ files:
215
230
  - test/test_server_scheduled.rb
216
231
  - test/test_sessions.rb
217
232
  - test/test_shell.rb
233
+ - test/test_shutdown.rb
218
234
  - test/test_sqlite.rb
219
235
  - test/test_sqlite_cached.rb
220
236
  - test/test_static.rb
@@ -257,8 +273,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
257
273
  - !ruby/object:Gem::Version
258
274
  version: '0'
259
275
  requirements: []
260
- rubygems_version: 3.4.20
261
- signing_key:
276
+ rubygems_version: 4.0.3
262
277
  specification_version: 4
263
278
  summary: A Sinatra-flavoured web framework that compiles to a native binary via Spinel
264
279
  test_files: []