bazil_client 1.0.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.
- data/.gitignore +9 -0
- data/ChangeLog.md +3 -0
- data/Gemfile +6 -0
- data/LICENSE +22 -0
- data/README +0 -0
- data/Rakefile +15 -0
- data/VERSION +1 -0
- data/bazil_client.gemspec +24 -0
- data/lib/bazil/client.rb +159 -0
- data/lib/bazil/error.rb +37 -0
- data/lib/bazil/model.rb +180 -0
- data/lib/bazil/rest.rb +78 -0
- data/lib/bazil.rb +1 -0
- data/spec/bazil/client_spec.rb +150 -0
- data/spec/bazil/model_spec.rb +166 -0
- data/spec/spec_helper.rb +11 -0
- metadata +115 -0
data/.gitignore
ADDED
data/ChangeLog.md
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2013 Preferred Infrastructure,Inc.
|
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
ADDED
File without changes
|
data/Rakefile
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'bundler'
|
2
|
+
Bundler::GemHelper.install_tasks
|
3
|
+
|
4
|
+
require 'rubygems'
|
5
|
+
require 'rake'
|
6
|
+
|
7
|
+
require 'rspec/core'
|
8
|
+
require 'rspec/core/rake_task'
|
9
|
+
|
10
|
+
RSpec::Core::RakeTask.new(:spec) do |spec|
|
11
|
+
spec.pattern = 'spec/**/*_spec.rb'
|
12
|
+
spec.rspec_opts = ['--color --backtrace']
|
13
|
+
end
|
14
|
+
|
15
|
+
task :default => [:spec]
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
$:.push File.expand_path('../lib', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.name = "bazil_client"
|
6
|
+
gem.description = "Ruby client of Bazil"
|
7
|
+
gem.homepage = "https://asp-bazil.preferred.jp/"
|
8
|
+
gem.summary = gem.description
|
9
|
+
gem.version = File.read("VERSION").strip
|
10
|
+
gem.authors = ["Nobuyuki Kubota"]
|
11
|
+
gem.email = "bazil-info@preferred.jp"
|
12
|
+
gem.has_rdoc = false
|
13
|
+
gem.platform = Gem::Platform::RUBY
|
14
|
+
gem.files = `git ls-files`.split("\n")
|
15
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
16
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
17
|
+
gem.require_paths = ['lib']
|
18
|
+
gem.license = "MIT"
|
19
|
+
|
20
|
+
gem.required_ruby_version = '>= 1.9.0'
|
21
|
+
gem.add_development_dependency "rake", ">= 0.9.2"
|
22
|
+
gem.add_development_dependency "simplecov", ">= 0.5.4"
|
23
|
+
gem.add_development_dependency "rspec", ">= 1.0.0"
|
24
|
+
end
|
data/lib/bazil/client.rb
ADDED
@@ -0,0 +1,159 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'json'
|
5
|
+
require 'net/http'
|
6
|
+
require 'net/https'
|
7
|
+
require 'bazil/model'
|
8
|
+
require 'bazil/rest'
|
9
|
+
require 'bazil/error'
|
10
|
+
|
11
|
+
module Bazil
|
12
|
+
class Client
|
13
|
+
extend Forwardable
|
14
|
+
|
15
|
+
class Options
|
16
|
+
attr_reader :host, :port, :scheme, :ca_file, :ssl_version, :verify_mode
|
17
|
+
|
18
|
+
def initialize(options)
|
19
|
+
if options.kind_of? String
|
20
|
+
options = {CA_FILE_KEY => options}
|
21
|
+
end
|
22
|
+
options = symbolize_keys(options)
|
23
|
+
|
24
|
+
url = URI::parse(options[URL_KEY] || DEFAULT_URL)
|
25
|
+
@host = url.host or raise "Failed to obtain host name from given url: url = #{url.to_s}"
|
26
|
+
@port = url.port or raise "Failed to obtain port number from given url: url = #{url.to_s}"
|
27
|
+
@scheme = url.scheme or raise "Failed to obtain scheme from given url: url = #{url.to_s}"
|
28
|
+
raise "Unsupported scheme '#{@scheme}'" unless AVAILABLE_SCHEMA.include? @scheme
|
29
|
+
|
30
|
+
@ca_file = options[CA_FILE_KEY] || DEFAULT_CA_FILE
|
31
|
+
if @ca_file
|
32
|
+
raise "ca_file option must be string value" unless @ca_file.is_a? String
|
33
|
+
raise "ca_file option must be absolute path" unless @ca_file[0] == '/'
|
34
|
+
raise "ca_file '#{@ca_file}' doesn't exist" unless File::exists? @ca_file
|
35
|
+
end
|
36
|
+
|
37
|
+
ssl_version = options[SSL_VERSION_KEY] || DEFAULT_SSL_VERSION
|
38
|
+
raise "Unsupported SSL version '#{ssl_version}'" unless AVAILABLE_SSL_VERSIONS.has_key? ssl_version
|
39
|
+
@ssl_version = AVAILABLE_SSL_VERSIONS[ssl_version]
|
40
|
+
|
41
|
+
skip_verify = options[SKIP_VERIFY_KEY] || DEFAULT_SKIP_VERIFY
|
42
|
+
raise "skip_verify option must be boolean value" unless skip_verify.is_a?(TrueClass) || skip_verify.is_a?(FalseClass)
|
43
|
+
@verify_mode = skip_verify ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def symbolize_keys(hash)
|
49
|
+
{}.tap{|new_hash|
|
50
|
+
hash.each{|k,v|
|
51
|
+
new_hash[k.to_s.to_sym] = v
|
52
|
+
}
|
53
|
+
}
|
54
|
+
end
|
55
|
+
|
56
|
+
URL_KEY = :url
|
57
|
+
DEFAULT_URL = 'https://asp-bazil.preferred.jp/'
|
58
|
+
AVAILABLE_SCHEMA = ['http', 'https']
|
59
|
+
|
60
|
+
CA_FILE_KEY = :ca_file
|
61
|
+
DEFAULT_CA_FILE = nil
|
62
|
+
|
63
|
+
SSL_VERSION_KEY = :ssl_version
|
64
|
+
AVAILABLE_SSL_VERSIONS = {SSLv3: 'SSLv3', TLSv1: 'TLSv1'}
|
65
|
+
DEFAULT_SSL_VERSION = :TLSv1
|
66
|
+
|
67
|
+
SKIP_VERIFY_KEY = :skip_verify
|
68
|
+
DEFAULT_SKIP_VERIFY = false
|
69
|
+
end
|
70
|
+
|
71
|
+
def set_ssl_options(http, options)
|
72
|
+
http.use_ssl = options.scheme == 'https'
|
73
|
+
http.ca_file = options.ca_file
|
74
|
+
http.ssl_version = options.ssl_version
|
75
|
+
http.verify_mode = options.verify_mode
|
76
|
+
end
|
77
|
+
|
78
|
+
def initialize(options={})
|
79
|
+
opt = Options.new(options)
|
80
|
+
http = Net::HTTP.new(opt.host, opt.port)
|
81
|
+
set_ssl_options(http,opt)
|
82
|
+
@http_cli = REST.new(http)
|
83
|
+
end
|
84
|
+
|
85
|
+
def_delegators :@http_cli, :read_timeout, :read_timeout=, :set_api_keys
|
86
|
+
|
87
|
+
def models(options = {})
|
88
|
+
queries = {}
|
89
|
+
queries[:tag_id] = options[:tag_id].to_i if options.has_key? :tag_id
|
90
|
+
queries[:page] = options[:page].to_i if options.has_key? :page
|
91
|
+
queries[:per_page] = options[:per_page].to_i if options.has_key? :per_page
|
92
|
+
|
93
|
+
res, body = @http_cli.get(gen_uri("models",queries))
|
94
|
+
raise_error("Failed to get models", res) unless res.code =~ /2[0-9][0-9]/
|
95
|
+
JSON.parse(res.body)["models"].map{|model|
|
96
|
+
model["config_ids"].map{|config_id|
|
97
|
+
Model.new(self, model["id"].to_i, config_id.to_i)
|
98
|
+
}
|
99
|
+
}.flatten
|
100
|
+
end
|
101
|
+
|
102
|
+
def create_model(config)
|
103
|
+
data = config.to_json
|
104
|
+
res, body = @http_cli.post(gen_uri('models'), data, {'Content-Type' => 'application/json; charset=UTF-8', 'Content-Length' => data.length.to_s})
|
105
|
+
raise_error("Failed to create model", res) unless res.code =~ /2[0-9][0-9]/ # TODO: return detailed error information
|
106
|
+
js = JSON.parse(res.body)
|
107
|
+
Model.new(self, js['model_id'].to_i, js['config_id'].to_i)
|
108
|
+
end
|
109
|
+
|
110
|
+
def delete_model(model_id)
|
111
|
+
res, body = @http_cli.delete(gen_uri("models/#{model_id}"))
|
112
|
+
raise_error("Failed to delete model", res) unless res.code =~ /2[0-9][0-9]/ # TODO: return detailed error information
|
113
|
+
JSON.parse(res.body)
|
114
|
+
end
|
115
|
+
|
116
|
+
def create_config(model_id, config)
|
117
|
+
data = config.to_json
|
118
|
+
res, body = @http_cli.post(gen_uri("models/#{model_id}/configs"), data, {'Content-Type' => 'application/json; charset=UTF-8', 'Content-Length' => data.length.to_s})
|
119
|
+
raise_error("Failed to create new configuration", res) unless res.code =~ /2[0-9][0-9]/ # TODO: return detailed error information
|
120
|
+
js = JSON.parse(res.body)
|
121
|
+
Model.new(self, model_id, js['config_id'].to_i)
|
122
|
+
end
|
123
|
+
|
124
|
+
def delete_config(model_id, config_id)
|
125
|
+
res, body = @http_cli.delete(gen_uri("models/#{model_id}/configs/#{config_id}"))
|
126
|
+
raise_error("Failed to delete configuration", res) unless res.code =~ /2[0-9][0-9]/ # TODO: return detailed error information
|
127
|
+
JSON.parse(res.body)
|
128
|
+
end
|
129
|
+
|
130
|
+
def model(model_id, config_id)
|
131
|
+
model = Model.new(self, model_id, config_id)
|
132
|
+
model.status
|
133
|
+
model
|
134
|
+
end
|
135
|
+
|
136
|
+
def http_client
|
137
|
+
@http_cli
|
138
|
+
end
|
139
|
+
|
140
|
+
# TODO: make this changable
|
141
|
+
def api_version
|
142
|
+
'v2'
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
def gen_uri(path, queries = {})
|
148
|
+
if queries.empty?
|
149
|
+
"/#{api_version}/#{path}"
|
150
|
+
else
|
151
|
+
"/#{api_version}/#{path}?#{queries.map{|k,v| "#{k}=#{v}"}.join('&')}"
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def raise_error(message, res)
|
156
|
+
raise APIError.new(message, res.code, JSON.parse(res.body))
|
157
|
+
end
|
158
|
+
end # class Client
|
159
|
+
end # module Bazil
|
data/lib/bazil/error.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
module Bazil
|
2
|
+
class BazilError < RuntimeError
|
3
|
+
def inspect
|
4
|
+
to_s
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class ConnectionError < BazilError
|
9
|
+
attr_reader :method, :address, :port
|
10
|
+
|
11
|
+
def initialize(method, address, port)
|
12
|
+
@method = method
|
13
|
+
@address = address
|
14
|
+
@port = port
|
15
|
+
end
|
16
|
+
|
17
|
+
def to_s
|
18
|
+
"Failed to connect to the server at #{@method} method: server = #{@address}:#{@port}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class APIError < BazilError
|
23
|
+
attr_reader :errors
|
24
|
+
|
25
|
+
def initialize(message, code, response)
|
26
|
+
@message = message
|
27
|
+
@code = code
|
28
|
+
@errors = response['errors']
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_s
|
32
|
+
result = [@message]
|
33
|
+
result += @errors.map { |error| "\t#{error['file']}(#{error['line']}): #{error['ecode']}: #{error['message']}" }
|
34
|
+
result.join("\n")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/lib/bazil/model.rb
ADDED
@@ -0,0 +1,180 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'json'
|
3
|
+
require 'bazil/error'
|
4
|
+
|
5
|
+
module Bazil
|
6
|
+
class Model
|
7
|
+
attr_reader :model_id, :config_id
|
8
|
+
|
9
|
+
def initialize(client, model_id, config_id)
|
10
|
+
@client = client
|
11
|
+
@http_cli = client.http_client
|
12
|
+
@model_id = model_id
|
13
|
+
@config_id = config_id
|
14
|
+
end
|
15
|
+
|
16
|
+
def status
|
17
|
+
res = @http_cli.get(gen_uri(target_path(@config_id, "status")))
|
18
|
+
raise_error("Failed to get status of the model: #{error_suffix}", res) unless res.code =~ /2[0-9][0-9]/
|
19
|
+
JSON.parse(res.body)
|
20
|
+
end
|
21
|
+
|
22
|
+
def model_config
|
23
|
+
res = @http_cli.get(gen_uri())
|
24
|
+
raise_error("Failed to get model config: #{error_suffix}", res) unless res.code =~ /2[0-9][0-9]/
|
25
|
+
JSON.parse(res.body)
|
26
|
+
end
|
27
|
+
|
28
|
+
def update_model_config(conf)
|
29
|
+
res = @http_cli.put(gen_uri(), conf.to_json, {'Content-Type' => 'application/json; charset=UTF-8', 'Content-Length' => conf.to_json.length.to_s})
|
30
|
+
JSON.parse(res.body)
|
31
|
+
end
|
32
|
+
|
33
|
+
def config
|
34
|
+
res = @http_cli.get(gen_uri("configs/#{@config_id}"))
|
35
|
+
raise_error("Failed to get config of the model: #{error_suffix}", res) unless res.code =~ /2[0-9][0-9]/
|
36
|
+
JSON.parse(res.body)
|
37
|
+
end
|
38
|
+
|
39
|
+
def update_config(config)
|
40
|
+
res = send(:put, "configs/#{@config_id}", config.to_json, "Failed to updated config")
|
41
|
+
{}
|
42
|
+
end
|
43
|
+
|
44
|
+
def train(train_data)
|
45
|
+
train_data = symbolize_keys(train_data)
|
46
|
+
raise ArgumentError, 'Annotation must be not nil' unless train_data.has_key? :annotation
|
47
|
+
raise ArgumentError, 'Data must be not nil' unless train_data.has_key? :data
|
48
|
+
|
49
|
+
body = post("training_data", train_data.to_json, "Failed to post training data")
|
50
|
+
JSON.parse(body)
|
51
|
+
end
|
52
|
+
|
53
|
+
def retrain(option = {})
|
54
|
+
body = post(target_path(@config_id, 'retrain'), option.to_json, "Failed to retrain the model")
|
55
|
+
JSON.parse(body)
|
56
|
+
end
|
57
|
+
|
58
|
+
def trace(method, data, config = nil)
|
59
|
+
new_data = {}
|
60
|
+
new_data['method'] = method if method
|
61
|
+
new_data['data'] = data if data
|
62
|
+
new_data['config'] = config if config
|
63
|
+
body = post(target_path(@config_id, "trace"), new_data.to_json, "Failed to execute trace")
|
64
|
+
JSON.parse(body)
|
65
|
+
end
|
66
|
+
|
67
|
+
def evaluate(method, config = nil)
|
68
|
+
new_data = {}
|
69
|
+
new_data['method'] = method if method
|
70
|
+
new_data['config'] = config if config
|
71
|
+
body = post(target_path(@config_id, "evaluate"), new_data.to_json, "Failed to execute evaluate")
|
72
|
+
JSON.parse(body)
|
73
|
+
end
|
74
|
+
|
75
|
+
def labels
|
76
|
+
res = @http_cli.get(gen_uri(target_path(@config_id, "labels")))
|
77
|
+
raise_error("Failed to get labels the model has: #{error_suffix}", res) unless res.code =~ /2[0-9][0-9]/
|
78
|
+
JSON.parse(res.body)['labels']
|
79
|
+
end
|
80
|
+
|
81
|
+
def training_data(id)
|
82
|
+
raise ArgumentError, 'Id must be Integer' unless id.kind_of? Integer
|
83
|
+
res = @http_cli.get(gen_uri("training_data/#{id}"))
|
84
|
+
raise_error("Failed to get training data of the model: id = #{id}, #{error_suffix}", res) unless res.code =~ /2[0-9][0-9]/
|
85
|
+
JSON.parse(res.body)
|
86
|
+
end
|
87
|
+
|
88
|
+
def list_training_data(condition = {})
|
89
|
+
condition = condition.dup
|
90
|
+
condition[:page] ||= 1
|
91
|
+
condition[:per_page] ||= 10
|
92
|
+
|
93
|
+
res = @http_cli.get(gen_uri("training_data?page=#{condition[:page]}&per_page=#{condition[:per_page]}"))
|
94
|
+
raise_error("Failed to query training data of the model", res) unless res.code =~ /2[0-9][0-9]/
|
95
|
+
JSON.parse(res.body)
|
96
|
+
end
|
97
|
+
|
98
|
+
def delete_all_training_data
|
99
|
+
res = @http_cli.delete(gen_uri("training_data"))
|
100
|
+
raise_error("Failed to delete all training_data of the model: #{error_suffix}", res) unless res.code =~ /2[0-9][0-9]/
|
101
|
+
{}
|
102
|
+
end
|
103
|
+
|
104
|
+
def put_training_data(new_data = {})
|
105
|
+
new_data = symbolize_keys(new_data)
|
106
|
+
raise ArgumentError, 'Data must be not nil' unless new_data.has_key? :data
|
107
|
+
body = post('training_data', new_data.to_json, "Failed to post training data")
|
108
|
+
JSON.parse(body)
|
109
|
+
end
|
110
|
+
|
111
|
+
def update_training_data(id, new_data = {})
|
112
|
+
new_data = symbolize_keys(new_data)
|
113
|
+
raise ArgumentError, 'Id must be Integer' unless id.kind_of? Integer
|
114
|
+
raise ArgumentError, 'Data must be not nil' unless new_data.has_key? :data
|
115
|
+
send(:put, "training_data/#{id}", new_data.to_json, "Failed to update training data")
|
116
|
+
{}
|
117
|
+
end
|
118
|
+
|
119
|
+
def delete_training_data(id)
|
120
|
+
raise ArgumentError, 'Id must be Integer' unless id.kind_of? Integer
|
121
|
+
res = @http_cli.delete(gen_uri("training_data/#{id}"))
|
122
|
+
raise_error("Failed to delete a training data: id = #{id}, #{error_suffix}", res) unless res.code =~ /2[0-9][0-9]/
|
123
|
+
{}
|
124
|
+
end
|
125
|
+
|
126
|
+
def query(data)
|
127
|
+
data = {'data' => data}.to_json
|
128
|
+
res = post(target_path(@config_id, 'query'), data, "Failed to post data for query")
|
129
|
+
JSON.parse(res)
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
def symbolize_keys(data)
|
135
|
+
{}.tap{|d|
|
136
|
+
data.each{|k,v|
|
137
|
+
d[k.to_sym] = v
|
138
|
+
}
|
139
|
+
}
|
140
|
+
end
|
141
|
+
|
142
|
+
def stringify_keys(data)
|
143
|
+
{}.tap{|d|
|
144
|
+
data.each{|k,v|
|
145
|
+
d[k.to_s] = v
|
146
|
+
}
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
def post(path, data, error_message)
|
151
|
+
send(:post, path, data, error_message)
|
152
|
+
end
|
153
|
+
|
154
|
+
def send(method, path, data, error_message)
|
155
|
+
res = @http_cli.method(method).call(gen_uri(path), data, {'Content-Type' => 'application/json; charset=UTF-8', 'Content-Length' => data.length.to_s})
|
156
|
+
raise_error("#{error_message}: #{error_suffix}", res) unless res.code =~ /2[0-9][0-9]/ # TODO: enhance error information
|
157
|
+
res.body
|
158
|
+
end
|
159
|
+
|
160
|
+
def target_path(id, path)
|
161
|
+
"configs/#{id}/#{path}"
|
162
|
+
end
|
163
|
+
|
164
|
+
def gen_uri(path = nil)
|
165
|
+
if path
|
166
|
+
"/#{@client.api_version}/models/#{@model_id}/#{path}"
|
167
|
+
else
|
168
|
+
"/#{@client.api_version}/models/#{@model_id}"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def error_suffix
|
173
|
+
"model = #{@model_id}"
|
174
|
+
end
|
175
|
+
|
176
|
+
def raise_error(message, res)
|
177
|
+
raise APIError.new(message, res.code, JSON.parse(res.body))
|
178
|
+
end
|
179
|
+
end # module Model
|
180
|
+
end # module Bazil
|
data/lib/bazil/rest.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'json'
|
5
|
+
require 'time'
|
6
|
+
require 'net/http'
|
7
|
+
require 'digest/md5'
|
8
|
+
|
9
|
+
module Bazil
|
10
|
+
class REST
|
11
|
+
extend Forwardable
|
12
|
+
|
13
|
+
def initialize(http)
|
14
|
+
@http = http
|
15
|
+
end
|
16
|
+
|
17
|
+
def_delegators :@http, :read_timeout, :read_timeout=
|
18
|
+
|
19
|
+
def set_api_keys(key, secret)
|
20
|
+
@api_key = key
|
21
|
+
@secret_key = secret
|
22
|
+
true
|
23
|
+
end
|
24
|
+
|
25
|
+
def get(uri)
|
26
|
+
uri, header = add_api_signature(uri, nil)
|
27
|
+
@http.get(uri, header)
|
28
|
+
rescue Errno::ECONNREFUSED => e
|
29
|
+
raise_error('GET')
|
30
|
+
end
|
31
|
+
|
32
|
+
def put(uri, data, header = {})
|
33
|
+
uri, header = add_api_signature(uri, data, header)
|
34
|
+
@http.put(uri, data, header)
|
35
|
+
rescue Errno::ECONNREFUSED => e
|
36
|
+
raise_error('PUT')
|
37
|
+
end
|
38
|
+
|
39
|
+
def post(uri, data, header = {})
|
40
|
+
uri, header = add_api_signature(uri, data, header)
|
41
|
+
@http.post(uri, data, header)
|
42
|
+
rescue Errno::ECONNREFUSED => e
|
43
|
+
raise_error('POST')
|
44
|
+
end
|
45
|
+
|
46
|
+
def delete(uri)
|
47
|
+
uri, header = add_api_signature(uri, nil)
|
48
|
+
@http.delete(uri, header)
|
49
|
+
rescue Errno::ECONNREFUSED => e
|
50
|
+
raise_error('DELETE')
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
def add_api_signature(uri, data, header = {})
|
55
|
+
return uri, header unless @api_key and @secret_key
|
56
|
+
|
57
|
+
base,param = uri.split('?',2)
|
58
|
+
current_time = Time.now.httpdate
|
59
|
+
|
60
|
+
signature = ''
|
61
|
+
signature = data.gsub(/\s/, '') if data
|
62
|
+
parameters = (param || "").split('&')
|
63
|
+
parameters << "api_key=#{@api_key}"
|
64
|
+
signature << parameters.sort.join()
|
65
|
+
signature << current_time
|
66
|
+
signature << @secret_key
|
67
|
+
parameters << "api_signature=#{Digest::MD5.hexdigest(signature)}"
|
68
|
+
base << '?'
|
69
|
+
base << parameters.join('&')
|
70
|
+
|
71
|
+
return base, header.merge({'Date' => current_time})
|
72
|
+
end
|
73
|
+
|
74
|
+
def raise_error(method)
|
75
|
+
raise ConnectionError.new(method, @http.address, @http.port)
|
76
|
+
end
|
77
|
+
end # class Rest
|
78
|
+
end # module Bazil
|
data/lib/bazil.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'bazil/client'
|
@@ -0,0 +1,150 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Bazil::Client do
|
4
|
+
describe Bazil::Client::Options, "with empty option provide default" do
|
5
|
+
let(:option) { Bazil::Client::Options.new({}) }
|
6
|
+
|
7
|
+
it "host name" do
|
8
|
+
expect(option.host).to eq('asp-bazil.preferred.jp')
|
9
|
+
end
|
10
|
+
|
11
|
+
it "port name" do
|
12
|
+
expect(option.port).to eq(443)
|
13
|
+
end
|
14
|
+
|
15
|
+
it "scheme" do
|
16
|
+
expect(option.scheme).to eq('https')
|
17
|
+
end
|
18
|
+
|
19
|
+
it "ca_file" do
|
20
|
+
expect(option.ca_file).to eq(nil)
|
21
|
+
end
|
22
|
+
|
23
|
+
it "ssl_version" do
|
24
|
+
expect(option.ssl_version).to eq('TLSv1')
|
25
|
+
end
|
26
|
+
|
27
|
+
it "verify_mode" do
|
28
|
+
expect(option.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe Bazil::Client::Options, "with all options can parse" do
|
33
|
+
let(:option) {
|
34
|
+
Bazil::Client::Options.new({
|
35
|
+
url: 'http://localhost:8080/',
|
36
|
+
ca_file: __FILE__, # always exists and absolute path
|
37
|
+
ssl_version: :SSLv3,
|
38
|
+
skip_verify: true
|
39
|
+
})
|
40
|
+
}
|
41
|
+
|
42
|
+
it "host name" do
|
43
|
+
expect(option.host).to eq('localhost')
|
44
|
+
end
|
45
|
+
|
46
|
+
it "port name" do
|
47
|
+
expect(option.port).to eq(8080)
|
48
|
+
end
|
49
|
+
|
50
|
+
it "scheme" do
|
51
|
+
expect(option.scheme).to eq('http')
|
52
|
+
end
|
53
|
+
|
54
|
+
it "ca_file" do
|
55
|
+
expect(option.ca_file).to eq(__FILE__)
|
56
|
+
end
|
57
|
+
|
58
|
+
it "ssl_version" do
|
59
|
+
expect(option.ssl_version).to eq('SSLv3')
|
60
|
+
end
|
61
|
+
|
62
|
+
it "skip_verify" do
|
63
|
+
expect(option.verify_mode).to eq(OpenSSL::SSL::VERIFY_NONE)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe Bazil::Client::Options, "'s url" do
|
68
|
+
it "can derivate HTTP port number from http schema" do
|
69
|
+
option = Bazil::Client::Options.new(url: 'http://localhost/')
|
70
|
+
expect(option.port).to eq(80)
|
71
|
+
end
|
72
|
+
|
73
|
+
it "can derivate HTTPS port number from https schem" do
|
74
|
+
option = Bazil::Client::Options.new(url: 'https://localhost/')
|
75
|
+
expect(option.port).to eq(443)
|
76
|
+
end
|
77
|
+
|
78
|
+
it "can overwrite port number for http schema" do
|
79
|
+
option = Bazil::Client::Options.new(url: 'http://localhost:443/')
|
80
|
+
expect(option.port).to eq(443)
|
81
|
+
end
|
82
|
+
|
83
|
+
it "can overwrite port number for https schem" do
|
84
|
+
option = Bazil::Client::Options.new(url: 'https://localhost:80/')
|
85
|
+
expect(option.port).to eq(80)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe Bazil::Client::Options, "will raise error for" do
|
90
|
+
it "invalid url" do
|
91
|
+
proc {
|
92
|
+
Bazil::Client::Options.new(url: 42)
|
93
|
+
}.should raise_error
|
94
|
+
end
|
95
|
+
|
96
|
+
it "empty url" do
|
97
|
+
proc {
|
98
|
+
Bazil::Client::Options.new(url: '')
|
99
|
+
}.should raise_error
|
100
|
+
end
|
101
|
+
|
102
|
+
it "invalid port" do
|
103
|
+
proc {
|
104
|
+
Bazil::Client::Options.new(url: 'http://localhost:ssl_port_please/')
|
105
|
+
}.should raise_error
|
106
|
+
end
|
107
|
+
|
108
|
+
it "unsupported scheme" do
|
109
|
+
proc {
|
110
|
+
Bazil::Client::Options.new(url: 'saitama://localhost:80/')
|
111
|
+
}.should raise_error
|
112
|
+
end
|
113
|
+
|
114
|
+
it "non string ca_file" do
|
115
|
+
proc {
|
116
|
+
Bazil::Client::Options.new(ca_file: 42)
|
117
|
+
}.should raise_error
|
118
|
+
end
|
119
|
+
|
120
|
+
it "relative ca_file" do
|
121
|
+
proc {
|
122
|
+
Bazil::Client::Options.new(ca_file: './' + File::basename(__FILE__))
|
123
|
+
}.should raise_error
|
124
|
+
end
|
125
|
+
|
126
|
+
it "non exists ca_file" do
|
127
|
+
proc {
|
128
|
+
Bazil::Client::Options.new(ca_file: '/:never:/:exist:/:file:/:path:')
|
129
|
+
}.should raise_error
|
130
|
+
end
|
131
|
+
|
132
|
+
it "invalid ssl_version" do
|
133
|
+
proc {
|
134
|
+
Bazil::Client::Options.new(ssl_version: 3.14)
|
135
|
+
}.should raise_error
|
136
|
+
end
|
137
|
+
|
138
|
+
it "unsupported ssl_version" do
|
139
|
+
proc {
|
140
|
+
Bazil::Client::Options.new(ssl_version: :SSL_version_saitama)
|
141
|
+
}.should raise_error
|
142
|
+
end
|
143
|
+
|
144
|
+
it "non-boolean skip_verify" do
|
145
|
+
proc {
|
146
|
+
Bazil::Client::Options.new(skip_verify: "YES")
|
147
|
+
}.should raise_error
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
class FakeResponse
|
4
|
+
attr_reader :code, :message, :body
|
5
|
+
def initialize(code,message,body)
|
6
|
+
@code = code
|
7
|
+
@message = message
|
8
|
+
@body = body
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class FakeClient
|
13
|
+
attr_accessor :http_client
|
14
|
+
def initialize
|
15
|
+
@http_client = Object.new
|
16
|
+
end
|
17
|
+
def api_version
|
18
|
+
'v2'
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
describe Bazil::Client do
|
23
|
+
let(:client) { FakeClient.new }
|
24
|
+
let(:model_id) { 42 }
|
25
|
+
let(:config_id) { 184 }
|
26
|
+
|
27
|
+
def gen_model_path(path = "")
|
28
|
+
"/#{client.api_version}/models/#{model_id}#{path}"
|
29
|
+
end
|
30
|
+
|
31
|
+
def gen_model_config_path(path = "")
|
32
|
+
"/#{client.api_version}/models/#{model_id}/configs/#{config_id}#{path}"
|
33
|
+
end
|
34
|
+
|
35
|
+
def header_for_json(js)
|
36
|
+
{
|
37
|
+
"Content-Type" => "application/json; charset=UTF-8",
|
38
|
+
"Content-Length" => js.to_json.length.to_s
|
39
|
+
}
|
40
|
+
end
|
41
|
+
|
42
|
+
let(:model) {
|
43
|
+
Bazil::Model.new(client, model_id, config_id)
|
44
|
+
}
|
45
|
+
|
46
|
+
describe "model" do
|
47
|
+
it "model_config send GET /models/:model_id" do
|
48
|
+
result = {}
|
49
|
+
client.http_client.should_receive(:get).with(gen_model_path("")).and_return(FakeResponse.new("200", "OK", result.to_json))
|
50
|
+
res = model.model_config
|
51
|
+
expect(res).to eq(result)
|
52
|
+
end
|
53
|
+
|
54
|
+
it "update_model_config send PUT /models/:model_id" do
|
55
|
+
arg = {id: model_id, name: 'test'}
|
56
|
+
result = {}
|
57
|
+
client.http_client.should_receive(:put).with(gen_model_path(""), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
58
|
+
res = model.update_model_config(arg)
|
59
|
+
expect(res).to eq(result)
|
60
|
+
end
|
61
|
+
|
62
|
+
it "config send GET /models/:model_id/configs/:config_id" do
|
63
|
+
result = {}
|
64
|
+
client.http_client.should_receive(:get).with(gen_model_config_path("")).and_return(FakeResponse.new("200", "OK", result.to_json))
|
65
|
+
res = model.config
|
66
|
+
expect(res).to eq(result)
|
67
|
+
end
|
68
|
+
|
69
|
+
it "update_config send PUT /models/:model_id/configs/:config_id" do
|
70
|
+
arg = {methd: 'arow', id: config_id, model_id: model_id}
|
71
|
+
result = {}
|
72
|
+
client.http_client.should_receive(:put).with(gen_model_config_path(""), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
73
|
+
res = model.update_config(arg)
|
74
|
+
expect(res).to eq(result)
|
75
|
+
end
|
76
|
+
|
77
|
+
it "status request GET /models/:model_id/configs/:config_id/status" do
|
78
|
+
result = { "num_features" => 0, "num_train_queries" => 0, "num_labels" => 0, "num_queries" => 0, }
|
79
|
+
client.http_client.should_receive(:get).with(gen_model_config_path("/status")).and_return(FakeResponse.new("200", "OK", result.to_json))
|
80
|
+
expect(model.status).to eq(result)
|
81
|
+
end
|
82
|
+
|
83
|
+
it "retrain with empty option request POST /models/:model_id/configs/:config_id/retrain" do
|
84
|
+
arg = {}
|
85
|
+
result = { "total" => 1000, "elapsed_time" => 0.5 }
|
86
|
+
client.http_client.should_receive(:post).with(gen_model_config_path("/retrain"), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
87
|
+
expect(model.retrain).to eq(result)
|
88
|
+
end
|
89
|
+
|
90
|
+
it "retrain with option request POST /models/:model_id/configs/:config_id/retrain" do
|
91
|
+
arg = {times: 10}
|
92
|
+
result = { "total" => 1000, "elapsed_time" => 0.5 }
|
93
|
+
client.http_client.should_receive(:post).with(gen_model_config_path("/retrain"), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
94
|
+
expect(model.retrain(arg)).to eq(result)
|
95
|
+
end
|
96
|
+
|
97
|
+
it "query request POST /models/:model_id/configs/:config_id/query" do
|
98
|
+
arg = {data: {key: "value"}}
|
99
|
+
result = { "score" => { "Label1" => 0.5, "Label2" => -0.5, }, "classifier_result" => "Label1", }
|
100
|
+
client.http_client.should_receive(:post).with(gen_model_config_path("/query"), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
101
|
+
expect(model.query(arg[:data])).to eq(result)
|
102
|
+
end
|
103
|
+
|
104
|
+
it "trace request POST /models/:model_id/configs/:config_id/trace" do
|
105
|
+
arg = {method: "feature_weights", data: {key: "value"}}
|
106
|
+
result = { "result" => { "Label1" => 0.5, "Label2" => -0.5, "feature_weights" => { } }, "data" => { "key" => "value" } }
|
107
|
+
client.http_client.should_receive(:post).with(gen_model_config_path("/trace"), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
108
|
+
expect(model.trace(arg[:method], arg[:data])).to eq(result)
|
109
|
+
end
|
110
|
+
|
111
|
+
it "evaluate request POST /models/:model_id/configs/:config_id/evaluate" do
|
112
|
+
arg = {method: "cross_validation", config: {num_folds: 2}}
|
113
|
+
result = { "folds" => [{},{}], "result" => {} }
|
114
|
+
client.http_client.should_receive(:post).with(gen_model_config_path("/evaluate"), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
115
|
+
expect(model.evaluate(arg[:method], arg[:config])).to eq(result)
|
116
|
+
end
|
117
|
+
|
118
|
+
it "put_training_data request POST /models/:model_id/training_data" do
|
119
|
+
arg = {data: {name: "P"}, annotation: "saitama"}
|
120
|
+
result = {"training_data_id" => 42}
|
121
|
+
client.http_client.should_receive(:post).with(gen_model_path("/training_data"), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
122
|
+
expect(model.put_training_data(arg)).to eq(result)
|
123
|
+
end
|
124
|
+
|
125
|
+
it "list_training_data request GET /models/:model_id/training_data and set default page information" do
|
126
|
+
result = { "num_training_data" => 1000, "page" => 1, "per_page" => 10, "training_data" => [] }
|
127
|
+
client.http_client.should_receive(:get).with(gen_model_path("/training_data?page=1&per_page=10")).and_return(FakeResponse.new("200", "OK", result.to_json))
|
128
|
+
expect(model.list_training_data).to eq(result)
|
129
|
+
end
|
130
|
+
|
131
|
+
it "list_training_data with page information request GET /models/:model_id/training_data" do
|
132
|
+
arg = {page: 100, per_page: 450}
|
133
|
+
result = { "num_training_data" => 1000, "page" => 1, "per_page" => 10, "training_data" => [] }
|
134
|
+
client.http_client.should_receive(:get).with(gen_model_path("/training_data?page=#{arg[:page]}&per_page=#{arg[:per_page]}")).and_return(FakeResponse.new("200", "OK", result.to_json))
|
135
|
+
expect(model.list_training_data(arg)).to eq(result)
|
136
|
+
end
|
137
|
+
|
138
|
+
it "delete_all_training_data request DELETE /models/:model_id/training_data" do
|
139
|
+
result = {}
|
140
|
+
client.http_client.should_receive(:delete).with(gen_model_path("/training_data")).and_return(FakeResponse.new("200", "OK", result.to_json))
|
141
|
+
expect(model.delete_all_training_data).to eq(result)
|
142
|
+
end
|
143
|
+
|
144
|
+
it "training_data request GET /models/:model_id/training_data/:training_data_id" do
|
145
|
+
training_data_id = 123
|
146
|
+
result = {"data" => {"name" => "P"}, "annotation" => "saitama"}
|
147
|
+
client.http_client.should_receive(:get).with(gen_model_path("/training_data/#{training_data_id}")).and_return(FakeResponse.new("200", "OK", result.to_json))
|
148
|
+
expect(model.training_data(training_data_id)).to eq(result)
|
149
|
+
end
|
150
|
+
|
151
|
+
it "update_training_data request PUT /models/:model_id/training_data/:training_data_id" do
|
152
|
+
training_data_id = 123
|
153
|
+
arg = {data: {name: "P"}, annotation: "gunnma"}
|
154
|
+
result = {}
|
155
|
+
client.http_client.should_receive(:put).with(gen_model_path("/training_data/#{training_data_id}"), arg.to_json, header_for_json(arg)).and_return(FakeResponse.new("200", "OK", result.to_json))
|
156
|
+
expect(model.update_training_data(training_data_id, arg)).to eq(result)
|
157
|
+
end
|
158
|
+
|
159
|
+
it "delete_all_training_data request DELETE /models/:model_id/training_data/:training_data_id" do
|
160
|
+
training_data_id = 123
|
161
|
+
result = {}
|
162
|
+
client.http_client.should_receive(:delete).with(gen_model_path("/training_data/#{training_data_id}")).and_return(FakeResponse.new("200", "OK", result.to_json))
|
163
|
+
expect(model.delete_training_data(training_data_id)).to eq(result)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: bazil_client
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Nobuyuki Kubota
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-02-14 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rake
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 0.9.2
|
22
|
+
type: :development
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 0.9.2
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: simplecov
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: 0.5.4
|
38
|
+
type: :development
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 0.5.4
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: rspec
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 1.0.0
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 1.0.0
|
62
|
+
description: Ruby client of Bazil
|
63
|
+
email: bazil-info@preferred.jp
|
64
|
+
executables: []
|
65
|
+
extensions: []
|
66
|
+
extra_rdoc_files: []
|
67
|
+
files:
|
68
|
+
- .gitignore
|
69
|
+
- ChangeLog.md
|
70
|
+
- Gemfile
|
71
|
+
- LICENSE
|
72
|
+
- README
|
73
|
+
- Rakefile
|
74
|
+
- VERSION
|
75
|
+
- bazil_client.gemspec
|
76
|
+
- lib/bazil.rb
|
77
|
+
- lib/bazil/client.rb
|
78
|
+
- lib/bazil/error.rb
|
79
|
+
- lib/bazil/model.rb
|
80
|
+
- lib/bazil/rest.rb
|
81
|
+
- spec/bazil/client_spec.rb
|
82
|
+
- spec/bazil/model_spec.rb
|
83
|
+
- spec/spec_helper.rb
|
84
|
+
homepage: https://asp-bazil.preferred.jp/
|
85
|
+
licenses:
|
86
|
+
- MIT
|
87
|
+
post_install_message:
|
88
|
+
rdoc_options: []
|
89
|
+
require_paths:
|
90
|
+
- lib
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
92
|
+
none: false
|
93
|
+
requirements:
|
94
|
+
- - ! '>='
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 1.9.0
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
|
+
none: false
|
99
|
+
requirements:
|
100
|
+
- - ! '>='
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
segments:
|
104
|
+
- 0
|
105
|
+
hash: -4262472802805615291
|
106
|
+
requirements: []
|
107
|
+
rubyforge_project:
|
108
|
+
rubygems_version: 1.8.23
|
109
|
+
signing_key:
|
110
|
+
specification_version: 3
|
111
|
+
summary: Ruby client of Bazil
|
112
|
+
test_files:
|
113
|
+
- spec/bazil/client_spec.rb
|
114
|
+
- spec/bazil/model_spec.rb
|
115
|
+
- spec/spec_helper.rb
|