Skip to content

Commit 3fb9096

Browse files
unexgecrisidev
andauthored
Python: Allow injecting Lambda Context (#1985)
* Provide Python wrappers for Lambda related types * Introduce `PyContext` to wrap raw context object * Use new `PyContext` in handlers * Expose `lambda` module to Python * Use `LambdaContext` in example service * Start Lambda handler in a different thread * Print summary of Lambda context in Pokemon service * Make sure to include Python `builtins` in tests * Make `lambda_ctx` optional Co-authored-by: Matteo Bigoi <1781140+crisidev@users.noreply.github.com> * Only inject types if they are type-hinted as `Optional[T]` * Export Lambda module as `aws_lambda` instead of `lambda_` * Comment why we need to run Hyper server in a background thread * Move `is_optional_of` to `util` module * Use `HeaderMap::from_iter` to build headers * Support edge case of `(None, T)` in `util::is_optional_of` * Make Lambda related types feature gated * Remove feature gate for Lambda * Make `xray_trace_id` an `Option` * Remove `aws-lambda` feature from generated `Cargo.toml`s * Fix linting issues * Pin `lambda_runtime` to `0.7.1` * Remove duplicate dependency in `Cargo.toml` Co-authored-by: Matteo Bigoi <1781140+crisidev@users.noreply.github.com>
1 parent 9d2d088 commit 3fb9096

File tree

14 files changed

+896
-49
lines changed

14 files changed

+896
-49
lines changed

codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/customizations/PythonServerCodegenDecorator.kt

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ package software.amazon.smithy.rust.codegen.server.python.smithy.customizations
77

88
import software.amazon.smithy.model.neighbor.Walker
99
import software.amazon.smithy.rust.codegen.client.smithy.customize.RustCodegenDecorator
10-
import software.amazon.smithy.rust.codegen.core.rustlang.Feature
1110
import software.amazon.smithy.rust.codegen.core.rustlang.Writable
1211
import software.amazon.smithy.rust.codegen.core.rustlang.docs
1312
import software.amazon.smithy.rust.codegen.core.rustlang.rust
@@ -104,21 +103,6 @@ class PubUsePythonTypesDecorator : RustCodegenDecorator<ServerProtocolGenerator,
104103
clazz.isAssignableFrom(ServerCodegenContext::class.java)
105104
}
106105

