scout-rig 0.1.0 → 0.2.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.
@@ -0,0 +1,110 @@
1
+ require_relative '../../python'
2
+ require 'shellwords'
3
+ module PythonWorkflow
4
+
5
+ def self.read_python_metadata(file)
6
+ out = ScoutPython.run_file file, '--scout-metadata'
7
+ raise "Error getting metadata from #{file}: #{err}" unless out.exit_status == 0
8
+ JSON.parse(out.read)
9
+ end
10
+
11
+ def self.map_returns(py_type)
12
+ case py_type
13
+ when 'string' then :string
14
+ when 'integer' then :integer
15
+ when 'float' then :float
16
+ when 'boolean' then :boolean
17
+ when 'binary' then :binary
18
+ when 'path' then :string
19
+ when 'list', 'array' then :array
20
+ else
21
+ if py_type.start_with?("list[")
22
+ :array
23
+ else
24
+ :string
25
+ end
26
+ end
27
+ end
28
+
29
+ def self.map_param(p)
30
+ desc = p['help'] || ""
31
+ default = p['default']
32
+ required = p['required'] ? true : false
33
+
34
+ ruby_type =
35
+ case p['type']
36
+ when 'string' then :string
37
+ when 'integer' then :integer
38
+ when 'float' then :float
39
+ when 'boolean' then :boolean
40
+ when 'binary' then :binary
41
+ when 'path' then :file
42
+ else
43
+ if p['type'].start_with?('list[')
44
+ subtype = p['type'][5..-2]
45
+ ruby_sub = case subtype
46
+ when 'path' then :file_array
47
+ else :array
48
+ end
49
+ ruby_sub
50
+ else
51
+ :string
52
+ end
53
+ end
54
+
55
+ options = {}
56
+ options[:required] = true if required
57
+
58
+ { name: p['name'], type: ruby_type, desc: desc, default: default, options: options }
59
+ end
60
+
61
+ def python_task(task_sym, file: nil, returns: nil, extension: nil, desc: nil)
62
+ name = task_sym.to_s
63
+ file ||= python_task_dir[name].find_with_extension('py')
64
+ raise "Python task file not found: #{file}" unless File.exist?(file)
65
+
66
+ metas = PythonWorkflow.read_python_metadata(file)
67
+ metas = [metas] unless Array === metas
68
+
69
+ # For each function defined in the python file, register a workflow task
70
+ metas.each do |meta|
71
+ meta['returns'] = returns.to_s if returns
72
+ task_desc = desc || meta['description']
73
+
74
+ ruby_returns = PythonWorkflow.map_returns(meta['returns'])
75
+ ruby_inputs = meta['params'].map { |p| PythonWorkflow.map_param(p) }
76
+
77
+ ruby_inputs.each do |inp|
78
+ input(inp[:name].to_sym, inp[:type], inp[:desc], inp[:default], inp[:options] || {})
79
+ end
80
+
81
+ self.desc(task_desc) if task_desc && !task_desc.empty?
82
+ self.extension(extension) if extension
83
+
84
+ task({ meta['name'].to_sym => ruby_returns }) do |*args|
85
+ arg_names = ruby_inputs.map { |i| i[:name] }
86
+ values = {}
87
+ arg_names.each_with_index { |n,i| values[n] = args[i] }
88
+
89
+ argv = PythonWorkflow.build_python_argv(meta['params'], values)
90
+ # prefix with function name so the python script runs the desired function
91
+ full_argv = [meta['name']] + argv
92
+
93
+ out = ScoutPython.run_file file, Shellwords.shelljoin(full_argv)
94
+ # out is expected to respond to exit_status and read
95
+ raise "Python task #{meta['name']} failed" unless out.exit_status == 0
96
+ txt = out.read.to_s
97
+ # try JSON
98
+ begin
99
+ next JSON.parse(txt)
100
+ rescue JSON::ParserError
101
+ # not JSON; for list returns, split by newline
102
+ if ruby_returns == :array || ruby_returns == :file_array
103
+ next txt.split("\n").map(&:to_s)
104
+ end
105
+ next txt.strip
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,14 @@
1
+ require 'scout/workflow'
2
+ require 'json'
3
+ require 'open3'
4
+
5
+ require_relative 'python/inputs'
6
+ require_relative 'python/task'
7
+
8
+ module PythonWorkflow
9
+ attr_accessor :python_task_dir
10
+
11
+ def python_task_dir
12
+ @python_task_dir ||= Scout.python.task.find(:lib)
13
+ end
14
+ end
data/lib/scout-rig.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  require 'scout'
2
2
  require 'scout/path'
