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()