packages feed

servant-py (empty) → 0.1.0.1

raw patch · 12 files changed

+1195/−0 lines, 12 filesdep +QuickCheckdep +aesondep +basesetup-changed

Dependencies added: QuickCheck, aeson, base, base-compat, blaze-html, bytestring, charset, filepath, hspec, hspec-expectations, lens, servant, servant-blaze, servant-foreign, servant-py, servant-server, stm, text, wai, warp

Files

+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Erik Aker (c) 2017++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Author name here nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,175 @@+# servant-py++This library lets you derive automatically Python functions that let you query each endpoint of a *servant* webservice.++Currently, the only supported method for generating requests is via the `requests` library, which is the recommended way to generate HTTP requests in the Python world (even among Python core devs).++## Inspiration++This library is largely inspired by [servant-js](https://github.com/haskell-servant/servant-js) and by the fantastic work of the Servant team in general. Any good ideas you find in here are from their work (any mistakes are almost entirely mine, however).++## Example++There are two different styles of function-return supported here: `DangerMode` and `RawResponse`.++The latter returns the raw response from issuing the request and the former calls `raise_for_status` and then attempts to return `resp.json()`. You can switch which style you'd like to use by creating a proper `CommonGeneratorOptions` object.++The default options just chucks it all to the wind and goes for `DangerMode` (because, seriously, we're using Haskell to generate Python here...).++Following is an example of using the Servant DSL to describe endpoints and then using `servant-py` to create Python clients for those endpoints.++#### Servant DSL API Description++``` haskell+{-# LANGUAGE DataKinds                  #-}+{-# LANGUAGE DeriveGeneric              #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE TypeOperators              #-}++module Main where++import           Data.Aeson+import qualified Data.ByteString.Char8 as B+import           Data.Proxy+import qualified Data.Text             as T+import           GHC.Generics+import           Servant+import           System.FilePath++import           Servant.PY++-- * A simple Counter data type+newtype Counter = Counter { value :: Int }+  deriving (Generic, Show, Num)+instance ToJSON Counter++data LoginForm = LoginForm+ { username :: !T.Text+ , password :: !T.Text+ , otherMissing :: Maybe T.Text+ } deriving (Eq, Show, Generic)+instance ToJSON LoginForm++-- * Our API type+type TestApi = "counter-req-header" :> Post '[JSON] Counter+          :<|> "counter-queryparam"+            :> QueryParam "sortby" T.Text+            :> Header "Some-Header" T.Text :> Get '[JSON] Counter+          :<|> "login-queryflag" :> QueryFlag "published" :> Get '[JSON] LoginForm+          :<|> "login-params-authors-with-reqBody"+            :> QueryParams "authors" T.Text+            :> ReqBody '[JSON] LoginForm :> Post '[JSON] LoginForm+          :<|> "login-with-path-var-and-header"+            :> Capture "id" Int+            :> Capture "Name" T.Text+            :> Capture "hungrig" Bool+            :> ReqBody '[JSON] LoginForm+            :> Post '[JSON] (Headers '[Header "test-head" B.ByteString] LoginForm)++testApi :: Proxy TestApi+testApi = Proxy++-- where our static files reside+result :: FilePath+result = "examples"++main :: IO ()+main = writePythonForAPI testApi requests (result </> "api.py")+```++#### Generated Python Code++If you build the above and run it, you will get some output that looks like the following:++```python+from urllib import parse++import requests++def post_counterreqheader():+    """+    POST "counter-req-header"++    """+    url = "http://localhost:8000/counter-req-header"++    resp = requests.post(url)+    resp.raise_for_status()+    return resp.json()+++def get_counterqueryparam(sortby, headerSomeHeader):+    """+    GET "counter-queryparam"++    """+    url = "http://localhost:8000/counter-queryparam"++    headers = {"Some-Header": headerSomeHeader}+    params = {"sortby": sortby}+    resp = requests.get(url,+                        headers=headers,+                        params=params)++    resp.raise_for_status()+    return resp.json()+++def get_loginqueryflag(published):+    """+    GET "login-queryflag"++    """+    url = "http://localhost:8000/login-queryflag"++    params = {"published": published}+    resp = requests.get(url,+                        params=params)++    resp.raise_for_status()+    return resp.json()+++def post_loginparamsauthorswithreqBody(authors, data):+    """+    POST "login-params-authors-with-reqBody"++    """+    url = "http://localhost:8000/login-params-authors-with-reqBody"++    params = {"authors": authors}+    resp = requests.post(url,+                         params=params,+                         json=data)++    resp.raise_for_status()+    return resp.json()+++def post_loginwithpathvarandheader_by_id_by_Name_by_hungrig(id, Name, hungrig, data):+    """+    POST "login-with-path-var-and-header/{id}/{Name}/{hungrig}"+    Args:+        id+        Name+        hungrig+    """+    url = "http://localhost:8000/login-with-path-var-and-header/{id}/{Name}/{hungrig}".format(+        id=parse.quote(id),+        Name=parse.quote(Name),+        hungrig=parse.quote(hungrig))++    resp = requests.post(url,+                         json=data)++    resp.raise_for_status()+    return resp.json()+```++If you would like to compile and run this example yourself, you can do that like so:++```+$ stack build --flag servant-py:example+$ stack exec servant-py-exe+$ cat examples/api.py+```
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ examples/Main.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE DataKinds                  #-}+{-# LANGUAGE DeriveDataTypeable         #-}+{-# LANGUAGE DeriveGeneric              #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE MultiParamTypeClasses      #-}+{-# LANGUAGE RankNTypes                 #-}+{-# LANGUAGE TypeOperators              #-}++module Main where++import           Data.Aeson+import qualified Data.ByteString.Char8 as B+import           Data.Data+import qualified Data.Text             as T+import           GHC.Generics+import           Servant+import           Servant.Foreign+import           System.FilePath++import           Servant.PY+import           Servant.PY.Python++-- * A simple Counter data type+newtype Counter = Counter { value :: Int }+  deriving (Generic, Show, Num, Data, Typeable)+instance ToJSON Counter+-- instance HasForeignType Python T.Text Counter where+--   typeFor _ _ _ = "{\"value\": int}"++data LoginForm = LoginForm+ { username     :: !T.Text+ , password     :: !T.Text+ , otherMissing :: Maybe T.Text+ } deriving (Eq, Show, Generic, Typeable, Data)+instance ToJSON LoginForm++-- You could provide a direct instance of your types to represent them as strings+-- instance HasForeignType Python T.Text LoginForm where+--   typeFor _ _ _ = "{\"username\": str, \"password\": str, \"otherMissing\":  Maybe Text}"+++-- Alternately, if you make your instance derive Typeable and Data, and+-- you enable the pragma DeriveDataTypeable,+-- then you can use recordToDict to automatically derive your records+instance HasForeignType Python T.Text LoginForm where+  typeFor _ _ _ = recordToDict (undefined :: LoginForm) LoginForm++instance HasForeignType Python T.Text Counter where+  typeFor _ _ _ = recordToDict (undefined :: Counter) Counter++-- * Our API type+type TestApi = "counter-req-header" :> Post '[JSON] Counter+          :<|> "counter-queryparam"+            :> QueryParam "sortby" T.Text+            :> Header "Some-Header" T.Text :> Get '[JSON] Counter+          :<|> "login-queryflag" :> QueryFlag "published" :> Get '[JSON] LoginForm+          :<|> "login-params-authors-with-reqBody"+            :> QueryParams "authors" T.Text+            :> ReqBody '[JSON] LoginForm :> Post '[JSON] LoginForm+          :<|> "login-with-path-var-and-header"+            :> Capture "id" Int+            :> Capture "Name" T.Text+            :> Capture "hungrig" Bool+            :> ReqBody '[JSON] LoginForm+            :> Post '[JSON] (Headers '[Header "test-head" B.ByteString] LoginForm)++testApi :: Proxy TestApi+testApi = Proxy++-- where our static files reside+result :: FilePath+result = "examples"++main :: IO ()+main = do+  writePythonForAPI testApi requests (result </> "api.py")+  writeTypedPythonForAPI testApi requests (result </> "api_typed.py")
+ servant-py.cabal view
@@ -0,0 +1,92 @@+name:                servant-py+version:             0.1.0.1+synopsis:            Automatically derive python functions to query servant webservices.+description:+  Automatically derive python functions to query servant webservices.+  .+  Supports deriving functions using Python's requests library.+homepage:            https://github.com/pellagic-puffbomb/servant-py#readme+license:             BSD3+license-file:        LICENSE+author:              Erik Aker+maintainer:          eraker@gmail.com+copyright:           2017 Erik Aker+category:            Web+build-type:          Simple+extra-source-files:  README.md+cabal-version:       >=1.10++flag example+  description: Build the example too+  manual: True+  default: False++library+  hs-source-dirs:      src+  exposed-modules:     Servant.PY+                     , Servant.PY.Requests+                     , Servant.PY.Internal+                     , Servant.PY.Python+  build-depends:       base >= 4.7 && < 5+                     , aeson+                     , bytestring+                     , charset+                     , lens+                     , servant-foreign+                     , servant+                     , text+  default-language:    Haskell2010+  default-extensions:  OverloadedStrings+  ghc-options:         -Wall++executable servant-py-exe+  ghc-options: -Wall -O2 -threaded -rtsopts -with-rtsopts=-N+  hs-source-dirs: examples++  if flag(example)+    buildable: True+  else+    buildable: False+  hs-source-dirs:      examples+  main-is:             Main.hs+  build-depends:       base+                     , blaze-html+                     , bytestring+                     , servant-py+                     , stm+                     , aeson+                     , wai+                     , servant+                     , servant-foreign+                     , servant-server+                     , servant-blaze+                     , text+                     , filepath+                     , warp+  default-language:    Haskell2010++test-suite servant-py-test+  type:                exitcode-stdio-1.0+  hs-source-dirs:      test+  ghc-options:         -Wall+  main-is:             Spec.hs+  other-modules:+      Servant.PYSpec+      Servant.PY.InternalSpec+  build-depends:       base+                     , servant-py+                     , aeson+                     , base-compat+                     , bytestring+                     , hspec+                     , hspec-expectations+                     , lens+                     , QuickCheck+                     , servant+                     , servant-foreign+                     , text+  default-language:    Haskell2010++source-repository head+  type:     git+  location: https://github.com/pellagic-puffbomb/servant-py
+ src/Servant/PY.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE DataKinds             #-}+{-# LANGUAGE FlexibleContexts      #-}+{-# LANGUAGE FlexibleInstances     #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeOperators         #-}++module Servant.PY ( -- * Generating python code from an API type+  PythonGenerator+  , python+  , pythonTyped+  , writePythonForAPI+  , pyForAPI+  , pyTypedForAPI+  , writeTypedPythonForAPI+  , -- * Options common to all generators+    CommonGeneratorOptions(..)+  , defCommonGeneratorOptions++  -- Requests library+  , requests++  , -- * Function renamers+    concatCase+  , snakeCase+  , camelCase++  , -- * Misc.+    listFromAPI+  , NoTypes+  , GenerateList(..)+  , FunctionName(..)+  ) where++import           Data.Proxy+import           Data.Text           (Text)+import qualified Data.Text           as T+import           Servant.Foreign++import           Servant.PY.Internal+import           Servant.PY.Python+import           Servant.PY.Requests++-- | Generate the data necessary to generate Python code+--   for all the endpoints of an API, as ':<|>'-separated values+--   of type 'PyRequest'.+python :: HasForeign NoTypes NoContent api => Proxy api -> Foreign NoContent api+python p = foreignFor (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) p defReq++-- | Generate the data necessary to generate Python code+--   for all the endpoints of an API, but try to get as much type-information+-- into Python docstrings, in order to aid discoverability of client functions.+pythonTyped :: HasForeign Python Text api => Proxy api -> Foreign Text api+pythonTyped p = foreignFor (Proxy :: Proxy Python) (Proxy :: Proxy Text) p defReq++-- | Directly generate all the Python functions for your API+--   from a 'Proxy' for your API type. You can then write it to+--   a file or integrate it in a page, for example.+pyForAPI :: (HasForeign NoTypes NoContent api, GenerateList NoContent (Foreign NoContent api))+         => Proxy api -- ^ proxy for your API type+         -> PythonGenerator -- ^ python code generator to use (requests is the only one for now)+         -> Text                -- ^ a text that you can embed in your pages or write to a file+pyForAPI p gen = gen (UnTypedPythonRequest <$> listFromAPI (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) p)++-- | Directly generate all the Python functions for your API+--   from a 'Proxy' for your API type using the given generator+--   and write the resulting code to a file at the given path.+writePythonForAPI :: (HasForeign NoTypes NoContent api, GenerateList NoContent (Foreign NoContent api))+              => Proxy api -- ^ proxy for your API type+              -> PythonGenerator -- ^ python code generator to use (requests is the only one for now)+              -> FilePath -- ^ path to the file you want to write the resulting javascript code into+              -> IO ()+writePythonForAPI p gen fp = writeFile fp (T.unpack $ pyForAPI p gen)+++-- | Directly generate all the Python functions for your API+--   from a 'Proxy' for your API type. You can then write it to+--   a file or integrate it in a page, for example.+pyTypedForAPI :: (HasForeign Python T.Text api, GenerateList T.Text (Foreign T.Text api))+         => Proxy api -- ^ proxy for your API type+         -> PythonGenerator -- ^ python code generator to use (requests is the only one for now)+         -> Text                -- ^ a text that you can embed in your pages or write to a file+pyTypedForAPI p gen = gen (TypedPythonRequest <$> listFromAPI (Proxy :: Proxy Python) (Proxy :: Proxy T.Text) p)+++writeTypedPythonForAPI :: (HasForeign Python T.Text api, GenerateList T.Text (Foreign T.Text api))+              => Proxy api -- ^ proxy for your API type+              -> PythonGenerator -- ^ python code generator to use (requests is the only one for now)+              -> FilePath -- ^ path to the file you want to write the resulting javascript code into+              -> IO ()+writeTypedPythonForAPI p gen fp = writeFile fp (T.unpack $ pyTypedForAPI p gen)
+ src/Servant/PY/Internal.hs view
@@ -0,0 +1,370 @@+{-# LANGUAGE DataKinds           #-}+{-# LANGUAGE KindSignatures      #-}+{-# LANGUAGE RankNTypes          #-}+{-# LANGUAGE ScopedTypeVariables #-}++module Servant.PY.Internal+  ( PythonGenerator+  , ReturnStyle(..)+  , PythonRequest(..)+  , PyRequestArgs(..)+  , CommonGeneratorOptions(..)+  , defCommonGeneratorOptions+  , defaultPyIndent+  , indent+  , Indent+  , indenter+  , makePyUrl+  , makePyUrl'+  , segmentToStr+  , capturesToFormatArgs+  , toValidFunctionName+  , functionName+  , toPyHeader+  , retrieveHeaders+  , getHeaderDict+  , retrieveHeaderText+  , toPyDict+  , toPyParams+  , getParams+  , paramNames+  , captures+  , getMethod+  , hasBody+  , withFormattedCaptures+  , buildDocString+  , buildHeaderDict+  , functionArguments+  , formatBuilder+  , remainingReqCall+  -- re-exports+  , (:<|>)(..)+  , (:>)+  , defReq+  , reqHeaders+  , HasForeign(..)+  , HasForeignType(..)+  , GenerateList(..)+  , NoTypes+  , ArgType(..)+  , HeaderArg(..)+  , QueryArg(..)+  , Req(..)+  , Segment(..)+  , SegmentType(..)+  , Url(..)+  , Path+  , Arg(..)+  , FunctionName(..)+  , PathSegment(..)+  , concatCase+  , snakeCase+  , camelCase+  , ReqBody+  , JSON+  , FormUrlEncoded+  , Post+  , Get+  , Raw+  , Header+  ) where++import           Control.Lens                  hiding (List)+import qualified Data.CharSet                  as Set+import qualified Data.CharSet.Unicode.Category as Set+import           Data.Data+import           Data.Maybe                    (isJust)+import           Data.Monoid+import           Data.Text                     (Text)+import qualified Data.Text                     as T+import           Data.Text.Encoding            (decodeUtf8)+import           GHC.TypeLits+import           Servant.Foreign+++-- A 'PythonGenerator' just takes the data found in the API type+-- for each endpoint and generates Python code as Text.+-- There are `NoContent` requests and Text requests with typing information.+type PythonGenerator = [PythonRequest] -> Text+data PythonRequest = TypedPythonRequest (Req Text)+                   | UnTypedPythonRequest (Req NoContent)+                   deriving (Eq, Show)++-- We'd like to encode at the type-level that indentation+-- is some multiplication of whitespace (sorry: never tabs!)+type Indent = (" " :: Symbol)++indent :: Proxy Indent+indent = Proxy++-- The defaultPyIndent function is 4 spaces.+-- You can create a different indentation width by passing a different Int to indenter.+defaultPyIndent :: Proxy Indent -> Text+defaultPyIndent = indenter 4++-- But you can create alternatives by specializing the `indenter` function+-- to other Ints. Then, to get your indentation, pass `indent` to the created function+indenter :: Int -> Proxy Indent -> Text+indenter width space = mconcat $ width `replicate` (T.pack . symbolVal) space+{-# INLINE indenter #-}+++-- Created python Functions can have different return styles+data ReturnStyle = DangerMode  -- Throw caution to the wind and return JSON+                 | RawResponse  -- Return response object itself+++data PyRequestArgs = PyRequestArgs {+  hasHeaders  :: Bool+  , hasParams :: Bool+  , hasData   :: Bool+  } deriving (Show)++-- | This structure is used by specific implementations to let you+-- customize the output+data CommonGeneratorOptions = CommonGeneratorOptions+  {+    functionNameBuilder :: FunctionName -> Text+    -- ^ function generating function names+  , requestBody         :: Text+    -- ^ name used when a user want to send the request body+    -- (to let you redefine it)+  , urlPrefix           :: Text+    -- ^ a prefix we should add to the Url in the codegen+  , indentation         :: Proxy Indent -> Text+  -- ^ indentation to use for Python codeblocks. Create this function by passing an Int to indenter.+  , returnMode          :: ReturnStyle+  -- ^ whether the generated functions return the raw response or content+  }++-- | Default options.+--+-- @+-- > defCommonGeneratorOptions = CommonGeneratorOptions+-- >   { functionNameBuilder = snakeCase+-- >   , requestBody = "body"+-- >   , urlPrefix = ""+-- >   , indentation = "    "  -- 4 spaces+-- >   , returnMode = DangerMode+-- >   }+-- @+defCommonGeneratorOptions :: CommonGeneratorOptions+defCommonGeneratorOptions = CommonGeneratorOptions+  {+    functionNameBuilder = snakeCase+  , requestBody = "data"+  , urlPrefix = "http://localhost:8000"+  , indentation = defaultPyIndent+  , returnMode = DangerMode+  }++-- | Attempts to reduce the function name provided to that allowed by @'Foreign'@.+--+-- For valid Python function identifiers see the following:+-- https://docs.python.org/3.2/reference/lexical_analysis.html#identifiers+-- valid start chars: Lu, Ll, Lt, Lm, Lo, Nl, the underscore+-- valid continuation chars: valid start chars <> Mn, Mc, Nd, Pc+toValidFunctionName :: Text -> Text+toValidFunctionName t =+  case T.uncons t of+    Just (x,xs) ->+      setFirstChar x `T.cons` T.filter remainder xs+    Nothing -> "_"+  where+    setFirstChar c = if Set.member c firstLetterOK then c else '_'+    remainder c = Set.member c remainderOK+    firstLetterOK = filterBmpChars $ mconcat+                      [ Set.fromDistinctAscList "_"+                      , Set.lowercaseLetter+                      , Set.uppercaseLetter+                      , Set.titlecaseLetter+                      , Set.modifierLetter+                      , Set.otherLetter+                      , Set.letterNumber+                      ]+    remainderOK   = firstLetterOK+               <> filterBmpChars (mconcat+                    [ Set.nonSpacingMark+                    , Set.spacingCombiningMark+                    , Set.decimalNumber+                    , Set.connectorPunctuation+                    ])++functionName :: CommonGeneratorOptions -> PythonRequest -> Text+functionName opts (TypedPythonRequest req) = toValidFunctionName (functionNameBuilder opts $ req ^. reqFuncName)+functionName opts (UnTypedPythonRequest req) = toValidFunctionName (functionNameBuilder opts $ req ^. reqFuncName)++-- Identifiers can only contain codepoints in the Basic Multilingual Plane+-- that is, codepoints that can be encoded in UTF-16 without a surrogate pair (UCS-2)+-- that is, codepoints that can fit in 16-bits, up to 0xffff (65535)+filterBmpChars :: Set.CharSet -> Set.CharSet+filterBmpChars = Set.filter (< '\65536')++-- This function creates a dict where the keys are string representations of variable+-- names. This is due to the way arguments are passed into the function, and these+-- arguments named params. In other words, [("key", "key")] becomes: {"key": key}+toPyDict :: Text -> [Text] -> Text+toPyDict offset dict+  | null dict = "{}"+  | otherwise = "{" <> T.intercalate (",\n" <> offset) insides <> "}"+  where insides = combiner <$> dict+        combiner a = "\"" <> a <> "\": " <> a++-- Query params are passed into the function that makes the request, so we make+-- a python dict out of them.+getParams :: Text -> PythonRequest -> Text+getParams offset (TypedPythonRequest req) = toPyParams offset $ req ^.. reqUrl.queryStr.traverse+getParams offset (UnTypedPythonRequest req) = toPyParams offset $ req ^.. reqUrl.queryStr.traverse++toPyParams :: Text -> [QueryArg f] -> Text+toPyParams _ [] = ""+toPyParams offset qargs = toPyDict offset paramList+  where paramList = fmap (\qarg -> qarg ^. queryArgName.argName._PathSegment) qargs++-- We also need to make sure we can retrieve just the param names for function args.+paramNames :: PythonRequest -> [Text]+paramNames (TypedPythonRequest req) = map (view $ queryArgName . argPath) $ req ^.. reqUrl.queryStr.traverse+paramNames (UnTypedPythonRequest req) = map (view $ queryArgName . argPath) $ req ^.. reqUrl.queryStr.traverse++-- Request headers are also passed into the function that makes the request, so we make+-- a python dict out of them.+toPyHeader :: HeaderArg f -> Text+toPyHeader (HeaderArg n)+  = toValidFunctionName ("header" <> n ^. argName . _PathSegment)+toPyHeader (ReplaceHeaderArg n p)+  | pn `T.isPrefixOf` p = pv <> " + \"" <> rp <> "\""+  | pn `T.isSuffixOf` p = "\"" <> rp <> "\" + " <> pv+  | pn `T.isInfixOf` p  = "\"" <> T.replace pn ("\" + " <> pv <> " + \"") p+                             <> "\""+  | otherwise         = p+  where+    pv = toValidFunctionName ("header" <> n ^. argName . _PathSegment)+    pn = "{" <> n ^. argName . _PathSegment <> "}"+    rp = T.replace pn "" p++buildHeaderDict :: [HeaderArg f] -> Text+buildHeaderDict [] = ""+buildHeaderDict hs = "{" <> headers <> "}"+  where headers = T.intercalate ", " $ map headerStr hs+        headerStr header = "\"" <> header ^. headerArg . argPath <> "\": "+                           <> toPyHeader header++getHeaderDict :: PythonRequest -> Text+getHeaderDict (TypedPythonRequest req) = buildHeaderDict $ req ^. reqHeaders+getHeaderDict (UnTypedPythonRequest req) = buildHeaderDict $ req ^. reqHeaders++retrieveHeaders :: PythonRequest -> [Text]+retrieveHeaders (TypedPythonRequest req) = retrieveHeaderText <$> req ^. reqHeaders+retrieveHeaders (UnTypedPythonRequest req) = retrieveHeaderText <$> req ^. reqHeaders++retrieveHeaderText :: forall f. HeaderArg f -> Text+retrieveHeaderText header = header ^. headerArg . argPath+++functionArguments :: forall f. Req f -> Text+functionArguments req =+  mconcat [ T.intercalate ", " args]+  where+    args = captures' req ++ qparam ++ body ++ headers++    qparam = map ((<>) "param_" . view (queryArgName . argPath)) queryParams++    body = if isJust $ req ^. reqBody+           then ["data"]+           else []+    queryParams = req ^.. reqUrl . queryStr . traverse+    headers = map ((<>) "header_"+                    . view (headerArg . argPath)+                  ) $ req ^. reqHeaders++captures :: PythonRequest -> [Text]+captures (TypedPythonRequest req)   = captures' req+captures (UnTypedPythonRequest req) = captures' req+++captures' :: forall f. Req f -> [Text]+captures' req = map (view argPath . captureArg)+         . filter isCapture+         $ req ^. reqUrl.path++makePyUrl :: CommonGeneratorOptions -> PythonRequest -> Text -> Text+makePyUrl opts (TypedPythonRequest req) offset   = makePyUrl' opts req offset+makePyUrl opts (UnTypedPythonRequest req) offset = makePyUrl' opts req offset++makePyUrl' :: forall f. CommonGeneratorOptions -> Req f -> Text -> Text+makePyUrl' opts req offset = if url' == "\"" then "\"/\"" else url'+  where url' = "\"" <> urlPrefix opts <> "/"+                    <> getSegments pathParts+                    <> withFormattedCaptures offset pathParts+        pathParts = req ^.. reqUrl.path.traverse++getSegments :: forall f. [Segment f] -> Text+getSegments segments = if null segments+                       then ""+                       else T.intercalate "/" (map segmentToStr segments) <> "\""++withFormattedCaptures :: Text -> [Segment f] -> Text+withFormattedCaptures offset segments = formattedCaptures (capturesToFormatArgs segments)+  where formattedCaptures [] = ""+        formattedCaptures xs = ".format(\n" <> offset+                              <> T.intercalate (",\n" <> offset) (map formatBuilder xs)+                              <> ")"++formatBuilder :: Text -> Text+formatBuilder val = val <> "=parse.quote(str("<> val <> "))"++segmentToStr :: Segment f -> Text+segmentToStr (Segment (Static s)) = s ^. _PathSegment+segmentToStr (Segment (Cap s))    = "{" <> s ^. argName . _PathSegment <> "}"++capturesToFormatArgs :: [Segment f] -> [Text]+capturesToFormatArgs segments = map getSegment $ filter isCapture segments+  where getSegment (Segment (Cap a)) = getCapture a+        getSegment _                 = ""+        getCapture s = s ^. argName . _PathSegment++captureArgsWithTypes :: [Segment Text] -> [Text]+captureArgsWithTypes segments =  map getSegmentArgType (filter isCapture segments)+  where getSegmentArgType (Segment (Cap a)) = pathPart a <> " (" <> a ^. argType <> ")"+        getSegmentArgType _                 = ""+        pathPart s = s ^. argName . _PathSegment+++buildDocString :: PythonRequest -> CommonGeneratorOptions -> Text -> Text+buildDocString (TypedPythonRequest req) opts returnVal = buildDocString' req opts args returnVal+  where args = captureArgsWithTypes $ req ^.. reqUrl.path.traverse+buildDocString (UnTypedPythonRequest req) opts returnVal = buildDocString' req opts args returnVal+  where args = capturesToFormatArgs $ req ^.. reqUrl.path.traverse++buildDocString' :: forall f. Req f -> CommonGeneratorOptions -> [Text] -> Text -> Text+buildDocString' req opts args returnVal = T.toUpper method <> " \"" <> url <> "\n"+                                                  <> includeArgs <> "\n\n"+                                                  <> indent' <> "Returns: " <> "\n"+                                                  <> indent' <> indent' <> returnVal+  where method = decodeUtf8 $ req ^. reqMethod+        url = getSegments $ req ^.. reqUrl.path.traverse+        includeArgs = if null args then "" else argDocs+        argDocs = indent' <> "Args: " <> "\n"+                  <> indent' <> indent' <> T.intercalate ("\n" <> indent' <> indent') args+        indent' = indentation opts indent++getMethod :: PythonRequest -> Text+getMethod (TypedPythonRequest req) = decodeUtf8 $ req ^. reqMethod+getMethod (UnTypedPythonRequest req) = decodeUtf8 $ req ^. reqMethod++hasBody :: PythonRequest -> Bool+hasBody (TypedPythonRequest req) = isJust (req ^. reqBody)+hasBody (UnTypedPythonRequest req) = isJust (req ^. reqBody)++remainingReqCall :: PyRequestArgs -> Int -> Text+remainingReqCall reqArgs width+  | null argsAsList = ")"+  | length argsAsList == 1 = ",\n" <> offset <> head argsAsList <> ")\n"+  | otherwise = ",\n" <> offset <> T.intercalate (",\n" <> offset) argsAsList <> ")\n"+  where argsAsList = requestArgsToList reqArgs+        offset = mconcat $ replicate width " "++requestArgsToList :: PyRequestArgs -> [Text]+requestArgsToList reqArgs = map snd . filter fst $ zip bools strings+  where bools = [hasHeaders reqArgs, hasParams reqArgs, hasData reqArgs]+        strings = ["headers=headers", "params=params", "json=data"]
+ src/Servant/PY/Python.hs view
@@ -0,0 +1,79 @@+{-# LANGUAGE DataKinds             #-}+{-# LANGUAGE FlexibleContexts      #-}+{-# LANGUAGE FlexibleInstances     #-}+{-# LANGUAGE KindSignatures        #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables   #-}+{-# LANGUAGE TypeOperators         #-}++module Servant.PY.Python where++import qualified Data.ByteString.Char8 as B+import qualified Data.ByteString.Lazy  as LB+import           Data.Data+import           Data.Monoid           ((<>))+import           Data.Text             (Text)+import qualified Data.Text             as T+import           GHC.TypeLits+import           Servant.Foreign++data Python++getFieldsForInstance :: forall a. Data a => a -> [Text]+getFieldsForInstance = map T.pack . mconcat . map constrFields . getConstr+  where getConstr = dataTypeConstrs . dataTypeOf++getFieldTypesForType :: forall a. Typeable a => a -> [Text]+getFieldTypesForType = init . T.splitOn " -> " . T.pack . show . typeOf++recordToDict :: forall a b. (Data a, Typeable b) => a -> b -> T.Text+recordToDict fieldsInstance fieldsType = "{" <> T.intercalate ", " (map printPair fieldPairs) <> "}"+  where printPair (c, d) = "\"" <> c <> "\": " <> d+        fieldPairs = zip fieldNames fieldTypes+        fieldNames = getFieldsForInstance fieldsInstance+        fieldTypes = getFieldTypesForType fieldsType++instance HasForeignType Python Text NoContent where+  typeFor _ _ _ = "None"++instance HasForeignType Python Text Int where+  typeFor _ _ _ = "int"++instance HasForeignType Python Text Bool where+  typeFor _ _ _ = "bool"++instance HasForeignType Python Text String where+  typeFor _ _ _ = "str"++instance HasForeignType Python Text Text where+  typeFor _ _ _ = "str"++instance HasForeignType Python Text LB.ByteString where+  typeFor _ _ _ = "str"++instance HasForeignType Python Text B.ByteString where+  typeFor _ _ _ = "str"++instance HasForeignType Python Text JSON where+  typeFor _ _ _ = "dict"++instance HasForeignType Python T.Text (a :: Symbol) where+  typeFor _ _ _ = "str"++instance HasForeignType Python Text a => HasForeignType Python Text (Header a) where+  typeFor lang ftype _ = "dict" <> typeFor lang ftype (Proxy :: Proxy a)++instance HasForeignType Python Text a => HasForeignType Python Text (Headers '[Header a b] c) where+  typeFor lang ftype _ = "dict" <> typeFor lang ftype (Proxy :: Proxy a)++instance HasForeignType Python Text a => HasForeignType Python Text [Header a b] where+  typeFor lang ftype _ = "[dict of " <> typeFor lang ftype (Proxy :: Proxy a) <> "]"++instance HasForeignType Python Text a => HasForeignType Python Text (Headers a) where+  typeFor lang ftype _ = "dict" <> typeFor lang ftype (Proxy :: Proxy a)++instance HasForeignType Python Text a => HasForeignType Python Text (Maybe a) where+  typeFor lang ftype _ = "Optional" <> typeFor lang ftype (Proxy :: Proxy a)++instance HasForeignType Python Text a => HasForeignType Python Text [a] where+   typeFor lang ftype _ = "list of " <> typeFor lang ftype (Proxy :: Proxy a)
+ src/Servant/PY/Requests.hs view
@@ -0,0 +1,75 @@+module Servant.PY.Requests where++import           Data.Monoid+import           Data.Proxy+import           Data.Text           (Text)+import qualified Data.Text           as T++import           Servant.PY.Internal++-- | Generate python functions that use the requests library.+--   Uses 'defCommonGeneratorOptions' for the generator options.+requests :: PythonGenerator+requests reqs = defPyImports <> mconcat (map requestsWithDef reqs)++-- | Generate python functions that use the requests library.+--   Lets you specify your own 'CommonGeneratorOptions'.+requestsWith :: CommonGeneratorOptions -> [PythonRequest] -> Text+requestsWith opts reqs = mconcat (map (generatePyRequestWith opts) reqs)++-- | python codegen using requests with default options+requestsWithDef :: PythonRequest -> Text+requestsWithDef = generatePyRequestWith defCommonGeneratorOptions++defPyImports :: Text+defPyImports =+  T.unlines+    [ "from urllib import parse"+    , "" -- Separate stdlib from 3rd-party imports+    , "import requests"+    ]++-- | python codegen with requests+generatePyRequestWith :: CommonGeneratorOptions -> PythonRequest -> Text+generatePyRequestWith opts req = "\n" <>+    "def " <> functionName opts req <> "(" <> argsStr <> "):\n"+    <> indent' <> docStringMarker+    <> indent' <> buildDocString req opts returnVal <> "\n"+    <> indent' <> docStringMarker+    <> indent' <> "url = " <> makePyUrl opts req (indent' <> indent') <> "\n\n"+    <> headerDef+    <> paramDef+    <> requestBuilder <> "(url" <> remaining (T.length requestBuilder + 1) <> "\n"+    <> functionReturn (returnMode opts) (indentation opts)+    <> "\n\n"+  where argsStr = T.intercalate ", " args+        args = captures req+             ++ qparams+             ++ body+             ++ map (toValidFunctionName+                    . (<>) "header"+                    ) hs+        hs = retrieveHeaders req+        qparams = paramNames req+        method = T.toLower $ getMethod req+        remaining = remainingReqCall $ PyRequestArgs (not . null $ hs) (not . null $ qparams) (hasBody req)+        paramDef+          | null qparams = ""+          | otherwise = indent' <> "params = " <> getParams (indent' <> indent') req <> "\n"+        headerDef+          | null hs = ""+          | otherwise = indent' <> "headers = " <> getHeaderDict req <> "\n"+        requestBuilder = indent' <> "resp = requests." <> method+        body = [requestBody opts | hasBody req]+        indent' = indentation opts indent+        docStringMarker = "\"\"\"\n"+        returnVal = case returnMode opts of+          DangerMode -> "JSON response from the endpoint"+          RawResponse -> "response (requests.Response) from issuing the request"++functionReturn :: ReturnStyle -> (Proxy Indent -> T.Text) -> T.Text+functionReturn DangerMode pyindenter = indent' <> "resp.raise_for_status()\n"+                                    <> indent' <> "return resp.json()"+  where indent' = pyindenter indent+functionReturn RawResponse pyindenter = indent' <> "return resp"+  where indent' = pyindenter indent
+ test/Servant/PY/InternalSpec.hs view
@@ -0,0 +1,143 @@+{-# LANGUAGE DataKinds           #-}+{-# LANGUAGE DeriveGeneric       #-}+{-# LANGUAGE FlexibleInstances   #-}+{-# LANGUAGE OverloadedStrings   #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies        #-}+{-# LANGUAGE TypeOperators       #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Servant.PY.InternalSpec where++import           Control.Lens+import           Data.Aeson+import qualified Data.ByteString.Char8                      as B+import           Data.Monoid+import           Data.Proxy+import           Data.Text                                  (Text)+import qualified Data.Text                                  as T+import           GHC.Generics+import           Prelude                                    ()+import           Prelude.Compat+import           Test.Hspec                                 hiding+                                                             (shouldContain,+                                                             shouldNotContain)+import           Test.QuickCheck                            (Arbitrary (..),+                                                             choose, listOf,+                                                             property)++import           Servant.API.ContentTypes+import           Servant.API.Internal.Test.ComprehensiveAPI+import           Servant.Foreign++import           Servant.PY.Internal+++data SomeJson = SomeJson+ { uvalue       :: !T.Text+ , pvalue       :: !T.Text+ , otherMissing :: Maybe T.Text+ } deriving (Eq, Show, Generic)+instance ToJSON SomeJson++-- * Our API type+type TestApi = "counter-req-header" :> Post '[JSON] SomeJson+          :<|> "counter-queryparam"+            :> QueryParam "sortby" T.Text+            :> Header "Some-Header" T.Text :> Get '[JSON] SomeJson+          :<|> "login-queryflag" :> QueryFlag "published" :> Get '[JSON] SomeJson+          :<|> "login-params-authors-with-reqBody"+            :> QueryParams "authors" T.Text+            :> ReqBody '[JSON] SomeJson :> Post '[JSON] SomeJson++testApi :: Proxy TestApi+testApi = Proxy+++type CaptureApi = "login-with-path-var-and-header"+                  :> Capture "id" Int+                  :> Capture "Name" T.Text+                  :> Capture "hungrig" Bool+                  :> ReqBody '[JSON] SomeJson+                  :> Post '[JSON] (Headers '[Header "test-head" B.ByteString] SomeJson)+captureApi :: Proxy CaptureApi+captureApi = Proxy++customOptions :: CommonGeneratorOptions+customOptions = defCommonGeneratorOptions+ { urlPrefix = "urlForRequesting:9000"+ , returnMode = RawResponse+ }++spec :: Spec+spec = describe "Servant.PY.Internal" internalSpec++shouldContain :: Text -> Text -> Expectation+a `shouldContain` b  = shouldSatisfy a (T.isInfixOf b)++shouldNotContain :: Text -> Text -> Expectation+a `shouldNotContain` b  = shouldNotSatisfy a (T.isInfixOf b)++newtype ASCII = ASCII {getASCII :: T.Text} deriving (Show)++instance Arbitrary ASCII where+   -- Our arbitrary instance is generating only ASCII, since the language-ecmascript's lexer+   -- is currently (October 2016) still a bit naïve+   arbitrary = fmap (ASCII . T.pack) $ listOf $ choose (minBound, '\127')+   shrink xs = (ASCII . T.pack) <$> shrink (T.unpack $ getASCII xs)+++internalSpec :: Spec+internalSpec = describe "Internal" $ do+  describe "pure text functions" $ do+    it "should only indent using whitespace" $+      property $ \n -> indenter n indent == mconcat (replicate n (T.pack " "))++    it "should generate a valid python identifier when supplied with hyphens, unicode whitespace, non-bmp unicode" $+      toValidFunctionName "a_--a\66352b\6158c\65075" `shouldBe` "a_abc\65075"++    it "should produce PyDicts where the key is a quoted version of the variable name" $ do+      let dict = toPyDict "  " ["forty", "one", "people"]+      dict `shouldBe` "{\"forty\": forty,\n  \"one\": one,\n  \"people\": people}"++  describe "functions that operate on Req objects" $ do+    let captureList = listFromAPI (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) captureApi+    it "should correctly find captures" $ do+      let captured = captures . head $ captureList+      captured `shouldBe` ["id", "Name", "hungrig"]++    let reqList = listFromAPI (Proxy :: Proxy NoTypes) (Proxy :: Proxy NoContent) testApi+    it "should not incorrectly find captures" $ do+      let captured = captures . head $ reqList+      captured `shouldBe` []++    let req = head captureList+    let pathParts = req ^.. reqUrl.path.traverse+    it "should correctly find captures as a list" $+      capturesToFormatArgs pathParts `shouldBe` ["id", "Name", "hungrig"]++    it "should correctly format captures" $+      withFormattedCaptures "  " pathParts `shouldBe` ".format(\n  id=parse.quote(str(id)),\n"+                                                    <> "  Name=parse.quote(str(Name)),\n  "+                                                    <> "hungrig=parse.quote(str(hungrig)))"++    it "should build a formatted val with parse.quote and str" $+      property $ \s -> T.isInfixOf "=parse.quote(str(" $ formatBuilder $ T.pack s+    it "should build a formatted val that ends with parens" $+      property $ \s -> T.isSuffixOf (T.pack s <> "))") $ formatBuilder $ T.pack s++    it "should build urls properly with / separator" $ do+      let pyUrl = makePyUrl customOptions req " "+      pyUrl `shouldBe` "\"urlForRequesting:9000/login-with-path-var-and-header/{id}/{Name}/{hungrig}\""+                       <> withFormattedCaptures " " pathParts++    it "should do segment-to-str as a plain string for Static" $+      segmentToStr (head pathParts) == "login-with-path-var-and-header"+    it "should do segment-to-str in formatting braces for a capture" $+      segmentToStr (last pathParts) == "{hungrig}"+    it "should build a doctstring that looks like a regular Python docstring" $ do+      let docstring = buildDocString req customOptions+      docstring `shouldContain` "POST"+      docstring `shouldContain` makePyUrl' pathParts+      docstring `shouldContain` "Args:"+      docstring `shouldContain` "Returns:"
+ test/Servant/PYSpec.hs view
@@ -0,0 +1,61 @@+{-# LANGUAGE DataKinds           #-}+{-# LANGUAGE FlexibleInstances   #-}+{-# LANGUAGE OverloadedStrings   #-}+{-# LANGUAGE QuasiQuotes         #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies        #-}+{-# LANGUAGE TypeOperators       #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}++module Servant.PYSpec where++import           Data.Either                                (isRight)+import           Data.Monoid                                ()+import           Data.Monoid.Compat                         ((<>))+import           Data.Proxy+import           Data.Text                                  (Text)+import qualified Data.Text                                  as T+import           GHC.TypeLits+import           Prelude                                    ()+import           Prelude.Compat+import           Test.Hspec                                 hiding+                                                             (shouldContain,+                                                             shouldNotContain)+import           Test.QuickCheck                            (Arbitrary (..),+                                                             choose, listOf,+                                                             property)++import           Servant.API.ContentTypes+import           Servant.API.Internal.Test.ComprehensiveAPI++import           Servant.PY.Internal+++customOptions :: CommonGeneratorOptions+customOptions = defCommonGeneratorOptions+ { urlPrefix = "urlForRequesting:9000"+ , returnMode = DangerMode+ }++spec :: Spec+spec = describe "Servant.PY.Internal" internalSpec++shouldContain :: Text -> Text -> Expectation+a `shouldContain` b  = shouldSatisfy a (T.isInfixOf b)++shouldNotContain :: Text -> Text -> Expectation+a `shouldNotContain` b  = shouldNotSatisfy a (T.isInfixOf b)++newtype ASCII = ASCII {getASCII :: T.Text} deriving (Show)++instance Arbitrary ASCII where+   -- Our arbitrary instance is generating only ASCII, since the language-ecmascript's lexer+   -- is currently (October 2016) still a bit naïve+   arbitrary = fmap (ASCII . T.pack) $ listOf $ choose (minBound, '\127')+   shrink xs = (ASCII . T.pack) <$> shrink (T.unpack $ getASCII xs)+++internalSpec :: Spec+internalSpec = describe "Internal" $ do+    it "should only indent using whitespace" $+      property $ \n -> indenter n indent == mconcat (replicate n (T.pack " "))
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}