kubetailrb 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b945e0de1dfdd765db891edb1a5e8c59a6cf7c1940a81147a4cd3c31b398003
4
- data.tar.gz: bbebd5de583b686f18750f26bb2b9b52f3505da68285286e91f20417c0ecfa23
3
+ metadata.gz: 427b49a8499da9f1c69481130560ba2b783dc122b1a89146e4b2e6ce2f516ec9
4
+ data.tar.gz: 6be87b2d3529725ef21c2acaee397ddf1989dec3e91bd98f9b99494c6531b9e2
5
5
  SHA512:
6
- metadata.gz: 871dc15cb59a8b657d4488fa08f78a04db28910b9aac8ff1938944d943d7a9a99b895164d6fe39f1ce8541a41730da3e34a84c09a89486bf73c2870fea13885d
7
- data.tar.gz: 86d562f6163e7206a5c7a95f52bf8dd2e56de246590b35b4fdd338c1bc1624aacdbd2a7df91475f9612e737e320bdc7ba53ea86cb7bdb0f54ea6d0c89baaf6fc
6
+ metadata.gz: a63a5917935cda345eab9b35f918efdd84778bc58ababf1ede52425cbb71760ac40738cf24ae11596b0c71b373c7aee00b25ec4fc343d0b58deb226e072919f6
7
+ data.tar.gz: '08b4cb3695fb7704010a0bf868fa3465fe6ee7f0a1b5dafc5e90de3fd83295e8b266aec4555ab2f2840becffcb9f5137564781dbfa8edd69698dff091ebb5d98'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
- ## [Unreleased]
1
+ ## [0.2.0] - 2024-12-05
2
+
3
+ - remove `--pretty` flag
4
+ - now by default, it's displayed in pretty mode
5
+ - to display the log without formatting, use `--raw` flag
6
+ - add possibility to filter out the access and datadog logs
7
+ - support displaying stack trace in pretty mode
8
+ - support formatting rails logs
9
+ - fix color when receiving HTTP 500 in access logs
10
+ - add `--display-names` flag to display the pod and container names
11
+ - colorize pod events (blue for new pod events, red for deleted pod events)
2
12
 
3
13
  ## [0.1.0] - 2024-10-27
4
14
 
5
15
  - Initial release
16
+
17
+ ## [Unreleased]
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Kubetailrb
2
2
 
