data_miner 3.0.0.alpha → 3.0.0.beta

Sign up to get free protection for your applications and to get access to all the features.
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/CHANGELOG CHANGED
@@ -1,3 +1,15 @@
1
+ 3.0.0.beta / 2013-07-26
2
+
3
+ * Enhancements
4
+
5
+ * test steps with after: and every: options, referring to row counts in the previous step
6
+ * :limit option for import steps - oh yeah
7
+ * :random_skip option - goes nicely with :limit if you're debugging
8
+
9
+ * Bug fixes
10
+
11
+ * Make sure to stringify data_miner(:append) option
12
+
1
13
  3.0.0.alpha / 2013-07-24
2
14
 
3
15
  * breaking changes
data/data_miner.gemspec CHANGED
@@ -25,7 +25,9 @@ Gem::Specification.new do |s|
25
25
  s.add_runtime_dependency 'posix-spawn'
26
26
  s.add_runtime_dependency 'unix_utils'
27
27
  s.add_runtime_dependency 'roo', '>=1.10.3'
28
-
28
+ s.add_runtime_dependency 'rspec-expectations'
29
+
30
+ s.add_development_dependency 'rspec'
29
31
  s.add_development_dependency 'pry'
30
32
  s.add_development_dependency 'active_record_inline_schema'
31
33
  s.add_development_dependency 'fuzzy_match'
data/lib/data_miner.rb CHANGED
@@ -21,6 +21,7 @@ require 'data_miner/step'
21
21
  require 'data_miner/step/import'
22
22
  require 'data_miner/step/process'
23
23
  require 'data_miner/step/sql'
24
+ require 'data_miner/step/test'
24
25
 
25
26
  # A singleton class that holds global configuration for data mining.
26
27
  #
@@ -91,6 +91,7 @@ class DataMiner
91
91
  #
92
92
  # @return [nil]
93
93
  def data_miner(options = {}, &blk)
94
+ options = options.stringify_keys
94
95
  unless options['append']
95
96
  @data_miner_script = nil
96
97
  end
@@ -92,6 +92,36 @@ class DataMiner
92
92
  append(:process, method_id_or_description, &blk)
93
93
  end
94
94
 
95
+ # A step that runs tests and stops the data miner on failures.
96
+ #
97
+ # rspec-expectations are automatically included.
98
+ #
99
+ # @see DataMiner::ActiveRecordClassMethods#data_miner Overview of how to define data miner scripts inside of ActiveRecord models.
100
+ # @see DataMiner::Step::Test The actual Test class.
101
+ #
102
+ # @param [String] description A description of what the block does.
103
+ # @param [Hash] settings Settings
104
+ # @option settings [String] :after After how many rows of the previous step to run the tests.
105
+ # @yield [] Tests to be run
106
+ #
107
+ # @example Tests
108
+ # data_miner do
109
+ # [...]
110
+ # test "make sure something works" do
111
+ # expect(Pet.count).to be > 10
112
+ # end
113
+ # [...]
114
+ # test "make sure something works", after: 20 do
115
+ # [...]
116
+ # end
117
+ # [...]
118
+ # end
119
+ #
120
+ # @return [nil]
121
+ def test(description, settings = {}, &blk)
122
+ append(:test, description, settings, &blk)
123
+ end
124
+
95
125
  # Import rows into your model.
96
126
  #
97
127
  # As long as...
@@ -217,6 +247,11 @@ class DataMiner
217
247
  Script.current_stack.clear
218
248
  end
219
249
  Script.current_stack << model_name
250
+ steps.each do |step|
251
+ steps.each do |other|
252
+ other.register step
253
+ end
254
+ end
220
255
  steps.each do |step|
221
256
  step.start
222
257
  model.reset_column_information
@@ -12,5 +12,21 @@ class DataMiner
12
12
  def model
13
13
  script.model
14
14
  end
15
+
16
+ def pos
17
+ script.steps.index self
18
+ end
19
+
20
+ def register(step)
21
+ # noop
22
+ end
23
+
24
+ def notify(*args)
25
+ # noop
26
+ end
27
+
28
+ def target?(*args)
29
+ false
30
+ end
15
31
  end
