scout-essentials 1.8.8 → 1.9.1

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 (67) 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/filesystem.rb +2 -2
  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 +4 -0
  38. data/lib/scout/path/find.rb +3 -2
  39. data/lib/scout/persist.rb +14 -10
  40. data/research/annotations-data-analysis.md +206 -0
  41. data/research/behavior-probes.md +1925 -0
  42. data/research/commands-streaming-analysis.md +272 -0
  43. data/research/design-philosophy-analysis.md +383 -0
  44. data/research/doc-audit-findings.md +294 -0
  45. data/research/ecosystem-attribution.md +118 -0
  46. data/research/implementation-inventory-core.md +1029 -0
  47. data/research/implementation-inventory-open.md +417 -0
  48. data/research/implementation-inventory-path-persist-resource.md +774 -0
  49. data/research/io-paths-analysis.md +228 -0
  50. data/research/persistence-resources-analysis.md +244 -0
  51. data/research/synthesis-report.md +80 -0
  52. data/scout-essentials.gemspec +37 -15
  53. data/test/scout/misc/test_filesystem.rb +10 -0
  54. data/test/scout/test_cmd.rb +411 -0
  55. metadata +36 -14
  56. data/doc/Annotation.md +0 -352
  57. data/doc/CMD.md +0 -363
  58. data/doc/ConcurrentStream.md +0 -163
  59. data/doc/IndiferentHash.md +0 -240
  60. data/doc/Log.md +0 -235
  61. data/doc/NamedArray.md +0 -174
  62. data/doc/Open.md +0 -331
  63. data/doc/Path.md +0 -217
  64. data/doc/Persist.md +0 -214
  65. data/doc/Resource.md +0 -229
  66. data/doc/SimpleOPT.md +0 -236
  67. data/doc/TmpFile.md +0 -154
