tina4ruby 3.12.2 → 3.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a97e5249b5ffd22de2dac45ce08f27e217c9c1253192d41b036b0ef90e14dc11
4
- data.tar.gz: d2a67f3f7a4e11dab682f2333e4aed920ada9ba553737c5c77899ed3b7bf8cd2
3
+ metadata.gz: 02f359671ab67c2f4d7f3202e8e02f6fcd432690ce034d5951cb84d7c0c297c5
4
+ data.tar.gz: de61dd0e21ad0c9e979a6049b67d3bba6acfe34ada9540564230a352a71927cf
5
5
  SHA512:
6
- metadata.gz: beb243e57f2aa6df523fe64e63c2057a46058d711acda139342ca06144fd207949e24a3a35d25787d1f110bf7bead2cd85178c1cefb0c8158e46be89e7f31004
7
- data.tar.gz: dd2169f5c8837fe90185e9c81eb5bd6e5fa49819230cf6ec79207f32aa6288a6eaec80236d565d7a0b561d08a95f7c0949456d21abd8ac887425c54ac7fe1997
6
+ metadata.gz: 44af7a12ed2e7cc6175f59549a4984cc24c2e6a411493fc03e7ec7e742179fb162aaea3d85f89ff48989a018f2174b77a7eb12073f05f665fa705784bc698df5
7
+ data.tar.gz: 677420505af7fbbbe8f5606dd563fbcce96fecb16757ff4d952fe5155b19defbf992d5e86c8bcf9b7449f5082bfcaf742551cb3c3bc92a5312d69e9269b0c31e
data/lib/tina4/ai.rb CHANGED
@@ -212,11 +212,18 @@ module Tina4
212
212
  next unless system("which #{cmd} > /dev/null 2>&1")
213
213
 
214
214
  result = `#{cmd} install --upgrade tina4-ai 2>&1`
215
+ # Subprocess output is ASCII-8BIT — force UTF-8 with byte replacement
216
+ # so non-ASCII content (often emitted by pip on locale mismatch)
217
+ # doesn't crash String#strip with Encoding::CompatibilityError.
218
+ safe_result = result.dup.force_encoding("UTF-8")
219
+ unless safe_result.valid_encoding?
220
+ safe_result = safe_result.encode("UTF-8", "UTF-8", invalid: :replace, undef: :replace, replace: "?")
221
+ end
215
222
  if $?.success?
216
223
  puts " \e[32m✓\e[0m Installed tina4-ai (mdview)"
217
224
  return
218
225
  else
219
- puts " \e[33m!\e[0m #{cmd} failed: #{result.strip[0..100]}"
226
+ puts " \e[33m!\e[0m #{cmd} failed: #{safe_result.strip[0..100]}"
220
227
  end
221
228
  end
222
229
  puts " \e[33m!\e[0m Python/pip not available -- skip tina4-ai"
@@ -61,7 +61,7 @@ module Tina4
61
61
  end
62
62
 
63
63
  # Check if a service is registered.
64
- def has(name)
64
+ def has?(name)
65
65
  registry.key?(name.to_sym)
66
66
  end
67
67
 
@@ -3,6 +3,17 @@
3
3
  module Tina4
4
4
  # Multi-backend response cache for GET requests.
5
5
  #
6
+ # Public surface (parity with Python tina4_python.cache):
7
+ # - Tina4::ResponseCache — middleware class
8
+ # - Tina4.cache_stats — module function returning cache stats
9
+ # - Tina4.clear_cache — module function flushing all cached entries
10
+ # - Tina4.cache_get / cache_set / cache_delete — module-level KV API
11
+ #
12
+ # The internal lookup/store of GET responses is performed by the middleware
13
+ # hooks (before_cache, after_cache) and is NOT exposed publicly. Use the
14
+ # middleware by attaching ResponseCache to your route, not by calling
15
+ # the (private) internal_lookup / internal_store directly.
16
+ #
6
17
  # Backends are selected via the TINA4_CACHE_BACKEND env var:
7
18
  # memory — in-process LRU cache (default, zero deps)
8
19
  # redis — Redis / Valkey (uses `redis` gem or raw RESP over TCP)
@@ -14,16 +25,6 @@ module Tina4
14
25
  # TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
