xapixctl 1.1.2 → 1.2.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xapixctl/base_cli'
4
+
5
+ module Xapixctl
6
+ class TitanCli < BaseCli
7
+ DEFAULT_METHODS = {
8
+ "predict" => :predict,
9
+ "performance" => :performance,
10
+ }.freeze
11
+
12
+ option :schema_import, desc: "Resource id of an existing Schema import"
13
+ option :data, desc: "JSON encoded data for predict API", default: "[[1,2,3]]"
14
+ desc "service URL", "build a service for the deployed model"
15
+ long_desc <<-LONGDESC
16
+ `xapixctl titan service URL` will build a ML service around a deployed ML model.
17
+
18
+ We expect the following of the deployed ML model:
19
+
20
+ - There should be a "POST /predict" endpoint. Use --data="JSON" to specify an example dataset the model expects.
21
+
22
+ - If there is a "GET /performance" endpoint, it'll be made available. It's expected to return a JSON object with the properties 'accuracy', 'precision', 'recall', and 'min_acc_threshold'.
23
+
24
+ Examples:
25
+ \x5> $ xapixctl titan service https://services.demo.akoios.com/ai-model-name -p xapix/ml-project
26
+ LONGDESC
27
+ def service(akoios_url)
28
+ url = URI.parse(akoios_url)
29
+ schema = JSON.parse(RestClient.get(File.join(url.to_s, 'spec'), params: { host: url.hostname }, headers: { accept: :json }))
30
+ patch_schema(schema)
31
+ connector_refs = import_swagger(File.basename(url.path), schema)
32
+ say "\n== Onboarding Connectors", :bold
33
+ connectors = match_connectors_to_action(connector_refs)
34
+ if connectors.empty?
35
+ warn "\nNo valid connectors for ML service detected, not building service."
36
+ exit 1
37
+ end
38
+ say "\n== Building Service", :bold
39
+ service_doc = build_service(schema.dig('info', 'title'), connectors)
40
+ res = prj_connection.apply(service_doc)
41
+ say "\ncreated / updated service #{res.first}"
42
+ end
43
+
44
+ private
45
+
46
+ def patch_schema(schema)
47
+ predict_schema = schema.dig('paths', '/predict', 'post')
48
+ if predict_schema
49
+ predict_data = JSON.parse(options[:data]) rescue {}
50
+ predict_schema['operationId'] = 'predict'
51
+ predict_schema['parameters'].each do |param|
52
+ if param['name'] == 'json' && param['in'] == 'body'
53
+ param['schema']['properties'] = { "data" => extract_schema(predict_data) }
54
+ param['schema']['example'] = { "data" => predict_data }
55
+ end
56
+ end
57
+ end
58
+
59
+ performane_schema = schema.dig('paths', '/performance', 'get')
60
+ if performane_schema
61
+ performane_schema['operationId'] = 'performance'
62
+ end
63
+ end
64
+
65
+ def import_swagger(filename, schema)
66
+ Tempfile.create([filename, '.json']) do |f|
67
+ f.write(schema.to_json)
68
+ f.rewind
69
+
70
+ if options[:schema_import]
71
+ result = prj_connection.update_schema_import(options[:schema_import], f)
72
+ say "updated #{result.dig('resource', 'kind')} #{result.dig('resource', 'id')}"
73
+ else
74
+ result = prj_connection.add_schema_import(f)
75
+ say "created #{result.dig('resource', 'kind')} #{result.dig('resource', 'id')}"
76
+ end
77
+ result.dig('schema_import', 'updated_resources')
78
+ end
79
+ end
80
+
81
+ def match_connectors_to_action(connector_refs)
82
+ connector_refs.map do |connector_ref|
83
+ connector = prj_connection.resource(connector_ref['kind'], connector_ref['id'])
84
+ action = DEFAULT_METHODS[connector.dig('definition', 'name')]
85
+ say "\n#{connector['kind']} #{connector.dig('definition', 'name')} -> "
86
+ if action
87
+ say "#{action} action"
88
+ updated_connector = update_connector_with_preview(connector)
89
+ [action, updated_connector] if updated_connector
90
+ else
91
+ say "no action type detected, ignoring"
92
+ nil
93
+ end
94
+ end.compact
95
+ end
96
+
97
+ def update_connector_with_preview(connector)
98
+ say "fetching preview for #{connector['kind']} #{connector.dig('definition', 'name')}..."
99
+ preview_details = prj_connection.data_source_preview(connector.dig('metadata', 'id'))
100
+ preview = preview_details.dig('preview', 'sample')
101
+ say "got a #{preview['status']} response: #{preview['body']}"
102
+ if preview['status'] != 200
103
+ say "unexpected status, please check data or model"
104
+ elsif yes?("Does this look alright?", :bold)
105
+ res = prj_connection.accept_data_source_preview(connector.dig('metadata', 'id'))
106
+ return res.dig('data_source', 'resource_description')
107
+ end
108
+ nil
109
+ end
110
+
111
+ def extract_schema(data_sample)
112
+ case data_sample
113
+ when Array
114
+ { type: 'array', items: extract_schema(data_sample[0]) }
115
+ when Hash
116
+ { type: 'object', properties: data_sample.transform_values { |v| extract_schema(v) } }
117
+ when Numeric
118
+ { type: 'number' }
119
+ else
120
+ {}
121
+ end
122
+ end
123
+
124
+ def build_service(title, connectors)
125
+ {
126
+ version: 'v1',
127
+ kind: 'Service',
128
+ metadata: { id: title.parameterize },
129
+ definition: {
130
+ name: title.humanize,
131
+ actions: connectors.map { |action, connector| build_service_action(action, connector) }
132
+ }
133
+ }
134
+ end
135
+
136
+ def build_service_action(action_type, connector)
137
+ {
138
+ name: action_type,
139
+ parameter_schema: parameter_schema(action_type, connector),
140
+ result_schema: result_schema(action_type),
141
+ pipeline: { units: pipeline_units(action_type, connector) }
142
+ }
143
+ end
144
+
145
+ def parameter_schema(action_type, connector)
146
+ case action_type
147
+ when :predict
148
+ { type: 'object', properties: {
149
+ data: connector.dig('definition', 'parameter_schema', 'properties', 'body', 'properties', 'data'),
150
+ } }
151
+ when :performance
152
+ { type: 'object', properties: {} }
153
+ end
154
+ end
155
+
156
+ def result_schema(action_type)
157
+ case action_type
158
+ when :predict
159
+ { type: 'object', properties: {
160
+ prediction: { type: 'object', properties: { percent: { type: 'number' }, raw: { type: 'number' } } },
161
+ success: { type: 'boolean' },
162
+ error: { type: 'string' }
163
+ } }
164
+ when :performance
165
+ { type: 'object', properties: {
166
+ performance: {
167
+ type: 'object', properties: {
168
+ accuracy: { type: 'number' },
169
+ precision: { type: 'number' },
170
+ recall: { type: 'number' },
171
+ min_acc_threshold: { type: 'number' }
172
+ }
173
+ }
174
+ } }
175
+ end
176
+ end
177
+
178
+ def pipeline_units(action_type, connector)
179
+ case action_type
180
+ when :predict
181
+ [entry_unit, predict_unit(connector), predict_result_unit]
182
+ when :performance
183
+ [entry_unit, performance_unit(connector), performance_result_unit]
184
+ end
185
+ end
186
+
187
+ def entry_unit
188
+ { version: 'v2', kind: 'Unit/Entry',
189
+ metadata: { id: 'entry' },
190
+ definition: { name: 'Entry' } }
191
+ end
192
+
193
+ def predict_unit(connector)
194
+ leafs = extract_leafs(connector.dig('definition', 'parameter_schema', 'properties', 'body'))
195
+ { version: 'v2', kind: 'Unit/DataSource',
196
+ metadata: { id: 'predict' },
197
+ definition: {
198
+ name: 'Predict',
199
+ inputs: ['entry'],
200
+ data_source: connector.dig('metadata', 'id'),
201
+ formulas: leafs.map { |leaf|
202
+ { ref: leaf[:node]['$id'], formula: (['', 'entry'] + leaf[:path]).join('.') }
203
+ }
204
+ } }
205
+ end
206
+
207
+ def predict_result_unit
208
+ { version: 'v2', kind: 'Unit/Result',
209
+ metadata: { id: 'result' },
210
+ definition: {
211
+ name: 'Result',
212
+ inputs: ['predict'],
213
+ formulas: [{
214
+ ref: "#prediction.percent",
215
+ formula: "IF(.predict.status = 200, ROUND(100*coerce.to-float(.predict.body), 2))"
216
+ }, {
217
+ ref: "#prediction.raw",
218
+ formula: "IF(.predict.status = 200, coerce.to-float(.predict.body))"
219
+ }, {
220
+ ref: "#success",
221
+ formula: ".predict.status = 200"
222
+ }, {
223
+ ref: "#error",
224
+ formula: "IF(.predict.status <> 200, 'Model not trained!')"
225
+ }],
226
+ parameter_sample: {
227
+ "prediction" => { "percent" => 51.12, "raw" => 0.5112131 },
228
+ "success" => true,
229
+ "error" => nil
230
+ }
231
+ } }
232
+ end
233
+
234
+ def performance_unit(connector)
235
+ { version: 'v2', kind: 'Unit/DataSource',
236
+ metadata: { id: 'performance' },
237
+ definition: {
238
+ name: 'Performance',
239
+ inputs: ['entry'],
240
+ data_source: connector.dig('metadata', 'id')
241
+ } }
242
+ end
243
+
244
+ def performance_result_unit
245
+ { version: 'v2', kind: 'Unit/Result',
246
+ metadata: { id: 'result' },
247
+ definition: {
248
+ name: 'Result',
249
+ inputs: ['performance'],
250
+ formulas: [{
251
+ ref: "#performance.recall",
252
+ formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.recall)"
253
+ }, {
254
+ ref: "#performance.accuracy",
255
+ formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.accuracy)"
256
+ }, {
257
+ ref: "#performance.precision",
258
+ formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.precision)"
259
+ }, {
260
+ ref: "#performance.min_acc_threshold",
261
+ formula: "WITH(data, JSON.DECODE(REGEXREPLACE(performance.body, \"'\", \"\\\"\")), .data.min_acc_threshold)"
262
+ }],
263
+ parameter_sample: {
264
+ "performance" => {
265
+ "recall" => 0.8184713375796179,
266
+ "accuracy" => 0.9807847896440129,
267
+ "precision" => 0.8711864406779661,
268
+ "min_acc_threshold" => 0.84,
269
+ }
270
+ }
271
+ } }
272
+ end
273
+
274
+ def extract_leafs(schema, current_path = [])
275
+ return unless schema
276
+ case schema['type']
277
+ when 'object'
278
+ schema['properties'].flat_map { |key, sub_schema| extract_leafs(sub_schema, current_path + [key]) }.compact
279
+ when 'array'
280
+ extract_leafs(schema['items'], current_path + [:[]])
281
+ else
282
+ { path: current_path, node: schema }
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Xapixctl
6
+ module Util
7
+ extend self
8
+
9
+ class InvalidDocumentStructureError < StandardError
10
+ def initialize(file)
11
+ super("#{file} has invalid document structure")
12
+ end
13
+ end
14
+
15
+ DOCUMENT_STRUCTURE = %w[version kind metadata definition].freeze
16
+
17
+ def resources_from_file(filename, ignore_missing: false)
18
+ load_files(filename, ignore_missing) do |actual_file, yaml_string|
19
+ yaml_string.split(/^---\s*\n/).map { |yml| Psych.safe_load(yml) }.compact.each do |doc|
20
+ raise InvalidDocumentStructureError, actual_file unless (DOCUMENT_STRUCTURE - doc.keys.map(&:to_s)).empty?
21
+ yield doc
22
+ end
23
+ end
24
+ end
25
+
26
+ def load_files(filename, ignore_missing)
27
+ if filename == '-'
28
+ yield 'STDIN', $stdin.read
29
+ else
30
+ pn = filename.is_a?(Pathname) ? filename : Pathname.new(filename)
31
+ if pn.directory?
32
+ pn.glob(["**/*.yaml", "**/*.yml"]).sort.each { |dpn| yield dpn.to_s, dpn.read }
33
+ elsif pn.exist?
34
+ yield pn.to_s, pn.read
35
+ elsif !ignore_missing
36
+ warn "file not found: #{filename}"
37
+ exit 1
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Xapixctl
4
- VERSION = "1.1.2"
4
+ VERSION = "1.2.4"
5
5
  end