3
3
  require 'scout/resource'
4
- Path.add_path :scout_rig, File.join(Path.caller_lib_dir(__FILE__), "{TOPLEVEL}/{SUBPATH}")
4
+ Path.add_path :scout_rig_lib, File.join(Path.caller_lib_dir(__FILE__), "{TOPLEVEL}/{SUBPATH}")
5
5
 
@@ -6,7 +6,6 @@ import shutil
6
6
  import pandas
7
7
  import numpy
8
8
 
9
-
10
9
  def cmd(cmd=None):
11
10
  if cmd is None:
12
11
  print("Rbbt")
@@ -14,6 +13,7 @@ def cmd(cmd=None):
14
13
  return subprocess.run('rbbt_exec.rb', input=cmd.encode('utf-8'), capture_output=True).stdout.decode()
15
14
 
16
15
 
16
+
17
17
  def libdir():
18
18
  return cmd('puts Rbbt.find(:lib)').rstrip()
19
19
 
@@ -188,9 +188,9 @@ def save_job_inputs(data):
188
188
  return temp_dir
189
189
 
190
190
 
191
- def run_job(workflow, task, name='Default', fork=False, clean=False, **kwargs):
191
+ def run_job(workflow, task, jobname='Default', fork=False, clean=False, **kwargs):
192
192
  inputs_dir = save_job_inputs(kwargs)
193
- cmd = ['rbbt', 'workflow', 'task', workflow, task, '--jobname', name, '--load_inputs', inputs_dir, '--nocolor']
193
+ cmd = ['rbbt', 'workflow', 'task', workflow, task, '--jobname', jobname, '--load_inputs', inputs_dir, '--nocolor']
194
194
 
195
195
  if fork:
196
196
  cmd.append('--fork')
@@ -219,3 +219,10 @@ if __name__ == "__main__":
219
219
  import json
220
220
  res = run_job('Baking', 'bake_muffin_tray', 'test', add_blueberries=True, fork=True)
221
221
  print(res)