15
26
  # TINA4_CACHE_MAX_ENTRIES — maximum cache entries (default: 1000)
16
27
  #
17
- # Usage:
18
- # cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
19
- # cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
20
- # hit = cache.get("GET", "/api/users")
21
- #
22
- # # Direct API (same across all 4 languages)
23
- # cache.cache_set("key", {"data" => "value"}, ttl: 120)
24
- # value = cache.cache_get("key")
25
- # cache.cache_delete("key")
26
- #
27
28
  class ResponseCache
28
29
  CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
29
30
 
@@ -57,94 +58,75 @@ module Tina4
57
58
  @ttl > 0
58
59
  end
59
60
 
60
- # Build a cache key from method and URL.
61
- #
62
- # @param method [String]
63
- # @param url [String]
64
- # @return [String]
65
- def cache_key(method, url)
66
- "#{method}:#{url}"
67
- end
68
-
69
- # Retrieve a cached response. Returns nil on miss or expired entry.
70
- #
71
- # @param method [String]
72
- # @param url [String]
73
- # @return [CacheEntry, nil]
74
- def get(method, url)
75
- return nil unless enabled?
76
- return nil unless method == "GET"
61
+ # ── Middleware hooks ────────────────────────────────────────────
77
62
 
78
- key = cache_key(method, url)
79
- entry = backend_get(key)
63
+ # Middleware hook — checks for a cached entry before the route handler runs.
64
+ # If a cached entry exists for this GET request, short-circuits by replacing
65
+ # the response. Otherwise tags the request so after_cache can capture the
66
+ # response.
67
+ def before_cache(request, response)
68
+ return [request, response] unless enabled?
80
69
 
81
- if entry.nil?
82
- @mutex.synchronize { @misses += 1 }
83
- return nil
84
- end
70
+ method = (request.respond_to?(:method) ? request.method : "GET").to_s.upcase
71
+ return [request, response] unless method == "GET"
85
72
 
86
- # For memory backend, entry is a CacheEntry; for others, reconstruct
87
- if entry.is_a?(CacheEntry)
88
- if Time.now.to_f > entry.expires_at
89
- backend_delete(key)
90
- @mutex.synchronize { @misses += 1 }
91
- return nil
73
+ url = request.respond_to?(:url) ? request.url : (request.respond_to?(:path) ? request.path : "/")
74
+ hit = internal_lookup(method, url)
75
+ if hit
76
+ if response.respond_to?(:call)
77
+ new_response = response.call(hit.body, hit.status_code, hit.content_type)
78
+ return [request, new_response]
92
79
  end
93
- @mutex.synchronize { @hits += 1 }
94
- entry
95
- elsif entry.is_a?(Hash)
96
- expires_at = entry["expires_at"] || entry[:expires_at] || 0
97
- if Time.now.to_f > expires_at
98
- backend_delete(key)
99
- @mutex.synchronize { @misses += 1 }
100
- return nil
101
- end
102
- @mutex.synchronize { @hits += 1 }
103
- CacheEntry.new(
104
- entry["body"] || entry[:body],
105
- entry["content_type"] || entry[:content_type],
106
- entry["status_code"] || entry[:status_code],
107
- expires_at
108
- )
109
80
  end
110
- end
111
-
112
- # Store a response in the cache.
113
- #
114
- # @param method [String]
115
- # @param url [String]
116
- # @param status_code [Integer]
117
- # @param content_type [String]
118
- # @param body [String]
119
- # @param ttl [Integer, nil] override default TTL
120
- def cache_response(method, url, status_code, content_type, body, ttl: nil)
121
- return unless enabled?
122
- return unless method == "GET"
123
- return unless @status_codes.include?(status_code)
124
-
125
- effective_ttl = ttl || @ttl
126
- key = cache_key(method, url)
127
- expires_at = Time.now.to_f + effective_ttl
128
-
129
- entry_data = {
130
- "body" => body,
131
- "content_type" => content_type,
132
- "status_code" => status_code,
133
- "expires_at" => expires_at
134
- }
135
81
 