107-
/**
108-
* Decorator adding an `aws-lambda` feature to the generated crate.
109-
*/
110-
class PythonFeatureFlagsDecorator : RustCodegenDecorator<ServerProtocolGenerator, ServerCodegenContext> {
111-
override val name: String = "PythonFeatureFlagsDecorator"
112-
override val order: Byte = 0
113-
114-
override fun extras(codegenContext: ServerCodegenContext, rustCrate: RustCrate) {
115-
rustCrate.mergeFeature(Feature("aws-lambda", true, listOf("aws-smithy-http-server-python/aws-lambda")))
116-
}
117-
118-
override fun supportsCodegenContext(clazz: Class<out CodegenContext>): Boolean =
119-
clazz.isAssignableFrom(ServerCodegenContext::class.java)
120-
}
121-
122106
val DECORATORS = listOf(
123107
/**
124108
* Add the [InternalServerError] error to all operations.
@@ -131,6 +115,4 @@ val DECORATORS = listOf(
131115
PubUsePythonTypesDecorator(),
132116
// Render the Python shared library export.
133117
PythonExportModuleDecorator(),
134-
// Add the `aws-lambda` feature flag
135-
PythonFeatureFlagsDecorator(),
136118
)

codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerModuleGenerator.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class PythonServerModuleGenerator(
4949
renderPyLogging()
5050
renderPyMiddlewareTypes()
5151
renderPyTlsTypes()
52+
renderPyLambdaTypes()
5253
renderPyApplicationType()
5354
}
5455
}
@@ -179,6 +180,22 @@ class PythonServerModuleGenerator(
179180
)
180181
}
181182

183+
private fun RustWriter.renderPyLambdaTypes() {
184+
rustTemplate(
185+
"""
186+
let aws_lambda = #{pyo3}::types::PyModule::new(py, "aws_lambda")?;
187+
aws_lambda.add_class::<#{SmithyPython}::lambda::PyLambdaContext>()?;
188+
pyo3::py_run!(
189+
py,
190+
aws_lambda,
191+
"import sys; sys.modules['$libName.aws_lambda'] = aws_lambda"
192+
);
193+
m.add_submodule(aws_lambda)?;
194+
""",
195+
*codegenScope,
196+
)
197+
}
198+
182199
// Render Python application type.
183200
private fun RustWriter.renderPyApplicationType() {
184201
rustTemplate(

codegen-server/python/src/main/kotlin/software/amazon/smithy/rust/codegen/server/python/smithy/generators/PythonServerOperationHandlerGenerator.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class PythonServerOperationHandlerGenerator(
6565
/// Python handler for operation `$operationName`.
6666
pub(crate) async fn $fnName(
6767
input: $input,
68-
state: #{SmithyServer}::Extension<#{pyo3}::PyObject>,
68+
state: #{SmithyServer}::Extension<#{SmithyPython}::context::PyContext>,
6969
handler: #{SmithyPython}::PyHandler,
7070
) -> std::result::Result<$output, $error> {
7171
// Async block used to run the handler and catch any Python error.
@@ -95,7 +95,7 @@ class PythonServerOperationHandlerGenerator(
9595
let output = if handler.args == 1 {
9696
pyhandler.call1((input,))?
9797
} else {
98-
pyhandler.call1((input, state.0))?
98+
pyhandler.call1((input, #{pyo3}::ToPyObject::to_object(&state.0, py)))?
9999
};
100100
output.extract::<$output>()
101101
})
@@ -114,7 +114,7 @@ class PythonServerOperationHandlerGenerator(
114114
let coroutine = if handler.args == 1 {
115115
pyhandler.call1((input,))?
116116
} else {
117-
pyhandler.call1((input, state.0))?
117+
pyhandler.call1((input, #{pyo3}::ToPyObject::to_object(&state.0, py)))?
118118
};
119119
#{pyo3_asyncio}::tokio::into_future(coroutine)
120120
})?;

rust-runtime/aws-smithy-http-server-python/Cargo.toml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,25 @@ Python server runtime for Smithy Rust Server Framework.
1212
"""
1313
publish = true
1414

15-
[features]
16-
aws-lambda = ["aws-smithy-http-server/aws-lambda", "dep:lambda_http"]
17-
1815
[dependencies]
1916
aws-smithy-http = { path = "../aws-smithy-http" }
20-
aws-smithy-http-server = { path = "../aws-smithy-http-server" }
17+
aws-smithy-http-server = { path = "../aws-smithy-http-server", features = ["aws-lambda"] }
2118
aws-smithy-json = { path = "../aws-smithy-json" }
2219
aws-smithy-types = { path = "../aws-smithy-types" }
2320
aws-smithy-xml = { path = "../aws-smithy-xml" }
2421
bytes = "1.2"
2522
futures = "0.3"
2623
http = "0.2"
2724
hyper = { version = "0.14.20", features = ["server", "http1", "http2", "tcp", "stream"] }
28-
lambda_http = { version = "0.7.1", optional = true }
2925
tls-listener = { version = "0.5.1", features = ["rustls", "hyper-h2"] }
3026
rustls-pemfile = "1.0.1"
3127
tokio-rustls = "0.23.4"
28+
lambda_http = { version = "0.7.1" }
29+
# There is a breaking change in `lambda_runtime` between `0.7.0` and `0.7.1`,
30+
# and `lambda_http` depends on `0.7` which by default resolves to `0.7.1` but in our CI
31+
# we are running `minimal-versions` which downgrades `lambda_runtime` to `0.7.0` and fails to compile
32+
# because of the breaking change. Here we are forcing it to use `lambda_runtime = 0.7.1`.
33+
lambda_runtime = { version = "0.7.1" }
3234
num_cpus = "1.13.1"
3335
parking_lot = "0.12.1"
3436
pin-project-lite = "0.2"

rust-runtime/aws-smithy-http-server-python/examples/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ release: codegen
3939
ln -sf $(RELEASE_SHARED_LIBRARY_SRC) $(SHARED_LIBRARY_DST)
4040

4141
run: build
42-
python $(CUR_DIR)/pokemon_service.py
42+
python3 $(CUR_DIR)/pokemon_service.py
4343

4444
test: build
4545
cargo test

rust-runtime/aws-smithy-http-server-python/examples/pokemon_service.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from libpokemon_service_server_sdk import App
1414
from libpokemon_service_server_sdk.tls import TlsConfig # type: ignore
15+
from libpokemon_service_server_sdk.aws_lambda import LambdaContext # type: ignore
1516
from libpokemon_service_server_sdk.error import ResourceNotFoundException # type: ignore
1617
from libpokemon_service_server_sdk.input import ( # type: ignore
1718
DoNothingInput,
@@ -80,6 +81,10 @@ def value(self) -> int:
8081
# https://docs.python.org/3/library/multiprocessing.html#sharing-state-between-processes
8182
@dataclass
8283
class Context:
84+
# Inject Lambda context if service is running on Lambda
85+
# NOTE: All the values that will be injected by the framework should be wrapped with `Optional`
86+
lambda_ctx: Optional[LambdaContext] = None
87+
8388
# In our case it simulates an in-memory database containing the description of Pikachu in multiple
8489
# languages.
8590
_pokemon_database = {
@@ -156,7 +161,7 @@ async def check_content_type_header(request: Request, next: Next) -> Response:
156161
logging.debug("Found valid `application/json` content type")
157162
else:
158163
logging.warning(
159-
f"Invalid content type {content_type}, dumping headers: {request.headers}"
164+
f"Invalid content type {content_type}, dumping headers: {request.headers.items()}"
160165
)
161166
return await next(request)
162167

@@ -197,6 +202,18 @@ def do_nothing(_: DoNothingInput) -> DoNothingOutput:
197202
def get_pokemon_species(
198203
input: GetPokemonSpeciesInput, context: Context
199204
) -> GetPokemonSpeciesOutput:
205+
if context.lambda_ctx is not None:
206+
logging.debug(
207+
"Lambda Context: %s",
208+
dict(
209+
request_id=context.lambda_ctx.request_id,
210+
deadline=context.lambda_ctx.deadline,
211+
invoked_function_arn=context.lambda_ctx.invoked_function_arn,
212+
function_name=context.lambda_ctx.env_config.function_name,
213+
memory=context.lambda_ctx.env_config.memory,
214+
version=context.lambda_ctx.env_config.version,
215+
),
216+
)
200217
context.increment_calls_count()
201218
flavor_text_entries = context.get_pokemon_description(input.name)
202219
if flavor_text_entries:
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
//! Python context definition.
7+
8+
use http::Extensions;
9+
use pyo3::{PyObject, PyResult, Python, ToPyObject};
10+
11+
mod lambda;
12+
pub mod layer;
13+
#[cfg(test)]
14+
mod testing;
15+
16+
/// PyContext is a wrapper for context object provided by the user.
17+
/// It injects some values (currently only [super::lambda::PyLambdaContext]) that is type-hinted by the user.
18+
///
19+
///
20+
/// PyContext is initialised during the startup, it inspects the provided context object for fields
21+
/// that are type-hinted to inject some values provided by the framework (see [PyContext::new()]).
22+
///
23+
/// After finding fields that needs to be injected, [layer::AddPyContextLayer], a [tower::Layer],
24+
/// populates request-scoped values from incoming request.
25+
///
26+
/// And finally PyContext implements [ToPyObject] (so it can by passed to Python handlers)
27+
/// that provides [PyObject] provided by the user with the additional values injected by the framework.
28+
#[derive(Clone)]
29+
pub struct PyContext {
30+
inner: PyObject,
31+
// TODO(Refactor): We should ideally keep record of injectable fields in a hashmap like:
32+
// `injectable_fields: HashMap<Field, Box<dyn Injectable>>` where `Injectable` provides a method to extract a `PyObject` from a `Request`,
33+
// but I couldn't find a way to extract a trait object from a Python object.
34+
// We could introduce a registry to keep track of every injectable type but I'm not sure that is the best way to do it,
35+
// so until we found a good way to achive that, I didn't want to introduce any abstraction here and
36+
// keep it simple because we only have one field that is injectable.
37+
lambda_ctx: lambda::PyContextLambda,
38+
}
39+
40+
impl PyContext {
41+
pub fn new(inner: PyObject) -> PyResult<Self> {
42+
Ok(Self {
43+
lambda_ctx: lambda::PyContextLambda::new(inner.clone())?,
44+
inner,
45+
})
46+
}
47+
48+
pub fn populate_from_extensions(&self, _ext: &Extensions) {
49+
self.lambda_ctx
50+
.populate_from_extensions(self.inner.clone(), _ext);
51+
}
52+
}
53+
54+
impl ToPyObject for PyContext {
55+
fn to_object(&self, _py: Python<'_>) -> PyObject {
56+
self.inner.clone()
57+
}
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use http::Extensions;
63+
use pyo3::{prelude::*, py_run};
64+
65+
use super::testing::get_context;
66+
67+
#[test]
68+
fn py_context() -> PyResult<()> {
69+
pyo3::prepare_freethreaded_python();
70+
71+
let ctx = get_context(
72+
r#"
73+
class Context:
74+
foo: int = 0
75+
bar: str = 'qux'
76+
77+
ctx = Context()
78+
ctx.foo = 42
79+
"#,
80+
);
81+
Python::with_gil(|py| {
82+
py_run!(
83+
py,
84+
ctx,
85+
r#"
86+
assert ctx.foo == 42
87+
assert ctx.bar == 'qux'
88+
# Make some modifications
89+
ctx.foo += 1
90+
ctx.bar = 'baz'
91+
"#
92+
);
93+
});
94+
95+
ctx.populate_from_extensions(&Extensions::new());
96+
97+
Python::with_gil(|py| {
98+
py_run!(
99+
py,
100+
ctx,
101+
r#"
102+
# Make sure we are preserving any modifications
103+
assert ctx.foo == 43
104+
assert ctx.bar == 'baz'
105+
"#
106+
);
107+
});
108+
109+
Ok(())
110+
}
111+
112+
#[test]
113+
fn works_with_none() -> PyResult<()> {
114+
// Users can set context to `None` by explicity or implicitly by not providing a custom context class,
115+
// it shouldn't be fail in that case.
116+
117+
pyo3::prepare_freethreaded_python();
118+
119+
let ctx = get_context("ctx = None");
120+
Python::with_gil(|py| {
121+
py_run!(py, ctx, "assert ctx is None");
122+
});
123+
124+
Ok(())
125+
}
126+
}

0 commit comments

Comments
 (0)