okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
59e1387
main
1"""File editing, search, and cell timing are exercised through the daemon's real Python tool.""" 2 3import json 4import unittest 5 6from harness import Albedo ,Provider ,Reply ,python ,text 7 8 9class PythonToolsTests (unittest .TestCase ): 10def setUp (self ): 11self .arguments = None 12 13def script (request ): 14if request ["messages" ][- 1 ].get ("role" )== "user" : 15if self .arguments is not None : 16return Reply ("python" ,tool_arguments = self .arguments ) 17return python (self .code ) 18return text ("done" ) 19 20self .provider = Provider (script ) 21self .addCleanup (self .provider .close ) 22self .app = Albedo (self .provider ) 23self .app .__enter__ () 24self .addCleanup (self .app .__exit__ ,None ,None ,None ) 25 26def run_cell (self ,code ,session ): 27"""The result of one prompt's cell, the latest in the session.""" 28self .code = code 29self .app .prompt (session ,"use the Python tools" ).close () 30self .app .idle (session ) 31results = [ 32json .loads (part ["value" ]) 33for entry in self .app .history (session )["items" ] 34if entry ["kind" ]== "tool_result" and entry ["tool" ]["name" ]== "python" 35for part in entry ["content" ] 36if part ["kind" ]== "json" and part ["field" ]== "result" 37 ] 38self .assertEqual (results [- 1 ]["status" ],"ok" ,results [- 1 ]) 39return results [- 1 ] 40 41def execute (self ,code ): 42return self .run_cell (code ,self .app .session ())["output" ] 43 44def test_host_replies_preserve_escaped_call_ids_and_argument_errors (self ): 45output = self .execute ( 46"kernel = sys.modules['__main__']\n" 47"key = 'host reply \" \\\\ λ\\n'\n" 48"pending = asyncio.get_running_loop().create_future()\n" 49"kernel.PENDING[key] = pending\n" 50"try:\n" 51" kernel.send({'type': 'call', 'id': key, 'method': 'mail.submit', 'args': []})\n" 52" print(json.dumps(await asyncio.wait_for(pending, 5)))\n" 53"finally:\n kernel.PENDING.pop(key, None)" 54 ) 55self .assertEqual ( 56json .loads (output ), 57 { 58"ok" :False , 59"code" :"invalid" , 60"message" :"mail.submit(to, body) takes two strings" , 61 }, 62 ) 63 64def test_deleting_shadowed_tools_recovers_the_original_bindings (self ): 65 (self .app .workspace / "note.txt" ).write_text ("recovered\n" ) 66session = self .app .session () 67output = self .run_cell ( 68"original_tools = (files, run, jobs, cells, output, show_image)\n" 69"files, run, jobs, cells, output, show_image = range(6)\n" 70"assert (files, run, jobs, cells, output, show_image) == tuple(range(6))\n" 71"del files, run, jobs, cells, output, show_image\n" 72"assert (files, run, jobs, cells, output, show_image) == original_tools\n" 73"def recovered_read():\n return files.read('note.txt')\n" 74"print(recovered_read())" , 75session , 76 )["output" ] 77self .assertIn ("recovered" ,output ) 78output = self .run_cell ( 79"assert (files, run, jobs, cells, output, show_image) == original_tools\n" 80"print(recovered_read())\n" 81"import builtins\nassert not hasattr(builtins, 'files')" , 82session , 83 )["output" ] 84self .assertIn ("recovered" ,output ) 85 86def test_ambiguous_edit_requires_a_hint_and_preserves_other_matches (self ): 87note = self .app .workspace / "note.txt" 88note .write_text ("same\nother\nsame\n" ) 89output = self .execute ( 90"try:\n files.edit('note.txt', 'same', 'changed')\n" 91"except Exception as exc:\n print('ambiguous:', exc)\n" 92"files.edit('note.txt', 'same', 'changed', line_hint=3)\n" 93"print(files.read('note.txt'))" 94 ) 95self .assertEqual (note .read_text (),"same\nother\nchanged\n" ) 96self .assertIn ("ambiguous:" ,output ) 97self .assertIn ("same" ,output ) 98self .assertIn ("changed" ,output ) 99 100def test_an_edit_miss_says_when_only_whitespace_differs (self ): 101 (self .app .workspace / "note.txt" ).write_text ("if ok:\n run()\n" ) 102output = self .execute ( 103"for old in ('if ok:\\n\\trun()', 'absent'):\n" 104" try:\n files.edit('note.txt', old, 'x')\n" 105" except ValueError as error:\n print(str(error).splitlines()[0])" 106 ) 107lines = output .splitlines () 108self .assertIn ("whitespace differs" ,lines [0 ]) 109self .assertNotIn ("whitespace differs" ,lines [1 ]) 110 111def test_find_searches_workspace_and_returns_context (self ): 112 (self .app .workspace / "note.txt" ).write_text ("before\nneedle\nafter\n" ) 113output = self .execute ( 114"rows = await files.find('needle', '.', context=1)\n" 115"print(rows)\nprint(files.read('note.txt', start_line=2, end_line=2))" 116 ) 117self .assertIn ("note.txt" ,output ) 118self .assertIn ("needle" ,output ) 119self .assertIn ("before" ,output ) 120self .assertIn ("after" ,output ) 121 122def test_an_empty_find_says_what_was_searched_and_skipped (self ): 123 (self .app .workspace / ".ignore" ).write_text ("ignored.txt\n" ) 124 (self .app .workspace / "ignored.txt" ).write_text ("needle\n" ) 125 (self .app .workspace / ".dot.txt" ).write_text ("needle\n" ) 126 (self .app .workspace / "plain.txt" ).write_text ("nothing\n" ) 127output = self .execute ( 128"print(await files.find('needle', '.'))\n" 129"print(await files.find('needle', '.', hidden=True, ignored=True))" 130 ) 131self .assertIn ("no matches for 'needle'" ,output ) 132self .assertIn ("1 files searched" ,output ) 133self .assertIn ("skipped hidden files, .gitignore/.ignore rules" ,output ) 134self .assertIn ("ignored.txt" ,output ) 135self .assertIn ("rerun with hidden=True and/or ignored=True" ,output ) 136self .assertEqual (output .count ("no matches" ),1 ,output ) 137 138def test_find_in_a_missing_root_raises_instead_of_returning_nothing (self ): 139output = self .execute ( 140"try:\n await files.find('needle', 'nowhere')\n" 141"except FileNotFoundError as exc:\n print('missing:', exc)" 142 ) 143self .assertIn ("missing: nowhere not found" ,output ) 144 145def test_a_file_result_is_awaitable_and_offers_content_and_text (self ): 146 (self .app .workspace / "note.txt" ).write_text ("hello\n" ) 147output = self .execute ( 148"text = await files.read('note.txt')\n" 149"print('awaited:', text.strip())\n" 150"print('content:', (await files.read('note.txt')).content.strip())\n" 151"print('text:', files.read('note.txt').text.strip())" 152 ) 153self .assertIn ("awaited: 1 | hello" ,output ) 154self .assertIn ("content: 1 | hello" ,output ) 155self .assertIn ("text: 1 | hello" ,output ) 156 157def test_paths_searches_a_list_of_directories_with_a_list_of_globs (self ): 158for directory ,name in [("a" ,"one.md" ), ("b" ,"two.txt" ), ("c" ,"three.md" )]: 159 (self .app .workspace / directory ).mkdir () 160 (self .app .workspace / directory / name ).write_text ("x" ) 161output = self .execute ( 162"rows = await files.paths(None, ['a', 'b'], glob=['*.md', '*.txt'])\n" 163"print(sorted(str(row) for row in rows))" 164 ) 165self .assertIn ("one.md" ,output ) 166self .assertIn ("two.txt" ,output ) 167self .assertNotIn ("three.md" ,output ) 168 169def test_paths_globs_a_path_from_the_workspace_root (self ): 170 (self .app .workspace / "src" / "deep" ).mkdir (parents = True ) 171 (self .app .workspace / "src" / "deep" / "stream_test.go" ).write_text ("x" ) 172output = self .execute ( 173"print([str(row) for row in await files.paths('src/*/*_test.go')])\n" 174"print([str(row) for row in await files.paths('./src/*/*_test.go')])" 175 ) 176self .assertEqual (output .count ("['src/deep/stream_test.go']" ),2 ,output ) 177 178def test_file_reads_decode_complete_lines_across_chunk_boundaries (self ): 179fixtures = [ 180 (b"" , []), 181 (b"\n\n" , ["" ,"" ]), 182 (b"a\r\r\n" , ["a" ,"" ]), 183 (b"a\n" , ["a" ]), 184 (b"a\nlast" , ["a" ,"last" ]), 185 ( 186"a\nb\rc\r\nd\ve\ff\x1cg\x1dh\x1ei\x85j\u2028k\u2029last" .encode (), 187list ("abcdefghijk" )+ ["last" ], 188 ), 189 (b"a\xffb\xe2\x82" , ["a\ufffdb\ufffd" ]), 190 ] 191for padding in (65_534 ,65_535 ,65_536 ): 192prefix = b"x" * padding 193for separator in ( 194b"\r\n" , 195"\x85" .encode (), 196"\u2028" .encode (), 197"\u2029" .encode (), 198 ): 199fixtures .append ((prefix + separator + b"tail" , ["x" * padding ,"tail" ])) 200fixtures .append ((prefix + "é\n尾" .encode (), ["x" * padding + "é" ,"尾" ])) 201fixtures .append ( 202 ( 203prefix + b"\xe2\x82\nend\xe2\x82" , 204 ["x" * padding + "\ufffd" ,"end\ufffd" ], 205 ) 206 ) 207for index , (data ,_lines )in enumerate (fixtures ): 208 (self .app .workspace / f"decode- { index } .txt" ).write_bytes (data ) 209# Keep large expected lines inside the kernel, out of the tool output. 210small_expected = [lines for _data ,lines in fixtures [:7 ]] 211output = self .execute ( 212f"expected = { small_expected !r } \n" 213"for padding in (65534, 65535, 65536):\n" 214" expected.extend([['x' * padding, 'tail']] * 4)\n" 215" expected.append(['x' * padding + 'é', '尾'])\n" 216" expected.append(['x' * padding + '\ufffd', 'end\ufffd'])\n" 217"for index, lines in enumerate(expected):\n" 218" result = files.read(f'decode-{index}.txt', max_chars=200000)\n" 219" numbered = '\\n'.join(f'{number:>6} | {line}' for number, line in enumerate(lines, 1))\n" 220" if lines:\n" 221" assert result == numbered, (index, len(result), len(numbered))\n" 222" else:\n" 223" assert ' | ' not in result\n" 224" assert all(detail in result for detail in ('decode-0.txt', '0 lines', 'line 1'))\n" 225"print('decoded complete lines')" 226 ) 227self .assertEqual (output .strip (),"decoded complete lines" ) 228 229def test_file_reads_enforce_budgets_and_character_limit_precedence (self ): 230 (self .app .workspace / "budget.txt" ).write_text ("a\nbb\nc\nd\n" ) 231 (self .app .workspace / "numbered.txt" ).write_text ("\n" * 999_999 + "x\nz" ) 232output = self .execute ( 233"first = ' 1 | a'\n" 234"second = ' 2 | bb'\n" 235"both = first + '\\n' + second\n" 236"assert files.read('budget.txt', end_line=2, max_chars=23) == both\n" 237"assert files.read('budget.txt', end_line=1, max_chars=11) == first\n" 238"rejected = files.read('budget.txt', max_chars=10)\n" 239"assert ' | ' not in rejected\n" 240"assert all(detail in rejected for detail in ('line 1', '10 characters', 'max_chars=10', 'start_line=1', 'end_line=1', 'max_chars=11'))\n" 241"for budget, shown, unshown, resume in ((22, [first], 'lines 2-4', 2), (23, [first, second], 'lines 3-4', 3)):\n" 242" result = files.read('budget.txt', max_chars=budget).splitlines()\n" 243" assert result[:-1] == shown\n" 244" assert all(detail in result[-1] for detail in (f'max_chars={budget}', unshown, f'start_line={resume}'))\n" 245"result = files.read('budget.txt', limit=2, max_chars=23).splitlines()\n" 246"assert result[:-1] == [first, second]\n" 247"assert all(detail in result[-1] for detail in ('limit=2', '2 more through line 4', 'start_line=3'))\n" 248"result = files.read('budget.txt', limit=3, max_chars=23).splitlines()\n" 249"assert result[:-1] == [first, second]\n" 250"assert all(detail in result[-1] for detail in ('max_chars=23', 'lines 3-3', 'start_line=3'))\n" 251"assert 'limit=' not in result[-1]\n" 252"assert files.read('numbered.txt', start_line=1000000, end_line=1000000, max_chars=12) == '1000000 | x'\n" 253"rejected = files.read('numbered.txt', start_line=1000000, end_line=1000000, max_chars=11)\n" 254"assert ' | ' not in rejected\n" 255"assert all(detail in rejected for detail in ('line 1000000', '11 characters', 'max_chars=11', 'start_line=1000000', 'end_line=1000000', 'max_chars=12'))\n" 256"print('budgets and numbering checked')" 257 ) 258self .assertEqual (output .strip (),"budgets and numbering checked" ) 259 260def test_file_reads_count_requested_windows_and_eof_exactly (self ): 261 (self .app .workspace / "window.txt" ).write_text ("a\nb\nc\nd" ) 262pattern = "a\r\nb\rc\nd\ve\ff\x1cg\x1dh\x1ei\x85j\u2028k\u2029last\n" .encode () 263 (self .app .workspace / "bulk.txt" ).write_bytes ( 264b"head\r\n" + pattern * 10_000 + b"tail\xe2\x82" 265 ) 266output = self .execute ( 267"assert files.read('window.txt', start_line=2, end_line=3) == ' 2 | b\\n 3 | c'\n" 268"assert files.read('window.txt', start_line=3, end_line=99) == ' 3 | c\\n 4 | d'\n" 269"for options, row, remaining, last, resume in (({'start_line': 2}, ' 2 | b', 2, 4, 3), ({'start_line': 2, 'end_line': 3}, ' 2 | b', 1, 3, 3), ({'start_line': 3, 'end_line': 99}, ' 3 | c', 1, 4, 4)):\n" 270" result = files.read('window.txt', limit=1, **options).splitlines()\n" 271" assert result[:-1] == [row]\n" 272" assert all(detail in result[-1] for detail in ('limit=1', f'{remaining} more through line {last}', f'start_line={resume}'))\n" 273"assert files.read('window.txt', start_line=4, limit=1) == ' 4 | d'\n" 274"empty = files.read('window.txt', start_line=5, end_line=99)\n" 275"assert all(detail in empty for detail in ('window.txt', '4 lines', 'line 5'))\n" 276"assert ' | ' not in empty\n" 277"result = files.read('bulk.txt', start_line=2, limit=1).splitlines()\n" 278"assert result[:-1] == [' 2 | a']\n" 279"assert all(detail in result[-1] for detail in ('limit=1', '120000 more through line 120002', 'start_line=3'))\n" 280"result = files.read('bulk.txt', start_line=2, end_line=100000, limit=1).splitlines()\n" 281"assert result[:-1] == [' 2 | a']\n" 282"assert all(detail in result[-1] for detail in ('limit=1', '99998 more through line 100000', 'start_line=3'))\n" 283"result = files.read('bulk.txt', start_line=120001, limit=1).splitlines()\n" 284"assert result[:-1] == ['120001 | last']\n" 285"assert all(detail in result[-1] for detail in ('limit=1', '1 more through line 120002', 'start_line=120002'))\n" 286"assert files.read('bulk.txt', start_line=120001, end_line=120001) == '120001 | last'\n" 287"assert files.read('bulk.txt', start_line=120002) == '120002 | tail\\ufffd'\n" 288"print('window counts checked')" 289 ) 290self .assertEqual (output .strip (),"window counts checked" ) 291 292def test_file_reads_count_giant_lines_without_shortening_them (self ): 293 (self .app .workspace / "giant.txt" ).write_text ( 294"head\n" + "x" * 1_048_576 + "\ntail\n" 295 ) 296 (self .app .workspace / "unicode.txt" ).write_text ("é" * 110_000 ) 297output = self .execute ( 298"assert files.read('giant.txt', start_line=3) == ' 3 | tail'\n" 299"assert files.read('giant.txt', end_line=1) == ' 1 | head'\n" 300"rejected = files.read('giant.txt', start_line=2, end_line=2)\n" 301"assert ' | ' not in rejected\n" 302"assert all(detail in rejected for detail in ('line 2', '1048585 characters', 'max_chars=16000', '200000'))\n" 303"assert 'max_chars=1048586' not in rejected\n" 304"result = files.read('giant.txt', max_chars=32).splitlines()\n" 305"assert result[:-1] == [' 1 | head']\n" 306"assert all(detail in result[-1] for detail in ('max_chars=32', 'lines 2-3', 'start_line=2'))\n" 307"result = files.read('giant.txt', limit=1).splitlines()\n" 308"assert result[:-1] == [' 1 | head']\n" 309"assert all(detail in result[-1] for detail in ('limit=1', '2 more through line 3', 'start_line=2'))\n" 310"for budget in (16000, 110009):\n" 311" rejected = files.read('unicode.txt', max_chars=budget)\n" 312" assert ' | ' not in rejected\n" 313" assert all(detail in rejected for detail in ('line 1', '110009 characters', f'max_chars={budget}', 'start_line=1', 'end_line=1', 'max_chars=110010'))\n" 314"assert files.read('unicode.txt', max_chars=110010) == ' 1 | ' + 'é' * 110000\n" 315"print('giant lines counted and returned intact')" 316 ) 317self .assertEqual (output .strip (),"giant lines counted and returned intact" ) 318 319def test_file_reads_validate_ranges_before_opening_and_report_path_errors (self ): 320output = self .execute ( 321"for options in ({'start_line': 0}, {'start_line': 2, 'end_line': 1}, {'limit': 0}, {'max_chars': 0}, {'max_chars': 200001}):\n" 322" try:\n" 323" files.read('absent.txt', **options)\n" 324" except ValueError:\n" 325" pass\n" 326" else:\n" 327" raise AssertionError(options)\n" 328"try:\n" 329" files.read('absent.txt')\n" 330"except FileNotFoundError as error:\n" 331" assert 'absent.txt' in str(error)\n" 332"else:\n" 333" raise AssertionError('missing file accepted')\n" 334"try:\n" 335" files.read('.')\n" 336"except IsADirectoryError as error:\n" 337" assert \"files.ls('.')\" in str(error)\n" 338"else:\n" 339" raise AssertionError('directory accepted')\n" 340"print('range and path errors preserved')" 341 ) 342self .assertEqual (output .strip (),"range and path errors preserved" ) 343 344def test_cells_get_the_api_a_model_reaches_for (self ): 345output = self .execute ( 346"job = run('echo', 'hello')\n" 347"await job\n" 348"assert (await job.tail()).strip() == 'hello'\n" 349"assert (await job.head(lines=1)).strip() == 'hello'\n" 350"assert output.read(job.id, 0, 3) == 'hel'\n" 351"try:\n" 352" output.read(job.id, limit=10**7)\n" 353"except ValueError as error:\n" 354" assert 'offset=' in str(error), error\n" 355"else:\n" 356" raise AssertionError('over-cap limit shortened silently')\n" 357"from albedo import files as imported\n" 358"assert imported is files\n" 359"assert re.fullmatch('a+', 'aa') and Path('.').is_dir()\n" 360"assert hashlib.md5(b'').hexdigest() and json.dumps(os.sep)\n" 361"try:\n" 362" run('true', timeout=120000)\n" 363"except ValueError as error:\n" 364" assert 'seconds' in str(error) and 'milliseconds' in str(error), error\n" 365"try:\n" 366" time.sleep(2)\n" 367"except PermissionError as error:\n" 368" assert 'run()' in str(error) and 'asyncio.sleep' in str(error), error\n" 369"time.sleep(0.01)\n" 370"await asyncio.sleep(0.01)\n" 371"print('api shapes ok')" 372 ) 373self .assertEqual (output .strip (),"api shapes ok" ) 374 375def test_a_cell_reports_how_long_it_ran (self ): 376session = self .app .session () 377slept = self .run_cell ("import asyncio\nawait asyncio.sleep(0.3)" ,session ) 378self .assertGreaterEqual (slept ["duration" ],0.3 ) 379self .assertLess (slept ["duration" ],5 ) 380# The journal keeps it, so cells.info reports it after the fact. 381info = self .run_cell ( 382f"print((await cells.info( { slept [ 'cell_id' ]!r } ))['duration'])" ,session 383 ) 384self .assertEqual (float (info ["output" ]),slept ["duration" ]) 385 386def call (self ,arguments ,session ): 387"""The result of one python call made with exactly these arguments.""" 388self .arguments = arguments 389self .app .prompt (session ,"call the Python tool" ).close () 390self .app .idle (session ) 391self .arguments = None 392return [ 393json .loads (part ["value" ]) 394for entry in self .app .history (session )["items" ] 395if entry ["kind" ]== "tool_result" and entry ["tool" ]["name" ]== "python" 396for part in entry ["content" ] 397if part ["kind" ]== "json" and part ["field" ]== "result" 398 ][- 1 ] 399 400def test_timeout_is_optional (self ): 401result = self .call ({"code" :"print('no timeout')" },self .app .session ()) 402self .assertEqual (result ["status" ],"ok" ,result ) 403self .assertEqual (result ["output" ].strip (),"no timeout" ) 404 405def test_an_unusable_timeout_keeps_the_code_for_a_rerun (self ): 406session = self .app .session () 407rejected = self .call ( 408 {"code" :"open('made.txt', 'w').write('hi')" ,"timeout_ms" :"soon" }, 409session , 410 ) 411self .assertIn ("timeout_ms" ,rejected ["error" ]) 412self .assertFalse ((self .app .workspace / "made.txt" ).exists ()) 413self .run_cell (f"await cells.run( { rejected [ 'cell_id' ]!r } )" ,session ) 414self .assertEqual ((self .app .workspace / "made.txt" ).read_text (),"hi" )