136
- case @backend_name
137
- when "memory"
138
- @mutex.synchronize do
139
- if @store.size >= @max_entries && !@store.key?(key)
140
- oldest_key = @store.keys.first
141
- @store.delete(oldest_key)
142
- end
143
- @store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
144
- end
82
+ # Tag for after_cache
83
+ if request.respond_to?(:[]=)
84
+ request[:_cache_method] = method
85
+ request[:_cache_url] = url
145
86
  else
146
- backend_set(key, entry_data, effective_ttl)
87
+ request.instance_variable_set(:@_cache_method, method)
88
+ request.instance_variable_set(:@_cache_url, url)
147
89
  end
90
+ [request, response]
91
+ end
92
+
93
+ # Middleware hook — captures the response body and stores it after the
94
+ # route handler runs.
95
+ def after_cache(request, response)
96
+ return [request, response] unless enabled?
97
+
98
+ method = if request.respond_to?(:[])
99
+ request[:_cache_method]
100
+ else
101
+ request.instance_variable_get(:@_cache_method)
102
+ end
103
+ url = if request.respond_to?(:[])
104
+ request[:_cache_url]
105
+ else
106
+ request.instance_variable_get(:@_cache_url)
107
+ end
108
+ return [request, response] if method.nil? || url.nil?
109
+
110
+ status = if response.respond_to?(:status_code)
111
+ response.status_code
112
+ elsif response.respond_to?(:status)
113
+ response.status
114
+ else
115
+ 200
116
+ end
117
+ content_type = if response.respond_to?(:content_type)
118
+ response.content_type
119
+ else
120
+ "application/json"
121
+ end
122
+ body = if response.respond_to?(:body)
123
+ response.body.to_s
124
+ else
125
+ response.to_s
126
+ end
127
+
128
+ internal_store(method, url, status.to_i, content_type.to_s, body)
129
+ [request, response]
148
130
  end
149
131
 
150
132
  # ── Direct Cache API (same across all 4 languages) ──────────
@@ -297,8 +279,95 @@ module Tina4
297
279
  @backend_name
298
280
  end
299
281
 
282
+ # @internal Test seam — exercises the same path the middleware uses.
283
+ # Public for parity tests only; do not use in application code.
284
+ def _internal_lookup(method, url)
285
+ internal_lookup(method, url)
286
+ end
287
+
288
+ # @internal Test seam — exercises the same path the middleware uses.
289
+ # Public for parity tests only; do not use in application code.
290
+ def _internal_store(method, url, status_code, content_type, body, ttl: nil)
291
+ internal_store(method, url, status_code, content_type, body, ttl: ttl)
292
+ end
293
+
300
294
  private
301
295
 
296
+ # Build a cache key from method and URL.
297
+ def cache_key(method, url)
298
+ "#{method}:#{url}"
299
+ end
300
+
301
+ # Internal: retrieve a cached response. Used by middleware hooks only.
302
+ def internal_lookup(method, url)
303
+ return nil unless enabled?
304
+ return nil unless method == "GET"
305
+
306
+ key = cache_key(method, url)
307
+ entry = backend_get(key)
308
+
309
+ if entry.nil?
310
+ @mutex.synchronize { @misses += 1 }
311
+ return nil
312
+ end
313
+
314
+ # For memory backend, entry is a CacheEntry; for others, reconstruct
315
+ if entry.is_a?(CacheEntry)
316
+ if Time.now.to_f > entry.expires_at
317
+ backend_delete(key)
318
+ @mutex.synchronize { @misses += 1 }
319
+ return nil
320
+ end
321
+ @mutex.synchronize { @hits += 1 }
322
+ entry
323
+ elsif entry.is_a?(Hash)
324
+ expires_at = entry["expires_at"] || entry[:expires_at] || 0
325
+ if Time.now.to_f > expires_at
326
+ backend_delete(key)
327
+ @mutex.synchronize { @misses += 1 }
328
+ return nil
329
+ end
330
+ @mutex.synchronize { @hits += 1 }
331
+ CacheEntry.new(
332
+ entry["body"] || entry[:body],
333
+ entry["content_type"] || entry[:content_type],
334
+ entry["status_code"] || entry[:status_code],
335
+ expires_at
336
+ )
337
+ end
338
+ end
339
+
340
+ # Internal: store a response in the cache. Used by middleware hooks only.
341
+ def internal_store(method, url, status_code, content_type, body, ttl: nil)
342
+ return unless enabled?
343
+ return unless method == "GET"
344
+ return unless @status_codes.include?(status_code)
345
+
346
+ effective_ttl = ttl || @ttl
347
+ key = cache_key(method, url)
348
+ expires_at = Time.now.to_f + effective_ttl
349
+
350
+ entry_data = {
351
+ "body" => body,
352
+ "content_type" => content_type,
353
+ "status_code" => status_code,
354
+ "expires_at" => expires_at
355
+ }
356
+
357
+ case @backend_name
358
+ when "memory"
359
+ @mutex.synchronize do
360
+ if @store.size >= @max_entries && !@store.key?(key)
361
+ oldest_key = @store.keys.first
362
+ @store.delete(oldest_key)
363
+ end
364
+ @store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
365
+ end
366
+ else
367
+ backend_set(key, entry_data, effective_ttl)
368
+ end
369
+ end
370
+
302
371
  # ── Backend initialization ─────────────────────────────────
