scout-rig 0.1.1 → 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.
data/doc/Python.md ADDED
@@ -0,0 +1,482 @@
1
+ # Python (ScoutPython)
2
+
3
+ ScoutPython is the bridge between Ruby (Scout) and Python. It provides:
4
+
5
+ - A thin, ergonomic layer on top of PyCall to execute Python code from Ruby (direct or in a dedicated thread).
6
+ - Import helpers and a safe “binding” scope that keeps Python names local to a call site.
7
+ - A small scripting facility to run ad‑hoc Python text, pass Ruby variables (including TSV) into Python, and return results.
8
+ - Data conversion helpers for numpy/list and pandas DataFrame to/from TSV.
9
+ - Path management to expose package Python modules to sys.path.
10
+ - A companion Python package (scout) with utilities for TSV IO and Workflow execution from Python, and a small remote Workflow client.
11
+
12
+ Sections:
13
+ - Requirements
14
+ - Path management
15
+ - Running Python code from Ruby
16
+ - Binding scopes and imports
17
+ - Scripting: run ad‑hoc Python text
18
+ - Iteration utilities
19
+ - Data conversion and pandas helpers
20
+ - Python-side helper package (scout)
21
+ - Workflow wrapper (local)
22
+ - Remote workflows over HTTP
23
+ - Error handling, logging, and threading
24
+ - API quick reference
25
+ - Examples
26
+
27
+ ---
28
+
29
+ ## Requirements
30
+
31
+ - Ruby gem: pycall (PyCall).
32
+ - Python 3 with:
33
+ - pandas (for DataFrame helpers),
34
+ - numpy (for numeric conversions),
35
+ - Ruby gems used in certain paths: python/pickle (for reading pickle files back into Ruby), json.
36
+
37
+ Make sure a Python3 interpreter is available in PATH.
38
+
39
+ ---
40
+
41
+ ## Path management
42
+
43
+ ScoutPython manages Python import paths and pre-populates them with known package Python dirs.
44
+
45
+ - Sources of Python paths:
46
+ - Scout.python.find_all returns Python directories registered via the Path subsystem; these are added automatically at load time.
47
+ - You can add more at runtime.
48
+
49
+ API:
50
+ ```ruby
51
+ # Add one or many paths for Python imports
52
+ ScoutPython.add_path("/path/to/python/package")
53
+ ScoutPython.add_paths(%w[/opt/mypkg/py /usr/local/mytools/py])
54
+
55
+ # Apply registered paths to sys.path (idempotent; called on init and before run_simple)
56
+ ScoutPython.process_paths
57
+ ```
58
+
59
+ process_paths executes in a Python context:
60
+ ```ruby
61
+ ScoutPython.run_direct 'sys' do
62
+ ScoutPython.paths.each { |p| sys.path.append p }
63
+ end
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Running Python code from Ruby
69
+
70
+ ScoutPython wraps PyCall to simplify imports and execution. The central methods accept optional import directives and a block of Python-interacting Ruby.
71
+
72
+ Imports argument forms:
73
+ - String/Symbol module only: pyimport "numpy"
74
+ - Array of names to import from module: pyfrom "module.submodule", import: [:Class, :func]
75
+ - Hash passed to pyimport (e.g., aliases): pyimport "numpy", as: :np
76
+
77
+ Methods:
78
+
79
+ - run(mod = nil, imports = nil) { ... }
80
+ - Initialize PyCall once (init_scout), ensure paths, run block. Garbage collects after run.
81
+ - run_simple(mod = nil, imports = nil) { ... }
82
+ - Same as run but without init_scout and extra GC. Synchronizes and calls process_paths.
83
+ - run_direct(mod = nil, imports = nil) { ... }
84
+ - Run without synchronization; perform a single pyimport/pyfrom (if provided) and module_eval the block.
85
+ - run_threaded(mod = nil, imports = nil) { ... }
86
+ - Execute in a dedicated background thread (see Threading below). Optional import executed in thread context.
87
+ - run_log(mod = nil, imports = nil, severity = 0, severity_err = nil) { ... }
88
+ - Wrap the execution with Log.trap_std to capture/route Python stdout/stderr.
89
+ - run_log_stderr(mod = nil, imports = nil, severity = 0) { ... }
90
+ - Capture only Python stderr via Log.trap_stderr.
91
+ - stop_thread
92
+ - Stop the background Python thread, join/kill, GC, and PyCall.finalize (if available).
93
+
94
+ Examples:
95
+ ```ruby
96
+ # Simple import and call
97
+ ScoutPython.run 'numpy', as: :np do
98
+ a = np.array([1,2,3])
99
+ a.sum # => Py object; can call PyCall on it
100
+ end
101
+
102
+ # From submodule and alias
103
+ ScoutPython.run "tensorflow.keras.models", import: :Sequential do
104
+ defined?(self::Sequential) # => true in this scope
105
+ end
106
+
107
+ # Threaded execution
108
+ arr = ScoutPython.run_threaded :numpy, as: :np do
109
+ np.array([1,2])
110
+ end
111
+ ScoutPython.stop_thread
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Binding scopes and imports
117
+
118
+ Use a dedicated Binding (includes PyCall::Import) to localize imported names so they don’t leak into other scopes.
119
+
120
+ - new_binding → returns a Binding instance with PyCall::Import mixed in.
121
+ - binding_run(binding = nil, *args) { ... }
122
+ - Create a new Binding, instance_exec the block, then discard.
123
+
124
+ Example (from tests):
125
+ ```ruby
126
+ raised = false
127
+ ScoutPython.binding_run do
128
+ pyimport :torch
129
+ pyfrom :torch, import: ["nn"]
130
+ begin
131
+ torch
132
+ rescue
133
+ raised = true
134
+ end
135
+ end
136
+ raised # => false (torch available inside)
137
+
138
+ raised = false
139
+ ScoutPython.binding_run do
140
+ begin
141
+ torch # not defined in this fresh binding
142
+ rescue
143
+ raised = true
144
+ end
145
+ end
146
+ raised # => true
147
+ ```
148
+
149
+ Import helpers:
150
+ - import_method(module_name, method_name, as=nil) → Method
151
+ - call_method(module_name, method_name, *args)
152
+ - get_module(module_name) → imported module object (aliased safely as name with dots replaced by underscores)
153
+ - get_class(module_name, class_name)
154
+ - class_new_obj(module_name, class_name, args={}) → instantiate class with keyword args
155
+ - exec(script) → PyCall.exec(script) one-liner
156
+
157
+ ---
158
+
159
+ ## Scripting: run ad‑hoc Python text
160
+
161
+ script(text, variables = {}) runs a Python script in a subprocess and returns the value of the Python variable result. It:
162
+
163
+ - Serializes provided variables into Python assignments at the top of the script:
164
+ - Ruby primitives (String, Numeric, true/false/nil) mapped to Python literals.
165
+ - Arrays and Hashes transformed recursively.
166
+ - TSV values are written to a temporary file and loaded using the companion Python function scout.tsv(file), yielding a pandas DataFrame.
167
+ - Appends a “save result” snippet to persist the result to a temporary file (default: Pickle).
168
+ - Executes the script with python3 via CMD.cmd_log, wiring PYTHONPATH from ScoutPython.paths.
169
+ - Loads the result back (default: pickled result via python/pickle gem) and returns it.
170
+
171
+ By default, pickle is used:
172
+ - save_script_result_pickle(file) → Python code to pickle.dump(result, file)
173
+ - load_pickle(file) → Ruby loads via Python::Pickle
174
+
175
+ Alternatively, you can alias to JSON-based persistence:
176
+ - save_script_result_json(file)
177
+ - load_json(file)
178
+ - Overwrite aliases if desired:
179
+ ```ruby
180
+ class << ScoutPython
181
+ alias save_script_result save_script_result_json
182
+ alias load_result load_json
183
+ end
184
+ ```
185
+
186
+ Examples (from tests):
187
+ ```ruby
188
+ # Simple arithmetic
189
+ res = ScoutPython.script <<~PY, value: 2
190
+ result = value * 3
191
+ PY
192
+ res # => 6
193
+
194
+ # Using pandas via the 'scout' Python helper
195
+ tsv = TSV.setup({}, "Key~ValueA,ValueB#:type=:list")
196
+ tsv["k1"] = %w[a1 b1]; tsv["k2"] = %w[a2 b2]
197
+
198
+ TmpFile.with_file(tsv.to_s) do |tsv_file|
199
+ TmpFile.with_file do |target|
200
+ res = ScoutPython.script <<~PY, file: tsv_file, target: target
201
+ import scout
202
+ df = scout.tsv(file)
203
+ result = df.loc["k2", "ValueB"]
204
+ scout.save_tsv(target, df)
205
+ PY
206
+ res # => "b2"
207
+ TSV.open(target, type: :list)["k2"]["ValueB"] # => "b2"
208
+ end
209
+ end
210
+
211
+ # Pass a TSV directly; script receives a pandas DataFrame
212
+ res = ScoutPython.script <<~PY, df: tsv, target: target
213
+ result = df.loc["k2", "ValueB"]
214
+ scout.save_tsv(target, df)
215
+ PY
216
+ ```
217
+
218
+ Errors:
219
+ - If the Python subprocess fails (syntax error, exception), script raises ConcurrentStreamProcessFailed (via CMD and ConcurrentStream), with stderr logged if logging enabled.
220
+
221
+ ---
222
+
223
+ ## Iteration utilities
224
+
225
+ Helpers to traverse Python iterables from Ruby, with optional progress bars:
226
+
227
+ - iterate(iterator, bar: nil|true|String) { |elem| ... } → nil
228
+ - Accepts PyCall iterable objects (with __iter__/__next__) or indexable sequences.
229
+ - bar: true creates a default bar; String sets desc.
230
+ - iterate_index(sequence, bar: ...) { |elem| ... } → nil
231
+ - Index-based loop using len and [i].
232
+ - collect(iterator, bar: ...) { |elem| block.call(elem) } → Array
233
+ - Convenience to map Python iterables to Ruby arrays.
234
+
235
+ StopIteration is respected and terminates traversal. Errors mark the bar as error and re-raise.
236
+
237
+ ---
238
+
239
+ ## Data conversion and pandas helpers
240
+
241
+ Low-level converters:
242
+ - py2ruby_a(listlike) / to_a → convert PyCall::List-like into a Ruby Array.
243
+ - list2ruby(list) → deep-convert nested Py lists to Ruby arrays.
244
+ - numpy2ruby(numpy_array) → numpy.ndarray.tolist to Ruby arrays.
245
+ - obj2hash(py_mapping) → build a Ruby Hash by iterating keys and indexing values.
246
+
247
+ pandas ↔ TSV:
248
+ - tsv2df(tsv) → pandas.DataFrame with:
249
+ - index: tsv.keys, columns: tsv.fields, df.columns.name = tsv.key_field.
250
+ - df2tsv(df, options = {})
251
+ - Default options[:type] = :list
252
+ - Builds a TSV with key_field = df.columns.name (or provided), fields from df.columns, and values from df rows.
253
+
254
+ Example (from tests):
255
+ ```ruby
256
+ tsv = TSV.setup([], key_field: "Key", fields: %w(Value1 Value2), type: :list)
257
+ tsv["k1"] = %w(V1_1 V2_1)
258
+ tsv["k2"] = %w(V1_2 V2_2)
259
+
260
+ df = ScoutPython.tsv2df(tsv)
261
+ new_tsv = ScoutPython.df2tsv(df)
262
+ new_tsv == tsv # => true
263
+ ```
264
+
265
+ ---
266
+
267
+ ## Python-side helper package (scout)
268
+
269
+ The repository also ships a small Python package named scout (python/scout), intended to be importable from Python code run by ScoutPython or independently.
270
+
271
+ Top-level utilities (scout/__init__.py):
272
+ - cmd(ruby_string=nil) → execute Ruby via rbbt_exec.rb; returns stdout.
273
+ - libdir() → resolve Ruby lib directory via cmd.
274
+ - add_libdir() → prepend libdir/python to sys.path.
275
+ - path(subdir=None, base_dir=None) → convenience location helper ("base" uses ~/.rbbt, "lib" uses libdir).
276
+ - read(subdir, base_dir=None) → read a file via path.
277
+ - inspect(obj) / rich(obj) → inspection helpers (rich requires rich).
278
+ - tsv_header/tsv_preamble/tsv_pandas(tsv_path, ...) → read TSV respecting Scout headers (:sep, :type, headers).
279
+ - tsv(*args, **kwargs) → alias to tsv_pandas.
280
+ - save_tsv(filename, df, key=None) → write pandas DataFrame to TSV with header and key (index_label).
281
+ - save_job_inputs(data: dict) → materialize Python values into files for Workflow inputs.
282
+ - run_job(workflow, task, name='Default', fork=False, clean=False, **kwargs) → shell out to CLI (`rbbt workflow task`) to execute or fork a job and return path or value.
283
+
284
+ Notes:
285
+ - The CLI used in run_job is named rbbt in this package; in Scout-based installs, a compatible executable should be available (often named scout). Setup a symlink or adjust if needed.
286
+
287
+ ### Workflow wrapper (local)
288
+
289
+ Pythonic interface to run workflows and get results (python/scout/workflow.py):
290
+
291
+ - Workflow(name)
292
+ - tasks() → list of task names.
293
+ - task_info(name) → JSON string from Workflow.task_info.
294
+ - run(task, **kwargs) → execute and return stdout (uses run_job).
295
+ - fork(task, **kwargs) → submit with fork=True, return a Step(path).
296
+
297
+ - Step(path)
298
+ - info() → job info (JSON parsed via Ruby Step.load(path).info).
299
+ - status(), done(), error(), aborted()
300
+ - join() → poll until completion.
301
+ - load() → load job result via Ruby Step.load(path).load.
302
+
303
+ Example (python/test.py):
304
+ ```python
305
+ if __name__ == "__main__":
306
+ import sys
307
+ sys.path.append('python')
308
+ import scout
309
+ import scout.workflow
310
+ wf = scout.workflow.Workflow('Baking')
311
+ step = wf.fork('bake_muffin_tray', add_blueberries=True, clean='recursive')
312
+ step.join()
313
+ print(step.load())
314
+ ```
315
+
316
+ ### Remote workflows over HTTP
317
+
318
+ A minimal client for remote Workflow services (python/scout/workflow/remote.py):
319
+
320
+ - RemoteWorkflow(url)
321
+ - init_remote_tasks() → populate available tasks.
322
+ - task_info(name) → JSON
323
+ - job(task, **kwargs) → start a job, returns RemoteStep.
324
+
325
+ - RemoteStep(url)
326
+ - info(), status()
327
+ - done(), error(), running()
328
+ - wait(time=1)
329
+ - raw() → GET bytes of result
330
+ - json() → GET JSON result
331
+
332
+ Requests are made via requests; results are obtained by GET/POST using a _format parameter (raw/json).
333
+
334
+ ---
335
+
336
+ ## Error handling, logging, and threading
337
+
338
+ - Logging:
339
+ - run_log and run_log_stderr wrap execution in Log.trap_std/stderr; stdout/stderr lines are routed to the Scout Log with chosen severity.
340
+ - script:
341
+ - Uses CMD.cmd_log to launch python3; stderr is logged; non-zero exit raises ConcurrentStreamProcessFailed on join (see CMD and ConcurrentStream docs).
342
+ - Threaded execution:
343
+ - A dedicated thread processes queued blocks (Queue IN/OUT).
344
+ - stop_thread sends a :stop sentinel, joins or kills the thread, triggers GC, and calls PyCall.finalize if available.
345
+ - At exit, ScoutPython attempts to stop non-main threads, run GC while Python is still initialized, and touch PyCall.builtins.object to validate GIL access.
346
+
347
+ ---
348
+
349
+ ## API quick reference
350
+
351
+ Path management:
352
+ - ScoutPython.paths → Array of Python paths
353
+ - ScoutPython.add_path(path)
354
+ - ScoutPython.add_paths(paths)
355
+ - ScoutPython.process_paths
356
+
357
+ Execution:
358
+ - ScoutPython.run(mod=nil, imports=nil) { ... }
359
+ - ScoutPython.run_simple(mod=nil, imports=nil) { ... }
360
+ - ScoutPython.run_direct(mod=nil, imports=nil) { ... }
361
+ - ScoutPython.run_threaded(mod=nil, imports=nil) { ... }
362
+ - ScoutPython.stop_thread
363
+ - ScoutPython.run_log(mod=nil, imports=nil, severity=0, severity_err=nil) { ... }
364
+ - ScoutPython.run_log_stderr(mod=nil, imports=nil, severity=0) { ... }
365
+
366
+ Binding/import helpers:
367
+ - ScoutPython.new_binding
368
+ - ScoutPython.binding_run(binding=nil, *args) { ... }
369
+ - ScoutPython.import_method(module_name, method_name, as=nil)
370
+ - ScoutPython.call_method(module_name, method_name, *args)
371
+ - ScoutPython.get_module(module_name)
372
+ - ScoutPython.get_class(module_name, class_name)
373
+ - ScoutPython.class_new_obj(module_name, class_name, args={})
374
+ - ScoutPython.exec(script)
375
+
376
+ Iteration:
377
+ - ScoutPython.iterate(iterator, bar: nil|true|String) { |elem| ... }
378
+ - ScoutPython.iterate_index(sequence, bar: ...) { |elem| ... }
379
+ - ScoutPython.collect(iterator, bar: ...) { |elem| ... } → Array
380
+
381
+ Scripting:
382
+ - ScoutPython.script(text, variables={}) → result
383
+ - ScoutPython.save_script_result_pickle(file) / load_pickle(file)
384
+ - ScoutPython.save_script_result_json(file) / load_json(file)
385
+ - Aliases (defaults): save_script_result → pickle; load_result → load_pickle
386
+
387
+ Data conversion:
388
+ - ScoutPython.py2ruby_a(obj) / ScoutPython.to_a(obj)
389
+ - ScoutPython.list2ruby(list)
390
+ - ScoutPython.numpy2ruby(numpy_array)
391
+ - ScoutPython.obj2hash(py_mapping)
392
+ - ScoutPython.tsv2df(tsv) → pandas.DataFrame
393
+ - ScoutPython.df2tsv(df, options={}) → TSV
394
+
395
+ Python package (scout):
396
+ - scout.tsv(path, ...) → pandas DataFrame (header-aware)
397
+ - scout.save_tsv(path, df, key=None)
398
+ - scout.run_job(workflow, task, name='Default', fork=False, clean=False, **inputs) → job path or stdout
399
+ - scout.workflow.Workflow(name).run/fork/tasks/task_info
400
+ - scout.workflow.Step(path).info/status/join/load
401
+ - scout.workflow.remote.RemoteWorkflow/RemoteStep for HTTP services
402
+
403
+ ---
404
+
405
+ ## Examples
406
+
407
+ Direct PyCall use with imports:
408
+ ```ruby
409
+ # Print sys.path in a background Python thread
410
+ ScoutPython.run_threaded :sys do
411
+ paths = sys.path()
412
+ puts paths
413
+ end
414
+ ScoutPython.stop_thread
415
+ ```
416
+
417
+ Script with result:
418
+ ```ruby
419
+ res = ScoutPython.script <<~PY, value: 2
420
+ result = value * 3
421
+ PY
422
+ # => 6
423
+ ```
424
+
425
+ Script with TSV and pandas:
426
+ ```ruby
427
+ tsv = TSV.setup({}, "Key~ValueA,ValueB#:type=:list")
428
+ tsv["k1"] = ["a1", "b1"]; tsv["k2"] = ["a2", "b2"]
429
+
430
+ TmpFile.with_file do |target|
431
+ res = ScoutPython.script <<~PY, df: tsv, target: target
432
+ result = df.loc["k2", "ValueB"]
433
+ scout.save_tsv(target, df)
434
+ PY
435
+ res # => "b2"
436
+ TSV.open(target, type: :list)["k2"]["ValueB"] # => "b2"
437
+ end
438
+ ```
439
+
440
+ Numpy conversion:
441
+ ```ruby
442
+ ra = ScoutPython.run :numpy, as: :np do
443
+ na = np.array([[[1,2,3], [4,5,6]]])
444
+ ScoutPython.numpy2ruby(na)
445
+ end
446
+ ra[0][1][2] # => 6
447
+ ```
448
+
449
+ pandas ↔ TSV:
450
+ ```ruby
451
+ tsv = TSV.setup([], key_field: "Key", fields: %w(Value1 Value2), type: :list)
452
+ tsv["k1"] = %w(V1_1 V2_1)
453
+ tsv["k2"] = %w(V1_2 V2_2)
454
+
455
+ df = ScoutPython.tsv2df(tsv)
456
+ new_tsv = ScoutPython.df2tsv(df)
457
+ new_tsv == tsv # => true
458
+ ```
459
+
460
+ Binding-local imports:
461
+ ```ruby
462
+ ScoutPython.binding_run do
463
+ pyimport :torch
464
+ # torch is available here
465
+ end
466
+ # torch is not defined here
467
+ ```
468
+
469
+ Python-side Workflow usage:
470
+ ```python
471
+ import scout.workflow as sw
472
+
473
+ wf = sw.Workflow('Baking')
474
+ print(wf.tasks())
475
+ step = wf.fork('bake_muffin_tray', add_blueberries=True, clean='recursive')
476
+ step.join()
477
+ print(step.load())
478
+ ```
479
+
480
+ ---
481
+
482
+ ScoutPython gives you a compact, production-friendly toolbox to interoperate with Python: safe imports, threaded or synchronous execution, TSV/pandas integration, and Workflow orchestration from both Ruby and Python. Use run/script for quick integrations, the conversion helpers to pass data efficiently, and the Python scout package to drive Scout Workflows from Python environments.
@@ -31,6 +31,7 @@ module ScoutPython
31
31
 