data/xapixctl.gemspec CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  lib = File.expand_path("lib", __dir__)
2
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
5
  require "xapixctl/version"
@@ -24,13 +26,19 @@ Gem::Specification.new do |spec|
24
26
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
27
  spec.require_paths = ["lib"]
26
28
 
29
+ spec.required_ruby_version = '>= 2.6'
30
+
27
31
  spec.add_dependency "activesupport", ">= 5.2.3", "< 6.0.0"
32
+ spec.add_dependency "hashdiff", ">= 1.0.1", "< 1.2.0"
28
33
  spec.add_dependency "rest-client", ">= 2.1.0", "< 3.0.0"
29
- spec.add_dependency "thor", ">= 1.0.0", "< 1.1.0"
34
+ spec.add_dependency "thor", ">= 1.0.0", "< 1.2.0"
30
35
 
31
- spec.add_development_dependency "bundler", "~> 1.17.3"
36
+ spec.add_development_dependency "bundler", "~> 2.1.4"
32
37
  spec.add_development_dependency "rake", "~> 13.0"
33
38
  spec.add_development_dependency "relaxed-rubocop", "~> 2.5"
34
- spec.add_development_dependency "rspec", "~> 3.0"
35
- spec.add_development_dependency "rubocop", "~> 0.89"
39
+ spec.add_development_dependency "rspec", "~> 3.10.0"
40
+ spec.add_development_dependency "rubocop", "~> 1.11.0"
41
+ spec.add_development_dependency "rubocop-rake", "~> 0.5.1"
42
+ spec.add_development_dependency "rubocop-rspec", "~> 2.2.0"
43
+ spec.add_development_dependency "webmock", "~> 3.11.0"
36
44
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xapixctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Reinsch
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-09-25 00:00:00.000000000 Z
11
+ date: 2021-06-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -30,6 +30,26 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
32
  version: 6.0.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: hashdiff
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.0.1
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: 1.2.0
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 1.0.1
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: 1.2.0
33
53
  - !ruby/object:Gem::Dependency