16
32
  end
@@ -20,6 +20,17 @@ class DataMiner
20
20
  # @return [String]
21
21
  attr_reader :description
22
22
 
23
+ # Max number of rows to import.
24
+ # @return [Numeric]
25
+ attr_reader :limit
26
+
27
+ # Number from zero to one representing what percentage of rows to skip. Defaults to 0, of course :)
28
+ # @return [Numeric]
29
+ attr_reader :random_skip
30
+
31
+ # @private
32
+ attr_reader :listeners
33
+
23
34
  # @private
24
35
  def initialize(script, description, settings, &blk)
25
36
  settings = settings.stringify_keys
@@ -41,6 +52,9 @@ class DataMiner
41
52
  @table_settings = settings.dup
42
53
  @table_settings['streaming'] = true
43
54
  @table_mutex = ::Mutex.new
55
+ @limit = settings.fetch 'limit', (1.0/0)
56
+ @random_skip = settings['random_skip']
57
+ @listeners = []
44
58
  instance_eval(&blk)
45
59
  end
46
60
 
@@ -94,6 +108,12 @@ class DataMiner
94
108
  @validate_query == true
95
109
  end
96
110
 
111
+ def register(step)
112
+ if step.target?(self)
113
+ listeners << step
114
+ end
115
+ end
116
+
97
117
  private
98
118
 
99
119
  def upsert_enabled?
@@ -110,7 +130,9 @@ class DataMiner
110
130
  count = 0
111
131
  Upsert.stream(c, model.table_name) do |upsert|
112
132
  table.each do |row|
133
+ next if random_skip and random_skip > Kernel.rand
113
134
  $stderr.puts "#{count}..." if count_every > 0 and count % count_every == 0
135
+ break if count > limit
114
136
  count += 1
115
137
  selector = @key ? { @key => attributes[@key].read(row) } : { model.primary_key => nil }
116
138
  document = attrs_except_key.inject({}) do |memo, attr|
@@ -125,6 +147,9 @@ class DataMiner
125
147
  memo
126
148
  end
127
149
  upsert.row selector, document
150
+ listeners.select! do |listener|
151
+ listener.notify self, count
152
+ end
128
153
  end
129
154
  end
130
155
  model.connection_pool.checkin c
@@ -133,11 +158,16 @@ class DataMiner
133
158
  def save_with_find_or_initialize
134
159
  count = 0
135
160
  table.each do |row|
161
+ next if random_skip and random_skip > Kernel.rand
136
162
  $stderr.puts "#{count}..." if count_every > 0 and count % count_every == 0
163
+ break if count > limit
137
164
  count += 1
138
165
  record = @key ? model.send("find_or_initialize_by_#{@key}", attributes[@key].read(row)) : model.new
139
166
  attributes.each { |_, attr| attr.set_from_row record, row }
140
167
  record.save!
168
+ listeners.select! do |listener|
169
+ listener.notify self, count
170
+ end
141
171
  end
142
172
  end
143
173
 