32
32
  self.thread ||= Thread.new do
33
33
  require 'pycall'
34
+ ScoutPython.init_scout
34
35
  ScoutPython.process_paths
35
36
  begin
36
37
  while block = QUEUE_IN.pop
@@ -48,8 +49,6 @@ module ScoutPython
48
49
  rescue Exception
49
50
  Log.exception $!
50
51
  raise $!
51
- ensure
52
- PyCall.finalize if PyCall.respond_to?(:finalize)
53
52
  end
54
53
  end
55
54
  end
@@ -64,9 +63,14 @@ module ScoutPython
64
63
 
65
64
  def self.stop_thread
66
65
  self.synchronize do
67
- QUEUE_IN.push :stop
68
- end if self.thread && self.thread.alive?
69
- self.thread.join if self.thread
66
+ if self.thread && self.thread.alive?
67
+ QUEUE_IN.push :stop
68
+ self.thread.join(2) || self.thread.kill
69
+ GC.start
70
+ PyCall.finalize if PyCall.respond_to?(:finalize)
71
+ end
72
+ self.thread = nil
73
+ end
70
74
  end
71
75
 
72
76
  def self.run_direct(mod = nil, imports = nil, &block)
@@ -104,8 +108,13 @@ module ScoutPython
104
108
  end
