Format: text/x-python
⬇ Download File (22.7 KB)
   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