mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-16 15:30:15 +00:00
simplex-chat-python: add python library (#6954)
* docs: simplex-chat-python design and implementation plan * bots: Python wire types codegen * simplex-chat-python: package scaffold * simplex-chat-python: native libsimplex loader * simplex-chat-python: async FFI wrappers * simplex-chat-python: ChatApi with 49 api methods * simplex-chat-python: Bot class with decorators and dispatch * simplex-chat-python: install CLI, example bot, README * simplex-chat-python: audit fixes * bots: regenerate API docs and types Catches up the markdown, TypeScript and Python codegen outputs with two upstream schema changes: - APIConnectPlan.connectionLink became optional (from sh/python-lib audit fixes); cmdString and EBNF syntax now reflect optional parameter. - APIAddGroupRelays command and CRGroupRelaysAdded/CRGroupRelaysAddFailed responses added in #6917 (relay management). The TS and markdown outputs were regenerated when #6917 landed but the Python types module only got the new entries with this regeneration. * core: refresh SQLite query plans after relay_inactive_at migration The M20260507_relay_inactive_at migration (#6917 / #6952) shifted the query plans that 'Save query plans' verifies. Regenerated via the test that owns those snapshots; no behavioral change. * bots: keep APIConnectPlan connectionLink as required parameter The prior audit-fixes commit changed the syntax expression to `Optional ...` because the Haskell field is `connectionLink :: Maybe AConnectionLink`. That misrepresents the API contract: the `Maybe` is purely an internal signal for link-parsing failure (the handler returns `CEInvalidConnReq` on `Nothing`), not API-level optionality. Callers MUST always pass a connection link. Revert the syntax expression to `Param "connectionLink"` and add a comment so the intent is preserved next time someone audits. Regenerates COMMANDS.md, commands.ts and _commands.py to match.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module API.Docs.Generate.Python where
|
||||
|
||||
import API.Docs.Commands
|
||||
import API.Docs.Events
|
||||
import API.Docs.Generate
|
||||
import API.Docs.Responses
|
||||
import API.Docs.Syntax
|
||||
import API.Docs.Syntax.Types
|
||||
import API.Docs.Types
|
||||
import API.TypeInfo
|
||||
import Data.Char (isAlphaNum, toUpper)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
|
||||
commandsCodeFile :: FilePath
|
||||
commandsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_commands.py"
|
||||
|
||||
responsesCodeFile :: FilePath
|
||||
responsesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_responses.py"
|
||||
|
||||
eventsCodeFile :: FilePath
|
||||
eventsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_events.py"
|
||||
|
||||
typesCodeFile :: FilePath
|
||||
typesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_types.py"
|
||||
|
||||
-- | Replace dashes with underscores so Python identifiers stay valid.
|
||||
pyIdent :: String -> Text
|
||||
pyIdent = T.replace "-" "_" . T.pack
|
||||
|
||||
-- | Python class name for a union member tag.
|
||||
pyConstrName :: String -> Text
|
||||
pyConstrName = pyIdent . fstToUpper
|
||||
|
||||
commandsCodeText :: Text
|
||||
commandsCodeText =
|
||||
("# API Commands\n# " <> autoGenerated <> "\n")
|
||||
<> "from __future__ import annotations\n"
|
||||
<> "import json\n"
|
||||
<> "from typing import NotRequired, TypedDict\n"
|
||||
<> "from . import _types as T\n"
|
||||
<> "from . import _responses as CR\n"
|
||||
<> foldMap commandCatCode chatCommandsDocs
|
||||
where
|
||||
commandCatCode CCCategory {categoryName, categoryDescr, commands} =
|
||||
(T.pack $ "\n# " <> categoryName <> "\n# " <> categoryDescr <> "\n")
|
||||
<> foldMap commandCode commands
|
||||
where
|
||||
commandCode CCDoc {commandType = ATUnionMember tag params, commandDescr, syntax, responses, network} =
|
||||
("\n# " <> commandDescr <> "\n")
|
||||
<> ("# Network usage: " <> networkUsage network <> ".\n")
|
||||
<> classDef
|
||||
<> (if syntax == "" then "" else cmdStringFunc)
|
||||
<> respAliasLine
|
||||
where
|
||||
constrName = T.pack $ fstToUpper tag
|
||||
classDef =
|
||||
("class " <> constrName <> "(TypedDict):\n")
|
||||
<> bodyOrPass (fieldsCodePy " " "T." params)
|
||||
<> "\n"
|
||||
cmdStringFunc =
|
||||
("\ndef " <> constrName <> "_cmd_string(self: " <> constrName <> ") -> str:\n")
|
||||
<> " return " <> pySelfSyntaxText "T." (fstToUpper tag, params) syntax <> "\n"
|
||||
respAliasLine =
|
||||
"\n" <> constrName <> "_Response = " <> respUnion <> "\n"
|
||||
respUnion = unionAliasRhs "" (responseRef . responseType) responses
|
||||
responseRef (ATUnionMember rtag _) = "CR." <> pyConstrName rtag
|
||||
|
||||
responsesCodeText :: Text
|
||||
responsesCodeText =
|
||||
("# API Responses\n# " <> autoGenerated <> "\n")
|
||||
<> pythonImports
|
||||
<> unionTypeCodePy moduleMember "T." "ChatResponse" chatRespConstrs
|
||||
where
|
||||
chatRespConstrs = L.fromList $ map responseType chatResponsesDocs
|
||||
|
||||
eventsCodeText :: Text
|
||||
eventsCodeText =
|
||||
("# API Events\n# " <> autoGenerated <> "\n")
|
||||
<> pythonImports
|
||||
<> unionTypeCodePy moduleMember "T." "ChatEvent" chatEventConstrs
|
||||
where
|
||||
chatEventConstrs = L.fromList $ concatMap catEvents chatEventsDocs
|
||||
catEvents CECategory {mainEvents, otherEvents} = map eventType $ mainEvents ++ otherEvents
|
||||
|
||||
typesCodeText :: Text
|
||||
typesCodeText =
|
||||
("# API Types\n# " <> autoGenerated <> "\n")
|
||||
<> "from __future__ import annotations\n"
|
||||
<> "from typing import Literal, NotRequired, TypedDict\n"
|
||||
<> foldMap typeCode chatTypesDocs
|
||||
where
|
||||
typeCode ctd@CTDoc {typeDef = APITypeDef {typeName' = name, typeDef}, typeDescr} =
|
||||
(if T.null typeDescr then "" else "\n# " <> typeDescr <> "\n")
|
||||
<> typeDefCode
|
||||
<> typeCmdStringCode ctd
|
||||
where
|
||||
name' = T.pack name
|
||||
enumValue m = case name of
|
||||
"ConnectionMode" -> map toUpper m
|
||||
"FileProtocol" -> map toUpper m
|
||||
_ -> m
|
||||
typeDefCode = case typeDef of
|
||||
ATDRecord fields ->
|
||||
("\nclass " <> name' <> "(TypedDict):\n")
|
||||
<> bodyOrPass (fieldsCodePy " " "" fields)
|
||||
ATDEnum cs ->
|
||||
"\n" <> name' <> " = Literal["
|
||||
<> T.intercalate ", " (map (\m -> "\"" <> T.pack (enumValue m) <> "\"") $ L.toList cs)
|
||||
<> "]\n"
|
||||
ATDUnion cs -> unionTypeCodePy typeMember "" name cs
|
||||
|
||||
-- | For types with non-empty `typeSyntax`, emit a top-level
|
||||
-- `<TypeName>_cmd_string(self: <TypeName>) -> str` helper that mirrors the
|
||||
-- Choice/Param expression. Records access fields via `self['<name>']`;
|
||||
-- enums and unions dispatch on `self` (a literal string) or `self['type']`
|
||||
-- respectively. Required so generated `_commands.py` produces valid CLI
|
||||
-- syntax for ChatRef/ChatType/ChatDeleteMode/GroupChatScope/PaginationByTime
|
||||
-- params instead of stringifying the wire dict.
|
||||
typeCmdStringCode :: CTDoc -> Text
|
||||
typeCmdStringCode CTDoc {typeDef = td@APITypeDef {typeName' = name, typeDef}, typeSyntax}
|
||||
| typeSyntax == "" = ""
|
||||
| otherwise =
|
||||
"\n\ndef " <> T.pack name <> "_cmd_string(self: " <> T.pack name <> ") -> str:\n"
|
||||
<> " return " <> body <> ignore <> "\n"
|
||||
where
|
||||
body = pyTypeSyntaxText "" (name, fields) typeSyntax
|
||||
-- Unions and enums use self/self['type'] to dispatch. Pyright cannot
|
||||
-- narrow TypedDict access by string-literal key, so suppress per-branch
|
||||
-- complaints with one ignore on the return.
|
||||
ignore = case typeDef of
|
||||
ATDUnion _ -> " # type: ignore[typeddict-item]"
|
||||
_ -> ""
|
||||
-- typeFields mirrors TS funcCode: include `self` so Choice "self"
|
||||
-- resolves; for unions add `type` and flatten member fields.
|
||||
self = APIRecordField "self" (ATDef td)
|
||||
fields = case typeDef of
|
||||
ATDRecord fs -> fs
|
||||
ATDUnion ms ->
|
||||
self : APIRecordField "type" tagType : concatMap (\(ATUnionMember _ fs) -> fs) (L.toList ms)
|
||||
where
|
||||
tagType = ATDef $ APITypeDef (name <> ".type") $ ATDEnum tags
|
||||
tags = L.map (\(ATUnionMember tag _) -> tag) ms
|
||||
ATDEnum _ -> [self]
|
||||
|
||||
-- | Like `pySelfSyntaxText` but excludes `self` from the param-rewrite list
|
||||
-- so `self == 'tag'` (enum dispatch) and `self['type']` (union dispatch)
|
||||
-- survive verbatim. Used only for type-level cmd_string functions inside
|
||||
-- @_types.py@, where peer type cmd_string calls don't need a namespace.
|
||||
pyTypeSyntaxText :: String -> TypeAndFields -> Expr -> Text
|
||||
pyTypeSyntaxText typeNamespace r expr =
|
||||
rewriteParams accessors (pySyntaxText typeNamespace r expr)
|
||||
where
|
||||
accessors = filter ((/= "self") . fst) (paramAccessors r)
|
||||
|
||||
-- | Member class name within the multi-type @_types.py@ module: prefix the
|
||||
-- tag with the union type name so members from different unions don't
|
||||
-- collide.
|
||||
typeMember :: String -> String -> Text
|
||||
typeMember typeName tag = T.pack typeName <> "_" <> pyIdent tag
|
||||
|
||||
-- | Member class name within a single-union module (responses/events): just
|
||||
-- the PascalCase tag, so commands can reference them as @CR.<Tag>@.
|
||||
moduleMember :: String -> String -> Text
|
||||
moduleMember _ tag = pyConstrName tag
|
||||
|
||||
-- | Common imports for the responses/events modules.
|
||||
pythonImports :: Text
|
||||
pythonImports =
|
||||
"from __future__ import annotations\n"
|
||||
<> "from typing import Literal, NotRequired, TypedDict\n"
|
||||
<> "from . import _types as T\n"
|
||||
|
||||
-- | Render a tagged-union type: one TypedDict per member, plus union alias
|
||||
-- and `<Name>_Tag` Literal alias. The member class names are produced by
|
||||
-- @memberName@ given the union type name and the member tag.
|
||||
unionTypeCodePy ::
|
||||
(String -> String -> Text) ->
|
||||
Text ->
|
||||
String ->
|
||||
L.NonEmpty ATUnionMember ->
|
||||
Text
|
||||
unionTypeCodePy memberName typesNamespace name cs =
|
||||
foldMap memberClass (L.toList cs)
|
||||
<> "\n" <> name' <> " = " <> unionAliasRhs name' constrTypeRef (L.toList cs)
|
||||
<> "\n" <> name' <> "_Tag = Literal[" <> tagLiterals <> "]\n"
|
||||
where
|
||||
name' = T.pack name
|
||||
constrTypeRef (ATUnionMember tag _) = memberName name tag
|
||||
tagLiterals = T.intercalate ", " $ map (\(ATUnionMember tag _) -> "\"" <> T.pack tag <> "\"") $ L.toList cs
|
||||
memberClass (ATUnionMember tag fields) =
|
||||
("\nclass " <> memberName name tag <> "(TypedDict):\n")
|
||||
<> (" type: Literal[\"" <> T.pack tag <> "\"]\n")
|
||||
<> fieldsCodePy " " typesNamespace fields
|
||||
|
||||
-- | Render the right-hand side of a union alias: either inline (one line) or
|
||||
-- multi-line wrapped in parentheses with `|` separators between alternatives.
|
||||
unionAliasRhs :: Text -> (a -> Text) -> [a] -> Text
|
||||
unionAliasRhs lhs constr cs
|
||||
| T.length (lhs <> " = " <> oneLine) <= 100 = oneLine <> "\n"
|
||||
| otherwise = "(\n" <> T.intercalate "\n" (map (" " <>) lines') <> "\n)\n"
|
||||
where
|
||||
oneLine = T.intercalate " | " cs'
|
||||
lines' = case cs' of
|
||||
[] -> []
|
||||
(h : t) -> h : map ("| " <>) t
|
||||
cs' = map constr cs
|
||||
|
||||
-- | Emit a body of `pass` if there are no fields, otherwise the rendered
|
||||
-- fields as-is.
|
||||
bodyOrPass :: Text -> Text
|
||||
bodyOrPass body
|
||||
| T.null body = " pass\n"
|
||||
| otherwise = body
|
||||
|
||||
-- | Render record fields for a TypedDict body. Each field becomes
|
||||
-- `<indent><name>: <type>[ # <comment>]`. Optional fields wrap the type in
|
||||
-- `NotRequired[...]`.
|
||||
fieldsCodePy :: Text -> Text -> [APIRecordField] -> Text
|
||||
fieldsCodePy indent namespace = foldMap render
|
||||
where
|
||||
render (APIRecordField name t) =
|
||||
indent <> T.pack name <> ": " <> wrapOptional t (typeText t) <> typeComment t <> "\n"
|
||||
wrapOptional t inner = case t of
|
||||
ATOptional _ -> "NotRequired[" <> inner <> "]"
|
||||
_ -> inner
|
||||
typeText = \case
|
||||
ATPrim (PT t) -> primName t
|
||||
ATDef (APITypeDef t _) -> quoted (namespace <> T.pack t)
|
||||
ATRef t -> quoted (namespace <> T.pack t)
|
||||
ATOptional t -> typeText t
|
||||
ATArray {elemType} -> "list[" <> typeText elemType <> "]"
|
||||
ATMap (PT k) v -> "dict[" <> primName k <> ", " <> typeText v <> "]"
|
||||
primName = \case
|
||||
TBool -> "bool"
|
||||
TString -> "str"
|
||||
TInt -> "int"
|
||||
TInt64 -> "int"
|
||||
TWord32 -> "int"
|
||||
TDouble -> "float"
|
||||
TJSONObject -> "dict[str, object]"
|
||||
TUTCTime -> "str"
|
||||
t -> T.pack t
|
||||
quoted s = "\"" <> s <> "\""
|
||||
typeComment t = let c = typeComment' t in if T.null c then "" else " # " <> c
|
||||
typeComment' = \case
|
||||
ATPrim (PT t) -> typeComment_ t
|
||||
ATOptional inner -> typeComment' inner
|
||||
ATArray {elemType, nonEmpty}
|
||||
| nonEmpty -> if T.null c then "non-empty" else c <> ", non-empty"
|
||||
| otherwise -> c
|
||||
where
|
||||
c = typeComment' elemType
|
||||
ATMap (PT k) v ->
|
||||
let kc = typeComment_ k
|
||||
vc = typeComment' v
|
||||
tc t c = if T.null c then t else c
|
||||
in if T.null kc && T.null vc then "" else tc (primName k) kc <> " : " <> tc (typeText v) vc
|
||||
_ -> ""
|
||||
typeComment_ = \case
|
||||
TInt -> "int"
|
||||
TInt64 -> "int64"
|
||||
TWord32 -> "word32"
|
||||
TDouble -> "double"
|
||||
TUTCTime -> "ISO-8601 timestamp"
|
||||
_ -> ""
|
||||
|
||||
-- | Wrap `pySyntaxText` so each parameter access uses `self['<name>']`. The
|
||||
-- output of `pySyntaxText` references params as bare Python identifiers
|
||||
-- (e.g. `str(userId)`); we rewrite those identifiers — but only outside
|
||||
-- string literals — into TypedDict subscript accesses. The
|
||||
-- @typeNamespace@ is prepended to any `<TypeName>_cmd_string(...)` calls
|
||||
-- emitted for params whose type has its own syntax (e.g. @"T."@ from
|
||||
-- @_commands.py@, or @""@ from within @_types.py@).
|
||||
--
|
||||
-- Unlike the JS variant, we do NOT collapse adjacent string literals via
|
||||
-- `T.replace "' + '" ""`: that pattern incorrectly matches `' ' + ','`
|
||||
-- (the space-then-comma sequence between a literal and `','.join(...)`),
|
||||
-- producing `' ,'.join(...)` which uses ` ,` as the join separator and
|
||||
-- swallows the leading space. The `intercalate " + "` output is correct
|
||||
-- without further string fixups.
|
||||
pySelfSyntaxText :: String -> TypeAndFields -> Expr -> Text
|
||||
pySelfSyntaxText typeNamespace r expr =
|
||||
rewriteParams (paramAccessors r) (pySyntaxText typeNamespace r expr)
|
||||
|
||||
-- | Map field name to the Python access expression: `self['<name>']` for
|
||||
-- required fields, `self.get('<name>')` for optional ones (since
|
||||
-- TypedDict's `NotRequired` allows the key to be absent and `[...]` would
|
||||
-- raise `KeyError`). Used by the rewriter so the same name is substituted
|
||||
-- consistently in Optional `is not None` checks and in the value position.
|
||||
paramAccessors :: TypeAndFields -> [(String, String)]
|
||||
paramAccessors (_, fields) = map mk fields
|
||||
where
|
||||
mk (APIRecordField n t) = (n, accessor n t)
|
||||
accessor n = \case
|
||||
ATOptional _ -> "self.get('" ++ n ++ "')"
|
||||
_ -> "self['" ++ n ++ "']"
|
||||
|
||||
-- | Replace bare identifiers (matching a key in @accessors@) with the
|
||||
-- corresponding accessor expression, skipping characters inside
|
||||
-- single-quoted string literals and respecting identifier word boundaries.
|
||||
rewriteParams :: [(String, String)] -> Text -> Text
|
||||
rewriteParams accessors = T.pack . go False . T.unpack
|
||||
where
|
||||
go _ [] = []
|
||||
-- Toggle in/out of single-quoted string on every unescaped quote.
|
||||
go inStr ('\'' : rest) = '\'' : go (not inStr) rest
|
||||
go True (c : rest) = c : go True rest
|
||||
go False s@(c : rest)
|
||||
| isIdentStart c = case takeIdent s of
|
||||
(ident, after) -> case lookup ident accessors of
|
||||
Just expr -> expr ++ go False after
|
||||
Nothing -> ident ++ go False after
|
||||
| otherwise = c : go False rest
|
||||
isIdentStart c = isAlphaNum c || c == '_'
|
||||
takeIdent = span (\c -> isAlphaNum c || c == '_')
|
||||
Reference in New Issue
Block a user