@@ -0,0 +1,77 @@
1
+ require 'rspec-expectations'
2
+
3
+ class DataMiner
4
+ class Step
5
+ # A step that runs tests and stops the data miner on failures.
6
+ #
7
+ # Create these by calling +test+ inside a +data_miner+ block.
8
+ #
9
+ # @see DataMiner::ActiveRecordClassMethods#data_miner Overview of how to define data miner scripts inside of ActiveRecord models.
10
+ # @see DataMiner::Script#test Creating a test step by calling DataMiner::Script#test from inside a data miner script
11
+ class Test < Step
12
+ include ::RSpec::Expectations
13
+ include ::RSpec::Matchers
14
+
15
+ # A description of what the block does. Doesn't exist when a single class method is specified using a Symbol.
16
+ # @return [String]
17
+ attr_reader :description
18
+
19
+ # The block of arbitrary code to be run.
20
+ # @return [Proc]
21
+ attr_reader :blk
22
+
23
+ # After how many rows of the previous step to run the tests.
24
+ # @return [Numeric]
25
+ attr_reader :after
26
+
27
+ # Every how many rows to run tests
28
+ # @return [Numeric]
29
+ attr_reader :every
30
+
31
+ alias :block_description :description
32
+
33
+ # @private
34
+ def initialize(script, description, settings, &blk)
35
+ @script = script
36
+ @description = description
37
+ @blk = blk
38
+ @after = settings[:after]
39
+ @every = settings[:every]
40
+ raise "can't do both after and every" if after and every
41
+ end
42
+
43
+ # @private
44
+ def start
45
+ if inline?
46
+ eval_catching_errors
47
+ end
48
+ nil
49
+ end
50
+
51
+ def target?(step)
52
+ !inline? and (step.pos == pos - 1)
53
+ end
54
+
55
+ def notify(step, count)
56
+ if count % (after || every) == 0
57
+ eval_catching_errors
58
+ !after # if it's an after, return false, so that we stop getting informed
59
+ else
60
+ true
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def inline?
67
+ not (after or every)
68
+ end
69
+
70
+ def eval_catching_errors
71
+ DataMiner::Script.uniq { instance_eval(&blk) }
72
+ rescue ::RSpec::Expectations::ExpectationNotMetError
73
+ raise RuntimeError, "FAILED: #{description} (#{$!.inspect})"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,3 +1,3 @@
1
1
  class DataMiner
2
- VERSION = '3.0.0.alpha'
2
+ VERSION = '3.0.0.beta'
3
3
  end
