chrome_data 0.0.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8afb6ab74cdffac02f50225de7260a9ef4e1456a
4
+ data.tar.gz: 33e84a8718d9cfe54b5e255ddf68a474a93138cd
5
+ SHA512:
6
+ metadata.gz: e6cdddc5b6a76f2d4d5e103f17961daa30945f5fb8e8abb9be8c10f5316415061dc4fd8bab1a7026dddfb8da1338d834e822136a0b4512a51a9d7c43fe910d90
7
+ data.tar.gz: c9cec11f40b545fdcb87ab665b0b9b73d1584ba2c4a06a9cb972bfa0a82260ef22be0be34d8a01cab9ce323dee0a0646567d98b3338055315360a22554d3fcae
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/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in chrome_data.gemspec
4
+ gemspec
5
+
6
+
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Room 118 Solutions, Inc.
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,114 @@
1
+ # ChromeData
2
+
3
+ Provides a simple ruby interface for Chrome Data's API. Read more about it here: http://www.chromedata.com/
4
+
5
+ The wonderful [lolsoap](https://github.com/loco2/lolsoap) gem does most of the heavy lifting.
6
+
7
+ ## Installation
8
+
9
+ Add this gem to your application's Gemfile:
10
+
11
+ gem 'chrome_data'
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install chrome_data
20
+
21
+ ## Usage
22
+
23
+ ### Configuration
24
+ Valid options:
25
+
26
+ * account_number (required)
27
+ * account_secret (required)
28
+ * country (default: 'US')
29
+ * language (default: 'en')
30
+ * cache_store
31
+
32
+ Configuration:
33
+
34
+ ChromeData.config.merge! { account_number: 1234, account_secret: '5678' }
35
+
36
+ ### Requests
37
+ #### Data Collection
38
+ #### Makes (Divisions)
39
+ Fields:
40
+
41
+ * id (Integer)
42
+ * name (String)
43
+
44
+ Request a set of divisions:
45
+
46
+ ChromeData::Division.find_all_by_year(1999)
47
+
48
+ Request models for a division (same as ChromeData::Model.find_all_by_year_and_division_id)
49
+
50
+ mazda = ChromeData::Division.new(id: 26, name: "Mazda")
51
+ mazda_models = mazda.models_for_year(1999)
52
+
53
+ #### Models
54
+ Fields:
55
+
56
+ * id (Integer)
57
+ * name (String)
58
+
59
+ Find models for year and division
60
+
61
+ mazda_models = ChromeData::Model.find_all_by_year_and_division_id(1999, 26)
62
+
63
+ Find styles for a specific model (same as ChromeData::Style.find_all_by_model_id)
64
+
65
+ miata = ChromeData::Model.new(id: 4768, name: "MX-5 Miata") # 1999 Mazda MX-5 Miata
66
+ miata_styles = miata.styles
67
+
68
+ #### Styles
69
+ Fields:
70
+
71
+ * id (Integer)
72
+ * name (String)
73
+
74
+ Only loaded through a Vehicle:
75
+
76
+ * trim (String)
77
+ * name_without_trim (String)
78
+
79
+ Find styles for a model by model id
80
+
81
+ miata_styles = ChromeData::Style.find_all_by_model_id(4768)
82
+
83
+ #### Vehicle
84
+ Fields:
85
+
86
+ * division (String)
87
+ * engines (Array of Engine)
88
+ * model (String)
89
+ * model_year (Integer)
90
+ * styles (Array of Style)
91
+
92
+ Find a vehicle by VIN
93
+
94
+ vehicle = ChromeData::Vehicle.find_by_vin('JM1NB3536X0131402')
95
+
96
+ #### Engine
97
+ Engines are only loaded through a find_by_vin request
98
+
99
+ Fields:
100
+
101
+ * type (String)
102
+
103
+ #### Model Years
104
+ Years start at 1981 due to VIN standardization
105
+
106
+ ChromeData::ModelYears.all
107
+
108
+ ## Contributing
109
+
110
+ 1. Fork it
111
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
112
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
113
+ 4. Push to the branch (`git push origin my-new-feature`)
114
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs.push "lib"
6
+ t.test_files = FileList['test/**/*_test.rb']
7
+ t.verbose = true
8
+ end
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'chrome_data/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "chrome_data"
8
+ spec.version = ChromeData::VERSION
9
+ spec.authors = ["Jim Ryan"]
10
+ spec.email = ["jim@room118solutions.com"]
11
+ spec.description = %q{Provides a ruby interface for Chrome Data's API. Read more about it here: http://www.chromedata.com/}
12
+ spec.summary = %q{A ruby interface for Chrome Data's API}
13
+ spec.homepage = "http://github.com/room118solutions/chrome_data"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.3"
22
+ spec.add_development_dependency "rake"
23
+ spec.add_development_dependency "minitest"
24
+ spec.add_development_dependency "vcr"
25
+ spec.add_development_dependency "webmock", '~> 1.10.0' # Locked at 1.10.x to prevent VCR warnings
26
+ spec.add_development_dependency "mocha"
27
+
28
+ spec.add_dependency "symboltable"
29
+ spec.add_dependency "activesupport", '>= 3.0'
30
+ spec.add_dependency "lolsoap", "~> 0.2.0"
31
+ end
@@ -0,0 +1,88 @@
1
+ require "net/http"
2
+ require "lolsoap"
3
+
4
+ module ChromeData
5
+ class BaseRequest
6
+ def initialize(attrs={})
7
+ attrs.each do |k, v|
8
+ send "#{k}=", v
9
+ end
10
+ end
11
+
12
+ class << self
13
+ # Builds request, sets additional data on request element, makes request,
14
+ # and returns array of child elements wrapped in instances of this class
15
+ def request(data)
16
+ request = build_request
17
+
18
+ request.body do |b|
19
+ # Set configured account info on builder
20
+ b.accountInfo(
21
+ number: ChromeData.config.account_number,
22
+ secret: ChromeData.config.account_secret,
23
+ country: ChromeData.config.country,
24
+ language: ChromeData.config.language
25
+ )
26
+
27
+ # Set additional elements on builder
28
+ data.each do |k, v|
29
+ # Add the key/value pair as an attribute to the request element if that's what it should be,
30
+ # otherwise add it as a sub-element
31
+ # NOTE: This basically mirrors LolSoap::Builder#method_missing
32
+ # because Builder undefines most methods, including #send
33
+ if b.__type__.has_attribute?(k)
34
+ b.__attribute__ k, v
35
+ else
36
+ b.__tag__ k, v
37
+ end
38
+ end
39
+ end
40
+
41
+ # Make the request
42
+ response = make_request(request)
43
+
44
+ parse_response(response)
45
+ end
46
+
47
+ # Makes request, returns LolSoap::Response
48
+ def make_request(request)
49
+ raw_response = Net::HTTP.start(endpoint_uri.host, endpoint_uri.port) do |http|
50
+ http.request_post(endpoint_uri.path, request.content, request.headers)
51
+ end
52
+
53
+ client.response(request, raw_response.body)
54
+ end
55
+
56
+ # Builds request, returns LolSoap::Request
57
+ def build_request
58
+ client.request request_name
59
+ end
60
+
61
+ def client
62
+ @@client ||= LolSoap::Client.new(wsdl_body)
63
+ end
64
+
65
+ def wsdl_body
66
+ @@wsdl_body ||= Net::HTTP.get_response(URI('http://services.chromedata.com/Description/7a?wsdl')).body
67
+ end
68
+
69
+ def endpoint_uri
70
+ @@endpoint_uri ||= URI(client.wsdl.endpoint)
71
+ end
72
+
73
+ # Given an element_name and LolSoap::Response, returns an array of Nokogiri::XML::Elements
74
+ def find_elements(element_name, response)
75
+ response.body.xpath(".//x:#{element_name}", 'x' => response.body.namespace.href)
76
+ end
77
+
78
+ # Internal: Given a LolSoap::Response, returns appropriately parsed response
79
+ def parse_response(response)
80
+ raise NotImplementedError, '.parse_response should be implemented in subclass'
81
+ end
82
+
83
+ def request_name
84
+ raise NotImplementedError, '.request_name should be implemented in subclass'
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,31 @@
1
+ module ChromeData::Caching
2
+ # Creates CacheStore object based on config.cache_store options
3
+ # reverse merges 'chromedata' namespace to options hash
4
+ def _cache_store
5
+ return @@_cache_store if defined? @@_cache_store
6
+
7
+ if ChromeData.config.cache_store
8
+ cache_opts = { namespace: 'chromedata' }
9
+
10
+ if ChromeData.config.cache_store.is_a?(Array)
11
+ if ChromeData.config.cache_store.last.is_a?(Hash)
12
+ ChromeData.config.cache_store.last.reverse_merge! cache_opts
13
+ else
14
+ ChromeData.config.cache_store << cache_opts
15
+ end
16
+ elsif ChromeData.config.cache_store.is_a?(Symbol)
17
+ ChromeData.config.cache_store = [ChromeData.config.cache_store, cache_opts]
18
+ end
19
+
20
+ @@_cache_store = ActiveSupport::Cache.lookup_store(ChromeData.config.cache_store)
21
+ end
22
+ end
23
+
24
+ def cache(key, &blk)
25
+ if ChromeData._cache_store
26
+ ChromeData._cache_store.fetch key, &blk
27
+ else
28
+ blk.call
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ module ChromeData
2
+ class CollectionRequest < BaseRequest
3
+ attr_accessor :id, :name
4
+
5
+ class << self
6
+ def request_name
7
+ # Cheap-o inflector
8
+ "get#{name.split('::').last}s"
9
+ end
10
+
11
+ # Find elements matching class name and instantiate them using their id attribute and text
12
+ def parse_response(response)
13
+ find_elements(name.split('::').last.downcase, response).map do |e|
14
+ new id: e.attr('id').to_i, name: e.text
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,14 @@
1
+ module ChromeData
2
+ class Division < CollectionRequest
3
+
4
+ def models_for_year(year)
5
+ Model.find_all_by_year_and_division_id year, id
6
+ end
7
+
8
+ def self.find_all_by_year(year)
9
+ ChromeData.cache "#{request_name.underscore}-model_year-#{year}" do
10
+ request 'modelYear' => year
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ module ChromeData
2
+ class Model < CollectionRequest
3
+ def styles
4
+ Style.find_all_by_model_id id
5
+ end
6
+
7
+ def self.find_all_by_year_and_division_id(year, division_id)
8
+ ChromeData.cache "#{request_name.underscore}-model_year-#{year}-division_id-#{division_id}" do
9
+ request 'modelYear' => year, 'divisionId' => division_id
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # NOTE: Chrome Data's API offers a getModelYears method that returns every year from 1981 through to next year.
2
+ # We could hit the API for that data, but I think that's silly, so this class calculates the years
3
+ # that Chrome Data would return.
4
+
5
+ module ChromeData
6
+ class ModelYear
7
+
8
+ # Chrome Data returns years in the opposite order, but that's not typically
9
+ # how selects are built, so they're reversed here,
10
+ # since that's the only purpose that I can see for this method.
11
+ def self.all
12
+ (Time.now.year + 1).downto(1981).to_a
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ module ChromeData
2
+ class Style < CollectionRequest
3
+ class BodyType < Struct.new(:id, :name); end
4
+
5
+ # These are only populated when accessing a style through a Vehicle
6
+ attr_accessor :trim, :name_without_trim, :body_types
7
+
8
+ def self.find_all_by_model_id(model_id)
9
+ ChromeData.cache "#{request_name.underscore}-model_id-#{model_id}" do
10
+ request 'modelId' => model_id
11
+ end
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,43 @@
1
+ module ChromeData
2
+ class Vehicle < BaseRequest
3
+ class Engine < Struct.new(:type); end
4
+
5
+ attr_accessor :model_year, :division, :model, :styles, :engines
6
+
7
+ class << self
8
+ def request_name
9
+ "describeVehicle"
10
+ end
11
+
12
+ def find_by_vin(vin)
13
+ request 'vin' => vin
14
+ end
15
+
16
+ def parse_response(response)
17
+ if vin_description = find_elements('vinDescription', response).first
18
+ new.tap do |v|
19
+ v.model_year = vin_description.attr('modelYear').to_i
20
+ v.division = vin_description.attr('division')
21
+ v.model = vin_description.attr('modelName')
22
+
23
+ v.styles = find_elements('style', response).map do |e|
24
+ Style.new(
25
+ id: e.attr('id').to_i,
26
+ name: e.attr('name'),
27
+ trim: e.attr('trim'),
28
+ name_without_trim: e.attr('nameWoTrim'),
29
+ body_types: e.xpath("x:bodyType", 'x' => response.body.namespace.href).map do |bt|
30
+ Style::BodyType.new(bt.attr('id').to_i, bt.text)
31
+ end
32
+ )
33
+ end
34
+
35
+ v.engines = find_elements('engine', response).map do |e|
36
+ Engine.new(e.at_xpath("x:engineType", 'x' => response.body.namespace.href).text)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,3 @@
1
+ module ChromeData
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,32 @@
1
+ require "chrome_data/version"
2
+ require "chrome_data/caching"
3
+ require "chrome_data/base_request"
4
+ require "chrome_data/collection_request"
5
+ require "chrome_data/division"
6
+ require "chrome_data/model"
7
+ require "chrome_data/style"
8
+ require "chrome_data/model_year"
9
+ require "chrome_data/vehicle"
10
+ require "active_support/cache"
11
+ require "active_support/core_ext/hash"
12
+
13
+ require "symboltable"
14
+
15
+ module ChromeData
16
+ extend Caching
17
+ extend self
18
+
19
+ def configure
20
+ yield config
21
+ end
22
+
23
+ # Valid options:
24
+ # account_number
25
+ # account_secret
26
+ # country (default: 'US')
27
+ # language (default: 'en')
28
+ # cache_store
29
+ def config
30
+ @@config ||= SymbolTable.new country: 'US', language: 'en'
31
+ end
32
+ end
@@ -0,0 +1,74 @@
1
+ require_relative '../minitest_helper'
2
+
3
+ describe ChromeData::Caching do
4
+ before do
5
+ ChromeData.remove_class_variable :@@config if ChromeData.class_variable_defined? :@@config
6
+ ChromeData::Caching.remove_class_variable :@@_cache_store if ChromeData::Caching.class_variable_defined? :@@_cache_store
7
+ end
8
+
9
+ describe '.cache' do
10
+ it 'caches request when caching is on' do
11
+ ChromeData.config.cache_store = :memory_store
12
+
13
+ foo = stub('foo')
14
+ foo.expects(:bar).once.returns 'bar'
15
+
16
+ 2.times do
17
+ ChromeData.cache 'foo' do
18
+ foo.bar
19
+ end.must_equal 'bar'
20
+ end
21
+ end
22
+
23
+ it 'does not cache request when caching is off' do
24
+ foo = stub('foo')
25
+ foo.expects(:bar).twice.returns 'bar'
26
+
27
+ 2.times do
28
+ ChromeData.cache 'foo' do
29
+ foo.bar
30
+ end.must_equal 'bar'
31
+ end
32
+ end
33
+ end
34
+
35
+ describe '._cache_store' do
36
+ before { ChromeData::Caching.remove_class_variable :@@_cache_store if ChromeData::Caching.class_variable_defined? :@@_cache_store }
37
+
38
+ it 'returns nil with no cache_store config' do
39
+ ChromeData._cache_store.must_equal nil
40
+ end
41
+
42
+ it 'looks up cache with appropriate namespace when cache_store is an array without options hash' do
43
+ ChromeData.config.cache_store = :file_store, '/path/to/cache'
44
+
45
+ ActiveSupport::Cache.expects(:lookup_store).with([:file_store, '/path/to/cache', { namespace: 'chromedata' }])
46
+
47
+ ChromeData._cache_store
48
+ end
49
+
50
+ it 'looks up cache with appropriate namespace when cache_store is an array with an options hash' do
51
+ ChromeData.config.cache_store = :memory_store, { foo: 'bar' }
52
+
53
+ ActiveSupport::Cache.expects(:lookup_store).with([:memory_store, { foo: 'bar', namespace: 'chromedata' }])
54
+
55
+ ChromeData._cache_store
56
+ end
57
+
58
+ it 'does not replace user-defined namespace' do
59
+ ChromeData.config.cache_store = :memory_store, { namespace: 'mynamespace' }
60
+
61
+ ActiveSupport::Cache.expects(:lookup_store).with([:memory_store, { namespace: 'mynamespace' }])
62
+
63
+ ChromeData._cache_store
64
+ end
65
+
66
+ it 'looks up cache with appropriate namespace when cache_store is a symbol' do
67
+ ChromeData.config.cache_store = :memory_store
68
+
69
+ ActiveSupport::Cache.expects(:lookup_store).with([:memory_store, { namespace: 'chromedata' }])
70
+
71
+ ChromeData._cache_store
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,57 @@
1
+ require_relative '../minitest_helper'
2
+
3
+ describe ChromeData::Division do
4
+ it 'returns a proper request name' do
5
+ ChromeData::Division.request_name.must_equal 'getDivisions'
6
+ end
7
+
8
+ describe '.find_all_by_year' do
9
+ before do
10
+ ChromeData.configure do |c|
11
+ c.account_number = '123456'
12
+ c.account_secret = '1111111111111111'
13
+ end
14
+ end
15
+
16
+ def find_divisions
17
+ VCR.use_cassette('wsdl') do
18
+ VCR.use_cassette('2013/divisions') do
19
+ @divisions = ChromeData::Division.find_all_by_year(2013)
20
+ end
21
+ end
22
+ end
23
+
24
+ it 'returns array of Division objects' do
25
+ find_divisions
26
+
27
+ @divisions.first.must_be_instance_of ChromeData::Division
28
+ @divisions.size.must_equal 38
29
+ end
30
+
31
+ it 'sets ID on Division objects' do
32
+ find_divisions
33
+
34
+ @divisions.first.id.must_equal 1
35
+ end
36
+
37
+ it 'sets name on Division objects' do
38
+ find_divisions
39
+
40
+ @divisions.first.name.must_equal 'Acura'
41
+ end
42
+
43
+ it 'caches with proper key' do
44
+ ChromeData.expects(:cache).with('get_divisions-model_year-2013')
45
+
46
+ find_divisions
47
+ end
48
+ end
49
+
50
+ describe '#models_for_year' do
51
+ it 'finds models for given year and Division ID' do
52
+ ChromeData::Model.expects(:find_all_by_year_and_division_id).with(2013, 13)
53
+
54
+ ChromeData::Division.new(id: 13, name: 'Ford').models_for_year 2013
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,57 @@
1
+ require_relative '../minitest_helper'
2
+
3
+ describe ChromeData::Model do
4
+ it 'returns a proper request name' do
5
+ ChromeData::Model.request_name.must_equal 'getModels'
6
+ end
7
+
8
+ describe '.find_all_by_year_and_division_id' do
9
+ before do
10
+ ChromeData.configure do |c|
11
+ c.account_number = '123456'
12
+ c.account_secret = '1111111111111111'
13
+ end
14
+ end
15
+
16
+ def find_models
17
+ VCR.use_cassette('wsdl') do
18
+ VCR.use_cassette('2013/divisions/13/models') do
19
+ @models = ChromeData::Model.find_all_by_year_and_division_id(2013, 13) # 2013 Fords
20
+ end
21
+ end
22
+ end
23
+
24
+ it 'returns array of Model objects' do
25
+ find_models
26
+
27
+ @models.first.must_be_instance_of ChromeData::Model
28
+ @models.size.must_equal 39
29
+ end
30
+
31
+ it 'sets ID on Model objects' do
32
+ find_models
33
+
34
+ @models.first.id.must_equal 25459
35
+ end
36
+
37
+ it 'sets name on Model objects' do
38
+ find_models
39
+
40
+ @models.first.name.must_equal 'C-Max Energi'
41
+ end
42
+
43
+ it 'caches with proper key' do
44
+ ChromeData.expects(:cache).with('get_models-model_year-2013-division_id-13')
45
+
46
+ find_models
47
+ end
48
+ end
49
+
50
+ describe '#styles' do
51
+ it 'finds styles for Model' do
52
+ ChromeData::Style.expects(:find_all_by_model_id).with(24997)
53
+
54
+ ChromeData::Model.new(id: 24997, name: 'Mustang').styles
55
+ end
56
+ end
57
+ end