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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +26 -12
  3. data/README.md +83 -112
  4. data/VERSION +1 -1
  5. data/doc/Improvements.md +226 -0
  6. data/doc/StartHere.md +122 -0
  7. data/doc/developer/AnnotationSystem.md +184 -0
  8. data/doc/developer/Architecture.md +147 -0
  9. data/doc/developer/Configuration.md +238 -0
  10. data/doc/developer/CoreUtilities.md +265 -0
  11. data/doc/developer/DesignPrinciples.md +129 -0
  12. data/doc/developer/ErrorHandling.md +203 -0
  13. data/doc/developer/LockingAndConcurrency.md +157 -0
  14. data/doc/developer/PathResolution.md +200 -0
  15. data/doc/developer/PersistenceAndResources.md +119 -0
  16. data/doc/developer/StreamingModel.md +236 -0
  17. data/doc/user/AnnotatingData.md +202 -0
  18. data/doc/user/CachingResults.md +183 -0
  19. data/doc/user/CommandLineOptions.md +189 -0
  20. data/doc/user/Cookbook.md +211 -0
  21. data/doc/user/HandlingStreams.md +236 -0
  22. data/doc/user/LoggingAndProgress.md +158 -0
  23. data/doc/user/ProducingResources.md +177 -0
  24. data/doc/user/RemoteData.md +157 -0
  25. data/doc/user/RunningCommands.md +218 -0
  26. data/doc/user/WorkingWithFiles.md +217 -0
  27. data/lib/scout/cmd.rb +343 -40
  28. data/lib/scout/concurrent_stream.rb +14 -1
  29. data/lib/scout/indiferent_hash.rb +1 -1
  30. data/lib/scout/log/fingerprint.rb +13 -8
  31. data/lib/scout/log/progress/report.rb +1 -1
  32. data/lib/scout/log.rb +4 -1
  33. data/lib/scout/misc/digest.rb +6 -5
  34. data/lib/scout/misc/format.rb +24 -0
  35. data/lib/scout/named_array.rb +1 -1
  36. data/lib/scout/open/stream.rb +2 -2
  37. data/lib/scout/open/util.rb +8 -4
  38. data/lib/scout/open.rb +3 -3
  39. data/lib/scout/path/find.rb +3 -2
  40. data/lib/scout/persist.rb +14 -10
  41. data/lib/scout/resource/produce.rb +9 -1
  42. data/research/annotations-data-analysis.md +206 -0
  43. data/research/behavior-probes.md +1925 -0
  44. data/research/commands-streaming-analysis.md +272 -0
  45. data/research/design-philosophy-analysis.md +383 -0
  46. data/research/doc-audit-findings.md +294 -0
  47. data/research/ecosystem-attribution.md +118 -0
  48. data/research/implementation-inventory-core.md +1029 -0
  49. data/research/implementation-inventory-open.md +417 -0
  50. data/research/implementation-inventory-path-persist-resource.md +774 -0
  51. data/research/io-paths-analysis.md +228 -0
  52. data/research/persistence-resources-analysis.md +244 -0
  53. data/research/synthesis-report.md +80 -0
  54. data/scout-essentials.gemspec +37 -15
  55. data/test/scout/open/test_remote.rb +1 -2
  56. data/test/scout/test_cmd.rb +411 -0
  57. metadata +36 -14
  58. data/doc/Annotation.md +0 -352
  59. data/doc/CMD.md +0 -363
  60. data/doc/ConcurrentStream.md +0 -163
  61. data/doc/IndiferentHash.md +0 -240
  62. data/doc/Log.md +0 -235
  63. data/doc/NamedArray.md +0 -174
  64. data/doc/Open.md +0 -331
  65. data/doc/Path.md +0 -217
  66. data/doc/Persist.md +0 -214
  67. data/doc/Resource.md +0 -229
  68. data/doc/SimpleOPT.md +0 -236
  69. data/doc/TmpFile.md +0 -154
