diff --git a/README.md b/README.md index 28c7b58..8a956c0 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,20 @@ def hello_world(event, context): The configuration is done by passing arguments to the decorator. To view which arguments are supported, head over to this [documentation](https://serverless-api-framework-python.readthedocs.io/) page. +### Local testing + +Before deploying, you can run your functions locally with the dev command: + +```console +scw-serverless dev app.py +``` + +This runs a local Flask server with your Serverless handlers. If no `relative_url` is defined for a function, it will be served on `/name` with `name` being the name of your Python function. + +By default, this runs Flask in debug mode which includes hot-reloading. You can disable this behavior by passing the `--no-debug` flag. + +### Deploying + When you are ready, you can deploy your function with the `scw-serverless` CLI tool: ```console diff --git a/docs/source/deploying.rst b/docs/source/deploying.rst index 8198dc6..f0783cc 100644 --- a/docs/source/deploying.rst +++ b/docs/source/deploying.rst @@ -1,7 +1,30 @@ Deploying ========= -After writing your functions, the included CLI tool `scw-serverless` helps deploy your application on Scaleway. +After writing your functions, the included CLI tool `scw-serverless` helps test and deploy your application on Scaleway. + +Running locally +--------------- + +You can test your functions locally before deploying with the `dev` command: + +.. code-block:: console + + scw-serverless dev app.py + +This will start a local Flask server with your functions that will behave similarly to Scaleway Functions. + +By default, functions are served by `/name` on port 8080 with the name being the name of the Python function. + +You can then use your favorite tools to query the functions: + +.. code-block:: console + + # For a function named def handle()... + curl http://localhost:8080/handle + +This command allows you to test your code, but as this test environment is not quite the same as Scaleway Functions, +there might be slight differences when deploying. Deploy ------ diff --git a/examples/hello_world/handler.py b/examples/hello_world/handler.py index 0a1ccb5..6e8bfe6 100644 --- a/examples/hello_world/handler.py +++ b/examples/hello_world/handler.py @@ -13,4 +13,4 @@ def handle(_event: dict[str, Any], _content: dict[str, Any]) -> dict[str, Any]: context (dict): function call metadata """ - return {"message": "Hello from Scaleway functions using Serverless API Framework"} + return {"body": "Hello from Scaleway functions using Serverless API Framework"} diff --git a/poetry.lock b/poetry.lock index da60392..84531ec 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "alabaster" @@ -44,20 +44,32 @@ files = [ {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, ] +[[package]] +name = "blinker" +version = "1.6.2" +description = "Fast, simple object-to-object and broadcast signaling" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "blinker-1.6.2-py3-none-any.whl", hash = "sha256:c3d739772abb7bc2860abf5f2ec284223d9ad5c76da018234f6f50d6f31ab1f0"}, + {file = "blinker-1.6.2.tar.gz", hash = "sha256:4afd3de66ef3a9f8067559fb7a1cbe555c17dcbe15971b05d1b625c3e7abe213"}, +] + [[package]] name = "boto3" -version = "1.26.130" +version = "1.26.131" description = "The AWS SDK for Python" category = "dev" optional = false python-versions = ">= 3.7" files = [ - {file = "boto3-1.26.130-py3-none-any.whl", hash = "sha256:d6f9c6ebf417260ea5fa7a227e7bce9451f1f5010be05ac4075596356f584455"}, - {file = "boto3-1.26.130.tar.gz", hash = "sha256:3ae2b34921bb08a1d7ce52db9ec1a25159fca779648e596ede37e1049ed77de8"}, + {file = "boto3-1.26.131-py3-none-any.whl", hash = "sha256:5b2b13d9f3430e3d5e768bf32097d5d6d16f47a4719f2656de67da49dd3e4de1"}, + {file = "boto3-1.26.131.tar.gz", hash = "sha256:061d3270472b9be09901bb08a45e9871ac8f86a9b1c9c615535ca0223acd7582"}, ] [package.dependencies] -botocore = ">=1.29.130,<1.30.0" +botocore = ">=1.29.131,<1.30.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.6.0,<0.7.0" @@ -66,14 +78,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.29.130" +version = "1.29.131" description = "Low-level, data-driven core of boto 3." category = "dev" optional = false python-versions = ">= 3.7" files = [ - {file = "botocore-1.29.130-py3-none-any.whl", hash = "sha256:56d1f54c3f8e140f965e5300d1cc5b565cb758134d9213fb05e91e1bb160330e"}, - {file = "botocore-1.29.130.tar.gz", hash = "sha256:3a31293b84ecfe5f5f2c4b7dc85a77d7b890b468a376b593fde15cacc76862dd"}, + {file = "botocore-1.29.131-py3-none-any.whl", hash = "sha256:d0dea23bccdfd7c2f6d0cd3216cfbd7065bc3e9e7b1ef6fee0952b04f5d2cffd"}, + {file = "botocore-1.29.131.tar.gz", hash = "sha256:ffbd85915b2624c545438a33c2624a809593720a10648f6e757fe50be4893188"}, ] [package.dependencies] @@ -86,14 +98,14 @@ crt = ["awscrt (==0.16.9)"] [[package]] name = "certifi" -version = "2022.12.7" +version = "2023.5.7" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, + {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, ] [[package]] @@ -305,6 +317,30 @@ files = [ docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +[[package]] +name = "flask" +version = "2.3.2" +description = "A simple framework for building complex web applications." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "Flask-2.3.2-py3-none-any.whl", hash = "sha256:77fd4e1249d8c9923de34907236b747ced06e5467ecac1a7bb7115ae0e9670b0"}, + {file = "Flask-2.3.2.tar.gz", hash = "sha256:8c2f9abd47a9e8df7f0c3f091ce9497d011dc3b31effcf4c85a6e2b50f4114ef"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=2.3.3" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + [[package]] name = "identify" version = "2.5.24" @@ -348,7 +384,7 @@ files = [ name = "importlib-metadata" version = "6.6.0" description = "Read metadata from Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -394,11 +430,23 @@ pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib" plugins = ["setuptools"] requirements-deprecated-finder = ["pip-api", "pipreqs"] +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + [[package]] name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." -category = "dev" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -499,7 +547,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.1.2" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -966,6 +1014,22 @@ python-dateutil = ">=2.8.2,<3.0.0" PyYAML = ">=6.0,<7.0" requests = ">=2.28.1,<3.0.0" +[[package]] +name = "scaleway-functions-python" +version = "0.2.0" +description = "Utilities for testing your Python handlers for Scaleway Serverless Functions." +category = "main" +optional = false +python-versions = ">=3.8.1,<3.12" +files = [ + {file = "scaleway_functions_python-0.2.0-py3-none-any.whl", hash = "sha256:8c069cde434d0b981905f81fcf83404b1c715a475bbdac0005dfcdbd83005fc7"}, + {file = "scaleway_functions_python-0.2.0.tar.gz", hash = "sha256:58248385fd6dabe34a61cfb014d6187f1c2e4c314e2b15275787b6b77c50d804"}, +] + +[package.dependencies] +flask = ">=2.2.2,<3.0.0" +typing-extensions = {version = ">=4.4.0,<5.0.0", markers = "python_version < \"3.11\""} + [[package]] name = "setuptools" version = "67.7.2" @@ -1259,6 +1323,24 @@ platformdirs = ">=3.2,<4" docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9)"] +[[package]] +name = "werkzeug" +version = "2.3.4" +description = "The comprehensive WSGI web application library." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "Werkzeug-2.3.4-py3-none-any.whl", hash = "sha256:48e5e61472fee0ddee27ebad085614ebedb7af41e88f687aaf881afb723a162f"}, + {file = "Werkzeug-2.3.4.tar.gz", hash = "sha256:1d5a58e0377d1fe39d061a5de4469e414e78ccb1e1e59c0f5ad6fa1c36c52b76"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [[package]] name = "wrapt" version = "1.15.0" @@ -1348,7 +1430,7 @@ files = [ name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1362,5 +1444,5 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "53d7e9d97e1332183478de718f191bdc7c075ef709f05b6c382faaedbad0bc47" +python-versions = ">=3.9,<3.12" +content-hash = "ce9ca33d2e6393dde0b8addf5d7c6e3cfff0cddf98ae6ea2e5240b600e18fa68" diff --git a/pyproject.toml b/pyproject.toml index ea20b54..0372a53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,9 +33,10 @@ include = ["CHANGELOG.md"] scw-serverless = "scw_serverless.cli:cli" [tool.poetry.dependencies] -python = "^3.9" +python = ">=3.9,<3.12" click = "^8.1.3" scaleway = ">=0.7,<0.13" +scaleway-functions-python = "^0.2.0" requests = "^2.28.2" typing-extensions = { version = "^4.4.0", python = "<3.11" } diff --git a/scw_serverless/cli.py b/scw_serverless/cli.py index 9468283..9e5846c 100644 --- a/scw_serverless/cli.py +++ b/scw_serverless/cli.py @@ -5,7 +5,8 @@ import click from scaleway import ScalewayException -from scw_serverless import deployment, loader, logger +import scw_serverless +from scw_serverless import app, deployment, loader, local_app, logger from scw_serverless.dependencies_manager import DependenciesManager from scw_serverless.gateway.gateway_manager import GatewayManager @@ -145,3 +146,30 @@ def deploy( sdk_client=client, ) manager.update_routes() + + +@cli.command() +@CLICK_ARG_FILE +@click.option( + "--port", + "-p", + "port", + default=8080, + show_default=True, + help="Set port to listen on.", +) +@click.option( + "--debug/--no-debug", + "debug", + default=True, + show_default=True, + help="Run Flask in debug mode.", +) +def dev(file: Path, port: int, debug: bool) -> None: + """Run functions locally with Serverless Local Testing.""" + app.Serverless = local_app.ServerlessLocal + scw_serverless.Serverless = local_app.ServerlessLocal + app_instance = cast( + local_app.ServerlessLocal, loader.load_app_instance(file.resolve()) + ) + app_instance.local_server.serve(port=port, debug=debug) diff --git a/scw_serverless/config/function.py b/scw_serverless/config/function.py index 3e38574..08dd9fa 100644 --- a/scw_serverless/config/function.py +++ b/scw_serverless/config/function.py @@ -57,7 +57,7 @@ class Function: """Representation of a Scaleway function.""" name: str - handler: str # Path to the handler + handler_path: str environment_variables: Optional[dict[str, str]] = None min_scale: Optional[int] = None max_scale: Optional[int] = None @@ -85,7 +85,7 @@ def from_handler( gateway_route = GatewayRoute(url, http_methods=args.get("http_methods")) return Function( name=to_valid_function_name(handler.__name__), - handler=module_to_path(handler.__module__) + "." + handler.__name__, + handler_path=module_to_path(handler.__module__) + "." + handler.__name__, environment_variables=args.get("env"), min_scale=args.get("min_scale"), max_scale=args.get("max_scale"), diff --git a/scw_serverless/deployment/api_wrapper.py b/scw_serverless/deployment/api_wrapper.py index 3a3cc97..4987b66 100644 --- a/scw_serverless/deployment/api_wrapper.py +++ b/scw_serverless/deployment/api_wrapper.py @@ -89,7 +89,7 @@ def create_function( max_scale=function.max_scale, memory_limit=function.memory_limit, timeout=function.timeout, - handler=function.handler, + handler=function.handler_path, description=function.description, secret_environment_variables=self._get_secrets_from_dict( function.secret_environment_variables @@ -110,7 +110,7 @@ def update_function( max_scale=function.max_scale, memory_limit=function.memory_limit, timeout=function.timeout, - handler=function.handler, + handler=function.handler_path, description=function.description, secret_environment_variables=self._get_secrets_from_dict( function.secret_environment_variables diff --git a/scw_serverless/local_app.py b/scw_serverless/local_app.py new file mode 100644 index 0000000..ebaf767 --- /dev/null +++ b/scw_serverless/local_app.py @@ -0,0 +1,47 @@ +from typing import Any, Callable + +from scaleway_functions_python import local + +from scw_serverless.app import Serverless +from scw_serverless.config.function import FunctionKwargs + +try: + from typing import Unpack +except ImportError: + from typing_extensions import Unpack +# pylint: disable=wrong-import-position # Conditional import considered a statement + + +class ServerlessLocal(Serverless): + """Serverless class that is used when testing locally. + + Crate a local testing framework server and inject the handlers to it. + """ + + def __init__( + self, + service_name: str, + env: dict[str, Any] | None = None, + secret: dict[str, Any] | None = None, + ): + super().__init__(service_name, env, secret) + self.local_server = local.LocalFunctionServer() + + def func( + self, + **kwargs: "Unpack[FunctionKwargs]", + ) -> Callable: + decorator = super().func(**kwargs) + + def _decorator(handler: Callable): + decorator(handler) + http_methods = None + if methods := kwargs.get("http_methods"): + http_methods = [method.value for method in methods] + self.local_server.add_handler( + handler=handler, + relative_url=kwargs.get("relative_url"), + http_methods=http_methods, + ) + + return _decorator diff --git a/tests/integrations/utils.py b/tests/integrations/utils.py index d7d3b06..329be20 100644 --- a/tests/integrations/utils.py +++ b/tests/integrations/utils.py @@ -76,7 +76,7 @@ def trigger_function(domain_name: str, max_retries: int = 10) -> requests.Respon def wait_for_body_text( - domain_name: str, body: str, max_retries: int = 10 + domain_name: str, body: str, max_retries: int = 20 ) -> requests.Response: last_body = None for _ in range(max_retries): diff --git a/tests/test_deployment/test_deployment_manager.py b/tests/test_deployment/test_deployment_manager.py index 8eb8246..74ac6d0 100644 --- a/tests/test_deployment/test_deployment_manager.py +++ b/tests/test_deployment/test_deployment_manager.py @@ -53,7 +53,7 @@ def get_test_backend() -> DeploymentManager: def test_scaleway_api_backend_deploy_function(mocked_responses: responses.RequestsMock): function = Function( name="test-function", - handler="handler", + handler_path="handler", ) backend = get_test_backend() backend.app_instance.functions = [function] @@ -137,7 +137,7 @@ def test_scaleway_api_backend_deploy_function_with_trigger( trigger = CronTrigger(schedule="* * * * * *", name="test-cron", args={"foo": "bar"}) function = Function( name="test-function-with-trigger", - handler="handler", + handler_path="handler", triggers=[trigger], ) diff --git a/tests/test_gateway/test_gateway_manager.py b/tests/test_gateway/test_gateway_manager.py index bc94539..81b813c 100644 --- a/tests/test_gateway/test_gateway_manager.py +++ b/tests/test_gateway/test_gateway_manager.py @@ -42,7 +42,7 @@ def test_gateway_manager_update_routes( ): function = Function( name="test-function", - handler="handler", + handler_path="handler", gateway_route=GatewayRoute( relative_url="/hello", http_methods=[HTTPMethod.GET] ),