3
+ [![License](http://img.shields.io/badge/license-MIT-green.svg?style=flat)](https://github.com/HazAT/badge/blob/master/LICENSE)
4
+ [![Gem](https://img.shields.io/gem/v/kubetailrb?style=flat)](http://rubygems.org/gems/kubetailrb)
5
+
3
6
  > Tail your Kubernetes pod logs at the same time.
4
7
 
5
8
  > [!NOTE]
@@ -16,6 +19,12 @@
16
19
 
17
20
  ## Installation
18
21
 
22
+ ```sh
23
+ gem install kubetailrb
24
+ ```
25
+
26
+ If you want to install directly from the repository instead:
27
+
19
28
  ```sh
20
29
  # Install dependencies.
21
30
  ./bin/setup
@@ -31,16 +40,18 @@ bundle exec rake install
31
40
  kubetailrb -h
32
41
 
33
42
  # follow pod logs
34
- kubetailrb 'clock' --namespace sandbox
43
+ kubetailrb 'clock' --namespace sandbox --raw
35
44
 
36
45
  # follow pod structured JSON logs and display in human friendly way
37
- kubetailrb 'clock-json' --namespace sandbox --pretty --raw --follow
46
+ kubetailrb 'clock-json' --namespace sandbox --follow
38
47
  # or with shorter flags
39
- kubetailrb 'clock-json' -n sandbox -p -r -f
48
+ kubetailrb 'clock-json' -n sandbox -f
40
49
 
41
50
  # you can filter the pods using regex on the pod names
42
- kubetailrb '^clock(?!-json)' -n sandbox -p -r
51
+ kubetailrb '^clock(?!-json)' -n sandbox -f
43
52
 
53
+ # you can also filter the containers using regex on the container names
54
+ kubetailrb 'clock' -n sandbox -f -c 'json'
44
55
  ```
45
56
 
46
57
  ## Development
@@ -64,6 +75,21 @@ bundle exec rake test:watch
64
75
  bundle exec rake
65
76
  ```
66
77
 
78
+ ## Release a new version
79
+
80
+ Update the version in
81
+ [`lib/kubetailrb/version.rb`](./lib/kubetailrb/version.rb).
82
+
83
+ > [!WARNING]
84
+ > You may have to update the tests...
85
+ > Too lazy to update the script to also update the tests...
86
+
87
+ Then execute the script:
88
+
89
+ ```sh
90
+ ./bin/release
91
+ ```
92
+
67
93
  ## Contributing
68
94
 
69
95
  Bug reports and pull requests are welcome on GitHub at https://github.com/l-lin/kubetailrb.
data/Rakefile CHANGED
@@ -66,6 +66,17 @@ namespace :k8s do
66
66
  puts `kubectl run #{app_name} --image #{docker_image}`
67
67
  end
68
68
 
69
+ desc 'Delete application from k8s.'
70
+ task :delete, [:app_name] do |_, args|
71
+ next unless k8s_up?
72
+
73
+ app_name = args[:app_name]
74
+
75
+ next if app_name.nil? || app_name.strip.empty?
76
+
77
+ puts `kubectl delete po #{app_name} --force true` if pod_up?(app_name)
78
+ end
79
+
69
80
  desc 'Deploy all applications to k8s.'
70
81
  task :deploy_all do
71
82
  Rake::Task['k8s:deploy'].invoke('clock')
data/journey_log.md CHANGED
@@ -4,12 +4,6 @@
4
4
  > tribulations, and triumphs as I navigated the world of this dynamic
5
5
  > programming language.
6
6
 
7
-
8
- ## 🤔 Things that I'm curious about
9
-
10
-
11
- ---
12
-
13
7
  ## 2024-10-27
14
8
  ### Context
15
9
 
@@ -90,7 +84,7 @@ It seems the convention is:
90
84
  - `features` contains the cucumber scenarios, i.e. integration tests.
91
85
  - `bin/` contains some scripts that can help the developer experience,
92
86
  - Rails projects also have scripts in this `bin/` directory.
93
- - `exec/` contains the executables that will be installed to the user system if
87
+ - `exe/` contains the executables that will be installed to the user system if
94
88
  the latter is installing the gem
95
89
  - It seems to be a convention from Bundler, but that is configurable in the
96
90
  `gemspec` file.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'kubetailrb/file_reader'
3
+ require 'kubetailrb/reader/file_reader'
4
4
 
5
5
  module Kubetailrb
6
6
  module Cmd
@@ -13,7 +13,7 @@ module Kubetailrb
13
13
  attr_reader :reader
14
14
 
15
15
  def initialize(filepath:, last_nb_lines: DEFAULT_NB_LINES, follow: DEFAULT_FOLLOW)
16
- @reader = Kubetailrb::FileReader.new(filepath: filepath, last_nb_lines: last_nb_lines, follow: follow)
16
+ @reader = Kubetailrb::Reader::FileReader.new(filepath: filepath, last_nb_lines: last_nb_lines, follow: follow)
17
17
  end
18
18
 
19
19
  def execute
@@ -14,14 +14,15 @@ module Kubetailrb
14
14
  kubetailrb pod-query [flags]
15
15
 
16
16
  Flags:
17
- -v, --version Display version.
18
- -h, --help Display help.
19
- --tail The number of lines from the end of the logs to show. Defaults to 10.
20
- -f, --follow Output appended data as the file grows.
21
- --file Display file content.
22
- -p, --pretty Pretty print JSON logs.
23
- -r, --raw Only display pod logs.
24
- -n, --namespace Kubernetes namespace to use.
17
+ -v, --version Display version.
18
+ -h, --help Display help.
19
+ --tail The number of lines from the end of the logs to show. Defaults to 10.
20
+ -f, --follow Output appended data as the file grows.
21
+ --file Display file content.
22
+ -r, --raw Only display pod logs, without any special formatting.
23
+ --display-names Display pod and container names.
24
+ -n, --namespace Kubernetes namespace to use.
25
+ -c, --container Container name when multiple containers in pod. Default to '.'.
25
26
  HELP
26
27
  end
27
28
 
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'kubetailrb/k8s_pods_reader'
4
- require 'kubetailrb/json_formatter'
5
- require 'kubetailrb/no_op_formatter'
3
+ require 'kubetailrb/reader/k8s_pods_reader'
6
4
 
7
5
  module Kubetailrb
8
6
  module Cmd
@@ -10,17 +8,25 @@ module Kubetailrb
10
8
  class K8s
11
9
  DEFAULT_NB_LINES = 10
12
10
  DEFAULT_NAMESPACE = 'default'
11
+ DEFAULT_CONTAINER_QUERY = '.*'
13
12
 
14
13
  NAMESPACE_FLAGS = %w[-n --namespace].freeze
15
14
  TAIL_FLAG = '--tail'
16
15
  FOLLOW_FLAGS = %w[-f --follow].freeze
17
16
  RAW_FLAGS = %w[-r --raw].freeze
18
- PRETTY_PRINT_FLAGS = %w[-p --pretty].freeze
17
+ DISPLAY_NAMES_FLAG = '--display-names'
18
+
19
+ CONTAINER_FLAGS = %w[-c --container].freeze
20
+ EXCLUDE_FLAGS = %w[-e --exclude].freeze
19
21
 
20
22
  attr_reader :reader
21
23
 
22
- def initialize(pod_query:, opts:, formatter:)
23
- @reader = Kubetailrb::K8sPodsReader.new(pod_query: pod_query, formatter: formatter, opts: opts)
24
+ def initialize(pod_query:, container_query:, opts:)
25
+ @reader = Kubetailrb::Reader::K8sPodsReader.new(
26
+ pod_query: pod_query,
27
+ container_query: container_query,
28
+ opts: opts
29
+ )
24
30
  end
25
31
 
26
32
  def execute
@@ -31,12 +37,14 @@ module Kubetailrb
31
37
  def create(*args)
32
38
  new(
33
39
  pod_query: parse_pod_query(*args),
34
- formatter: parse_pretty_print(*args) ? Kubetailrb::JsonFormatter.new : Kubetailrb::NoOpFormatter.new,
40
+ container_query: parse_container_query(*args),
35
41
  opts: K8sOpts.new(
36
42
  namespace: parse_namespace(*args),
37
43
  last_nb_lines: parse_nb_lines(*args),
38
44
  follow: parse_follow(*args),
39
- raw: parse_raw(*args)
45
+ raw: parse_raw(*args),
46
+ display_names: parse_display_names(*args),
47
+ exclude: parse_exclude(*args)
40
48
  )
41
49
  )
42
50
  end
@@ -109,8 +117,39 @@ module Kubetailrb
109
117
  args.any? { |arg| RAW_FLAGS.include?(arg) }
110
118
  end
111
119
 
112
- def parse_pretty_print(*args)
113
- args.any? { |arg| PRETTY_PRINT_FLAGS.include?(arg) }
120
+ def parse_container_query(*args)
121
+ return DEFAULT_CONTAINER_QUERY unless args.any? { |arg| CONTAINER_FLAGS.include?(arg) }
122
+
123
+ index = args.find_index { |arg| CONTAINER_FLAGS.include?(arg) }.to_i
124
+
125
+ raise MissingContainerQueryValueError, "Missing #{CONTAINER_FLAGS} value." if args[index + 1].nil?
126
+
127
+ args[index + 1]
128
+ end
129
+
130
+ def parse_display_names(*args)
131
+ args.include?(DISPLAY_NAMES_FLAG)
132
+ end
133
+
134
+ #
135
+ # Parse log exclusion from arguments provided in the CLI, e.g.
136
+ #
137
+ # kubetailrb some-pod --exclude access-logs,dd-logs
138
+ #
139
+ # will return [access-logs, dd-logs].
140
+ #
141
+ # Will raise `MissingExcludeValueError` if the value is not provided:
142
+ #
143
+ # kubetailrb some-pod --exclude
144
+ #
145
+ def parse_exclude(*args)
146
+ return [] unless args.any? { |arg| EXCLUDE_FLAGS.include?(arg) }
147
+
148
+ index = args.find_index { |arg| EXCLUDE_FLAGS.include?(arg) }.to_i
149
+
150
+ raise MissingExcludeValueError, "Missing #{EXCLUDE_FLAGS} value." if args[index + 1].nil?
151
+
152
+ args[index + 1].split(',')
114
153
  end
115
154
  end
116
155
  end
@@ -121,6 +160,12 @@ module Kubetailrb
121
160
  class MissingNamespaceValueError < RuntimeError
122
161
  end
123
162
 
163
+ class MissingContainerQueryValueError < RuntimeError
164
+ end
165
+
166
+ class MissingExcludeValueError < RuntimeError
167
+ end
168
+
124
169
  class InvalidNbLinesValueError < RuntimeError
125
170
  end
126
171
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubetailrb/validated'
4
+
5
+ module Kubetailrb
6
+ module Filter
7
+ # Filter the logs that we do not want to see.
8
+ # Currently only supporting excluding access logs and datadog logs.
9
+ class LogFilter
10
+ include Validated
11
+
12
+ def self.create(exclude)
13
+ new(exclude.include?('access-logs'), exclude.include?('dd-logs'))
14
+ end
15
+
16
+ def initialize(exclude_access_logs, exclude_dd_logs)
17
+ @exclude_access_logs = exclude_access_logs
18
+ @exclude_dd_logs = exclude_dd_logs
19
+
20
+ validate
21
+ end
22
+
23
+ # Returns true if the log should be print, false otherwise.
24
+ def test(log)
25
+ return false if @exclude_access_logs && access_log?(log)
26
+ return false if @exclude_dd_logs && dd_log?(log)
27
+
28
+ true
29
+ end
30
+
31
+ def exclude_access_logs?
32
+ @exclude_access_logs
33
+ end
34
+
35
+ def exclude_dd_logs?
36
+ @exclude_dd_logs
37
+ end
38
+
39
+ private
40
+
41
+ def validate
42
+ validate_boolean @exclude_access_logs, "Invalid exclude_access_logs: #{@exclude_access_logs}."
43
+ validate_boolean @exclude_dd_logs, "Invalid exclude_dd_logs: #{@exclude_dd_logs}."
44
+ end
45
+
46
+ def access_log?(log)
47
+ json = JSON.parse(log)
48
+ # NOTE: Shall I mutualize this function, as it's also used in
49
+ # JsonFormatter? It's only implemented in 2 places... Maybe I shall wait
50
+ # until there's a third one before applying DRY.
51
+ json.include?('http.response.status_code') || json.include?('http_status')
52
+ rescue JSON::ParserError
53
+ false
54
+ end
55
+
56
+ def dd_log?(log)
57
+ # NOTE: Is there's a better way to detect if it's a datadog log?
58
+ log.include?('[dd') || log.include?('[datadog]')
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubetailrb/painter'
4
+
5
+ module Kubetailrb
6
+ module Formatter
7
+ # Format JSON to human readable.
8
+ class JsonFormatter
9
+ include Painter
10
+
11
+ def format(log)
12
+ json = JSON.parse(log)
13
+
14
+ return format_access_log(json) if access_log?(json)
15
+
16
+ format_application_log(json)
17
+ rescue JSON::ParserError
18
+ log
19
+ end
20
+
21
+ private
22
+
23
+ def access_log?(json)
24
+ json.include?('http.response.status_code') || json.include?('http_status')
25
+ end
26
+
27
+ def format_access_log(json)
28
+ "#{json["@timestamp"]}#{http_status_code json}#{http_method json} #{url_path json}"
29
+ end
30
+
31
+ def format_application_log(json)
32
+ "#{json["@timestamp"]}#{log_level json}#{json["message"]}#{format_stack_trace json}"
33
+ end
34
+
35
+ def format_stack_trace(json)
36
+ stack_trace = json['error.stack_trace']
37
+
38
+ return '' if stack_trace.nil? || stack_trace.strip&.empty?
39
+
40
+ "\n#{stack_trace}"
41
+ end
42
+
43
+ def http_status_code(json)
44
+ code = json['http.response.status_code'] || json['http_status']
45
+
46
+ return " #{highlight_blue(" I ")} [#{code}] " if code >= 200 && code < 400
47
+ return " #{highlight_yellow(" W ")} [#{code}] " if code >= 400 && code < 500
48
+ return " #{highlight_red(" E ")} [#{code}] " if code >= 500
49
+
50
+ " #{code} "
51
+ end
52
+
53
+ def log_level(json)
54
+ level = json['log.level'] || json.dig('log', 'level')
55
+ return ' ' if level.nil? || level.strip.empty?
56
+ return " #{highlight_blue(" I ")} " if level == 'INFO'
57
+ return " #{highlight_yellow(" W ")} " if level == 'WARN'
58
+ return " #{highlight_red(" E ")} " if level == 'ERROR'
59
+
60
+ " #{level} "
61
+ end
62
+
63
+ def http_method(json)
64
+ json['http.request.method'] || json['http_method']
65
+ end
66
+
67
+ def url_path(json)
68
+ json['url.path'] || json['http_path']
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kubetailrb
4
+ module Formatter
5
+ # Formatter that does nothing except return what's given to it.
6
+ class NoOpFormatter
7
+ def format(log)
8
+ log
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubetailrb/validated'
4
+
5
+ module Kubetailrb
6
+ module Formatter
7
+ # Display the pod and container name.
8
+ class PodMetadataFormatter
9
+ include Validated
10
+
11
+ def initialize(pod_name, container_name, formatter)
12
+ @pod_name = pod_name
13
+ @container_name = container_name
14
+ @formatter = formatter
15
+
16
+ validate
17
+ end
18
+
19
+ def format(log)
20
+ "#{@pod_name}/#{@container_name} | #{@formatter.format(log)}"
21
+ end
22
+
23
+ private
24
+
25
+ def validate
26
+ raise_if_blank @pod_name, 'Pod name not set.'
27
+ raise_if_blank @container_name, 'Container name not set.'
28
+ raise_if_nil @formatter, 'Formatter not set.'
29
+ end
30
+ end
31
+ end
32
+ end
@@ -7,13 +7,15 @@ module Kubetailrb
7
7
  class K8sOpts
8
8
  include Validated
9
9
 
10
- attr_reader :namespace, :last_nb_lines
10
+ attr_reader :namespace, :last_nb_lines, :exclude
11
11
 
12
- def initialize(namespace:, last_nb_lines:, follow:, raw:)
12
+ def initialize(namespace:, last_nb_lines:, follow:, raw:, display_names:, exclude:) # rubocop:disable Metrics/ParameterLists
13
13
  @namespace = namespace
14
14
  @last_nb_lines = last_nb_lines
15
15
  @follow = follow
16
16
  @raw = raw
17
+ @display_names = display_names
18
+ @exclude = exclude
17
19
 
18
20
  validate
19
21
  end
@@ -26,6 +28,10 @@ module Kubetailrb
26
28
  @raw
27
29
  end
28
30
 
31
+ def display_names?
32
+ @display_names
33
+ end
34
+
29
35
  private
30
36
 
31
37
  def validate
@@ -33,6 +39,8 @@ module Kubetailrb
33
39
  validate_last_nb_lines @last_nb_lines
34
40
  validate_boolean @follow, "Invalid follow: #{@follow}."
35
41
  validate_boolean @raw, "Invalid raw: #{@raw}."
42
+ validate_boolean @display_names, "Invalid display names: #{@display_names}."
43
+ raise_if_nil @exclude, 'Exclude not set'
36
44
  end
37
45
  end
38
46
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kubetailrb
4
+ # Add behaviors to colorize console output.
5
+ module Painter
6
+ def blue(text)
7
+ colorize(text, '34')
8
+ end
9
+
10
+ def red(text)
11
+ colorize(text, '31')
12
+ end
13
+
14
+ def highlight_blue(text)
15
+ colorize(text, '1;30;44')
16
+ end
17
+
18
+ def highlight_yellow(text)
19
+ colorize(text, '1;30;43')
20
+ end
21
+
22
+ def highlight_red(text)
23
+ colorize(text, '1;30;41')
24
+ end
25
+
26
+ private
27
+
28
+ def colorize(text, color_code)
29
+ "\e[#{color_code}m#{text}\e[0m"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubetailrb/validated'
4
+
5
+ module Kubetailrb
6
+ module Reader
7
+ # Read file content
8
+ class FileReader
9
+ include Validated
10
+
11
+ attr_reader :filepath, :last_nb_lines
12
+
13
+ def initialize(filepath:, last_nb_lines:, follow:)
14
+ @filepath = filepath
15
+ @last_nb_lines = last_nb_lines
16
+ @follow = follow
17
+
18
+ validate
19
+ end
20
+
21
+ def read
22
+ # naive_read
23
+ read_with_fd
24
+ end
25
+
26
+ # NOTE: Is there something like `attr_reader` but for boolean?
27
+ # Nope, Ruby does not know if a variable is a boolean or not. So it cannot
28
+ # create a dedicated method for those booleans.
29
+ def follow?
30
+ @follow
31
+ end
32
+
33
+ private
34
+
35
+ def validate
36
+ raise NoSuchFileError, "#{@filepath} not found" unless File.exist?(@filepath)
37
+
38
+ validate_last_nb_lines @last_nb_lines
39
+ validate_boolean @follow, "Invalid follow: #{@follow}."
40
+ end
41
+
42
+ #
43
+ # Naive implementation to read the last N lines of a file.
44
+ # Does not support `--follow`.
45
+ # Took ~1.41s to read a 3.1G file (5M lines).
46
+ #
47
+ def naive_read
48
+ # Let's us `wc` optimized to count the number of lines!
49
+ nb_lines = `wc -l #{@filepath}`.split.first.to_i
50
+
51
+ start = nb_lines - @last_nb_lines
52
+ i = 0
53
+
54
+ File.open(@filepath, 'r').each_line do |line|
55
+ puts line if i >= start
56
+ i += 1
57
+ end
58
+ end
59
+
60
+ #
61
+ # Use `seek` to start from the EOF.
62
+ # Use `read` to read the content of the file from the given position.
63
+ # src: https://renehernandez.io/tutorials/implementing-tail-command-in-ruby/
64
+ # Took ~0.13s to read a 3.1G file (5M lines).
65
+ #
66
+ def read_with_fd
67
+ file = File.open(@filepath)
68
+ update_stats file
69
+ read_last_nb_lines file
70
+
71
+ if @follow
72
+ loop do
73
+ if file_changed?(file)
74
+ update_stats(file)
75
+ print file.read
76
+ end
77
+ end
78
+ end
79
+ ensure
80
+ file&.close
81
+ end
82
+
83
+ def read_last_nb_lines(file)
84
+ return if File.empty?(file)
85
+
86
+ pos = 0
87
+ current_line_nb = 0
88
+
89
+ loop do
90
+ pos -= 1
91
+ # Seek file position from the end.
92
+ file.seek(pos, IO::SEEK_END)
93
+
94
+ # If we have reached the begining of the file, read all the file.
95
+ # We need to do this check before reading the next byte, otherwise, the
96
+ # cursor will be moved to 1.
97
+ break if file.tell.zero?
98
+
99
+ # Read only one character (or is it byte?).
100
+ char = file.read(1)
101
+ current_line_nb += 1 if char == "\n"
102
+
103
+ break if current_line_nb > @last_nb_lines
104
+ end
105
+
106
+ update_stats file
107
+ puts file.read
108
+ end
109
+
110
+ def update_stats(file)
111
+ @mtime = file.stat.mtime
112
+ @size = file.size
113
+ end
114
+
115
+ def file_changed?(file)
116
+ @mtime != file.stat.mtime || @size != file.size
117
+ end
118
+ end
119
+
120
+ # NOTE: We can create custom exceptions by extending RuntimeError.
121
+ class NoSuchFileError < RuntimeError
122
+ end
123
+ end
124
+ end