Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Python: Type-stub generation for SSDKs #2149

Merged
merged 23 commits into from
Feb 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9e359bd
Initial Python stub generation
unexge Dec 30, 2022
787a0b9
Handle default values correctly
unexge Jan 3, 2023
4436d3e
Only generate `__init__` for classes that have constructor signatures
unexge Jan 3, 2023
5d5298e
Preserve doc comments
unexge Jan 3, 2023
ae53061
Make context class generic
unexge Jan 3, 2023
5adc034
Put type hint into a string to fix runtime error
unexge Jan 4, 2023
bc17873
Run `mypy` on CI
unexge Jan 4, 2023
8a20a4f
Use `make` to build Python SSDKs while generating diffs
unexge Jan 4, 2023
ac02352
Escape Python types in Rust comments
unexge Jan 5, 2023
6b77a10
Only mark class methods with
unexge Jan 5, 2023
9bf1b17
Sort imports to minimize diffs
unexge Jan 5, 2023
022835e
Add type annotations for `PySocket`
unexge Jan 5, 2023
c8201ab
Dont extend classes from `object` as every class already implicitly e…
unexge Jan 5, 2023
9afcfbb
Use `vars` instead of `inspect.getmembers` to skip inherited members …
unexge Jan 5, 2023
d081d8a
Fix linting issues
unexge Jan 5, 2023
f7155fc
Add some tests for stubgen and refactor it
unexge Jan 6, 2023
9b29bd1
Add type annotations to `PyMiddlewareException`
unexge Jan 9, 2023
086370c
Fix tests on Python 3.7
unexge Jan 9, 2023
d72fff9
Provide default values for `typing.Optional[T]` types in type-stubs
unexge Jan 9, 2023
0ddba29
Update `is_fn_like` to cover more cases
unexge Jan 23, 2023
7ad167d
Remove `tools/smithy-rs-tool-common/`
unexge Feb 10, 2023
7e9c9cb
Make `DECORATORS` an array instead of a list
unexge Feb 10, 2023
cd00b0b
Ignore missing type stub errors for `aiohttp`
unexge Feb 10, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ class RustWriter private constructor(
fun factory(debugMode: Boolean): Factory<RustWriter> = Factory { fileName: String, namespace: String ->
when {
fileName.endsWith(".toml") -> RustWriter(fileName, namespace, "#", debugMode = debugMode)
fileName.endsWith(".py") -> RustWriter(fileName, namespace, "#", debugMode = debugMode)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do this customization in Python namespace?

Copy link
Contributor

@crisidev crisidev Jan 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so (well, not easily).. This is a foundational module that is hard to move around or inherit from. We could have a PyRustWriter that adds this functionality, but the real RustWriter is so embedded into the whole codebase that it's going to be really hard to just use something that inherits from it everywhere.

To me it is reasonable to have it here, same goes for toml for example, but I would like to hear the SDK team thoughts..

fileName.endsWith(".md") -> rawWriter(fileName, debugMode = debugMode)
fileName == "LICENSE" -> rawWriter(fileName, debugMode = debugMode)
fileName.startsWith("tests/") -> RustWriter(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

package software.amazon.smithy.rust.codegen.server.python.smithy

import software.amazon.smithy.rust.codegen.core.rustlang.RustType

/**
* A hierarchy of Python types handled by Smithy codegen.
*
* Mostly copied from [RustType] and modified for Python accordingly.
*/
sealed class PythonType {
/**
* A Python type that contains [member], another [PythonType].
* Used to generically operate over shapes that contain other shape.
*/
sealed interface Container {
val member: PythonType
val namespace: String?
val name: String
}

/**
* Name refers to the top-level type for import purposes.
*/
abstract val name: String

open val namespace: String? = null

object None : PythonType() {
override val name: String = "None"
}

object Bool : PythonType() {
override val name: String = "bool"
}

object Int : PythonType() {
override val name: String = "int"
}

object Float : PythonType() {
override val name: String = "float"
}

object Str : PythonType() {
override val name: String = "str"
}

object Any : PythonType() {
override val name: String = "Any"
override val namespace: String = "typing"
}

data class List(override val member: PythonType) : PythonType(), Container {
override val name: String = "List"
override val namespace: String = "typing"
}

data class Dict(val key: PythonType, override val member: PythonType) : PythonType(), Container {
override val name: String = "Dict"
override val namespace: String = "typing"
}

data class Set(override val member: PythonType) : PythonType(), Container {
override val name: String = "Set"
override val namespace: String = "typing"
}

data class Optional(override val member: PythonType) : PythonType(), Container {
override val name: String = "Optional"
override val namespace: String = "typing"
}

data class Awaitable(override val member: PythonType) : PythonType(), Container {
override val name: String = "Awaitable"
override val namespace: String = "typing"
}

data class Callable(val args: kotlin.collections.List<PythonType>, val rtype: PythonType) : PythonType() {
override val name: String = "Callable"
override val namespace: String = "typing"
}

data class Union(val args: kotlin.collections.List<PythonType>) : PythonType() {
override val name: String = "Union"
override val namespace: String = "typing"
}

data class Opaque(override val name: String, val rustNamespace: String? = null) : PythonType() {
// Since Python doesn't have a something like Rust's `crate::` we are using a custom placeholder here
// and in our stub generation script we will replace placeholder with the real root module name.
private val pythonRootModulePlaceholder = "__root_module_name__"

override val namespace: String? = rustNamespace?.split("::")?.joinToString(".") {
when (it) {
"crate" -> pythonRootModulePlaceholder
// In Python, we expose submodules from `aws_smithy_http_server_python`
// like `types`, `middleware`, `tls` etc. from `__root_module__name`
"aws_smithy_http_server_python" -> pythonRootModulePlaceholder
else -> it
}
}
}
}

/**
* Return corresponding [PythonType] for a [RustType].
*/
fun RustType.pythonType(): PythonType =
when (this) {
is RustType.Unit -> PythonType.None
is RustType.Bool -> PythonType.Bool
is RustType.Float -> PythonType.Float
is RustType.Integer -> PythonType.Int
is RustType.String -> PythonType.Str
is RustType.Vec -> PythonType.List(this.member.pythonType())
is RustType.Slice -> PythonType.List(this.member.pythonType())
is RustType.HashMap -> PythonType.Dict(this.key.pythonType(), this.member.pythonType())
is RustType.HashSet -> PythonType.Set(this.member.pythonType())
is RustType.Reference -> this.member.pythonType()
is RustType.Option -> PythonType.Optional(this.member.pythonType())
is RustType.Box -> this.member.pythonType()
is RustType.Dyn -> this.member.pythonType()
is RustType.Opaque -> PythonType.Opaque(this.name, this.namespace)
// TODO(Constraints): How to handle this?
// Revisit as part of https://github.com/awslabs/smithy-rs/issues/2114
Comment on lines +129 to +130
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you have to.. I was talking with @david-perez and right now Python only handles constraints during deserialization and hands you off the original structure, without newtypes if the validation succeeded or an error otherwise.

is RustType.MaybeConstrained -> this.member.pythonType()
}

/**
* Render this type, including references and generic parameters.
* It generates something like `typing.Dict[String, String]`.
*/
fun PythonType.render(fullyQualified: Boolean = true): String {
val namespace = if (fullyQualified) {
this.namespace?.let { "$it." } ?: ""
} else ""
val base = when (this) {
is PythonType.None -> this.name
is PythonType.Bool -> this.name
is PythonType.Float -> this.name
is PythonType.Int -> this.name
is PythonType.Str -> this.name
is PythonType.Any -> this.name
is PythonType.Opaque -> this.name
is PythonType.List -> "${this.name}[${this.member.render(fullyQualified)}]"
is PythonType.Dict -> "${this.name}[${this.key.render(fullyQualified)}, ${this.member.render(fullyQualified)}]"
is PythonType.Set -> "${this.name}[${this.member.render(fullyQualified)}]"
is PythonType.Awaitable -> "${this.name}[${this.member.render(fullyQualified)}]"
is PythonType.Optional -> "${this.name}[${this.member.render(fullyQualified)}]"
is PythonType.Callable -> {
val args = this.args.joinToString(", ") { it.render(fullyQualified) }
val rtype = this.rtype.render(fullyQualified)
"${this.name}[[$args], $rtype]"
}
is PythonType.Union -> {
val args = this.args.joinToString(", ") { it.render(fullyQualified) }
"${this.name}[$args]"
}
}
return "$namespace$base"
}

/**
* Renders [PythonType] with proper escaping for Docstrings.
*/
fun PythonType.renderAsDocstring(): String =
this.render()
.replace("[", "\\[")
.replace("]", "\\]")
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class PubUsePythonTypesDecorator : ServerCodegenDecorator {
/**
* Generates `pyproject.toml` for the crate.
* - Configures Maturin as the build system
* - Configures Python source directory
*/
class PyProjectTomlDecorator : ServerCodegenDecorator {
override val name: String = "PyProjectTomlDecorator"
Expand All @@ -110,6 +111,11 @@ class PyProjectTomlDecorator : ServerCodegenDecorator {
"requires" to listOfNotNull("maturin>=0.14,<0.15"),
"build-backend" to "maturin",
).toMap(),
"tool" to listOfNotNull(
"maturin" to listOfNotNull(
"python-source" to "python",
).toMap(),
).toMap(),
)
writeWithNoFormatting(TomlWriter().write(config))
}
Expand All @@ -134,6 +140,60 @@ class PyO3ExtensionModuleDecorator : ServerCodegenDecorator {
}
}

/**
* Generates `__init__.py` for the Python source.
*
* This file allows Python module to be imported like:
* ```
* import pokemon_service_server_sdk
* pokemon_service_server_sdk.App()
* ```
* instead of:
* ```
* from pokemon_service_server_sdk import pokemon_service_server_sdk
* ```
*/
class InitPyDecorator : ServerCodegenDecorator {
override val name: String = "InitPyDecorator"
override val order: Byte = 0

override fun extras(codegenContext: ServerCodegenContext, rustCrate: RustCrate) {
val libName = codegenContext.settings.moduleName.toSnakeCase()

rustCrate.withFile("python/$libName/__init__.py") {
writeWithNoFormatting(
"""
from .$libName import *

__doc__ = $libName.__doc__
if hasattr($libName, "__all__"):
__all__ = $libName.__all__
""".trimIndent(),
)
}
}
}

/**
* Generates `py.typed` for the Python source.
*
* This marker file is required to be PEP 561 compliant stub package.
* Type definitions will be ignored by `mypy` if the package is not PEP 561 compliant:
* https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-library-stubs-or-py-typed-marker
*/
class PyTypedMarkerDecorator : ServerCodegenDecorator {
override val name: String = "PyTypedMarkerDecorator"
override val order: Byte = 0

override fun extras(codegenContext: ServerCodegenContext, rustCrate: RustCrate) {
val libName = codegenContext.settings.moduleName.toSnakeCase()

rustCrate.withFile("python/$libName/py.typed") {
writeWithNoFormatting("")
}
}
}

val DECORATORS = arrayOf(
/**
* Add the [InternalServerError] error to all operations.
Expand All @@ -150,4 +210,8 @@ val DECORATORS = arrayOf(
PyProjectTomlDecorator(),
// Add PyO3 extension module feature.
PyO3ExtensionModuleDecorator(),
// Generate `__init__.py` for the Python source.
InitPyDecorator(),
// Generate `py.typed` for the Python source.
PyTypedMarkerDecorator(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import software.amazon.smithy.rust.codegen.core.util.outputShape
import software.amazon.smithy.rust.codegen.core.util.toPascalCase
import software.amazon.smithy.rust.codegen.core.util.toSnakeCase
import software.amazon.smithy.rust.codegen.server.python.smithy.PythonServerCargoDependency
import software.amazon.smithy.rust.codegen.server.python.smithy.PythonType
import software.amazon.smithy.rust.codegen.server.python.smithy.renderAsDocstring
import software.amazon.smithy.rust.codegen.server.smithy.ServerCargoDependency
import software.amazon.smithy.rust.codegen.server.smithy.generators.protocol.ServerProtocol

Expand Down Expand Up @@ -103,6 +105,9 @@ class PythonApplicationGenerator(
"""
##[#{pyo3}::pyclass]
##[derive(Debug)]
/// :generic Ctx:
/// :extends typing.Generic\[Ctx\]:
/// :rtype None:
pub struct App {
handlers: #{HashMap}<String, #{SmithyPython}::PyHandler>,
middlewares: Vec<#{SmithyPython}::PyMiddlewareHandler>,
Expand Down Expand Up @@ -239,19 +244,33 @@ class PythonApplicationGenerator(
""",
*codegenScope,
) {
val middlewareRequest = PythonType.Opaque("Request", "crate::middleware")
val middlewareResponse = PythonType.Opaque("Response", "crate::middleware")
val middlewareNext = PythonType.Callable(listOf(middlewareRequest), PythonType.Awaitable(middlewareResponse))
val middlewareFunc = PythonType.Callable(listOf(middlewareRequest, middlewareNext), PythonType.Awaitable(middlewareResponse))
val tlsConfig = PythonType.Opaque("TlsConfig", "crate::tls")

rustTemplate(
"""
/// Create a new [App].
##[new]
pub fn new() -> Self {
Self::default()
}

/// Register a context object that will be shared between handlers.
///
/// :param context Ctx:
/// :rtype ${PythonType.None.renderAsDocstring()}:
##[pyo3(text_signature = "(${'$'}self, context)")]
pub fn context(&mut self, context: #{pyo3}::PyObject) {
self.context = Some(context);
}

/// Register a Python function to be executed inside a Tower middleware layer.
///
/// :param func ${middlewareFunc.renderAsDocstring()}:
/// :rtype ${PythonType.None.renderAsDocstring()}:
##[pyo3(text_signature = "(${'$'}self, func)")]
pub fn middleware(&mut self, py: #{pyo3}::Python, func: #{pyo3}::PyObject) -> #{pyo3}::PyResult<()> {
let handler = #{SmithyPython}::PyMiddlewareHandler::new(py, func)?;
Expand All @@ -263,8 +282,16 @@ class PythonApplicationGenerator(
self.middlewares.push(handler);
Ok(())
}

/// Main entrypoint: start the server on multiple workers.
##[pyo3(text_signature = "(${'$'}self, address, port, backlog, workers, tls)")]
///
/// :param address ${PythonType.Optional(PythonType.Str).renderAsDocstring()}:
/// :param port ${PythonType.Optional(PythonType.Int).renderAsDocstring()}:
/// :param backlog ${PythonType.Optional(PythonType.Int).renderAsDocstring()}:
/// :param workers ${PythonType.Optional(PythonType.Int).renderAsDocstring()}:
/// :param tls ${PythonType.Optional(tlsConfig).renderAsDocstring()}:
/// :rtype ${PythonType.None.renderAsDocstring()}:
##[pyo3(text_signature = "(${'$'}self, address=None, port=None, backlog=None, workers=None, tls=None)")]
pub fn run(
&mut self,
py: #{pyo3}::Python,
Expand All @@ -277,7 +304,10 @@ class PythonApplicationGenerator(
use #{SmithyPython}::PyApp;
self.run_server(py, address, port, backlog, workers, tls)
}

/// Lambda entrypoint: start the server on Lambda.
///
/// :rtype ${PythonType.None.renderAsDocstring()}:
##[pyo3(text_signature = "(${'$'}self)")]
pub fn run_lambda(
&mut self,
Expand All @@ -286,8 +316,9 @@ class PythonApplicationGenerator(
use #{SmithyPython}::PyApp;
self.run_lambda_handler(py)
}

/// Build the service and start a single worker.
##[pyo3(text_signature = "(${'$'}self, socket, worker_number, tls)")]
##[pyo3(text_signature = "(${'$'}self, socket, worker_number, tls=None)")]
pub fn start_worker(
&mut self,
py: pyo3::Python,
Expand All @@ -306,10 +337,31 @@ class PythonApplicationGenerator(
operations.map { operation ->
val operationName = symbolProvider.toSymbol(operation).name
val name = operationName.toSnakeCase()

val input = PythonType.Opaque("${operationName}Input", "crate::input")
val output = PythonType.Opaque("${operationName}Output", "crate::output")
val context = PythonType.Opaque("Ctx")
val returnType = PythonType.Union(listOf(output, PythonType.Awaitable(output)))
val handler = PythonType.Union(
listOf(
PythonType.Callable(
listOf(input, context),
returnType,
),
PythonType.Callable(
listOf(input),
returnType,
),
),
)

rustTemplate(
"""
/// Method to register `$name` Python implementation inside the handlers map.
/// It can be used as a function decorator in Python.
///
/// :param func ${handler.renderAsDocstring()}:
/// :rtype ${PythonType.None.renderAsDocstring()}:
##[pyo3(text_signature = "(${'$'}self, func)")]
pub fn $name(&mut self, py: #{pyo3}::Python, func: #{pyo3}::PyObject) -> #{pyo3}::PyResult<()> {
use #{SmithyPython}::PyApp;
Expand Down
Loading