tayle 0.0.1
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 +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +476 -0
- data/.ruby-version +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +40 -0
- data/Rakefile +16 -0
- data/lib/tayle/command.rb +36 -0
- data/lib/tayle/gnu_tail.rb +37 -0
- data/lib/tayle/version.rb +3 -0
- data/lib/tayle/writer.rb +23 -0
- data/lib/tayle.rb +128 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4c3659dae92c4938d1fcd1b672acdaae573381d210e53c760960dac3ba03fe91
|
|
4
|
+
data.tar.gz: f0a81610b40f35d0aae755aebd0e48f87eee4facc711cc8bad4167b8ebc5bfa2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 315cec2e7111c0e0ce81ee01df7fe0c15cb96e24b7f9d778214dcdd45d3d6f3e8a6bde57111ba85d08105425410d223987f2eb17f2d4d1418a0c0da5d24f7937
|
|
7
|
+
data.tar.gz: e5f7be6634d58b59df2dc222476b136ba481c388f7f022bad00a8f64240ef08442a9f937c9958568709e3e932cb160df8db39cfd257a85495fc1e8549934f956
|
data/.rspec
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
--require spec_helper
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# RuboCop configuration
|
|
3
|
+
#
|
|
4
|
+
# Base: stock RuboCop defaults (verified against 1.91.0)
|
|
5
|
+
# Plugins: rubocop-claude (AI guardrails), rubocop-rspec, rubocop-performance
|
|
6
|
+
# (chain-hostile cops disabled), rubocop-rake
|
|
7
|
+
# Philosophy: idiomatic Ruby, pipeline-style chaining, strict for AI agents,
|
|
8
|
+
# readable for humans.
|
|
9
|
+
# =============================================================================
|
|
10
|
+
|
|
11
|
+
plugins:
|
|
12
|
+
- rubocop-claude
|
|
13
|
+
- rubocop-rspec
|
|
14
|
+
- rubocop-performance
|
|
15
|
+
- rubocop-rake
|
|
16
|
+
|
|
17
|
+
AllCops:
|
|
18
|
+
NewCops: enable
|
|
19
|
+
TargetRubyVersion: 4.0
|
|
20
|
+
Exclude:
|
|
21
|
+
- bin/*
|
|
22
|
+
- vendor/**/*
|
|
23
|
+
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# Layout
|
|
26
|
+
# =============================================================================
|
|
27
|
+
|
|
28
|
+
# Consistent 2-space indent for a first argument on its own line, never
|
|
29
|
+
# aligned to a nested call.
|
|
30
|
+
#
|
|
31
|
+
# # bad
|
|
32
|
+
# foo = some_method(nested_call(
|
|
33
|
+
# nested_first_param
|
|
34
|
+
# ))
|
|
35
|
+
#
|
|
36
|
+
# # good
|
|
37
|
+
# foo = some_method(nested_call(
|
|
38
|
+
# nested_first_param
|
|
39
|
+
# ))
|
|
40
|
+
Layout/FirstArgumentIndentation:
|
|
41
|
+
EnforcedStyle: consistent
|
|
42
|
+
|
|
43
|
+
# Dot-aligned chaining: the dots form a visual column. This is stock's default,
|
|
44
|
+
# restated here because rubocop-claude switches it to `indented`.
|
|
45
|
+
#
|
|
46
|
+
# # bad
|
|
47
|
+
# ROOT = Pathname(__dir__)
|
|
48
|
+
# .parent
|
|
49
|
+
#
|
|
50
|
+
# # good
|
|
51
|
+
# ROOT = Pathname(__dir__)
|
|
52
|
+
# .parent
|
|
53
|
+
Layout/MultilineMethodCallIndentation:
|
|
54
|
+
EnforcedStyle: aligned
|
|
55
|
+
|
|
56
|
+
# =============================================================================
|
|
57
|
+
# Metrics
|
|
58
|
+
# =============================================================================
|
|
59
|
+
|
|
60
|
+
# Short blocks push toward small chained steps instead of fat lambdas.
|
|
61
|
+
# Stock default is 25. We want 8.
|
|
62
|
+
#
|
|
63
|
+
# # bad - 9 lines of body
|
|
64
|
+
# users.map do |user|
|
|
65
|
+
# name = user.full_name
|
|
66
|
+
# parts = name.split(" ")
|
|
67
|
+
# first = parts.first
|
|
68
|
+
# last = parts.last
|
|
69
|
+
# domain = user.email.split("@").last
|
|
70
|
+
# address = "#{first}.#{last}@#{domain}"
|
|
71
|
+
# normalized = address.downcase
|
|
72
|
+
# handle = normalized.strip
|
|
73
|
+
# handle.to_sym
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# # good - each step is one line
|
|
77
|
+
# users
|
|
78
|
+
# .map(&:full_name)
|
|
79
|
+
# .map { it.split(" ") }
|
|
80
|
+
# .map { "#{it.first}.#{it.last}" }
|
|
81
|
+
# .map(&:downcase)
|
|
82
|
+
#
|
|
83
|
+
# CountAsOne: a multiline array, hash, heredoc, or method call is one line.
|
|
84
|
+
#
|
|
85
|
+
# # good - the hash spans 10 lines but counts as 1
|
|
86
|
+
# users.each do |user|
|
|
87
|
+
# attributes = {
|
|
88
|
+
# id: user.id,
|
|
89
|
+
# name: user.name,
|
|
90
|
+
# email: user.email,
|
|
91
|
+
# role: user.role,
|
|
92
|
+
# plan: user.plan,
|
|
93
|
+
# status: user.status,
|
|
94
|
+
# created_at: user.created_at,
|
|
95
|
+
# updated_at: user.updated_at,
|
|
96
|
+
# }
|
|
97
|
+
# audit(attributes)
|
|
98
|
+
# end
|
|
99
|
+
#
|
|
100
|
+
# AllowedMethods: DSL blocks are exempt. Replaces stock's `refine` entry.
|
|
101
|
+
#
|
|
102
|
+
# # good - long, but `describe` is allowed
|
|
103
|
+
# describe Foo do
|
|
104
|
+
# # ... any number of lines
|
|
105
|
+
# end
|
|
106
|
+
#
|
|
107
|
+
# Exclude: the gemspec is one long block by construction.
|
|
108
|
+
Metrics/BlockLength:
|
|
109
|
+
Max: 8
|
|
110
|
+
CountAsOne:
|
|
111
|
+
- array
|
|
112
|
+
- hash
|
|
113
|
+
- heredoc
|
|
114
|
+
- method_call
|
|
115
|
+
AllowedMethods:
|
|
116
|
+
- command
|
|
117
|
+
- describe
|
|
118
|
+
- context
|
|
119
|
+
- namespace
|
|
120
|
+
- shared_examples
|
|
121
|
+
- shared_examples_for
|
|
122
|
+
- shared_context
|
|
123
|
+
Exclude:
|
|
124
|
+
- "tayle.gemspec"
|
|
125
|
+
|
|
126
|
+
# =============================================================================
|
|
127
|
+
# Style: strings
|
|
128
|
+
# =============================================================================
|
|
129
|
+
|
|
130
|
+
# Double quotes everywhere. One less decision to make.
|
|
131
|
+
#
|
|
132
|
+
# # bad
|
|
133
|
+
# name = 'Alice'
|
|
134
|
+
#
|
|
135
|
+
# # good
|
|
136
|
+
# name = "Alice"
|
|
137
|
+
# greeting = "Hello, #{name}"
|
|
138
|
+
Style/StringLiterals:
|
|
139
|
+
EnforcedStyle: double_quotes
|
|
140
|
+
|
|
141
|
+
# Same rule inside interpolation. Stock wants single quotes here.
|
|
142
|
+
#
|
|
143
|
+
# # bad
|
|
144
|
+
# status = "Tests #{success ? 'PASS' : 'FAIL'}"
|
|
145
|
+
#
|
|
146
|
+
# # good
|
|
147
|
+
# status = "Tests #{success ? "PASS" : "FAIL"}"
|
|
148
|
+
Style/StringLiteralsInInterpolation:
|
|
149
|
+
EnforcedStyle: double_quotes
|
|
150
|
+
|
|
151
|
+
# Frozen string literal is transitional cruft. Ruby 3.4 has chilled strings,
|
|
152
|
+
# and full default freeze is coming. `never` makes autocorrect strip existing
|
|
153
|
+
# magic comments.
|
|
154
|
+
#
|
|
155
|
+
# # bad - autocorrect will strip this
|
|
156
|
+
# # frozen_string_literal: true
|
|
157
|
+
#
|
|
158
|
+
# class Foo
|
|
159
|
+
# end
|
|
160
|
+
#
|
|
161
|
+
# # good
|
|
162
|
+
# class Foo
|
|
163
|
+
# end
|
|
164
|
+
Style/FrozenStringLiteralComment:
|
|
165
|
+
EnforcedStyle: never
|
|
166
|
+
|
|
167
|
+
# =============================================================================
|
|
168
|
+
# Style: literals
|
|
169
|
+
# =============================================================================
|
|
170
|
+
|
|
171
|
+
# Write arrays like arrays. Disabled cops flag the second form in stock.
|
|
172
|
+
#
|
|
173
|
+
# # allowed here, flagged by stock
|
|
174
|
+
# colors = ["red", "green", "blue"]
|
|
175
|
+
#
|
|
176
|
+
# # not used
|
|
177
|
+
# colors = %w[red green blue]
|
|
178
|
+
Style/WordArray:
|
|
179
|
+
Enabled: false
|
|
180
|
+
|
|
181
|
+
# # allowed here, flagged by stock
|
|
182
|
+
# statuses = [:active, :inactive, :pending]
|
|
183
|
+
#
|
|
184
|
+
# # not used
|
|
185
|
+
# statuses = %i[active inactive pending]
|
|
186
|
+
Style/SymbolArray:
|
|
187
|
+
Enabled: false
|
|
188
|
+
|
|
189
|
+
# Trailing commas in multiline literals and arguments. Cleaner diffs: adding an
|
|
190
|
+
# element doesn't touch the previous line, and appending saves a keystroke.
|
|
191
|
+
# `comma` applies only when each element sits on its own line.
|
|
192
|
+
#
|
|
193
|
+
# # bad
|
|
194
|
+
# method_call(
|
|
195
|
+
# arg1,
|
|
196
|
+
# arg2
|
|
197
|
+
# )
|
|
198
|
+
#
|
|
199
|
+
# # good
|
|
200
|
+
# method_call(
|
|
201
|
+
# arg1,
|
|
202
|
+
# arg2,
|
|
203
|
+
# )
|
|
204
|
+
Style/TrailingCommaInArguments:
|
|
205
|
+
EnforcedStyleForMultiline: comma
|
|
206
|
+
|
|
207
|
+
# # bad
|
|
208
|
+
# colors = [
|
|
209
|
+
# "red",
|
|
210
|
+
# "green"
|
|
211
|
+
# ]
|
|
212
|
+
#
|
|
213
|
+
# # good
|
|
214
|
+
# colors = [
|
|
215
|
+
# "red",
|
|
216
|
+
# "green",
|
|
217
|
+
# ]
|
|
218
|
+
Style/TrailingCommaInArrayLiteral:
|
|
219
|
+
EnforcedStyleForMultiline: comma
|
|
220
|
+
|
|
221
|
+
# # bad
|
|
222
|
+
# hash = {
|
|
223
|
+
# name: "Alice",
|
|
224
|
+
# age: 30
|
|
225
|
+
# }
|
|
226
|
+
#
|
|
227
|
+
# # good
|
|
228
|
+
# hash = {
|
|
229
|
+
# name: "Alice",
|
|
230
|
+
# age: 30,
|
|
231
|
+
# }
|
|
232
|
+
Style/TrailingCommaInHashLiteral:
|
|
233
|
+
EnforcedStyleForMultiline: comma
|
|
234
|
+
|
|
235
|
+
# Only literal mutable values get flagged. The Zeitwerk loader is mutable by
|
|
236
|
+
# design — it keeps registering autoloads after LOADER is assigned.
|
|
237
|
+
#
|
|
238
|
+
# # bad
|
|
239
|
+
# COLORS = ["red", "green", "blue"]
|
|
240
|
+
#
|
|
241
|
+
# # good
|
|
242
|
+
# COLORS = ["red", "green", "blue"].freeze
|
|
243
|
+
#
|
|
244
|
+
# # good - not a literal, so `strict` would be wrong here
|
|
245
|
+
# LOADER = Zeitwerk::Loader.for_gem.tap(&:setup)
|
|
246
|
+
Style/MutableConstant:
|
|
247
|
+
EnforcedStyle: literals
|
|
248
|
+
|
|
249
|
+
# =============================================================================
|
|
250
|
+
# Style: blocks and pipelines
|
|
251
|
+
# =============================================================================
|
|
252
|
+
|
|
253
|
+
# Pipeline style. Chaining multiline blocks is the whole point. Stock forbids
|
|
254
|
+
# a multiline block followed by another call.
|
|
255
|
+
#
|
|
256
|
+
# # allowed here, flagged by stock
|
|
257
|
+
# users
|
|
258
|
+
# .select do |user|
|
|
259
|
+
# user.active?
|
|
260
|
+
# user.confirmed?
|
|
261
|
+
# end
|
|
262
|
+
# .map do |user|
|
|
263
|
+
# user.name
|
|
264
|
+
# end
|
|
265
|
+
Style/MultilineBlockChain:
|
|
266
|
+
Enabled: false
|
|
267
|
+
|
|
268
|
+
# Block delimiters are a taste call. Braces for functional transforms, do/end
|
|
269
|
+
# for side effects. No cop captures this nuance.
|
|
270
|
+
#
|
|
271
|
+
# # allowed here, flagged by stock (multiline braces)
|
|
272
|
+
# names = users.map { |user|
|
|
273
|
+
# user.name.downcase
|
|
274
|
+
# }
|
|
275
|
+
#
|
|
276
|
+
# # good in both
|
|
277
|
+
# users.each do |user|
|
|
278
|
+
# send_notification(user)
|
|
279
|
+
# log_activity(user)
|
|
280
|
+
# end
|
|
281
|
+
Style/BlockDelimiters:
|
|
282
|
+
Enabled: false
|
|
283
|
+
|
|
284
|
+
# =============================================================================
|
|
285
|
+
# Style: error handling
|
|
286
|
+
# =============================================================================
|
|
287
|
+
|
|
288
|
+
# Inline fallback is fine when the failure mode is obvious.
|
|
289
|
+
#
|
|
290
|
+
# # allowed here, flagged by stock
|
|
291
|
+
# port = Integer(ENV["PORT"]) rescue 3000
|
|
292
|
+
Style/RescueModifier:
|
|
293
|
+
Enabled: false
|
|
294
|
+
|
|
295
|
+
# An explicit begin/rescue/end scopes what's being rescued. Without it, the
|
|
296
|
+
# rescue reads as part of the method signature. Stock flags a begin that wraps
|
|
297
|
+
# the entire body.
|
|
298
|
+
#
|
|
299
|
+
# # allowed here, flagged by stock
|
|
300
|
+
# def process
|
|
301
|
+
# begin
|
|
302
|
+
# dangerous_call
|
|
303
|
+
# rescue NetworkError => e
|
|
304
|
+
# retry_later(e)
|
|
305
|
+
# end
|
|
306
|
+
# end
|
|
307
|
+
#
|
|
308
|
+
# # also fine, and needs no exemption: begin scopes only part of the body
|
|
309
|
+
# def process
|
|
310
|
+
# logger.info("starting")
|
|
311
|
+
# begin
|
|
312
|
+
# dangerous_call
|
|
313
|
+
# rescue NetworkError => e
|
|
314
|
+
# retry_later(e)
|
|
315
|
+
# end
|
|
316
|
+
# logger.info("done")
|
|
317
|
+
# end
|
|
318
|
+
Style/RedundantBegin:
|
|
319
|
+
Enabled: false
|
|
320
|
+
|
|
321
|
+
# =============================================================================
|
|
322
|
+
# Style: methods and documentation
|
|
323
|
+
# =============================================================================
|
|
324
|
+
|
|
325
|
+
# Named args are explicit and portable. Anonymous forwarding (*, **, &) breaks
|
|
326
|
+
# TruffleRuby, JRuby, and Ruby < 3.2. Stock wants `...` or anonymous.
|
|
327
|
+
#
|
|
328
|
+
# # allowed here, flagged by stock
|
|
329
|
+
# def process(*args, **kwargs, &block)
|
|
330
|
+
# other_method(*args, **kwargs, &block)
|
|
331
|
+
# end
|
|
332
|
+
Style/ArgumentsForwarding:
|
|
333
|
+
Enabled: false
|
|
334
|
+
|
|
335
|
+
# Same rationale as above. Stock wants a bare `&`.
|
|
336
|
+
#
|
|
337
|
+
# # bad
|
|
338
|
+
# def process(&)
|
|
339
|
+
# other_method(&)
|
|
340
|
+
# end
|
|
341
|
+
#
|
|
342
|
+
# # good
|
|
343
|
+
# def process(&block)
|
|
344
|
+
# other_method(&block)
|
|
345
|
+
# end
|
|
346
|
+
Naming/BlockForwarding:
|
|
347
|
+
EnforcedStyle: explicit
|
|
348
|
+
|
|
349
|
+
# Endless methods are a one-line trick. Every method gets def/end. Stock
|
|
350
|
+
# only forbids the multiline form.
|
|
351
|
+
#
|
|
352
|
+
# # bad
|
|
353
|
+
# def upstream = "http://#{host}:#{port}"
|
|
354
|
+
#
|
|
355
|
+
# # good
|
|
356
|
+
# def upstream
|
|
357
|
+
# "http://#{host}:#{port}"
|
|
358
|
+
# end
|
|
359
|
+
Style/EndlessMethod:
|
|
360
|
+
EnforcedStyle: disallow
|
|
361
|
+
|
|
362
|
+
# Classes get rdoc. Run `rake rdoc` and keep it honest. Already on in stock;
|
|
363
|
+
# the override only exempts specs and tests.
|
|
364
|
+
#
|
|
365
|
+
# # bad
|
|
366
|
+
# class UserService
|
|
367
|
+
# attr_reader :user
|
|
368
|
+
# end
|
|
369
|
+
#
|
|
370
|
+
# # good
|
|
371
|
+
# # Handles user lifecycle operations.
|
|
372
|
+
# class UserService
|
|
373
|
+
# attr_reader :user
|
|
374
|
+
# end
|
|
375
|
+
Style/Documentation:
|
|
376
|
+
Enabled: true
|
|
377
|
+
Exclude:
|
|
378
|
+
- "spec/**/*"
|
|
379
|
+
- "test/**/*"
|
|
380
|
+
|
|
381
|
+
# Compact class names are fine for small files and tests.
|
|
382
|
+
#
|
|
383
|
+
# # allowed here, flagged by stock
|
|
384
|
+
# class Tayle::Writer
|
|
385
|
+
# end
|
|
386
|
+
Style/ClassAndModuleChildren:
|
|
387
|
+
Enabled: false
|
|
388
|
+
|
|
389
|
+
# =============================================================================
|
|
390
|
+
# Overrides from rubocop-claude — loosen where pipeline style conflicts
|
|
391
|
+
# =============================================================================
|
|
392
|
+
|
|
393
|
+
# Safe navigation reads fine two links deep; past that it hides a nil bug.
|
|
394
|
+
Claude/NoOverlyDefensiveCode:
|
|
395
|
+
MaxSafeNavigationChain: 2
|
|
396
|
+
|
|
397
|
+
Style/SafeNavigation:
|
|
398
|
+
MaxChainLength: 2
|
|
399
|
+
|
|
400
|
+
# Allow `return a, b` for tuple-style returns.
|
|
401
|
+
Style/RedundantReturn:
|
|
402
|
+
AllowMultipleReturnValues: true
|
|
403
|
+
|
|
404
|
+
# The gemspec's `\x0`-delimited `git ls-files` reader is deliberate, not fancy,
|
|
405
|
+
# and the PTY specs have to send real control characters: ctrl+c and ctrl+z.
|
|
406
|
+
Claude/NoFancyUnicode:
|
|
407
|
+
AllowedUnicode:
|
|
408
|
+
- "\u0003" # ctrl+c
|
|
409
|
+
- "\u001A" # ctrl+z
|
|
410
|
+
Exclude:
|
|
411
|
+
- "tayle.gemspec"
|
|
412
|
+
|
|
413
|
+
# lib/ documents with rdoc, whose usage examples are indented code in comments.
|
|
414
|
+
# The cop only recognizes YARD's `@example`, so every rdoc example reads as
|
|
415
|
+
# commented-out code to it.
|
|
416
|
+
Claude/NoCommentedCode:
|
|
417
|
+
Exclude:
|
|
418
|
+
- "lib/**/*"
|
|
419
|
+
|
|
420
|
+
# =============================================================================
|
|
421
|
+
# Overrides from rubocop-performance — disable chain-hostile cops
|
|
422
|
+
# =============================================================================
|
|
423
|
+
|
|
424
|
+
Performance/ChainArrayAllocation:
|
|
425
|
+
Enabled: false
|
|
426
|
+
|
|
427
|
+
Performance/MapMethodChain:
|
|
428
|
+
Enabled: false
|
|
429
|
+
|
|
430
|
+
# =============================================================================
|
|
431
|
+
# RSpec — rubocop-rspec overrides
|
|
432
|
+
# =============================================================================
|
|
433
|
+
|
|
434
|
+
# spec/fixtures holds programs the specs run, not specs. They are ordinary
|
|
435
|
+
# Ruby and the RSpec cops have nothing to say about them.
|
|
436
|
+
RSpec:
|
|
437
|
+
Exclude:
|
|
438
|
+
- "spec/fixtures/**/*"
|
|
439
|
+
|
|
440
|
+
# Not every describe block wraps a class.
|
|
441
|
+
RSpec/DescribeClass:
|
|
442
|
+
Enabled: false
|
|
443
|
+
|
|
444
|
+
# spec_helper gives every example `aggregate_failures`, so a second expectation
|
|
445
|
+
# still reports both failures instead of masking the later one.
|
|
446
|
+
RSpec/MultipleExpectations:
|
|
447
|
+
Max: 2
|
|
448
|
+
|
|
449
|
+
# These specs nest by process state: shell, then job control, then signal.
|
|
450
|
+
RSpec/NestedGroups:
|
|
451
|
+
Max: 5
|
|
452
|
+
|
|
453
|
+
# `in` for the shell a watcher runs in, `and` for a context that narrows the
|
|
454
|
+
# one above it.
|
|
455
|
+
RSpec/ContextWording:
|
|
456
|
+
Prefixes:
|
|
457
|
+
- when
|
|
458
|
+
- with
|
|
459
|
+
- without
|
|
460
|
+
- in
|
|
461
|
+
- and
|
|
462
|
+
|
|
463
|
+
# Subject placement is a readability call, not a rule.
|
|
464
|
+
RSpec/LeadingSubject:
|
|
465
|
+
Enabled: false
|
|
466
|
+
|
|
467
|
+
# Block style for expect { }.to change { } reads like a sentence.
|
|
468
|
+
RSpec/ExpectChange:
|
|
469
|
+
EnforcedStyle: block
|
|
470
|
+
|
|
471
|
+
RSpec/NamedSubject:
|
|
472
|
+
Enabled: false
|
|
473
|
+
|
|
474
|
+
# Shared contexts legitimately define many helpers.
|
|
475
|
+
RSpec/MultipleMemoizedHelpers:
|
|
476
|
+
Max: 10
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.7
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Gillis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Tayle
|
|
2
|
+
|
|
3
|
+
Follow a file by name with GNU tail -F, across rotation and truncation
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install the gem and add to the application's Gemfile by executing:
|
|
8
|
+
|
|
9
|
+
$ bundle add tayle
|
|
10
|
+
|
|
11
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
|
12
|
+
|
|
13
|
+
$ gem install tayle
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
`Tayle.new` follows a file by name, so it keeps going across log rotation and
|
|
18
|
+
truncation. Reading starts at the current end of the file:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
Tayle.new("app.log") do |tayle|
|
|
22
|
+
tayle.each_line(chomp: true).lazy.map(&:upcase).each { puts it }
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Pass `position: :start` to read what the log already holds first, `writer_pid:`
|
|
27
|
+
to stop once the process writing it does, and `tail_path:` to run a `tail`
|
|
28
|
+
other than the first on `PATH`. See the rdoc on `Tayle` for the details.
|
|
29
|
+
|
|
30
|
+
Requires GNU coreutils `tail` 8.28 or newer; anything else raises
|
|
31
|
+
`Tayle::Unsupported`.
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
36
|
+
`rake` to run the specs and RuboCop.
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "bundler/gem_tasks"
|
|
2
|
+
|
|
3
|
+
require "rspec/core/rake_task"
|
|
4
|
+
|
|
5
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
6
|
+
|
|
7
|
+
require "rubocop/rake_task"
|
|
8
|
+
RuboCop::RakeTask.new
|
|
9
|
+
|
|
10
|
+
require "gempilot/version_task"
|
|
11
|
+
Gempilot::VersionTask.new
|
|
12
|
+
|
|
13
|
+
require "gempilot/zeitwerk_task"
|
|
14
|
+
Gempilot::ZeitwerkTask.new
|
|
15
|
+
|
|
16
|
+
task default: [:spec, :rubocop]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class Tayle
|
|
2
|
+
POSITIONS = [:start, :end].freeze
|
|
3
|
+
private_constant :POSITIONS
|
|
4
|
+
|
|
5
|
+
Command = Data.define(:executable, :path, :writer, :position) do
|
|
6
|
+
def initialize(executable:, path:, writer:, position:)
|
|
7
|
+
unless POSITIONS.include?(position)
|
|
8
|
+
raise ArgumentError, "position: must be :start or :end, not #{position.inspect}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
super(executable:, path: Pathname(path), writer:, position:)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def argv
|
|
15
|
+
[
|
|
16
|
+
executable.to_s,
|
|
17
|
+
"-F",
|
|
18
|
+
"--sleep-interval=0.1",
|
|
19
|
+
*writer&.flag,
|
|
20
|
+
"--bytes=+#{first_byte}",
|
|
21
|
+
"--",
|
|
22
|
+
path.to_s,
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def first_byte
|
|
29
|
+
case position
|
|
30
|
+
in :start then 1
|
|
31
|
+
in :end then path.size?.to_i + 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
private_constant :Command
|
|
36
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class Tayle
|
|
2
|
+
OLDEST_SUPPORTED_VERSION = [8, 28].freeze
|
|
3
|
+
private_constant :OLDEST_SUPPORTED_VERSION
|
|
4
|
+
|
|
5
|
+
GnuTail = Data.define(:executable, :version) do
|
|
6
|
+
def self.locate(command)
|
|
7
|
+
executable = list_candidates(command).find { it.file? && it.executable? }
|
|
8
|
+
raise Unsupported, "#{command} not found or not executable" unless executable
|
|
9
|
+
|
|
10
|
+
new(executable:, version: read_version(executable)).tap(&:check_supported)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.list_candidates(command)
|
|
14
|
+
return [Pathname(command)] if command.to_s.include?("/")
|
|
15
|
+
|
|
16
|
+
ENV
|
|
17
|
+
.fetch("PATH", "")
|
|
18
|
+
.split(File::PATH_SEPARATOR)
|
|
19
|
+
.map { Pathname(it).join(command) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.read_version(executable)
|
|
23
|
+
IO.popen([executable.to_s, "--version"], err: File::NULL, &:read)
|
|
24
|
+
.match(/\Atail \(GNU coreutils\) (\d+)\.(\d+)/)
|
|
25
|
+
&.then { [Integer(it[1]), Integer(it[2])] }
|
|
26
|
+
end
|
|
27
|
+
private_class_method :list_candidates, :read_version
|
|
28
|
+
|
|
29
|
+
def check_supported
|
|
30
|
+
raise Unsupported, "#{executable} is not GNU coreutils tail" unless version
|
|
31
|
+
return unless (version <=> OLDEST_SUPPORTED_VERSION).negative?
|
|
32
|
+
|
|
33
|
+
raise Unsupported, "#{executable} is GNU coreutils tail #{version.join(".")}; Tayle needs 8.28 or newer"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
private_constant :GnuTail
|
|
37
|
+
end
|
data/lib/tayle/writer.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class Tayle
|
|
2
|
+
Writer = Data.define(:pid) do
|
|
3
|
+
def initialize(pid:)
|
|
4
|
+
super(pid: Integer(pid))
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def flag
|
|
8
|
+
"--pid=#{pid}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def gone?
|
|
12
|
+
begin
|
|
13
|
+
Process.kill(0, pid)
|
|
14
|
+
false
|
|
15
|
+
rescue Errno::EPERM
|
|
16
|
+
false
|
|
17
|
+
rescue Errno::ESRCH
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
private_constant :Writer
|
|
23
|
+
end
|
data/lib/tayle.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "zeitwerk"
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# Follows a file by name with GNU <tt>tail -F</tt>, so it keeps going across log rotation and
|
|
5
|
+
# truncation. If this process dies without #close, +tail+ exits once nothing holds the read end
|
|
6
|
+
# of its pipe: at once with inotify, within 0.1s when polling.
|
|
7
|
+
#
|
|
8
|
+
# GNU tail's own limits still apply: records written to a log after it is rotated away are lost,
|
|
9
|
+
# and a log truncated and refilled past its old size before +tail+ notices is read from the old
|
|
10
|
+
# offset.
|
|
11
|
+
#
|
|
12
|
+
# Tayle.new("app.log") do |tayle|
|
|
13
|
+
# tayle.each_line(chomp: true).lazy.map(&:upcase).each { puts it }
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Pass <tt>position: :start</tt> to read what the log already holds first, +writer_pid+ to stop
|
|
17
|
+
# when the process writing it does, and +tail_path+ to run a +tail+ other than the first on +PATH+:
|
|
18
|
+
#
|
|
19
|
+
# writer_pid = Integer(Pathname("app.pid").read)
|
|
20
|
+
# Tayle.new("app.log", position: :start, writer_pid:, tail_path: "/opt/coreutils/bin/tail") do |tayle|
|
|
21
|
+
# tayle.each { print it }
|
|
22
|
+
# end
|
|
23
|
+
class Tayle
|
|
24
|
+
include Enumerable
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# The Zeitwerk loader autoloading everything under +lib/tayle/+.
|
|
28
|
+
LOADER = Zeitwerk::Loader.for_gem.tap(&:setup)
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# Raised by Tayle.new unless +tail_path+ names an executable GNU coreutils +tail+ 8.28 or newer,
|
|
32
|
+
# the first release to exit by itself once nothing reads its output.
|
|
33
|
+
class Unsupported < StandardError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# Raised by #each if +tail+ stops for any reason but its writer exiting: killed, say, or failed.
|
|
38
|
+
class Exited < StandardError
|
|
39
|
+
##
|
|
40
|
+
# The Process::Status of +tail+, or +nil+ if something else reaped it first.
|
|
41
|
+
attr_reader :status
|
|
42
|
+
|
|
43
|
+
def initialize(status:)
|
|
44
|
+
@status = status
|
|
45
|
+
super("tail exited: #{status || "unknown status"}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# The number of lines #each has yielded, across every call; still readable after #close.
|
|
51
|
+
attr_reader :lines_read
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# Follows +path+ (a String or Pathname) from its current end, so only what is appended from now
|
|
55
|
+
# on is yielded; <tt>position: :start</tt> yields what is already there first. A missing +path+
|
|
56
|
+
# is read from its first byte once it appears, though GNU tail then polls instead of using
|
|
57
|
+
# inotify.
|
|
58
|
+
#
|
|
59
|
+
# With +writer_pid+, <tt>tail --pid</tt> exits within 0.1s of that process being reaped (an
|
|
60
|
+
# unreaped zombie still counts as alive), after one last read, and #each returns.
|
|
61
|
+
#
|
|
62
|
+
# +tail_path+ (a String or Pathname) names the +tail+ to run: a path if it contains a slash,
|
|
63
|
+
# otherwise a command looked up on +PATH+. Raises Unsupported unless that is an executable GNU
|
|
64
|
+
# coreutils +tail+ 8.28 or newer.
|
|
65
|
+
#
|
|
66
|
+
# With a block, yields the Tayle and closes it when the block exits.
|
|
67
|
+
def initialize(path, writer_pid: nil, tail_path: "tail", position: :end, &block)
|
|
68
|
+
@writer = writer_pid&.then { Writer.new(pid: it) }
|
|
69
|
+
@lines_read = 0
|
|
70
|
+
@owner_pid = Process.pid
|
|
71
|
+
gnu_tail = GnuTail.locate(tail_path)
|
|
72
|
+
@command = Command.new(executable: gnu_tail.executable, path:, writer: @writer, position:)
|
|
73
|
+
@pipe = IO.popen(@command.argv)
|
|
74
|
+
yield_and_close(&block) if block
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# The Pathname being followed.
|
|
79
|
+
def path
|
|
80
|
+
@command.path
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# Where reading began: +:start+ or +:end+ of #path. Not a live offset, since +tail+ moves on to
|
|
85
|
+
# a new file when the log is rotated.
|
|
86
|
+
def position
|
|
87
|
+
@command.position
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# Yields each line as it is appended, blocking until the next one arrives; +chomp+ strips the
|
|
92
|
+
# line ending. Returns an Enumerator without a block, or +self+ once +tail+ exits after the
|
|
93
|
+
# writer has; raises Exited if +tail+ stops any other way. As with any IO, #close from another
|
|
94
|
+
# thread raises IOError in the thread iterating.
|
|
95
|
+
def each(chomp: false)
|
|
96
|
+
return enum_for(__method__, chomp:) unless block_given?
|
|
97
|
+
|
|
98
|
+
@pipe.each_line(chomp:) do |line|
|
|
99
|
+
@lines_read += 1
|
|
100
|
+
yield line
|
|
101
|
+
end
|
|
102
|
+
@pipe.close
|
|
103
|
+
raise Exited.new(status: Process.last_status) unless @writer&.gone?
|
|
104
|
+
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
alias each_line each
|
|
108
|
+
|
|
109
|
+
##
|
|
110
|
+
# Kills +tail+ with SIGKILL, which it cannot ignore, and reaps it; in a forked child, only drops
|
|
111
|
+
# the child's copy of the pipe. Safe to call more than once.
|
|
112
|
+
def close
|
|
113
|
+
return if @pipe.closed?
|
|
114
|
+
|
|
115
|
+
Process.kill(:KILL, @pipe.pid) if Process.pid == @owner_pid
|
|
116
|
+
@pipe.close
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def yield_and_close
|
|
122
|
+
begin
|
|
123
|
+
yield self
|
|
124
|
+
ensure
|
|
125
|
+
close
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tayle
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- David Gillis
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: zeitwerk
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
email:
|
|
27
|
+
- david@flipmine.com
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- ".rspec"
|
|
33
|
+
- ".rubocop.yml"
|
|
34
|
+
- ".ruby-version"
|
|
35
|
+
- LICENSE.txt
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- lib/tayle.rb
|
|
39
|
+
- lib/tayle/command.rb
|
|
40
|
+
- lib/tayle/gnu_tail.rb
|
|
41
|
+
- lib/tayle/version.rb
|
|
42
|
+
- lib/tayle/writer.rb
|
|
43
|
+
licenses:
|
|
44
|
+
- MIT
|
|
45
|
+
metadata:
|
|
46
|
+
rubygems_mfa_required: 'true'
|
|
47
|
+
rdoc_options: []
|
|
48
|
+
require_paths:
|
|
49
|
+
- lib
|
|
50
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: 4.0.6
|
|
55
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
requirements: []
|
|
61
|
+
rubygems_version: 4.0.22
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: Follow a file by name with GNU tail -F, across rotation and truncation
|
|
64
|
+
test_files: []
|