packages feed

convex-schema-parser-0.1.3.0: src/Backend/Python/Validator.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeSynonymInstances #-}

module Backend.Python.Validator (run, PythonValidator (..), PythonValidatorEnv (..)) where

import Control.Monad.Reader
import Convex.Validator
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO (hGetContents)
import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, waitForProcess)

newtype PythonValidator a = PythonValidator
  { runPythonValidator :: ReaderT PythonValidatorEnv IO a
  }
  deriving (Functor, Applicative, Monad, MonadIO, MonadReader PythonValidatorEnv, MonadFail)

data PythonValidatorEnv = PythonValidatorEnv {projectPath :: FilePath}

run :: PythonValidatorEnv -> PythonValidator a -> IO a
run renv action = runReaderT (runPythonValidator action) renv

instance Validator PythonValidator where
  -- \| Sets up the Python validation sandbox project.
  setup = do
    config <- ask
    let pythonProjectPath = projectPath config
    let stubsPath = pythonProjectPath </> "stubs"

    -- Create the directory structure.
    liftIO $ createDirectoryIfMissing True stubsPath

    -- Write the mypy.ini config file.
    liftIO $
      doesFileExist (pythonProjectPath </> "mypy.ini") >>= \case
        True -> return () -- If it exists, we don't overwrite it.
        False -> writeFile (pythonProjectPath </> "mypy.ini") mypyIniContent

    -- Write the pydantic stub file.
    liftIO $
      doesFileExist (stubsPath </> "pydantic.pyi") >>= \case
        True -> return () -- If it exists, we don't overwrite it.
        False -> writeFile (stubsPath </> "pydantic.pyi") pydanticStubContent

    -- Write the convex stub file.
    liftIO $
      doesFileExist (stubsPath </> "convex.pyi") >>= \case
        True -> return () -- If it exists, we don't overwrite it.
        False -> writeFile (stubsPath </> "convex.pyi") convexStubContent

    -- Write an empty stub for pydantic_core to satisfy mypy
    liftIO $
      doesFileExist (stubsPath </> "pydantic_core.pyi") >>= \case
        True -> return () -- If it exists, we don't overwrite it.
        False -> writeFile (stubsPath </> "pydantic_core.pyi") ""

    return ()

  -- \| Validates the generated Python code using mypy.
  validate generatedCode = do
    pythonProjectPath <- asks projectPath
    let generatedFilePath = pythonProjectPath </> "generated_api.py"

    -- Write the generated code to the sandbox project.
    liftIO $ writeFile generatedFilePath generatedCode

    -- Run `mypy` on the generated file.
    liftIO $ putStrLn "[Validator] Running 'mypy'..."
    -- We run `mypy .` to make it pick up the mypy.ini config automatically.
    let mypyCmd = (proc "mypy" ["."]) {cwd = Just pythonProjectPath, std_out = CreatePipe, std_err = CreatePipe}
    (_, Just hOut, Just hErr, handle) <- liftIO $ createProcess mypyCmd
    exitCode <- liftIO $ waitForProcess handle

    if exitCode == ExitSuccess
      then do
        liftIO $ putStrLn "[Validator] Validation successful."
        content <- liftIO $ readFile generatedFilePath
        return $ Just content
      else do
        liftIO $ putStrLn "[Validator] Error: 'mypy' failed. The generated code has type errors."
        -- Print both stdout and stderr for comprehensive error reporting.
        liftIO $ hGetContents hOut >>= putStrLn
        liftIO $ hGetContents hErr >>= putStrLn
        return Nothing

-- | Content for the mypy.ini configuration file.
mypyIniContent :: String
mypyIniContent =
  unlines
    [ "[mypy]",
      "python_version = 3.9",
      "disallow_untyped_defs = True",
      "check_untyped_defs = True",
      "warn_return_any = True",
      "ignore_missing_imports = True"
    ]

-- | Minimal stub content for the `pydantic` library.
pydanticStubContent :: String
pydanticStubContent =
  unlines
    [ "from typing import Any, Type",
      "",
      "class BaseModel:",
      "    def model_validate(cls: Type, obj: Any) -> Any: ...",
      "    def model_dump_json(self, *, indent: int | None = ...) -> str: ...",
      "",
      "def Field(default: Any = ..., *, alias: str | None = ...) -> Any: ...",
      "",
      "class TypeAdapter:",
      "    def __init__(self, type: Any) -> None: ...",
      "    def validate_python(self, data: Any) -> Any: ...",
      "",
      "class ValidationError(Exception): ..."
    ]

-- | Minimal stub content for the `convex` library.
convexStubContent :: String
convexStubContent =
  unlines
    [ "from typing import Any, Iterator",
      "",
      "class ConvexClient:",
      "    def __init__(self, url: str) -> None: ...",
      "    def set_admin_auth(self, key: str) -> None: ...",
      "    def set_auth(self, token: str) -> None: ...",
      "    def query(self, path: str, args: dict[str, Any]) -> Any: ...",
      "    def mutation(self, path: str, args: dict[str, Any]) -> Any: ...",
      "    def action(self, path: str, args: dict[str, Any]) -> Any: ...",
      "    def subscribe(self, path: str, args: dict[str, Any]) -> Iterator[Any]: ...",
      "",
      "class ConvexError(Exception): ..."
    ]