34
54
  name: rest-client
35
55
  requirement: !ruby/object:Gem::Requirement
@@ -59,7 +79,7 @@ dependencies:
59
79
  version: 1.0.0
60
80
  - - "<"
61
81
  - !ruby/object:Gem::Version
62
- version: 1.1.0
82
+ version: 1.2.0
63
83
  type: :runtime
64
84
  prerelease: false
65
85
  version_requirements: !ruby/object:Gem::Requirement
@@ -69,21 +89,21 @@ dependencies:
69
89
  version: 1.0.0
70
90
  - - "<"
71
91
  - !ruby/object:Gem::Version
72
- version: 1.1.0
92
+ version: 1.2.0
73
93
  - !ruby/object:Gem::Dependency
74
94
  name: bundler
75
95
  requirement: !ruby/object:Gem::Requirement
76
96
  requirements:
77
97
  - - "~>"
78
98
  - !ruby/object:Gem::Version
79
- version: 1.17.3
99
+ version: 2.1.4
80
100
  type: :development
81
101
  prerelease: false
82
102
  version_requirements: !ruby/object:Gem::Requirement
83
103
  requirements:
84
104
  - - "~>"
85
105
  - !ruby/object:Gem::Version
86
- version: 1.17.3
106
+ version: 2.1.4
87
107
  - !ruby/object:Gem::Dependency
