asterism 0.8.0 → 0.10.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,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
@@ -0,0 +1,216 @@
1
+ # The values the applications put on their own keys
2
+ # (asterism/<node>/<app>/<object>/<more>, docs/protocol.md 2.2: a game
3
+ # table's state, a log), for `asterism object get / echo` and the MCP
4
+ # bridge's asterism_values. A put is not kept anywhere and a get has no
5
+ # answer for it (an application's queryable answers only the key shapes of
6
+ # 4.3), so the latest value is only what a subscriber received and kept:
7
+ # Values::Store keeps it, a key at a time, within limits.
8
+ require "json"
9
+
10
+ module Asterism
11
+ class CLI
12
+ module Values
13
+ PREFIX = "asterism/"
14
+ # Characters a part of a key cannot have (2.1); * and ** are whole
15
+ # parts.
16
+ BAD = /[*$?#]/
17
+
18
+ module_function
19
+
20
+ # The pattern (node/app/object/more, * and ** as whole parts) checked;
21
+ # ArgumentError otherwise.
22
+ def check_pattern!(pattern)
23
+ parts = pattern.to_s.split("/", -1)
24
+ bad = parts.empty? || parts.any? do |p|
25
+ p.empty? || p.start_with?("@") || (p.match?(BAD) && !%w[* **].include?(p))
26
+ end
27
+ raise ArgumentError, "#{pattern.inspect}: a key's parts are names, * (one part) or ** (any parts)" if bad
28
+ pattern
29
+ end
30
+
31
+ # The Zenoh key expression of a pattern.
32
+ def key_expr(pattern)
33
+ "#{PREFIX}#{pattern}"
34
+ end
35
+
36
+ def match?(pattern, key)
37
+ ::Asterism.glob?(pattern, key)
38
+ end
39
+
40
+ # [value, format] of a payload: MessagePack (Asterism's own, 3.1);
41
+ # else JSON text; else UTF-8 text; else "(n bytes)". The value is
42
+ # what JSON can hold (bytes inside as "(n bytes)").
43
+ def decode(payload)
44
+ bytes = payload.to_s.b
45
+ return [nil, "empty"] if bytes.empty?
46
+ begin
47
+ return [json_safe(::MessagePack.unpack(bytes)), "msgpack"]
48
+ rescue StandardError
49
+ nil
50
+ end
51
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
52
+ return ["(#{bytes.bytesize} bytes)", "bytes"] unless text.valid_encoding?
53
+ begin
54
+ [json_safe(JSON.parse(text)), "json"]
55
+ rescue JSON::ParserError
56
+ [text, "text"]
57
+ end
58
+ end
59
+
60
+ def json_safe(v)
61
+ case v
62
+ when Hash then v.to_h { |k, x| [k.is_a?(String) ? k : k.to_s, json_safe(x)] }
63
+ when Array then v.map { json_safe(_1) }
64
+ when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
65
+ when Float then v.finite? ? v : v.to_s
66
+ when Integer, true, false, nil then v
67
+ when Symbol then v.to_s
68
+ else v.inspect
69
+ end
70
+ end
71
+
72
+ def time(t)
73
+ t.getutc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
74
+ end
75
+
76
+ # One put as the command line and the MCP tool show it: {"key"
77
+ # (without asterism/), "t", "bytes", "format", "value"}.
78
+ def entry(key, payload, t = Time.now)
79
+ value, format = decode(payload)
80
+ { "key" => key.delete_prefix(PREFIX), "t" => time(t), "bytes" => payload.to_s.bytesize,
81
+ "format" => format, "value" => value }
82
+ end
83
+
84
+ # The latest put of each key, as received since it was made: at most
85
+ # max_keys keys and max_total payload bytes (the key received
86
+ # longest ago is forgotten first); a payload over max_bytes is kept
87
+ # as its size only. Thread safe; wait blocks until a key matches.
88
+ class Store
89
+ MAX_KEYS = 1000
90
+ MAX_BYTES = 256 * 1024
91
+ MAX_TOTAL = 16 * 1024 * 1024
92
+ # The JSON text of a value longer than this is cut in an answer.
93
+ VALUE_CUT = 8192
94
+ # The most keys an answer lists (the rest counted as "more").
95
+ MAX_ANSWER = 100
96
+ # The longest wait an answer may ask for (seconds).
97
+ MAX_WAIT = 30
98
+
99
+ attr_reader :since, :forgotten
100
+
101
+ # alive: given "<node>/<app>", true when the application is on the
102
+ # network now. clock: the monotonic seconds (for the tests).
103
+ def initialize(max_keys: MAX_KEYS, max_bytes: MAX_BYTES, max_total: MAX_TOTAL, alive: nil,
104
+ clock: nil, now: nil)
105
+ @max_keys = max_keys
106
+ @max_bytes = max_bytes
107
+ @max_total = max_total
108
+ @alive = alive || ->(_app) { true }
109
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
110
+ @now = now || -> { Time.now }
111
+ @since = @now.call
112
+ @keys = {} # key => record, the oldest received first
113
+ @total = 0
114
+ @forgotten = 0
115
+ @lock = Mutex.new
116
+ @cond = ConditionVariable.new
117
+ end
118
+
119
+ # A put received (key with or without asterism/).
120
+ def put(key, payload)
121
+ key = key.delete_prefix(PREFIX)
122
+ payload = payload.to_s
123
+ size = payload.bytesize
124
+ rec = if size > @max_bytes
125
+ { "value" => "(#{size} bytes: too large to keep)", "format" => "dropped", "kept" => false }
126
+ else
127
+ v, f = Values.decode(payload)
128
+ { "value" => v, "format" => f }
129
+ end
130
+ rec.merge!("key" => key, "bytes" => size, "t" => @now.call, "mono" => @clock.call, "left" => false)
131
+ @lock.synchronize do
132
+ old = @keys.delete(key)
133
+ @total -= old["kept"] == false ? 0 : old["bytes"] if old
134
+ @keys[key] = rec
135
+ @total += size unless rec["kept"] == false
136
+ while @keys.size > @max_keys || (@total > @max_total && @keys.size > 1)
137
+ _, gone = @keys.shift
138
+ @total -= gone["bytes"] unless gone["kept"] == false
139
+ @forgotten += 1
140
+ end
141
+ @cond.broadcast
142
+ end
143
+ self
144
+ end
145
+
146
+ # The application "<node>/<app>" left the network: its values are
147
+ # from before (kept, marked).
148
+ def left(app)
149
+ prefix = "#{app}/"
150
+ @lock.synchronize { @keys.each_value { |r| r["left"] = true if r["key"].start_with?(prefix) } }
151
+ self
152
+ end
153
+
154
+ def size
155
+ @lock.synchronize { @keys.size }
156
+ end
157
+
158
+ def keys
159
+ @lock.synchronize { @keys.keys }
160
+ end
161
+
162
+ # The records whose keys match (sorted by key); with wait (seconds),
163
+ # until one matches or the time is up.
164
+ def match(pattern, wait: 0)
165
+ deadline = @clock.call + wait.to_f
166
+ @lock.synchronize do
167
+ loop do
168
+ got = @keys.values.select { Values.match?(pattern, _1["key"]) }
169
+ return got.sort_by { _1["key"] } unless got.empty?
170
+ left = deadline - @clock.call
171
+ return [] if left <= 0
172
+ @cond.wait(@lock, left)
173
+ end
174
+ end
175
+ end
176
+
177
+ # What asterism_values answers: {"since", "values" => [{"key", "t",
178
+ # "age_s", "app", "bytes", "format", "value"(, "truncated")}]}, with
179
+ # "more" when more keys matched than an answer lists, "forgotten"
180
+ # when the limits made it forget keys, and "note" when nothing came.
181
+ def answer(pattern = "**", wait: 0)
182
+ Values.check_pattern!(pattern)
183
+ wait = wait.to_f.clamp(0, MAX_WAIT)
184
+ recs = match(pattern, wait: wait)
185
+ now = @clock.call
186
+ alive = {}
187
+ out = { "since" => Values.time(@since) }
188
+ out["values"] = recs.first(MAX_ANSWER).map do |r|
189
+ app = r["key"].split("/")[0, 2].join("/")
190
+ here = alive.key?(app) ? alive[app] : (alive[app] = @alive.call(app) ? true : false)
191
+ h = { "key" => r["key"], "t" => Values.time(r["t"]), "age_s" => (now - r["mono"]).round(1),
192
+ "app" => here ? (r["left"] ? "restarted" : "alive") : "gone", "bytes" => r["bytes"],
193
+ "format" => r["format"] }
194
+ text = JSON.generate(r["value"])
195
+ if text.bytesize > VALUE_CUT
196
+ h["value"] = "#{text.byteslice(0, VALUE_CUT).scrub('')}…"
197
+ h["truncated"] = true
198
+ else
199
+ h["value"] = r["value"]
200
+ end
201
+ h["truncated"] = true if r["kept"] == false
202
+ h
203
+ end
204
+ out["more"] = recs.size - MAX_ANSWER if recs.size > MAX_ANSWER
205
+ out["forgotten"] = @forgotten if @forgotten.positive?
206
+ if recs.empty?
207
+ out["note"] = "nothing came for #{pattern}#{wait.positive? ? " in #{format('%g', wait)} s" : ''}: " \
208
+ "it has not been put since this server started (puts come only when a value " \
209
+ "changes), or the router does not let this server read it"
210
+ end
211
+ out
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end