packages feed

inline-python-0.3.0.0: test/TST/Run.hs

-- |
-- Tests for variable scope and names
module TST.Run(tests) where

import Control.Concurrent
import Control.Concurrent.STM
import Control.Exception
import Control.Monad
import Control.Monad.IO.Class
import Data.Map.Strict        qualified as Map
import Data.Text              qualified as T
import Test.Tasty
import Test.Tasty.HUnit
import Python.Inline
import Python.Inline.QQ
import Python.Inline.Eval
import Python.Inline.Async
import TST.Util

tests :: TestTree
tests = testGroup "Run python"
  [ testCase "Empty QQ" $ runPy [py_| |]
  , testCase "Second init is noop" $ initializePython
  , testRunPy
  , testScope
  , testEvalExec
  , testMonadic
  , testAsync
  ]

-- Test that python evaluation works in general and its interaction
-- with exceptions
testRunPy :: TestTree
testRunPy = testGroup "Running python"
  [ testCase "Nested runPy" $ runPy $ liftIO $ runPy $ pure ()
  , testCase "Nested runPyInMain" $ runPyInMain $ liftIO $ runPyInMain $ pure ()
  , testCase "runPyInMain" $ runPyInMain $ [py_|
      import threading
      tid_main = threading.main_thread()
      tid_our  = threading.current_thread()
      assert tid_main == tid_our, f"TID[main]={tid_main}, TID[our]={tid_our}"
      |]
  , testCase "Python exceptions are converted (py)"   $ runPy      $ throwsPy    [py_| 1 / 0 |]
  , testCase "Python exceptions are converted (std)"  $ throwsPyIO $ runPy       [py_| 1 / 0 |]
  , testCase "Python exceptions are converted (main)" $ throwsPyIO $ runPyInMain [py_| 1 / 0 |]
  , testCase "Main doesn't deadlock after exception"  $ do
      throwsPyIO $ runPyInMain [py_| 1 / 0 |]
      runPyInMain [py_| assert True |]
    -- Here we test that exceptions are really passed to python's thread without running python
  , testCase "Exception in runPyInMain works hask" $ do
      lock <- newEmptyMVar
      tid  <- myThreadId
      _    <- forkIO $ takeMVar lock >> threadDelay 1000 >> throwTo tid Stop
      handle (\Stop -> pure ())
        $ runPyInMain
        $ do liftIO $ putMVar lock ()
             liftIO $ threadDelay 10_000_000
             error "Should be interrupted"
  ]

-- Test that different quasiquotes correctly handle scoping
testScope :: TestTree
testScope = testGroup "Python scope"
  [ testCase "Scope pymain->any" $ runPy $ do
      [pymain|
             x = 12
             x
             |]
      -- Visible
      [py_| x |]
      _ <- [pye| x |]
      [pymain|
        x
        del x
        |]
      -- Disappears
      [pymain|
        try:
            x
            assert False, "x shouln't be visible"
        except NameError:
            pass
        |]
      [py_|
        try:
            x
            assert False, "x shouln't be visible"
        except NameError:
            pass
        |]
  , testCase "Scope py_->any" $ runPy $ do
      [py_|
        x = 12
        x
        |]
      -- Not visible
      throwsPy $ void [pye| x |]
      [py_|
        try:
            x
            assert False, "x shouln't be visible (1)"
        except NameError:
            pass
        |]
      [pymain|
        try:
            x
            assert False, "x shouln't be visible (2)"
        except NameError:
            pass
        |]
  , testCase "Import py_->any" $ runPy $ do
      [py_|
        import sys
        sys
        |]
      -- Not visible
      throwsPy $ void [pye| sys |]
      [py_|
        try:
            sys
            assert False, "sys shouln't be visible (1)"
        except NameError:
            pass
        |]
      [pymain|
        try:
            sys
            assert False, "sys shouln't be visible (2)"
        except NameError:
            pass
        |]
  , testCase "Scope pyf->any" $ runPy $ do
      _ <- [pyf|
        x = 12
        x
        return 12
        |]
      -- Not visible
      throwsPy $ void [pye| x |]
      [py_|
        try:
            x
            assert False, "x shouln't be visible (1)"
        except NameError:
            pass
        |]
      [pymain|
        try:
            x
            assert False, "x shouln't be visible (2)"
        except NameError:
            pass
        |]
  ]