303
372
 
304
373
  def init_backend
@@ -519,15 +588,17 @@ module Tina4
519
588
  end
520
589
  end
521
590
 
522
- # ── Module-level convenience (singleton) ───────────────────────
591
+ # ── Module-level convenience (singleton, parity with Python) ───
523
592
 
524
593
  @default_cache = nil
525
594
 
526
595
  class << self
596
+ # Lazy module-level singleton for cache_stats / clear_cache.
527
597
  def cache_instance
528
598
  @default_cache ||= ResponseCache.new(ttl: ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 60)
529
599
  end
530
600
 
601
+ # Module-level KV API (parity with Python tina4_python.cache).
531
602
  def cache_get(key)
532
603
  cache_instance.cache_get(key)
533
604
  end
@@ -540,12 +611,19 @@ module Tina4
540
611
  cache_instance.cache_delete(key)
541
612
  end
542
613
 
543
- def cache_clear
614
+ # Module-level cache stats (parity with Python tina4_python.cache.cache_stats()).
615
+ def cache_stats
616
+ cache_instance.cache_stats
617
+ end
618
+
619
+ # Module-level cache clear (parity with Python tina4_python.cache.clear_cache()).
620
+ def clear_cache
544
621
  cache_instance.clear_cache
545
622
  end
546
623
 
547
- def cache_stats
548
- cache_instance.cache_stats
624
+ # Backward-compat alias for cache_clear (deprecated — use clear_cache).
625
+ def cache_clear
626
+ cache_instance.clear_cache
549
627
  end
550
628
  end
551
629
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.12.2"
4
+ VERSION = "3.12.3"
5
5
  end
@@ -105,12 +105,16 @@ module Tina4
105
105
 
106
106
  # ── Rooms ──────────────────────────────────────────────────
107
107
 
108
- def join_room_for(conn_id, room_name)
108
+ # Internal: add connection ID to a room (called by WebSocketConnection#join_room).
109
+ # Mirrors Python's WebSocketManager._join_room — not part of the public API.
110
+ def _join_room(conn_id, room_name)
109
111
  @rooms[room_name] ||= Set.new
110
112
  @rooms[room_name].add(conn_id)
111
113
  end
112
114
 
113
- def leave_room_for(conn_id, room_name)
115
+ # Internal: remove connection ID from a room (called by WebSocketConnection#leave_room).
116
+ # Mirrors Python's WebSocketManager._leave_room — not part of the public API.
117
+ def _leave_room(conn_id, room_name)
114
118
  @rooms[room_name]&.delete(conn_id)
115
119
  end
116
120
 
@@ -251,12 +255,12 @@ module Tina4
251
255
 
252
256
  def join_room(room_name)
253
257
  @rooms.add(room_name)
254
- @ws_server&.join_room_for(@id, room_name)
258
+ @ws_server&._join_room(@id, room_name)
255
259
  end
256
260
 
257
261
  def leave_room(room_name)
258
262
  @rooms.delete(room_name)
259
- @ws_server&.leave_room_for(@id, room_name)
263
+ @ws_server&._leave_room(@id, room_name)
260
264
  end
261
265
 
262
266
  def broadcast_to_room(room_name, message, exclude_self: false)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.2
4
+ version: 3.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team