scout-rig 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d34d9beba93482d6fdac2fae8ccf7c2a7dec45c36ed2a4f108887d9d32b98115
4
- data.tar.gz: 6e47a0cf50320930b9781ece3496b17f9fcd62ad5f21b92ced385902392d5b48
3
+ metadata.gz: e217da5dacfbc60fa8f10fdeb62671ae53ecb74b537f422a61b9bdd0d68703a2
4
+ data.tar.gz: f8e6e1431abcc659d67f265270a114948c6d8f38e2430c03feb4ca35d9132ed2
5
5
  SHA512:
6
- metadata.gz: 499c0b0c768e4ab955175244fb7ec5066503232d05bd2faf978aa5d4e8bcb364afe83b05b4e7d27f65e8c6ca3bdc225b96568d2d576992415832fee68f636b86
7
- data.tar.gz: a30bcba9ce89f5f1622b89ffc0bd7a01971f4dbbba7c546321883e5d2ff74148a1529ab8dd044381b22f4ff98e64dce7fe3053f92a6e45f3f9a78dcd0be55b88
6
+ metadata.gz: c1a584e51e233aca2a640e9d4b9a5dc1dda24a96d17bad349d3d4e287d5e43a0a6e5092412dd0f9158cb88511266c5f4831f28788eccfc5aa94f2488e5c0b697
7
+ data.tar.gz: 28218f64da5fd31fe30ede23cb181cc9a9cd67fc5c768a7312672ac75321431125cf39be76df37511dbd2e5e62c6879f1cc0ffa10c2704f665c90ed2defa7438
data/.vimproject CHANGED
@@ -3,6 +3,7 @@ scout-rig=/$PWD filter="*" {
3
3
  README.rdoc
4
4
  Rakefile
5
5
  chats=chats{
6
+
6
7
  documenter.rb
7
8
 
8
9
  python_workflow
@@ -39,7 +40,6 @@ scout-rig=/$PWD filter="*" {
39
40
  }
40
41
  python=python{
41
42
  task=task{
42
- hello.py
43
43
  }
44
44
  test.py
45
45
  scout=scout{
@@ -47,9 +47,12 @@ scout-rig=/$PWD filter="*" {
47
47
  runner.py
48
48
  workflow.py
49
49
  workflow=workflow{
50
- definition.py
51
50
  remote.py
52
51
  }
53
52
  }
54
53
  }
54
+ doc=doc{
55
+ Python.md
56
+ PythonWorkflow.md
57
+ }
55
58
  }
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.1
1
+ 0.2.2
@@ -0,0 +1,163 @@
1
+ PythonWorkflow lets you define Ruby Scout workflows whose tasks are implemented as standalone Python functions.
2
+
3
+ This module is meant for workflow authors who prefer to write task logic in Python, while still benefiting from Scout/Rbbt features such as dependency management, persistence, provenance, and CLI integration.
4
+
5
+ A Python-backed task is defined in a `.py` script (typically under a `python/task` directory). The script registers one or more functions using `scout.task(...)`. The Ruby workflow then calls `python_task` to import those function definitions as regular Scout tasks.
6
+
7
+ Key ideas
8
+
9
+ - Python tasks are ordinary Python functions with type hints, defaults, and a docstring.
10
+ - The Python script can be run on its own:
11
+ - `--scout-metadata` prints machine-readable JSON task metadata (consumed by Ruby to define inputs and return types).
12
+ - without `--scout-metadata` it behaves like a CLI that runs the function.
13
+ - Ruby side (`PythonWorkflow`) reads metadata and auto-creates Workflow inputs and tasks.
14
+ - At execution time, the Ruby task block runs the Python script in a subprocess using `ScoutPython.run_file`.
15
+
16
+ Minimal directory layout
17
+
18
+ - `workflow.rb` defines the Ruby workflow.
19
+ - `python/task/<name>.py` defines one or more Python functions and registers them.
20
+
21
+ Example Python task
22
+
23
+ ```python
24
+ import scout
25
+
26
+ def hello(name: str, excited: bool = False) -> str:
27
+ """
28
+ Generate a greeting.
29
+
30
+ Args:
31
+ name: Name of the person to greet.
32
+ excited: Whether to add an exclamation mark.
33
+
34
+ Returns:
35
+ Greeting text.
36
+ """
37
+ return f"Hello, {name}{'!' if excited else ''}"
38
+
39
+ scout.task(hello)
40
+ ```
41
+
42
+ Example Ruby workflow
43
+
44
+ ```ruby
45
+ require 'scout'
46
+
47
+ module TestPythonWF
48
+ extend Workflow
49
+ extend PythonWorkflow
50
+
51
+ self.name = 'TestPythonWF'
52
+
53
+ python_task :hello
54
+ end
55
+ ```
56
+
57
+ Type mapping (Python metadata to Scout inputs/returns)
58
+
59
+ PythonWorkflow relies on `scout.task` metadata type strings (produced by `python/scout/runner.py`) and maps them to standard Workflow types.
60
+
61
+ - Scalars
62
+ - `string` -> `:string`
63
+ - `integer` -> `:integer`
64
+ - `float` -> `:float`
65
+ - `boolean` -> `:boolean`
66
+ - `binary` -> `:binary`
67
+ - `path` -> `:file` for inputs (passed as a path string on the CLI)
68
+ - Lists
69
+ - `list[string]`, `list[integer]`, `list[float]` -> `:array`
70
+ - `list[path]` -> `:file_array`
71
+
72
+ List inputs in Ruby
73
+
74
+ When building the Python command line, list parameters accept several Ruby-side formats:
75
+
76
+ - A Ruby Array (`['a', 'b', 'c']`)
77
+ - A comma-separated String (`"a,b,c"`)
78
+ - A path to an existing file (the file is read line-by-line and passed as items)
79
+
80
+ Return value decoding
81
+
82
+ The Python runner prints function results to stdout, and Ruby tries to interpret them as follows:
83
+
84
+ - If stdout is valid JSON, it is parsed with `JSON.parse` and returned.
85
+ - Otherwise, if the declared Scout return type is `:array` or `:file_array`, stdout is split on newlines.
86
+ - Otherwise, stdout is returned as a stripped string.
87
+
88
+ This means you can return complex objects from Python, as long as the runner prints JSON and your declared return type can sensibly persist that Ruby value.
89
+
90
+ Python CLI behavior (standalone execution)
91
+
92
+ A Python task file registered via `scout.task(...)` can be used directly as a command-line tool.
93
+
94
+ - Metadata:
95
+ - `python hello.py --scout-metadata`
96
+ - For files that register multiple functions, `--scout-metadata` prints a JSON array of metadata objects.
97
+ - Run:
98
+ - `python hello.py --name Alice --excited`
99
+ - If multiple functions are registered in the same file, you can select one by passing its name as the first positional argument:
100
+ - `python tasks.py hello --name Alice`
101
+
102
+ Python import paths
103
+
104
+ Python tasks are executed as subprocesses with a `PYTHONPATH` composed from `ScoutPython.paths`. These are initialized from `Scout.python.find_all` and can be extended at runtime using `ScoutPython.add_path` or `ScoutPython.add_paths`.
105
+
106
+ # Tasks
107
+
108
+ ## python_task
109
+ Register one or more Python functions as Workflow tasks.
110
+
111
+ `python_task` discovers and reads metadata from a Python script (by running it with `--scout-metadata`) and then defines one Workflow task per function found.
112
+
113
+ Inputs and return type are inferred from the Python function signature and type hints.
114
+
115
+ If the Python script registers multiple functions, multiple Workflow tasks are created (one per registered function). The `task_sym` argument selects the default filename to locate, but does not limit how many functions will be imported from that file.
116
+
117
+ The task execution runs the Python script as a subprocess and passes CLI options that correspond to the declared inputs.
118
+
119
+ ## python_task_dir
120
+ Configure where Python task scripts are discovered.
121
+
122
+ By default, `python_task_dir` is taken from `Scout.python.task.find(:lib)`, so tasks can be shipped as part of a Scout package and located via the Path subsystem.
123
+
124
+ You can override it by setting `self.python_task_dir` in your workflow module to a different Path or directory-like object that supports `[]` indexing and `find_with_extension('py')`.
125
+
126
+ ## scout.task
127
+ Register a Python function as a Scout-compatible task and enable metadata/CLI execution.
128
+
129
+ A Python task script should end with one or more `scout.task(function)` calls. This:
130
+
131
+ - Captures signature, type hints, and docstring to build a metadata object.
132
+ - Parses per-argument documentation primarily from Google-style `Args:` sections.
133
+ - Enables `--scout-metadata` output for Ruby to consume.
134
+ - Enables standalone CLI execution using argparse, including support for list and boolean arguments.
135
+
136
+ For scripts that register multiple functions, `scout.task` defers CLI dispatch until interpreter shutdown so all functions are registered before selecting a target function.
137
+
138
+ Docstring format for parameter descriptions
139
+
140
+ To maximize interoperability with agent/tool frameworks and to provide good CLI help text, write docstrings in Google style.
141
+
142
+ Recommended pattern:
143
+
144
+ ```python
145
+ def my_task(query: str, max_results: int = 10) -> str:
146
+ """
147
+ Search items.
148
+
149
+ Args:
150
+ query: Natural language query describing what to search.
151
+ max_results: Maximum number of results to return.
152
+
153
+ Returns:
154
+ A newline-delimited or JSON-encoded result.
155
+ """
156
+ ```
157
+
158
+ Notes:
159
+
160
+ - The task description is taken from the docstring preamble: everything up to the first `Args:`/`Arguments:` or `Returns:` section.
161
+ - The `Args:` section is used to populate each parameter `help` field in `--scout-metadata`.
162
+ - Multi-line argument descriptions are supported as long as continuation lines stay indented.
163
+ - A NumPy-style `Parameters` section is still accepted as a legacy fallback, but `Args:` is preferred.
@@ -11,4 +11,24 @@ module PythonWorkflow
11
11
  def python_task_dir
12
12
  @python_task_dir ||= Scout.python.task.find(:lib)
13
13
  end
14
+
15
+ def self.load_directory(path = nil, workflow_name = nil)
16
+ workflow = begin
17
+ m = Module.new
18
+ m.extend Workflow
19
+ m.extend PythonWorkflow
20
+ m.name = workflow_name || "PythonWorkflow"
21
+ m.tasks = {}
22
+ m
23
+ end
24
+
25
+ path = Scout.python.task
26
+ workflow.python_task_dir = path
27
+ path.glob_names("*.py").each do |name|
28
+ name = name.sub '.py', ''
29
+ workflow.python_task name
30
+ end
31
+
32
+ workflow
33
+ end
14
34
  end
@@ -1,6 +1,7 @@
1
1
  import argparse
2
2
  import inspect
3
3
  import json
4
+ import re
4
5
  import sys
5
6
  from pathlib import Path
6
7
  from typing import get_origin, get_args, Union, List
@@ -57,9 +58,118 @@ def _required_from_default(default_val, ann) -> bool:
57
58
 
58
59
 
59
60
  def _parse_numpy_params(doc: str) -> dict:
61
+ """
62
+ Extract per-parameter documentation from a docstring.
63
+
64
+ Despite the historical name, this parser prefers Google-style docstrings
65
+ ("Args:") because they map cleanly to tool schemas used by AI agents.
66
+
67
+ Supported formats:
68
+
69
+ Google style (recommended)::
70
+
71
+ Do something.
72
+
73
+ Args:
74
+ name: Description.
75
+ flag: Description that can wrap to
76
+ multiple indented lines.
77
+
78
+ Returns:
79
+ Description.
80
+
81
+ NumPy style (legacy fallback)::
82
+
83
+ Parameters
84
+ ----------
85
+ name : str
86
+ Description.
87
+
88
+ Returns:
89
+ dict mapping parameter name -> description string
90
+ """
91
+
60
92
  if not doc:
61
93
  return {}
94
+
62
95
  lines = doc.splitlines()
96
+
97
+ # -- Google style: Args: / Arguments: -----------------------------------
98
+ def parse_google_args() -> dict:
99
+ params = {}
100
+ i = 0
101
+ # Find section header
102
+ while i < len(lines):
103
+ header = lines[i].strip()
104
+ if header in ("Args:", "Arguments:"):
105
+ i += 1
106
+ break
107
+ i += 1
108
+ else:
109
+ return {}
110
+
111
+ # Parse items until next top-level section (Returns:, Raises:, etc.)
112
+ current = None
113
+ current_desc = []
114
+
115
+ def flush():
116
+ nonlocal current, current_desc
117
+ if current:
118
+ params[current] = " ".join(s.strip() for s in current_desc).strip()
119
+ current = None
120
+ current_desc = []
121
+
122
+ while i < len(lines):
123
+ raw = lines[i]
124
+ stripped = raw.strip()
125
+
126
+ # End conditions: next section header at left margin
127
+ if stripped.endswith(":") and not raw.startswith((" ", "\t")):
128
+ # Example: "Returns:", "Raises:", "Examples:", ...
129
+ break
130
+
131
+ # Skip empty lines between entries
132
+ if stripped == "":
133
+ if current is not None:
134
+ current_desc.append("")
135
+ i += 1
136
+ continue
137
+
138
+ # Item start: indented "name:" or "name (type):"
139
+ # We require indentation so we don't confuse it with section headers.
140
+ if raw.startswith((" ", "\t")):
141
+ # Google-style param line (type is optional and ignored):
142
+ # name: description
143
+ # name (str): description
144
+ m = re.match(r"^\s*([A-Za-z_]\w*)\s*(?:\([^)]*\))?\s*:\s*(.*)$", stripped)
145
+ if m:
146
+ param_name = m.group(1)
147
+ first_desc = m.group(2).strip()
148
+
149
+ if current and param_name != current:
150
+ flush()
151
+ if current is None:
152
+ current = param_name
153
+ if first_desc:
154
+ current_desc.append(first_desc)
155
+ i += 1
156
+ continue
157
+
158
+ # Continuation line (more indented than the item header)
159
+ if current is not None:
160
+ current_desc.append(stripped)
161
+
162
+ i += 1
163
+
164
+ flush()
165
+ # Drop empty descriptions
166
+ return {k: v for k, v in params.items() if v is not None}
167
+
168
+ google = parse_google_args()
169
+ if google:
170
+ return google
171
+
172
+ # -- NumPy style fallback: Parameters / ---------- ----------------------
63
173
  params = {}
64
174
  i = 0
65
175
  while i < len(lines):
@@ -94,13 +204,54 @@ def _parse_numpy_params(doc: str) -> dict:
94
204
  return params
95
205
 
96
206
 
207
+ def _extract_description(docstring: str) -> str:
208
+ """Extract a task description from a docstring.
209
+
210
+ The description is the full docstring preamble (potentially multiple lines
211
+ and paragraphs) up to the first schema-oriented section header.
212
+
213
+ This matches common conventions in LLM tool / JSON-schema extraction:
214
+ the preamble is the capability description, while sections like "Args:" and
215
+ "Returns:" provide structured schema information.
216
+ """
217
+
218
+ if not docstring:
219
+ return ""
220
+
221
+ stop_headers = {
222
+ # Google-style
223
+ "args:", "arguments:", "returns:", "raises:", "examples:", "example:",
224
+ "notes:", "note:",
225
+ # NumPy-style
226
+ "parameters", "returns", "raises", "examples", "notes",
227
+ }
228
+
229
+ out_lines = []
230
+ for line in docstring.splitlines():
231
+ stripped = line.strip()
232
+
233
+ # Stop at first recognized header line
234
+ if stripped and stripped.lower() in stop_headers:
235
+ break
236
+
237
+ out_lines.append(line.rstrip())
238
+
239
+ # Trim leading/trailing blank lines, preserve internal blank lines.
240
+ while out_lines and out_lines[0].strip() == "":
241
+ out_lines.pop(0)
242
+ while out_lines and out_lines[-1].strip() == "":
243
+ out_lines.pop()
244
+
245
+ return "\n".join(out_lines).strip()
246
+
247
+
97
248
  def describe_function(func) -> dict:
98
249
  sig = inspect.signature(func)
99
250
  doc = inspect.getdoc(func) or ""
100
251
  params_doc = _parse_numpy_params(doc)
101
252
 
102
253
  ret_type = _python_type_to_string(sig.return_annotation)
103
- description = doc.split("\n\n", 1)[0] if doc else ""
254
+ description = _extract_description(doc)
104
255
 
105
256
  params = []
106
257
  for name, p in sig.parameters.items():
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.2.1 ruby lib
5
+ # stub: scout-rig 0.2.2 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "scout-rig".freeze
9
- s.version = "0.2.1".freeze
9
+ s.version = "0.2.2".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]
@@ -26,6 +26,7 @@ Gem::Specification.new do |s|
26
26
  "Rakefile",
27
27
  "VERSION",
28
28
  "doc/Python.md",
29
+ "doc/PythonWorkflow.md",
29
30
  "lib/scout-rig.rb",
30
31
  "lib/scout/python.rb",
31
32
  "lib/scout/python/paths.rb",
@@ -51,7 +52,7 @@ Gem::Specification.new do |s|
51
52
  ]
52
53
  s.homepage = "http://github.com/mikisvaz/scout-rig".freeze
53
54
  s.licenses = ["MIT".freeze]
54
- s.rubygems_version = "3.7.2".freeze
55
+ s.rubygems_version = "3.7.0.dev".freeze
55
56
  s.summary = "Scouts rigging things together".freeze
56
57
 
57
58
  s.specification_version = 4
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scout-rig
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miguel Vazquez
@@ -52,6 +52,7 @@ files:
52
52
  - Rakefile
53
53
  - VERSION
54
54
  - doc/Python.md
55
+ - doc/PythonWorkflow.md
55
56
  - lib/scout-rig.rb
56
57
  - lib/scout/python.rb
57
58
  - lib/scout/python/paths.rb
@@ -92,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
93
  - !ruby/object:Gem::Version
93
94
  version: '0'
94
95
  requirements: []
95
- rubygems_version: 3.7.2
96
+ rubygems_version: 3.7.0.dev
96
97
  specification_version: 4
97
98
  summary: Scouts rigging things together
98
99
  test_files: []