on master help sign in
jibb/tools/ship
2400 lines 92 kB history raw
1#!/usr/bin/env python3
2"""Run the Jibb desks on a fake ship: start it, sync desks, run the tests.
4 tools/ship start start the fake ship in the background
5 tools/ship stop stop it
6 tools/ship status running or not; desks, revisions, mounts
7 tools/ship sync my-app assemble a desk from its folders, |commit
8 tools/ship test sync the jibb-dev test desk, run -test
9 tools/ship gen-tests write tests/lib/jibb.hoon from tests/golden
10 tools/ship check-commit prove that |commit stores .jibb files
11 tools/ship scry /cx/=my-app=/ui/about/jibb
12 tools/ship hoon '(add 2 2)' evaluate Hoon on the ship, as dojo would
13 tools/ship run FILE run a thread file (a strand) on the ship
14 tools/ship poke hood kiln-commit '[%my-app |]'
15 tools/ship commit|mount|unmount DESK
16 tools/ship log the end of the ship's log
18The ship is a fake ~zod at /root/urbit/zod (--pier or $JIBB_PIER), run
19with -t in its own session, its output going to <pier>.log (--log or
20$JIBB_SHIP_LOG); a start moves the previous log to <pier>.log.1.
22Commands reach the ship over its conn.sock, as khan-eval threads: click
23(/root/src/tools/pkg/click/click, --click or $JIBB_CLICK) formats and
24jams each request, and this script sends it and reads the whole reply.
25It doesn't let click do the sending, because click reads the reply with
26`nc -U -W 1`, which stops after the first 16 KB, so bigger replies came
27back cut off and decoded to garbage. click's -i quoting escapes only
28single quotes and joins the lines with gaps, so threads are written
29without comments and their backslashes are doubled before click sees
30them.
32Exit status: 0 on success, 1 when something failed (a commit, a test, a
33check), 2 when the command couldn't run (a usage error, no ship).
34"""
36import argparse
37import datetime
38import hashlib
39import os
40import re
41import shutil
42import signal
43import socket
44import subprocess
45import sys
46import tempfile
47import time
49sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
50import golden_case
52ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
53PIER = os.environ.get('JIBB_PIER', '/root/urbit/zod')
54CLICK = os.environ.get('JIBB_CLICK', '/root/src/tools/pkg/click/click')
55STUB = os.path.join(ROOT, 'tools', 'testdata', 'ship-stub.hoon')
56GOLDEN = os.path.join(ROOT, 'tests', 'golden')
57TESTS_LIB = os.path.join(ROOT, 'tests', 'lib', 'jibb.hoon')
59# Desks the repo builds, and the folders each is assembled from, in
60# order. Every desk gets every package under vendor/. A folder is
61# (repo folder, desk folder, folders left out).
62DESKS = {
63 'my-app': [('jibb-dev', '', ['tests']), ('my-app', '', [])],
64 'jibb': [('jibb', '', [])],
65 'jibb-dev': [('jibb-dev', '', []), ('tests', 'tests', [])],
67# The test desk has no sys.kelvin of its own; Clay refuses a commit
68# without one, so it gets the %jibb desk's.
69EXTRA = {'jibb-dev': {'sys.kelvin': os.path.join('jibb', 'sys.kelvin')}}
71# Marks whose stored noun is the file's bytes, as a cord. sync checks
72# these files by hash; files with other marks, such as desk.bill and
73# the golden .txt files, are checked by converting the repo's copy on
74# the ship and comparing the values. (%txt is not one: it stores a
75# wain, and vere's +to-wain jet keeps no trace of a final newline.)
76BYTE_MARKS = {'hoon', 'jibb', 'js'}
78# vere's conn.sock closes a connection as soon as it has written the
79# reply, so a reply bigger than the socket's buffer (about 200 KB) is
80# cut off. Threads that may print a lot cut their output to this.
81REPLY_BYTES = 150000
83KNOT = re.compile(r'^[a-z0-9\-.~_]+$')
84TERM = re.compile(r'^[a-z][a-z0-9\-]*$')
86# Log lines that come from the ship's own business, not from what we
87# asked: docket and kiln failing to reach the network on a fake ship.
88NOISE = re.compile(
89 r'^(http: fail \(\d+, \d+\)|docket: (fetching|thread failed|'
90 r'retry)|%retry-too-many|kiln: activation failed|thread-fail$|'
91 r'cancelled$|eyre: replacing existing binding|mdns: )')
94class Fail(Exception):
95 """Something failed: report it, exit 1."""
98class Unable(Exception):
99 """The command can't run: report it, exit 2."""
102def say(msg=''):
103 print(msg, flush=True)
106_warned = set()
109def warn(msg):
110 """Print a warning once, however often the code finds it."""
111 if msg not in _warned:
112 _warned.add(msg)
113 print('ship: ' + msg, file=sys.stderr, flush=True)
116# ---------------------------------------------------------------- nouns
118# A noun is an int (an atom) or a 2-tuple (a cell).
121def cue(data):
122 """Decode a jammed noun, without recursion."""
123 total = len(data) * 8
125 def bit(i):
126 if i >= total:
127 raise ValueError('cue: ran off the end of the jam')
128 return (data[i >> 3] >> (i & 7)) & 1
130 def bits(i, k):
131 if k == 0:
132 return 0
133 if i + k > total:
134 raise ValueError('cue: ran off the end of the jam')
135 v = int.from_bytes(data[i >> 3:(i + k + 7) >> 3], 'little')
136 return (v >> (i & 7)) & ((1 << k) - 1)
138 def rub(i):
139 c = 0
140 while bit(i + c) == 0:
141 c += 1
142 if c == 0:
143 return 1, 0
144 b = (1 << (c - 1)) | bits(i + c + 1, c - 1)
145 return c + c + b, bits(i + c + c, b)
147 refs = {}
148 stack = []
149 i = 0
150 while True:
151 start = i
152 if bit(i) == 0:
153 length, val = rub(i + 1)
154 i += 1 + length
155 refs[start] = val
156 elif bit(i + 1) == 0:
157 stack.append([start, False, None])
158 i += 2
159 continue
160 else:
161 length, ref = rub(i + 2)
162 i += 2 + length
163 val = refs[ref]
164 while stack:
165 top = stack[-1]
166 if not top[1]:
167 top[1] = True
168 top[2] = val
169 break
170 stack.pop()
171 val = (top[2], val)
172 refs[top[0]] = val
173 else:
174 return val
177def atom_bytes(a):
178 """An atom's bytes, low byte first, as Hoon cords are."""
179 return a.to_bytes((a.bit_length() + 7) // 8, 'little')
182def text(a):
183 return atom_bytes(a).decode('utf-8', 'replace')
186def term(s):
187 return int.from_bytes(s.encode(), 'little')
190def items(n):
191 """A Hoon list as a Python list."""
192 out = []
193 while isinstance(n, tuple):
194 out.append(n[0])
195 n = n[1]
196 return out
199def tape(n):
200 return bytes(items(n)).decode('utf-8', 'replace')
203def path_text(n):
204 return '/' + '/'.join(text(s) for s in items(n))
207def hoon_cord(data):
208 """A Hoon cord literal holding exactly these bytes."""
209 out = ["'"]
210 for b in data:
211 if b == 0x5c:
212 out.append('\\\\')
213 elif b == 0x27:
214 out.append("\\'")
215 elif 0x20 <= b < 0x7f:
216 out.append(chr(b))
217 else:
218 out.append('\\%02x' % b)
219 out.append("'")
220 return ''.join(out)
223def hoon_path(segments):
224 """A Hoon path literal. A segment that isn't a term, such as
225 odd.name_1, goes in as a knot, [~.odd.name_1]: written plain, the
226 parser would read the . as the end of a coin."""
227 return '/' + '/'.join(s if TERM.match(s) else '[~.%s]' % s
228 for s in segments) if segments else '/'
231# ---------------------------------------------------------------- tangs
234def ram(tank):
235 """A tank on one line, as Hoon's +ram:re prints it."""
236 if isinstance(tank, int):
237 return text(tank)
238 tag = text(tank[0])
239 if tag == 'leaf':
240 return tape(tank[1])
241 if tag in ('rose', 'palm'):
242 spec, kids = tank[1]
243 if tag == 'palm':
244 p, (q, (r, s)) = spec
245 sep, opn, cls = tape(p), tape(q) + tape(r), tape(s)
246 else:
247 p, (q, r) = spec
248 sep, opn, cls = tape(p), tape(q), tape(r)
249 return opn + sep.join(ram(k) for k in items(kids)) + cls
250 return repr(tank)
253def tang_lines(tang):
254 out = []
255 for tank in items(tang):
256 try:
257 out.append(ram(tank))
258 except (TypeError, ValueError):
259 out.append(repr(tank))
260 return out
263# ---------------------------------------------------------------- the ship
266def find_urbit(pier):
267 run = os.path.join(pier, '.run')
268 if os.access(run, os.X_OK):
269 return run
270 found = shutil.which('urbit')
271 if not found:
272 raise Unable('no urbit binary: neither %s nor urbit on PATH' % run)
273 return found
276class Ship:
277 def __init__(self, pier, log, click):
278 self.pier = os.path.realpath(pier)
279 self.log = log or self.pier + '.log'
280 self.click = click
281 self.sock = os.path.join(self.pier, '.urb', 'conn.sock')
283 # -- processes
285 def processes(self):
286 """(king pid, serf pid) of the urbit running this pier, or None."""
287 king = serf = None
288 for pid in os.listdir('/proc'):
289 if not pid.isdigit():
290 continue
291 try:
292 with open('/proc/%s/cmdline' % pid, 'rb') as f:
293 args = [a.decode('utf-8', 'replace')
294 for a in f.read().split(b'\0') if a]
295 except OSError:
296 continue
297 if len(args) < 2 or self.pier not in [
298 os.path.realpath(a) if a.startswith('/') else a
299 for a in args[1:]]:
300 continue
301 if args[1] == 'work':
302 serf = int(pid)
303 elif args[1] not in ('eval', 'dock', 'info', 'pack', 'meld'):
304 king = int(pid)
305 return king, serf
307 def running(self):
308 king, serf = self.processes()
309 return king or serf
311 def require(self):
312 if not self.running():
313 raise Unable('the ship at %s is not running; start it with '
314 'tools/ship start' % self.pier)
315 if not os.path.exists(self.sock):
316 raise Unable('the ship is running but has no %s yet; wait for '
317 'it to come up' % self.sock)
319 # -- the log
321 def log_size(self):
322 try:
323 return os.path.getsize(self.log)
324 except OSError:
325 return 0
327 def log_since(self, offset):
328 """The log's lines from a byte offset on, without colours."""
329 try:
330 with open(self.log, 'rb') as f:
331 f.seek(offset)
332 data = f.read()
333 except OSError:
334 return []
335 data = re.sub(rb'\x1b\[[0-9;?]*[A-Za-z]', b'', data)
336 return data.decode('utf-8', 'replace').replace('\r', '').split('\n')
338 # -- start and stop
340 def start(self, timeout):
341 if self.running():
342 say('the ship is already running (pid %d)' % self.running())
343 self.wait_live(time.time() + timeout, None, 0)
344 return
345 if not os.path.isdir(os.path.join(self.pier, '.urb')):
346 raise Unable('no pier at %s' % self.pier)
347 binary = find_urbit(self.pier)
348 if os.path.exists(self.log) and os.path.getsize(self.log):
349 os.replace(self.log, self.log + '.1')
350 logf = open(self.log, 'ab')
351 logf.write(('tools/ship: starting %s -t %s at %s\n' % (
352 binary, self.pier,
353 datetime.datetime.now().isoformat(timespec='seconds'))).encode())
354 logf.flush()
355 proc = subprocess.Popen(
356 [binary, '-t', self.pier], stdin=subprocess.DEVNULL,
357 stdout=logf, stderr=subprocess.STDOUT,
358 cwd=os.path.dirname(self.pier), start_new_session=True)
359 logf.close()
360 say('starting the ship at %s (pid %d), logging to %s'
361 % (self.pier, proc.pid, self.log))
362 self.wait_live(time.time() + timeout, proc, 0)
364 def wait_live(self, deadline, proc, offset):
365 """Wait for the pier to say it's live and for a thread to run."""
366 live = re.compile(r'pier \(\d+\): live')
367 while True:
368 if proc is not None and proc.poll() is not None:
369 tail = '\n'.join(self.log_since(offset)[-30:])
370 raise Unable('the ship exited with status %d; the end of '
371 'its log:\n%s' % (proc.returncode, tail))
372 if any(live.search(l) for l in self.log_since(offset)) or \
373 proc is None:
374 if os.path.exists(self.sock):
375 try:
376 our = self.thread(OUR, timeout=60)
377 say('the ship is live: %s' % text(our))
378 return
379 except (Fail, OSError) as e:
380 if time.time() > deadline:
381 raise Unable('the ship is up but its threads '
382 "don't run: %s" % e)
383 if time.time() > deadline:
384 raise Unable('the ship did not come up in time; see %s'
385 % self.log)
386 time.sleep(0.5)
388 def stop(self, timeout, force):
389 king, serf = self.processes()
390 if not king and not serf:
391 say('the ship is not running')
392 return
393 target = king or serf
394 say('stopping the ship (pid %d)' % target)
395 os.kill(target, signal.SIGTERM)
396 deadline = time.time() + timeout
397 while time.time() < deadline:
398 if not self.running():
399 say('the ship has stopped')
400 return
401 time.sleep(0.3)
402 if not force:
403 raise Fail('the ship is still running after %ds; tools/ship '
404 'stop --force kills it' % timeout)
405 for pid in self.processes():
406 if pid:
407 os.kill(pid, signal.SIGKILL)
408 time.sleep(1)
409 say('killed the ship')
411 # -- threads
413 def thread(self, hoon, timeout=600):
414 """Run a strand on the ship with khan-eval; the noun of its vase.
416 hoon is a whole strand, `=/ m (strand ,vase) ...`, in the
417 subject khan-eval gives it: strandio, spider and zuse.
418 """
419 if not os.path.exists(self.sock):
420 raise Unable('no %s: is the ship running?' % self.sock)
421 frame = self.request(hoon)
422 reply = self.call(frame, timeout)
423 noun = cue(reply[5:])
424 try:
425 _rid, (kind, rest) = noun
426 except (TypeError, ValueError):
427 raise Fail('a strange reply from the ship: %.200r' % (noun,))
428 if kind == term('avow'):
429 ok, payload = rest
430 if ok == 0:
431 return payload[1]
432 mote, tang = payload
433 raise Fail('the thread failed: %%%s\n%s' % (
434 text(mote), '\n'.join(' ' + l for l in tang_lines(tang))))
435 raise Fail('the ship refused the request: %%%s %.200r'
436 % (text(kind), rest))
438 def request(self, hoon):
439 """The request for a khan-eval thread, as click formats and jams it."""
440 prepared = prepare(hoon)
441 with tempfile.TemporaryDirectory(prefix='jibb-ship-') as tmp:
442 src = os.path.join(tmp, 'thread.hoon')
443 out = os.path.join(tmp, 'request.jam')
444 with open(src, 'w', encoding='ascii') as f:
445 f.write(prepared)
446 res = subprocess.run(
447 [self.click, '-k', '-j', '-b', find_urbit(self.pier),
448 '-i', src, '-o', out, self.pier],
449 capture_output=True)
450 try:
451 with open(out, 'rb') as f:
452 frame = f.read()
453 except OSError:
454 frame = b''
455 size = int.from_bytes(frame[1:5], 'little') if len(frame) >= 5 else -1
456 if len(frame) < 6 or frame[0] != 0 or size != len(frame) - 5:
457 err = [l for l in res.stderr.decode('utf-8', 'replace')
458 .replace('\r', '').split('\n')
459 if l and not re.match(r'^(loom|lite|eval \()', l)]
460 raise Fail('click could not jam the request:\n ' +
461 '\n '.join(err[-20:] or ['(no output)']))
462 return frame
464 def call(self, frame, timeout):
465 """Send a newt frame to conn.sock and read one whole frame back."""
466 s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
467 s.settimeout(timeout)
468 try:
469 s.connect(self.sock)
470 s.sendall(frame)
471 buf = b''
472 need = None
473 while need is None or len(buf) < need:
474 chunk = s.recv(1 << 20)
475 if not chunk:
476 break
477 buf += chunk
478 if need is None and len(buf) >= 5:
479 need = 5 + int.from_bytes(buf[1:5], 'little')
480 except socket.timeout:
481 raise Fail('no reply from the ship in %ds' % timeout)
482 finally:
483 s.close()
484 if need is None:
485 raise Fail('the ship closed the connection without replying')
486 if len(buf) < need:
487 raise Fail('the reply was cut off after %d of its %d bytes: '
488 'vere closes conn.sock as soon as it has written a '
489 'reply, so only what fits in the socket buffer gets '
490 'through; ask for less' % (len(buf), need))
491 return buf[:need]
494def prepare(hoon):
495 """Hoon as click -i needs it: no comments, backslashes doubled, and
496 every byte that isn't printable ASCII written as a \\XX escape, all
497 inside the cord click wraps the thread in."""
498 lines = []
499 for line in hoon.split('\n'):
500 line = strip_comment(line).rstrip()
501 if line:
502 lines.append(line)
503 out = []
504 for ch in '\n'.join(lines).encode('utf-8'):
505 if ch == 0x5c:
506 out.append('\\\\')
507 elif ch == 0x0a or 0x20 <= ch < 0x7f:
508 out.append(chr(ch))
509 else:
510 out.append('\\%02x' % ch)
511 return ''.join(out) + '\n'
514def strip_comment(line):
515 """A line of Hoon without its :: comment, minding quotes."""
516 quote = None
517 i = 0
518 while i < len(line):
519 c = line[i]
520 if quote:
521 if c == '\\':
522 i += 1
523 elif c == quote:
524 quote = None
525 elif c in '\'"':
526 quote = c
527 elif line.startswith('::', i):
528 return line[:i]
529 i += 1
530 return line
533# ---------------------------------------------------------------- threads
535# Each is a strand, filled in by fill(): @@NAME@@ is replaced. No
536# comments here, since click joins the lines (see prepare()).
538OUR = r"""
539=/ m (strand ,vase)
540;< =bowl:spider bind:m get-bowl
541(pure:m !>((scot %p our.bowl)))
542"""
544# Mount points, from Clay's %hill, and each desk with its revision.
545DESKS_INFO = r"""
546=/ m (strand ,vase)
547;< =bowl:spider bind:m get-bowl
548;< ~ bind:m (send-raw-card [%pass /boat %arvo %c %boat ~])
549;< res=[wire sign-arvo] bind:m take-sign-arvo
550=/ sig=sign-arvo +.res
551=/ mon=(list @tas) ?.(?=([%clay %hill *] sig) ~ p.sig)
552=/ our (scot %p our.bowl)
553=/ now (scot %da now.bowl)
554=/ dez=(list desk) ~(tap in .^((set desk) %cd /[our]/[%$]/[now]))
555%- pure:m
556!> :- mon
557%+ turn dez
558|= d=desk
559[d ud:.^(cass:clay %cw /[our]/[d]/[now])]
560"""
562# |new-desk @@DESK@@, as gen/hood/new-desk.hoon makes it: the %noun,
563# %hoon, %txt and %kelvin marks and sys.kelvin, from %base.
564NEW_DESK = r"""
565=/ m (strand ,vase)
566;< =bowl:spider bind:m get-bowl
567=/ bas=path /(scot %p our.bowl)/base/(scot %da now.bowl)
568=/ paz=(list path)
569 ~[/mar/noun/hoon /mar/hoon/hoon /mar/txt/hoon /mar/kelvin/hoon /sys/kelvin]
570=/ pages=(map path page:clay)
571 %- ~(gas by *(map path page:clay))
572 %+ turn paz
573 |= p=path
574 [p (rear p) .^(* %cx (weld bas p))]
575;< ~ bind:m (poke-our %hood %helm-pass !>((new-desk:cloy %@@DESK@@ ~ pages)))
576(pure:m !>(~))
577"""
579# |mount %@@DESK@@
580MOUNT = r"""
581=/ m (strand ,vase)
582;< =bowl:spider bind:m get-bowl
583=/ pax=path (en-beam [our.bowl %@@DESK@@ da+now.bowl] /)
584;< ~ bind:m (poke-our %hood %kiln-mount !>([pax %@@DESK@@]))
585(pure:m !>(~))
586"""
588# |unmount %@@DESK@@
589UNMOUNT = r"""
590=/ m (strand ,vase)
591;< ~ bind:m (poke-our %hood %kiln-unmount !>(`term`%@@DESK@@))
592(pure:m !>(~))
593"""
595# |commit %@@DESK@@, giving the revision before it.
596COMMIT = r"""
597=/ m (strand ,vase)
598;< =bowl:spider bind:m get-bowl
599=/ rev=@ud ud:.^(cass:clay %cw /(scot %p our.bowl)/@@DESK@@/(scot %da now.bowl))
600;< ~ bind:m (poke-our %hood %kiln-commit !>([%@@DESK@@ |]))
601(pure:m !>(rev))
602"""
604# Change a desk with @@ACT@@ and wait for the change to land: [%done
605# revision] when the desk gets a new revision, [%crud term tang] when
606# Clay rejects it, [%none ~] when nothing happens in @@SECS@@ seconds.
607# The change is a |commit (COMMIT_ACT) or deletions (DELETE_ACT), and
608# Clay names a rejected one by its task, @@MOTE@@: %into or %info.
609# Clay's error reaches only dill's %logs watchers (dill no longer
610# prints errors, and -t sends drum's output nowhere), so the thread
611# watches %logs while it waits.
612CHANGE_WAIT = r"""
613=/ m (strand ,vase)
614;< =bowl:spider bind:m get-bowl
615=/ rev=@ud ud:.^(cass:clay %cw /(scot %p our.bowl)/@@DESK@@/(scot %da now.bowl))
616=/ until=@da (add now.bowl (mul ~s1 @@SECS@@))
617=/ finish
618 |= [res=* rest=?]
619 =/ m (strand ,vase)
620 ^- form:m
621 ;< ~ bind:m (send-raw-card [%pass /jibb/logs %arvo %d %logs ~])
622 ;< ~ bind:m (send-raw-card [%pass /jibb/next %arvo %c %warp our.bowl %@@DESK@@ ~])
623 ;< ~ bind:m
624 ?. rest (pure:(strand ,~) ~)
625 (send-raw-card [%pass /jibb/wait %arvo %b %rest until])
626 (pure:m !>(res))
627;< ~ bind:m (send-raw-card [%pass /jibb/logs %arvo %d %logs `~])
628;< ~ bind:m (send-raw-card [%pass /jibb/next %arvo %c %warp our.bowl %@@DESK@@ `[%next %w ud+rev /]])
629;< ~ bind:m (send-raw-card [%pass /jibb/wait %arvo %b %wait until])
630;< ~ bind:m @@ACT@@
631|- ^- form:m
632=* loop $
633;< [wir=wire sig=sign-arvo] bind:m take-sign-arvo
634?: ?=([%jibb %logs ~] wir)
635 ?. ?=([%dill %logs %crud %@@MOTE@@ *] sig)
636 loop
637 =/ cud ;;([@tas tang] +>+.sig)
638 (finish [%crud -.cud (scag 300 +.cud)] &)
639?: ?=([%jibb %next ~] wir)
640 ;< now=@da bind:m get-time
641 =/ new=@ud ud:.^(cass:clay %cw /(scot %p our.bowl)/@@DESK@@/(scot %da now))
642 (finish [%done new] &)
643?: ?=([%jibb %wait ~] wir)
644 (finish [%none ~] |)
645loop
646"""
648# |commit %@@DESK@@
649COMMIT_ACT = '(poke-our %hood %kiln-commit !>([%@@DESK@@ |]))'
651# Delete the files @@PATHS@@ from %@@DESK@@ through Clay, with a %info
652# task, as a |commit does for files deleted from the mount.
653DELETE_ACT = ('(send-raw-card [%pass /jibb/info %arvo %c %info %@@DESK@@ %& '
654 '(turn `(list path)`@@PATHS@@ |=(p=path [p %del ~]))])')
656# |merge %@@DESK@@ our %@@FROM@@, =gem %@@GERM@@
657MERGE = r"""
658=/ m (strand ,vase)
659;< =bowl:spider bind:m get-bowl
660=/ arg [%@@DESK@@ our.bowl %@@FROM@@ da+now.bowl %@@GERM@@]
661;< ~ bind:m (poke-our %hood %kiln-merge !>(arg))
662(pure:m !>(~))
663"""
665# A page of the desk's manifest, for comparing it with the repo: the
666# revision; whether +to-wain ends the last line at a final newline, as
667# vere's jet does, or gives an empty line after it, as the Hoon does;
668# how many files the desk has; for each file sent with its content,
669# whether the desk's mark, converting that content from %mime, gives
670# the stored value; and for the files @@FROM@@ to @@FROM@@ + @@COUNT@@,
671# the path with [%cord hash] for a cord, [%wain hash] for a %txt file's
672# lines, each with a newline after it, or [%other 0]. A hash is the
673# first 8 bytes of the sha-256. now is fixed across pages.
674MANIFEST = r"""
675=/ m (strand ,vase)
676;< =bowl:spider bind:m get-bowl
677=/ now=@da @@NOW@@
678=/ pre=path /(scot %p our.bowl)/@@DESK@@/(scot %da now)
679=/ all=(list path) .^((list path) %ct pre)
680=/ conv=(list [path ?])
681 %+ turn `(list [path @t])`@@CONV@@
682 |= [pax=path c=@t]
683 ?. .^(? %cu (weld pre pax)) [pax |]
684 =/ have .^(* %cx (weld pre pax))
685 =/ tub .^(tube:clay %cc (weld pre /mime/[(rear pax)]))
686 =/ res (mule |.((tub !>([/text/plain (as-octs:mimes:html c)]))))
687 [pax &(?=(%& -.res) =(q.p.res have))]
688=/ page=(list [path @tas @])
689 %+ turn (swag [@@FROM@@ @@COUNT@@] all)
690 |= pax=path
691 =/ have .^(* %cx (weld pre pax))
692 :- pax
693 ?@ have [%cord (end [3 8] (shax have))]
694 ?. =(%txt (rear pax)) [%other 0]
695 :- %wain
696 (end [3 8] (shax (rap 3 (turn ;;(wain have) |=(l=@t (cat 3 l '\0a'))))))
697=/ jet=? =(1 (lent (to-wain:format 'a\0a')))
698(pure:m !>([ud:.^(cass:clay %cw pre) now jet (lent all) conv page]))
699"""
701# -test /=@@DESK@@=/tests, through the %test thread on %base.
702RUN_TEST = r"""
703=/ m (strand ,vase)
704;< =bowl:spider bind:m get-bowl
705=/ pax=path /(scot %p our.bowl)/@@DESK@@/(scot %da now.bowl)/tests
706;< res=thread-result bind:m (await-thread %test !>([~ pax]))
707?- -.res
708 %& (pure:m !>([%& ;;(? q.p.res)]))
709 %| (pure:m !>([%| p.res]))
710==
711"""
713def hoon_ud(n):
714 """A number as a Hoon @ud literal: 150.000."""
715 return '{:,}'.format(n).replace(',', '.')
718# +fit: printed lines, cut to what a reply can carry (see REPLY_BYTES).
719FIT = r"""
720=/ fit
721 |= lines=(list @t)
722 =| [n=@ud out=(list @t)]
723 |- ^- (list @t)
724 ?~ lines (flop out)
725 =. n (add n +((met 3 i.lines)))
726 ?: (gth n @@BYTES@@)
727 %- flop
728 :_ out
729 (crip "... and {(a-co:co (lent lines))} more lines, cut to fit the reply")
730 $(lines t.lines, out [i.lines out])
731""".replace('@@BYTES@@', hoon_ud(REPLY_BYTES))
733# A Hoon expression, evaluated as dojo would with our, now and eny,
734# printed as dojo prints it.
735EXPR = r"""
736=/ m (strand ,vase)
737;< =bowl:spider bind:m get-bowl
738=/ our our.bowl
739=/ now now.bowl
740=/ eny eny.bowl
741=/ vax=vase
742 !>
743 @@EXPR@@
744""" + FIT + r"""
745(pure:m !>((fit (turn (wash [0 @@WIDTH@@] (sell vax)) crip))))
746"""
748# A strand's result vase, printed as dojo prints it.
749RUN_FILE = r"""
750=/ m (strand ,vase)
751;< vax=vase bind:m
752 @@THREAD@@
753""" + FIT + r"""
754(pure:m !>((fit (turn (wash [0 @@WIDTH@@] (sell vax)) crip))))
755"""
757# A poke, as dojo's :agent &mark value.
758POKE = r"""
759=/ m (strand ,vase)
760;< =bowl:spider bind:m get-bowl
761=/ our our.bowl
762=/ now now.bowl
763;< ~ bind:m (poke-our %@@AGENT@@ %@@MARK@@ !>(@@VALUE@@))
764(pure:m !>(~))
765"""
768def fill(template, **kw):
769 out = template
770 for k, v in kw.items():
771 out = out.replace('@@%s@@' % k, v)
772 left = re.findall(r'@@[A-Z]+@@', out)
773 if left:
774 raise RuntimeError('unfilled template slots: %s' % left)
775 return out
778def desk_term(name):
779 if not TERM.match(name):
780 raise Unable('%s is not a desk name: desks are terms, lowercase '
781 'letters, digits and hyphens, starting with a letter'
782 % name)
783 return name
786# ---------------------------------------------------------------- desks
789def clay_path(rel):
790 """A desk file's Clay path from its Unix path: mar/txt.hoon is
791 /mar/txt/hoon."""
792 segs = rel.split('/')
793 name, ext = segs[-1].rsplit('.', 1)
794 return segs[:-1] + [name, ext]
797def unix_problem(rel):
798 """Why vere would leave this file out of the desk, or None."""
799 segs = rel.split('/')
800 name = segs[-1]
801 if '.' not in name or name.endswith('.'):
802 return 'it has no extension, so no mark'
803 if name.endswith('~'):
804 return 'it ends in ~'
805 for s in segs:
806 if not KNOT.match(s):
807 return ('%r is not a knot: Clay paths are lowercase letters, '
808 'digits and - . ~ _' % s)
809 return None
812def mark_exists(files, mark):
813 """Whether the desk has a mark: Clay looks for mark a-b-c at
814 /mar/a-b-c.hoon, then /mar/a/b-c.hoon, and so on."""
815 parts = mark.split('-')
816 for i in range(len(parts)):
817 rel = '/'.join(['mar'] + parts[:i] + ['-'.join(parts[i:])]) + '.hoon'
818 if rel in files:
819 return True
820 return False
823class Assembly:
824 """A desk's files: desk-relative Unix path -> (bytes, where from)."""
826 def __init__(self, desk):
827 self.desk = desk
828 self.files = {}
830 def add(self, rel, data, src):
831 old = self.files.get(rel)
832 if old is not None and old[0] != data:
833 raise Unable('%s/%s comes from both %s and %s, which differ'
834 % (self.desk, rel, old[1], src))
835 self.files[rel] = (data, src)
837 def replace(self, rel, data, src):
838 self.files[rel] = (data, src)
840 def add_tree(self, folder, into, skip=(), drop=None):
841 if not os.path.isdir(folder):
842 raise Unable('no folder %s' % show(folder))
843 for dirpath, dirnames, filenames in os.walk(folder):
844 rel_dir = os.path.relpath(dirpath, folder)
845 rel_dir = '' if rel_dir == '.' else rel_dir
846 dirnames[:] = sorted(
847 d for d in dirnames if not d.startswith('.')
848 and os.path.join(rel_dir, d) not in skip)
849 for fn in sorted(filenames):
850 if fn.startswith('.'):
851 continue
852 rel = os.path.join(rel_dir, fn)
853 if drop and drop(rel):
854 continue
855 desk_rel = os.path.join(into, rel) if into else rel
856 with open(os.path.join(dirpath, fn), 'rb') as f:
857 self.add(desk_rel, f.read(), show(os.path.join(dirpath, fn)))
860def show(path):
861 path = os.path.abspath(path)
862 rel = os.path.relpath(path, ROOT)
863 return path if rel.startswith('..') else rel
866def assemble(desk, lib=None, golden=None):
867 """The files of one of the repo's desks, checked as vere and Clay
868 will see them. lib replaces /lib/jibb.hoon; golden, for the
869 jibb-dev test desk, is the folder of golden cases. When either is
870 overridden, exclude other /tests/lib programs so they don't run
871 against an unrelated library or pollute the custom case results."""
872 if desk not in DESKS:
873 raise Unable('no desk %s here; the repo builds %s'
874 % (desk, ', '.join(sorted(DESKS))))
875 # Override runs are harness tests, not tests of the repository's
876 # importer, checker, or other test files against the replacement lib.
877 isolated = (lib is not None or
878 (golden is not None and
879 os.path.abspath(golden) != os.path.abspath(GOLDEN)))
880 a = Assembly(desk)
881 vendor = os.path.join(ROOT, 'vendor')
882 for pkg in sorted(os.listdir(vendor)):
883 if os.path.isdir(os.path.join(vendor, pkg)) and not pkg.startswith('.'):
884 a.add_tree(os.path.join(vendor, pkg), '')
885 for folder, into, skip in DESKS[desk]:
886 if folder == 'tests':
887 golden = golden or GOLDEN
888 if os.path.isdir(os.path.join(ROOT, 'tests')):
889 a.add_tree(os.path.join(ROOT, 'tests'), 'tests',
890 skip=['golden'], drop=lambda rel:
891 rel == os.path.join('lib', 'jibb.hoon') or
892 (isolated and rel.startswith('lib' + os.sep) and
893 rel.endswith('.hoon')))
894 if os.path.isdir(golden):
895 a.add_tree(golden, os.path.join('tests', 'golden'))
896 a.add(os.path.join('tests', 'lib', 'jibb.hoon'),
897 generate_tests(golden).encode(),
898 'tools/ship gen-tests --golden %s' % show(golden))
899 else:
900 a.add_tree(os.path.join(ROOT, folder), into, skip=skip)
901 for rel, src in EXTRA.get(desk, {}).items():
902 with open(os.path.join(ROOT, src), 'rb') as f:
903 a.add(rel, f.read(), src)
904 if lib is not None:
905 if 'lib/jibb.hoon' not in a.files:
906 raise Unable('desk %s has no lib/jibb.hoon to replace' % desk)
907 with open(lib, 'rb') as f:
908 a.replace('lib/jibb.hoon', f.read(), show(lib))
909 check_assembly(a)
910 return a
913def check_assembly(a):
914 problems = []
915 for rel in sorted(a.files):
916 why = unix_problem(rel)
917 if why is None and not mark_exists(a.files, rel.rsplit('.', 1)[1]):
918 why = 'the desk has no mark for .%s files' % rel.rsplit('.', 1)[1]
919 if why is None:
920 continue
921 if rel.startswith('tests/golden/'):
922 warn('leaving %s out of %s: %s' % (a.files[rel][1], a.desk, why))
923 del a.files[rel]
924 else:
925 problems.append('%s (%s): %s' % (rel, a.files[rel][1], why))
926 if 'sys.kelvin' not in a.files:
927 problems.append('no sys.kelvin: Clay refuses a commit without one')
928 if problems:
929 raise Unable('desk %s would not commit as it is:\n %s'
930 % (a.desk, '\n '.join(problems)))
933# ---------------------------------------------------------------- gen-tests
935TESTS_HEADER = """\
936:: tests/lib/jibb.hoon: the golden tests, run on a ship by -test
937::
938:: Generated by tools/ship gen-tests from the cases in
939:: @@SOURCE@@/. Don't edit it: run tools/ship gen-tests again
940:: after changing the cases.
941::
942:: Each test- arm runs golden:jibb on one case's source.jibb, with the
943:: file set to /tests/golden/<case>/source/jibb or the logical path in
944:: path.txt, and compares the outputs with tokens.txt and tree.txt.
945:: Each file holds the output plus one newline. An expect-types: line
946:: or types.txt opts a case into golden-types and its types.txt output.
947:: Files come in through /*; a missing expected output fails its case.
948:: tools/ship test syncs the desk and runs -test /=jibb-dev=/tests.
949::
950:: The .txt files reach Clay through the %txt mark, as lines, so the
951:: test compares lines: the file's with those of the output plus a
952:: newline. A %txt file can't show whether it ends in a newline
953:: (vere's +to-wain jet ends the last line there, with no empty line
954:: after it), so only tools/golden, and tools/ship test's own warning,
955:: catch an expected file without its final newline.
956::
957"""
959TESTS_HELPERS = r"""::
960:: +check: run golden:jibb on a case and compare both outputs
961::
962++ check
963 |= [case=@ta src=@t tokens=(unit wain) tree=(unit wain)]
964 ^- tang
965 (check-at case /tests/golden/[case]/source/jibb src tokens tree)
966::
967++ check-at
968 |= [case=@ta at=path src=@t tokens=(unit wain) tree=(unit wain)]
969 ^- tang
970 =/ out=[tokens=@t tree=@t] (golden:jibb at src)
971 %+ weld
972 (compare (file case "tokens.txt") tokens tokens.out)
973 (compare (file case "tree.txt") tree tree.out)
974::
975++ file
976 |= [case=@ta name=tape]
977 ^- tape
978 "tests/golden/{(trip case)}/{name}"
979::
980:: +compare: a file's lines against those of an output plus a newline
981::
982++ compare
983 |= [name=tape want=(unit wain) got=@t]
984 ^- tang
985 =/ have=wain (to-wain:format (cat 3 got '\0a'))
986 ?~ want
987 :- leaf+"{name} is missing; tools/golden --update writes it"
988 (turn have |=(l=@t leaf+" | {(trip l)}"))
989 ?: =(u.want have)
990 ~
991 =/ n=@ud (first-difference u.want have)
992 :~ leaf+"{name} differs from the output at line {(a-co:co +(n))}:"
993 leaf+" expected: {(line-at u.want n)}"
994 leaf+" actual: {(line-at have n)}"
995 ==
996::
997++ first-difference
998 |= [a=wain b=wain]
999 =| n=@ud
1000 |- ^- @ud
1001 ?~ a n
1002 ?~ b n
1003 ?. =(i.a i.b) n
1004 $(a t.a, b t.b, n +(n))
1006++ line-at
1007 |= [lines=wain n=@ud]
1008 ^- tape
1009 ?: (gte n (lent lines))
1010 "(the end of the file)"
1011 ?: =('' (snag n lines))
1012 "(an empty line)"
1013 (trip (snag n lines))
1015"""
1018def golden_cases(golden):
1019 """[(case name, folder)] for each case folder holding a source.jibb."""
1020 if not os.path.isdir(golden):
1021 return []
1022 cases = []
1023 for name in sorted(os.listdir(golden)):
1024 folder = os.path.join(golden, name)
1025 if not os.path.isdir(folder) or name.startswith('.'):
1026 continue
1027 if not os.path.isfile(os.path.join(folder, 'source.jibb')):
1028 warn('skipping %s: it has no source.jibb' % show(folder))
1029 continue
1030 if not KNOT.match(name):
1031 warn('skipping %s: %r is not a knot, so it has no Clay path '
1032 '(lowercase letters, digits and - . ~ _)' % (show(folder), name))
1033 continue
1034 cases.append((name, folder))
1035 return cases
1038def arm_names(names):
1039 """A test- arm name for each case: its name with what a term can't
1040 hold made -, and a number added to tell clashes apart."""
1041 out, used = [], set()
1042 for name in names:
1043 base = 'test-' + re.sub(r'[^a-z0-9-]', '-', name)
1044 arm, n = base, 1
1045 while arm in used:
1046 n += 1
1047 arm = '%s-%d' % (base, n)
1048 used.add(arm)
1049 out.append(arm)
1050 return out
1053def hoon_list(items):
1054 """Render a Hoon list, including the empty list, which is just ~."""
1055 return '~[' + ' '.join(items) + ']' if items else '~'
1058def generate_tests(golden):
1059 cases = golden_cases(golden)
1060 has_imports = False
1061 has_marks = False
1062 has_modules = False
1063 has_module_types = False
1064 for _, folder in cases:
1065 try:
1066 sur = golden_case.sur_imports(folder)
1067 marks = golden_case.mark_imports(folder)
1068 modules = golden_case.module_fixtures(folder)
1069 has_imports |= bool(sur or marks)
1070 has_marks |= bool(marks)
1071 has_modules |= bool(modules)
1072 has_module_types |= bool(golden_case.wants_modules(folder) and
1073 golden_case.wants_types(folder))
1074 except ValueError as e:
1075 raise Unable(str(e))
1076 lines = [fill(TESTS_HEADER, SOURCE=show(golden)).rstrip('\n'),
1077 '/+ jibb, jibb-import' if has_imports or has_module_types else '/+ jibb']
1078 if has_module_types:
1079 lines.append('/* prelude-source %jibb /lib/jibb/prelude/jibb')
1080 lines.append('/* internal-source %jibb /lib/jibb/internal/jibb')
1081 arms = []
1082 for i, ((name, folder), arm) in enumerate(
1083 zip(cases, arm_names([c[0] for c in cases])), 1):
1084 base = '/tests/golden/%s' % name
1085 lines.append('/* src-%d %%jibb %s/source/jibb' % (i, base))
1086 # a knot literal: a case's name may hold . and _, which a
1087 # path literal in Hoon can't, though /* takes them
1088 try:
1089 logical = golden_case.source_path(folder)
1090 imports = golden_case.sur_imports(folder)
1091 marks = golden_case.mark_imports(folder)
1092 modules = golden_case.module_fixtures(folder)
1093 want_modules = golden_case.wants_modules(folder)
1094 except ValueError as e:
1095 raise Unable(str(e))
1096 args = ['~.%s' % name]
1097 if logical:
1098 args.append(logical)
1099 args.append('src-%d' % i)
1100 for kind, face in (('tokens', 'tok'), ('tree', 'tre')):
1101 if os.path.isfile(os.path.join(folder, kind + '.txt')):
1102 lines.append('/* %s-%d %%txt %s/%s/txt'
1103 % (face, i, base, kind))
1104 args.append('`%s-%d' % (face, i))
1105 else:
1106 args.append('~')
1107 call = '(%s %s)' % ('check-at' if logical else 'check', ' '.join(args))
1108 module_fixtures = []
1109 for j, (module, at, _) in enumerate(modules, 1):
1110 face = 'mod-%d-%d' % (i, j)
1111 lines.append('/* %s %%jibb %s/modules%s/jibb' %
1112 (face, base, module[:-5]))
1113 module_fixtures.append('[%s %s %s]' % (hoon_cord(module.encode()), at, face))
1114 if golden_case.wants_types(folder):
1115 if os.path.isfile(os.path.join(folder, 'types.txt')):
1116 lines.append('/* typ-%d %%txt %s/types/txt' % (i, base))
1117 expected_types = '`typ-%d' % i
1118 else:
1119 expected_types = '~'
1120 path = logical or '~[%%tests %%golden ~.%s %%source %%jibb]' % name
1121 if want_modules:
1122 fixtures = []
1123 for j, (module, mold, _) in enumerate(imports, 1):
1124 face = 'imp-%d-%d' % (i, j)
1125 lines.append('/* %s %%txt %s/sur/%s/txt' % (face, base, module))
1126 fixtures.append('[%%%s %%%s %s]' % (module, mold, face))
1127 mark_fixtures = []
1128 for j, (mark, _) in enumerate(marks, 1):
1129 face = 'mark-%d-%d' % (i, j)
1130 lines.append('/* %s %%txt %s/mar/%s/txt' % (face, base, mark))
1131 mark_fixtures.append('[%%%s %s]' % (mark, face))
1132 call = '(weld %s (check-module-types ~.%s %s src-%d %s %s %s %s))' % (
1133 call, name, path, i, expected_types, hoon_list(fixtures),
1134 hoon_list(mark_fixtures), hoon_list(module_fixtures))
1135 elif marks:
1136 fixtures = []
1137 for j, (module, mold, _) in enumerate(imports, 1):
1138 face = 'imp-%d-%d' % (i, j)
1139 lines.append('/* %s %%txt %s/sur/%s/txt' % (face, base, module))
1140 fixtures.append('[%%%s %%%s %s]' % (module, mold, face))
1141 mark_fixtures = []
1142 for j, (mark, _) in enumerate(marks, 1):
1143 face = 'mark-%d-%d' % (i, j)
1144 lines.append('/* %s %%txt %s/mar/%s/txt' % (face, base, mark))
1145 mark_fixtures.append('[%%%s %s]' % (mark, face))
1146 call = '(weld %s (check-mark-types ~.%s %s src-%d %s %s %s))' % (
1147 call, name, path, i, expected_types, hoon_list(fixtures),
1148 hoon_list(mark_fixtures))
1149 elif imports:
1150 fixtures = []
1151 for j, (module, mold, _) in enumerate(imports, 1):
1152 face = 'imp-%d-%d' % (i, j)
1153 lines.append('/* %s %%txt %s/sur/%s/txt' % (face, base, module))
1154 fixtures.append('[%%%s %%%s %s]' % (module, mold, face))
1155 call = '(weld %s (check-import-types ~.%s %s src-%d %s ~[%s]))' % (
1156 call, name, path, i, expected_types, ' '.join(fixtures))
1157 else:
1158 call = '(weld %s (check-types ~.%s %s src-%d %s))' % (
1159 call, name, path, i, expected_types)
1160 if modules:
1161 if os.path.isfile(os.path.join(folder, 'module-trees.txt')):
1162 lines.append('/* mtre-%d %%txt %s/module-trees/txt' % (i, base))
1163 expected_modules = '`mtre-%d' % i
1164 else:
1165 expected_modules = '~'
1166 call = '(weld %s (check-module-trees ~.%s %s ~[%s]))' % (
1167 call, name, expected_modules, ' '.join(module_fixtures))
1168 arms.append('++ %s\n %s' % (arm, call))
1169 if has_module_types:
1170 lines.append('=/ prepared-prelude (prepare-prelude-with-internal:jibb prelude-source internal-source)')
1171 lines.append('|%')
1172 if arms:
1173 lines.append(':: one arm per case')
1174 lines.append('::')
1175 lines.extend(arms)
1176 types_helper = ''
1177 if any(golden_case.wants_types(folder) for _, folder in cases):
1178 types_helper = '''::
1179++ check-types
1180 |= [case=@ta at=path src=@t want=(unit wain)]
1181 ^- tang
1182 (compare (file case "types.txt") want (golden-types:jibb at src))
1183'''
1184 if has_imports:
1185 types_helper += '''::
1186++ check-import-types
1187 |= [case=@ta at=path src=@t want=(unit wain) fixtures=(list [module=term mold=term source=wain])]
1188 ^- tang
1189 =/ result (mule |.((import-types-result at src fixtures)))
1190 ?: ?=(%| -.result)
1191 [leaf+"fixture compilation or type checking crashed" p.result]
1192 (compare (file case "types.txt") want p.result)
1194++ import-types-result
1195 |= [at=path src=@t fixtures=(list [module=term mold=term source=wain])]
1196 ^- @t
1197 =/ external=(list [module=term name=term result=*])
1198 %+ turn fixtures
1199 |= fixture=[module=term mold=term source=wain]
1200 ~_ leaf+"/sur/{(trip module.fixture)}.txt fixture"
1201 ?~ source.fixture !!
1202 =/ text=@t (rap 3 (turn source.fixture |=(line=@t (cat 3 line '\\0a'))))
1203 =/ cor=type -:(ride -:!>(..zuse) text)
1204 [module.fixture mold.fixture (read-named:jibb-import module.fixture mold.fixture cor)]
1205 (golden-types-imports:jibb at src external)
1206'''
1207 if has_marks:
1208 types_helper += '''::
1209++ check-mark-types
1210 |= [case=@ta at=path src=@t want=(unit wain) fixtures=(list [module=term mold=term source=wain]) marks=(list [name=term source=wain])]
1211 ^- tang
1212 =/ result (mule |.((mark-types-result at src fixtures marks)))
1213 ?: ?=(%| -.result)
1214 [leaf+"fixture compilation or type checking crashed" p.result]
1215 (compare (file case "types.txt") want p.result)
1217++ mark-types-result
1218 |= [at=path src=@t fixtures=(list [module=term mold=term source=wain]) marks=(list [name=term source=wain])]
1219 ^- @t
1220 =/ external=(list [module=term name=term result=*])
1221 %+ turn fixtures
1222 |= fixture=[module=term mold=term source=wain]
1223 ~_ leaf+"/sur/{(trip module.fixture)}.txt fixture"
1224 ?~ source.fixture !!
1225 =/ text=@t (rap 3 (turn source.fixture |=(line=@t (cat 3 line '\\0a'))))
1226 =/ cor=type -:(ride -:!>(..zuse) text)
1227 [module.fixture mold.fixture (read-named:jibb-import module.fixture mold.fixture cor)]
1228 =/ mark-external=(list [name=term result=*])
1229 %+ turn marks
1230 |= fixture=[name=term source=wain]
1231 ~_ leaf+"/mar/{(trip name.fixture)}.txt fixture"
1232 ?~ source.fixture !!
1233 =/ text=@t (rap 3 (turn source.fixture |=(line=@t (cat 3 line '\\0a'))))
1234 =/ cor=type -:(ride -:!>(..zuse) text)
1235 [name.fixture (read-mark:jibb-import name.fixture cor)]
1236 (golden-types-with-marks:jibb at src external mark-external)
1237'''
1238 if has_modules:
1239 types_helper += '''::
1240++ check-module-trees
1241 |= [case=@ta want=(unit wain) fixtures=(list [label=@t at=path source=@t])]
1242 ^- tang
1243 =/ result (mule |.((module-trees-result fixtures)))
1244 ?: ?=(%| -.result)
1245 [leaf+"module fixture parsing crashed" p.result]
1246 (compare (file case "module-trees.txt") want p.result)
1248++ module-trees-result
1249 |= fixtures=(list [label=@t at=path source=@t])
1250 ^- @t
1251 =/ actual=@t
1252 %+ rap 3
1253 %+ join '\\0a'
1254 %+ turn fixtures
1255 |= fixture=[label=@t at=path source=@t]
1256 ~_ leaf+(trip label.fixture)
1257 =/ out (golden:jibb at.fixture source.fixture)
1258 (rap 3 ~[label.fixture '\\0a' tree.out])
1259 actual
1260'''
1261 if has_module_types:
1262 types_helper += '''::
1263++ check-module-types
1264 |= [case=@ta at=path src=@t want=(unit wain) fixtures=(list [module=term mold=term source=wain]) marks=(list [name=term source=wain]) modules=(list [label=@t at=path source=@t])]
1265 ^- tang
1266 =/ result (mule |.((module-types-result at src fixtures marks modules)))
1267 ?: ?=(%| -.result)
1268 [leaf+"fixture compilation or type checking crashed" p.result]
1269 (compare (file case "types.txt") want p.result)
1271++ module-types-result
1272 |= [at=path src=@t fixtures=(list [module=term mold=term source=wain]) marks=(list [name=term source=wain]) modules=(list [label=@t at=path source=@t])]
1273 ^- @t
1274 =/ external=(list [module=term name=term result=*])
1275 %+ turn fixtures
1276 |= fixture=[module=term mold=term source=wain]
1277 ~_ leaf+"/sur/{(trip module.fixture)}.txt fixture"
1278 ?~ source.fixture !!
1279 =/ text=@t (rap 3 (turn source.fixture |=(line=@t (cat 3 line '\\0a'))))
1280 =/ cor=type -:(ride -:!>(..zuse) text)
1281 [module.fixture mold.fixture (read-named:jibb-import module.fixture mold.fixture cor)]
1282 =/ mark-external=(list [name=term result=*])
1283 %+ turn marks
1284 |= fixture=[name=term source=wain]
1285 ~_ leaf+"/mar/{(trip name.fixture)}.txt fixture"
1286 ?~ source.fixture !!
1287 =/ text=@t (rap 3 (turn source.fixture |=(line=@t (cat 3 line '\\0a'))))
1288 =/ cor=type -:(ride -:!>(..zuse) text)
1289 [name.fixture (read-mark:jibb-import name.fixture cor)]
1290 =/ sources=(list [path=path source=@t])
1291 (turn modules |=(item=[label=@t at=path source=@t] [at.item source.item]))
1292 ?~ prepared-prelude
1293 (golden-types-with-prelude-and-internal:jibb at src external mark-external sources prelude-source internal-source)
1294 (golden-types-with-prepared-prelude:jibb at src external mark-external sources prepared-prelude)
1295'''
1296 return '\n'.join(lines) + '\n' + TESTS_HELPERS.replace('\n--\n', '\n' + types_helper + '--\n')
1299# ---------------------------------------------------------------- sync
1302def desks_info(ship):
1303 """(mount points, {desk: revision})."""
1304 mon, dez = ship.thread(DESKS_INFO)
1305 return ({text(m) for m in items(mon)},
1306 {text(d): rev for d, rev in items(dez)})
1309def ensure_desk(ship, desk, quiet=False):
1310 """Create the desk if it's missing and mount it; the mount folder."""
1311 mounts, revs = desks_info(ship)
1312 mount = os.path.join(ship.pier, desk)
1313 if desk not in revs:
1314 ship.thread(fill(NEW_DESK, DESK=desk))
1315 if not quiet:
1316 say('%s: created the desk, as |new-desk does' % desk)
1317 if desk in mounts and not os.path.isdir(mount):
1318 ship.thread(fill(UNMOUNT, DESK=desk))
1319 mounts.discard(desk)
1320 if desk not in mounts:
1321 ship.thread(fill(MOUNT, DESK=desk))
1322 deadline = time.time() + 30
1323 while not os.path.isfile(os.path.join(mount, 'sys.kelvin')):
1324 if time.time() > deadline:
1325 raise Fail('%s: mounted, but %s/sys.kelvin never appeared'
1326 % (desk, mount))
1327 time.sleep(0.2)
1328 if not quiet:
1329 say('%s: mounted at %s' % (desk, mount))
1330 return mount
1333def mirror(mount, files):
1334 """Make the mount folder hold exactly these files (dotfiles aside,
1335 which vere ignores); the paths added, changed and deleted."""
1336 added, changed, deleted = [], [], []
1337 for rel, (data, _src) in sorted(files.items()):
1338 path = os.path.join(mount, rel)
1339 try:
1340 with open(path, 'rb') as f:
1341 old = f.read()
1342 except FileNotFoundError:
1343 old = None
1344 if old == data:
1345 continue
1346 os.makedirs(os.path.dirname(path), exist_ok=True)
1347 with open(path, 'wb') as f:
1348 f.write(data)
1349 (added if old is None else changed).append(rel)
1350 for dirpath, dirnames, filenames in os.walk(mount, topdown=False):
1351 rel_dir = os.path.relpath(dirpath, mount)
1352 rel_dir = '' if rel_dir == '.' else rel_dir
1353 if any(s.startswith('.') for s in rel_dir.split(os.sep) if s):
1354 continue
1355 for fn in filenames:
1356 rel = os.path.join(rel_dir, fn)
1357 if fn.startswith('.') or rel in files:
1358 continue
1359 os.remove(os.path.join(dirpath, fn))
1360 deleted.append(rel)
1361 if rel_dir and not os.listdir(dirpath):
1362 os.rmdir(dirpath)
1363 return added, changed, sorted(deleted)
1366def commit(ship, desk):
1367 """|commit the desk; the revision before."""
1368 return ship.thread(fill(COMMIT, DESK=desk))
1371MANIFEST_PAGE = 2000 # files per reply; see REPLY_BYTES
1372CONV_BYTES = 65536 # bigger files with other marks are only looked for
1375def short_hash(data):
1376 """The first 8 bytes of the sha-256, as the atom the ship gives."""
1377 return int.from_bytes(hashlib.sha256(data).digest()[:8], 'little')
1380def txt_lines(data, jet):
1381 """The wain the %txt mark stores for a file: lines, without the
1382 empty one after a final newline if jet, as vere's +to-wain does."""
1383 if not data:
1384 return []
1385 lines = data.split(b'\n')
1386 if jet and data.endswith(b'\n'):
1387 lines.pop()
1388 # a line is an atom, which can't end in zero bytes
1389 return [l.rstrip(b'\0') for l in lines]
1392def verify(ship, desk, files):
1393 """Compare the desk with the files; (revision, problems, extra), extra
1394 being the paths of the desk's files that aren't among the files.
1396 The ship sends its manifest, a short hash of each file, and Python
1397 compares: files whose mark stores a cord by the hash of their
1398 bytes, %txt files by the hash of their lines, and the rest, which
1399 are few and small (desk.bill, sys.kelvin, desk.docket-0), by
1400 sending their content to be converted by the desk's own mark.
1401 """
1402 by_path = {'/' + '/'.join(clay_path(rel)): rel for rel in files}
1403 conv = []
1404 for pax, rel in sorted(by_path.items()):
1405 mark = clay_path(rel)[-1]
1406 data = files[rel][0]
1407 if mark not in BYTE_MARKS and mark != 'txt' and \
1408 len(data) <= CONV_BYTES:
1409 conv.append('[%s %s]' % (hoon_path(clay_path(rel)),
1410 hoon_cord(data)))
1411 now, start, total, desk_files = 'now.bowl', 0, None, {}
1412 while total is None or start < total:
1413 noun = ship.thread(fill(
1414 MANIFEST, DESK=desk, NOW=now, FROM=hoon_ud(start),
1415 COUNT=hoon_ud(MANIFEST_PAGE),
1416 CONV='~[%s]' % ' '.join(conv) if conv else '~'))
1417 rev, (da, (jet, (total, (conv_res, page)))) = noun
1418 now = '`@da``@`%s' % hoon_ud(da)
1419 for pax, (kind, val) in items(page):
1420 desk_files[path_text(pax)] = (text(kind), val)
1421 start += MANIFEST_PAGE
1422 jet = jet == 0
1423 problems = []
1424 extra = sorted(set(desk_files) - set(by_path))
1425 for pax in extra:
1426 problems.append('%s is on the desk but not in the repo' % pax)
1427 converted = {path_text(p): ok == 0 for p, ok in items(conv_res)}
1428 for pax, rel in sorted(by_path.items()):
1429 data, src = files[rel]
1430 mark = clay_path(rel)[-1]
1431 if pax not in desk_files:
1432 problems.append('%s is missing from the desk' % pax)
1433 continue
1434 kind, val = desk_files[pax]
1435 if mark in BYTE_MARKS:
1436 same = kind == 'cord' and val == short_hash(data.rstrip(b'\0'))
1437 elif mark == 'txt':
1438 same = kind == 'wain' and val == short_hash(
1439 b''.join(l + b'\n' for l in txt_lines(data, jet)))
1440 elif pax in converted:
1441 same = converted[pax]
1442 else:
1443 continue # too big to send; it's there
1444 if not same:
1445 problems.append('%s differs from %s' % (pax, src))
1446 return rev, problems, extra
1449def sync(ship, desk, lib=None, golden=None, timeout=60, quiet=False):
1450 """Assemble a desk, copy it into its mount and |commit; the revision.
1452 The desk is checked against the repo first: when they already match
1453 there's nothing to commit. Files on the desk that the repo doesn't
1454 have are deleted through Clay first, since a |commit deletes only
1455 the files vere has seen in the mount, and vere forgets them when
1456 the ship restarts. Then the commit runs in a thread that waits for
1457 the new revision, or for Clay's error.
1458 """
1459 started = time.time()
1460 a = assemble(desk, lib=lib, golden=golden)
1461 ship.require()
1462 mount = ensure_desk(ship, desk, quiet=quiet)
1463 rev, problems, extra = verify(ship, desk, a.files)
1464 done = []
1465 if extra:
1466 rev = delete_and_wait(ship, desk, extra, timeout)
1467 done.append('deleted %d from the desk in revision %d'
1468 % (len(extra), rev))
1469 added, changed, deleted = mirror(mount, a.files)
1470 if len(problems) > len(extra):
1471 rev = commit_and_wait(ship, desk, timeout)
1472 done.append('committed revision %d' % rev)
1473 if done:
1474 rev, problems, _ = verify(ship, desk, a.files)
1475 if problems:
1476 raise Fail('%s: after |commit, at revision %d, the desk still '
1477 "doesn't match the repo:\n %s"
1478 % (desk, rev, '\n '.join(problems[:20])))
1479 what = '; '.join(done)
1480 else:
1481 what = 'nothing to commit: already at revision %d' % rev
1482 if not quiet:
1483 say('%s: %d files; copied +%d ~%d -%d; %s; the desk matches the '
1484 'repo (%.1fs)' % (desk, len(a.files), len(added), len(changed),
1485 len(deleted), what, time.time() - started))
1486 return rev
1489def commit_and_wait(ship, desk, timeout):
1490 """|commit, and wait for the new revision; raise Clay's error."""
1491 return change_and_wait(ship, desk, fill(COMMIT_ACT, DESK=desk), 'into',
1492 'the commit', timeout)
1495# click -i still passes the formatted thread as one argument to click-format.
1496# Keep well below Linux's per-argument limit, including click's escaping.
1497DELETE_REQUEST_BYTES = 60000
1498DELETE_BATCH_PATHS = 1024
1501def click_argument_bytes(hoon):
1502 """Bytes in click-format's single command argument, including its NUL."""
1503 src = prepare(hoon).rstrip('\n')
1504 return len(src.replace("'", "\\'").replace('\n', ' ').encode('ascii')) + 1
1507def delete_action(desk, paths):
1508 return fill(DELETE_ACT, DESK=desk, PATHS='~[%s]' % ' '.join(
1509 hoon_path(p.split('/')[1:]) for p in paths))
1512def delete_batches(desk, paths, timeout):
1513 """Preflight bounded deletion requests before changing the desk."""
1514 def partition(batch):
1515 act = delete_action(desk, batch)
1516 request = fill(CHANGE_WAIT, ACT=act, MOTE='info', DESK=desk,
1517 SECS=hoon_ud(timeout))
1518 if click_argument_bytes(request) <= DELETE_REQUEST_BYTES:
1519 return [(batch, act)]
1520 if len(batch) <= 1:
1521 raise Unable('a Clay deletion path is too long for click')
1522 mid = len(batch) // 2
1523 return partition(batch[:mid]) + partition(batch[mid:])
1525 batches = []
1526 for start in range(0, len(paths), DELETE_BATCH_PATHS):
1527 batches.extend(partition(paths[start:start + DELETE_BATCH_PATHS]))
1528 # Preserve the existing empty-list behavior for direct callers.
1529 return batches or partition([])
1532def delete_and_wait(ship, desk, paths, timeout):
1533 """Delete files through Clay, waiting for each resulting revision."""
1534 batches = delete_batches(desk, paths, timeout)
1535 for batch, act in batches:
1536 rev = change_and_wait(ship, desk, act, 'info',
1537 'the deletion of %d files' % len(batch), timeout)
1538 return rev
1541def change_and_wait(ship, desk, act, mote, what, timeout):
1542 """Change the desk with act, a CHANGE_WAIT action whose rejection
1543 Clay names %mote, and wait for the new revision; raise Clay's error.
1544 what names the change in errors."""
1545 noun = ship.thread(fill(CHANGE_WAIT, ACT=act, MOTE=mote, DESK=desk,
1546 SECS=hoon_ud(timeout)),
1547 timeout=timeout + 60)
1548 kind, val = noun
1549 kind = text(kind)
1550 if kind == 'done':
1551 return val
1552 if kind == 'crud':
1553 mote, tang = val
1554 raise Fail('%s: Clay rejected %s: %%%s\n %s' % (
1555 desk, what, text(mote), '\n '.join(tang_lines(tang))))
1556 raise Fail('%s: %s changed nothing in %ds' % (desk, what, timeout))
1559# ---------------------------------------------------------------- scry
1562def parse_scry(spec, care=None):
1563 """A scry path as dojo writes it, /cx/=my-app=/ui/about/jibb, into
1564 (care, Hoon for the path). = stands for our, the desk %base and
1565 now, in the first three places after the care."""
1566 segs = spec.split('/')
1567 if not spec.startswith('/'):
1568 raise Unable('a scry path starts with /: %s' % spec)
1569 segs = segs[1:]
1570 if care is None:
1571 if not segs or not re.match(r'^[a-z]{2}$', segs[0]):
1572 raise Unable('a scry path starts with the vane and care, as '
1573 '/cx/=my-app=/desk/bill: %s' % spec)
1574 care, segs = segs[0], segs[1:]
1575 # =desk= is three segments: our, desk, now
1576 out = []
1577 if segs and segs[0].startswith('=') and segs[0].endswith('=') and \
1578 len(segs[0]) > 2:
1579 segs = ['=', segs[0][1:-1], '='] + segs[1:]
1580 defaults = ['(scot %p our.bowl)', '%base', '(scot %da now)']
1581 for i, s in enumerate(segs):
1582 if i < 3 and s in ('=', ''):
1583 out.append('[%s]' % defaults[i])
1584 elif s == '':
1585 continue # a trailing or doubled /
1586 elif TERM.match(s):
1587 out.append(s)
1588 elif KNOT.match(s):
1589 out.append('[~.%s]' % s)
1590 else:
1591 out.append('[%s]' % hoon_cord(s.encode()))
1592 return care, '/' + '/'.join(out)
1595# now is the time the path's = stands for: the ship's now, or for the
1596# later parts of a big --raw read, the time of the first.
1597SCRY = r"""
1598=/ m (strand ,vase)
1599;< =bowl:spider bind:m get-bowl
1600=/ now=@da @@NOW@@
1601=/ val .^(@@TYPE@@ %@@CARE@@ @@PATH@@)
1602@@RESULT@@
1603"""
1605# --raw: [%atom size now part], part being @@SIZE@@ bytes from
1606# @@OFFSET@@ on, or [%cell ~].
1607SCRY_RAW = r"""
1608=/ raw=* val
1609?^ raw (pure:m !>([%cell ~]))
1610(pure:m !>([%atom (met 3 raw) now (cut 3 [@@OFFSET@@ @@SIZE@@] raw)]))
1611"""
1613SCRY_PRINT = FIT + r"""
1614(pure:m !>((fit (turn (wash [0 @@WIDTH@@] (sell !>(val))) crip))))
1615"""
1618def cmd_scry(ship, args):
1619 ship.require()
1620 care, pax = parse_scry(args.path)
1621 if not args.raw:
1622 noun = ship.thread(fill(SCRY, TYPE=args.type, CARE=care, PATH=pax,
1623 NOW='now.bowl', RESULT=fill(
1624 SCRY_PRINT, WIDTH=hoon_ud(args.width))))
1625 for line in items(noun):
1626 say(text(line))
1627 return
1628 # an atom comes in parts, all read at the time of the first
1629 now, offset, parts, size = 'now.bowl', 0, [], None
1630 while size is None or offset < size:
1631 noun = ship.thread(fill(SCRY, TYPE=args.type, CARE=care, PATH=pax,
1632 NOW=now, RESULT=fill(
1633 SCRY_RAW, OFFSET=hoon_ud(offset),
1634 SIZE=hoon_ud(REPLY_BYTES))))
1635 if text(noun[0]) != 'atom':
1636 raise Fail('the result is a cell, not an atom; drop --raw')
1637 size, (da, part) = noun[1]
1638 now = '`@da``@`%s' % hoon_ud(da)
1639 want = min(REPLY_BYTES, size - offset)
1640 parts.append(atom_bytes(part).ljust(want, b'\0'))
1641 offset += want
1642 sys.stdout.buffer.write(b''.join(parts))
1643 sys.stdout.flush()
1646# ---------------------------------------------------------------- hoon, run, poke
1649def cmd_hoon(ship, args):
1650 ship.require()
1651 expr = args.expr
1652 if args.file:
1653 with open(args.file, encoding='utf-8') as f:
1654 expr = f.read()
1655 if not expr:
1656 raise Unable('give an expression, or -f FILE')
1657 noun = ship.thread(fill(EXPR, EXPR=indent(expr, 2), WIDTH=hoon_ud(args.width)))
1658 for line in items(noun):
1659 say(text(line))
1662def cmd_run(ship, args):
1663 ship.require()
1664 with open(args.file, encoding='utf-8') as f:
1665 thread = f.read()
1666 noun = ship.thread(fill(RUN_FILE, THREAD=indent(thread, 2),
1667 WIDTH=hoon_ud(args.width)), timeout=args.timeout)
1668 for line in items(noun):
1669 say(text(line))
1672def cmd_poke(ship, args):
1673 ship.require()
1674 for name in (args.agent, args.mark):
1675 if not TERM.match(name):
1676 raise Unable('%s is not a term' % name)
1677 ship.thread(fill(POKE, AGENT=args.agent, MARK=args.mark,
1678 VALUE=args.value))
1679 say('poked %%%s with %%%s' % (args.agent, args.mark))
1682def indent(text_, n):
1683 return '\n'.join((' ' * n + l) if l.strip() else l
1684 for l in text_.strip('\n').split('\n'))
1687# ---------------------------------------------------------------- status, log
1690def cmd_status(ship, args):
1691 king, serf = ship.processes()
1692 if not king and not serf:
1693 say('the ship at %s is not running' % ship.pier)
1694 return 3
1695 say('the ship at %s is running: pid %s (serf %s), log %s'
1696 % (ship.pier, king, serf, ship.log))
1697 try:
1698 our = ship.thread(OUR, timeout=30)
1699 mounts, revs = desks_info(ship)
1700 except (Fail, OSError) as e:
1701 say('but it does not answer: %s' % e)
1702 return 1
1703 say('%s is live; its desks:' % text(our))
1704 width = max([len(d) for d in revs] + [10])
1705 for d in sorted(revs):
1706 mount = os.path.join(ship.pier, d)
1707 say((' %%%-*s revision %-4d %s' % (
1708 width, d, revs[d], 'mounted at ' + mount if d in mounts
1709 else '')).rstrip())
1710 return 0
1713def cmd_log(ship, args):
1714 lines = ship.log_since(0)
1715 if lines and lines[-1] == '':
1716 lines.pop()
1717 if not args.all:
1718 lines = [l for l in lines if not NOISE.match(l)]
1719 for line in lines[-args.lines:]:
1720 say(line)
1723def cmd_commit(ship, args):
1724 ship.require()
1725 try:
1726 rev = commit_and_wait(ship, desk_term(args.desk), args.timeout)
1727 except Fail as e:
1728 if 'changed nothing' not in str(e):
1729 raise
1730 say('|commit %%%s: no new revision in %ds; nothing to commit?'
1731 % (args.desk, args.timeout))
1732 return 0
1733 say('|commit %%%s: committed revision %d' % (args.desk, rev))
1736def cmd_mount(ship, args):
1737 ship.require()
1738 mount = ensure_desk(ship, desk_term(args.desk))
1739 say('%s is mounted at %s' % (args.desk, mount))
1742def cmd_unmount(ship, args):
1743 ship.require()
1744 ship.thread(fill(UNMOUNT, DESK=desk_term(args.desk)))
1745 say('|unmount %%%s sent' % args.desk)
1748def cmd_sync(ship, args):
1749 lib = STUB if args.stub else args.lib
1750 for desk in args.desks:
1751 sync(ship, desk, lib=lib, golden=args.golden, timeout=args.timeout)
1754def cmd_gen_tests(ship, args):
1755 golden = args.golden or GOLDEN
1756 out = args.out or TESTS_LIB
1757 content = generate_tests(golden)
1758 try:
1759 with open(out, encoding='utf-8') as f:
1760 old = f.read()
1761 except FileNotFoundError:
1762 old = None
1763 n = len(golden_cases(golden))
1764 if args.check:
1765 if old != content:
1766 say('%s is out of date with %s; run tools/ship gen-tests'
1767 % (show(out), show(golden)))
1768 return 1
1769 say('%s is up to date: %d cases' % (show(out), n))
1770 return 0
1771 if old == content:
1772 say('%s is up to date: %d cases' % (show(out), n))
1773 return 0
1774 os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
1775 with open(out, 'w', encoding='utf-8') as f:
1776 f.write(content)
1777 say('wrote %s: %d cases from %s' % (show(out), n, show(golden)))
1778 return 0
1781# ---------------------------------------------------------------- test
1783TEST_LINE = re.compile(r'^(OK|FAILED|CRASHED|built) +(/\S*)')
1786def settle(ship, offset, quiet=0.5, limit=5.0):
1787 """Wait for the log to stop growing, so a thread's output is in."""
1788 deadline = time.time() + limit
1789 size = ship.log_size()
1790 while time.time() < deadline:
1791 time.sleep(quiet)
1792 now = ship.log_size()
1793 if now == size:
1794 break
1795 size = now
1796 return [l for l in ship.log_since(offset) if l.strip() and not NOISE.match(l)]
1799def cmd_test(ship, args):
1800 ship.require()
1801 lib = STUB if args.stub else args.lib
1802 golden = args.golden or GOLDEN
1803 started = time.time()
1804 if args.golden is None:
1805 content = generate_tests(golden)
1806 try:
1807 with open(TESTS_LIB, encoding='utf-8') as f:
1808 stale = f.read() != content
1809 except FileNotFoundError:
1810 stale = True
1811 if stale:
1812 os.makedirs(os.path.dirname(TESTS_LIB), exist_ok=True)
1813 with open(TESTS_LIB, 'w', encoding='utf-8') as f:
1814 f.write(content)
1815 say('updated %s from %s' % (show(TESTS_LIB), show(golden)))
1816 for _name, folder in golden_cases(golden):
1817 for fn in ('tokens.txt', 'tree.txt', 'types.txt', 'module-trees.txt'):
1818 data = _read(os.path.join(folder, fn))
1819 if data and not data.endswith(b'\n'):
1820 warn("%s has no final newline: the ship can't see that (the "
1821 "%%txt mark keeps only lines, and vere's +to-wain jet "
1822 "drops a final newline), but tools/golden fails it"
1823 % show(os.path.join(folder, fn)))
1824 if not args.no_sync:
1825 sync(ship, 'jibb-dev', lib=lib, golden=golden, timeout=args.timeout)
1826 synced = time.time()
1827 offset = ship.log_size()
1828 flag, rest = ship.thread(fill(RUN_TEST, DESK='jibb-dev'),
1829 timeout=args.timeout)
1830 lines = settle(ship, offset)
1831 for line in lines:
1832 say(line)
1833 counts = {'OK': 0, 'FAILED': 0, 'CRASHED': 0}
1834 for line in lines:
1835 m = TEST_LINE.match(line)
1836 if m and m.group(1) in counts and '/test-' in m.group(2):
1837 counts[m.group(1)] += 1
1838 built_failed = any(re.match(r'^FAILED +\S+ \(build\)', l) for l in lines)
1839 took = 'sync %.1fs, -test %.1fs' % (synced - started, time.time() - synced)
1840 if flag != 0:
1841 mote, tang = rest
1842 say('-test itself failed: %%%s' % text(mote))
1843 for line in tang_lines(tang):
1844 say(' ' + line)
1845 say('FAIL (%s)' % took)
1846 return 1
1847 ok = rest == 0
1848 summary = '%d OK, %d FAILED, %d CRASHED' % (
1849 counts['OK'], counts['FAILED'], counts['CRASHED'])
1850 if built_failed:
1851 summary += '; a test file failed to build'
1852 say('%s: %s (%s)' % ('PASS' if ok else 'FAIL', summary, took))
1853 return 0 if ok else 1
1856# ---------------------------------------------------------------- check-commit
1858# Each file's value as a cord, read with %cx.
1859READ_FILES = r"""
1860=/ m (strand ,vase)
1861;< =bowl:spider bind:m get-bowl
1862=/ pre=path (en-beam [our.bowl %@@DESK@@ da+now.bowl] /)
1863=/ paz=(list path) @@PATHS@@
1864%- pure:m
1865!> %+ turn paz
1866|= p=path
1867?. .^(? %cu (weld pre p))
1868 [p %| ~]
1869[p %& .^(@t %cx (weld pre p))]
1870"""
1872# Each %jibb file through the desk's own tubes: to %mime and back, to
1873# %txt and back. Also whether +to-wain ends the last line at a final
1874# newline, as vere's jet does, rather than giving an empty line after
1875# it, as the Hoon in zuse does.
1876CONVERT = r"""
1877=/ m (strand ,vase)
1878;< =bowl:spider bind:m get-bowl
1879=/ bek=beak [our.bowl %@@DESK@@ da+now.bowl]
1880=/ pre=path (en-beam bek /)
1881=/ paz=(list path) @@PATHS@@
1882;< to-mime=tube:clay bind:m (build-tube bek %jibb %mime)
1883;< of-mime=tube:clay bind:m (build-tube bek %mime %jibb)
1884;< to-txt=tube:clay bind:m (build-tube bek %jibb %txt)
1885;< of-txt=tube:clay bind:m (build-tube bek %txt %jibb)
1886=/ jet=? =(1 (lent (to-wain:format 'a\0a')))
1887%- pure:m
1888!> :- jet
1889%+ turn paz
1890|= p=path
1891=/ src=@t .^(@t %cx (weld pre p))
1892=/ mym=mime !<(mime (to-mime !>(src)))
1893=/ wan=wain !<(wain (to-txt !>(src)))
1894=/ bam=@t !<(@t (of-mime !>(mym)))
1895=/ bat=@t !<(@t (of-txt !>(wan)))
1896[p p.mym p.q.mym q.q.mym (of-wain:format wan) (lent wan) bam bat]
1897"""
1899# Overwrite a desk with a commit that has no parents, as |new-desk,
1900# =hard & does. It gets what |new-desk puts in a new desk, %base's
1901# %noun, %hoon, %txt and %kelvin marks and sys.kelvin; then the marks
1902# of another desk, if asked; then some %jibb files. Clay won't commit
1903# a desk without sys.kelvin, nor sys.kelvin without the %kelvin mark.
1904RESET_DESK = r"""
1905=/ m (strand ,vase)
1906;< =bowl:spider bind:m get-bowl
1907=/ bas=path /(scot %p our.bowl)/base/(scot %da now.bowl)
1908=/ src=path /(scot %p our.bowl)/@@FROM@@/(scot %da now.bowl)
1909=/ new=(list path)
1910 ~[/mar/noun/hoon /mar/hoon/hoon /mar/txt/hoon /mar/kelvin/hoon /sys/kelvin]
1911=/ pages=(map path page:clay)
1912 %- ~(gas by *(map path page:clay))
1913 ;: weld
1914 (turn new |=(p=path [p (rear p) .^(* %cx (weld bas p))]))
1915 (turn `(list path)`@@MARKS@@ |=(p=path [p (rear p) .^(* %cx (weld src p))]))
1916 `(list [path page:clay])`@@FILES@@
1917 ==
1918;< ~ bind:m (poke-our %hood %helm-pass !>((new-desk:cloy %@@DESK@@ ~ pages)))
1919(pure:m !>(~))
1920"""
1922CHECK_OURS = 'jibb-check-ours'
1923CHECK_THEIRS = 'jibb-check-theirs'
1925# The file the merge checks edit: the start of worked example 3.
1926MERGE_BASE = b"""\
1927/= todo /ui/api/todo
1929++ main (program init update view subs)
1931+$ model [todos=(list item.todo) draft=@t error=(unit @t)]
1932+$ msg
1933 [%typed text=@t]
1934 %submit
1935 [%toggled id=@ud]
1936 [%got todos=(list item.todo)]
1937 [%acked err=(unit @t)]
1938"""
1940HALF_WRITTEN = {
1941 'ui/check-commit/unclosed-string.jibb':
1942 b'++ main view\n\n++ view\n text "Hello, wor',
1943 'ui/check-commit/unfinished-rune.jibb':
1944 b'++ update |= [=msg m=model]\n ?- msg\n [%typed t]\n ?-\n',
1945 'ui/check-commit/cut-utf8.jibb':
1946 b'++ title "Caf\xc3',
1947 'ui/check-commit/empty.jibb': b'',
1950# What kiln prints when a merge ends.
1951KILN_SAID = re.compile(r'^(merged with strategy|failed to merge with '
1952 r'strategy|auto merge failed on strategy)')
1955def paths_hoon(rels):
1956 return '~[%s]' % ' '.join(hoon_path(clay_path(r)) for r in rels) \
1957 if rels else '~'
1960def read_files(ship, desk, rels):
1961 """{rel: bytes or None} for files on a desk, read with %cx."""
1962 noun = ship.thread(fill(READ_FILES, DESK=desk, PATHS=paths_hoon(rels)))
1963 out = {}
1964 for rel, (_p, (flag, val)) in zip(rels, items(noun)):
1965 out[rel] = atom_bytes(val) if flag == 0 else None
1966 return out
1969def wait_until(what, test, timeout):
1970 deadline = time.time() + timeout
1971 while True:
1972 result = test()
1973 if result:
1974 return result
1975 if time.time() > deadline:
1976 raise Fail('timed out waiting for %s' % what)
1977 time.sleep(0.4)
1980def reset_desk(ship, desk, files=None, marks_from=None):
1981 """Overwrite a desk with what |new-desk gives, the marks of another
1982 desk if asked, and these files ({rel: bytes}, all %jibb)."""
1983 pages = ' '.join('[%s %%jibb %s]' % (hoon_path(clay_path(rel)),
1984 hoon_cord(data))
1985 for rel, data in sorted((files or {}).items()))
1986 ship.thread(fill(
1987 RESET_DESK, DESK=desk, FROM=marks_from or 'base',
1988 MARKS='.^((list path) %ct (weld src /mar))' if marks_from else '~',
1989 FILES='~[%s]' % pages if pages else '~'))
1992def merge_and_wait(ship, desk, source, germ, timeout):
1993 """|merge %desk our %source, =gem %germ, and wait for kiln to say
1994 how it went: (merged, the lines the ship printed)."""
1995 offset = ship.log_size()
1996 ship.thread(fill(MERGE, DESK=desk, FROM=source, GERM=germ))
1997 said = wait_until('kiln to finish |merge %%%s our %%%s' % (desk, source),
1998 lambda: [l for l in ship.log_since(offset)
1999 if KILN_SAID.match(l)], timeout)
2000 return said[0].startswith('merged with'), settle(ship, offset)
2003def change_line(data, n, new):
2004 lines = data.split(b'\n')
2005 lines[n - 1] = new
2006 return b'\n'.join(lines)
2009def commit_edit(ship, desk, rel, data, timeout):
2010 """Write a file into a desk's mount and |commit it; wait for it."""
2011 mount = os.path.join(ship.pier, desk)
2012 path = os.path.join(mount, rel)
2013 os.makedirs(os.path.dirname(path), exist_ok=True)
2014 with open(path, 'wb') as f:
2015 f.write(data)
2016 commit_and_wait(ship, desk, timeout)
2017 got = read_files(ship, desk, [rel])[rel]
2018 if got != data:
2019 raise Fail('%s: after |commit, /%s reads back as %r' % (desk, rel, got))
2022def without_final_newline(data):
2023 return data[:-1] if data.endswith(b'\n') else data
2026def cmd_check_commit(ship, args):
2027 ship.require()
2028 results = []
2029 started = time.time()
2031 def check(name, fn):
2032 t0 = time.time()
2033 try:
2034 notes = fn() or []
2035 ok = True
2036 except Fail as e:
2037 notes = str(e).split('\n')
2038 ok = False
2039 results.append((name, ok))
2040 say('%s %s (%.1fs)' % ('PASS' if ok else 'FAIL', name, time.time() - t0))
2041 for note in notes:
2042 say(' ' + note)
2044 a = assemble('my-app')
2045 ui = sorted(rel for rel in a.files
2046 if rel.startswith('ui/') and rel.endswith('.jibb'))
2048 def synced():
2049 rev = sync(ship, 'my-app', timeout=args.timeout, quiet=True)
2050 return ['my-app is at revision %d with the repo\'s %d files, %d of '
2051 'them under /ui' % (rev, len(a.files), len(ui))]
2053 def byte_for_byte():
2054 got = read_files(ship, 'my-app', ui)
2055 notes, bad = [], []
2056 for rel in ui:
2057 want = a.files[rel][0]
2058 if got[rel] is None:
2059 bad.append('/%s is not on the desk' % rel)
2060 elif got[rel] != want:
2061 bad.append('/%s: %d bytes on the desk, %d in %s'
2062 % (rel, len(got[rel]), len(want), a.files[rel][1]))
2063 else:
2064 notes.append('%s: %d bytes, identical' % (
2065 '/' + '/'.join(clay_path(rel)), len(want)))
2066 if bad:
2067 raise Fail('\n'.join(bad))
2068 return notes
2070 def conversions():
2071 jet, res = ship.thread(fill(CONVERT, DESK='my-app',
2072 PATHS=paths_hoon(ui)))
2073 jet = jet == 0
2074 bad, lost = [], 0
2075 for rel, res in zip(ui, items(res)):
2076 want = a.files[rel][0]
2077 _p, (mite, (size, (octs, (joined, (count, (bam, bat)))))) = res
2078 lines = txt_lines(want, jet)
2079 back = b'\n'.join(lines)
2080 name = '/' + rel
2081 if path_text(mite) != '/text/x-jibb':
2082 bad.append('%s: to %%mime gives the type %s'
2083 % (name, path_text(mite)))
2084 if size != len(want) or atom_bytes(octs) != want:
2085 bad.append('%s: to %%mime changes the bytes' % name)
2086 if atom_bytes(bam) != want:
2087 bad.append('%s: %%mime back to %%jibb changes it' % name)
2088 if count != len(lines) or atom_bytes(joined) != back:
2089 bad.append('%s: to %%txt gives %d lines, not the file\'s %d'
2090 % (name, count, len(lines)))
2091 if atom_bytes(bat) != back:
2092 bad.append('%s: %%txt back to %%jibb gives something else '
2093 'than the lines joined' % name)
2094 lost += back != want
2095 if bad:
2096 raise Fail('\n'.join(bad))
2097 notes = ['%d files: to %%mime (/text/x-jibb) and back unchanged; to '
2098 '%%txt, one cord per line, and back' % len(ui)]
2099 if lost:
2100 notes.append(
2101 'but: %%txt keeps no final newline, so %d of the %d files '
2102 'come back from %%txt without theirs. The mark is right; '
2103 "vere's +to-wain jet ends the last line at a final newline, "
2104 'where the Hoon in zuse gives an empty line after it. %%hoon '
2105 'files do the same.' % (lost, len(ui)))
2106 return notes
2108 def half_written():
2109 mount = ensure_desk(ship, 'my-app', quiet=True)
2110 rels = sorted(HALF_WRITTEN)
2111 notes = []
2112 try:
2113 for rel in rels:
2114 path = os.path.join(mount, rel)
2115 os.makedirs(os.path.dirname(path), exist_ok=True)
2116 with open(path, 'wb') as f:
2117 f.write(HALF_WRITTEN[rel])
2118 rev = commit_and_wait(ship, 'my-app', args.timeout)
2119 got = read_files(ship, 'my-app', rels)
2120 notes.append('|commit made revision %d' % rev)
2121 for rel in rels:
2122 if got[rel] != HALF_WRITTEN[rel]:
2123 raise Fail('/%s committed, but reads back as %r'
2124 % (rel, got[rel]))
2125 notes.append('/%s committed and reads back the same: %r'
2126 % (rel, HALF_WRITTEN[rel][-24:]))
2127 finally:
2128 shutil.rmtree(os.path.join(mount, 'ui', 'check-commit'),
2129 ignore_errors=True)
2130 rev = commit_and_wait(ship, 'my-app', args.timeout)
2131 left = [r for r, v in read_files(ship, 'my-app', rels).items()
2132 if v is not None]
2133 if left:
2134 raise Fail('deleting them and |commit left %s' % ', '.join(left))
2135 notes.append('deleted them again: |commit made revision %d' % rev)
2136 return notes
2138 def merges_by_line():
2139 rel = 'ui/merge.jibb'
2140 base = MERGE_BASE
2141 ours = change_line(base, 5, b'+$ model [todos=(list item.todo) '
2142 b'draft=@t error=(unit @t) note=@t]')
2143 theirs = change_line(base, 9, b' [%toggled id=@ud on=?]')
2144 merged = change_line(ours, 9, b' [%toggled id=@ud on=?]')
2145 notes = []
2146 prepare_pair(ship, rel, base, args.timeout)
2147 commit_edit(ship, CHECK_OURS, rel, ours, args.timeout)
2148 commit_edit(ship, CHECK_THEIRS, rel, theirs, args.timeout)
2149 notes.append('%%%s changed line 5 and %%%s line 9 of /%s, each with '
2150 '|commit' % (CHECK_OURS, CHECK_THEIRS, rel))
2151 cmd = '|merge %%%s our %%%s' % (CHECK_OURS, CHECK_THEIRS)
2152 ok, said = merge_and_wait(ship, CHECK_OURS, CHECK_THEIRS, 'auto',
2153 args.timeout)
2154 if not ok:
2155 if not any('auto merge failed on strategy %fine' in l
2156 for l in said):
2157 raise Fail('%s failed:\n %s' % (cmd, '\n '.join(said[:20])))
2158 notes.append(
2159 "but: %s, with kiln's default =gem %%auto, stops at "
2160 '"auto merge failed on strategy %%fine": kiln goes on to '
2161 '%%meet only on a %%bad-fine-merge error, and this Clay '
2162 'reports %%merge-failed' % cmd)
2163 cmd += ', =gem %mate'
2164 ok, said = merge_and_wait(ship, CHECK_OURS, CHECK_THEIRS, 'mate',
2165 args.timeout)
2166 if not ok:
2167 raise Fail('%s failed:\n %s' % (cmd, '\n '.join(said[:20])))
2168 got = read_files(ship, CHECK_OURS, [rel])[rel]
2169 if got == merged:
2170 notes.append('%s: both changes, byte for byte' % cmd)
2171 elif got == without_final_newline(merged):
2172 notes.append('%s: both changes, line for line' % cmd)
2173 notes.append(
2174 'but: the merged file has lost its final newline: Clay '
2175 'merges through %txt (grad %txt), which keeps none; see '
2176 'the %txt note above')
2177 else:
2178 raise Fail('%s did not give both changes; /%s is now:\n%s'
2179 % (cmd, rel, got))
2180 return notes + ['the ship said: ' + l for l in said
2181 if KILN_SAID.match(l)]
2183 def conflicts_by_line():
2184 rel = 'ui/merge.jibb'
2185 base = MERGE_BASE
2186 ours = change_line(base, 7, b' [%typed text=@t at=@da]')
2187 theirs = change_line(base, 7, b' [%typed words=@t]')
2188 prepare_pair(ship, rel, base, args.timeout)
2189 commit_edit(ship, CHECK_OURS, rel, ours, args.timeout)
2190 commit_edit(ship, CHECK_THEIRS, rel, theirs, args.timeout)
2191 ok, said = merge_and_wait(ship, CHECK_OURS, CHECK_THEIRS, 'mate',
2192 args.timeout)
2193 got = read_files(ship, CHECK_OURS, [rel])[rel]
2194 if ok or got != ours or not any('mate-conflict' in l for l in said):
2195 raise Fail('a %%mate merge of two changes to line 7 should fail '
2196 'with a conflict and leave %%%s alone; the ship said:'
2197 '\n %s' % (CHECK_OURS, '\n '.join(said[:20])))
2198 return ['both desks changed line 7: |merge %%%s our %%%s, =gem %%mate '
2199 'fails and leaves the file alone' % (CHECK_OURS, CHECK_THEIRS)] \
2200 + ['the ship said: ' + l for l in said
2201 if KILN_SAID.match(l) or l == 'mate-conflict']
2203 def clean_up():
2204 mounts, revs = desks_info(ship)
2205 for desk in (CHECK_OURS, CHECK_THEIRS):
2206 if desk in revs:
2207 reset_desk(ship, desk)
2208 if desk in mounts:
2209 ship.thread(fill(UNMOUNT, DESK=desk))
2210 rev = sync(ship, 'my-app', timeout=args.timeout, quiet=True)
2211 return ['%%%s and %%%s hold only what |new-desk gives and are '
2212 'unmounted (Clay keeps desks); my-app matches the repo at '
2213 'revision %d' % (CHECK_OURS, CHECK_THEIRS, rev)]
2215 check('sync my-app: |commit copies the repo onto the desk', synced)
2216 check('every /ui .jibb file reads back with %cx byte for byte',
2217 byte_for_byte)
2218 check('%jibb converts to %mime and back, and to %txt and back',
2219 conversions)
2220 check('half-written .jibb files still commit', half_written)
2221 check('a three-way merge of a .jibb file merges by line', merges_by_line)
2222 check('changes to the same line conflict', conflicts_by_line)
2223 check('clean up', clean_up)
2224 failed = [n for n, ok in results if not ok]
2225 say('%s: %d of %d checks passed (%.1fs)' % (
2226 'FAIL' if failed else 'PASS', len(results) - len(failed),
2227 len(results), time.time() - started))
2228 return 1 if failed else 0
2231def prepare_pair(ship, rel, base, timeout):
2232 """Reset the merge checks' desks: ours holds my-app's marks and the
2233 file, in a commit without parents; theirs takes it with a merge, so
2234 that ours's commit is the base of the next merge between them."""
2235 _mounts, revs = desks_info(ship)
2236 reset_desk(ship, CHECK_OURS, {rel: base}, marks_from='my-app')
2237 germ = 'only-that' if revs.get(CHECK_THEIRS) else 'init'
2238 ok, said = merge_and_wait(ship, CHECK_THEIRS, CHECK_OURS, germ, timeout)
2239 if not ok or read_files(ship, CHECK_THEIRS, [rel])[rel] != base:
2240 raise Fail('|merge %%%s our %%%s, =gem %%%s did not give it the file:'
2241 '\n %s' % (CHECK_THEIRS, CHECK_OURS, germ,
2242 '\n '.join(said[:20])))
2243 for desk in (CHECK_OURS, CHECK_THEIRS):
2244 mount = ensure_desk(ship, desk, quiet=True)
2245 wait_until('the mount of %s to hold the file' % desk,
2246 lambda: _read(os.path.join(mount, rel)) == base, timeout)
2249def _read(path):
2250 try:
2251 with open(path, 'rb') as f:
2252 return f.read()
2253 except OSError:
2254 return None
2257# ---------------------------------------------------------------- main
2259EPILOG = """\
2260desks:
2261 my-app vendor/* + jibb-dev (without any tests/) + my-app
2262 jibb vendor/* + jibb
2263 jibb-dev vendor/* + jibb-dev + tests, the test desk: tests/golden from
2264 --golden, tests/lib/jibb.hoon generated from it, and sys.kelvin
2265 from jibb/
2266A sync creates the desk if it's missing (as |new-desk does), mounts it at
2267<pier>/<desk>, makes the mount hold exactly the desk's files, runs
2268|commit, and waits until the desk matches the repo, checking every file.
2270Exit status: 0 on success, 1 when something failed, 2 when the command
2271couldn't run. status exits 3 when the ship isn't running.
2272"""
2275def main(argv):
2276 ap = argparse.ArgumentParser(
2277 prog='tools/ship', description=__doc__.split('\n\n')[0],
2278 epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
2279 ap.add_argument('--pier', default=PIER,
2280 help='the pier (default %(default)s, or $JIBB_PIER)')
2281 ap.add_argument('--log', default=os.environ.get('JIBB_SHIP_LOG'),
2282 help="the ship's log (default <pier>.log, or $JIBB_SHIP_LOG)")
2283 ap.add_argument('--click', default=CLICK,
2284 help='click (default %(default)s, or $JIBB_CLICK)')
2285 sub = ap.add_subparsers(dest='command', metavar='command')
2286 sub.required = True
2288 p = sub.add_parser('start', help='start the ship in the background')
2289 p.add_argument('--timeout', type=int, default=600)
2290 p = sub.add_parser('stop', help='stop the ship')
2291 p.add_argument('--timeout', type=int, default=120)
2292 p.add_argument('--force', action='store_true',
2293 help='kill it if it does not stop in time')
2294 sub.add_parser('status', help='whether it runs; its desks and mounts')
2295 p = sub.add_parser('log', help="the end of the ship's log")
2296 p.add_argument('-n', '--lines', type=int, default=40)
2297 p.add_argument('--all', action='store_true',
2298 help="keep docket's and kiln's network noise")
2300 p = sub.add_parser('sync', help='assemble desks from their folders and '
2301 '|commit them')
2302 p.add_argument('desks', nargs='+', metavar='desk')
2303 g = p.add_mutually_exclusive_group()
2304 g.add_argument('--stub', action='store_true',
2305 help='use tools/testdata/ship-stub.hoon as /lib/jibb.hoon')
2306 g.add_argument('--lib', help='use this file as /lib/jibb.hoon')
2307 p.add_argument('--golden', help='the golden cases for the jibb-dev desk '
2308 '(default tests/golden)')
2309 p.add_argument('--timeout', type=int, default=120,
2310 help='seconds to wait for the commit')
2312 p = sub.add_parser('test', help='sync the jibb-dev desk and run -test '
2313 'on its /tests')
2314 g = p.add_mutually_exclusive_group()
2315 g.add_argument('--stub', action='store_true',
2316 help='use tools/testdata/ship-stub.hoon as /lib/jibb.hoon')
2317 g.add_argument('--lib', help='use this file as /lib/jibb.hoon')
2318 p.add_argument('--golden', help='test these golden cases instead of '
2319 'tests/golden (and leave tests/lib/jibb.hoon alone)')
2320 p.add_argument('--no-sync', action='store_true',
2321 help='run -test on the desk as it is')
2322 p.add_argument('--timeout', type=int, default=3600)
2324 p = sub.add_parser('gen-tests', help='write tests/lib/jibb.hoon from '
2325 'the golden cases')
2326 p.add_argument('--golden', help='the cases (default tests/golden)')
2327 p.add_argument('--out', help='the file (default tests/lib/jibb.hoon)')
2328 p.add_argument('--check', action='store_true',
2329 help="don't write; exit 1 if the file is out of date")
2331 p = sub.add_parser('check-commit', help='prove that |commit stores '
2332 '.jibb files, and leave the desks clean')
2333 p.add_argument('--timeout', type=int, default=120)
2335 p = sub.add_parser('scry', help='scry, as /cx/=my-app=/ui/about/jibb')
2336 p.add_argument('path')
2337 p.add_argument('--type', default='*', help='the mold (default *)')
2338 p.add_argument('--raw', action='store_true',
2339 help="write an atom's bytes, as a file's")
2340 p.add_argument('--width', type=int, default=100)
2342 p = sub.add_parser('hoon', help='evaluate a Hoon expression, with our, '
2343 'now and eny')
2344 p.add_argument('expr', nargs='?')
2345 p.add_argument('-f', '--file', help='read the expression from a file')
2346 p.add_argument('--width', type=int, default=100)
2348 p = sub.add_parser('run', help='run a thread: a file holding a strand')
2349 p.add_argument('file')
2350 p.add_argument('--timeout', type=int, default=600)
2351 p.add_argument('--width', type=int, default=100)
2353 p = sub.add_parser('poke', help="poke an agent, as dojo's :agent &mark "
2354 'value')
2355 p.add_argument('agent')
2356 p.add_argument('mark')
2357 p.add_argument('value')
2359 p = sub.add_parser('commit', help='|commit a desk, and wait for the '
2360 'new revision or the error')
2361 p.add_argument('desk')
2362 p.add_argument('--timeout', type=int, default=10)
2363 for name, what in (('mount', '|mount a desk, creating it if missing'),
2364 ('unmount', '|unmount a desk')):
2365 p = sub.add_parser(name, help=what)
2366 p.add_argument('desk')
2368 args = ap.parse_args(argv)
2369 ship = Ship(args.pier, args.log, args.click)
2370 handlers = {
2371 'start': lambda: ship.start(args.timeout),
2372 'stop': lambda: ship.stop(args.timeout, args.force),
2373 'status': lambda: cmd_status(ship, args),
2374 'log': lambda: cmd_log(ship, args),
2375 'sync': lambda: cmd_sync(ship, args),
2376 'test': lambda: cmd_test(ship, args),
2377 'gen-tests': lambda: cmd_gen_tests(ship, args),
2378 'check-commit': lambda: cmd_check_commit(ship, args),
2379 'scry': lambda: cmd_scry(ship, args),
2380 'hoon': lambda: cmd_hoon(ship, args),
2381 'run': lambda: cmd_run(ship, args),
2382 'poke': lambda: cmd_poke(ship, args),
2383 'commit': lambda: cmd_commit(ship, args),
2384 'mount': lambda: cmd_mount(ship, args),
2385 'unmount': lambda: cmd_unmount(ship, args),
2387 try:
2388 return handlers[args.command]() or 0
2389 except Fail as e:
2390 print('ship: %s' % e, file=sys.stderr)
2391 return 1
2392 except Unable as e:
2393 print('ship: %s' % e, file=sys.stderr)
2394 return 2
2395 except KeyboardInterrupt:
2396 return 130
2399if __name__ == '__main__':
2400 sys.exit(main(sys.argv[1:]))