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,206 @@
1
+ # What `asterism nodes / objects / tree` and the MCP bridge read from the
2
+ # network, on a connection that is already open (Asterism.connect): the
3
+ # applications and objects from the liveliness tokens, then the meta of
4
+ # the objects, the nodes' profiles and the applications' statuses
5
+ # (docs/protocol.md 5, 7, 8) with one get for each node or application.
6
+ #
7
+ # Not one wildcard get for all (asterism/*/*/@status): behind a router's
8
+ # access control a query must fit the rules of every node it would reach
9
+ # (a game host may answer asterism/games/*/@status, not asterism/*/*/@status),
10
+ # so a wildcard over the nodes is refused where the plain keys pass. The
11
+ # gets go out at once and are read as their replies come (zenoh-c fills
12
+ # them on its own threads), so a listing costs one time limit at most,
13
+ # whatever the number of nodes. What a router refuses is simply not there,
14
+ # and the listing says so instead of calling a node gone.
15
+ module Asterism
16
+ class CLI
17
+ class Survey
18
+ # What watch subscribes to (puts are routed per subscriber, so the
19
+ # wildcards pass where the rules let them through).
20
+ PROFILES_ALL = "asterism/*/@profile"
21
+ STATUS_ALL = "asterism/*/*/@status"
22
+ STEP = 0.005
23
+
24
+ attr_reader :timeout
25
+
26
+ # own: this connection's "<node>/<app>", left out of the listings.
27
+ def initialize(timeout: 2.0, own: nil)
28
+ @timeout = timeout
29
+ @own = own || "#{::Asterism.node_id}/#{::Asterism.app}"
30
+ @object_docs = {}
31
+ end
32
+
33
+ # Waits for the tokens alive at connect to come in (they come in one
34
+ # burst from the router): until the applications stop changing for
35
+ # quiet seconds, at most max seconds.
36
+ def settle(max = 1.5, quiet: 0.3)
37
+ t0 = now
38
+ last = nil
39
+ since = now
40
+ loop do
41
+ cur = [::Asterism.apps, ::Asterism.each("**").size]
42
+ if cur != last
43
+ last = cur
44
+ since = now
45
+ end
46
+ break if now - since >= quiet || now - t0 >= max
47
+ sleep 0.02
48
+ end
49
+ end
50
+
51
+ # The applications alive, "<node>/<app>", sorted (this one left out).
52
+ def apps(pattern = "*/*")
53
+ (::Asterism.apps(pattern) - [@own]).sort
54
+ end
55
+
56
+ # The nodes that have an application or an object alive (sorted).
57
+ def nodes
58
+ (apps.map { _1.split("/")[0] } + objects.map { _1.split("/")[0] }).uniq.sort
59
+ end
60
+
61
+ # The objects alive, "<node>/<app>/<object>", sorted (this
62
+ # application's own left out).
63
+ def objects(pattern = "**")
64
+ ::Asterism.each(pattern).map(&:asterism_path).reject { _1.start_with?("#{@own}/") }.sort
65
+ end
66
+
67
+ # {path => [{"name", "arity"}, ...]} for the paths given (an object
68
+ # that does not answer its meta in time is left out). One get per
69
+ # application (asterism/<node>/<app>/*/meta), then one for each object
70
+ # it did not bring.
71
+ def metas(paths)
72
+ return {} if paths.empty?
73
+ out = {}
74
+ apps = paths.map { _1.split("/")[0, 2].join("/") }.uniq
75
+ gets(apps.map { "asterism/#{_1}/*/meta" }).each_value do |replies|
76
+ replies.each do |key, payload|
77
+ path = key.delete_prefix("asterism/").delete_suffix("/meta")
78
+ m = meta_list(payload, path)
79
+ out[path] = m if m && paths.include?(path)
80
+ end
81
+ end
82
+ missing = paths - out.keys
83
+ unless missing.empty?
84
+ gets(missing.map { "asterism/#{_1}/meta" }).each do |key, replies|
85
+ path = key.delete_prefix("asterism/").delete_suffix("/meta")
86
+ m = replies.first && meta_list(replies.first[1], path)
87
+ out[path] = m if m
88
+ end
89
+ end
90
+ out
91
+ end
92
+
93
+ # {node => profile without the icon} of the nodes that answered (one
94
+ # get per node, all at once).
95
+ def profiles(list = nodes)
96
+ out = {}
97
+ gets(list.map { "asterism/#{_1}/@profile" }, params: "icon=0").each do |key, replies|
98
+ pr = replies.first && ::Asterism::Profile.check(::Asterism::Codec.unpack(replies.first[1]))
99
+ out[key.split("/")[1]] = pr if pr
100
+ end
101
+ out
102
+ end
103
+
104
+ # {"<node>/<app>" => status} of the applications that answered (one
105
+ # get per application, all at once).
106
+ def statuses(list = apps)
107
+ out = {}
108
+ gets(list.map { "asterism/#{_1}/@status" }).each do |key, replies|
109
+ st = replies.first && ::Asterism::Status.check(::Asterism::Codec.unpack(replies.first[1]))
110
+ parts = key.split("/")
111
+ out["#{parts[1]}/#{parts[2]}"] = st if st
112
+ end
113
+ out
114
+ end
115
+
116
+ # The tree: [{"node", "profile", "apps" => [{"app", "status",
117
+ # "objects" => [{"name", "methods"}]}]}]. meta: false leaves the
118
+ # methods out (no meta gets); node: (and app:) narrow it to one node
119
+ # (one application).
120
+ def tree(meta: true, node: nil, app: nil)
121
+ scope = node && "#{node}/#{app || '*'}"
122
+ objs = scope ? objects("#{scope}/*") : objects
123
+ app_list = scope ? apps(scope) : apps
124
+ threads = []
125
+ node_list = (app_list.map { _1.split("/")[0] } + objs.map { _1.split("/")[0] }).uniq
126
+ threads << Thread.new { profiles(node_list) }
127
+ threads << Thread.new { statuses(app_list) }
128
+ threads << Thread.new { meta ? metas(objs) : {} }
129
+ prof, stat, met = threads.map(&:value)
130
+ by_node = Hash.new { |h, k| h[k] = {} }
131
+ app_list.each { |a| n, ap = a.split("/"); by_node[n][ap] ||= [] }
132
+ objs.each do |o|
133
+ n, ap, name = o.split("/")
134
+ (by_node[n][ap] ||= []) << name
135
+ end
136
+ by_node.keys.sort.map do |n|
137
+ apps = by_node[n].keys.sort.map do |ap|
138
+ h = { "app" => ap }
139
+ h["status"] = stat["#{n}/#{ap}"] if stat.key?("#{n}/#{ap}")
140
+ h["objects"] = by_node[n][ap].sort.map do |name|
141
+ e = { "name" => name }
142
+ e["methods"] = met["#{n}/#{ap}/#{name}"] if met.key?("#{n}/#{ap}/#{name}")
143
+ d = object_doc("#{n}/#{ap}/#{name}")
144
+ e["doc"] = d if d
145
+ e
146
+ end
147
+ h
148
+ end
149
+ h = { "node" => n }
150
+ h["profile"] = prof[n] if prof[n]
151
+ h["apps"] = apps
152
+ h
153
+ end
154
+ end
155
+
156
+ # Sends a get on each key at once and waits for all of them (at most
157
+ # the time limit): {key => [[reply key, payload], ...]}.
158
+ def gets(keys, params: nil)
159
+ s = ::Asterism.session
160
+ ms = (@timeout * 1000).round
161
+ pending = keys.to_h { |k| [k, s.get(k, timeout_ms: ms, params: params)] }
162
+ out = keys.to_h { [_1, []] }
163
+ until pending.empty?
164
+ pending.delete_if do |k, g|
165
+ ended = g.done?
166
+ out[k].concat(g.each_reply.map { |r| [r[0], r[1]] })
167
+ ended
168
+ end
169
+ sleep STEP unless pending.empty?
170
+ end
171
+ out
172
+ rescue ::Asterism::Zenoh::Error => e
173
+ raise ::Asterism::Disconnected, e.message
174
+ end
175
+
176
+ # A meta reply's methods: [{"name", "arity"} and, when the object
177
+ # tells them (docs/protocol.md 5), "args" (names) and "doc"]. Fields
178
+ # of another shape are left out, unknown ones ignored.
179
+ def meta_list(payload, path = nil)
180
+ m = ::Asterism::Codec.unpack(payload)
181
+ list = m.is_a?(Hash) ? m["methods"] : nil
182
+ return nil unless list.is_a?(Array)
183
+ @object_docs[path] = m["doc"] if path && m["doc"].is_a?(String)
184
+ list.filter_map do |e|
185
+ next unless e.is_a?(Hash) && e["name"]
186
+ h = { "name" => e["name"].to_s, "arity" => e["arity"].to_i }
187
+ args = e["args"]
188
+ h["args"] = args if args.is_a?(Array) && args.all? { _1.is_a?(String) } &&
189
+ (h["arity"].negative? || args.size == h["arity"])
190
+ h["doc"] = e["doc"] if e["doc"].is_a?(String)
191
+ h
192
+ end
193
+ end
194
+
195
+ # The text an object tells about itself in its meta (read by metas),
196
+ # or nil.
197
+ def object_doc(path)
198
+ @object_docs[path]
199
+ end
200
+
201
+ def now
202
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,256 @@
1
+ # How the command line's text looks (not --json, which stays as it is):
2
+ # colours and the terminal's width when the output is a terminal, plain
3
+ # text 80 columns wide (or COLUMNS) otherwise; NO_COLOR turns the colours
4
+ # off. Statuses are laid out as indented key=value lines, the methods and
5
+ # the descriptions are wrapped to the width, and watch tells what changed
6
+ # in a status. The standard library only (io/console for the width).
7
+ require "json"
8
+
9
+ module Asterism
10
+ class CLI
11
+ # Colours and width for one output.
12
+ class Style
13
+ CODES = { bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36 }.freeze
14
+ # What each kind of thing is painted with.
15
+ ROLES = {
16
+ node: %i[bold blue], app: [:cyan], object: [:green], method: [:bold], note: [:dim], time: [:dim],
17
+ join: [:green], leave: [:red], status: [:yellow], profile: [:magenta]
18
+ }.freeze
19
+ MIN_WIDTH = 20
20
+
21
+ attr_reader :width
22
+
23
+ def initialize(io, env = ENV)
24
+ tty = io.respond_to?(:tty?) && io.tty?
25
+ @color = tty && env["NO_COLOR"].to_s.empty?
26
+ w = (tty && terminal_width(io)) || env["COLUMNS"].to_i
27
+ @width = w.positive? ? [w, MIN_WIDTH].max : 80
28
+ end
29
+
30
+ def color?
31
+ @color
32
+ end
33
+
34
+ # text painted as role (a ROLES key) or with the CODES given.
35
+ def paint(text, *styles)
36
+ text = text.to_s
37
+ return text unless @color
38
+ codes = styles.flat_map { ROLES.fetch(_1, [_1]) }.map { CODES.fetch(_1) }
39
+ codes.empty? ? text : "\e[#{codes.join(';')}m#{text}\e[0m"
40
+ end
41
+
42
+ private
43
+
44
+ def terminal_width(io)
45
+ require "io/console"
46
+ w = io.winsize[1]
47
+ w.positive? ? w : nil
48
+ rescue StandardError, LoadError
49
+ nil
50
+ end
51
+ end
52
+
53
+ # Laying text out: display widths (East Asian wide characters take two
54
+ # columns), word wrapping, statuses and their differences.
55
+ module Text
56
+ module_function
57
+
58
+ ANSI = /\e\[[0-9;]*m/
59
+ # Code points that take two columns on a terminal (East Asian Wide and
60
+ # Fullwidth, roughly).
61
+ WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]/
62
+ # A string value longer than this (columns) is cut, and so is an
63
+ # array written on one line; --json has them whole.
64
+ LONG = 60
65
+ ARROW = "→"
66
+ ELLIPSIS = "…"
67
+
68
+ # The columns text takes on a terminal (colour codes take none).
69
+ def width(text)
70
+ s = text.to_s.gsub(ANSI, "")
71
+ s = s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless s.encoding == Encoding::UTF_8
72
+ s.scrub.each_char.sum { _1.match?(WIDE) ? 2 : 1 }
73
+ end
74
+
75
+ # The tokens joined with sep in lines at most cols columns wide: the
76
+ # first starts with first, the others with rest (spaces as wide as
77
+ # first by default). A plain token wider than a line is broken between
78
+ # characters (break_long: false keeps it whole, on a line of its own);
79
+ # a painted one (a method's signature) is kept whole. Returns the
80
+ # lines (at least one).
81
+ def wrap(tokens, cols, first: "", rest: nil, sep: " ", break_long: true)
82
+ rest ||= " " * width(first)
83
+ lines = []
84
+ line = first
85
+ lw = width(first)
86
+ empty = true
87
+ tokens.each do |tok|
88
+ tok = tok.to_s
89
+ tw = width(tok)
90
+ if !empty && lw + sep.size + tw > cols
91
+ lines << line
92
+ line = rest
93
+ lw = width(rest)
94
+ empty = true
95
+ end
96
+ while break_long && lw + tw > cols && !tok.match?(ANSI) && tok.size > 1
97
+ head, tail = split_width(tok, [cols - lw, 1].max)
98
+ break if tail.empty?
99
+ lines << (empty ? line : line + sep) + head
100
+ line = rest
101
+ lw = width(rest)
102
+ empty = true
103
+ tok = tail
104
+ tw = width(tok)
105
+ end
106
+ line += (empty ? "" : sep) + tok
107
+ lw += (empty ? 0 : sep.size) + tw
108
+ empty = false
109
+ end
110
+ lines << line
111
+ lines
112
+ end
113
+
114
+ # text's words wrapped (wrap), each paragraph (blank lines between)
115
+ # starting a new line with rest.
116
+ def wrap_text(text, cols, first: "", rest: nil)
117
+ rest ||= " " * width(first)
118
+ paras = text.to_s.strip.split(/\n\s*\n/).map(&:split).reject(&:empty?)
119
+ return [first.rstrip] if paras.empty?
120
+ paras.each_with_index.flat_map { |words, i| wrap(words, cols, first: i.zero? ? first : rest, rest: rest) }
121
+ end
122
+
123
+ # [the start of text no wider than cols (one character at least), the rest]
124
+ def split_width(text, cols)
125
+ w = 0
126
+ n = 0
127
+ text.each_char do |c|
128
+ cw = width(c)
129
+ break if n.positive? && w + cw > cols
130
+ w += cw
131
+ n += 1
132
+ end
133
+ [text[0, n], text[n..]]
134
+ end
135
+
136
+ # ------------------------------------------------------------ statuses
137
+
138
+ # A status (a Hash) as lines indented by indent spaces: the plain
139
+ # values of a level as key=value on one line (wrapped), a Hash of
140
+ # plain values as "key: k=v k=v", deeper ones as "key:" and their
141
+ # lines two spaces further in, an array with a Hash in it as "key:"
142
+ # and its elements as "- " lines. A key=value is never broken: one
143
+ # wider than the line has a line of its own (the terminal folds it).
144
+ def status_lines(h, cols, indent = 0)
145
+ pad = " " * indent
146
+ lines = []
147
+ flat = []
148
+ flush = lambda do
149
+ lines.concat(wrap(flat, cols, first: pad, break_long: false)) unless flat.empty?
150
+ flat = []
151
+ end
152
+ h.each do |k, v|
153
+ if plain?(v)
154
+ flat << pair(k, v)
155
+ next
156
+ end
157
+ flush.call
158
+ if v.is_a?(Hash) && v.empty?
159
+ lines << "#{pad}#{k}: {}"
160
+ elsif v.is_a?(Hash) && v.values.all? { plain?(_1) }
161
+ # The lines after the first under the first value (two spaces in
162
+ # when the key is long).
163
+ hang = width(k) + 2 > cols / 3 ? 2 : width(k) + 2
164
+ lines.concat(wrap(v.map { |kk, vv| pair(kk, vv) }, cols, first: "#{pad}#{k}: ", rest: pad + (" " * hang),
165
+ break_long: false))
166
+ elsif v.is_a?(Hash)
167
+ lines << "#{pad}#{k}:"
168
+ lines.concat(status_lines(v, cols, indent + 2))
169
+ else
170
+ lines << "#{pad}#{k}:"
171
+ v.each { |e| lines.concat(element_lines(e, cols, indent + 2)) }
172
+ end
173
+ end
174
+ flush.call
175
+ lines.empty? ? ["#{pad}{}"] : lines
176
+ end
177
+
178
+ # One element of an array that holds a Hash: "- " and the element.
179
+ def element_lines(e, cols, indent)
180
+ pad = " " * indent
181
+ if e.is_a?(Hash) && !e.empty? && e.values.all? { plain?(_1) }
182
+ wrap(e.map { |k, v| pair(k, v) }, cols, first: "#{pad}- ", rest: "#{pad} ", break_long: false)
183
+ elsif e.is_a?(Hash) && !e.empty?
184
+ sub = status_lines(e, cols, indent + 2)
185
+ sub[0] = "#{pad}- #{sub[0].lstrip}"
186
+ sub
187
+ else
188
+ ["#{pad}- #{inline(e)}"]
189
+ end
190
+ end
191
+
192
+ # No Hash anywhere in v (it goes on one line).
193
+ def plain?(v)
194
+ case v
195
+ when Hash then false
196
+ when Array then v.all? { plain?(_1) }
197
+ else true
198
+ end
199
+ end
200
+
201
+ def pair(k, v)
202
+ "#{k}=#{inline(v)}"
203
+ end
204
+
205
+ # A value on one line: a word as it is, other text quoted (JSON), a
206
+ # long one cut; [a, b] (a long array cut, with the count left out);
207
+ # {k=v k=v}; nil as nil.
208
+ def inline(v)
209
+ case v
210
+ when Hash then "{#{v.map { |k, x| pair(k, x) }.join(' ')}}"
211
+ when Array then inline_array(v)
212
+ when String then inline_string(v)
213
+ when nil then "nil"
214
+ else v.to_s
215
+ end
216
+ end
217
+
218
+ def inline_string(s)
219
+ s = s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless s.encoding == Encoding::UTF_8
220
+ s = "#{split_width(s, LONG - 1)[0]}#{ELLIPSIS}" if width(s) > LONG
221
+ s.match?(/\A[^\s="'{}\[\],]+\z/) ? s : JSON.generate(s)
222
+ end
223
+
224
+ def inline_array(a)
225
+ parts = a.map { inline(_1) }
226
+ text = "[#{parts.join(', ')}]"
227
+ return text if width(text) <= LONG || parts.size < 2
228
+ kept = []
229
+ parts.each do |p|
230
+ break if width("[#{(kept + [p]).join(', ')}, #{ELLIPSIS}+#{parts.size}]") > LONG && kept.any?
231
+ kept << p
232
+ end
233
+ "[#{kept.join(', ')}, #{ELLIPSIS}+#{parts.size - kept.size}]"
234
+ end
235
+
236
+ # What changed from the status old to new, one String a change:
237
+ # "a.b 0→1" (a value changed), "+a.b=1" (a key came; a Hash on one
238
+ # line), "-a.b" (a key went). Hashes in both are compared key by key.
239
+ def status_diff(old, new, prefix = nil)
240
+ out = []
241
+ at = ->(k) { prefix ? "#{prefix}.#{k}" : k.to_s }
242
+ new.each do |k, v|
243
+ if !old.key?(k)
244
+ out << "+#{pair(at.(k), v)}"
245
+ elsif old[k].is_a?(Hash) && v.is_a?(Hash)
246
+ out.concat(status_diff(old[k], v, at.(k)))
247
+ elsif old[k] != v
248
+ out << "#{at.(k)} #{inline(old[k])}#{ARROW}#{inline(v)}"
249
+ end
250
+ end
251
+ old.each_key { |k| out << "-#{at.(k)}" unless new.key?(k) }
252
+ out
253
+ end
254
+ end
255
+ end
256
+ end