@@ -0,0 +1,25 @@
1
+ require 'bundler/setup'
2
+
3
+ # This file was generated by the `rspec --init` command. Conventionally, all
4
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
5
+ # Require this file using `require "spec_helper"` to ensure that it is only
6
+ # loaded once.
7
+ #
8
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
9
+ RSpec.configure do |config|
10
+ config.treat_symbols_as_metadata_keys_with_true_values = true
11
+ config.run_all_when_everything_filtered = true
12
+ config.filter_run :focus
13
+
14
+ # Run specs in random order to surface order dependencies. If you find an
15
+ # order dependency and want to debug it, you can fix the order by providing
16
+ # the seed, which is printed after each run.
17
+ # --seed 1234
18
+ config.order = 'random'
19
+ end
20
+
21
+ require_relative '../test/support/database'
22
+ init_database
23
+ init_models
24
+
25
+ require 'data_miner'
@@ -0,0 +1,134 @@
1
+ require 'spec_helper'
2
+
3
+ class PetTest1 < ActiveRecord::Base
4
+ self.primary_key = "name"
5
+ col :name
6
+ col :favorite_food
7
+ data_miner do
8
+ process :auto_upgrade!
9
+ import("A list of pets", :url => "file://#{Pet::PETS}") do
10
+ key :name
11
+ store :favorite_food
12
+ end
13
+ test "Jerry likes cheese" do
14
+ expect(PetTest1.find('Jerry').favorite_food).to eq 'cheese'
15
+ end
16
+ end
17
+ end
18
+
19
+ class PetTest2 < ActiveRecord::Base
20
+ self.primary_key = "name"
21
+ col :name
22
+ col :favorite_food
23
+ data_miner do
24
+ process :auto_upgrade!
25
+ import("A list of pets", :url => "file://#{Pet::PETS}") do
26
+ key :name
27
+ store :favorite_food
28
+ end
29
+ test "Jerry likes veggies" do
30
+ expect(PetTest2.find('Jerry').favorite_food).to eq 'veggies'
31
+ end
32
+ end
33
+ end
34
+
35
+ class PetTest3 < ActiveRecord::Base
36
+ self.primary_key = "name"
37
+ col :name
38
+ col :favorite_food
39
+ data_miner do
40
+ process :auto_upgrade!
41
+ import("A list of pets", :url => "file://#{Pet::PETS}") do
42
+ key :name
43
+ store :favorite_food
44
+ end
45
+ test "First few have somebody named Pierre", after: 2 do
46
+ expect(PetTest3.count).to eq 2
47
+ expect(PetTest3.where(name: 'Pierre').count).to be > 0
48
+ end
49
+ end
50
+ end
51
+
52
+ class PetTest4 < ActiveRecord::Base
53
+ self.primary_key = "name"
54
+ col :name
55
+ col :favorite_food
56
+ data_miner do
57
+ process :auto_upgrade!
58
+ import("A list of pets", :url => "file://#{Pet::PETS}") do
59
+ key :name
60
+ store :favorite_food
61
+ end
62
+ test "First few have somebody named Johnny", after: 2 do
63
+ expect(PetTest4.count).to eq 2 # that's where we are
64
+ expect(PetTest4.where(name: 'Johnny').count).to be > 0
65
+ end
66
+ end
67
+ end
68
+
69
+ $pet_test_5_i = 0
70
+ class PetTest5 < ActiveRecord::Base
71
+ self.primary_key = "name"
72
+ col :name
73
+ col :favorite_food
74
+ data_miner do
75
+ process :auto_upgrade!
76
+ import("A list of pets", :url => "file://#{Pet::PETS}") do
77
+ key :name
78
+ store :favorite_food
79
+ end
80
+ test "Everybody has a name", every: 2 do
81
+ $pet_test_5_i += 1
82
+ expect(PetTest5.count).to eq $pet_test_5_i*2
83
+ expect(PetTest5.where(name: nil).count).to be 0
84
+ end
85
+ end
86
+ end
87
+
88
+ $pet_test_6_i = 0
89
+ class PetTest6 < ActiveRecord::Base
90
+ self.primary_key = "name"
91
+ col :name
92
+ col :favorite_food
93
+ data_miner do
94
+ process :auto_upgrade!
95
+ import("A list of pets", :url => "file://#{Pet::PETS}") do
96
+ key :name
97
+ store :favorite_food
98
+ end
99
+ test "Everybody has a favorite food", every: 2 do
100
+ $pet_test_6_i += 1
101
+ expect(PetTest6.count).to eq $pet_test_6_i*2
102
+ expect(PetTest6.where(favorite_food: nil).count).to be 0
103
+ end
104
+ end
105
+ end
106
+
107
+ describe DataMiner::Step::Test do
108
+ it "keeps going if it passes" do
109
+ PetTest1.run_data_miner!
110
+ expect(PetTest1.count).to be > 0
111
+ end
112
+ it "stops on failure" do
113
+ expect { PetTest2.run_data_miner! }.to raise_error(/Jerry.*veggies/)
114
+ expect(PetTest2.count).to be > 0 # still populated tho
115
+ end
116
+ it "can be run in the middle of the previous step" do
117
+ PetTest3.run_data_miner!
118
+ expect(PetTest3.count).to be > 0
119
+ end
120
+ it "can be run in the middle of the previous step - failing" do
121
+ expect { PetTest4.run_data_miner! }.to raise_error(/First few.*Johnny/)
122
+ expect(PetTest4.count).to be 2 # stopped after 2
123
+ end
124
+ it "can be run every 2" do
125
+ PetTest5.run_data_miner!
126
+ expect($pet_test_5_i).to eq 2
127
+ expect(PetTest5.count).to be 5
128
+ end
129
+ it "can be run every 2 - failing" do
130
+ expect { PetTest6.run_data_miner! }.to raise_error(/Everybody has a favorite food/)
131
+ expect($pet_test_6_i).to eq 2
132
+ expect(PetTest6.count).to be $pet_test_6_i*2
133
+ end
134
+ end
data/test/helper.rb CHANGED
@@ -1,4 +1,3 @@
1
- require 'rubygems'
2
1
  require 'bundler/setup'
3
2
 
4
3
  if Bundler.definition.specs['debugger'].first
@@ -12,53 +11,6 @@ require 'minitest/autorun'
12
11
  require 'minitest/reporters'
13
12
  MiniTest::Reporters.use!
14
13
 
15
- require 'active_record'
16
- require 'logger'
17
- ActiveRecord::Base.logger = Logger.new $stderr
18
- ActiveRecord::Base.logger.level = (ENV['VERBOSE'] == 'true') ? Logger::DEBUG : Logger::INFO
19
-
20
- ActiveRecord::Base.mass_assignment_sanitizer = :strict
21
-
22
- require 'active_record_inline_schema'
14
+ require_relative 'support/database'
23
15
 