105
109
  end
106
110
 
107
- class << self
108
- alias run run_simple
111
+ def self.run(...)
112
+ begin
113
+ ScoutPython.init_scout
114
+ run_simple(...)
115
+ ensure
116
+ GC.start
117
+ end
109
118
  end
110
119
 
111
120
  def self.run_log(mod = nil, imports = nil, severity = 0, severity_err = nil, &block)
@@ -107,4 +107,9 @@ if result is not None:
107
107
  end
108
108
  end
109
109
  end
110
+
111
+ def self.run_file(file, arg_str)
112
+ path_env = ScoutPython.paths * ":"
113
+ CMD.cmd("env PYTHONPATH=#{path_env} python '#{file}' #{arg_str}")
114
+ end
110
115
  end
data/lib/scout/python.rb CHANGED
@@ -12,12 +12,28 @@ module ScoutPython
12
12
 
13
13
  def self.init_scout
14
14
  if ! defined?(@@__init_scout_python) || ! @@__init_scout_python
15
+ PyCall.init
16
+
15
17
  ScoutPython.process_paths
16
- res = ScoutPython.run do
18
+ res = ScoutPython.run_direct do
17
19
  Log.debug "Loading python 'scout' module into pycall ScoutPython module"
18
20
  pyimport("scout")