88
108
  name: rake
89
109
  requirement: !ruby/object:Gem::Requirement
@@ -118,28 +138,70 @@ dependencies:
118
138
  requirements:
119
139
  - - "~>"
120
140
  - !ruby/object:Gem::Version
121
- version: '3.0'
141
+ version: 3.10.0
122
142
  type: :development
123
143
  prerelease: false
124
144
  version_requirements: !ruby/object:Gem::Requirement
125
145
  requirements:
126
146
  - - "~>"
127
147
  - !ruby/object:Gem::Version
128
- version: '3.0'
148
+ version: 3.10.0
129
149
  - !ruby/object:Gem::Dependency
130
150
  name: rubocop
131
151
  requirement: !ruby/object:Gem::Requirement
132
152
  requirements:
133
153
  - - "~>"
134
154
  - !ruby/object:Gem::Version
135
- version: '0.89'
155
+ version: 1.11.0
136
156
  type: :development
137
157
  prerelease: false
138
158
  version_requirements: !ruby/object:Gem::Requirement
139
159
  requirements:
140
160
  - - "~>"
141
161
  - !ruby/object:Gem::Version
142
- version: '0.89'
162
+ version: 1.11.0
163
+ - !ruby/object:Gem::Dependency
164
+ name: rubocop-rake
165
+ requirement: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - "~>"
168
+ - !ruby/object:Gem::Version
169
+ version: 0.5.1
170
+ type: :development
171
+ prerelease: false
172
+ version_requirements: !ruby/object:Gem::Requirement
173
+ requirements:
174
+ - - "~>"
175
+ - !ruby/object:Gem::Version
176
+ version: 0.5.1
177
+ - !ruby/object:Gem::Dependency
178
+ name: rubocop-rspec
179
+ requirement: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - "~>"
182
+ - !ruby/object:Gem::Version
183
+ version: 2.2.0
184
+ type: :development
185
+ prerelease: false
186
+ version_requirements: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - "~>"
189
+ - !ruby/object:Gem::Version
190
+ version: 2.2.0
191
+ - !ruby/object:Gem::Dependency
192
+ name: webmock
193
+ requirement: !ruby/object:Gem::Requirement
194
+ requirements:
195
+ - - "~>"
196
+ - !ruby/object:Gem::Version
197
+ version: 3.11.0
198
+ type: :development
199
+ prerelease: false
200
+ version_requirements: !ruby/object:Gem::Requirement
201
+ requirements:
202
+ - - "~>"
203
+ - !ruby/object:Gem::Version
204
+ version: 3.11.0
143
205
  description:
