Python Client
vovk generate creates the Python client from the py or pySrc template.
Install the generator package:
npm install vovk-python --save-devCreate a Python package with the CLI:
npx vovk generate --from py --out ./python_packageThis produces:
- __init__.py
- api_client.py
- py.typed
- schema.json
- pyproject.toml
- setup.cfg
- README.md
Publish to PyPI with:
python3 -m build ./python_package --wheel --sdist && python3 -m twine upload ./python_package/dist/*To generate source files for another Python project instead, use the pySrc template:
npx vovk generate --from pySrc --out ./python_srcThis generates:
- __init__.py
- api_client.py
- py.typed
- schema.json
Configuring the Python Client
You can configure the default generate command (without flags) to create the client. vovk dev creates it too, and creates it again on every schema change. Add the py template to the composed client config:
/** @type {import('vovk').VovkConfig} */
const config = {
composedClient: {
fromTemplates: ['ts', 'py'], // keeps the default "ts" template
},
clientTemplateDefs: {
py: {
extends: 'py', // extends the built-in "py" template
outputConfig: {
origin: 'https://example.com', // the server the Python client calls
},
},
},
};
export default config;The Python client calls an absolute URL, so it needs outputConfig.origin. Set it on the py template, and the TypeScript client keeps its relative URLs. Without an origin, vovk generate warns, and every call needs its own api_root.
Like other templates, the py template has a default outDir for composed clients: ./dist_python. Change it in the template definitions:
/** @type {import('vovk').VovkConfig} */
const config = {
// ...
clientTemplateDefs: {
py: {
extends: 'py', // extends the built-in "py" template
composedClient: {
outDir: './my_dist_python', // custom output directory for the composed client
},
},
},
};
export default config;Generated Python Client Example
JSON Endpoints
The samples below are based on the example from the Hello World page.
A controller like this:
user-controller.ts
import { operation, post, prefix, procedure } from 'vovk';
import { z } from 'zod';
import UserService from './user-service';
@prefix('users')
export default class UserController {
@operation({
summary: 'Update user',
description: 'Update user by ID',
})
@post('{id}')
static updateUser = procedure({
body: z
.object({
email: z.email().meta({
description: 'User email',
examples: ['john@example.com', 'jane@example.com'],
}),
profile: z
.object({
name: z
.string()
.min(2)
.meta({
description: 'User full name',
examples: ['John Doe', 'Jane Smith'],
}),
age: z
.int()
.min(16)
.max(120)
.meta({ description: 'User age', examples: [25, 30] }),
})
.meta({ description: 'User profile object' }),
})
.meta({ description: 'User data object' }),
params: z
.object({
id: z.uuid().meta({
description: 'User ID',
examples: ['123e4567-e89b-12d3-a456-426614174000'],
}),
})
.meta({
description: 'Path parameters',
}),
query: z
.object({
notify: z
.enum(['email', 'push', 'none'])
.meta({ description: 'Notification type' }),
})
.meta({
description: 'Query parameters',
}),
output: z
.object({
success: z.boolean().meta({ description: 'Success status' }),
id: z.uuid().meta({ description: 'User ID' }),
notify: z.enum(['email', 'push', 'none']).meta({
description: 'Notification type',
}),
})
.meta({ description: 'Response object' }),
}).handle(async (req, { id }) => {
const body = await req.vovk.body();
const { notify } = req.vovk.query();
return UserService.updateUser(id, body, notify);
});
}…emits a Vovk.ts schema. The Python client is generated from it. It follows Python conventions, adds comments from description, and picks matching number types: age becomes an int, as in the controller.
from __future__ import annotations
import sys
from typing import Any, Dict, List, Literal, Optional, Sequence, Set, Union, Tuple, Generator, BinaryIO # pyright: ignore[reportUnusedImport]
# as HttpException: a name the package exports, also under mypy --strict
from .api_client import ApiClient, HttpException as HttpException
# pydantic reads a TypedDict only from typing_extensions before Python 3.12
if sys.version_info >= (3, 12):
from typing import NotRequired, TypeAlias, TypedDict
else:
from typing_extensions import NotRequired, TypeAlias, TypedDict
client = ApiClient("https://hello-world.vovk.dev/api", {
"": ("https://hello-world.vovk.dev/api", ""),
"static": ("https://hello-world.vovk.dev/api", "static"),
})
class UserRPC:
# UserRPC.update_user POST `https://hello-world.vovk.dev/api/users/{id}`
class _UpdateUserBody_profile(TypedDict):
"""
User profile object
"""
name: str
age: int
class UpdateUserBody(TypedDict):
"""
User data object
"""
email: str
profile: UserRPC._UpdateUserBody_profile
class UpdateUserQuery(TypedDict):
"""
Query parameters
"""
notify: Literal["email", "push", "none"]
class UpdateUserParams(TypedDict):
"""
Path parameters
"""
id: str
class UpdateUserOutput(TypedDict):
"""
Response object
"""
success: bool
id: str
notify: Literal["email", "push", "none"]
@staticmethod
def update_user(
body: UpdateUserBody,
query: UpdateUserQuery,
params: UpdateUserParams,
headers: Optional[Dict[str, str]] = None,
api_root: Optional[str] = None,
disable_client_validation: bool = False
) -> UpdateUserOutput:
"""
Update user
Description: Update user by ID
Body: User data object
Query: Query parameters
Returns: Response object
"""
return client.request( # type: ignore[no-any-return, unused-ignore]
segment_name="",
rpc_name="UserRPC",
handler_name="updateUser",
body=body,
query=query,
params=params,
headers=headers,
api_root=api_root,
disable_client_validation=disable_client_validation
)All RPC modules are in __init__.py, with the RPC functions and their types. Nested structures get TypedDict types. A key the schema doesn’t require is NotRequired. A type that isn’t a TypedDict, such as a str output, is a TypeAlias. Before Python 3.12, NotRequired, TypeAlias and TypedDict come from typing_extensions, where pydantic reads a TypedDict from.
Types for body, query and params are named [PascalCaseMethodName][InputType]. For updateUser, they are UpdateUserBody, UpdateUserQuery and UpdateUserParams.
Method names are in snake_case: UserRPC.updateUser is UserRPC.update_user. Each RPC class names its methods in schema order. A name that is already taken gets the first free suffix, _2, _3, so getUserByID and getUserById give get_user_by_id and get_user_by_id_2.
from vovk_hello_world import UserRPC
def main() -> None:
body: UserRPC.UpdateUserBody = {
"email": "john@example.com",
"profile": {
"name": "John Doe",
"age": 25
}
}
query: UserRPC.UpdateUserQuery = {"notify": "email"}
params: UserRPC.UpdateUserParams = {"id": "123e4567-e89b-12d3-a456-426614174000"}
update_user_response = UserRPC.update_user(
params=params,
body=body,
query=query
)
print('UserRPC.update_user:', update_user_response)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Error: {e}")The client uses requests for HTTP, jsonschema for client-side validation, and other common libraries. A pattern that Python’s re can’t compile, such as \p{L}, is left to the server. A form body is checked as the object it is made from, without its files, and a uri as the server’s z.url() reads it.
A call returns the parsed JSON, or None for an empty JSON body. A text/* response, or a response of any type with a charset, comes back as a str, decoded as UTF-8 unless the charset names another encoding. Any other response, such as a file, comes back as bytes.
JSON Lines Endpoints
For JSON Lines endpoints, the Python client returns a Generator that you iterate over.
A controller like this:
stream-controller.ts
import { get, operation, prefix, procedure } from 'vovk';
import { z } from 'zod';
import StreamService from './stream-service';
@prefix('streams')
export default class StreamController {
@operation({
summary: 'Stream tokens',
description: 'Stream tokens to the client',
})
@get('tokens')
static streamTokens = procedure({
validateEachIteration: true,
iteration: z
.object({
message: z.string().meta({ description: 'Message from the token' }),
})
.meta({
description: 'Streamed token object',
}),
}).handle(async function* () {
yield* StreamService.streamTokens();
});
}Compiles to:
class StreamRPC:
# StreamRPC.stream_tokens GET `https://hello-world.vovk.dev/api/streams/tokens`
class StreamTokensIteration(TypedDict):
"""
Streamed token object
"""
message: str
@staticmethod
def stream_tokens(
headers: Optional[Dict[str, str]] = None,
api_root: Optional[str] = None,
disable_client_validation: bool = False,
query: Optional[Dict[str, Any]] = None
) -> Generator[StreamTokensIteration, None, None]:
"""
Stream tokens
Description: Stream tokens to the client
"""
return client.request( # type: ignore[no-any-return, unused-ignore]
segment_name="",
rpc_name="StreamRPC",
handler_name="streamTokens",
query=query,
headers=headers,
api_root=api_root,
disable_client_validation=disable_client_validation
)Usage:
from vovk_hello_world import StreamRPC
def main() -> None:
stream_response = StreamRPC.stream_tokens()
print("streamTokens:")
for item in stream_response:
print(item['message'], end='', flush=True)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Error: {e}")Timeout and Session
All calls share one requests.Session, so connections stay open between them. The client waits 10 seconds to connect and 300 seconds for each read. An upload fails when the server takes no data for 10 seconds. The timeout and the session are attributes of the module-level client:
from package_name import client
client.timeout = 30 # seconds, or a (connect, read) tuple; None waits forever
client.session.headers["Authorization"] = "Bearer ..."The session doesn’t keep cookies that responses set, because one client serves every call and thread. A cookie you set by hand, such as client.session.cookies.set("session", "..."), goes with every call. To keep the cookies that responses set, call client.session.cookies.set_policy(http.cookiejar.DefaultCookiePolicy()).
Roadmap/bugs
- ✨ Generate importable types for named schemas defined in
components/schemas.