@@ -0,0 +1,218 @@
1
+ # Running Commands
2
+
3
+ `CMD` is scout-essentials' subprocess layer: one entry point, `CMD.cmd`, that
4
+ covers shell commands, no-shell command arrays, stdin piping, timeouts, stderr
5
+ handling and external-tool bootstrap. This page documents what the code does
6
+ (`lib/scout/cmd.rb`); for the object `:pipe => true` hands back, see
7
+ [Handling Streams](HandlingStreams.md) and the
8
+ [Streaming Model](../developer/StreamingModel.md).
9
+
10
+ ## Command forms
11
+
12
+ `CMD.cmd(tool, cmd, options)` accepts three shapes:
13
+
14
+ ```ruby
15
+ CMD.cmd('echo hello').read # => "hello\n" (String: run by a shell)
16
+ CMD.cmd(['echo', 'array-form']).read # => "array-form\n"
17
+ CMD.cmd('echo', 'arg2', '-n' => true, '-r' => true).read
18
+ ```
19
+
20
+ - **String** — `tool` alone, or `tool + ' ' + cmd` when both are given. The
21
+ string is handed to `Open3.popen3(ENV, cmd)`, so shell features work.
22
+ - **Array** — no shell is involved; `Open3.popen3(ENV, *cmd_array)` is called
23
+ with the array plus each option as a separate argument. `process_cmd_options_array`
24
+ turns `'n' => 'val'` into `['n', 'val']` (or `['n=val']` for a key ending in
25
+ `=`), so quoting is not an issue here.
26
+ - **Hash-only** (`CMD.cmd({'echo' => 'x'})`) is *not* a command form; the Hash is
27
+ treated as options, `cmd`/`tool` stay nil and the call fails.
28
+
29
+ Verified: probe `tmp/rewrite_B/probe_15_cmd_forms.rb` (string, string+cmd,
30
+ array, array+string, symbol tool with nil cmd, ProcessFailed on hash-only).
31
+
32
+ ### The `'{opt}'` placeholder
33
+
34
+ In String form the processed options are substituted for the literal
35
+ placeholder `'{opt}'` **only when it is single-quoted** in the command string:
36
+
37
+ ```ruby
38
+ CMD.cmd("cut -d' ' -f2 '{opt}'", '-n' => true, in: 'one two three').read
39
+ # => "two\n" options replaced the quoted placeholder
40
+
41
+ CMD.cmd("echo {opt} a 1", 'x' => 1).read # => "{opt} a 1\n"
42
+ CMD.cmd("echo '{opt}' a 1", 'x' => 1).read # => "a 1\n" (note: trailing space)
43
+ ```
44
+
45
+ Unquoted `{opt}` is left untouched — the substitution matches `'\{opt\}'`
46
+ exactly. Without a placeholder the option string is appended to the command.
47
+ Verified: probe `probe_14_cmd_opts.rb` lines 10-11 and the C8 fix in
48
+ `research/doc_audit/RunningCommands.md`.
49
+
50
+ ## Option quoting: `process_cmd_options`
51
+
52
+ Every option that is not one of the reserved keys below becomes part of the
53
+ command line. `CMD.process_cmd_options(options)` builds that string:
54
+
55
+ | option/value | result |
56
+ |---|---|
57
+ | `'opt' => true` | `opt` (bare flag, no value) |
58
+ | `'-v' => 'V'` | `-v 'V'` |
59
+ | `'-v=' => 'V'` | `-v='V'` (key ends in `=`: value glued, still quoted) |
60
+ | `'opt' => nil`, `'opt' => false` | dropped |
61
+ | value containing `'` | `\'`-escaped, then wrapped in `'...'` |
62
+ | `:add_option_dashes => true` | prepends `--` to keys not already starting with `-` |
63
+
64
+ The quoting rule is uniform: the value is always wrapped in single quotes, and
65
+ a value containing an apostrophe has it backslash-escaped first. There is no
66
+ "spaces only" special case.
67
+
68
+ **Key validation**: an option key that does not match `/^[a-z_0-9\-=.]+$/i`
69
+ raises `Invalid option key` before anything runs.
70
+
71
+ **Arrays are not expanded.** An Array value is stringified (`to_s`) and quoted
72
+ like any other value — `"#{value}"` produces `n '["a", "b"]'` in String mode
73
+ and `['n', '["a", "b"]']` in array mode. Pass separate calls or build the
74
+ command yourself if you need repeated flags. Verified: `probe_14_cmd_opts.rb`
75
+ lines 4-5 and the cmd-level Array check.
76
+
77
+ Verified examples: `probe_14_cmd_opts.rb` (dashes, `=`, nil/false, true,
78
+ apostrophes, invalid key, arrays).
79
+
80
+ ## stderr: severities, capture and files
81
+
82
+ `options[:stderr]` selects how the child's stderr is treated. The default
83
+ (added by `cmd.rb:181`) is **`Log::DEBUG`** — stderr lines are only logged at
84
+ debug severity, i.e. invisible unless you raise `Log.severity` above it.
85
+
86
+ - `:stderr => true` is normalised to `Log::HIGH` (probe `probe_23_bar_log.rb`).
87
+ - Any other Integer is a `Log` severity: the ladder is
88
+ `DEBUG=0, LOW=1, MEDIUM=2, HIGH=3, INFO=4, WARN=5, ERROR=6, NONE=7`
89
+ (`lib/scout/log.rb` `SEVERITY_NAMES`), so `:stderr => Log::MEDIUM` shows
90
+ stderr as warnings while staying quieter than `HIGH`.
91
+ - Log output itself goes to the Log logfile / STDERR (see the
92
+ [Streaming Model](../developer/StreamingModel.md) for what per-stream capture
93
+ means); the per-command stderr *text* is never attached to the stream.
94
+
95
+ ### `:save_stderr` — capture stderr instead of logging it
96
+
97
+ `ee24c68` extended `:save_stderr` to three shapes. All of them also fill the
98
+ `std_err` attribute (String on non-pipe results, on the returned stream in pipe
99
+ mode), so you can inspect it after the fact:
100
+
101
+ - **`:save_stderr => true`** — the text is captured into `std_err` and nothing
102
+ is logged.
103
+ - **`:save_stderr => path`** (String, Scout `Path` or `Pathname`) — CMD opens
104
+ the path for writing (truncating), **creates missing parent directories**,
105
+ writes stderr to it **line-buffered** so `tail -f` can follow a running
106
+ command, and **closes it when the command ends**. `std_err` is populated too.
107
+ - **`:save_stderr => io`** (anything responding to `write`/`<<`) — every chunk
108
+ is written and flushed, but CMD **never closes it**; closing is the caller's
109
+ business. `std_err` is populated too.
110
+
111
+ ```ruby
112
+ res = CMD.cmd('sh -c "echo err >&2; echo out"', :save_stderr => true)
113
+ res2 = CMD.cmd(..., :save_stderr => 'log/cmd.err') # nested dirs created
114
+ dst = File.open('err.txt', 'w')
115
+ CMD.cmd(..., :save_stderr => dst) # dst stays open
116
+ ```
117
+
118
+ Implementation: `cmd.rb:194-217` (destination setup), the writer thread /
119
+ inline writer, and the `ensure` at `cmd.rb:600-612` that flushes and closes a
120
+ CMD-owned file. Verified by `test/scout/test_cmd_save_stderr.rb` (13 tests,
121
+ incl. live `tail -f` polling) and probes `probe_17_save_stderr.rb`
122
+ (boolean/String/Path/Pathname/IO/StringIO, pipe and non-pipe, truncation) and
123
+ `probe_16_exitstatus.rb` (`std_err` populated in both modes).
124
+
125
+ ## Exit status, `no_fail` and failure
126
+
127
+ - Non-pipe mode: `CMD.cmd(...)` waits for the child and raises
128
+ `ProcessFailed` when the exit status is non-zero, unless `:no_fail` (alias
129
+ `:nofail`) is given.
130
+ - `:no_fail => true` **suppresses** `ProcessFailed`/`ConcurrentStreamProcessFailed`
131
+ — and `exit_status` then stays `nil`, in pipe mode too. If you need the code,
132
+ call `join_pids` yourself. Verified: `probe_16_exitstatus.rb`
133
+ (`pipe read+join exit_status: nil`, `explicit join_pids exit_status: 0`,
134
+ `non-pipe exit_status: 0`).
135
+ - `exit_status` is only ever set by `ConcurrentStream#join_pids`
136
+ (`concurrent_stream.rb:127`), which also empties `pids`, so it can only be
137
+ used once. A stream that is read and joined normally has `exit_status == nil`
138
+ (probe `probe_26_join_es.rb`: `read+join: es=nil` with `joined?` true; only
139
+ an explicit early `join_pids` yields `0`). Do not rely on `stream.exit_status`.
140
+ - If the process never starts (bad executable, no such file) `ProcessFailed` is
141
+ raised immediately — also suppressed by `no_fail`, which then returns `nil`.
142
+ - A failed *producer thread* in pipe mode surfaces as
143
+ `ConcurrentStreamProcessFailed` when the consumer closes/joins the stream
144
+ (`probe_13_force_close.rb`).
145
+
146
+ ## Timeout
147
+
148
+ `CMD::Timeout < ProcessFailed` carries `command` and `timeout` and is raised by
149
+ a watchdog when `:timeout => seconds` elapses (`cmd.rb:310-408`,
150
+ `TIMEOUT_KILL_GRACE = 1.0`). This is the only way to bound a command's runtime.
151
+
152
+ - **Non-pipe mode**: the watchdog raises in the calling thread.
153
+ - **Pipe mode**: the exception is routed through `ConcurrentStream#abort` — it
154
+ lands in `stream_exception`, the stream is aborted (killing the process,
155
+ clearing pids, unblocking a blocked reader) and is re-raised when the consumer
156
+ reads or joins. It is *not* raised directly in the caller at command start.
157
+
158
+ ## Tool management
159
+
160
+ There is **no `CMD.add_tool`**. Registration is:
161
+
162
+ ```ruby
163
+ CMD.tool(:samtools, claim, test, cmd, &block) # internally stored [claim, test, block, cmd]
164
+ CMD.get_tool(:samtools) # ensures it is usable; returns the command name
165
+ CMD.versions # => {"samtools" => "1.17", ...}
166
+ CMD.conda('samtools', 'env', 'bioconda') # conda install fallback
167
+ CMD.bash(cmd) # bash -l login shell, :autojoin => true
168
+ CMD.scan_version_text(text, 'samtools') # heuristically pull a version string
169
+ CMD.cmd_log('...') # run + echo STDOUT/STDERR, returns nil
170
+ CMD.cmd_pid('...') # same implementation, also returns nil
171
+ # (both force :pipe/:log; the pid only shows up
172
+ # inside the 'STDOUT [pid]:' header)
173
+ ```
174
+
175
+ `get_tool` runs `test` (or `command -v cmd`), and if that fails produces the
176
+ `claim` Resource (or calls `block`; a Hash result is passed to
177
+ `Resource.install`). It then records a version from `--version`/`-version`/
178
+ `--help` output. Tools are stored in the `TOOLS` IndiferentHash; `versions`
179
+ returns only entries matching `/\d+\./`.
180
+
181
+ ## Options reference (consumed by `CMD.cmd`)
182
+
183
+ | key | effect |
184
+ |---|---|
185
+ | `:in` | stdin: a String is written by a thread; an IO/StringIO is read; a ConcurrentStream is streamed (and closed unless `:dont_close_in`). Also `:in_pipe` for a pipe-backed writer. Verified: `probe_25_keeping_in.rb` (String, IO, stream, `in_pipe` returns an IO). |
186
+ | `:pipe` | return a ConcurrentStream instead of the text/StringIO |
187
+ | `:stderr` | severity for stderr logging (default `Log::DEBUG`); `true` → `Log::HIGH` |
188
+ | `:save_stderr` | `true` / path / IO — see above |
189
+ | `:no_fail`, `:nofail` | suppress failure raising (both spellings) |
190
+ | `:autojoin` | join the stream when it is closed/read; **`CMD.cmd` sets `:autojoin => no_fail`** |
191
+ | `:no_wait` | alias used to default `autojoin` (`autojoin = no_wait if autojoin.nil?`) |
192
+ | `:timeout` | seconds; watchdog; only runtime bound |
193
+ | `:post` | proc run after the command/stream finishes (teardown, forcing upstream closes) |
194
+ | `:progress_bar` | a `Log::ProgressBar`; stderr lines tick it (`probe_24_bar.rb`: 2 ticks in both pipe and non-pipe mode) |
195
+ | `:log` | defaults to `true` (`log = true if log.nil?`, cmd.rb:224): pipe-mode stderr lines are `Log.log`ged at the chosen `:stderr` severity. `:log => false` silences that logging. Not the same as `CMD.cmd_log` (a separate helper that forces `:pipe`/`:log`). |
196
+ | `:sudo`, `:xvfb` | prefix the command |
197
+ | `:dont_close_in` | keep the `:in` stream open |
198
+ | `:add_option_dashes` | passed through to `process_cmd_options*` |
199
+
200
+ `:wait`, `:canfail`, `:empty_inputs` and `:separator` are **not** options of
201
+ `CMD.cmd` in this repo — `cmd.rb` deletes none of them, so they would be
202
+ forwarded to `process_cmd_options` and end up as command-line text. `:canfail`
203
+ exists on `Persist` (`persist.rb:133`) and on `Resource` claims, not here.
204
+ Verified by grepping `lib/` for the four names (`probe_25_keeping_in.rb` tail).
205
+
206
+ ## The block is ignored
207
+
208
+ `CMD.cmd(...) { ... }` accepts a block but **never calls it** — the only block
209
+ invocation inside `cmd.rb` is in `CMD.tool`. Verified live by
210
+ `tmp/docaudit/probe_hs_c15_block.rb`: in both pipe and non-pipe mode the block
211
+ body never runs, while `:post` and `stream.add_callback` do. Use
212
+ `:post => proc{}` or stream callbacks for post-join work.
213
+
214
+ ## Related
215
+
216
+ - [Handling Streams](HandlingStreams.md) — the returned object's lifecycle.
217
+ - [Streaming Model](../developer/StreamingModel.md) — internals, error paths.
218
+ - [Working with Files](WorkingWithFiles.md) — `Open.grep` on top of `CMD`.
@@ -0,0 +1,217 @@
1
+ # Working with Files
2
+
3
+ This guide explains how to read, write, and manage files in scout-essentials:
4
+ the `Open` module for I/O, compression, grepping, atomic writes, locking, and
5
+ remote file handling, plus `TmpFile` for scratch space.
6
+
7
+ ## Reading: `Open.read` and `Open.open`
8
+
9
+ ```ruby
10
+ require 'scout-essentials'
11
+
12
+ Open.read('VERSION') # => "1.8.8" (UTF-8 fixed by default)
13
+ Open.read('VERSION', nofix: true) # => raw bytes as read
14
+
15
+ Open.open('data.txt') do |f|
16
+ f.each_line { |line| ... }
17
+ end
18
+
19
+ Every stream returned by `Open.open` is extended with `Open::NamedStream`,
20
+ whose `filename` attribute records the file it was opened from (useful when
21
+ the stream flows through pipelines):
22
+
23
+ ```ruby
24
+ io = Open.open('data.txt')
25
+ io.filename # => "data.txt"
26
+ ```
27
+
28
+ - `Open.read`/`Open.open` accept a `Path` (or a `String`); a `Path` is
29
+ resolved with `find` before opening.
30
+ - Both accept options. Notable ones:
31
+ - `:mode` — open mode (`'r'` default, `'rb'` for binary reads).
32
+ - `:grep` / `:invert_grep` — filter lines while reading (below).
33
+ - `:zip` / `:gzip` / `:bgzip` — force a decompressor regardless of
34
+ extension.
35
+ - `:noz` — disable automatic decompression and read the raw file.
36
+
37
+ ## Compression
38
+
39
+ Detection is **extension-only and case-sensitive**. A file is decompressed
40
+ only when its name ends (lower-case) with `.gz`, `.bgz`, `.zip`:
41
+
42
+ ```ruby
43
+ Open.read('file.gz') # decompressed
44
+ Open.read('file.GZ') # raw gzip bytes — NOT detected
45
+ Open.read('file.tgz') # raw gzip bytes — NOT detected
46
+ Open.read('file.tar.gz')# decompressed (ends in .gz)
47
+ Open.read('file.gz.bak')# raw gzip bytes — NOT detected
48
+ Open.gzip?('file.tgz') # => false
49
+ ```
50
+
51
+ Force decompression explicitly when the name does not cooperate:
52
+
53
+ ```ruby
54
+ Open.open('file.tgz', :gzip => true).read
55
+ ```
56
+
57
+ ## Grepping lines
58
+
59
+ `:grep` and `:invert_grep` select lines using the system `grep`. **With a
60
+ block**, `Open.read` collects the transformed lines into an Array; **without
61
+ a block** it returns the matched lines as a String (and `Open.open` returns
62
+ a grep pipe):
63
+
64
+ ```ruby
65
+ # file f contains: apple / banana / cherry
66
+ Open.read(f, grep: 'an') # => "banana\n" (String)
67
+ Open.read(f, grep: 'an') { |l| l.strip.upcase } # => ["BANANA"]
68
+ Open.read(f, grep: 'an', invert_grep: true) # => "apple\ncherry\n"
69
+ Open.read(f, grep: %w(apple banana)) { |l| l.strip } # => ["apple", "banana"]
70
+ ```
71
+
72
+ **`invert_grep` has no effect on its own.** `Open.read`/`Open.open` only
73
+ grep when `grep` is truthy (`Open#file_open(file, grep = false, ...)`
74
+ extracts `:grep`/`:invert_grep` but only greps for `grep`); passing
75
+ `invert_grep:` without `grep:` returns the whole file unchanged:
76
+
77
+ ```ruby
78
+ Open.read(f, invert_grep: 'an') # => "apple\nbanana\ncherry\n" (no-op)
79
+ ```
80
+
81
+ To invert without going through `:grep`, use the low-level helper with its
82
+ third argument:
83
+
84
+ ```ruby
85
+ Open.grep(Open.open(f), 'an', true).read # => "apple\ncherry\n"
86
+ ```
87
+
88
+ `:grep` accepts a String (passed to the system `grep` as a pattern) or an
89
+ Array of Strings (written to a pattern file used with `grep -f`). Arrays are
90
+ matched with `grep -w -F` (whole-word, fixed strings) unless you pass
91
+ `fixed_grep: false`, which switches to plain `grep -f` semantics.
92
+
93
+ ## Writing: `Open.write` and appends
94
+
95
+ ```ruby
96
+ Open.write('out.txt', "content\n") # creates parent dirs
97
+ Open.write('out.txt') { |f| f.puts "x" } # block form
98
+ ```
99
+
100
+ `Open.write` always overwrites by default (`mode: 'w'`). **To append, pass
101
+ `mode: 'a'`** — there is no public `Open.append` class method:
102
+
103
+ ```ruby
104
+ Open.write('log.txt', "one\n")
105
+ Open.write('log.txt', "two\n", mode: 'a')
106
+ Open.read('log.txt') # => "one\ntwo\n"
107
+ ```
108
+
109
+ On error, `Open.write` removes the partially written file and re-raises.
110
+
111
+ ### Atomic writes: `sensible_write`
112
+
113
+ `Open.sensible_write(file, content = nil, options = {})` refuses to overwrite
114
+ an existing file unless `:force => true`; it takes a String or a block and
115
+ removes the target when the block raises (`Aborted` is swallowed silently):
116
+
117
+ ```ruby
118
+ Open.sensible_write(path, "v1")
119
+ Open.sensible_write(path, "v2") # keeps "v1"
120
+ Open.sensible_write(path, "v2", force: true) # overwrites
121
+ ```
122
+
123
+ ## File primitives
124
+
125
+ All of these accept `Path` or `String` and resolve `Path`s via `find`; most
126
+ create parent directories as needed:
127
+
128
+ | Call | Effect |
129
+ |---|---|
130
+ | `Open.cp(src, dst)` | `cp_r`, creates parent dirs, replaces `dst` |
131
+ | `Open.mv(src, dst)` | two-step move (`.tmp_mv.` intermediate), creates parent dirs |
132
+ | `Open.rm(file)` | removes a file or broken link |
133
+ | `Open.rm_rf(file)` | recursive remove |
134
+ | `Open.mkdir(dir)` | `mkdir -p` if missing |
135
+ | `Open.mkfiledir(file)` | `mkdir -p` the parent of `file` |
136
+ | `Open.touch(file)` | `touch`, creating parent dirs |
137
+ | `Open.ln(src, dst)` | hard link (falls back to `ln_s` via `Open.link`) |
138
+ | `Open.ln_s(src, dst)` | symbolic link |
139
+ | `Open.ln_h(src, dst)` | `ln -L` with `cp -L` fallback |
140
+ | `Open.link(src, dst)` | `Open.ln`, falling back to `Open.ln_s` |
141
+ | `Open.link_dir(src, dst)` | `cp -lr` — copy a directory tree of hard links |
142
+ | `Open.same_file(a, b)` | `File.identical?` |
143
+ | `Open.exists?(f)`, `Open.directory?(f)`, `Open.size(f)`, `Open.ctime(f)`, `Open.mtime(f)` | stats |
144
+
145
+ ```ruby
146
+ src = 'data/src.txt'
147
+ Open.write(src, 'S')
148
+ Open.cp(src, 'data/sub/dst.txt') # parents created
149
+ Open.mv('data/sub/dst.txt', 'data/m.txt')
150
+ Open.touch('data/t/t.txt')
151
+ Open.ln(src, 'data/hard.txt') # nlink > 1
152
+ Open.link_dir('data/a', 'data/a2')
153
+ Open.same_file(src, 'data/hard.txt') # => true
154
+ ```
155
+
156
+ `Open.mtime` has one special case: for a symlink or a file with multiple
157
+ hard links it consults a sibling `.info` file when `Step` is defined (a
158
+ scout-gear concept) and falls back to `Pathname#realpath`; otherwise it is a
159
+ plain `File.mtime`.
160
+
161
+ ## Locking: `Open.lock`
162
+
163
+ ```ruby
164
+ Open.lock('var/cache/persistence/file') do
165
+ # exclusive access while the block runs
166
+ end
167
+ ```
168
+
169
+ `Open.lock(file, unlock = true, options = {})` uses the vendored `Lockfile`
170
+ implementation. The lock file lives next to the target by default; pass a
171
+ `Lockfile` instance via `options[:lock]` to reuse an existing one. See
172
+ [Caching Results](CachingResults.md) for how `Persist` uses it.
173
+
174
+ ## Streams and `consume_stream`
175
+
176
+ `Open.open_pipe` returns a `ConcurrentStream` that you can consume lazily;
177
+ `Open.consume_stream(stream)` reads it to the end (and joins it), and
178
+ `Open.sensible_write` accepts a stream, writing it asynchronously. When a
179
+ stream carries a `filename`, `NamedStream` records it so tools can recover
180
+ the original name. See
181
+ [Streaming Model](../developer/StreamingModel.md).
182
+
183
+ ## Remote files
184
+
185
+ `Open.read`/`Open.open` transparently handle `http(s)://` and `ssh:` URLs by
186
+ shelling out (`Open.wget`, `Open.ssh`), and `Open.sync` / `Open.rsync` copy
187
+ remote trees into the local cache (`Open.remote_cache_dir`, default
188
+ `$HOME/.scout/var/cache/open-remote`). Remote fetching is covered in
189
+ [RemoteData.md](RemoteData.md).
190
+
191
+ ## Scratch files: `TmpFile`
192
+
193
+ ```ruby
194
+ TmpFile.tmp_file('prefix') # => ".../tmpfiles/tmp-<n>" (a fresh path)
195
+ TmpFile.user_tmp # => $HOME/tmp/scout
196
+ TmpFile.tmp_for_file('a/b/c') # => "·a·b·c" (flat, separators replaced with ·)
197
+ TmpFile.with_file(content) do |file| ... end # writes, yields the path, removes it
198
+ TmpFile.with_dir do |dir| ... end
199
+ ```
200
+
201
+ `TmpFile.tmp_for_file` flattens the whole path into a single file name
202
+ inside the tmpfiles directory (`/` → `·`), it does **not** create a nested
203
+ directory structure; `TmpFile.tmp_for_dir` does not exist. Options may
204
+ append a `[key]` and an `&F[...]` fingerprint, and names are truncated to
205
+ `MAX_FILE_LENGTH` (150).
206
+
207
+ ## Related
208
+
209
+ - [Caching Results](CachingResults.md) — `Persist` uses `Open.lock` and
210
+ `sensible_write`.
211
+ - [Path Resolution](../developer/PathResolution.md) — `find`/`follow`, path
212
+ maps.
213
+ - [Producing Resources](ProducingResources.md) — Resource uses Path for
214
+ resolution.
215
+ - For streaming internals, see
216
+ [Streaming Model](../developer/StreamingModel.md).
217
+ - For remote data, see [RemoteData.md](RemoteData.md).