19
21
  end
20
22
  @@__init_scout_python = true
23
+
24
+ at_exit do
25
+ (Thread.list - [Thread.current]).each { |t| t.kill }
26
+ (Thread.list - [Thread.current]).each { |t| t.join rescue nil }
27
+
28
+ # GC while Python is still initialized so PyCall can safely acquire the GIL
29
+ GC.start
30
+
31
+ # (Optional) tiny no-op to ensure GIL path is healthy
32
+ begin
33
+ PyCall.builtins.object
34
+ rescue => _
35
+ end
36
+ end
21
37
  end
22
38
  end
23
39
 
@@ -0,0 +1,59 @@
1
+ module PythonWorkflow
2
+ def self.build_python_argv(py_params, values)
3
+ argv = []
4
+ py_params.each do |p|
5
+ name = p['name']
6
+ ptype = p['type']
7
+ default = p['default']
8
+ required = p['required']
9
+ val = values[name]
10
+
11
+ if val.nil?
12
+ next unless required
13
+ next
14
+ end
15
+
16
+ flag = "--#{name}"
17
+ if ptype.start_with?('list[')
18
+ # Accept several input formats for lists:
19
+ # - Ruby Array
20
+ # - Comma-separated string: "a,b,c"
21
+ # - Path to a file: "file.txt" -> read lines
22
+ items = []
23
+ if val.is_a?(String)
24
+ # If file exists, read lines
25
+ if File.exist?(val)
26
+ items = File.readlines(val, chomp: true)
27
+ elsif val.include?(',')
28
+ items = val.split(',').map(&:strip)
29
+ else
30
+ items = [val]
31
+ end
32
+ elsif val.respond_to?(:to_ary)
33
+ items = Array(val).map(&:to_s)
34
+ else
35
+ items = [val.to_s]
36
+ end
37
+
38
+ # pass flag once followed by all items (argparse with nargs='+' expects this)
39
+ argv << flag
40
+ items.each do |x|
41
+ argv << x.to_s
42
+ end
43
+ elsif ptype == 'boolean'
44
+ if default == true
45
+ argv << "--no-#{name}" unless val
46
+ else
47
+ argv << flag if val
48
+ end
49
+ else
50
+ # For scalar inputs: if given a file path and the file exists, pass the path
51
+ # as-is (the Python side can decide how to handle it). Keep quoting to
52
+ # preserve spaces when later shell-joining.
53
+ argv << flag
54
+ argv << val.to_s
55
+ end
56
+ end
57
+ argv
58
+ end
59
+ end