24
16
  require 'data_miner'
25
-
26
- def init_database
27
- case ENV['DATABASE']
28
- when /postgr/i
29
- system %{dropdb test_data_miner}
30
- system %{createdb test_data_miner}
31
- ActiveRecord::Base.establish_connection(
32
- 'adapter' => 'postgresql',
33
- 'encoding' => 'utf8',
34
- 'database' => 'test_data_miner',
35
- 'username' => `whoami`.chomp
36
- )
37
- when /sqlite/i
38
- ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:")
39
- else
40
- system %{mysql -u root -ppassword -e "DROP DATABASE test_data_miner"}
41
- system %{mysql -u root -ppassword -e "CREATE DATABASE test_data_miner CHARSET utf8"}
42
- ActiveRecord::Base.establish_connection(
43
- 'adapter' => (RUBY_PLATFORM == 'java' ? 'mysql' : 'mysql2'),
44
- 'encoding' => 'utf8',
45
- 'database' => 'test_data_miner',
46
- 'username' => 'root',
47
- 'password' => 'password'
48
- )
49
- end
50
- end
51
-
52
- def init_models
53
- require 'support/breed'
54
- require 'support/pet'
55
- require 'support/pet2'
56
- require 'support/pet3'
57
- Pet.auto_upgrade!
58
- Pet2.auto_upgrade!
59
- Pet3.auto_upgrade!
60
-
61
- ActiveRecord::Base.descendants.each do |model|
62
- model.attr_accessible nil
63
- end
64
- end
@@ -0,0 +1,50 @@
1
+ require 'active_record'
2
+ require 'logger'
3
+ ActiveRecord::Base.logger = Logger.new $stderr
4
+ ActiveRecord::Base.logger.level = (ENV['VERBOSE'] == 'true') ? Logger::DEBUG : Logger::INFO
5
+
6
+ ActiveRecord::Base.mass_assignment_sanitizer = :strict
7
+
8
+ require 'active_record_inline_schema'
9
+
10
+ require 'data_miner'
11
+
12
+ def init_database
13
+ case ENV['DATABASE']
14
+ when /postgr/i
15
+ system %{dropdb test_data_miner}
16
+ system %{createdb test_data_miner}
17
+ ActiveRecord::Base.establish_connection(
18
+ 'adapter' => 'postgresql',
19
+ 'encoding' => 'utf8',
20
+ 'database' => 'test_data_miner',
21
+ 'username' => `whoami`.chomp
22
+ )
23
+ when /sqlite/i
24
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:")
25
+ else
26
+ system %{mysql -u root -ppassword -e "DROP DATABASE test_data_miner"}
27
+ system %{mysql -u root -ppassword -e "CREATE DATABASE test_data_miner CHARSET utf8"}
28
+ ActiveRecord::Base.establish_connection(
29
+ 'adapter' => (RUBY_PLATFORM == 'java' ? 'mysql' : 'mysql2'),
30
+ 'encoding' => 'utf8',
31
+ 'database' => 'test_data_miner',
32
+ 'username' => 'root',
33
+ 'password' => 'password'
34
+ )
35
+ end
36
+ end
37
+
38
+ def init_models
39
+ require_relative 'breed'
40
+ require_relative 'pet'
41
+ require_relative 'pet2'
42
+ require_relative 'pet3'
43
+ Pet.auto_upgrade!
44
+ Pet2.auto_upgrade!
45
+ Pet3.auto_upgrade!
46
+
47
+ ActiveRecord::Base.descendants.each do |model|
48
+ model.attr_accessible nil
49
+ end
50
+ end
data/test/support/pet.rb CHANGED
@@ -15,9 +15,15 @@ class Pet < ActiveRecord::Base
15
15
  col :command_phrase
16
16
  col :emphatic_command_phrase
17
17
  belongs_to :breed
18
+
19
+
20
+
18
21
  data_miner do
22
+
19
23
  process :auto_upgrade!
24
+
20
25
  process :run_data_miner_on_parent_associations!
26
+
21
27
  import("A list of pets", :url => "file://#{PETS}") do
22
28
  key :name
23
29
  store :age
