scout-essentials 1.8.7 → 1.9.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.
- checksums.yaml +4 -4
- data/.vimproject +26 -12
- data/README.md +83 -112
- data/VERSION +1 -1
- data/doc/Improvements.md +226 -0
- data/doc/StartHere.md +122 -0
- data/doc/developer/AnnotationSystem.md +184 -0
- data/doc/developer/Architecture.md +147 -0
- data/doc/developer/Configuration.md +238 -0
- data/doc/developer/CoreUtilities.md +265 -0
- data/doc/developer/DesignPrinciples.md +129 -0
- data/doc/developer/ErrorHandling.md +203 -0
- data/doc/developer/LockingAndConcurrency.md +157 -0
- data/doc/developer/PathResolution.md +200 -0
- data/doc/developer/PersistenceAndResources.md +119 -0
- data/doc/developer/StreamingModel.md +236 -0
- data/doc/user/AnnotatingData.md +202 -0
- data/doc/user/CachingResults.md +183 -0
- data/doc/user/CommandLineOptions.md +189 -0
- data/doc/user/Cookbook.md +211 -0
- data/doc/user/HandlingStreams.md +236 -0
- data/doc/user/LoggingAndProgress.md +158 -0
- data/doc/user/ProducingResources.md +177 -0
- data/doc/user/RemoteData.md +157 -0
- data/doc/user/RunningCommands.md +218 -0
- data/doc/user/WorkingWithFiles.md +217 -0
- data/lib/scout/cmd.rb +343 -40
- data/lib/scout/concurrent_stream.rb +14 -1
- data/lib/scout/indiferent_hash.rb +1 -1
- data/lib/scout/log/fingerprint.rb +13 -8
- data/lib/scout/log/progress/report.rb +1 -1
- data/lib/scout/log.rb +4 -1
- data/lib/scout/misc/digest.rb +6 -5
- data/lib/scout/misc/format.rb +24 -0
- data/lib/scout/named_array.rb +1 -1
- data/lib/scout/open/stream.rb +2 -2
- data/lib/scout/open/util.rb +8 -4
- data/lib/scout/open.rb +3 -3
- data/lib/scout/path/find.rb +3 -2
- data/lib/scout/persist.rb +14 -10
- data/lib/scout/resource/produce.rb +9 -1
- data/research/annotations-data-analysis.md +206 -0
- data/research/behavior-probes.md +1925 -0
- data/research/commands-streaming-analysis.md +272 -0
- data/research/design-philosophy-analysis.md +383 -0
- data/research/doc-audit-findings.md +294 -0
- data/research/ecosystem-attribution.md +118 -0
- data/research/implementation-inventory-core.md +1029 -0
- data/research/implementation-inventory-open.md +417 -0
- data/research/implementation-inventory-path-persist-resource.md +774 -0
- data/research/io-paths-analysis.md +228 -0
- data/research/persistence-resources-analysis.md +244 -0
- data/research/synthesis-report.md +80 -0
- data/scout-essentials.gemspec +37 -15
- data/test/scout/open/test_remote.rb +1 -2
- data/test/scout/test_cmd.rb +411 -0
- metadata +36 -14
- data/doc/Annotation.md +0 -352
- data/doc/CMD.md +0 -363
- data/doc/ConcurrentStream.md +0 -163
- data/doc/IndiferentHash.md +0 -240
- data/doc/Log.md +0 -235
- data/doc/NamedArray.md +0 -174
- data/doc/Open.md +0 -331
- data/doc/Path.md +0 -217
- data/doc/Persist.md +0 -214
- data/doc/Resource.md +0 -229
- data/doc/SimpleOPT.md +0 -236
- data/doc/TmpFile.md +0 -154
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Investigation: Command Execution and Logging
|
|
2
|
+
|
|
3
|
+
**Status:** Non-normative investigation artifact. May be outdated.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
CMD and Log.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## CMD
|
|
11
|
+
|
|
12
|
+
### What it is
|
|
13
|
+
A unified wrapper for running external commands with:
|
|
14
|
+
- Tool discovery and auto-installation.
|
|
15
|
+
- Streaming (pipe) and blocking (read-all) modes.
|
|
16
|
+
- stdin piping.
|
|
17
|
+
- stderr logging, progress tracking, and capture.
|
|
18
|
+
- sudo, xvfb, pipe-chaining support.
|
|
19
|
+
- SSH through Open (for remote commands).
|
|
20
|
+
- Option-hash-to-flags conversion.
|
|
21
|
+
|
|
22
|
+
### Core API
|
|
23
|
+
```ruby
|
|
24
|
+
# Blocking: returns a StringIO-like result
|
|
25
|
+
result = CMD.cmd("ls -la")
|
|
26
|
+
puts result.read
|
|
27
|
+
|
|
28
|
+
# Streaming (pipe: true): returns a ConcurrentStream
|
|
29
|
+
stream = CMD.cmd("cat huge_file", :pipe => true)
|
|
30
|
+
stream.each { |line| ... }
|
|
31
|
+
|
|
32
|
+
# With tool discovery
|
|
33
|
+
CMD.cmd(:samtools, "view -bS aln.sam")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### `CMD.cmd(tool, cmd = nil, options = {})`
|
|
37
|
+
- `tool` — may be a Symbol registered via `CMD.tool` (auto-install + discovery)
|
|
38
|
+
or a plain String command.
|
|
39
|
+
- `cmd` — optional subcommand appended after the tool.
|
|
40
|
+
- `options` — IndiferentHash with special keys:
|
|
41
|
+
- `:in` — String/IO/StringIO piped to stdin.
|
|
42
|
+
- `:pipe` — if true, return a ConcurrentStream immediately.
|
|
43
|
+
- `:stderr` — severity level (Integer) or boolean.
|
|
44
|
+
- `:progress_bar` — Log::ProgressBar or options hash.
|
|
45
|
+
- `:sudo` — prepend `sudo`.
|
|
46
|
+
- `:xvfb` — run under xvfb-run.
|
|
47
|
+
- `:autojoin` — autojoin the stream on close.
|
|
48
|
+
- `:no_fail` / `:nofail` — don't raise on failure.
|
|
49
|
+
- `:log` — toggle logging (default true).
|
|
50
|
+
- `:save_stderr` — capture stderr text.
|
|
51
|
+
- `:pipe` — output piped to another command.
|
|
52
|
+
- `:post` — callback executed after command completion.
|
|
53
|
+
- Other keys are converted to command-line flags.
|
|
54
|
+
|
|
55
|
+
### Tool system (`CMD.tool` / `CMD.get_tool`)
|
|
56
|
+
```ruby
|
|
57
|
+
CMD.tool(:samtools, nil, nil, "samtools") { install_samtools }
|
|
58
|
+
```
|
|
59
|
+
- `TOOLS` is a global registry: `{ tool => [claim, test, block, cmd] }`.
|
|
60
|
+
- `get_tool(tool)` checks if the tool exists (via `test` or `command -v`).
|
|
61
|
+
- If not found, it tries:
|
|
62
|
+
1. `claim.produce` (if a Resource claim is given).
|
|
63
|
+
2. `block.call` (if a block is given).
|
|
64
|
+
- After installation, version is detected via `scan_version_text`.
|
|
65
|
+
- Tools are cached in `@@init_cmd_tool` to avoid repeated checks.
|
|
66
|
+
|
|
67
|
+
### Option-to-flags conversion (`process_cmd_options`)
|
|
68
|
+
```ruby
|
|
69
|
+
CMD.cmd("grep", :pattern => "foo", :ignore_case => true)
|
|
70
|
+
# → grep --pattern 'foo' --ignore_case
|
|
71
|
+
```
|
|
72
|
+
- Boolean `true` → flag without value.
|
|
73
|
+
- Boolean `false`/`nil` → flag omitted.
|
|
74
|
+
- String → flag with quoted value.
|
|
75
|
+
- `--key=value` → `--key 'value'`.
|
|
76
|
+
- `--key=` → `--key='value'` (equals form).
|
|
77
|
+
- `:add_option_dashes => true` → adds `--` prefix to keys.
|
|
78
|
+
|
|
79
|
+
### SSH / remote
|
|
80
|
+
CMD itself does not handle SSH directly. Use `Open.ssh` or `Open.open`/`Open.read`
|
|
81
|
+
with remote paths (detected via `Open.remote?`). However, CMD commands may
|
|
82
|
+
include ssh as a string.
|
|
83
|
+
|
|
84
|
+
### `CMD.bash`
|
|
85
|
+
```ruby
|
|
86
|
+
CMD.bash(<<~SH)
|
|
87
|
+
set -e
|
|
88
|
+
source ~/.bashrc
|
|
89
|
+
conda activate myenv
|
|
90
|
+
do_stuff
|
|
91
|
+
SH
|
|
92
|
+
```
|
|
93
|
+
Wraps the string in a login shell.
|
|
94
|
+
|
|
95
|
+
### `CMD.cmd_log` / `CMD.cmd_pid`
|
|
96
|
+
Variants that tee stdout/stderr to `Log` in real time, useful for long-running
|
|
97
|
+
commands.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Log
|
|
102
|
+
|
|
103
|
+
### What it is
|
|
104
|
+
A thread-safe, severity-based logging system with colored output, progress
|
|
105
|
+
bars, exception formatting, and support for redirecting output to a file or
|
|
106
|
+
IO.
|
|
107
|
+
|
|
108
|
+
### Severity levels
|
|
109
|
+
```ruby
|
|
110
|
+
Log::DEBUG = 0
|
|
111
|
+
Log::LOW = 1
|
|
112
|
+
Log::MEDIUM = 2
|
|
113
|
+
Log::HIGH = 3
|
|
114
|
+
Log::INFO = 4
|
|
115
|
+
Log::WARN = 5
|
|
116
|
+
Log::ERROR = 6
|
|
117
|
+
Log::NONE = 7
|
|
118
|
+
```
|
|
119
|
+
Messages are only emitted if `severity >= Log.severity`.
|
|
120
|
+
|
|
121
|
+
### Configuration
|
|
122
|
+
- `Log.severity = Log::DEBUG` — set threshold (Integer).
|
|
123
|
+
- `SCOUT_LOG` environment variable — DEBUG, LOW, MEDIUM, HIGH, WARN, ERROR, NONE.
|
|
124
|
+
- `~/.scout/etc/log_severity` file — numeric severity.
|
|
125
|
+
- `Log.logfile(path)` — redirect all log output to a file.
|
|
126
|
+
- `Log.nocolor = true` — disable ANSI colors.
|
|
127
|
+
|
|
128
|
+
### Log methods
|
|
129
|
+
```ruby
|
|
130
|
+
Log.debug("Detailed info") # DEBUG
|
|
131
|
+
Log.low("Slightly important") # LOW
|
|
132
|
+
Log.medium("Medium importance") # MEDIUM
|
|
133
|
+
Log.high("Important") # HIGH
|
|
134
|
+
Log.info("User-facing info") # INFO
|
|
135
|
+
Log.warn("Warning") # WARN
|
|
136
|
+
Log.error("Error") # ERROR
|
|
137
|
+
Log.exception(e) # ERROR + backtrace
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Block form (lazy evaluation)
|
|
141
|
+
```ruby
|
|
142
|
+
Log.debug { "Computed value: #{expensive_computation()}" }
|
|
143
|
+
```
|
|
144
|
+
The block is only evaluated if the severity threshold is met. Use this for
|
|
145
|
+
expensive-to-compute log messages.
|
|
146
|
+
|
|
147
|
+
### Thread-safety
|
|
148
|
+
- `MUTEX = Mutex.new` guards `log_write` and `log_puts`.
|
|
149
|
+
- Multiple threads logging simultaneously are safe.
|
|
150
|
+
- The ProgressBar system uses `BAR_MUTEX` for its own state.
|
|
151
|
+
|
|
152
|
+
### Log::ProgressBar
|
|
153
|
+
A sophisticated multi-bar progress tracking system:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
Log::ProgressBar.with_bar(1000, :desc => "Processing") do |bar|
|
|
157
|
+
1000.times do |i|
|
|
158
|
+
# ... work ...
|
|
159
|
+
bar.tick
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Features:
|
|
165
|
+
- **Stacked bars** — multiple concurrent bars rendered with vertical stacking
|
|
166
|
+
using ANSI cursor movement (`up_lines`/`down_lines`).
|
|
167
|
+
- **Throughput estimation** — short-term and long-term rate, ETA calculation.
|
|
168
|
+
- **Auto max-guessing** — `with_obj_bar` infers the max from file size
|
|
169
|
+
(`wc -l`), TSV/Array/Hash length, etc.
|
|
170
|
+
- **Persistence** — bar state can be saved to/reloaded from a file.
|
|
171
|
+
- **Depth tracking** — nested bars track their position in the stack.
|
|
172
|
+
- **Silencing** — `SILENCED` array hides specific bars from rendering.
|
|
173
|
+
- **Callback chains** — `callback` procs executed on completion.
|
|
174
|
+
|
|
175
|
+
#### Key methods
|
|
176
|
+
- `Log::ProgressBar.with_bar(max, options) { |bar| ... }` — main entry point.
|
|
177
|
+
- `bar.tick(n = 1)` — increment by n.
|
|
178
|
+
- `bar.pos(n)` — set absolute position.
|
|
179
|
+
- `bar.process(elem)` — call `@process` callback, then tick based on return.
|
|
180
|
+
- `bar.done` — print completion summary.
|
|
181
|
+
- `bar.error` — print error summary.
|
|
182
|
+
- `bar.remove` / `remove_bar` — clean up bar from display.
|
|
183
|
+
- `with_obj_bar(obj, desc_or_max) { |bar| ... }` — auto-infer max from object.
|
|
184
|
+
|
|
185
|
+
#### Bar removal
|
|
186
|
+
- On normal completion: `bar.done` then `remove_bar`.
|
|
187
|
+
- On exception: `bar.error` then `remove_bar`.
|
|
188
|
+
- `KeepBar` exception — prevents removal for debugging.
|
|
189
|
+
|
|
190
|
+
### Log::Color
|
|
191
|
+
ANSI color codes for severity levels and general use:
|
|
192
|
+
```ruby
|
|
193
|
+
Log.color(:yellow, "warning text")
|
|
194
|
+
Log.color(Log::INFO, "informational")
|
|
195
|
+
```
|
|
196
|
+
- `nocolor?` — checks if colors are disabled.
|
|
197
|
+
- Colors are defined in `log/color_class.rb` with a mapping from severity → ANSI code.
|
|
198
|
+
- `Log.uncolor(text)` — strips ANSI codes.
|
|
199
|
+
|
|
200
|
+
### Log::Trap
|
|
201
|
+
```ruby
|
|
202
|
+
Log::Trap.trap('SIGINT') { ... }
|
|
203
|
+
```
|
|
204
|
+
Safely intercepts signals. Stores handlers for restore. Ensures progress bars
|
|
205
|
+
are cleaned up on signal.
|
|
206
|
+
|
|
207
|
+
### Log::fingerprint
|
|
208
|
+
```ruby
|
|
209
|
+
Log.fingerprint(obj)
|
|
210
|
+
```
|
|
211
|
+
Produces a compact, human-readable representation of an object for logging:
|
|
212
|
+
- String → the string (truncated if long).
|
|
213
|
+
- Array → `[a, b, ...]` (truncated).
|
|
214
|
+
- Hash → `{k => v, ...}` (truncated).
|
|
215
|
+
- Path → the path string.
|
|
216
|
+
- Other → `inspect`.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Cross-module interactions
|
|
221
|
+
|
|
222
|
+
- **CMD depends on Log** — for stderr logging, debug logging, and progress bars.
|
|
223
|
+
- **CMD depends on ConcurrentStream** — for stream lifecycle.
|
|
224
|
+
- **CMD depends on Open** — for remote operations and `consume_stream`.
|
|
225
|
+
- **Log depends on IndiferentHash** — `process_options` in ProgressBar.
|
|
226
|
+
- **Log::ProgressBar depends on CMD** — `guess_obj_max` uses `CMD.cmd("wc -l")`.
|
|
227
|
+
- **Log depends on Misc** — `format_seconds`, `fixutf8`.
|
|
228
|
+
- **All modules use Log** — it is the foundational logging layer.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Gotchas and warnings
|
|
233
|
+
|
|
234
|
+
1. **CMD.cmd with `:pipe => true` returns immediately** — the command may
|
|
235
|
+
still be running when you receive the stream. You must `join` the stream
|
|
236
|
+
to wait for completion.
|
|
237
|
+
2. **CMD.cmd default stderr level is Log::DEBUG** — by default, stderr is
|
|
238
|
+
logged at DEBUG severity. Use `:stderr => Log::HIGH` to make it visible at
|
|
239
|
+
default severity.
|
|
240
|
+
3. **CMD option keys are validated** — `process_cmd_options` raises if an
|
|
241
|
+
option key contains characters outside `[a-z_0-9\-=.]+`. This prevents
|
|
242
|
+
shell injection.
|
|
243
|
+
4. **Log.severity is global** — `Log.severity = Log::DEBUG` affects all
|
|
244
|
+
threads. Use `Log.with_severity(level) { ... }` for scoped severity changes.
|
|
245
|
+
This is thread-safe via a thread-local pattern.
|
|
246
|
+
5. **Log::ProgressBar bars are global** — the `BARS` array is class-level.
|
|
247
|
+
Nested calls add bars. Bars from different threads may interleave in
|
|
248
|
+
display.
|
|
249
|
+
6. **Log.debug block form is not always lazy** — `Log.debug { "..." }`
|
|
250
|
+
evaluates the block only if severity >= DEBUG, but `Log.debug("..." )`
|
|
251
|
+
always evaluates the string. For expensive messages, use the block form.
|
|
252
|
+
7. **CMD.cmd missing tool** — if a tool is not found and no install block is
|
|
253
|
+
given, the command will fail with a `ProcessFailed` exception. Check
|
|
254
|
+
`CMD.versions` for known tools.
|
|
255
|
+
8. **Log.nocolor detection** — checks `ENV['SCOUT_NO_COLOR']`, output not a
|
|
256
|
+
tty, and `Log.nocolor` flag. Colors may be unexpectedly disabled in
|
|
257
|
+
pipelines.
|
|
258
|
+
9. **CMD.cmd option escaping** — single quotes are escaped (`'` → `\'`), but
|
|
259
|
+
other shell metacharacters are not escaped by default. This is by design
|
|
260
|
+
(commands may need them), but means user-supplied option values must be
|
|
261
|
+
sanitized.
|
|
262
|
+
10. **Log.exception NOLOG/NOSTACK** — exception messages containing "NOLOG"
|
|
263
|
+
or "NOSTACK" will skip logging or backtrace printing. This is an internal
|
|
264
|
+
convention, not documented.
|
|
265
|
+
11. **CMD.cmd with no_fail:true and pipe:true** — the stream will have
|
|
266
|
+
`no_fail` set, meaning `join` won't raise on process failure. Check
|
|
267
|
+
`stream.exit_status` to verify success.
|
|
268
|
+
12. `CMD.cmd("grep", :pattern => "foo")` generates `grep --pattern 'foo'`.
|
|
269
|
+
The dashes are auto-added via `:add_option_dashes`. To suppress, pass
|
|
270
|
+
`:add_option_dashes => false`. Check current default: it appears to be
|
|
271
|
+
falsy by default, so `CMD.cmd("grep", "--pattern" => "foo")` may be
|
|
272
|
+
needed.
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# Investigation: Design Philosophy and Cross-Cutting Patterns
|
|
2
|
+
|
|
3
|
+
**Status:** Non-normative investigation artifact. May be outdated.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
Cross-cutting design principles that make scout-essentials elegant and
|
|
7
|
+
expressive, identified by analyzing patterns across all modules.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Principle 1: Annotate, don't wrap
|
|
12
|
+
|
|
13
|
+
**The single most important principle.** When you have a piece of data that
|
|
14
|
+
already has a natural Ruby type (String, Array, Hash, IO), you do not create
|
|
15
|
+
a wrapper class. Instead, you annotate it.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# GOOD — annotate an existing object
|
|
19
|
+
path = Path.setup("data/file.tsv") # path.class == String
|
|
20
|
+
gene = Gene.setup("BRCA1", :organism, "Hsa") # gene.class == String
|
|
21
|
+
|
|
22
|
+
# WRONG — create a wrapper class
|
|
23
|
+
class MyPath < String; end # breaks String API
|
|
24
|
+
class GeneWrapper; def initialize(g); @g = g; end; end # loses String-ness
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Annotation uses Ruby's singleton-class extension:
|
|
28
|
+
- `extend SomeModule` on the specific object instance.
|
|
29
|
+
- The object's class is unchanged.
|
|
30
|
+
- The annotation is removable via `Annotation.purge`.
|
|
31
|
+
|
|
32
|
+
This principle is applied to: Path (annotated Strings), NamedArray (annotated
|
|
33
|
+
Arrays), IndiferentHash (annotated Hashes), ConcurrentStream (annotated IOs).
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Principle 2: Module composition over inheritance
|
|
38
|
+
|
|
39
|
+
There are no deep class hierarchies. Behavior is composed via Ruby modules
|
|
40
|
+
mixed into objects at runtime.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# Path = String + Path annotations
|
|
44
|
+
module Path
|
|
45
|
+
extend Annotation # becomes an annotation module
|
|
46
|
+
annotation :pkgdir, :libdir, :path_maps, :map_order, :where, :original
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
path = Path.setup("data/file.tsv") # String + Path module
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each concern is a module: Annotation, IndiferentHash, ConcurrentStream,
|
|
53
|
+
Resource, Path. They can be mixed into the same object without conflict.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Principle 3: `setup` is the constructor
|
|
58
|
+
|
|
59
|
+
Classes are rarely instantiated with `.new`. Instead, `Module.setup(obj, ...)`
|
|
60
|
+
extends an existing object with the module and sets its annotations.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Annotation modules provide a class-level `setup` method
|
|
64
|
+
Path.setup("data/file.tsv", "mypkg") # → annotated String
|
|
65
|
+
IndiferentHash.setup({a: 1}) # → annotated Hash
|
|
66
|
+
ConcurrentStream.setup(io, pids: [123]) # → annotated IO
|
|
67
|
+
Gene.setup("BRCA1", :organism, "Hsa") # → annotated String
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`setup` always returns the same object it received (after extension). It does
|
|
71
|
+
not clone unless the object is frozen. This means `setup` is idempotent and
|
|
72
|
+
non-destructive.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Principle 4: `method_missing` as a fluent builder
|
|
77
|
+
|
|
78
|
+
When a module needs to provide a dynamic, open-ended API, `method_missing` is
|
|
79
|
+
used to build structures fluently.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# Path uses method_missing to build nested paths
|
|
83
|
+
path = Path.setup("data")
|
|
84
|
+
path.genes.tsv # → "data/genes/tsv" (annotated Path)
|
|
85
|
+
path.results["run1"] # → "data/results/run1"
|
|
86
|
+
|
|
87
|
+
# NamedArray uses method_missing for field accessors
|
|
88
|
+
arr = NamedArray.setup([1, 2, 3], [:a, :b, :c])
|
|
89
|
+
arr.a # → 1
|
|
90
|
+
arr.b # → 2
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The key insight: `method_missing` is not for error recovery, it's for
|
|
94
|
+
**building an API that mirrors the domain**. Path segments become method
|
|
95
|
+
names; field names become accessors.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Principle 5: Conventions for resource discovery
|
|
100
|
+
|
|
101
|
+
The framework discovers resources by convention, not by explicit registration:
|
|
102
|
+
|
|
103
|
+
| Convention | Resolution |
|
|
104
|
+
|---|---|
|
|
105
|
+
| Path maps `{PKGDIR}`, `{LIBDIR}`, `{PWD}`, `{HOME}` | Automatically resolved |
|
|
106
|
+
| `Resource.claim(path, type, &block)` | Declares how to produce a path |
|
|
107
|
+
| `CMD.tool(:name) { ... }` | Registers tool auto-install |
|
|
108
|
+
| `Path#find` | Tries each map in `map_order` |
|
|
109
|
+
| `Resource#produce` | Triggers claim if file missing |
|
|
110
|
+
|
|
111
|
+
There are no plugin manifests or registration calls. Put a claim in the right
|
|
112
|
+
module and the framework finds it.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Principle 6: The "produced on demand" pattern
|
|
117
|
+
|
|
118
|
+
Files are not pre-generated. They are produced lazily when first accessed:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
module MyData
|
|
122
|
+
extend Resource
|
|
123
|
+
self.claim Path.setup("data/file.tsv"), :proc do |path|
|
|
124
|
+
Open.write(path, generate_data())
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
path = MyData.data["file.tsv"] # accesses via method_missing
|
|
129
|
+
path.read # triggers produce → generate_data()
|
|
130
|
+
path.read # second read: file exists, no production
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This is the Scout equivalent of a build system. The claim is the build rule;
|
|
134
|
+
`produce` is the build trigger; `find` is the output path.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Principle 7: Stream everything
|
|
139
|
+
|
|
140
|
+
Expensive I/O (command output, file reads, network) is always returned as
|
|
141
|
+
streams, not eagerly-read buffers. Streams are ConcurrentStream-enhanced IOs
|
|
142
|
+
with lifecycle management:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
stream = CMD.cmd("cat huge_file", :pipe => true)
|
|
146
|
+
stream.each_line { |line| process(line) }
|
|
147
|
+
stream.join # wait for command completion
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- `pipe: true` → streaming mode.
|
|
151
|
+
- Default → blocking mode (reads all output).
|
|
152
|
+
- Streams carry their PID/threads for proper cleanup.
|
|
153
|
+
- Streams support callbacks for cleanup.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Principle 8: IndiferentHash everywhere
|
|
158
|
+
|
|
159
|
+
Options and configuration are always IndiferentHash — symbol/string
|
|
160
|
+
indifferent. This eliminates symbol-vs-string bugs:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
options = IndiferentHash.setup({})
|
|
164
|
+
options[:model] = "gpt-4"
|
|
165
|
+
options['model'] # → "gpt-4"
|
|
166
|
+
options[:model] # → "gpt-4"
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
All option-processing utilities (`process_options`, `add_defaults`, `pull_keys`)
|
|
170
|
+
work on IndiferentHash. When writing code that accepts options, use these
|
|
171
|
+
utilities.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Principle 9: Self-documenting through annotation propagation
|
|
176
|
+
|
|
177
|
+
When you extract items from annotated collections, they inherit the parent's
|
|
178
|
+
annotations:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
list = Gene.setup(["BRCA1", "TP53"], :organism, "Hsa")
|
|
182
|
+
list.first.organism # → "Hsa" (inherited from parent list)
|
|
183
|
+
list.select { |g| g.length > 4 }.organism # → "Hsa" (preserved through select)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
This is the AnnotatedArray pattern: enumeration methods are overridden to
|
|
187
|
+
propagate annotations. The data carries its metadata with it.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Principle 10: Compact DSLs over verbose configuration
|
|
192
|
+
|
|
193
|
+
Scout prefers compact, expressive DSLs:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
# SOPT compact definition
|
|
197
|
+
SOPT.parse("-t--tool* tool to use
|
|
198
|
+
-d--database* database
|
|
199
|
+
-v--verbose")
|
|
200
|
+
|
|
201
|
+
# Path maps as a hash of templates
|
|
202
|
+
path_maps = {
|
|
203
|
+
current: "{PWD}/{TOPLEVEL}/{SUBPATH}",
|
|
204
|
+
home: "{HOME}/{TOPLEVEL}/{PKGDIR}/{SUBPATH}"
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Configuration is data (hashes, strings), not objects.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Anti-patterns to avoid
|
|
213
|
+
|
|
214
|
+
### 1. Creating wrapper classes for native types
|
|
215
|
+
```ruby
|
|
216
|
+
# WRONG
|
|
217
|
+
class PathWrapper
|
|
218
|
+
def initialize(path); @path = path; end
|
|
219
|
+
def read; File.read(@path); end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# RIGHT — annotate
|
|
223
|
+
path = Path.setup("data/file.tsv")
|
|
224
|
+
path.read # works because Resource/path.rb adds read
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### 2. Eager initialization
|
|
228
|
+
```ruby
|
|
229
|
+
# WRONG
|
|
230
|
+
class Processor
|
|
231
|
+
def initialize
|
|
232
|
+
@cache = build_huge_cache() # expensive, always runs
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# RIGHT — lazy
|
|
237
|
+
def cache
|
|
238
|
+
@cache ||= build_huge_cache()
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### 3. Explicit delegation when method_missing already works
|
|
243
|
+
```ruby
|
|
244
|
+
# WRONG — don't add these to a module that already uses method_missing
|
|
245
|
+
def data; @path.data; end
|
|
246
|
+
def results; @path.results; end
|
|
247
|
+
|
|
248
|
+
# RIGHT — method_missing handles it
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### 4. Not using IndiferentHash for options
|
|
252
|
+
```ruby
|
|
253
|
+
# WRONG — symbol/string fragility
|
|
254
|
+
def foo(options)
|
|
255
|
+
options[:key] || options['key'] # verbose, error-prone
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# RIGHT
|
|
259
|
+
def foo(options)
|
|
260
|
+
options = IndiferentHash.setup(options) unless IndiferentHash === options
|
|
261
|
+
options[:key]
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### 5. Breaking the annotate-don't-wrap principle for IO
|
|
266
|
+
```ruby
|
|
267
|
+
# WRONG
|
|
268
|
+
class TrackedStream
|
|
269
|
+
def initialize(io); @io = io; end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# RIGHT — annotate the IO
|
|
273
|
+
ConcurrentStream.setup(io, :pids => [pid], :threads => [thread])
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### 6. Forgetting to `join` streams
|
|
277
|
+
```ruby
|
|
278
|
+
# WRONG — orphaned subprocess
|
|
279
|
+
stream = CMD.cmd("long_command", :pipe => true)
|
|
280
|
+
lines = stream.readlines # may leave process running if exception
|
|
281
|
+
|
|
282
|
+
# RIGHT
|
|
283
|
+
begin
|
|
284
|
+
stream = CMD.cmd("long_command", :pipe => true)
|
|
285
|
+
lines = stream.readlines
|
|
286
|
+
ensure
|
|
287
|
+
stream.join
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### 7. Mutating global path configuration
|
|
292
|
+
```ruby
|
|
293
|
+
# WRONG — affects all paths globally
|
|
294
|
+
Path.path_maps = { ... } # class-level mutation
|
|
295
|
+
|
|
296
|
+
# RIGHT — per-path or per-resource
|
|
297
|
+
path = Path.setup("data", path_maps: { ... }, map_order: [...])
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Cross-cutting utilities (Misc module)
|
|
303
|
+
|
|
304
|
+
The `Misc` module is a grab-bag of utilities used across the codebase. It is
|
|
305
|
+
the "stdlib" of scout-essentials. Key categories:
|
|
306
|
+
|
|
307
|
+
### Format utilities
|
|
308
|
+
- `Misc.format_paragraph(text, size, indent, offset)` — word-wrap text to fit
|
|
309
|
+
terminal width, preserving code blocks and lists.
|
|
310
|
+
- `Misc.format_definition_list(hash, sep)` — format a hash as `key: value`.
|
|
311
|
+
- `Misc.format_seconds(time)` — `HH:MM:SS` format.
|
|
312
|
+
- `Misc.format_seconds_short(time)` — human-readable short form.
|
|
313
|
+
- `Misc.colors_for(list)` — assign hex colors to unique elements.
|
|
314
|
+
|
|
315
|
+
### Math utilities
|
|
316
|
+
- `Misc.log2(x)`, `Misc.log10(x)` — cached multiplier versions.
|
|
317
|
+
- `Misc.max`, `Misc.min`, `Misc.mean`, `Misc.stddev` — list statistics.
|
|
318
|
+
- `Misc.zip_fields(lists)` — transpose arrays.
|
|
319
|
+
- `Misc.bin_for_value`, `Misc.bins` — histogram binning.
|
|
320
|
+
|
|
321
|
+
### Digest utilities
|
|
322
|
+
- `Misc.digest_str(obj)` — deterministic string representation for digesting.
|
|
323
|
+
- `Misc.digest(obj)` — MD5 of `digest_str`.
|
|
324
|
+
- `Misc.file_md5(path)` — MD5 of file content.
|
|
325
|
+
- `Misc.obj_md5(obj)` — MD5 of object representation.
|
|
326
|
+
|
|
327
|
+
### Filesystem utilities
|
|
328
|
+
- `Misc.in_dir(dir) { ... }` — chdir + yield + restore.
|
|
329
|
+
- `Misc.path_relative_to(basedir, path)` — relative path computation.
|
|
330
|
+
- `Misc.add_libdir(dir)` — add lib directory to LOAD_PATH.
|
|
331
|
+
- `Misc.zip_zones`, `Misc.tar_files` — archive helpers.
|
|
332
|
+
|
|
333
|
+
### System utilities
|
|
334
|
+
- `Misc.hostname` — cached hostname.
|
|
335
|
+
- `Misc.children(pid)` — child processes.
|
|
336
|
+
- `Misc.pid_alive?(pid)` — check if process is alive.
|
|
337
|
+
|
|
338
|
+
### Process utilities
|
|
339
|
+
- `Misc.benchmark(repeats) { ... }` — benchmark and log.
|
|
340
|
+
- `Misc.insist(times, sleep) { ... }` — retry with exponential backoff.
|
|
341
|
+
- `Misc.pid_alive?(pid)` — check liveness.
|
|
342
|
+
|
|
343
|
+
### Matching utilities
|
|
344
|
+
- `Misc.match_value(value, condition)` — fuzzy/comparison matching.
|
|
345
|
+
- `Misc._convert_match_condition(str)` — parse `>1`, `/regex/`, `!value`, etc.
|
|
346
|
+
- `Misc.intersect_sorted_arrays(a1, a2)` — efficient sorted intersection.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Gotchas and warnings
|
|
351
|
+
|
|
352
|
+
1. **Annotation `setup` on frozen objects** — `setup` dup's frozen objects,
|
|
353
|
+
which creates a new instance. If you hold a reference to the original
|
|
354
|
+
frozen object, it won't have the annotation.
|
|
355
|
+
2. **`method_missing` can mask typos** — In Path, calling `path.typo_method`
|
|
356
|
+
will create a new path `"data/typo_method"` instead of raising. This is by
|
|
357
|
+
design but can cause subtle bugs.
|
|
358
|
+
- Mitigation: `path.method_missing` only fires if the method name doesn't
|
|
359
|
+
start with `to_` and no block is given.
|
|
360
|
+
3. **IndiferentHash default value interaction** — if a hash has a `default`
|
|
361
|
+
or `default_proc`, the alternate-form lookup is bypassed. This can silently
|
|
362
|
+
return the default instead of trying the other key form.
|
|
363
|
+
4. **ConcurrentStream callback ordering** — callbacks are chained in LIFO
|
|
364
|
+
order (most recently added runs first). If ordering matters, document it.
|
|
365
|
+
5. **Persist cache path determinism** — the path is derived from the name +
|
|
366
|
+
options hash. If the options hash is not deterministic (e.g., contains a
|
|
367
|
+
Proc or a non-deterministic object), the cache path changes every run.
|
|
368
|
+
6. **Resource produce race condition** — if two processes call `produce`
|
|
369
|
+
simultaneously on a non-existent resource, both will run the claim. The
|
|
370
|
+
lock prevents this, but only if both use the same lock path. Verify that
|
|
371
|
+
`Open.lock` is always called with the canonical lock path.
|
|
372
|
+
7. **Log.severity is global** — changes affect all threads. Use
|
|
373
|
+
`Log.with_severity(level) { ... }` for scoped changes.
|
|
374
|
+
8. **Misc.digest of a Path** — `Misc.digest` checks if the string is a valid
|
|
375
|
+
filename and if so, digests the file content. This is usually desired
|
|
376
|
+
(digesting the data, not the path string), but can be surprising.
|
|
377
|
+
9. **Path.map_order as class variable** — `@@map_order` is shared across all
|
|
378
|
+
Path instances. Modifying it globally affects all paths. Per-instance
|
|
379
|
+
`@map_order` can be set during `Path.setup`.
|
|
380
|
+
10. **Annotation propagation may not match expectations** — AnnotatedArray
|
|
381
|
+
overrides specific enumeration methods (`[]`, `first`, `last`, `select`,
|
|
382
|
+
`collect`, etc.). Methods not in the list (e.g., `filter_map`, `tally`)
|
|
383
|
+
will NOT propagate annotations.
|