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,189 @@
|
|
|
1
|
+
# Command-Line Options
|
|
2
|
+
|
|
3
|
+
`SOPT` is scout-essentials' option parser: a small registry of inputs,
|
|
4
|
+
shortcuts and descriptions plus a **destructive** consumer that edits
|
|
5
|
+
`ARGV` in place. It is deliberately minimal — no subcommands, no coercion
|
|
6
|
+
beyond booleans, no config-file layer.
|
|
7
|
+
|
|
8
|
+
## Declaring inputs
|
|
9
|
+
|
|
10
|
+
Options are declared as a single string. Each entry is one line:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
-[short]--[long][*] description
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
require 'scout-essentials'
|
|
18
|
+
|
|
19
|
+
SOPT.parse <<~OPT
|
|
20
|
+
-o--organism* Organism code
|
|
21
|
+
-t--tissue* Tissue of origin
|
|
22
|
+
-d--dry-run Do not write anything
|
|
23
|
+
OPT
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The `*` suffix is the **only** type marker: it marks the option as taking a
|
|
27
|
+
string value. Without it the option is a boolean (`lib/scout/simple_opt/parse.rb:53`).
|
|
28
|
+
It does **not** mean "required".
|
|
29
|
+
|
|
30
|
+
`SOPT.setup(str)` parses the same grammar from a fuller usage document —
|
|
31
|
+
summary, synopsis (a line starting with `$`), description and options — and
|
|
32
|
+
then immediately calls `SOPT.consume`, so it both registers and consumes
|
|
33
|
+
`ARGV` in one step (`lib/scout/simple_opt/setup.rb`).
|
|
34
|
+
|
|
35
|
+
The registry lives in module-level accessors (`simple_opt/accessor.rb`):
|
|
36
|
+
`inputs`, `input_types`, `input_shortcuts`, `shortcuts`, `input_descriptions`,
|
|
37
|
+
`input_defaults`.
|
|
38
|
+
|
|
39
|
+
## Consuming the command line
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
options = SOPT.consume # defaults to ARGV, which it MUTATES
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`SOPT.consume(args = ARGV)` walks `args` left to right and **deletes**
|
|
46
|
+
every token it recognises:
|
|
47
|
+
|
|
48
|
+
- a matching `--long` or `-s` is removed and, if the input takes a value,
|
|
49
|
+
the following token (or `=value`) is removed with it;
|
|
50
|
+
- unknown flags and free-standing words are left alone;
|
|
51
|
+
- `--` stops the whole loop: everything after it is left in `args`.
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# probe_02 (tmp/rewrite_C/probe_02_sopt.rb)
|
|
55
|
+
argv = ['-o', 'Human', '--tissue', 'Liver', 'positional', '-d']
|
|
56
|
+
SOPT.consume(argv)
|
|
57
|
+
# => {:organism=>"Human", :tissue=>"Liver", :"dry-run"=>true}
|
|
58
|
+
# argv is now ["positional"]
|
|
59
|
+
|
|
60
|
+
argv = ['-d', '--', '--not-an-option', '-x']
|
|
61
|
+
# => {:"dry-run"=>true}; argv unchanged from "--" onwards
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Because the array is edited in place, `SOPT.consume` is how a program
|
|
65
|
+
separates its own options from the positional arguments it will still read.
|
|
66
|
+
|
|
67
|
+
### Boolean parsing
|
|
68
|
+
|
|
69
|
+
Booleans are true unless the value is one of `F`, `false`, `FALSE`, `no`
|
|
70
|
+
(`simple_opt/get.rb:45`):
|
|
71
|
+
|
|
72
|
+
- `--dry-run=false`, `--dry-run=F` → `false`
|
|
73
|
+
- `--dry-run` → `true`
|
|
74
|
+
- `--dry-run false` → `false`, and Ruby logs a warning telling you to use
|
|
75
|
+
`=` instead — this convenience swallows the next token, so use `=`.
|
|
76
|
+
|
|
77
|
+
A word that merely *follows* a boolean flag and is not one of those four is
|
|
78
|
+
**not** eaten: `['--dry-run', 'stray']` leaves `"stray"` in `ARGV`
|
|
79
|
+
(probe_02).
|
|
80
|
+
|
|
81
|
+
## State and reuse
|
|
82
|
+
|
|
83
|
+
`SOPT.consume` writes two things:
|
|
84
|
+
|
|
85
|
+
- the return value (an `IndiferentHash` with symbol keys), which is also
|
|
86
|
+
stashed as `@@current_options`; `SOPT.current_options = hash` lets you
|
|
87
|
+
seed it;
|
|
88
|
+
- `SOPT::GOT_OPTIONS`, a module-level hash that is **merged into, never
|
|
89
|
+
reset**. Every `consume` call accumulates there, which is how
|
|
90
|
+
sub-commands that each declare their own inputs still end up with a
|
|
91
|
+
global picture of what was given (probe_02: two separate `consume` calls
|
|
92
|
+
on disjoint inputs both appear in `GOT_OPTIONS`).
|
|
93
|
+
|
|
94
|
+
`SOPT.get(opt_str)` is just `parse` followed by `consume(ARGV)`.
|
|
95
|
+
|
|
96
|
+
### `SOPT.require` — the only enforcement
|
|
97
|
+
|
|
98
|
+
Nothing about a declared option is required. If you want to fail on a
|
|
99
|
+
missing option, call it yourself:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
SOPT.require(options, :organism, :tissue)
|
|
103
|
+
# raises ParameterException: Parameter 'tissue' not given
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`ParameterException < ScoutException < StandardError` (probe_02), so plain
|
|
107
|
+
`rescue` works. There is **no** variant that extracts a subset of the
|
|
108
|
+
options — `SOPT.get` always parses a fresh string and consumes the whole
|
|
109
|
+
`ARGV`.
|
|
110
|
+
|
|
111
|
+
## Help text
|
|
112
|
+
|
|
113
|
+
`SOPT.doc` renders a man-page-style document:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
myprog(1) -- <summary>
|
|
117
|
+
=========================
|
|
118
|
+
|
|
119
|
+
## SYNOPSYS
|
|
120
|
+
|
|
121
|
+
myprog [--organism=<string>] [--tissue=<string>] [--dry-run[=false]]
|
|
122
|
+
|
|
123
|
+
## OPTIONS
|
|
124
|
+
|
|
125
|
+
-o,--organism=<string> Organism code
|
|
126
|
+
...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The header really is `## SYNOPSYS` — the misspelling is in the source
|
|
130
|
+
(`simple_opt/doc.rb:112`) and callers grep for it; do not "fix" it in your
|
|
131
|
+
matching code. `SOPT.usage` prints the doc and calls `exit 0` (probe_07 traps
|
|
132
|
+
`SystemExit` and reports status 0).
|
|
133
|
+
|
|
134
|
+
`SOPT.input_doc` (used by `doc`) is also the public way to format an
|
|
135
|
+
explicit option list, and `SOPT.input_array_doc` formats
|
|
136
|
+
`[[name, type, description, default, options], ...]` arrays — that is the
|
|
137
|
+
form `Workflow`-level code uses to pass shortcut choices through.
|
|
138
|
+
|
|
139
|
+
## Shortcuts and `fix_shortcut`
|
|
140
|
+
|
|
141
|
+
Every declared long name automatically gets a short form: the first letter
|
|
142
|
+
of the long name, if it is free (`simple_opt/doc.rb:33`,
|
|
143
|
+
`fix_shortcut(name[0], name)`). When it is taken, `SOPT.fix_shortcut`
|
|
144
|
+
searches for a free one:
|
|
145
|
+
|
|
146
|
+
1. an existing shortcut already bound to that exact long name is reused;
|
|
147
|
+
2. if the long name contains `-` or `_`, the initials of its parts
|
|
148
|
+
(`--max-cpu` → `-m` if free, else the accumulated initials);
|
|
149
|
+
3. if it contains digits, the first letter plus the number;
|
|
150
|
+
4. otherwise it walks forward through the letters.
|
|
151
|
+
|
|
152
|
+
If no shortcut can be found, `fix_shortcut` returns `nil` and the option
|
|
153
|
+
simply has no short form. **Collisions are silent**: declaring `-a--alpha`
|
|
154
|
+
and `-a--also` yields `{"a"=>"alpha", "al"=>"also"}` — the second entry
|
|
155
|
+
gets a longer shortcut rather than an error (probe_02). Live probe
|
|
156
|
+
(`tmp/rewrite_C/probe_07_sopt_extra.rb`): registering `t` while `-t` is
|
|
157
|
+
bound to `tissue` yields `"th" => "threshold"`; `another_one` gets the
|
|
158
|
+
initials `"ao"`; `alpha2` gets `"a2"`.
|
|
159
|
+
|
|
160
|
+
`SOPT.delete_inputs(['organism'])` removes an input from `inputs`,
|
|
161
|
+
`input_shortcuts`, `shortcuts`, `input_types`, `input_defaults` and
|
|
162
|
+
`input_descriptions` (`simple_opt/accessor.rb:39`). `input_shortcuts` is the
|
|
163
|
+
reverse map `{'organism'=>'o'}` (probe_07).
|
|
164
|
+
|
|
165
|
+
`SOPT.reset` clears **only** `shortcuts` and the internal `all` registry;
|
|
166
|
+
`inputs` and the other per-input tables survive (probe_02). Call
|
|
167
|
+
`SOPT.delete_inputs(SOPT.inputs.dup)` if you actually want an empty slate.
|
|
168
|
+
|
|
169
|
+
## Quirks to design around
|
|
170
|
+
|
|
171
|
+
- Repeating the same boolean flag just re-sets it to `true`; string
|
|
172
|
+
options keep the last value (the hash is overwritten).
|
|
173
|
+
- `--opt=value` and `--opt value` are equivalent; `--opt=` (empty value)
|
|
174
|
+
yields `""`.
|
|
175
|
+
- `-x` unknown: left in `ARGV`, ignored.
|
|
176
|
+
- `--` is not removed from `ARGV` either; it only stops scanning.
|
|
177
|
+
- Options are keyed by their **long** name, symbolised. Shortcuts never
|
|
178
|
+
appear in the result.
|
|
179
|
+
- `SOPT.consume` returns the current options *and* keeps them in
|
|
180
|
+
`GOT_OPTIONS`; the two objects are not the same object, and `GOT_OPTIONS`
|
|
181
|
+
is the one that survives later `SOPT.reset` calls.
|
|
182
|
+
|
|
183
|
+
## Where to go next
|
|
184
|
+
|
|
185
|
+
- [StartHere](../StartHere.md)
|
|
186
|
+
- [Logging and Progress](LoggingAndProgress.md) — `Log.warn` used by the
|
|
187
|
+
boolean heuristic.
|
|
188
|
+
- [Architecture](../developer/Architecture.md) — which repos own the
|
|
189
|
+
higher-level CLI layers (attribution table).
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Cookbook
|
|
2
|
+
|
|
3
|
+
Short, self-contained recipes that combine the scout-essentials modules.
|
|
4
|
+
Every snippet here was executed; the backing probes are listed under each
|
|
5
|
+
heading and live in `tmp/rewrite_C/` (older probes in `tmp/rewrite_A/`,
|
|
6
|
+
`tmp/rewrite_B/` are referenced by their P-numbers, which map to
|
|
7
|
+
`research/behavior-probes.md`).
|
|
8
|
+
|
|
9
|
+
The theme of the library: plain objects annotated with provenance, paths
|
|
10
|
+
resolved from a declaration, work cached on disk, streams piped without
|
|
11
|
+
holding everything in memory.
|
|
12
|
+
|
|
13
|
+
## Get-or-build a file: `claim` + `produce_and_find`
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require 'scout-essentials'
|
|
17
|
+
|
|
18
|
+
module Data
|
|
19
|
+
extend Resource
|
|
20
|
+
self.pkgdir = 'cookbook_probe'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Data.claim Data.tmp['list'], :string, "S001\nS002\nS003\n"
|
|
24
|
+
found = Data.tmp['list'].produce_and_find
|
|
25
|
+
Open.read(found) # => "S001\nS002\nS003\n"
|
|
26
|
+
Data.tmp['list'].produce_and_find # second call returns the same path, no work
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`Resource.claim(path, type, contents, block)` registers *how* a file is
|
|
30
|
+
produced (a type like `:string`/`:proc`, literal contents, or a block); the
|
|
31
|
+
claimed `Path` is annotated and its `produce` materialises it into the
|
|
32
|
+
resource tree (`lib/scout/resource/path.rb:2`,
|
|
33
|
+
`lib/scout/resource/produce.rb`). `produce_and_find` produces when needed and
|
|
34
|
+
returns `self.find`. Probe: `tmp/rewrite_C/probe_03_cookbook.rb`
|
|
35
|
+
(`claim` + first/second call returning the same path, `Open.read` content).
|
|
36
|
+
|
|
37
|
+
## Cache invalidation with `:update` and `:check`
|
|
38
|
+
|
|
39
|
+
`Persist.persist` normally returns the cached value untouched. Two options
|
|
40
|
+
change that (`lib/scout/persist.rb`):
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
a = Persist.persist('expensive', :marshal, :update => false) { "computed-once" }
|
|
44
|
+
b = Persist.persist('expensive', :marshal, :update => false) { "recomputed" }
|
|
45
|
+
a == b # => true, block skipped both times
|
|
46
|
+
c = Persist.persist('expensive', :marshal, :update => true) { "forced-recompute" }
|
|
47
|
+
c # => "forced-recompute" — block re-run
|
|
48
|
+
|
|
49
|
+
d = Persist.persist('dependent', :marshal,
|
|
50
|
+
:check => 'tmp/src.txt') { "from-#{Open.read('tmp/src.txt')}" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`:update => true` always re-runs the block; `:check` names a file whose mtime
|
|
54
|
+
invalidates the entry. Probe: probe_03 (a == b, block not re-run; `:update`
|
|
55
|
+
re-runs; `:check` path resolves).
|
|
56
|
+
|
|
57
|
+
## Fetch a remote file
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
require 'scout-essentials'
|
|
61
|
+
|
|
62
|
+
url = 'https://example.org/data.tsv'
|
|
63
|
+
Open.wget(url, :auto) # downloads; cached under Open.remote_cache_dir
|
|
64
|
+
data = Open.wget(url) # serves from cache on the next call
|
|
65
|
+
|
|
66
|
+
Open.scp('user@host:/path/file', 'local_copy', :target => 'user@host')
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`Open.wget` shells out to `wget` (`lib/scout/open/remote.rb`); `Open.read` on
|
|
70
|
+
an `http(s)://` or `ssh:` URL fetches transparently. Retries and rate limits
|
|
71
|
+
are controlled by `Open.wait(lag, key)`. See
|
|
72
|
+
[RemoteData.md](RemoteData.md) and
|
|
73
|
+
[Working with Files](WorkingWithFiles.md).
|
|
74
|
+
|
|
75
|
+
## Annotate, serialise, restore
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
require 'scout-essentials'
|
|
79
|
+
|
|
80
|
+
module SampleInfo
|
|
81
|
+
extend Annotation
|
|
82
|
+
annotation :organism, :tissue
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sample = SampleInfo.setup('S001', :organism => 'Human', :tissue => 'Liver')
|
|
86
|
+
sample.annotation_hash # {:organism=>"Human", :tissue=>"Liver"}
|
|
87
|
+
sample.serialize # {:organism=>"Human", :tissue=>"Liver",
|
|
88
|
+
# :annotation_types=>[SampleInfo], :annotated_array=>false,
|
|
89
|
+
# :literal=>"S001"}
|
|
90
|
+
|
|
91
|
+
restored = Annotation.setup('S003', 'SampleInfo',
|
|
92
|
+
'organism' => 'Human', 'tissue' => 'Liver')
|
|
93
|
+
restored.tissue # => "Liver"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`Annotation.setup(obj, "A|B", hash)` is the module-level deserialiser; the
|
|
97
|
+
`"A|B"` string is split on `|` and each name is looked up as a constant. An
|
|
98
|
+
unknown type name is **warned about and skipped** (probe_03 prints
|
|
99
|
+
`Annotation NoSuchAnnotation not defined` on STDERR, then
|
|
100
|
+
`Annotation.setup('S004', 'NoSuchAnnotation', ...)` returns the plain
|
|
101
|
+
un-annotated string). `serialize` produces a plain `Hash` with `:literal`,
|
|
102
|
+
`:annotation_types` (module objects) and `:annotated_array` keys. There is no
|
|
103
|
+
TSV serialisation of annotations in this repo — `Annotation.tsv` belongs to
|
|
104
|
+
scout-gear (see the [attribution table](../developer/Architecture.md)).
|
|
105
|
+
|
|
106
|
+
## Stream a pipeline without buffering
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
require 'scout-essentials'
|
|
110
|
+
|
|
111
|
+
stream = Open.open_pipe do |sin|
|
|
112
|
+
['S001', 'S002'].each { |s| sin.write "1\t#{s}\n" }
|
|
113
|
+
sin.close
|
|
114
|
+
end
|
|
115
|
+
Open.consume_stream(stream) # => "1\tS001\n1\tS002\n"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`Open.open_pipe` builds an IO from a block and returns it unread; `consume_stream`
|
|
119
|
+
drains it. `tee_stream` splits one stream into two consumers:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
Open.write('tmp/in.txt', "1\n2\n3\n")
|
|
123
|
+
main, copy = Open.tee_stream(
|
|
124
|
+
CMD.cmd('gzip -c', :pipe => true, :in => Open.open('tmp/in.txt'))
|
|
125
|
+
)
|
|
126
|
+
Open.consume_stream(main, true, 'tmp/in.txt.gz') # writes the file
|
|
127
|
+
copy.join # waits for the copy thread
|
|
128
|
+
File.exist?('tmp/in.txt.gz') # => true
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Consumers follow the rescue contract for the deliberate-abort signals:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
begin
|
|
135
|
+
data = Open.consume_stream(stream)
|
|
136
|
+
rescue Aborted, AbortedStream
|
|
137
|
+
Log.warn "aborted mid-stream"
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Probe: probe_03 (open_pipe data, tee_stream producing a real gzip file).
|
|
142
|
+
See [Handling Streams](HandlingStreams.md) and the
|
|
143
|
+
[Streaming Model](../developer/StreamingModel.md).
|
|
144
|
+
|
|
145
|
+
## Progress bars
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
require 'scout-essentials'
|
|
149
|
+
|
|
150
|
+
items = (1..100).to_a
|
|
151
|
+
|
|
152
|
+
Log::ProgressBar.with_obj_bar(items, 100) do |bar|
|
|
153
|
+
items.each { bar.tick }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
Log::ProgressBar.with_bar(20, :desc => 'Counting') do |bar|
|
|
157
|
+
20.times { bar.tick }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
bar = Log::ProgressBar.new_bar(10, :desc => 'Half-way')
|
|
161
|
+
10.times { bar.tick }
|
|
162
|
+
Log::ProgressBar.remove_bar(bar)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The block of `with_obj_bar` receives **only the bar**; the object is never
|
|
166
|
+
yielded back (`log/progress/util.rb:167-170`). The *second* argument selects
|
|
167
|
+
the bar: a String is the description, a Numeric the max, a Hash the options,
|
|
168
|
+
an existing bar object is reused, and `true` guesses the max from the first
|
|
169
|
+
argument via `guess_obj_max` — which returns `nil` even for a plain `Array`
|
|
170
|
+
in a bare scout-essentials process: the first `when TSV` arm raises
|
|
171
|
+
`NameError` (the constant is not defined here) and the surrounding
|
|
172
|
+
`rescue Exception` turns that into `nil` (live check;
|
|
173
|
+
`log/progress/util.rb:101-137`). Pass a Numeric max or a `:max` hash for
|
|
174
|
+
deterministic sizing. There is no `Log.bar` helper; use
|
|
175
|
+
`Log::ProgressBar.new_bar` / `remove_bar`.
|
|
176
|
+
|
|
177
|
+
## Command-line usage with SOPT
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
require 'scout-essentials'
|
|
181
|
+
|
|
182
|
+
SOPT.parse <<~OPT
|
|
183
|
+
-o--organism* Organism code
|
|
184
|
+
-t--tissue* Tissue of origin
|
|
185
|
+
-d--dry-run Skip writing
|
|
186
|
+
OPT
|
|
187
|
+
|
|
188
|
+
argv = ['-o', 'Human', 'positional', '-d']
|
|
189
|
+
options = SOPT.consume(argv) # argv is mutated: matched args removed
|
|
190
|
+
argv # => ["positional"]
|
|
191
|
+
|
|
192
|
+
SOPT.require(options, :organism) # ParameterException when nil
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The `*` marks an option as **taking a string value**; without it the option is
|
|
196
|
+
a boolean. Probe: probe_02 (options hash, `argv_left == ["positional"]`,
|
|
197
|
+
`SOPT.require` raising). See
|
|
198
|
+
[Command-Line Options](CommandLineOptions.md).
|
|
199
|
+
|
|
200
|
+
## Recipe index
|
|
201
|
+
|
|
202
|
+
| Task | Tool | Page |
|
|
203
|
+
| --- | --- | --- |
|
|
204
|
+
| Resolve a path | `Path.setup`, `Resource` | [Working with Files](WorkingWithFiles.md) |
|
|
205
|
+
| Run a command | `CMD.cmd` | [Running Commands](RunningCommands.md) |
|
|
206
|
+
| Cache a computation | `Persist.persist` | [Caching Results](CachingResults.md) |
|
|
207
|
+
| Build a file on demand | `Resource.claim` + `produce_and_find` | [Producing Resources](ProducingResources.md) |
|
|
208
|
+
| Annotate objects | `Annotation` | [Annotating Data](AnnotatingData.md) |
|
|
209
|
+
| Pipe data | `Open.open_pipe`, `CMD.cmd(:pipe)` | [Handling Streams](HandlingStreams.md) |
|
|
210
|
+
| Log and show progress | `Log`, `Log::ProgressBar` | [Logging and Progress](LoggingAndProgress.md) |
|
|
211
|
+
| Parse options | `SOPT` | [Command-Line Options](CommandLineOptions.md) |
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Handling Streams
|
|
2
|
+
|
|
3
|
+
When `CMD.cmd(..., :pipe => true)` or `Open.open_pipe` hands you an IO, that
|
|
4
|
+
object has been extended with `ConcurrentStream`: it carries the producer
|
|
5
|
+
thread(s) and pid(s) that feed it, plus callbacks and an abort protocol. This
|
|
6
|
+
page is the user-facing contract; the internals are in
|
|
7
|
+
[Streaming Model](../developer/StreamingModel.md).
|
|
8
|
+
|
|
9
|
+
## Anatomy
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
io = CMD.cmd('grep x', :pipe => true)
|
|
13
|
+
io.threads # => [input thread, stderr thread, ...] (producer side)
|
|
14
|
+
io.pids # => [pid]
|
|
15
|
+
io.callback # => nil or a Proc run on successful join (see :post)
|
|
16
|
+
io.abort_callback # => nil or a Proc run on abort(exception)
|
|
17
|
+
io.std_err # => "" (filled by :save_stderr)
|
|
18
|
+
io.log # => last stderr line (pipe mode, when :log => true)
|
|
19
|
+
io.lock, io.lockfile, io.pair, io.next, io.filename, io.autojoin, io.no_fail
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Attributes come from `ConcurrentStream.setup` (concurrent_stream.rb:12-70) and
|
|
23
|
+
`CMD.cmd` sets `:pids`, `:threads`, `:autojoin` (default `no_fail`) and
|
|
24
|
+
`:no_fail` on the returned stream. `exit_status` exists but is *not* reliable
|
|
25
|
+
after a normal read+join — it stays `nil` because only `join_pids` sets it, and
|
|
26
|
+
that method empties `pids` when it runs; see
|
|
27
|
+
[Running Commands](RunningCommands.md) for the probe.
|
|
28
|
+
|
|
29
|
+
## `close` vs `join` vs abort
|
|
30
|
+
|
|
31
|
+
- **`join`** is the finishing move: `join_threads`, `join_pids`, raise
|
|
32
|
+
`stream_exception` if one is set, run `join_callback` (the composed callback),
|
|
33
|
+
close, release the lock, and mark `joined?`. It never joins `@pair` — the
|
|
34
|
+
other end of an internal pipe is the producer's business.
|
|
35
|
+
- **`close`** on an `autojoin` stream performs `super` (the plain IO close) and
|
|
36
|
+
then joins if the stream is at EOF; with `autojoin` off it just closes.
|
|
37
|
+
Closing early while a producer still writes raises `IOError`/`Errno::EPIPE`
|
|
38
|
+
in the producer, which the stream converts to an abort — so for an early,
|
|
39
|
+
deliberate stop use `abort`, not `close`.
|
|
40
|
+
- **`abort(exception)`** is the safe early close: it marks the stream aborted,
|
|
41
|
+
extends it with the `AbortedStream` marker, runs `abort_callback`, interrupts
|
|
42
|
+
and joins every producer thread, sends `SIGINT` to every pid, clears the
|
|
43
|
+
callbacks, **propagates to `@pair`** (aborting the other pipe end), closes and
|
|
44
|
+
unlocks. It is idempotent (a second call only logs). Threads get
|
|
45
|
+
`Aborted.new` raised in them, so producer bodies should rescue `Aborted`.
|
|
46
|
+
- **`force_close` does not exist** in this repo. The only reference is a dead
|
|
47
|
+
`respond_to?` guard inside `Open.grep` (open/util.rb:26); a plain IO has no
|
|
48
|
+
such method (probe `tmp/rewrite_B/probe_13_force_close.rb`: `IO#respond_to?
|
|
49
|
+
(:force_close) => false`). The early-close tool is `abort`.
|
|
50
|
+
|
|
51
|
+
Consumers should follow this rescue contract:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
begin
|
|
55
|
+
data = Open.consume_stream(stream, false, dst) # or .each / .read
|
|
56
|
+
rescue Aborted, AbortedStream
|
|
57
|
+
# deliberate stop: log and move on; the stream is already aborted+closed
|
|
58
|
+
rescue ConcurrentStreamProcessFailed => e
|
|
59
|
+
# a producer failed; e.pid / e.msg available
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`AbortedStream` is a *marker module* (`concurrent_stream.rb:4-9`) —
|
|
64
|
+
`AbortedStream.setup(obj, exception)` extends an object so later code can read
|
|
65
|
+
`obj.exception` and recover the real cause; `sensible_write` uses exactly that.
|
|
66
|
+
|
|
67
|
+
## Callbacks
|
|
68
|
+
|
|
69
|
+
- `stream.add_callback(&block)` **composes**: it wraps the existing callback so
|
|
70
|
+
the *new* block runs *after* the old one (probe
|
|
71
|
+
`tmp/rewrite_B/probe_19_streaming_apis.rb`: `add_callback order:
|
|
72
|
+
[:first, :second]`). `ConcurrentStream.setup(stream, &block)` also composes in
|
|
73
|
+
that order, which is why calling setup twice on the same stream is safe.
|
|
74
|
+
- `callback` / `abort_callback` are plain accessors over single chained procs
|
|
75
|
+
(built by setup when you pass `:callback` / `:abort_callback` options or a
|
|
76
|
+
block). There is **no `add_abort_callback`** — assign `abort_callback = proc
|
|
77
|
+
{ |exception| ... }` (only the last one wins unless you compose by hand).
|
|
78
|
+
- `join` runs the composed `callback`; `abort` runs `abort_callback` with the
|
|
79
|
+
exception and then discards both callbacks.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
s = Open.open_pipe { |sin| sin.puts 'x' }
|
|
83
|
+
s.add_callback { puts 'a' }
|
|
84
|
+
s.add_callback { puts 'b' } # runs after 'a'
|
|
85
|
+
s.join # prints "a\nb"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Helpers in `Open`
|
|
89
|
+
|
|
90
|
+
### `Open.consume_stream(io, in_thread = false, into = nil, into_close = true, &block)`
|
|
91
|
+
|
|
92
|
+
Pumps the stream to completion and returns the last chunk read. `Path` inputs
|
|
93
|
+
are ignored, closed streams are joined and skipped. With `into` (an IO, or a
|
|
94
|
+
String/Path file path whose parent dirs are created) it writes every chunk
|
|
95
|
+
there; `into_close` (default true) closes `into` when it responds to `close`.
|
|
96
|
+
On `Aborted` or any exception it aborts the source, closes `into`, **removes the
|
|
97
|
+
partial output file** and re-raises. With `in_thread: true` the whole drain runs
|
|
98
|
+
in a new thread that is pushed onto `io.threads`. The block runs after a
|
|
99
|
+
successful drain. Verified: `probe_19_streaming_apis.rb` (`consume_stream
|
|
100
|
+
return: "data"`, `consume_stream into path: "into-file\n"`).
|
|
101
|
+
|
|
102
|
+
### `Open.sensible_write(path, content, options = {}, &block)`
|
|
103
|
+
|
|
104
|
+
Atomic write into a lock-protected tmp file followed by a rename. Key
|
|
105
|
+
behaviours for streams:
|
|
106
|
+
|
|
107
|
+
- When the content is a stream, an `Aborted` raised while copying is
|
|
108
|
+
**swallowed** (`Log.low "Aborted sensible_write"`), the stream is aborted and
|
|
109
|
+
the target is deleted; the partial tmp file is always removed in `ensure`.
|
|
110
|
+
- A non-Aborted exception recovers the *original* upstream cause from an
|
|
111
|
+
`AbortedStream`-marked content (`content.exception`) and re-raises that,
|
|
112
|
+
deleting the target. See
|
|
113
|
+
[Streaming Model](../developer/StreamingModel.md) for the marker.
|
|
114
|
+
- After a successful copy the content stream is joined (but not if it is a
|
|
115
|
+
`Path` or already joined).
|
|
116
|
+
|
|
117
|
+
Verified: P25/P33 in `research/behavior-probes.md` ("Aborted in
|
|
118
|
+
sensible_write: NOT raised (swallowed); partial tmp left: 0").
|
|
119
|
+
|
|
120
|
+
### `Open.open_pipe(do_fork = false, close = true, &block)`
|
|
121
|
+
|
|
122
|
+
Creates a pipe and returns the **read end** (`sout`), with the block executed in
|
|
123
|
+
a producer thread that writes the other end (`sin`).
|
|
124
|
+
|
|
125
|
+
- **Block arity**: the block always receives `sin` whether it declares a
|
|
126
|
+
parameter or not (arity 0 blocks simply ignore it). Verified:
|
|
127
|
+
`probe_20_open_pipe_arity.rb` (`arity-0 block: "arity0\n"`, `arity-1: "w\n"`).
|
|
128
|
+
- **No block** raises `RuntimeError "No block given"`.
|
|
129
|
+
- **Fork mode** (`do_fork: true`) runs the block in a child process instead of
|
|
130
|
+
a thread: the child purges registered input pipes, closes `sout`, yields,
|
|
131
|
+
`exit! 0`; the parent closes `sin` and sets the pid on `sout` via
|
|
132
|
+
`ConcurrentStream.setup(sout, :pids => [pid])` — no threads, no callbacks.
|
|
133
|
+
`close: false` in the child leaves `sin` open after the block returns
|
|
134
|
+
(verified: `fork mode: "from-fork\n"`, `fork noclose: "fork-noclose\n"`).
|
|
135
|
+
- **Thread mode** pairs the two ends (`pair`), runs the block through
|
|
136
|
+
`ConcurrentStream.process_stream` (close+join on exit, abort on error), and
|
|
137
|
+
registers the thread on both ends. An exception in the block aborts the
|
|
138
|
+
stream and re-raises at the consumer.
|
|
139
|
+
|
|
140
|
+
### `Open.pipe`
|
|
141
|
+
|
|
142
|
+
Takes **no arguments** and returns the raw `[sout, sin]` pair from `IO.pipe`
|
|
143
|
+
(plus registering `sin` in `OPEN_PIPE_IN`). Calling it with a positional
|
|
144
|
+
argument raises `ArgumentError` (probe `probe_19_streaming_apis.rb`:
|
|
145
|
+
`Open.pipe positional: ArgumentError`). There is no multi-command helper here —
|
|
146
|
+
chain commands by feeding one stream into `:in` of the next `CMD.cmd`.
|
|
147
|
+
|
|
148
|
+
### `Open.tee_stream(stream)` / `tee_stream_thread_multiple(stream, num)`
|
|
149
|
+
|
|
150
|
+
Returns an **Array** of streams (`num` copies, default 2): the first is the
|
|
151
|
+
"main" copy with `autojoin: true`, the rest have no autojoin. A splitter thread
|
|
152
|
+
reads the source once and writes every chunk to all copies; the main copy's
|
|
153
|
+
callback joins the source and closes the extra write ends, and its
|
|
154
|
+
`abort_callback` propagates an abort to the source and the other copies.
|
|
155
|
+
Verified: P31/P33 and `probe_19_streaming_apis.rb` (`tee_stream count: 2`,
|
|
156
|
+
`tee[0].autojoin => true`, `tee[1].autojoin => nil`).
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
main, copy = Open.tee_stream(CMD.cmd('gzip -c', :pipe => true, :in => input))
|
|
160
|
+
# write copy to disk while main feeds the next command, then join copy
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### `Open.line_monitor_stream(stream, &block)`
|
|
164
|
+
|
|
165
|
+
Builds a tee, then a monitor thread reads the monitor copy line by line calling
|
|
166
|
+
`block.call(line)` — the block therefore runs **concurrently** with whoever
|
|
167
|
+
consumes the returned stream, not after. Failures in the block abort the monitor
|
|
168
|
+
and are re-raised into the returned stream (`out.raise $!` when supported). The
|
|
169
|
+
returned stream is the second copy, annotated from the source and set up with
|
|
170
|
+
the monitor thread. Verified: P33 and `probe_19_streaming_apis.rb`.
|
|
171
|
+
|
|
172
|
+
### `Open.read_stream(stream, size)`
|
|
173
|
+
|
|
174
|
+
Blocking read of exactly `size` bytes (plain `stream.read(missing)` loop,
|
|
175
|
+
`lib/scout/open/stream.rb:401`), raising `ClosedStream` if EOF is reached
|
|
176
|
+
first. Useful for binary framing; `probe_19_streaming_apis.rb` shows
|
|
177
|
+
`read_stream(4) => "0123"`.
|
|
178
|
+
|
|
179
|
+
### `Open.sort_stream(stream, header_hash: '#', cmd_args: nil, memory: false)`
|
|
180
|
+
|
|
181
|
+
Streams `header_hash`-prefixed lines straight through, then sorts the rest.
|
|
182
|
+
`memory: false` (default) pipes the remainder into `env LC_ALL=C sort
|
|
183
|
+
<cmd_args>` (`-u` by default when `cmd_args` is nil) and consumes that stream
|
|
184
|
+
into the output; `memory: true` reads the whole remainder, sorts it in Ruby and
|
|
185
|
+
writes it out. Everything runs inside `ConcurrentStream.process_stream`, so the
|
|
186
|
+
source is closed+joined and aborted on error. Verified:
|
|
187
|
+
probe_19_streaming_apis.rb: feeding `"# header\nc\na\nb\n"` returns
|
|
188
|
+
`"# header\na\nb\nc\n"` — the header passes through untouched, the rest is
|
|
189
|
+
sorted.
|
|
190
|
+
|
|
191
|
+
### `Open.collapse_stream(s, line: nil, sep: "\t", header: nil, compact: false, &block)`
|
|
192
|
+
|
|
193
|
+
Merges consecutive lines sharing the same first field, joining the other
|
|
194
|
+
columns with `|` (or dropping empty parts when `compact: true`). An optional
|
|
195
|
+
block receives the accumulated column array and its return value becomes the
|
|
196
|
+
row payload. Verified: `probe_19_streaming_apis.rb`.
|
|
197
|
+
|
|
198
|
+
## `Open.open` block form and `DontClose`
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
res = Open.open(file) do |io|
|
|
202
|
+
next io.read if io.is_a?(String) # IO/StringIO pass straight through
|
|
203
|
+
raise DontClose.new(io.read) # payload escapes, io still closes
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`Open.open` yields and **always closes and joins** the IO afterwards (the
|
|
208
|
+
`ensure` at open.rb:70-77). Raising `DontClose` with a payload makes the block
|
|
209
|
+
form *return* the payload instead of the IO, while still closing — it is an
|
|
210
|
+
early-return mechanism, not a way to keep the handle. Verified:
|
|
211
|
+
`probe_21_dontclose.rb` (`DontClose returns payload: "payload"`, `closed after
|
|
212
|
+
DontClose: true`). Any other exception aborts, joins and re-raises the stream.
|
|
213
|
+
|
|
214
|
+
## Progress bars
|
|
215
|
+
|
|
216
|
+
Pass `:progress_bar` (a `Log::ProgressBar`, `lib/scout/log/progress.rb`; the option key is `:progress_bar` — there is no `:bar` key)
|
|
217
|
+
to `CMD.cmd` and each stderr line ticks it (`bar.process(line)` at cmd.rb:643):
|
|
218
|
+
`probe_24_bar.rb` counts 2 ticks for a 2-line stderr, in both pipe and
|
|
219
|
+
non-pipe mode. With `:log => true` (`CMD.cmd_log`) the stderr text is also
|
|
220
|
+
recorded in `stream.log`. `Log::ProgressBar` itself supports `:process =>
|
|
221
|
+
proc{|elem| elem.length}` so a tick can be weighted per element.
|
|
222
|
+
|
|
223
|
+
## Where the diagnostics go
|
|
224
|
+
|
|
225
|
+
Log output (including severity-logged stderr lines) goes to the Log logfile /
|
|
226
|
+
STDERR. **`std_err` is the per-stream capture**, filled by `:save_stderr` — see
|
|
227
|
+
[Running Commands](RunningCommands.md#save-stderr--capture-stderr-instead-of-logging-it).
|
|
228
|
+
There is no "paired stderr stream" object; in pipe mode stderr is drained by a
|
|
229
|
+
thread inside `CMD.cmd`.
|
|
230
|
+
|
|
231
|
+
## Related
|
|
232
|
+
|
|
233
|
+
- [Running Commands](RunningCommands.md) — how these streams are produced.
|
|
234
|
+
- [Streaming Model](../developer/StreamingModel.md) — setup, join/abort
|
|
235
|
+
internals, exception propagation.
|
|
236
|
+
- [Working with Files](WorkingWithFiles.md) — `Open.read/write` on top.
|