@@ -31,5 +37,6 @@ class Pet < ActiveRecord::Base
31
37
  (row['command_phrase'] + "!!!!!") if row['command_phrase']
32
38
  end
33
39
  end
40
+
34
41
  end
35
42
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: data_miner
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.alpha
4
+ version: 3.0.0.beta
5
5
  prerelease: 6
6
6
  platform: ruby
7
7
  authors:
@@ -13,7 +13,7 @@ authors:
13
13
  autorequire:
14
14
  bindir: bin
15
15
  cert_chain: []
16
- date: 2013-07-25 00:00:00.000000000 Z
16
+ date: 2013-07-26 00:00:00.000000000 Z
17
17
  dependencies:
18
18
  - !ruby/object:Gem::Dependency
19
19
  name: activerecord
@@ -143,6 +143,38 @@ dependencies:
143
143
  - - ! '>='
144
144
  - !ruby/object:Gem::Version
145
145
  version: 1.10.3
146
+ - !ruby/object:Gem::Dependency
147
+ name: rspec-expectations
148
+ requirement: !ruby/object:Gem::Requirement
149
+ none: false
150
+ requirements:
151
+ - - ! '>='
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
154
+ type: :runtime
155
+ prerelease: false
156
+ version_requirements: !ruby/object:Gem::Requirement
157
+ none: false
158
+ requirements:
159
+ - - ! '>='
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ - !ruby/object:Gem::Dependency
163
+ name: rspec
164
+ requirement: !ruby/object:Gem::Requirement
165
+ none: false
166
+ requirements:
167
+ - - ! '>='
168
+ - !ruby/object:Gem::Version
169
+ version: '0'
170
+ type: :development
171
+ prerelease: false
172
+ version_requirements: !ruby/object:Gem::Requirement
173
+ none: false
174
+ requirements:
175
+ - - ! '>='
176
+ - !ruby/object:Gem::Version
177
+ version: '0'
146
178
  - !ruby/object:Gem::Dependency
147
179
  name: pry
148
180
  requirement: !ruby/object:Gem::Requirement
@@ -333,6 +365,7 @@ extensions: []
333
365
  extra_rdoc_files: []
334
366
  files:
335
367
  - .gitignore
368
+ - .rspec
336
369
  - .yardopts
337
370
  - CHANGELOG
338
371
  - Gemfile
@@ -348,7 +381,10 @@ files:
348
381
  - lib/data_miner/step/import.rb
349
382
  - lib/data_miner/step/process.rb
350
383
  - lib/data_miner/step/sql.rb
384
+ - lib/data_miner/step/test.rb
351
385
  - lib/data_miner/version.rb
386
+ - spec/spec_helper.rb
387
+ - spec/test_step_spec.rb
352
388
  - test/data_miner/step/test_sql.rb
353
389
  - test/data_miner/test_attribute.rb
354
390
  - test/helper.rb
@@ -356,6 +392,7 @@ files:
356
392
  - test/support/breed_by_license_number.csv
357
393
  - test/support/breeds.xls
358
394
  - test/support/data_miner_with_alchemist.rb
395
+ - test/support/database.rb
359
396
  - test/support/pet.rb
360
397
  - test/support/pet2.rb
361
398
  - test/support/pet3.rb
@@ -390,6 +427,8 @@ specification_version: 3
390
427
  summary: Download, pull out of a ZIP/TAR/GZ/BZ2 archive, parse, correct, and import
391
428
  XLS, ODS, XML, CSV, HTML, etc. into your ActiveRecord models.
392
429
  test_files:
430
+ - spec/spec_helper.rb
431
+ - spec/test_step_spec.rb
393
432
  - test/data_miner/step/test_sql.rb
394
433
  - test/data_miner/test_attribute.rb
395
434
  - test/helper.rb
@@ -397,6 +436,7 @@ test_files:
397
436
  - test/support/breed_by_license_number.csv
398
437
  - test/support/breeds.xls
399
438
  - test/support/data_miner_with_alchemist.rb
439
+ - test/support/database.rb
400
440
  - test/support/pet.rb
401
441
  - test/support/pet2.rb
402
442
  - test/support/pet3.rb