asterism 0.7.1 → 0.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.
@@ -0,0 +1,282 @@
1
+ # The shell's Tab completion: `asterism completion bash|zsh` prints a
2
+ # script, and the script calls the hidden `asterism __complete` on each
3
+ # Tab, which prints the candidates, one a line.
4
+ #
5
+ # The commands, the verbs and the options come from the command line's own
6
+ # tables (no network). The names on the network (nodes, node/app,
7
+ # node/app/object, an object's methods) come from the network named on the
8
+ # line (--router, --credentials, --peer) or in the environment, asked
9
+ # briefly (Complete::BUDGET: nothing comes back when it does not answer in
10
+ # time, the shell is not kept waiting) and remembered for a few seconds
11
+ # (Cache: a file per network under $XDG_CACHE_HOME/asterism, named by a
12
+ # digest of where the network is; it holds names only, never a key or a
13
+ # certificate). A name with "/" is completed a part at a time:
14
+ # `ga<Tab>` gives `games/`, `games/games/<Tab>` the objects.
15
+ require "json"
16
+ require "digest"
17
+ require "fileutils"
18
+ require "shellwords"
19
+
20
+ module Asterism
21
+ class CLI
22
+ class Complete
23
+ # Seconds the network may take to answer one Tab (connecting, the
24
+ # tokens, a meta get).
25
+ BUDGET = 1.5
26
+ # What a line of the output means "complete a file name here".
27
+ FILES = ":files"
28
+
29
+ # spec: the CLI (its command tables and option lists); source: what
30
+ # the network names (#names, #methods(path), #all_methods), each
31
+ # nil when it did not answer.
32
+ def initialize(spec, source)
33
+ @spec = spec
34
+ @source = source
35
+ end
36
+
37
+ # The candidates for the last word of words (the words after
38
+ # `asterism`, the last one the word being completed, "" for a new
39
+ # one), those that start with it, sorted.
40
+ def candidates(words)
41
+ words = words.dup
42
+ cur = words.pop || ""
43
+ pos = []
44
+ prev = nil
45
+ words.each do |w|
46
+ if prev
47
+ prev = nil
48
+ next
49
+ end
50
+ if w.start_with?("-") && w.size > 1
51
+ prev = w if @spec.option_takes_value?(w)
52
+ next
53
+ end
54
+ pos << w
55
+ end
56
+ key, args = @spec.command_key(pos)
57
+ list =
58
+ if prev then values(prev, key, args)
59
+ elsif cur.start_with?("-") then key ? @spec.options_for(key) : []
60
+ else positional(pos, key, args, cur)
61
+ end
62
+ return list if list == [FILES]
63
+ list.select { _1.start_with?(cur) }.uniq.sort
64
+ end
65
+
66
+ private
67
+
68
+ def values(opt, key, args)
69
+ case opt
70
+ when "-o", "--output" then %w[json yaml]
71
+ when "--credentials" then [FILES]
72
+ when "--call" then key == "ping" && args[0] ? methods_of(args[0]) : []
73
+ else []
74
+ end
75
+ end
76
+
77
+ def positional(pos, key, args, cur)
78
+ return @spec.command_words if pos.empty?
79
+ return @spec.verbs(pos[0]) if pos.size == 1 && @spec.noun?(pos[0])
80
+ return [] unless key
81
+ i = args.size
82
+ case [key, i]
83
+ when ["node info", 0] then nodes
84
+ when ["app info", 0], ["status get", 0], ["status echo", 0], ["status hz", 0] then staged(cur, apps, 2)
85
+ when ["object list", 0], ["object info", 0], ["objects", 0], ["call", 0] then staged(cur, objects, 3)
86
+ when ["ping", 0], ["echo", 0] then (cur.include?("/") ? [] : nodes) + staged(cur, objects, 3)
87
+ when ["object find", 0] then @source.all_methods || []
88
+ when ["call", 1] then methods_of(args[0])
89
+ when ["completion", 0] then %w[bash zsh]
90
+ when ["help", 0] then @spec.nouns
91
+ else []
92
+ end
93
+ end
94
+
95
+ def methods_of(path)
96
+ path.count("/") == 2 ? (@source.methods(path) || []) : []
97
+ end
98
+
99
+ def names
100
+ @names ||= @source.names || { "apps" => [], "objects" => [] }
101
+ end
102
+
103
+ def objects
104
+ names["objects"]
105
+ end
106
+
107
+ # The applications, with those that only objects tell of.
108
+ def apps
109
+ (names["apps"] + objects.map { _1.split("/")[0, 2].join("/") }).uniq
110
+ end
111
+
112
+ def nodes
113
+ (apps + objects).map { _1.split("/")[0] }.uniq
114
+ end
115
+
116
+ # The next part of the paths (depth parts each) that start with cur:
117
+ # the parts up to the one cur is in, with "/" after it when more come.
118
+ def staged(cur, paths, depth)
119
+ k = cur.count("/")
120
+ return [] if k >= depth
121
+ paths.filter_map do |p|
122
+ s = p.split("/")
123
+ next if s.size < depth
124
+ c = s[0..k].join("/")
125
+ c += "/" if k + 1 < depth
126
+ c if c.start_with?(cur)
127
+ end
128
+ end
129
+
130
+ public
131
+
132
+ # The words of a command line up to the cursor (bash's COMP_LINE cut
133
+ # at COMP_POINT): the words after the command, the last one the word
134
+ # being completed ("" after a space). An open quote is closed.
135
+ def self.split_line(line)
136
+ words = begin
137
+ Shellwords.split(line)
138
+ rescue ArgumentError
139
+ begin
140
+ Shellwords.split("#{line}\"")
141
+ rescue ArgumentError
142
+ Shellwords.split("#{line}'")
143
+ end
144
+ end
145
+ words << "" if line.match?(/\s\z/) || words.empty?
146
+ words.drop(1)
147
+ end
148
+
149
+ # Bash splits the word being completed at ":" and "=" (COMP_WORDBREAKS):
150
+ # the candidates without what comes before the last of them in cur.
151
+ def self.bash_trim(cands, cur)
152
+ cut = cur.rindex(/[:=]/)
153
+ return cands unless cut
154
+ cands.map { _1.start_with?(cur[0..cut]) ? _1[(cut + 1)..] : _1 }
155
+ end
156
+
157
+ # ------------------------------------------------------------ the cache
158
+
159
+ # What __complete remembers of a network for a few seconds: a JSON
160
+ # file per network ({entry => {"t" => seconds, "v" => value}}), the
161
+ # directory 0700 and the files 0600. The network's identity is a
162
+ # digest of where it is (router, the credentials file's path, peer);
163
+ # nothing else about it is written.
164
+ class Cache
165
+ TTL = 20
166
+
167
+ def initialize(dir:, network:, ttl: TTL, clock: -> { Time.now.to_f })
168
+ @dir = dir
169
+ @ttl = ttl
170
+ @clock = clock
171
+ id = network.map { _1.to_s }.join("\n")
172
+ @file = File.join(dir, "#{Digest::SHA256.hexdigest(id)[0, 24]}.json")
173
+ end
174
+
175
+ attr_reader :file
176
+
177
+ # The cache directory: $XDG_CACHE_HOME/asterism, else
178
+ # ~/.cache/asterism.
179
+ def self.dir(env)
180
+ base = env["XDG_CACHE_HOME"].to_s
181
+ base = File.join(Dir.home, ".cache") if base.empty?
182
+ File.join(base, "asterism")
183
+ rescue ArgumentError
184
+ nil
185
+ end
186
+
187
+ # The entry's value when it is fresh, else the block's (kept unless
188
+ # it is nil: what did not answer is asked again next time).
189
+ def fetch(entry)
190
+ data = read
191
+ e = data[entry]
192
+ now = @clock.call
193
+ return e["v"] if e.is_a?(Hash) && e["t"].is_a?(Numeric) && now - e["t"] < @ttl && now >= e["t"]
194
+ v = yield
195
+ return v if v.nil?
196
+ data = read.reject { |_, x| !x.is_a?(Hash) || !x["t"].is_a?(Numeric) || now - x["t"] >= @ttl }
197
+ data[entry] = { "t" => now, "v" => v }
198
+ write(data)
199
+ v
200
+ end
201
+
202
+ private
203
+
204
+ def read
205
+ h = JSON.parse(File.read(@file))
206
+ h.is_a?(Hash) ? h : {}
207
+ rescue StandardError
208
+ {}
209
+ end
210
+
211
+ def write(data)
212
+ FileUtils.mkdir_p(@dir, mode: 0o700)
213
+ tmp = "#{@file}.#{Process.pid}.tmp"
214
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { _1.write(JSON.generate(data)) }
215
+ File.rename(tmp, @file)
216
+ rescue StandardError
217
+ begin
218
+ File.unlink(tmp) if tmp
219
+ rescue StandardError
220
+ nil
221
+ end
222
+ nil
223
+ end
224
+ end
225
+
226
+ # ------------------------------------------------------------ the scripts
227
+
228
+ BASH = <<~'BASH'
229
+ # asterism's Tab completion for bash: source <(asterism completion bash)
230
+ # (or put that line in ~/.bashrc). Each Tab runs `asterism __complete`;
231
+ # the names on the network come from the network named on the line
232
+ # (--router, --credentials, --peer) or in ASTERISM_CREDENTIALS /
233
+ # ASTERISM_ROUTER, asked briefly and remembered for a few seconds.
234
+ _asterism() {
235
+ local cur=${COMP_WORDS[COMP_CWORD]}
236
+ local IFS=$'\n'
237
+ local out
238
+ out=$(asterism __complete --bash "${COMP_LINE:0:COMP_POINT}" 2>/dev/null)
239
+ if [[ $out == ":files" ]]; then
240
+ compopt -o filenames 2>/dev/null
241
+ COMPREPLY=($(compgen -f -- "$cur"))
242
+ return 0
243
+ fi
244
+ COMPREPLY=($out)
245
+ local c
246
+ for c in "${COMPREPLY[@]}"; do
247
+ if [[ $c == */ ]]; then
248
+ compopt -o nospace 2>/dev/null
249
+ break
250
+ fi
251
+ done
252
+ return 0
253
+ }
254
+ complete -F _asterism asterism
255
+ BASH
256
+
257
+ ZSH = <<~'ZSH'
258
+ # asterism's Tab completion for zsh: source <(asterism completion zsh)
259
+ # after compinit (or put that line in ~/.zshrc). Each Tab runs
260
+ # `asterism __complete`; the names on the network come from the network
261
+ # named on the line (--router, --credentials, --peer) or in
262
+ # ASTERISM_CREDENTIALS / ASTERISM_ROUTER, asked briefly and remembered
263
+ # for a few seconds.
264
+ _asterism() {
265
+ local -a cands slash plain
266
+ cands=("${(@f)$(asterism __complete -- "${(@)words[2,CURRENT]}" 2>/dev/null)}")
267
+ cands=(${cands:#})
268
+ if [[ ${cands[1]} == ":files" ]]; then
269
+ _files
270
+ return
271
+ fi
272
+ slash=(${(M)cands:#*/})
273
+ plain=(${cands:#*/})
274
+ (( ${#plain} )) && compadd -- "${plain[@]}"
275
+ (( ${#slash} )) && compadd -S '' -- "${slash[@]}"
276
+ return 0
277
+ }
278
+ compdef _asterism asterism
279
+ ZSH
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,77 @@
1
+ # `asterism status hz`: how often an application's status changes, as
2
+ # `ros2 topic hz` tells how often a topic is published. Each change is a
3
+ # time (a monotonic clock, given or Process.clock_gettime); the intervals
4
+ # between the last ones (a window of them) give the average rate and the
5
+ # intervals' min, max and standard deviation. A put that brings the same
6
+ # status again (as after a reconnection) is not a change.
7
+ module Asterism
8
+ class CLI
9
+ class Hz
10
+ attr_reader :count
11
+
12
+ # window: how many of the last intervals count. clock: a callable
13
+ # that returns the time in seconds (monotonic).
14
+ def initialize(window: 1000, clock: nil)
15
+ @window = window
16
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
17
+ @times = []
18
+ @last = nil
19
+ @seeded = false
20
+ @count = 0
21
+ @reported = 0
22
+ end
23
+
24
+ # The status there at the start: what the first put is compared with
25
+ # (not a change itself).
26
+ def seed(status)
27
+ @last = status
28
+ @seeded = true
29
+ end
30
+
31
+ # A put of the status: a change (true) when it differs from the last
32
+ # one seen (the first put without a seed is one).
33
+ def put(status)
34
+ return false if @seeded && status == @last
35
+ @last = status
36
+ @seeded = true
37
+ add(@clock.call)
38
+ true
39
+ end
40
+
41
+ # A change at time t (seconds).
42
+ def add(t)
43
+ @count += 1
44
+ @times << t
45
+ @times.shift while @times.size > @window + 1
46
+ end
47
+
48
+ # {"rate_hz", "min_s", "max_s", "std_s", "window", "count"} over the
49
+ # intervals in the window, or nil before two changes.
50
+ def stats
51
+ return nil if @times.size < 2
52
+ ints = @times.each_cons(2).map { |a, b| b - a }
53
+ avg = ints.sum / ints.size
54
+ var = ints.sum { (_1 - avg)**2 } / ints.size
55
+ { "rate_hz" => avg.positive? ? 1.0 / avg : nil, "min_s" => ints.min, "max_s" => ints.max,
56
+ "std_s" => Math.sqrt(var), "window" => ints.size, "count" => @count }
57
+ end
58
+
59
+ # stats when a change came since the last report (else nil): what a
60
+ # period prints.
61
+ def report
62
+ return nil if @count == @reported
63
+ s = stats
64
+ @reported = @count if s
65
+ s
66
+ end
67
+
68
+ # The lines of a report, as ros2 topic hz prints them.
69
+ def self.text(s)
70
+ rate = s["rate_hz"] ? format("%.3f", s["rate_hz"]) : "inf"
71
+ ["average rate: #{rate} Hz",
72
+ format(" min: %.3f s max: %.3f s std dev: %.3f s window: %d", s["min_s"], s["max_s"], s["std_s"],
73
+ s["window"])]
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,132 @@
1
+ # The arguments of `asterism call` (and of the MCP bridge's tests): each is
2
+ # read as JSON, else as a Ruby literal, else taken as the text it is.
3
+ #
4
+ # Literal.parse('{"seat": 1}') # => {"seat" => 1} (JSON)
5
+ # Literal.parse("[1, :a, nil]") # => [1, :a, nil] (Ruby)
6
+ # Literal.parse("{seat: 1}") # => {seat: 1} (Ruby)
7
+ # Literal.parse("hello") # => "hello" (text)
8
+ #
9
+ # The Ruby form is read with Ripper and only literals are taken: nil,
10
+ # true, false, integers, floats, strings without interpolation, symbols,
11
+ # arrays (also %w / %i) and hashes. Nothing is evaluated: anything else (a
12
+ # method call, a constant, a variable) makes it not a literal, and the
13
+ # argument is then the text as written.
14
+ require "json"
15
+ require "ripper"
16
+
17
+ module Asterism
18
+ class CLI
19
+ module Literal
20
+ class NotLiteral < StandardError; end
21
+
22
+ module_function
23
+
24
+ # The value of one argument (see above).
25
+ def parse(text)
26
+ json(text)
27
+ rescue JSON::ParserError
28
+ begin
29
+ ruby(text)
30
+ rescue NotLiteral
31
+ text
32
+ end
33
+ end
34
+
35
+ # JSON: a value of any kind at the top ("1", "\"x\"", "null" too).
36
+ def json(text)
37
+ JSON.parse(text)
38
+ end
39
+
40
+ # A Ruby literal, or NotLiteral.
41
+ def ruby(text)
42
+ tree = Ripper.sexp(text)
43
+ raise NotLiteral, "not Ruby" unless tree && tree[0] == :program
44
+ body = tree[1]
45
+ raise NotLiteral, "one value expected" unless body.is_a?(Array) && body.size == 1
46
+ node(body[0])
47
+ end
48
+
49
+ def node(n)
50
+ raise NotLiteral, "unexpected #{n.inspect}" unless n.is_a?(Array)
51
+ case n[0]
52
+ when :@int then Integer(n[1].delete("_"), 0)
53
+ when :@float then Float(n[1].delete("_"))
54
+ when :unary
55
+ raise NotLiteral, "only - before a number" unless n[1] == :-@ && %i[@int @float].include?(n[2][0])
56
+ -node(n[2])
57
+ when :var_ref, :@kw then keyword(n[0] == :var_ref ? n[1] : n)
58
+ when :string_literal then string(n[1])
59
+ when :symbol_literal then symbol(n[1])
60
+ when :dyna_symbol then string(n[1]).to_sym
61
+ when :array then array(n[1])
62
+ when :hash then hash(n[1])
63
+ when :bare_assoc_hash then pairs(n[1])
64
+ else raise NotLiteral, "not a literal: #{n[0]}"
65
+ end
66
+ end
67
+
68
+ def keyword(kw)
69
+ raise NotLiteral, "not a keyword" unless kw[0] == :@kw
70
+ case kw[1]
71
+ when "nil" then nil
72
+ when "true" then true
73
+ when "false" then false
74
+ else raise NotLiteral, "keyword #{kw[1]}"
75
+ end
76
+ end
77
+
78
+ # string_content: plain parts only (no #{...}).
79
+ def string(content)
80
+ return "" if content.nil?
81
+ parts = content[0] == :string_content ? content[1..] : content
82
+ parts.map do |p|
83
+ raise NotLiteral, "interpolation" unless p.is_a?(Array) && p[0] == :@tstring_content
84
+ unescape(p[1])
85
+ end.join
86
+ end
87
+
88
+ # The escapes of a double-quoted string (\n, \t, \", \\, \u...).
89
+ def unescape(s)
90
+ return s unless s.include?("\\")
91
+ "\"#{s}\"".undump
92
+ rescue RuntimeError
93
+ s
94
+ end
95
+
96
+ def symbol(sym)
97
+ raise NotLiteral, "symbol" unless sym[0] == :symbol
98
+ sym[1][1].to_sym
99
+ end
100
+
101
+ def array(items)
102
+ return [] if items.nil?
103
+ items.map do |e|
104
+ if e.is_a?(Array) && e[0] == :@tstring_content # %w[...]
105
+ e[1]
106
+ elsif e.is_a?(Array) && %i[qwords_add words_add qsymbols_add symbols_add].include?(e[0])
107
+ raise NotLiteral, "old Ripper form"
108
+ elsif e.is_a?(Array) && e[0] == :@ident # %i[...]
109
+ e[1].to_sym
110
+ else
111
+ node(e)
112
+ end
113
+ end
114
+ end
115
+
116
+ def hash(list)
117
+ return {} if list.nil?
118
+ raise NotLiteral, "hash" unless list[0] == :assoclist_from_args
119
+ pairs(list[1])
120
+ end
121
+
122
+ def pairs(assocs)
123
+ assocs.to_h do |a|
124
+ raise NotLiteral, "not a key => value" unless a[0] == :assoc_new
125
+ k = a[1]
126
+ key = k[0] == :@label ? k[1].chomp(":").to_sym : node(k)
127
+ [key, node(a[2])]
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end