222
+
223
+ # expose smaller task helper implemented in runner.py
224
+ try:
225
+ from .runner import task, describe_function
226
+ except Exception:
227
+ # avoid failing import if runner missing
228
+ pass
@@ -0,0 +1,385 @@
1
+ import argparse
2
+ import inspect
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import get_origin, get_args, Union, List
7
+ import atexit
8
+
9
+ __all__ = ["task", "describe_function"]
10
+
11
+ # registry to hold tasks defined via scout.task in a module
12
+ _SCOUT_TASK_REGISTRY = [] # list of (func, meta)
13
+ _SCOUT_DEFER_REGISTERED = set()
14
+
15
+
16
+ def _python_type_to_string(ann) -> str:
17
+ if ann is inspect._empty:
18
+ return "string"
19
+ origin = get_origin(ann)
20
+ args = get_args(ann)
21
+
22
+ if origin is Union:
23
+ non_none = [a for a in args if a is not type(None)]
24
+ if len(non_none) == 1:
25
+ return _python_type_to_string(non_none[0])
26
+ return "string"
27
+
28
+ if origin in (list, List):
29
+ subtype = "string"
30
+ if args:
31
+ subtype = _python_type_to_string(args[0])
32
+ return f"list[{subtype}]"
33
+
34
+ if ann is Path:
35
+ return "path"
36
+
37
+ if ann in (str, bytes):
38
+ return "string" if ann is str else "binary"
39
+ if ann is int:
40
+ return "integer"
41
+ if ann is float:
42
+ return "float"
43
+ if ann is bool:
44
+ return "boolean"
45
+
46
+ return "string"
47
+
48
+
49
+ def _required_from_default(default_val, ann) -> bool:
50
+ if default_val is not inspect._empty:
51
+ return False
52
+ origin = get_origin(ann)
53
+ args = get_args(ann)
54
+ if origin is Union and type(None) in args:
55
+ return False
56
+ return True
57
+
58
+
59
+ def _parse_numpy_params(doc: str) -> dict:
60
+ if not doc:
61
+ return {}
62
+ lines = doc.splitlines()
63
+ params = {}
64
+ i = 0
65
+ while i < len(lines):
66
+ if lines[i].strip().lower() == "parameters":
67
+ i += 1
68
+ if i < len(lines) and set(lines[i].strip()) == {"-"}:
69
+ i += 1
70
+ break
71
+ i += 1
72
+ else:
73
+ return {}
74
+
75
+ current_name = None
76
+ current_desc = []
77
+ while i < len(lines):
78
+ line = lines[i]
79
+ if ":" in line and not line.startswith(" "):
80
+ if current_name:
81
+ params[current_name] = " ".join(d.strip() for d in current_desc).strip()
82
+ current_desc = []
83
+ current_name = line.split(":")[0].strip()
84
+ else:
85
+ if current_name is not None:
86
+ current_desc.append(line)
87
+ else:
88
+ break
89
+ i += 1
90
+
91
+ if current_name:
92
+ params[current_name] = " ".join(d.strip() for d in current_desc).strip()
93
+
94
+ return params
95
+
96
+
97
+ def describe_function(func) -> dict:
98
+ sig = inspect.signature(func)
99
+ doc = inspect.getdoc(func) or ""
100
+ params_doc = _parse_numpy_params(doc)
101
+
102
+ ret_type = _python_type_to_string(sig.return_annotation)
103
+ description = doc.split("\n\n", 1)[0] if doc else ""
104
+
105
+ params = []
106
+ for name, p in sig.parameters.items():
107
+ p_type = _python_type_to_string(p.annotation)
108
+ default = None if p.default is inspect._empty else p.default
109
+ required = _required_from_default(p.default, p.annotation)
110
+ help_text = params_doc.get(name, "")
111
+ params.append({
112
+ "name": name,
113
+ "type": p_type,
114
+ "required": required,
115
+ "default": default,
116
+ "help": help_text,
117
+ })
118
+
119
+ return {
120
+ "name": func.__name__,
121
+ "description": description,
122
+ "returns": ret_type,
123
+ "params": params,
124
+ }
125
+
126
+
127
+ def _has_boolean_optional_action():
128
+ try:
129
+ _ = argparse.BooleanOptionalAction
130
+ return True
131
+ except AttributeError:
132
+ return False
133
+
134
+
135
+ class _BooleanOptionalAction(argparse.Action):
136
+ def __init__(self, option_strings, dest, default=None, required=False, help=None, metavar=None):
137
+ _option_strings = []
138
+ for option in option_strings:
139
+ _option_strings.append(option)
140
+ if option.startswith("--"):
141
+ _option_strings.append("--no-" + option[2:])
142
+ super().__init__(option_strings=_option_strings, dest=dest, nargs=0,
143
+ const=None, default=default, required=required, help=help)
144
+
145
+ def __call__(self, parser, namespace, values, option_string=None):
146
+ if option_string and option_string.startswith("--no-"):
147
+ setattr(namespace, self.dest, False)
148
+ else:
149
+ setattr(namespace, self.dest, True)
150
+
151
+
152
+ def _add_arg_for_param(parser, p):
153
+ name = p["name"]
154
+ ptype = p["type"]
155
+ required = p["required"]
156
+ default = p["default"]
157
+ help_text = p.get("help") or None
158
+
159
+ flag = f"--{name}"
160
+
161
+ if ptype.startswith("list["):
162
+ subtype = ptype[5:-1] or "string"
163
+ py_caster = str
164
+ if subtype == "integer":
165
+ py_caster = int
166
+ elif subtype == "float":
167
+ py_caster = float
168
+ elif subtype == "path":
169
+ py_caster = lambda s: str(Path(s))
170
+ parser.add_argument(flag, nargs="+", type=py_caster, required=required, default=default, help=help_text)
171
+ return
172
+
173
+ if ptype == "boolean":
174
+ if default is True:
175
+ if _has_boolean_optional_action():
176
+ parser.add_argument(flag, action=argparse.BooleanOptionalAction, default=True, help=help_text, required=False)
177
+ else:
178
+ parser.add_argument(flag, action=_BooleanOptionalAction, default=True, help=help_text, required=False)
179
+ else:
180
+ parser.add_argument(flag, action="store_true", default=False, required=False, help=help_text)
181
+ return
182
+
183
+ py_caster = str
184
+ if ptype == "integer":
185
+ py_caster = int
186
+ elif ptype == "float":
187
+ py_caster = float
188
+ elif ptype == "path":
189
+ py_caster = lambda s: str(Path(s))
190
+
191
+ parser.add_argument(flag, type=py_caster, required=required, default=default, help=help_text)
192
+
193
+
194
+ def _write_output(out_path: str, data):
195
+ path = Path(out_path)
196
+ path.parent.mkdir(parents=True, exist_ok=True)
197
+ if isinstance(data, (bytes, bytearray)):
198
+ path.write_bytes(data)
199
+ else:
200
+ path.write_text(str(data))
201
+
202
+
203
+ def _run_cli(func, meta: dict):
204
+ # single-function CLI metadata
205
+ if "--scout-metadata" in sys.argv:
206
+ print(json.dumps(meta))
207
+ return 0
208
+
209
+ parser = argparse.ArgumentParser(description=meta.get("description") or "")
210
+ for p in meta["params"]:
211
+ _add_arg_for_param(parser, p)
212
+
213
+ parser.add_argument("--scout-output", default=None, help="Optional output file path for the result")
214
+ args = vars(parser.parse_args())
215
+
216
+ out_path = args.pop("scout_output", None)
217
+
218
+ kwargs = {}
219
+ for p in meta["params"]:
220
+ name = p["name"]
221
+ val = args.get(name)
222
+ kwargs[name] = val
223
+
224
+ result = func(**kwargs)
225
+
226
+ if out_path:
227
+ _write_output(out_path, result)
228
+ return 0
229
+
230
+ # Serialization rules:
231
+ # - bytes -> raw bytes to stdout.buffer
232
+ # - str/int/float/bool/None -> printed as plain string
233
+ # - list/tuple -> newline-separated items
234
+ # - others -> JSON dump
235
+ if isinstance(result, (bytes, bytearray)):
236
+ sys.stdout.buffer.write(result)
237
+ return 0
238
+
239
+ if result is None or isinstance(result, (str, int, float, bool)):
240
+ print(result if result is not None else "")
241
+ return 0
242
+
243
+ if isinstance(result, (list, tuple)):
244
+ # print each item on its own line
245
+ for item in result:
246
+ # convert None to empty string
247
+ if item is None:
248
+ print("")
249
+ else:
250
+ # bytes inside list -> decode
251
+ if isinstance(item, (bytes, bytearray)):
252
+ try:
253
+ sys.stdout.buffer.write(item)
254
+ # ensure newline
255
+ sys.stdout.buffer.write(b"\n")
256
+ except Exception:
257
+ print(str(item))
258
+ else:
259
+ print(item)
260
+ return 0
261
+
262
+ # fallback: JSON serialize (use default=str to handle Paths etc.)
263
+ try:
264
+ print(json.dumps(result, default=str, ensure_ascii=False))
265
+ except Exception:
266
+ # last resort: string representation
267
+ print(str(result))
268
+ return 0
269
+
270
+
271
+ def task(func=None, **options):
272
+ if func is None:
273
+ raise ValueError("scout.task expects a function")
274
+
275
+ meta = describe_function(func)
276
+ setattr(func, "__scout_meta__", meta)
277
+
278
+ # register globally
279
+ _SCOUT_TASK_REGISTRY.append((func, meta))
280
+
281
+ mod = inspect.getmodule(func)
282
+ # If the defining module is executed as a script, defer execution until
283
+ # after the module is fully imported by using an atexit handler. This
284
+ # allows multiple scout.task(...) calls to register multiple functions.
285
+ if mod and getattr(mod, "__name__", None) == "__main__":
286
+ mod_id = id(mod)
287
+ if mod_id not in _SCOUT_DEFER_REGISTERED:
288
+ _SCOUT_DEFER_REGISTERED.add(mod_id)
289
+
290
+ def _scout_run_deferred():
291
+ # if metadata requested, emit all metas
292
+ if "--scout-metadata" in sys.argv:
293
+ metas = [m for (_f, m) in _SCOUT_TASK_REGISTRY]
294
+ print(json.dumps(metas))
295
+ return
296
+
297
+ # Determine if user requested help
298
+ has_help = any(a in ("-h", "--help") for a in sys.argv[1:])
299
+
300
+ # Determine function to run: optional first positional arg
301
+ args = sys.argv[1:]
302
+ func_name = None
303
+ if len(args) >= 1 and not args[0].startswith("-"):
304
+ func_name = args[0]
305
+ # do not pop yet; if running we will pop below
306
+
307
+ # If user requested help, show the help for the selected task and exit
308
+ if has_help:
309
+ # choose meta
310
+ chosen_meta = None
311
+ if func_name:
312
+ for _f, m in _SCOUT_TASK_REGISTRY:
313
+ if m.get('name') == func_name:
314
+ chosen_meta = m
315
+ break
316
+ if chosen_meta is None:
317
+ print(f"[scout.task] Unknown task '{func_name}'", file=sys.stderr)
318
+ import os
319
+ os._exit(2)
320
+ else:
321
+ chosen_meta = _SCOUT_TASK_REGISTRY[-1][1]
322
+
323
+ # Build a parser for that function and print help
324
+ parser = argparse.ArgumentParser(prog=f"{Path(sys.argv[0]).name} {chosen_meta.get('name')}", description=chosen_meta.get('description') or "")
325
+ for p in chosen_meta['params']:
326
+ _add_arg_for_param(parser, p)
327
+ parser.add_argument("--scout-output", default=None, help="Optional output file path for the result")
328
+ parser.print_help()
329
+ try:
330
+ sys.stdout.flush()
331
+ except Exception:
332
+ pass
333
+ try:
334
+ sys.stderr.flush()
335
+ except Exception:
336
+ pass
337
+ import os
338
+ os._exit(0)
339
+
340
+ chosen = None
341
+ if func_name:
342
+ for f, m in _SCOUT_TASK_REGISTRY:
343
+ if m.get('name') == func_name:
344
+ chosen = (f, m)
345
+ break
346
+ if chosen is None:
347
+ print(f"[scout.task] Unknown task '{func_name}'", file=sys.stderr)
348
+ sys.exit(2)
349
+ else:
350
+ # default: last registered task
351
+ chosen = _SCOUT_TASK_REGISTRY[-1]
352
+
353
+ try:
354
+ # if a function name was provided as first positional arg, remove it
355
+ if func_name and len(sys.argv) > 1:
356
+ # remove the first positional argument (function name)
357
+ sys.argv.pop(1)
358
+
359
+ code = _run_cli(chosen[0], chosen[1])
360
+ try:
361
+ sys.stdout.flush()
362
+ except Exception:
363
+ pass
364
+ try:
365
+ sys.stderr.flush()
366
+ except Exception:
367
+ pass
368
+ import os
369
+ os._exit(code)
370
+ except SystemExit as e:
371
+ # argparse may trigger SystemExit (e.g. --help). Ensure we exit quietly.
372
+ try:
373
+ import os
374
+ code = e.code if isinstance(e.code, int) else 0
375
+ os._exit(code)
376
+ except Exception:
377
+ pass
378
+ except Exception as e:
379
+ print(f"[scout.task] Error: {e}", file=sys.stderr)
380
+ import os
381
+ os._exit(1)
382
+
383
+ atexit.register(_scout_run_deferred)
384
+
385
+ return func
data/scout-rig.gemspec CHANGED
@@ -2,11 +2,11 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: scout-rig 0.1.0 ruby lib
5
+ # stub: scout-rig 0.2.0 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "scout-rig".freeze
9
- s.version = "0.1.0".freeze
9
+ s.version = "0.2.0".freeze
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib".freeze]
@@ -16,36 +16,42 @@ Gem::Specification.new do |s|
16
16
  s.email = "mikisvaz@gmail.com".freeze