144
206
  email:
145
207
  - michael@xapix.io
@@ -148,6 +210,7 @@ executables:
148
210
  extensions: []
149
211
  extra_rdoc_files: []
150
212
  files:
213
+ - ".github/workflows/cd.yaml"
151
214
  - ".gitignore"
152
215
  - ".rspec"
153
216
  - ".rubocop.yml"
@@ -163,8 +226,16 @@ files:
163
226
  - lib/xapixctl.rb
164
227
  - lib/xapixctl/base_cli.rb
165
228
  - lib/xapixctl/cli.rb
229
+ - lib/xapixctl/connector_cli.rb
166
230
  - lib/xapixctl/phoenix_client.rb
231
+ - lib/xapixctl/phoenix_client/connection.rb
232
+ - lib/xapixctl/phoenix_client/organization_connection.rb
233
+ - lib/xapixctl/phoenix_client/project_connection.rb
234
+ - lib/xapixctl/phoenix_client/result_handler.rb
167
235
  - lib/xapixctl/preview_cli.rb
236
+ - lib/xapixctl/sync_cli.rb
237
+ - lib/xapixctl/titan_cli.rb
238
+ - lib/xapixctl/util.rb
168
239
  - lib/xapixctl/version.rb
169
240
  - xapixctl.gemspec
170
241
  homepage: https://github.com/xapix-io/xapixctl
@@ -181,14 +252,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
181
252
  requirements:
182
253
  - - ">="
183
254
  - !ruby/object:Gem::Version
184
- version: '0'
255
+ version: '2.6'
185
256
  required_rubygems_version: !ruby/object:Gem::Requirement
186
257
  requirements:
187
258
  - - ">="
188
259
  - !ruby/object:Gem::Version
189
260
  version: '0'
190
261
  requirements: []
191
- rubygems_version: 3.0.8
262
+ rubygems_version: 3.0.9
192
263
  signing_key:
193
264
  specification_version: 4
194
265
  summary: xapix client library and command line tool