tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

project_dependencies.py (11733B)


      1 #!/usr/bin/env python3
      2 """Cheap real-CLI project controls; controlled GET peer, no compiler execution."""
      3 import argparse
      4 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
      5 from pathlib import Path
      6 import subprocess
      7 import tempfile
      8 import threading
      9 
     10 ROOT = Path(__file__).resolve().parent.parent
     11 
     12 
     13 def main():
     14     parser = argparse.ArgumentParser(description=__doc__)
     15     parser.add_argument('tricu', type=Path)
     16     exe = parser.parse_args().tricu.resolve()
     17     with tempfile.TemporaryDirectory(prefix='tricu-project-') as temp:
     18         root = Path(temp)
     19         store, registry = root / 'cas', root / 'registry'
     20         declaration, lock, config = root / 'tricu.project', root / 'tricu.lock', root / 'registries'
     21         workspace = root / 'workspace'; workspace.mkdir()
     22         requests, overrides = [], {}
     23         def run(*args, ok=True):
     24             reply = subprocess.run([str(exe), *map(str, args), '+RTS', '-N1', '-M1024m', '-RTS'],
     25                                    cwd=root, capture_output=True, text=True, timeout=30)
     26             assert (reply.returncode == 0) == ok, (args, reply.stdout, reply.stderr)
     27             return reply.stdout if ok else reply.stderr
     28         def publish(name, version, fixture):
     29             return run('package', 'publish', '--root', registry, '--name', name, '--version', version,
     30                        '--file', ROOT / 'test/fixtures' / (fixture + '.arboricx')).splitlines()[1].split('\t')
     31         first = publish('core', '1', 'id')
     32         second = publish('core', '2', 'append')
     33         other = publish('other', '1', 'false')
     34         class Handler(BaseHTTPRequestHandler):
     35             def log_message(self, *_): pass
     36             def do_GET(self):
     37                 requests.append(self.path)
     38                 parts = self.path.split('/')
     39                 target = None
     40                 if parts[:3] == ['', '_arboricx', 'package'] and len(parts) == 5:
     41                     target = registry / 'packages' / parts[3] / parts[4]
     42                 elif parts[:4] == ['', '_arboricx', 'bundle', 'hash'] and len(parts) == 5:
     43                     target = registry / 'objects' / parts[4][:3] / (parts[4] + '.arboricx')
     44                 body = overrides.get(self.path)
     45                 if body is None and target and target.exists(): body = target.read_bytes()
     46                 self.send_response(200 if body is not None else 404)
     47                 body = body if body is not None else b'not found'
     48                 self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body)
     49         server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
     50         thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
     51         url = 'http://127.0.0.1:' + str(server.server_port)
     52         config.write_text('arboricx.registries.v1\nhome\t' + url + '\n')
     53         def write(ds):
     54             declaration.write_text('arboricx.project.v1\n' + ''.join('\t'.join(row) + '\n' for row in ds))
     55         initial = [('core', 'home', 'core', '1'), ('other', 'home', 'other', '1')]
     56         def project(action, *args, ok=True):
     57             opts = ['--registries', config] if action in ['resolve', 'update', 'fetch'] else ['--directory', workspace]
     58             if action in ['fetch', 'bind', 'check']: opts += ['--store', store]
     59             return run('project', action, *opts, *args, ok=ok)
     60         def frozen(): return lock.read_bytes()
     61         state = workspace / '.arboricx-project-bindings'
     62         pending = workspace / '.arboricx-project-bindings.pending'
     63         def pins(data):
     64             return {fields[0]: fields[1:] for fields in (row.split('\t') for row in data.decode().splitlines()[1:])}
     65         try:
     66             write(initial)
     67             project('update', '--all', ok=False)
     68             assert not requests and not lock.exists()
     69             assert 'Added core' in project('resolve')
     70             original = frozen()
     71             assert len(requests) == 2
     72             assert 'unchanged' in project('resolve') and len(requests) == 2
     73             assert not store.exists()  # resolve never fetches or imports
     74             project('bind', ok=False)
     75             assert not state.exists() and not list(workspace.glob('*.tri'))
     76             project('fetch')
     77             assert len(requests) == 4 and not list(workspace.glob('*.tri'))
     78             # Preflight detects a conflict anywhere before writing any bindings.
     79             authored = workspace / 'other.tri'; authored.write_text('authored source\n')
     80             project('bind', ok=False)
     81             assert not state.exists() and not pending.exists() and not (workspace / 'core.tri').exists()
     82             authored.unlink()
     83             authored.symlink_to(declaration)
     84             project('bind', ok=False)
     85             authored.unlink()
     86             project('bind'); project('check'); project('bind')
     87             old_binding = (workspace / 'core.tri').read_bytes()
     88             before_state = state.read_bytes()
     89             # Scope enforcement includes additions and removals, and happens before GET.
     90             write([('core', 'home', 'core', '2'), ('other', 'home', 'other', '2')])
     91             project('resolve', ok=False)
     92             project('update', '--dependency', 'core', ok=False)
     93             assert len(requests) == 4 and frozen() == original
     94             write([('core', 'home', 'core', '2'), initial[1]])
     95             for flags in [[], ['--dependency', 'missing'], ['--dependency', 'core', '--dependency', 'core'],
     96                           ['--all', '--dependency', 'core']]:
     97                 project('update', *flags, ok=False)
     98             assert len(requests) == 4
     99             text = project('update', '--dependency', 'core')
    100             assert 'Updated core' in text and 'Updated other' not in text and 'module changed; bundle changed' in text
    101             updated = frozen()
    102             assert len(requests) == 5 and pins(original)['other'] == pins(updated)['other']
    103             project('check', ok=False)
    104             project('bind', ok=False)  # new module not fetched; old bindings preserved
    105             assert (workspace / 'core.tri').read_bytes() == old_binding and state.read_bytes() == before_state
    106             project('fetch'); assert len(requests) == 6
    107             # Simulate interrupted binding after journal and one new file; recover.
    108             next_state = 'arboricx.project-bindings.v1\n' + ''.join(a + '\t' + r[4] + '\n' for a, r in pins(updated).items())
    109             pending.write_text(next_state)
    110             (workspace / 'core.tri').write_text('!export * from ' + second[2] + '\n')
    111             project('check', ok=False)
    112             project('bind'); project('check'); assert not pending.exists()
    113             (workspace / 'core.tri').write_text('user edit\n')
    114             project('check', ok=False); project('bind', ok=False)
    115             assert (workspace / 'core.tri').read_text() == 'user edit\n'
    116             (workspace / 'core.tri').write_text('!export * from ' + second[2] + '\n')
    117             # Rechecking a frozen version rejects changed assertions, also across
    118             # a local binding rename; failed resolutions never replace the lock.
    119             path = '/_arboricx/package/core/2'
    120             overrides[path] = ('arboricx.package-release.v1\ncore\t2\t' + first[2] + '\t' + first[3] + '\n').encode()
    121             assert 'equivocation' in project('update', '--dependency', 'core', ok=False)
    122             assert frozen() == updated
    123             write([('renamed', 'home', 'core', '2'), initial[1]])
    124             assert 'equivocation' in project('update', '--all', ok=False)
    125             assert frozen() == updated
    126             overrides.clear()
    127             # Failed later resolution leaves the old complete lock untouched.
    128             write([('core', 'home', 'core', '1'), ('other', 'home', 'other', 'missing')])
    129             project('update', '--all', ok=False); assert frozen() == updated
    130             # A late-origin mismatch must not populate even the first dependency
    131             # in a fresh store. This is stronger than observing a cached fetch.
    132             write([('core', 'home', 'core', '2'), ('other', 'away', 'other', '1')])
    133             lock.write_bytes(updated.replace(b'other\thome\t', b'other\taway\t'))
    134             config.write_text('arboricx.registries.v1\nhome\t' + url + '\naway\thttp://127.0.0.1:1\n')
    135             clean = root / 'must-stay-empty'
    136             count = len(requests)
    137             run('project', 'fetch', '--registries', config, '--store', clean, ok=False)
    138             assert len(requests) == count and not any(p.is_file() for p in clean.rglob('*'))
    139             lock.write_bytes(updated)
    140             # Origin trust is preflighted for ALL dependencies before fetch.
    141             write([('core', 'home', 'core', '2'), initial[1]])
    142             config.write_text('arboricx.registries.v1\nhome\thttp://127.0.0.1:1\n')
    143             count = len(requests)
    144             project('fetch', ok=False); project('resolve', ok=False)
    145             assert len(requests) == count and frozen() == updated
    146             config.write_text('arboricx.registries.v1\nhome\t' + url + '\n')
    147             # Remove only an owned, unedited binding. Other authored files survive.
    148             (workspace / 'main.tri').write_text('authored main\n')
    149             write([('core', 'home', 'core', '2')])
    150             project('update', '--dependency', 'other')
    151             assert len(requests) == count
    152             authored.write_text('edited owned binding\n')
    153             project('bind', ok=False)
    154             assert authored.read_text() == 'edited owned binding\n'
    155             authored.write_text('!export * from ' + other[2] + '\n')
    156             project('bind'); project('check')
    157             assert not authored.exists() and (workspace / 'main.tri').read_text() == 'authored main\n'
    158             # Output alias/input collisions must not destroy declarations or locks.
    159             project('resolve', '--lock', declaration, ok=False)
    160             project('resolve', '--lock', config, ok=False)
    161         finally:
    162             server.shutdown(); server.server_close(); thread.join()
    163         count = len(requests)
    164         project('resolve'); project('fetch'); project('bind'); project('check')
    165         assert len(requests) == count
    166         # Corrupt a CAS manifest: offline check/fetch must fail, not repair.
    167         manifest = store / 'objects' / second[2][:3] / second[2]
    168         saved = manifest.read_bytes(); manifest.write_bytes(saved + b'corrupt')
    169         project('fetch', ok=False); project('check', ok=False)
    170         manifest.write_bytes(saved)
    171         project('check')
    172         assert not any(p.is_file() for p in (store / 'aliases').rglob('*'))
    173         # Non-default declaration/lock paths, default adjacent binding directory,
    174         # and the global --store option all compose without changing cwd.
    175         alternate = root / 'alternate'; alternate.mkdir()
    176         (alternate / 'input.project').write_bytes(declaration.read_bytes())
    177         (alternate / 'selection.lock').write_bytes(lock.read_bytes())
    178         for action in ['bind', 'check']:
    179             run('--store', store, 'project', action, '--project', 'alternate/input.project', '--lock', 'alternate/selection.lock')
    180         assert (alternate / 'core.tri').exists() and not (root / 'core.tri').exists()
    181         # Empty sets remain explicit and may remove every dependency offline.
    182         write([])
    183         project('update', '--all'); project('bind'); project('check'); project('resolve'); project('fetch')
    184         assert not list(workspace.glob('core.tri')) and (workspace / 'main.tri').exists()
    185         assert len(requests) == count
    186         print('PASS project freeze/update scope, atomic lock failure, offline fetch/bind/check, ownership, recovery, equivocation and corruption controls')
    187 
    188 
    189 if __name__ == '__main__':
    190     main()