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

feat: initial code release #2

Merged
merged 18 commits into from
Mar 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,4 @@ Keep in mind only the **pull request title** will be used as the commit message

See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).

Thank you for reading through all of this, if you have any questions feel free to [reach us](../README.md#💜-reach-us)!
Thank you for reading through all of this, if you have any questions feel free to [reach us](../README.md#reach-us)!
14 changes: 2 additions & 12 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
test:
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
python-version: ["3.8", "3.9", "3.10", "3.11"]
thomas-tacquet marked this conversation as resolved.
Show resolved Hide resolved

runs-on: ubuntu-22.04
steps:
Expand All @@ -39,14 +39,4 @@ jobs:

- name: Test with pytest
working-directory: tests
run: poetry run pytest --junitxml=junit/test-results-${{ matrix.python-version
}}.xml

- name: Upload pytest report
uses: actions/upload-artifact@v3
with:
name: pytest-results-${{ matrix.python-version }}
path: tests/junit/test-results-${{ matrix.python-version }}.xml
retention-days: 3
# Use always() to always run this step to publish test results when there are test failures
if: ${{ always() }}
run: poetry run pytest
26 changes: 0 additions & 26 deletions .github/workflows/report.yml

This file was deleted.

10 changes: 6 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ repos:
additional_dependencies:
- tomli # for reading config from pyproject.toml
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.0.0
rev: v1.0.1
hooks:
- id: mypy
exclude: "^tests/" # See: https://github.com/pre-commit/mirrors-mypy/issues/1
args: [--ignore-missing-imports] # Needed because pre-commit runs mypy in a venv
additional_dependencies:
- typing_extensions
- repo: https://github.com/Lucas-C/pre-commit-hooks-bandit
cyclimse marked this conversation as resolved.
Show resolved Hide resolved
rev: v1.0.6
- repo: https://github.com/PyCQA/bandit
rev: 1.7.4
hooks:
- id: python-bandit-vulnerability-check
- id: bandit
args: [--skip, B101, --recursive, clumper]
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Serverless Functions Python 💜

This repo contains utilities for testing your Python functions for Scaleway Serverless Functions.
This repo contains utilities for testing your Python handlers for Scaleway Serverless Functions.
cyclimse marked this conversation as resolved.
Show resolved Hide resolved

## ⚙️ Quick Start

Expand Down Expand Up @@ -59,7 +59,7 @@ We welcome all contributions to our open-source projects, please see our [contri

Do not hesitate to raise issues and pull requests we will have a look at them.

## 💜 Reach Us
## 📭 Reach Us

We love feedback. Feel free to:

Expand Down
8 changes: 5 additions & 3 deletions examples/mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from serverless_functions_python import Context, Event, Response
# Doing a conditional import avoids the need to install the library
# when deploying the function
from scaleway_functions_python.framework.v1.hints import Context, Event, Response


def handler(event: "Event", context: "Context") -> "Response":
Expand All @@ -18,6 +20,6 @@ def handler(event: "Event", context: "Context") -> "Response":


if __name__ == "__main__":
from serverless_functions_python import serve_handler_locally
from scaleway_functions_python import local

serve_handler_locally(handler)
local.serve_handler(handler)
20 changes: 11 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[tool.poetry]
name = "serverless-functions-python"
name = "scaleway-functions-python"
version = "0.1.0"
description = "Framework to provide a good developer experience when writing Serverless Functions in Python."
description = "Utilities for testing your Python handlers for Scaleway Serverless Functions."
authors = ["Scaleway Serverless Team <[email protected]>"]

readme = "README.md"
Expand All @@ -27,10 +27,6 @@ classifiers = [
"Programming Language :: Python :: 3.11",
]

packages = [
{ include = "framework", from = "src" },
{ include = "testing", from = "src" },
]
include = ["CHANGELOG.md"]

[tool.poetry.dependencies]
Expand All @@ -56,7 +52,6 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

[tool.pylint]
Expand All @@ -65,15 +60,22 @@ disable = "missing-module-docstring"
# Commented Black formatted code.
max-line-length = 89
# Short and common names. e is commonly used for exceptions.
good-names = "i,fp,e"
good-names = "i,e"

[tool.pylint-per-file-ignores]
# Redfined outer name is for pytest fixtures
# Import aliases are prefered over unused imports or __all__
"__init__.py" = "useless-import-alias"
# Redefined outer name is for pytest fixtures
"/tests/" = "missing-class-docstring,missing-function-docstring,protected-access,redefined-outer-name"

[tool.isort]
profile = "black"

[tool.mypy]
python_version = "3.8"
strict = true
exclude = "tests"
cyclimse marked this conversation as resolved.
Show resolved Hide resolved

[tool.pydocstyle]
# Compatible with Sphinx
convention = "google"
Expand Down
3 changes: 3 additions & 0 deletions scaleway_functions_python/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import local as local
from .framework import v1 as v1
from .local.serving import serve_handler as serve_handler
1 change: 1 addition & 0 deletions scaleway_functions_python/framework/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import v1 as v1
1 change: 1 addition & 0 deletions scaleway_functions_python/framework/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import hints as hints
58 changes: 58 additions & 0 deletions scaleway_functions_python/framework/v1/hints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import typing as t

try:
from typing import NotRequired # type: ignore
except ImportError:
from typing_extensions import NotRequired


class RequestContext(t.TypedDict):
"""Request context that is sent in the http event."""

accountId: str
resourceId: str
stage: str
requestId: str
resourcePath: str
authorizer: t.Literal[None]
httpMethod: str
apiId: str


class Event(t.TypedDict):
"""Event dictionnary passed to the function."""

path: str
httpMethod: str
headers: t.Dict[str, str]
multiValueHeaders: t.Literal[None]
queryStringParameters: t.Dict[str, str]
multiValueQueryStringParameters: t.Literal[None]
pathParameters: t.Literal[None]
stageVariable: t.Dict[str, str]
requestContext: RequestContext
body: str
isBase64Encoded: NotRequired[t.Literal[True]]


class Context(t.TypedDict):
"""Context dictionnary passed to the function."""

memoryLimitInMb: int
functionName: str
functionVersion: str


class ResponseRecord(t.TypedDict, total=False):
"""Response dictionnary that the handler is expected to return."""

body: str
headers: t.Dict[str, str]
statusCode: int
isBase64Encoded: bool


# Type that the Serverless handler is expected to return
Response = t.Union[str, ResponseRecord]

Handler = t.Callable[[Event, Context], Response]
1 change: 1 addition & 0 deletions scaleway_functions_python/local/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .serving import serve_handler as serve_handler
13 changes: 13 additions & 0 deletions scaleway_functions_python/local/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from ..framework.v1.hints import Context, Handler


def format_context(handler: "Handler") -> "Context":
"""Formats the request context from the request."""
return {
"memoryLimitInMb": 128,
"functionName": handler.__name__,
"functionVersion": "",
}
46 changes: 46 additions & 0 deletions scaleway_functions_python/local/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import binascii
from base64 import b64decode
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from flask.wrappers import Request

from ..framework.v1.hints import Event, RequestContext


def _format_request_context(request: "Request") -> "RequestContext":
"""Format the request context from the request."""
return {
"accountId": "",
"resourceId": "",
"stage": "",
"requestId": "",
"resourcePath": "",
"authorizer": None,
"httpMethod": request.method,
"apiId": "",
}


def format_http_event(request: "Request") -> "Event":
"""Format the event from a generic http request."""
context = _format_request_context(request)
body = request.get_data(as_text=True)
event: "Event" = {
"path": request.path,
"httpMethod": request.method,
"headers": dict(request.headers.items()),
"multiValueHeaders": None,
"queryStringParameters": request.args.to_dict(),
"multiValueQueryStringParameters": None,
"pathParameters": None,
"stageVariable": {},
"requestContext": context,
"body": body,
} # type: ignore # NotRequired works with Pylance here but not mypy 1.0 (bug?)
try:
b64decode(body, validate=True).decode("utf-8")
event["isBase64Encoded"] = True
except (binascii.Error, UnicodeDecodeError):
pass
return event
38 changes: 38 additions & 0 deletions scaleway_functions_python/local/infra.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Utility module to inject provider-side headers."""

import uuid
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from flask.wrappers import Request, Response

from ..framework.v1.hints import Event


def inject_ingress_headers(request: "Request", event: "Event") -> None:
"""Inject headers for incoming requests.

..note::

Because WGSI request headers are immutable,
it's simpler to inject them into the event object directly.
"""
if not request.remote_addr:
raise RuntimeWarning("remote_addr is not set in the request")
headers = {
"Forwarded": f"for={request.remote_addr};proto=http",
"X-Forwarded-For": request.remote_addr,
"X-Envoy-External-Adrdress": request.remote_addr,
"X-Forwarded-Proto": "http",
# In this context "X-Forwared-For" == "X-Envoy-External-Address"
# this property doesn't hold for actual functions
"X-Envoy-External-Address": request.remote_addr,
"X-Request-Id": str(uuid.uuid4()),
}
# Not using |= to keep compatibility with python 3.8
event["headers"].update(**headers)


def inject_egress_headers(response: "Response") -> None:
"""Inject headers for outgoing requests."""
response.headers.add("server", "envoy")
Loading