enigma_io 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. data/.gitignore +17 -0
  2. data/.rubocop.yml +14 -0
  3. data/.travis.yml +14 -0
  4. data/Gemfile +11 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +64 -0
  7. data/Rakefile +28 -0
  8. data/enigma_io.gemspec +30 -0
  9. data/examples/data.md +94 -0
  10. data/examples/export.md +35 -0
  11. data/lib/enigma/client.rb +26 -0
  12. data/lib/enigma/download.rb +81 -0
  13. data/lib/enigma/endpoint.rb +108 -0
  14. data/lib/enigma/endpoints/data.rb +5 -0
  15. data/lib/enigma/endpoints/export.rb +22 -0
  16. data/lib/enigma/endpoints/meta.rb +5 -0
  17. data/lib/enigma/endpoints/stats.rb +5 -0
  18. data/lib/enigma/response.rb +17 -0
  19. data/lib/enigma/version.rb +6 -0
  20. data/lib/enigma.rb +49 -0
  21. data/test/client_test.rb +32 -0
  22. data/test/data_test.rb +81 -0
  23. data/test/endpoint_test.rb +55 -0
  24. data/test/export_test.rb +67 -0
  25. data/test/fixtures/download.csv +4 -0
  26. data/test/fixtures/download.zip +0 -0
  27. data/test/fixtures/vcr_cassettes/compound_average.yml +121 -0
  28. data/test/fixtures/vcr_cassettes/data_with_error.yml +38 -0
  29. data/test/fixtures/vcr_cassettes/export.yml +37 -0
  30. data/test/fixtures/vcr_cassettes/filtered_data.yml +6727 -0
  31. data/test/fixtures/vcr_cassettes/limit_data.yml +54 -0
  32. data/test/fixtures/vcr_cassettes/page_data.yml +53 -0
  33. data/test/fixtures/vcr_cassettes/selected_data.yml +541 -0
  34. data/test/fixtures/vcr_cassettes/simple_data.yml +6762 -0
  35. data/test/fixtures/vcr_cassettes/simple_metadata.yml +75 -0
  36. data/test/fixtures/vcr_cassettes/simple_stats.yml +48 -0
  37. data/test/fixtures/vcr_cassettes/sorted_data.yml +6690 -0
  38. data/test/fixtures/vcr_cassettes/sorted_data_descending.yml +6690 -0
  39. data/test/meta_test.rb +26 -0
  40. data/test/response_test.rb +13 -0
  41. data/test/stats_test.rb +27 -0
  42. data/test/test_helper.rb +37 -0
  43. metadata +223 -0
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rubocop.yml ADDED
@@ -0,0 +1,14 @@
1
+ # This configuration was generated by `rubocop --auto-gen-config`.
2
+ # The point is for the user to remove these configuration records
3
+ # one by one as the offences are removed from the code base.
4
+
5
+ MethodLength:
6
+ Max: 20
7
+
8
+ TrivialAccessors:
9
+ Enabled: false
10
+
11
+ AllCops:
12
+ Excludes:
13
+ - test/**
14
+ - examples/**
data/.travis.yml ADDED
@@ -0,0 +1,14 @@
1
+ language: ruby
2
+ rvm:
3
+ - "1.9.3"
4
+ - "2.0.0"
5
+ - "2.1.0"
6
+ - jruby-19mode # JRuby in 1.9 mode
7
+
8
+ # uncomment this line if your project needs to run something other than `rake`:
9
+ # script: bundle exec rspec spec
10
+ before_install: gem update --remote bundler
11
+
12
+ script:
13
+ - bundle exec rake test
14
+ - bundle exec rubocop
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
3
+
4
+ group :test do
5
+ gem 'rake', '>= 0.8.7'
6
+ gem 'simplecov'
7
+ gem 'webmock'
8
+ gem 'excon', '>= 0.27.4'
9
+ gem 'vcr'
10
+ gem 'mocha'
11
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Stephen Pike
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # Ruby client for the enigma api
2
+
3
+ Ruby client for the enigma api located at https://app.enigma.io/api. Supports ruby >= 1.9.3
4
+
5
+ Note that you need api key to use their api.
6
+
7
+ [![Build Status](https://travis-ci.org/scpike/enigma.png?branch=master)](https://travis-ci.org/scpike/enigma)
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ gem 'enigma'
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install enigma
22
+
23
+ ## Usage
24
+
25
+ Basic usage is straightforward. There are also more detailed [data api examples](examples/data.md) and [export api examples](examples/export.md) available.
26
+
27
+ # Defaults to looking for key in ENV['ENIGMA_KEY']
28
+
29
+ client = Enigma::Client.new(key: :secret_key)
30
+
31
+ client.meta('us.gov.whitehouse.visitor-list')
32
+
33
+ res = client.data('us.gov.whitehouse.visitor-list')
34
+
35
+ # get some data
36
+
37
+ res.result.map { |r| ... }
38
+
39
+ client.stats('us.gov.whitehouse.visitor-list', select: 'type_of_access')
40
+
41
+ client.export('us.gov.whitehouse.visitor-list').parse.each do |row|
42
+ puts row.inspect
43
+ end
44
+
45
+ ## Contributing
46
+
47
+ 1. Fork it
48
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
49
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
50
+ 4. Push to the branch (`git push origin my-new-feature`)
51
+ 5. Create new Pull Request
52
+
53
+ ### Notes
54
+
55
+ You'll need to have rubocop installed.
56
+
57
+ gem install rubocop
58
+
59
+ Tests are run with `rake`. Check the test coverage (printed when you
60
+ run rake) as well as the output of rubocop (also run with rake).
61
+
62
+ ## License
63
+
64
+ MIT license
data/Rakefile ADDED
@@ -0,0 +1,28 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rake/testtask'
3
+
4
+ task :gendoc do
5
+ system "yardoc"
6
+ system "yard stats --list-undoc"
7
+ end
8
+
9
+ Rake::TestTask.new do |t|
10
+ t.libs << 'test'
11
+ t.test_files = FileList['test/**_test.rb']
12
+ t.verbose = false
13
+ t.warning = true
14
+ end
15
+
16
+ task :rubocop do |t|
17
+ sh 'rubocop'
18
+ end
19
+
20
+ task :build => :gendoc do
21
+ system "gem build enigma_io.gemspec"
22
+ end
23
+
24
+ task :release => :build do
25
+ system "gem push enigma_io-#{Enigma::VERSION}.gem"
26
+ end
27
+
28
+ task default: [:test, :rubocop]
data/enigma_io.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'enigma/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'enigma_io'
8
+ spec.version = Enigma::VERSION
9
+ spec.authors = ['Stephen Pike']
10
+ spec.email = ['steve@scpike.net']
11
+ spec.description = 'Ruby client for the Enigma API'
12
+ spec.summary = 'Ruby client for the Enigma API'
13
+ spec.homepage = 'https://github.com/scpike/enigma'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
17
+ spec.test_files = Dir.glob("{test/**/*}")
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.add_development_dependency 'bundler', '~> 1.3'
21
+ spec.add_development_dependency 'rake'
22
+ spec.add_development_dependency 'rubocop'
23
+ spec.add_development_dependency 'yard'
24
+
25
+ spec.add_runtime_dependency 'typhoeus'
26
+ spec.add_runtime_dependency 'hashie'
27
+ spec.add_runtime_dependency 'rubyzip', '>= 1.0.0'
28
+
29
+ spec.required_ruby_version = '>= 1.9.3'
30
+ end
data/examples/data.md ADDED
@@ -0,0 +1,94 @@
1
+ # Accessing the Enigma metadata and data apis
2
+
3
+ #### Create a client, key should be set in the environment variable 'ENIGMA_KEY'
4
+
5
+ client = Enigma::Client.new
6
+
7
+ #### Let's look at internet use in the United States
8
+
9
+ res = client.meta('us.gov.census.internet.usage11')
10
+
11
+ #### The result is a Hashie::Mash object, so you can access it like a hash or like an object
12
+
13
+ puts res.keys
14
+
15
+ => ["datapath", "success", "info", "result", "raw"]
16
+
17
+ `raw` is the raw repsonse from the Enigma api. Everything else is
18
+ the contents of the api response
19
+
20
+ #### Verify this is a table (something we can get data about):
21
+
22
+ res.info.result_type
23
+
24
+ => "table"
25
+
26
+ #### Let's see the columns
27
+
28
+ res.result.columns.each { |c| puts "#{c.id}: #{c.description}" }
29
+
30
+ > region: State Region
31
+ >
32
+ > total_over3: Total (thousands)
33
+ >
34
+ > number_somewhere: Number of individuals accessing the Internet from some location (thousands). "Some location" means Internet access that occurs either inside or outside the householder's home.
35
+ >
36
+ > percent_somewhere: Percent of individuals accessing the Internet from some location. "Some location" means Internet access that occurs either inside or outside the householder's home.
37
+ >
38
+ > number_inhome: Individual lives in household with Internet (thousands). At least one member of the individual's household reported using the Internet from home.
39
+ >
40
+ > percent_inhome: Percent of individual living in household with Internet. At least one member of the individual's household reported using the Internet from home.
41
+ >
42
+ > serialid: Serialid
43
+
44
+ #### Now let's get the data
45
+
46
+ data = client.data('us.gov.census.internet.usage11')
47
+
48
+ data.result.each { |r| puts "#{r.region} #{r.percent_inhome}" }
49
+
50
+ > United States 76.50
51
+ >
52
+ > Alaska 80.00
53
+ >
54
+ > Alabama 69.50
55
+ >
56
+ > Arizona 76.00
57
+ >
58
+ > Arkansas 68.50
59
+ >
60
+ > ...
61
+
62
+ #### Let's find the state withthe highest percentage. We also only care about the region and percent_inhome columns
63
+
64
+ res = client.data('us.gov.census.internet.usage11',
65
+ sort: 'percent_inhome',
66
+ select: [ 'region', 'percent_inhome' ])
67
+
68
+ puts res.result.first.to_hash
69
+
70
+ => {"region" =>"New Hampshire", "percent_inhome" =>"87.10"}
71
+
72
+ ##### What about the lowest?
73
+
74
+ puts res.result.last.to_hash
75
+
76
+ => {"region" =>"Mississippi", "percent_inhome" =>"61.40"}
77
+
78
+ ##### Let's search for Pennsylvania
79
+
80
+ res = client.data('us.gov.census.internet.usage11',
81
+ select: [ 'region', 'percent_inhome' ],
82
+ search: { region: 'Pennsylvania })
83
+
84
+ #### Verify only one result found
85
+
86
+ > res.info.total_results
87
+
88
+ => 1
89
+
90
+ #### Check it out
91
+
92
+ > puts res.first.to_hash
93
+
94
+ => {"region" =>"Pennsylvania", "percent_inhome" =>"75.40"}
@@ -0,0 +1,35 @@
1
+ # Export API
2
+
3
+ #### Start the connection
4
+
5
+ client = Enigma::Client.new
6
+
7
+ dl = client.export('us.gov.census.internet.usage11')
8
+
9
+ #### This did *not* download anything. The api replied with a link to where the file will eventually be
10
+
11
+ puts dl.download_url
12
+
13
+ > Secret one time url
14
+
15
+ #### If you want the actual contents, you can ask the client to poll until the file is available
16
+
17
+ dl.get # puts the zipped contents in dl.raw_download
18
+
19
+ #### Write the zipped contents to a file
20
+
21
+ zipfile = File.open('out.zip', 'wb')
22
+
23
+ dl.write zipfile
24
+
25
+ #### Write the unzipped contents to a file
26
+
27
+ csvfile = File.open('out.csv', 'wb')
28
+
29
+ dl.write_csv csvfile
30
+
31
+ #### Read the contents of the csv file
32
+
33
+ dl.parse.each do |row|
34
+ puts row.inspect
35
+ end
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+
3
+ module Enigma
4
+ # Connects to the Enigma api at https://app.enigma.io/api.
5
+ #
6
+ class Client
7
+ # Creates a new client connection
8
+ #
9
+ # @option opts [String] key - Enigma API key.
10
+ #
11
+ # The api key Defaults to the ENIGMA_KEY environment variable
12
+ #
13
+ def initialize(opts = {})
14
+ Enigma.key = ENV['ENIGMA_KEY'] || opts[:key]
15
+ fail ArgumentError, 'API key is required' unless Enigma.key
16
+ end
17
+
18
+ # Each endpoint becomes a method like `client.meta`
19
+ #
20
+ Endpoint.descendants.each do |klass|
21
+ define_method klass.url_chunk do |*args|
22
+ klass.new(*args).request
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,81 @@
1
+ # coding: utf-8
2
+
3
+ module Enigma
4
+ # Handle polling the url returned by the Export api until we're able
5
+ # to download it. Will load the zipped contents of the file into
6
+ # memory, and can then either write it to disk (`write`), write the
7
+ # unzipped version to disk (`write_csv`) or parse the unzipped
8
+ # contents using the CSV library as an array of hashes
9
+ #
10
+ class Download
11
+ DELAY = 1 # How long to wait between polling attempts, in seconds
12
+ attr_accessor :response, :raw_download, :download_contents
13
+
14
+ def initialize(res)
15
+ self.response = res
16
+ end
17
+
18
+ def download_url
19
+ response.export_url
20
+ end
21
+
22
+ def datapath
23
+ response.datapath
24
+ end
25
+
26
+ def get
27
+ @raw_download ||= do_download
28
+ end
29
+
30
+ def write(io)
31
+ get
32
+ io.write raw_download
33
+ end
34
+
35
+ def write_tmp
36
+ tmp = Tempfile.new(datapath)
37
+ write(tmp)
38
+ tmp.rewind
39
+ tmp
40
+ end
41
+
42
+ def unzip
43
+ @download_contents ||=
44
+ begin
45
+ tmp = write_tmp
46
+ contents = nil
47
+ Zip::File.open(tmp.path) do |zipfile|
48
+ contents = zipfile.first.get_input_stream.read
49
+ end
50
+ contents
51
+ end
52
+ end
53
+
54
+ def write_csv(io)
55
+ unzip
56
+ io.write download_contents
57
+ end
58
+
59
+ def parse(opts = {})
60
+ opts = { headers: true, header_converters: :symbol }
61
+ CSV.parse(unzip, opts.merge(opts || {}))
62
+ end
63
+
64
+ def do_download
65
+ Enigma.logger.info "Trying to download #{download_url}"
66
+ success = false
67
+ until success
68
+ req = Typhoeus::Request.new(download_url)
69
+ req.on_complete do |response|
70
+ if response.response_code == 404
71
+ sleep DELAY
72
+ else
73
+ success = true
74
+ return response.body
75
+ end
76
+ end
77
+ req.run
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,108 @@
1
+ # coding: utf-8
2
+ module Enigma
3
+ # Generic Enigma endpoint. Knows how to construct a URL, request it,
4
+ # and wrap the response in an `Enigma::Response`
5
+ #
6
+ # Assumes the api endpoints for its descendants are a lowercase
7
+ # version of their class names
8
+ #
9
+ # Handles some nice conversion of select, search, and where clauses
10
+ # from ruby hashes to string parameters
11
+ #
12
+ class Endpoint
13
+ attr_accessor :params, :datapath, :url
14
+
15
+ def self.descendants
16
+ ObjectSpace.each_object(Class).select { |klass| klass < self }
17
+ end
18
+
19
+ def initialize(datapath, opts = {})
20
+ self.datapath = datapath
21
+ self.params = opts
22
+ end
23
+
24
+ def self.url_chunk
25
+ to_s.gsub(/.*::/, '').downcase
26
+ end
27
+
28
+ def params
29
+ @params[:where] = serialize_where(@params[:where]) if @params[:where]
30
+ @params[:search] = serialize_search(@params[:search]) if @params[:search]
31
+ @params[:select] = serialize_select(@params[:select]) if @params[:select]
32
+ @params
33
+ end
34
+
35
+ # Serialize a where clause. Allows you to pass in a hash and have
36
+ # it converted to an equality where
37
+ #
38
+ # > Filter results with a SQL-style "where" clause. Only applies to
39
+ # > numerical columns - use the "search" parameter for strings. Valid
40
+ # > operators are >, < and =. Only one "where" clause per request is
41
+ # > currently supported.
42
+ #
43
+ # @param [String|Hash] where clause to convert
44
+ # @return [String] parameter ready for the request
45
+ #
46
+ def serialize_where(where)
47
+ if where.is_a? Hash
48
+ column, value = where.first
49
+ "#{column}=#{value}"
50
+ else
51
+ where
52
+ end
53
+ end
54
+
55
+ # Serialize a search clause. Allows you to pass in a hash of
56
+ # one or more fieldName: value pairs
57
+ #
58
+ # @param [String|Hash] search clause to convert
59
+ # @return [String] parameter ready for the request
60
+ #
61
+ def serialize_search(search)
62
+ if search.is_a? Hash
63
+ search.map do |field, value|
64
+ value = [value].flatten.join('|')
65
+ "@#{field} (#{value})"
66
+ end.join ' '
67
+ else
68
+ search
69
+ end
70
+ end
71
+
72
+ # Serialize a search clause. Allows you to pass in an array of
73
+ # column names
74
+ #
75
+ # @param [String|Array] select clause to convert
76
+ # @return [String] parameter ready for the request
77
+ #
78
+ def serialize_select(select)
79
+ if select.is_a? Enumerable
80
+ select.join(',')
81
+ else
82
+ select
83
+ end
84
+ end
85
+
86
+ # Endpoints show up in urls as a lowercase version of their class
87
+ # names
88
+ def url_chunk
89
+ self.class.url_chunk
90
+ end
91
+
92
+ def path
93
+ [Enigma.api_version, url_chunk, Enigma.key, datapath].join('/')
94
+ end
95
+
96
+ def url
97
+ URI.join(Enigma.root_url, path).to_s
98
+ end
99
+
100
+ def request
101
+ Enigma.logger.info "Making request to #{url}"
102
+ req = Typhoeus::Request.new(url, method: :get, params: params).run
103
+ Response.parse(req)
104
+ end
105
+ end
106
+ end
107
+
108
+ Dir[File.dirname(__FILE__) + '/endpoints/*.rb'].each { |file| require(file) }
@@ -0,0 +1,5 @@
1
+ # coding: utf-8
2
+ module Enigma
3
+ class Data < Endpoint
4
+ end
5
+ end
@@ -0,0 +1,22 @@
1
+ # coding: utf-8
2
+ module Enigma
3
+ # The export endpoint has no filtering, but it returns a URL where
4
+ # the download will eventually be available. This client will
5
+ # default to waiting for the file to be available before continuing
6
+ #
7
+ class Export < Endpoint
8
+ attr_accessor :download, :unzip
9
+
10
+ # The API request responds with a URL to poll until it's
11
+ # ready. Create a new download object with that URL and return it
12
+ #
13
+ # @return [Download]
14
+ #
15
+ def request
16
+ req = Typhoeus::Request.new(url, method: :get, params: params).run
17
+ Enigma.logger.info req.body
18
+ res = Response.parse(req)
19
+ Download.new(res)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # coding: utf-8
2
+ module Enigma
3
+ class Meta < Endpoint
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ # coding: utf-8
2
+ module Enigma
3
+ class Stats < Endpoint
4
+ end
5
+ end
@@ -0,0 +1,17 @@
1
+ # coding: utf-8
2
+
3
+ module Enigma
4
+ # We'll wrap the response in a Hashie::Mash subclass so that you can
5
+ # access the attributes as function calls
6
+ #
7
+ # Raw response is kept in the `raw` attribute
8
+ #
9
+ class Response
10
+ def self.parse(res)
11
+ mash = Hashie::Mash.new(JSON.parse(res.body))
12
+ mash.raw = res
13
+ fail mash.message.to_s if mash.info && mash.info.error
14
+ mash
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,6 @@
1
+ # coding: utf-8
2
+
3
+ # Enigma version
4
+ module Enigma
5
+ VERSION = '0.0.1'
6
+ end
data/lib/enigma.rb ADDED
@@ -0,0 +1,49 @@
1
+ # coding: utf-8
2
+ require 'rubygems'
3
+
4
+ # Required gems
5
+ require 'hashie'
6
+ require 'typhoeus'
7
+
8
+ # Core dependencies
9
+ require 'net/https'
10
+ require 'uri'
11
+ require 'json'
12
+ require 'zip'
13
+ require 'csv'
14
+
15
+ # Library
16
+ require 'enigma/version'
17
+ require 'enigma/download'
18
+ require 'enigma/endpoint'
19
+ require 'enigma/response'
20
+ require 'enigma/client'
21
+
22
+ # Access to the engima API
23
+ module Enigma
24
+ attr_accessor :key, :root_url, :api_version
25
+
26
+ def self.root_url
27
+ 'https://api.enigma.io/'
28
+ end
29
+
30
+ def self.api_version
31
+ 'v2'
32
+ end
33
+
34
+ def self.key
35
+ @key
36
+ end
37
+ def self.key=(k)
38
+ @key = k
39
+ end
40
+
41
+ def self.logger
42
+ @logger ||=
43
+ begin
44
+ logger = Logger.new(STDOUT)
45
+ logger.level = Logger::INFO
46
+ logger
47
+ end
48
+ end
49
+ end