17
17
  s.extra_rdoc_files = [
18
18
  "LICENSE.txt",
19
- "README.rdoc"
19
+ "README.md"
20
20
  ]
21
21
  s.files = [
22
22
  ".document",
23
23
  ".vimproject",
24
24
  "LICENSE.txt",
25
- "README.rdoc",
25
+ "README.md",
26
26
  "Rakefile",
27
27
  "VERSION",
28
+ "doc/Python.md",
28
29
  "lib/scout-rig.rb",
29
30
  "lib/scout/python.rb",
30
31
  "lib/scout/python/paths.rb",
31
32
  "lib/scout/python/run.rb",
32
33
  "lib/scout/python/script.rb",
33
34
  "lib/scout/python/util.rb",
35
+ "lib/scout/workflow/python.rb",
36
+ "lib/scout/workflow/python/inputs.rb",
37
+ "lib/scout/workflow/python/task.rb",
34
38
  "python/scout/__init__.py",
35
- "python/scout/__pycache__/__init__.cpython-310.pyc",
36
- "python/scout/__pycache__/workflow.cpython-310.pyc",
39
+ "python/scout/runner.py",
37
40
  "python/scout/workflow.py",
38
41
  "python/scout/workflow/remote.py",
39
42
  "python/test.py",
40
43
  "scout-rig.gemspec",
44
+ "test/scout/python/test_run.rb",
41
45
  "test/scout/python/test_script.rb",
42
46
  "test/scout/python/test_util.rb",
43
47
  "test/scout/test_python.rb",
48
+ "test/scout/workflow/python/test_task.rb",
49
+ "test/scout/workflow/test_python.rb",
44
50
  "test/test_helper.rb"
45
51
  ]