testEvalExec :: TestTree
testEvalExec = testGroup "eval/exec"
  [ testCase "pyf works" $ do
      let x = 12 :: Int
      eq (Just (482412::Int)) [pyf|
         xs = [i*x_hs for i in [1, 200, 40000]]
         return sum(xs)
         |]
  , testCase "exec with Dict" $ runPy $ do
      dct <- [pye| {} |]
      exec Main (Dict dct) [pycode|
        a = 12
        b = 13
      |]
      throwsPy $ exec Main (Module dct) [pycode| |]
      d <- fromPy dct
      liftIO $ assertEqual "dict" (Just (Map.fromList [("a",12::Int),("b",13)])) d
  , testCase "exec with Module" $ runPy $ do
      m <- [pyf|
        import importlib.util
        spec = importlib.util.spec_from_loader("dyn", loader=None)
        return importlib.util.module_from_spec(spec)
        |]
      exec Main (Module m) [pycode|
        a = 12
        b = 'asd'
        |]
      [py_|
        import types
        isinstance(m_hs, types.ModuleType)
        assert m_hs.a == 12
        assert m_hs.b == 'asd'
        |]
  , testCase "evalPyFunction" $ runPy $ do
      let n = 2 :: Int
      x <- fromPy =<< evalPyFunction Temp [pyfun| return 100 * n_hs |]
      liftIO $ Just (200::Int) @=? x
  , testCase "createModule" $ runPy $ do
      m <- createModule (T.pack "foo") [pycode||]
      [py_|
        import types
        assert isinstance(m_hs, types.ModuleType)
        |]
  ]

testAsync :: TestTree
testAsync = testGroup "async" $ guardThreaded
  [ -- We can run async computation at all
    testCase "runPyAsync" $ do
      runPy [pymain| dct = {} |]
      a <- runPyAsync $ [py_| dct[1] = 100 |]
      _ <- atomically $ waitPy a
      n <- runPy $ fromPy =<< [pye| dct[1] |]
      assertEqual "x" (Just (100::Int)) n
      runPy [pymain| del dct |]
  , -- Cancellation of python code
    testCase "cancelPy [python]" $ do
      a <- runPyAsync $ forever $ [py_|
        import time
        while True:
            time.sleep(1e-3)
        |]
      d <- registerDelay 100_000
      threadDelay 100 -- Wait to make sure execution actually started
      cancelPy a
      _ <- atomically $ waitPyCatch a `orElse` do readTVar d >>= \case
                                                    True  -> error "Timeout"
                                                    False -> retry
      return ()
  , -- Cancellation of haskell code
    testCase "cancelPy [haskell]" $ do
      a <- runPyAsync $ do
        liftIO $ forever $ threadDelay 1_000_000
      d <- registerDelay 100_000
      threadDelay 100
      cancelPy a
      _ <- atomically $ waitPyCatch a `orElse` do readTVar d >>= \case
                                                    True  -> error "Timeout"
                                                    False -> retry
      return ()
  , -- Cancellation of haskell code
    testCase "cancelPy [callback]" $ do
      a <- runPyAsync $ do
        let loop = forever $ threadDelay 1_000_000 :: IO ()
        forever [py_| loop_hs() |]
      d <- registerDelay 100_000
      threadDelay 100
      _ <- forkIO $ cancelPy a
      _ <- atomically $ waitPyCatch a `orElse` do readTVar d >>= \case
                                                    True  -> error "Timeout"
                                                    False -> retry
      return ()
  , testCase "Exception in runPyInMain works py" $ do
      lock <- newEmptyMVar
      tid  <- myThreadId
      _    <- forkIO $ takeMVar lock >> threadDelay 1000 >> throwTo tid Stop
      handle (\Stop -> pure ())
        $ runPyInMain
        $ do liftIO $ putMVar lock ()
             [py_|
               import time
               while True:
                   time.sleep(1e-3)
               |]
             error "Should be interrupted"
  ]


-- Here we only test that quasiquotes produce correct code
testMonadic :: TestTree
testMonadic = testGroup "Monadic"
  [ testCase "pymain" $ runPy [pymain| assert True |]
  , testCase "py_"    $ runPy [py_|    assert True |]
  , testCase "pye" $ runPy $ do
      n <- fromPy =<< [pye| 42 |]
      liftIO $ Just (42::Int) @=? n
  , testCase "pyf" $ runPy $ do
      n <- fromPy =<< [pyf| return 42 |]
      liftIO $ Just (42::Int) @=? n
  ]


data Stop = Stop
  deriving stock    Show
  deriving anyclass Exception

guardThreaded :: [TestTree] -> [TestTree]
guardThreaded ts
  | rtsSupportsBoundThreads = ts
  | otherwise               = []