1 """Tests for autoreload extension. 2 """ 3 4 # ----------------------------------------------------------------------------- 5 # Copyright (c) 2012 IPython Development Team. 6 # 7 # Distributed under the terms of the Modified BSD License. 8 # 9 # The full license is in the file COPYING.txt, distributed with this software. 10 # ----------------------------------------------------------------------------- 11 12 # ----------------------------------------------------------------------------- 13 # Imports 14 # ----------------------------------------------------------------------------- 15 16 import os 17 import platform 18 import pytest 19 import sys 20 import tempfile 21 import textwrap 22 import shutil 23 import random 24 import time 25 import traceback 26 from io import StringIO 27 from dataclasses import dataclass 28 29 import IPython.testing.tools as tt 30 31 from unittest import TestCase 32 33 from IPython.extensions.autoreload import AutoreloadMagics 34 from IPython.core.events import EventManager, pre_run_cell 35 from IPython.testing.decorators import skipif_not_numpy 36 from IPython.core.interactiveshell import ExecutionInfo 37 38 if platform.python_implementation() == "PyPy": 39 pytest.skip( 40 "Current autoreload implementation is extremely slow on PyPy", 41 allow_module_level=True, 42 ) 43 44 # ----------------------------------------------------------------------------- 45 # Test fixture 46 # ----------------------------------------------------------------------------- 47 48 noop = lambda *a, **kw: None 49 50 51 class FakeShell: 52 def __init__(self): 53 self.ns = {} 54 self.user_ns = self.ns 55 self.user_ns_hidden = {} 56 self.events = EventManager(self, {"pre_run_cell", pre_run_cell}) 57 self.auto_magics = AutoreloadMagics(shell=self) 58 self.events.register("pre_run_cell", self.auto_magics.pre_run_cell) 59 60 register_magics = set_hook = noop 61 62 def showtraceback( 63 self, 64 exc_tuple=None, 65 filename=None, 66 tb_offset=None, 67 exception_only=False, 68 running_compiled_code=False, 69 ): 70 traceback.print_exc() 71 72 def run_code(self, code): 73 self.events.trigger( 74 "pre_run_cell", 75 ExecutionInfo( 76 raw_cell="", 77 store_history=False, 78 silent=False, 79 shell_futures=False, 80 cell_id=None, 81 ), 82 ) 83 exec(code, self.user_ns) 84 self.auto_magics.post_execute_hook() 85 86 def push(self, items): 87 self.ns.update(items) 88 89 def magic_autoreload(self, parameter): 90 self.auto_magics.autoreload(parameter) 91 92 def magic_aimport(self, parameter, stream=None): 93 self.auto_magics.aimport(parameter, stream=stream) 94 self.auto_magics.post_execute_hook() 95 96 97 class Fixture(TestCase): 98 """Fixture for creating test module files""" 99 100 test_dir = None 101 old_sys_path = None 102 filename_chars = "abcdefghijklmopqrstuvwxyz0123456789" 103 104 def setUp(self): 105 self.test_dir = tempfile.mkdtemp() 106 self.old_sys_path = list(sys.path) 107 sys.path.insert(0, self.test_dir) 108 self.shell = FakeShell() 109 110 def tearDown(self): 111 shutil.rmtree(self.test_dir) 112 sys.path = self.old_sys_path 113 114 self.test_dir = None 115 self.old_sys_path = None 116 self.shell = None 117 118 def get_module(self): 119 module_name = "tmpmod_" + "".join(random.sample(self.filename_chars, 20)) 120 if module_name in sys.modules: 121 del sys.modules[module_name] 122 file_name = os.path.join(self.test_dir, module_name + ".py") 123 return module_name, file_name 124 125 def write_file(self, filename, content): 126 """ 127 Write a file, and force a timestamp difference of at least one second 128 129 Notes 130 ----- 131 Python's .pyc files record the timestamp of their compilation 132 with a time resolution of one second. 133 134 Therefore, we need to force a timestamp difference between .py 135 and .pyc, without having the .py file be timestamped in the 136 future, and without changing the timestamp of the .pyc file 137 (because that is stored in the file). The only reliable way 138 to achieve this seems to be to sleep. 139 """ 140 content = textwrap.dedent(content) 141 # Sleep one second + eps 142 time.sleep(1.05) 143 144 # Write 145 with open(filename, "w", encoding="utf-8") as f: 146 f.write(content) 147 148 def new_module(self, code): 149 code = textwrap.dedent(code) 150 mod_name, mod_fn = self.get_module() 151 with open(mod_fn, "w", encoding="utf-8") as f: 152 f.write(code) 153 return mod_name, mod_fn 154 155 156 # ----------------------------------------------------------------------------- 157 # Test automatic reloading 158 # ----------------------------------------------------------------------------- 159 160 161 def pickle_get_current_class(obj): 162 """ 163 Original issue comes from pickle; hence the name. 164 """ 165 name = obj.__class__.__name__ 166 module_name = getattr(obj, "__module__", None) 167 obj2 = sys.modules[module_name] 168 for subpath in name.split("."): 169 obj2 = getattr(obj2, subpath) 170 return obj2 171 172 173 class TestAutoreload(Fixture): 174 def test_reload_enums(self): 175 mod_name, mod_fn = self.new_module( 176 textwrap.dedent( 177 """ 178 from enum import Enum 179 class MyEnum(Enum): 180 A = 'A' 181 B = 'B' 182 """ 183 ) 184 ) 185 self.shell.magic_autoreload("2") 186 self.shell.magic_aimport(mod_name) 187 self.write_file( 188 mod_fn, 189 textwrap.dedent( 190 """ 191 from enum import Enum 192 class MyEnum(Enum): 193 A = 'A' 194 B = 'B' 195 C = 'C' 196 """ 197 ), 198 ) 199 with tt.AssertNotPrints( 200 ("[autoreload of %s failed:" % mod_name), channel="stderr" 201 ): 202 self.shell.run_code("pass") # trigger another reload 203 204 def test_reload_class_type(self): 205 self.shell.magic_autoreload("2") 206 mod_name, mod_fn = self.new_module( 207 """ 208 class Test(): 209 def meth(self): 210 return "old" 211 """ 212 ) 213 assert "test" not in self.shell.ns 214 assert "result" not in self.shell.ns 215 216 self.shell.run_code("from %s import Test" % mod_name) 217 self.shell.run_code("test = Test()") 218 219 self.write_file( 220 mod_fn, 221 """ 222 class Test(): 223 def meth(self): 224 return "new" 225 """, 226 ) 227 228 test_object = self.shell.ns["test"] 229 230 # important to trigger autoreload logic ! 231 self.shell.run_code("pass") 232 233 test_class = pickle_get_current_class(test_object) 234 assert isinstance(test_object, test_class) 235 236 # extra check. 237 self.shell.run_code("import pickle") 238 self.shell.run_code("p = pickle.dumps(test)") 239 240 def test_reload_class_attributes(self): 241 self.shell.magic_autoreload("2") 242 mod_name, mod_fn = self.new_module( 243 textwrap.dedent( 244 """ 245 class MyClass: 246 247 def __init__(self, a=10): 248 self.a = a 249 self.b = 22 250 # self.toto = 33 251 252 def square(self): 253 print('compute square') 254 return self.a*self.a 255 """ 256 ) 257 ) 258 self.shell.run_code("from %s import MyClass" % mod_name) 259 self.shell.run_code("first = MyClass(5)") 260 self.shell.run_code("first.square()") 261 with self.assertRaises(AttributeError): 262 self.shell.run_code("first.cube()") 263 with self.assertRaises(AttributeError): 264 self.shell.run_code("first.power(5)") 265 self.shell.run_code("first.b") 266 with self.assertRaises(AttributeError): 267 self.shell.run_code("first.toto") 268 269 # remove square, add power 270 271 self.write_file( 272 mod_fn, 273 textwrap.dedent( 274 """ 275 class MyClass: 276 277 def __init__(self, a=10): 278 self.a = a 279 self.b = 11 280 281 def power(self, p): 282 print('compute power '+str(p)) 283 return self.a**p 284 """ 285 ), 286 ) 287 288 self.shell.run_code("second = MyClass(5)") 289 290 for object_name in {"first", "second"}: 291 self.shell.run_code(f"{object_name}.power(5)") 292 with self.assertRaises(AttributeError): 293 self.shell.run_code(f"{object_name}.cube()") 294 with self.assertRaises(AttributeError): 295 self.shell.run_code(f"{object_name}.square()") 296 self.shell.run_code(f"{object_name}.b") 297 self.shell.run_code(f"{object_name}.a") 298 with self.assertRaises(AttributeError): 299 self.shell.run_code(f"{object_name}.toto") 300 301 @skipif_not_numpy 302 def test_comparing_numpy_structures(self): 303 self.shell.magic_autoreload("2") 304 self.shell.run_code("1+1") 305 mod_name, mod_fn = self.new_module( 306 textwrap.dedent( 307 """ 308 import numpy as np 309 class MyClass: 310 a = (np.array((.1, .2)), 311 np.array((.2, .3))) 312 """ 313 ) 314 ) 315 self.shell.run_code("from %s import MyClass" % mod_name) 316 self.shell.run_code("first = MyClass()") 317 318 # change property `a` 319 self.write_file( 320 mod_fn, 321 textwrap.dedent( 322 """ 323 import numpy as np 324 class MyClass: 325 a = (np.array((.3, .4)), 326 np.array((.5, .6))) 327 """ 328 ), 329 ) 330 331 with tt.AssertNotPrints( 332 ("[autoreload of %s failed:" % mod_name), channel="stderr" 333 ): 334 self.shell.run_code("pass") # trigger another reload 335 336 def test_autoload_newly_added_objects(self): 337 # All of these fail with %autoreload 2 338 self.shell.magic_autoreload("3") 339 mod_code = """ 340 def func1(): pass 341 """ 342 mod_name, mod_fn = self.new_module(textwrap.dedent(mod_code)) 343 self.shell.run_code(f"from {mod_name} import *") 344 self.shell.run_code("func1()") 345 with self.assertRaises(NameError): 346 self.shell.run_code("func2()") 347 with self.assertRaises(NameError): 348 self.shell.run_code("t = Test()") 349 with self.assertRaises(NameError): 350 self.shell.run_code("number") 351 352 # ----------- TEST NEW OBJ LOADED -------------------------- 353 354 new_code = """ 355 def func1(): pass 356 def func2(): pass 357 class Test: pass 358 number = 0 359 from enum import Enum 360 class TestEnum(Enum): 361 A = 'a' 362 """ 363 self.write_file(mod_fn, textwrap.dedent(new_code)) 364 365 # test function now exists in shell's namespace namespace 366 self.shell.run_code("func2()") 367 # test function now exists in module's dict 368 self.shell.run_code(f"import sys; sys.modules['{mod_name}'].func2()") 369 # test class now exists 370 self.shell.run_code("t = Test()") 371 # test global built-in var now exists 372 self.shell.run_code("number") 373 # test the enumerations gets loaded successfully 374 self.shell.run_code("TestEnum.A") 375 376 # ----------- TEST NEW OBJ CAN BE CHANGED -------------------- 377 378 new_code = """ 379 def func1(): return 'changed' 380 def func2(): return 'changed' 381 class Test: 382 def new_func(self): 383 return 'changed' 384 number = 1 385 from enum import Enum 386 class TestEnum(Enum): 387 A = 'a' 388 B = 'added' 389 """ 390 self.write_file(mod_fn, textwrap.dedent(new_code)) 391 self.shell.run_code("assert func1() == 'changed'") 392 self.shell.run_code("assert func2() == 'changed'") 393 self.shell.run_code("t = Test(); assert t.new_func() == 'changed'") 394 self.shell.run_code("assert number == 1") 395 if sys.version_info < (3, 12): 396 self.shell.run_code("assert TestEnum.B.value == 'added'") 397 398 # ----------- TEST IMPORT FROM MODULE -------------------------- 399 400 new_mod_code = """ 401 from enum import Enum 402 class Ext(Enum): 403 A = 'ext' 404 def ext_func(): 405 return 'ext' 406 class ExtTest: 407 def meth(self): 408 return 'ext' 409 ext_int = 2 410 """ 411 new_mod_name, new_mod_fn = self.new_module(textwrap.dedent(new_mod_code)) 412 current_mod_code = f""" 413 from {new_mod_name} import * 414 """ 415 self.write_file(mod_fn, textwrap.dedent(current_mod_code)) 416 self.shell.run_code("assert Ext.A.value == 'ext'") 417 self.shell.run_code("assert ext_func() == 'ext'") 418 self.shell.run_code("t = ExtTest(); assert t.meth() == 'ext'") 419 self.shell.run_code("assert ext_int == 2") 420 421 def test_verbose_names(self): 422 # Asserts correspondense between original mode names and their verbose equivalents. 423 @dataclass 424 class AutoreloadSettings: 425 check_all: bool 426 enabled: bool 427 autoload_obj: bool 428 429 def gather_settings(mode): 430 self.shell.magic_autoreload(mode) 431 module_reloader = self.shell.auto_magics._reloader 432 return AutoreloadSettings( 433 module_reloader.check_all, 434 module_reloader.enabled, 435 module_reloader.autoload_obj, 436 ) 437 438 assert gather_settings("0") == gather_settings("off") 439 assert gather_settings("0") == gather_settings("OFF") # Case insensitive 440 assert gather_settings("1") == gather_settings("explicit") 441 assert gather_settings("2") == gather_settings("all") 442 assert gather_settings("3") == gather_settings("complete") 443 444 # And an invalid mode name raises an exception. 445 with self.assertRaises(ValueError): 446 self.shell.magic_autoreload("4") 447 448 def test_aimport_parsing(self): 449 # Modules can be included or excluded all in one line. 450 module_reloader = self.shell.auto_magics._reloader 451 self.shell.magic_aimport("os") # import and mark `os` for auto-reload. 452 assert module_reloader.modules["os"] is True 453 assert "os" not in module_reloader.skip_modules.keys() 454 455 self.shell.magic_aimport("-math") # forbid autoreloading of `math` 456 assert module_reloader.skip_modules["math"] is True 457 assert "math" not in module_reloader.modules.keys() 458 459 self.shell.magic_aimport( 460 "-os, math" 461 ) # Can do this all in one line; wasn't possible before. 462 assert module_reloader.modules["math"] is True 463 assert "math" not in module_reloader.skip_modules.keys() 464 assert module_reloader.skip_modules["os"] is True 465 assert "os" not in module_reloader.modules.keys() 466 467 def test_autoreload_output(self): 468 self.shell.magic_autoreload("complete") 469 mod_code = """ 470 def func1(): pass 471 """ 472 mod_name, mod_fn = self.new_module(mod_code) 473 self.shell.run_code(f"import {mod_name}") 474 with tt.AssertPrints("", channel="stdout"): # no output; this is default 475 self.shell.run_code("pass") 476 477 self.shell.magic_autoreload("complete --print") 478 self.write_file(mod_fn, mod_code) # "modify" the module 479 with tt.AssertPrints( 480 f"Reloading '{mod_name}'.", channel="stdout" 481 ): # see something printed out 482 self.shell.run_code("pass") 483 484 self.shell.magic_autoreload("complete -p") 485 self.write_file(mod_fn, mod_code) # "modify" the module 486 with tt.AssertPrints( 487 f"Reloading '{mod_name}'.", channel="stdout" 488 ): # see something printed out 489 self.shell.run_code("pass") 490 491 self.shell.magic_autoreload("complete --print --log") 492 self.write_file(mod_fn, mod_code) # "modify" the module 493 with tt.AssertPrints( 494 f"Reloading '{mod_name}'.", channel="stdout" 495 ): # see something printed out 496 self.shell.run_code("pass") 497 498 self.shell.magic_autoreload("complete --print --log") 499 self.write_file(mod_fn, mod_code) # "modify" the module 500 with self.assertLogs(logger="autoreload") as lo: # see something printed out 501 self.shell.run_code("pass") 502 assert lo.output == [f"INFO:autoreload:Reloading '{mod_name}'."] 503 504 self.shell.magic_autoreload("complete -l") 505 self.write_file(mod_fn, mod_code) # "modify" the module 506 with self.assertLogs(logger="autoreload") as lo: # see something printed out 507 self.shell.run_code("pass") 508 assert lo.output == [f"INFO:autoreload:Reloading '{mod_name}'."] 509 510 def _check_smoketest(self, use_aimport=True): 511 """ 512 Functional test for the automatic reloader using either 513 '%autoreload 1' or '%autoreload 2' 514 """ 515 516 mod_name, mod_fn = self.new_module( 517 """ 518 x = 9 519 520 z = 123 # this item will be deleted 521 522 def foo(y): 523 return y + 3 524 525 class Baz(object): 526 def __init__(self, x): 527 self.x = x 528 def bar(self, y): 529 return self.x + y 530 @property 531 def quux(self): 532 return 42 533 def zzz(self): 534 '''This method will be deleted below''' 535 return 99 536 537 class Bar: # old-style class: weakref doesn't work for it on Python < 2.7 538 def foo(self): 539 return 1 540 """ 541 ) 542 543 # 544 # Import module, and mark for reloading 545 # 546 if use_aimport: 547 self.shell.magic_autoreload("1") 548 self.shell.magic_aimport(mod_name) 549 stream = StringIO() 550 self.shell.magic_aimport("", stream=stream) 551 self.assertIn(("Modules to reload:\n%s" % mod_name), stream.getvalue()) 552 553 with self.assertRaises(ImportError): 554 self.shell.magic_aimport("tmpmod_as318989e89ds") 555 else: 556 self.shell.magic_autoreload("2") 557 self.shell.run_code("import %s" % mod_name) 558 stream = StringIO() 559 self.shell.magic_aimport("", stream=stream) 560 self.assertTrue( 561 "Modules to reload:\nall-except-skipped" in stream.getvalue() 562 ) 563 self.assertIn(mod_name, self.shell.ns) 564 565 mod = sys.modules[mod_name] 566 567 # 568 # Test module contents 569 # 570 old_foo = mod.foo 571 old_obj = mod.Baz(9) 572 old_obj2 = mod.Bar() 573 574 def check_module_contents(): 575 self.assertEqual(mod.x, 9) 576 self.assertEqual(mod.z, 123) 577 578 self.assertEqual(old_foo(0), 3) 579 self.assertEqual(mod.foo(0), 3) 580 581 obj = mod.Baz(9) 582 self.assertEqual(old_obj.bar(1), 10) 583 self.assertEqual(obj.bar(1), 10) 584 self.assertEqual(obj.quux, 42) 585 self.assertEqual(obj.zzz(), 99) 586 587 obj2 = mod.Bar() 588 self.assertEqual(old_obj2.foo(), 1) 589 self.assertEqual(obj2.foo(), 1) 590 591 check_module_contents() 592 593 # 594 # Simulate a failed reload: no reload should occur and exactly 595 # one error message should be printed 596 # 597 self.write_file( 598 mod_fn, 599 """ 600 a syntax error 601 """, 602 ) 603 604 with tt.AssertPrints( 605 ("[autoreload of %s failed:" % mod_name), channel="stderr" 606 ): 607 self.shell.run_code("pass") # trigger reload 608 with tt.AssertNotPrints( 609 ("[autoreload of %s failed:" % mod_name), channel="stderr" 610 ): 611 self.shell.run_code("pass") # trigger another reload 612 check_module_contents() 613 614 # 615 # Rewrite module (this time reload should succeed) 616 # 617 self.write_file( 618 mod_fn, 619 """ 620 x = 10 621 622 def foo(y): 623 return y + 4 624 625 class Baz(object): 626 def __init__(self, x): 627 self.x = x 628 def bar(self, y): 629 return self.x + y + 1 630 @property 631 def quux(self): 632 return 43 633 634 class Bar: # old-style class 635 def foo(self): 636 return 2 637 """, 638 ) 639 640 def check_module_contents(): 641 self.assertEqual(mod.x, 10) 642 self.assertFalse(hasattr(mod, "z")) 643 644 self.assertEqual(old_foo(0), 4) # superreload magic! 645 self.assertEqual(mod.foo(0), 4) 646 647 obj = mod.Baz(9) 648 self.assertEqual(old_obj.bar(1), 11) # superreload magic! 649 self.assertEqual(obj.bar(1), 11) 650 651 self.assertEqual(old_obj.quux, 43) 652 self.assertEqual(obj.quux, 43) 653 654 self.assertFalse(hasattr(old_obj, "zzz")) 655 self.assertFalse(hasattr(obj, "zzz")) 656 657 obj2 = mod.Bar() 658 self.assertEqual(old_obj2.foo(), 2) 659 self.assertEqual(obj2.foo(), 2) 660 661 self.shell.run_code("pass") # trigger reload 662 check_module_contents() 663 664 # 665 # Another failure case: deleted file (shouldn't reload) 666 # 667 os.unlink(mod_fn) 668 669 self.shell.run_code("pass") # trigger reload 670 check_module_contents() 671 672 # 673 # Disable autoreload and rewrite module: no reload should occur 674 # 675 if use_aimport: 676 self.shell.magic_aimport("-" + mod_name) 677 stream = StringIO() 678 self.shell.magic_aimport("", stream=stream) 679 self.assertTrue(("Modules to skip:\n%s" % mod_name) in stream.getvalue()) 680 681 # This should succeed, although no such module exists 682 self.shell.magic_aimport("-tmpmod_as318989e89ds") 683 else: 684 self.shell.magic_autoreload("0") 685 686 self.write_file( 687 mod_fn, 688 """ 689 x = -99 690 """, 691 ) 692 693 self.shell.run_code("pass") # trigger reload 694 self.shell.run_code("pass") 695 check_module_contents() 696 697 # 698 # Re-enable autoreload: reload should now occur 699 # 700 if use_aimport: 701 self.shell.magic_aimport(mod_name) 702 else: 703 self.shell.magic_autoreload("") 704 705 self.shell.run_code("pass") # trigger reload 706 self.assertEqual(mod.x, -99) 707 708 def test_smoketest_aimport(self): 709 self._check_smoketest(use_aimport=True) 710 711 def test_smoketest_autoreload(self): 712 self._check_smoketest(use_aimport=False) 713