46
52
  s.homepage = "http://github.com/mikisvaz/scout-rig".freeze
47
53
  s.licenses = ["MIT".freeze]
48
- s.rubygems_version = "3.6.8".freeze
54
+ s.rubygems_version = "3.7.0.dev".freeze
49
55
  s.summary = "Scouts rigging things together".freeze
50
56
 
51
57
  s.specification_version = 4
@@ -0,0 +1,15 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require 'scout/python'
3
+ require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1')
4
+
5
+ class TestClass < Test::Unit::TestCase
6
+ def __test_run_threaded
7
+
8
+ ScoutPython.run_threaded :sys do
9
+ paths = sys.path()
10
+ puts paths
11
+ end
12
+ ScoutPython.stop_thread
13
+ end
14
+ end
15
+
@@ -0,0 +1,37 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1')
3
+
4
+ class TestPythonWorkflowTask < Test::Unit::TestCase
5
+ def test_load_metadata
6
+ code =<<-EOF
7
+ import scout
8
+ from typing import List, Optional
9
+
10
+ def hello(name: str, excited: bool = False) -> str:
11
+ """
12
+ Greet a user.
13
+
14
+ Parameters
15
+ ----------
16
+ name : str
17
+ The name of the person to greet.
18
+ excited : bool, optional
19
+ Whether to add an exclamation mark, by default False.
20
+
21
+ Returns
22
+ -------
23
+ str
24
+ A greeting message.
25
+ """
26
+ return f"Hello, {name}{'!' if excited else ''}"
27
+
28
+ scout.task(hello)
29
+ EOF
30
+
31
+ TmpFile.with_file code do |task_file|
32
+ res = PythonWorkflow.read_python_metadata task_file
33
+ assert_equal 'hello', res.first['name']
34
+ end
35
+ end
36
+ end
37
+