diff --git a/python/hello/Procfile b/python/hello/Procfile deleted file mode 100644 index bb57f5a8..00000000 --- a/python/hello/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: python -m parliament . diff --git a/python/hello/README.md b/python/hello/README.md index 1bd6d50b..0b6aeab4 100644 --- a/python/hello/README.md +++ b/python/hello/README.md @@ -1,28 +1,73 @@ -# Python 'hello' template +# Python HTTP Function -Welcome to your new Python function project! The boilerplate function -code can be found in [`func.py`](./func.py). This function will -simply print 'Hello, World!' if a request is received successfuly. +## Introduction -## Endpoints +A Python HTTP function built with ASGI protocol support. The implementation +provides a simple HTTP endpoint that responds with "Hello World!" to incoming +requests. -Running this function will expose three endpoints. +The function is structured as a class-based implementation with proper lifecycle +management, including configurable startup and shutdown hooks. - * `/` The endpoint for your function. - * `/health/readiness` The endpoint for a readiness health check - * `/health/liveness` The endpoint for a liveness health check +## Recommended Deployment -The health checks can be accessed in your browser at -[http://localhost:8080/health/readiness]() and -[http://localhost:8080/health/liveness](). +> [!NOTE] +> We recommend using the host builder. +> This feature is currently behind a flag because its not available for all the +> languages yet so you will need to enable it. -You can use `func invoke` to send an HTTP request to the function endpoint. +```bash +# Enable the host builder +export FUNC_ENABLE_HOST_BUILDER=1 + +# Deploy your code to cluster +# Make sure to set the builder to use it +func deploy --builder=host + +# Local development and testing +func run --builder=host --container=false +``` + +## Customization + +- This function uses the ASGI (Asynchronous Server Gateway Interface) 3.0 +specification therefore its compatible with the signature `handle(scope, receive, send)` + +### Lifecycle Management +The function provides lifecycle hooks: +- **start()**: Initialization hook called when function instances are created +- **stop()**: Cleanup hook, ensuring graceful termination and resource cleanup +- **alive()**: Health check exposed at `/health/liveness` +- **ready()**: Readiness check exposed at `/health/readiness` ## Testing -This function project includes a [unit test](./test_func.py). Update this -as you add business logic to your function in order to test its behavior. +The function includes unit tests that verify the HTTP handler behavior. +Tests are located in the `tests/` directory and use pytest with asyncio support. + +To run the tests: -```console -python test_func.py +```bash +# Install dependencies (if not already installed) +pip install -e . + +# Run tests +pytest + +# Run tests with verbose output +pytest -v ``` + +### Test Structure + +Tests are organized in the `tests/` directory with the current test file +`test_func.py` verifying that the ASGI handler returns a proper 200 OK response. +The testing framework uses pytest with asyncio support, configured in `pyproject.toml`. + +### Writing New Tests + +To add new tests, create files named `test_*.py` in the `tests/` directory. +For async functions, use the `@pytest.mark.asyncio` decorator. Mock ASGI +components by creating mock `scope`, `receive`, and `send` functions as shown +in the existing test. You can also test lifecycle methods like `start()`, +`stop()`, `alive()`, and `ready()` by calling them directly on a function instance. diff --git a/python/hello/app.sh b/python/hello/app.sh deleted file mode 100755 index 4da37d4d..00000000 --- a/python/hello/app.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -exec python -m parliament "$(dirname "$0")" diff --git a/python/hello/func.py b/python/hello/func.py deleted file mode 100644 index cf250695..00000000 --- a/python/hello/func.py +++ /dev/null @@ -1,16 +0,0 @@ -from parliament import Context -import json - -def main(context: Context): - """ - Function template 'hello' -- prints "Received request" on server - and "Hello, World!" on client on success - """ - - # Add your business logic here - print("Received request") - if 'request' in context.keys(): - return "Hello, World!",200 - else: - print("Empty request", flush=True) - return "{}", 200 diff --git a/python/hello/function/__init__.py b/python/hello/function/__init__.py new file mode 100644 index 00000000..c16dbac2 --- /dev/null +++ b/python/hello/function/__init__.py @@ -0,0 +1 @@ +from .func import new diff --git a/python/hello/function/func.py b/python/hello/function/func.py new file mode 100644 index 00000000..e3b31302 --- /dev/null +++ b/python/hello/function/func.py @@ -0,0 +1,72 @@ +# Function +import logging + + +def new(): + """ New is the only method that must be implemented by a Function. + The instance returned can be of any name. + """ + return Function() + + +class Function: + def __init__(self): + """ The init method is an optional method where initialization can be + performed. See the start method for a startup hook which includes + configuration. + """ + + async def handle(self, scope, receive, send): + """ Handle all HTTP requests to this Function other than readiness + and liveness probes.""" + + logging.info("OK: Request Received") + + # echo the request to the calling client + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'content-type', b'text/plain'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': 'Hello World!'.encode(), + }) + + def start(self, cfg): + """ start is an optional method which is called when a new Function + instance is started, such as when scaling up or during an update. + Provided is a dictionary containing all environmental configuration. + Args: + cfg (Dict[str, str]): A dictionary containing environmental config. + In most cases this will be a copy of os.environ, but it is + best practice to use this cfg dict instead of os.environ. + """ + logging.info("Function starting") + + def stop(self): + """ stop is an optional method which is called when a function is + stopped, such as when scaled down, updated, or manually canceled. Stop + can block while performing function shutdown/cleanup operations. The + process will eventually be killed if this method blocks beyond the + platform's configured maximum studown timeout. + """ + logging.info("Function stopping") + + def alive(self): + """ alive is an optional method for performing a deep check on your + Function's liveness. If removed, the system will assume the function + is ready if the process is running. This is exposed by default at the + path /health/liveness. The optional string return is a message. + """ + return True, "Alive" + + def ready(self): + """ ready is an optional method for performing a deep check on your + Function's readiness. If removed, the system will assume the function + is ready if the process is running. This is exposed by default at the + path /health/rediness. + """ + return True, "Ready" diff --git a/python/hello/pyproject.toml b/python/hello/pyproject.toml new file mode 100644 index 00000000..749da18e --- /dev/null +++ b/python/hello/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "function" +description = "" +version = "0.1.0" +requires-python = ">=3.9" +readme = "README.md" +license = "MIT" +dependencies = [ + "httpx", + "pytest", + "pytest-asyncio" +] +authors = [ + { name="Your Name", email="you@example.com"}, +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" + diff --git a/python/hello/requirements.txt b/python/hello/requirements.txt deleted file mode 100644 index 0229b309..00000000 --- a/python/hello/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -parliament-functions==0.1.0 diff --git a/python/hello/test_func.py b/python/hello/test_func.py deleted file mode 100644 index 83a6adf1..00000000 --- a/python/hello/test_func.py +++ /dev/null @@ -1,13 +0,0 @@ -import unittest - -func = __import__("func") - -class TestFunc(unittest.TestCase): - - def test_func_empty_request(self): - resp, code = func.main({}) - self.assertEqual(resp, "{}") - self.assertEqual(code, 200) - -if __name__ == "__main__": - unittest.main() diff --git a/python/hello/tests/__pycache__/test_func.cpython-313-pytest-8.4.1.pyc b/python/hello/tests/__pycache__/test_func.cpython-313-pytest-8.4.1.pyc new file mode 100644 index 00000000..da6a6a5f Binary files /dev/null and b/python/hello/tests/__pycache__/test_func.cpython-313-pytest-8.4.1.pyc differ diff --git a/python/hello/tests/test_func.py b/python/hello/tests/test_func.py new file mode 100644 index 00000000..5b37a735 --- /dev/null +++ b/python/hello/tests/test_func.py @@ -0,0 +1,38 @@ +""" +An example set of unit tests which confirm that the main handler (the +callable function) returns 200 OK for a simple HTTP GET. +""" +import pytest +from function import new + + +@pytest.mark.asyncio +async def test_function_handle(): + f = new() # Instantiate Function to Test + + sent_ok = False + sent_headers = False + sent_body = False + + # Mock Send + async def send(message): + nonlocal sent_ok + nonlocal sent_headers + nonlocal sent_body + + if message.get('status') == 200: + sent_ok = True + + if message.get('type') == 'http.response.start': + sent_headers = True + + if message.get('type') == 'http.response.body': + sent_body = True + + # Invoke the Function + await f.handle({}, {}, send) + + # Assert send was called + assert sent_ok, "Function did not send a 200 OK" + assert sent_headers, "Function did not send headers" + assert sent_body, "Function did not send a body"