@@ -0,0 +1,158 @@
1
+ # Logging and Progress
2
+
3
+ `Log` is the single reporting surface: a severity-gated logger that writes to
4
+ STDERR, plus a stack of progress bars that render in the same place.
5
+ `lib/scout/log.rb`, `log/color.rb`, `log/fingerprint.rb`, `log/progress.rb`,
6
+ `log/progress/util.rb` and `log/progress/report.rb` are the whole
7
+ implementation.
8
+
9
+ ## Severity ladder
10
+
11
+ `Log::SEVERITY_NAMES` is `%w(DEBUG LOW MEDIUM HIGH INFO WARN ERROR NONE)` with
12
+ integer constants `DEBUG=0 .. NONE=7`. `INFO` sits in the middle (4), not at
13
+ the top: `LOW`/`MEDIUM`/`HIGH` are *more verbose than info*.
14
+
15
+ The severity is set at require time from `ENV['SCOUT_LOG']` first
16
+ (`lib/scout/log.rb:36-54`): the known names (`DEBUG`, `LOW`, `MEDIUM`, `HIGH`,
17
+ `WARN`, `ERROR`, `NONE`) are honoured, and anything else — including an
18
+ unrecognised value — falls back to `Log.default_severity`, which reads
19
+ `~/.scout/etc/log_severity` if that file exists and is `INFO` otherwise
20
+ (log.rb:23-34).
21
+
22
+ Verified by `tmp/rewrite_C/probe_04_log_severity.rb` (out04.txt): ladder
23
+ order, `Log.severity == 4` with no env/file, `Log.with_severity(level){}`
24
+ restoring the original value after the block.
25
+
26
+ Change it at runtime with `Log.severity = Log::DEBUG`, or scope a block with
27
+ `Log.with_severity(level){ ... }`, or use the top-level `sss(level)` /
28
+ `sss(level){ ... }` helper (log.rb:430).
29
+
30
+ ## Where output goes, and how it is protected
31
+
32
+ - `Log.log_write` / `Log.log_puts` wrap every write in `Log::MUTEX`
33
+ (log.rb:141-163). If a logfile was installed, output goes there; otherwise
34
+ STDERR (IOError swallowed).
35
+ - **`Log.logfile(path)` sets the logfile; `Log.logfile` with no argument does
36
+ NOT read it back — it resets it to `nil`** (log.rb:110-113). There is no
37
+ reader; the accessor is only `Log.logfile=` (`attr_writer`).
38
+ - `Log::LAST` is one shared mutable `String` (starts as `"log"`, never frozen)
39
+ used as a protocol between `logn` and progress-bar printing so bars know how
40
+ many lines to move up and which kind of output came last
41
+ (`progress/report.rb` `print`/`report`). Probe out04.txt confirms
42
+ `class == String`, `frozen? == false`.
43
+
44
+ ## Message forms
45
+
46
+ - `Log.debug "msg"` .. `Log.error "msg"` print when severity allows; the top
47
+ of the ladder is the `Log::NONE` constant, not a method.
48
+ - `Log.debug { "expensive " + build }` — **the block is lazy message
49
+ evaluation, not a timer**: it is only called if the level passes. Probe
50
+ (out05.txt): a counter incremented inside a block passed to `Log.debug`
51
+ stays at 0 under ERROR severity and reaches 1 under DEBUG severity.
52
+ - `Log.exception(e)` (log.rb:254) prints `BACKTRACE` lines derived from
53
+ `e.backtrace` after fingerprinting messages longer than 1000 chars; it
54
+ returns early when the message contains `NOLOG` and drops the backtrace when
55
+ it contains `NOSTACK`.
56
+ - `Log.stack(stack)` (log.rb:318) prints a magenta header then the stack
57
+ **reversed** (innermost frame first) unless `SCOUT_ORIGINAL_STACK=true`.
58
+ Probe (out05.txt): a caller list `a.rb:1, b.rb:2, c.rb:3` prints as
59
+ `c.rb:3`, `b.rb:2`, `a.rb:1`.
60
+
61
+ ## Top-level debug helpers (defined on Object)
62
+
63
+ `ppp(msg)` (log.rb:358) prints a cyan `PRINT:` banner plus the caller line and
64
+ pretty-prints the message. `fff(obj)` (log.rb:374) logs the fingerprint at
65
+ DEBUG. `ddd/lll/mmm/iii/wwww/eee` inspect an object at the matching severity,
66
+ and the `f`-suffixed variants (`ddf`, `llf`, `mmf`, `iif`, `wwwf`, `eef`)
67
+ fingerprint it first. `sss(level[, &blk])` sets or scopes severity. `ccc`
68
+ (log.rb:439) enables `$scout_debug_log` around a block so nested `ccc` calls
69
+ print. Probe out05.txt confirms all five are top-level methods defined inside
70
+ `lib/scout/log.rb`.
71
+
72
+ ## Colors
73
+
74
+ - `Log.color(:red, "text")` — symbol color name. Produces `"\e[31mtext\e[0m"`.
75
+ - Passing a *String* name does not resolve the color table; the string is used
76
+ as the literal prefix and an escape is appended (`"redred\e[0m"`). Use
77
+ symbols.
78
+ - `Log.uncolor(str)` strips ANSI codes. `Log.nocolor` is true when
79
+ `ENV['SCOUT_NOCOLOR'] == 'true'` (exact string), which also blanks the cursor helpers
80
+ `up_lines`/`down_lines`/`return_line`/`clear_line`.
81
+
82
+ ## Fingerprints
83
+
84
+ `Log.fingerprint(obj)` (log/fingerprint.rb:18) builds a stable, truncated text
85
+ representation for logging big values. It dispatches: objects responding to
86
+ `fingerprint` are delegated to; `nil/true/false/Symbol` are rendered
87
+ literally; strings longer than `FP_MAX_STRING` (150) are truncated to a
88
+ digest-marked middle (`"xxx<...400 - 0d723...>xxx"`); arrays longer than 20
89
+ show first/middle/last elements; hashes longer than 10 fall back to
90
+ keys+values fingerprints; floats get 1/3/6 decimals depending on magnitude.
91
+
92
+ The signature is `fingerprint(obj)` — **one positional argument**; there are no
93
+ `max_length`/`sep` options (probe_05). Hash rendering separates pairs with a
94
+ space, not a comma: `Log.fingerprint({a: 1, b: 2, c: 3})` gives
95
+ `"{:a=>1 :b=>2 :c=>3}"`.
96
+
97
+ ## Progress bars
98
+
99
+ `Log::ProgressBar` instances are created with the class helpers in
100
+ `progress/util.rb`:
101
+
102
+ - `Log::ProgressBar.new_bar(max, options = {})` — also accepts a plain Hash
103
+ alone (`new_bar(:max => 50)`); `cleanup_bars` runs first and `:depth`
104
+ defaults to the current bar-stack depth plus offset. `new_bar(true)` treats
105
+ `true` as "no max" (`max = nil if TrueClass === max`, progress.rb:38).
106
+ - `Log::ProgressBar.with_bar(max = nil, options = {})` (util.rb:85) — wraps a
107
+ block; `KeepBar` keeps the bar alive, any other exception marks it errored
108
+ and re-raises.
109
+ - `Log::ProgressBar.with_obj_bar(obj, bar = true, &block)` (util.rb:167) —
110
+ the *second* argument (not `obj`) picks the bar: a String is the
111
+ description, `true` calls `guess_obj_max(obj)` (which — see the caveat
112
+ below — returns `nil` in a bare scout-essentials process), a Numeric is the
113
+ explicit max, a Hash carries `:max` and other options, and an existing
114
+ `Log::ProgressBar` is reused
115
+ (util.rb:139-162). **The block receives only the bar**:
116
+ `with_obj_bar(list, 'Processing'){ |bar| ... }`.
117
+ - `Log.no_bar` / `Log.no_bar=` / `SCOUT_NO_PROGRESS=true` disable ticking
118
+ entirely (progress.rb:5-11). There is no `Log.bar` module method.
119
+
120
+ Instance API (`progress.rb`): `init`, `tick(step = 1)`, `pos(pos)`,
121
+ `process(elem)`, `percent`, and the `max/ticks/frequency/depth/desc/file/
122
+ bytes/process/callback/severity` accessors.
123
+
124
+ - `percent` returns 0 with no ticks, **100 when `max == 0`**, and integer
125
+ division otherwise.
126
+ - `tick` is a no-op when `Log.no_bar`; it reports when `diff >= frequency`
127
+ (default 2 s) or when the percent advanced and `diff > 0.3` — that ~0.3 s
128
+ throttle plus the frequency gate is the render throttling.
129
+ - `report(io = STDERR)` redraws the whole active-bar stack with cursor
130
+ movement. `report_msg` shows **elapsed time and rate** and, when `max` is
131
+ set, an ETA: `· <rate> per sec. -- <dots> <pct>% <eta> => <elapsed> -
132
+ <ticks> of <max> items · <desc>`; with no `max` the ETA part is replaced
133
+ by `<ticks> items` (probe_12 shows both forms).
134
+ - `add_offset` / `remove_offset` / `offset` (util.rb:9-28) indent bars created
135
+ in nested threads.
136
+ - `file:` option: `save` writes the bar state to YAML (`:desc, :last_count,
137
+ :last_percent, :last_time, :max, :start, :ticks`) and `done`/`error` remove
138
+ the file, so a bar given a `file:` **resumes across runs** — the persisted
139
+ YAML is reloaded into `ticks` (probe_06 round-trips 3 ticks).
140
+
141
+ All bar bookkeeping is guarded by `BAR_MUTEX`; per-bar `tick` state is not
142
+ synchronized and races by design.
143
+
144
+ ### A scout-essentials-only caveat
145
+
146
+ `guess_obj_max` (util.rb:101-136) matches `TSV` and `Step` *before* `Array` and
147
+ `Hash`. In a plain `require 'scout-essentials'` process neither `TSV` nor
148
+ `Step` exists, so the `when TSV` arm raises `NameError`, the rescue turns it
149
+ into `nil`, and **`guess_obj_max` always returns `nil`** — i.e.
150
+ `get_obj_bar(list, true).max == nil` (probe_06). Pass an explicit Numeric or a
151
+ `:max` hash, or define those constants in the embedding repo, to get a real
152
+ max.
153
+
154
+ ## Related
155
+
156
+ - [Cookbook](Cookbook.md) — progress bars inside real recipes.
157
+ - [Architecture](../developer/Architecture.md) — where `Log` sits and
158
+ cross-repo attribution.
@@ -0,0 +1,177 @@
1
+ # Producing Resources
2
+
3
+ A **resource** is a logical file — a name like `data/config.yaml` — that a
4
+ software package can materialize on demand. This page explains the `claim`
5
+ syntax, how `produce` uses claims to write files, and the helpers around
6
+ them.
7
+
8
+ ## Declaring a resource
9
+
10
+ Any module that `extend`s `Resource` becomes a resource package:
11
+
12
+ ```ruby
13
+ require 'scout-essentials'
14
+
15
+ module MyApp
16
+ extend Resource
17
+ annotation :pkgdir
18
+ self.pkgdir = 'myapp'
19
+ end
20
+
21
+ MyApp.claim MyApp.data.config, :string, "key=value\n"
22
+ ```
23
+
24
+ Inside such a module the idiomatic form (note: no `self.` prefix on `claim`)
25
+ is:
26
+
27
+ ```ruby
28
+ module MyApp
29
+ extend Resource
30
+ annotation :pkgdir
31
+ self.pkgdir = 'myapp'
32
+
33
+ claim data.file, :string, "content\n"
34
+ end
35
+ ```
36
+
37
+
38
+ - `claim(path, type, content = nil, &block)` — `type` is a **mandatory
39
+ positional** argument (`claim path { }` raises `ArgumentError`). Passing
40
+ only the path plus a block is not a valid call.
41
+ - `type` is one of `:string`, `:url`, `:proc`, `:rake`, `:install`.
42
+ `:csv` is an unimplemented stub that raises `RuntimeError
43
+ "TSV/CSV Not implemented yet"` when produced. There is **no `:annotation`
44
+ claim type**.
45
+ - `path` is normally a `Path` built with `method_missing` segments
46
+ (`MyApp.data.config`); a plain `String` is also accepted.
47
+
48
+ ## Claim types
49
+
50
+ | Type | `content` | Effect |
51
+ |------|-----------|--------|
52
+ | `:string` | String | written verbatim |
53
+ | `:url` | source path/URL | the source is opened (with `:noz` when it looks compressed) and copied to the final path |
54
+ | `:proc` | `Proc` (arity 0 or 1) | called with no args (arity 0) or the final path (arity 1); the result is dispatched (below) |
55
+ | `:rake` | Rakefile directory/task | `run_rake` builds the file (below) |
56
+ | `:install` | software spec hash | `Resource.install` installs software into `share/software` (below) |
57
+
58
+ `:proc` result dispatch — the returned value drives how the file is written:
59
+
60
+ - `String`, `IO`, `StringIO` → `Open.sensible_write(final_path, data)`
61
+ - `Array` → elements joined with `"\n"` then written
62
+ - `TSV` / `TSV::Dumper` → the dumper stream is written (requires
63
+ `rbbt-util`/scout-gear; **a `nil` return trips the `when TSV` branch
64
+ first** and raises `NameError: uninitialized constant Resource::TSV`, so
65
+ procs that may return nil must guard themselves)
66
+ - `nil` → nothing is written (file left missing) — but see the caveat above
67
+ - anything else → `RuntimeError "Unkown object produced: ..."`
68
+
69
+ ## Producing
70
+
71
+ `Path#produce` (in `lib/scout/resource/path.rb`) is the entry point:
72
+
73
+ ```ruby
74
+ MyApp.data.config.produce # returns the logical path (a Path)
75
+ MyApp.data.config.find # => "$HOME/.myapp/data/config" (now existing)
76
+ Open.read(MyApp.data.config.find) # => "key=value\n"
77
+ ```
78
+
79
+ - It never returns nil: on `ResourceNotFound` (nothing claims the path) it
80
+ stores `false` and returns it, and that decision is **latched** in
81
+ `@produced` — a later call does not retry unless you pass `force`.
82
+ - A raised `Exception` stored in `@produced` is re-raised on the next call.
83
+ - On a successful produce the target is written under a **produce lock**
84
+ (`TmpFile.tmp_for_file(final_path, dir: lock_dir)` + `Open.lock`).
85
+ - If nothing claims the plain path but a `.gz` or `.bgz` variant is claimed,
86
+ production falls through to those extensions. The fall-through is
87
+ asymmetric: producing `x.txt` may materialize `x.txt.gz`, but asking for
88
+ `x.gz` when only `x.txt` is claimed fails.
89
+ - `force: true` (or `produce(true)`) removes the existing file and reruns.
90
+
91
+ ```ruby
92
+ MyApp.data.config.produce(true) # force re-production
93
+ ```
94
+
95
+ ### Extension-aware helpers
96
+
97
+ - `produce_and_find(extension = nil, *args)` — produce (falling back to the
98
+ given extension with `produce_with_extension`) and return the located
99
+ path; raises `RuntimeError "Not found"` if the result is still missing.
100
+ - `produce_with_extension(extension, *args)` — try the plain path first, then
101
+ the variant with `extension` appended; raise the *first* exception if both
102
+ fail.
103
+ - `find_with_extension(ext, *args, produce: true)` — `find` first; if the
104
+ plain result does not exist, look for `self.set_extension(ext)`.
105
+ `produce:` defaults to **true**, so it may trigger a produce.
106
+ - `exists?(produce: true)` — default **produces** before answering; pass
107
+ `false` for a pure existence check.
108
+
109
+ ### Produce-aware `Path` I/O
110
+
111
+ `Path#read` and `Path#open` call `produce` first; `Path#list` is
112
+ `produce_and_find('list')` then `Open.list`. `Path#write` does **not**
113
+ produce — it writes straight to `self.find` (use it to place a manual file
114
+ under the resource tree):
115
+
116
+ ```ruby
117
+ MyApp.data.manual.write("manual\n")
118
+ # => writes to the found location, no claim consulted
119
+ ```
120
+
121
+ ## Rake claims
122
+
123
+ A rake claim points at a directory containing a `Rakefile`:
124
+
125
+ - `rake_dirs` lists candidate directories; `has_rake?(path)` is true when a
126
+ rake prefix matches, and `rake_for(path)` returns the longest matching
127
+ prefix (an empty-string prefix matches everything).
128
+ - Production runs the task through `ScoutRake` (forked execution). If rake
129
+ reports `Don't know how to build task`, the walk-up retry joins the task
130
+ name with the parent directory and retries; if that also fails, the error
131
+ is converted to `ResourceNotFound`.
132
+ - A `Path` whose pkgdir is the resource module is produced with
133
+ `pkgdir.produce(self)`; `Path#produce` on a String path returns `false`.
134
+
135
+ ```ruby
136
+ # Rakefile in ./ with: file "data/built.txt" do |t| Open.write(t.name, "x") end
137
+ MyApp.data.built.txt.produce
138
+ Open.read(MyApp.data.built.txt.find) # => "x"
139
+ ```
140
+
141
+ ## Software installs
142
+
143
+ `:install` claims (and `Resource.install(name, software_dir, options)`)
144
+ install software binaries under `share/software/<name>` by sourcing
145
+ `share/software/install_helpers` and running the recipe from the spec hash
146
+ (`:git`, `:src`, `:url`, `:jar`, `:extra`, `:configure`, `:make`, ...).
147
+ `Resource.set_software_env(software_dir)` runs at load time on the default
148
+ `software` dir to expose installed binaries on `PATH`/`JAVA_CLASSPATH`-style
149
+ variables.
150
+
151
+ ## `Resource.sync`
152
+
153
+ ```ruby
154
+ Resource.sync(path, map = nil, options = {})
155
+ ```
156
+
157
+ A module-level method (there is **no** `Path#sync`) that walks the map
158
+ order copying/symlinking resource files found in other locations into the
159
+ map's directory, so that unlocated paths resolve locally. It is unrelated to
160
+ `Open.rsync`.
161
+
162
+ ## Scout itself is a Resource
163
+
164
+ `Scout` extends `Resource` with `pkgdir 'scout'` and is
165
+ `Resource.default_resource`. `Scout.etc`, `Scout.tmp`, ... are ordinary
166
+ `Path` segment builders over the `Scout` package:
167
+
168
+ ```ruby
169
+ Resource.default_resource # => Scout
170
+ Scout.etc.find # => "$HOME/.scout/etc"
171
+ ```
172
+
173
+ ## Related
174
+
175
+ - [Path Resolution](../developer/PathResolution.md) for `find`/`follow`.
176
+ - [Persistence and Resources](../developer/PersistenceAndResources.md) for the
177
+ produce lock namespace and claim semantics from a developer angle.
@@ -0,0 +1,157 @@
1
+ # Remote Data
2
+
3
+ Fetching remote files over HTTP(S)/FTP/SSH, the URL cache that backs
4
+ `Open.wget`, and the rsync helpers used to move directories between hosts.
5
+ Source: `lib/scout/open/remote.rb`, `lib/scout/open/sync.rb`,
6
+ `lib/scout/resource/sync.rb`.
7
+
8
+ Executed examples come from `tmp/rewrite_D/probe_07_remote.rb`, which
9
+ starts a throwaway local `python3 -m http.server` to serve real URLs;
10
+ transcripts live in `tmp/rewrite_D/BATCH_D_PROBE_TRANSCRIPTS.txt`.
11
+
12
+ For the local side of `Open` see [WorkingWithFiles.md](WorkingWithFiles.md);
13
+ for `Resource` itself see [ProducingResources.md](ProducingResources.md).
14
+
15
+ ## Recognising remote paths
16
+
17
+ ```ruby
18
+ Open.remote?('http://a') # => true
19
+ Open.remote?('https://a') # => true
20
+ Open.remote?('ssh://a:x') # => true
21
+ Open.remote?('ftp://a') # => true
22
+ Open.remote?('/local') # => false
23
+ Open.ssh?('ssh://a:x') # => true
24
+ Open.ssh?('http://a') # => false
25
+ ```
26
+
27
+ Both are pure regexes (`/^(?:https?|ftp|ssh):\/\//` and `/^ssh:\/\//`). A
28
+ path without a scheme is never remote. Verified by
29
+ `tmp/rewrite_D/probe_07_remote.rb`.
30
+
31
+ ## `Open.ssh` — reading over SSH
32
+
33
+ `Open.ssh('ssh://server:path')` parses server and path from the URI. When
34
+ the server is exactly `localhost` it bypasses SSH entirely and calls
35
+ `Open.open(file)` on the bare path — handy for tests. Otherwise it returns a
36
+ pipe from `CMD.cmd("ssh '<server>' cat '<file>'", :pipe => true, :autojoin
37
+ => true)`, i.e. a `ConcurrentStream`, not a String.
38
+
39
+ ## `Open.wget` and the URL cache
40
+
41
+ `Open.wget(url, options)` is the main entry point for HTTP/FTP. It has a
42
+ **permanent file cache** keyed by a digest of the request, and the default
43
+ behaviour is to serve from that cache without touching the network.
44
+
45
+ ### Cache location and key
46
+
47
+ ```ruby
48
+ Open.remote_cache_dir # $HOME/.scout/var/cache/open-remote
49
+ Open.remote_cache_dir = dir # override (probe sets it to a tmpdir)
50
+ Open.cache_file(url, options) # <cache_dir>/<digest>
51
+ ```
52
+
53
+ The digest (`Open.digest_url`) covers the URL, the `--post-data` value and
54
+ the **sorted lines** of `--post-file`. Different post-data therefore gets a
55
+ different cache entry (probe: three URLs/options => three distinct cache
56
+ paths).
57
+
58
+ ### No TTL, no expiry
59
+
60
+ There is no timestamp check and no maximum age. Once a URL is cached, a
61
+ later `Open.wget` of the same URL/options returns the cached bytes even if
62
+ the server has changed. The probe demonstrates it: the file was edited on
63
+ the server three times (`HELLO` → `FIRST` → `SECOND` → `THIRD`) while the
64
+ second `Open.wget` still reported the old cached content.
65
+
66
+ ### Forcing a refresh
67
+
68
+ ```ruby
69
+ Open.wget(url, :nocache => 'update') # re-fetch, write cache, read cache
70
+ Open.wget(url, :force => true) # skip the cache check entirely
71
+ Open.remove_from_cache(url, options) # delete the cache entry
72
+ ```
73
+
74
+ **Exactly `'update'`** re-fetches and re-writes the cache, then *reads it
75
+ back* (probe: after `Open.wget(url, :nocache => 'update')` the returned file
76
+ is the cache file again, not the raw pipe). Any other truthy `nocache`
77
+ value takes the other branch and returns the raw wget pipe **without**
78
+ touching the cache. `:force` only bypasses the initial `in_cache?` lookup,
79
+ so a `:force` fetch without `nocache` still ends by writing and re-opening
80
+ the cache.
81
+
82
+ Other `wget` passthroughs: `--user-agent=` (defaults to `rbbt`),
83
+ `:post` → `--post-data=`, `:cookies => file` → `--save-cookies`,
84
+ `--load-cookies`, `--keep-session-cookies`, plus any literal wget flag.
85
+ `:nice` / `:nice_key` route through `Open.wait` (below). Network errors are
86
+ wrapped in `OpenURLError` with the message
87
+ `Error reading remote url: <url>. <cause>`.
88
+
89
+ ## `Open.download` and `Open.scp`
90
+
91
+ `Open.download(url, file)` runs `wget '<url>' -O '<file>'` via
92
+ `CMD.cmd_log`; on failure it removes the partial output file and re-raises
93
+ the original error. Verified: downloading a live URL produced the current
94
+ server bytes (no cache involved).
95
+
96
+ `Open.scp(source_file, target_file, target:, source:)` first creates the
97
+ remote parent directory with `ssh <target> mkdir -p ...`, then runs
98
+ `scp -r`. Both keyword arguments are optional and only prefix the paths with
99
+ `host:` when they are not already prefixed.
100
+
101
+ ## `Open.rsync` and `Open.sync`
102
+
103
+ `Open.rsync(source, target, options)` shells out to
104
+ `rsync -avztHP --copy-unsafe-links --omit-dir-times` with:
105
+
106
+ - `:excludes` — defaults to `%w(.save .crap .source tmp filecache
107
+ open-remote)`; a String is split on commas unless it already contains
108
+ `--exclude`.
109
+ - `:files` — a list of relative filenames written to a temp file and passed
110
+ as `--files-from=`.
111
+ - `:hard_link` — adds `--link-dest '<source>'` (local sources only).
112
+ - `:test` — adds `-nv` (dry run).
113
+ - `:delete` — appends `&& rm -Rf <source>` when no `:files`, or removes the
114
+ listed files and empties their directories afterwards.
115
+ - `:source` / `:target` — turn one side into `server:'path'` URIs, creating
116
+ the remote directory with `ssh <server> mkdir -p` when needed.
117
+ - `:print => true` — return the command string instead of running it.
118
+ - `:other` — a String or Array appended verbatim.
119
+
120
+ Trailing-slash semantics follow rsync: if either side is a directory (or ends
121
+ in `/`), both get a trailing `/`, which copies *contents* rather than the
122
+ directory itself. A local-to-local sync of the identical path logs a warning
123
+ and returns.
124
+
125
+ `Open.sync(...)` is a literal alias: `Open.sync` and `Open.rsync` are the
126
+ same implementation forwarded with Ruby's `...` (probe: same owner). Use
127
+ either name — they are interchangeable.
128
+
129
+ ## `Resource.sync(path, map, options)`
130
+
131
+ `Resource.sync` copies a `Resource` path into a mapped location:
132
+ `resource.identify(path).find(map)` is the target, and each existing file or
133
+ globbed match is synced with `Open.sync(source, target, options)`. The
134
+ resource is resolved from `:resource`, from the path's `pkgdir` when it is
135
+ already a `Path` over a `Resource`, or from `Resource.default_resource`.
136
+ `map` defaults to `'user'`. All `Open.rsync` options pass through.
137
+
138
+ ## `Open.wait` — client-side rate limiting
139
+
140
+ ```ruby
141
+ Open.wait(lag, key = nil)
142
+ ```
143
+
144
+ A tiny rate limiter backed by the module-level `LAST_TIME` hash. If the
145
+ last call *for that key* was less than `lag` seconds ago, it sleeps the
146
+ remainder. `Open.wget` uses it via `:nice` / `:nice_key`. Probe: two
147
+ `Open.wait(0.3, :k)` calls in a row took 0.3 s wall clock.
148
+
149
+ Note the process-local `LAST_TIME` map is shared mutable state with no
150
+ mutex; see [LockingAndConcurrency.md](../developer/LockingAndConcurrency.md).
151
+
152
+ ## Where this is used
153
+
154
+ - [CachingResults.md](CachingResults.md) — the other caching layers
155
+ (`Persist`) and how they differ from this URL cache.
156
+ - [WorkingWithFiles.md](WorkingWithFiles.md) — `Open.open` dispatching to
157
+ `wget`/`ssh` when a path looks remote.