ucam-faas 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.1
2
+ Name: ucam-faas
3
+ Version: 0.1.2
4
+ Summary: Opinionated FaaS support framework extending Google's functions-framework
5
+ Home-page: https://gitlab.developers.cam.ac.uk/uis/devops/ucam-faas-python/ucam-faas
6
+ License: MIT
7
+ Author: University of Cambridge Information Services
8
+ Author-email: devops-wilson@uis.cam.ac.uk
9
+ Requires-Python: >=3.9,<4.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Provides-Extra: testing
20
+ Requires-Dist: click (>=8.1.7,<9.0.0)
21
+ Requires-Dist: cloudevents (>=1.10.1,<2.0.0)
22
+ Requires-Dist: flask (>=3.0.3,<4.0.0)
23
+ Requires-Dist: functions-framework (>=3.5.0,<4.0.0)
24
+ Requires-Dist: gunicorn (>=22.0.0,<23.0.0)
25
+ Requires-Dist: pytest (>=8.1.1,<9.0.0) ; extra == "testing"
26
+ Requires-Dist: requests (>=2.31.0,<3.0.0)
27
+ Requires-Dist: structlog (>=24.1.0,<25.0.0)
28
+ Project-URL: Repository, https://gitlab.developers.cam.ac.uk/uis/devops/ucam-faas-python/ucam-faas
29
+ Description-Content-Type: text/markdown
30
+
31
+ # UCam FaaS Library
32
+
33
+ This project contains a support library and base Docker image to be used to
34
+ create Function as a Service (FaaS) applications intended to be deployed to a
35
+ GCP cloud run environment.
36
+
37
+ It is highly opinionated and non-configurable by design.
38
+
39
+ ## Usage
40
+
41
+ Install the library via pip:
42
+
43
+ ```console
44
+ pip install ucam-faas
45
+ ```
46
+
47
+ Install the library with testing support:
48
+
49
+ ```console
50
+ pip install ucam-faas[testing]
51
+ ```
52
+
53
+ The library provides a decorator to create a runnable application from a single
54
+ function. The function must accept a dictionary as an argument, and return
55
+ either a string or a dictionary as a response:
56
+
57
+ ```python
58
+ # main.py
59
+ from ucam_faas import event_handler
60
+
61
+
62
+ @event_handler
63
+ def say_hello(event):
64
+ return "hello!"
65
+ ```
66
+
67
+ This can then be run as a FaaS app using:
68
+
69
+ ```console
70
+ ucam-faas --debug --target say_hello
71
+ ```
72
+
73
+ ### Testing FaaS Functions
74
+
75
+ To unit test FaaS functions there are two available approaches. Firstly, the
76
+ unwrapped version of the function can be directly accessed. This is recommended
77
+ as the primary way to test FaaS functions and requires no additional
78
+ configuration:
79
+
80
+ ```python
81
+ # test_my_event_handler.py
82
+ from main import say_hello
83
+
84
+ def test_say_hello():
85
+ assert say_hello.__wrapped__({"event_key": "event_value"}) == "hello!"
86
+ ```
87
+
88
+ The original function version is made available through the `__wrapped__`
89
+ variable.
90
+
91
+ Alternatively, if required, a support testing client can be used to instantiate
92
+ a version of the web application running the function. To do this the extra
93
+ "testing" must also be installed:
94
+
95
+ ```shell
96
+ pip install ucam-faas[testing]
97
+ ```
98
+
99
+ Then tests can register the provided `pytest` fixture and use it in tests:
100
+
101
+ ```python
102
+ # test_my_event_handler.py
103
+ pytest_plugins = ["ucam_faas.testing"]
104
+
105
+ def test_say_hello(event_app_client):
106
+ # Provide the target function for the test webapp
107
+ eac = event_app_client("say_hello")
108
+ response = eac.get("/")
109
+ assert response.status_code == 200
110
+ ```
111
+
112
+ Note that with this approach it is not necessary to import the function under
113
+ test, it is discovered and imported during the test webapp setup.
114
+
115
+ ### Example
116
+
117
+ An example application and example tests can be found in this repository in the
118
+ `example` directory.
119
+
120
+ Note that the example dockerfile uses a relative file `FROM` - this means it
121
+ must be built in the context of its parent directory:
122
+
123
+ ```console
124
+ docker build -f example/Dockerfile .
125
+ ```
126
+
127
+ ## Local Development
128
+
129
+ Install poetry via:
130
+
131
+ ```console
132
+ pipx install poetry
133
+ pipx inject poetry poethepoet[poetry_plugin]
134
+ ```
135
+
136
+ Install dependencies via:
137
+
138
+ ```console
139
+ poetry install
140
+ ```
141
+
142
+ Build the library via:
143
+
144
+ ```console
145
+ poetry build
146
+ ```
147
+
148
+ Run the example application via:
149
+
150
+ ```console
151
+ poetry poe example
152
+ ```
153
+
154
+ Run the tests via:
155
+
156
+ ```console
157
+ poetry poe tox
158
+ ```
159
+
160
+ Note that the tests are found under the example directory, there are *currently*
161
+ no tests in the root library as the code is predominantly configuration and
162
+ setup, and example testing has been deemed sufficient.
163
+
164
+ ### Dependencies
165
+
166
+ > **IMPORTANT:** if you add a new dependency to the application as described
167
+ > below you will need to run `docker compose build` or add `--build` to the
168
+ > `docker compose run` and/or `docker compose up` command at least once for
169
+ > changes to take effect when running code inside containers. The poe tasks have
170
+ > already got `--build` appended to the command line.
171
+
172
+ To add a new dependency _for the application itself_:
173
+
174
+ ```console
175
+ poetry add {dependency}
176
+ ```
177
+
178
+ To add a new development-time dependency _used only when the application is
179
+ running locally in development or in testing_:
180
+
181
+ ```console
182
+ poetry add -G dev {dependency}
183
+ ```
184
+
185
+ To remove a dependency which is no longer needed:
186
+
187
+ ```console
188
+ poetry remove {dependency}
189
+ ```
190
+
191
+ ## CI configuration
192
+
193
+ The project is configured with Gitlab AutoDevOps via Gitlab CI using the .gitlab-ci.yml file.
194
+
@@ -0,0 +1,163 @@
1
+ # UCam FaaS Library
2
+
3
+ This project contains a support library and base Docker image to be used to
4
+ create Function as a Service (FaaS) applications intended to be deployed to a
5
+ GCP cloud run environment.
6
+
7
+ It is highly opinionated and non-configurable by design.
8
+
9
+ ## Usage
10
+
11
+ Install the library via pip:
12
+
13
+ ```console
14
+ pip install ucam-faas
15
+ ```
16
+
17
+ Install the library with testing support:
18
+
19
+ ```console
20
+ pip install ucam-faas[testing]
21
+ ```
22
+
23
+ The library provides a decorator to create a runnable application from a single
24
+ function. The function must accept a dictionary as an argument, and return
25
+ either a string or a dictionary as a response:
26
+
27
+ ```python
28
+ # main.py
29
+ from ucam_faas import event_handler
30
+
31
+
32
+ @event_handler
33
+ def say_hello(event):
34
+ return "hello!"
35
+ ```
36
+
37
+ This can then be run as a FaaS app using:
38
+
39
+ ```console
40
+ ucam-faas --debug --target say_hello
41
+ ```
42
+
43
+ ### Testing FaaS Functions
44
+
45
+ To unit test FaaS functions there are two available approaches. Firstly, the
46
+ unwrapped version of the function can be directly accessed. This is recommended
47
+ as the primary way to test FaaS functions and requires no additional
48
+ configuration:
49
+
50
+ ```python
51
+ # test_my_event_handler.py
52
+ from main import say_hello
53
+
54
+ def test_say_hello():
55
+ assert say_hello.__wrapped__({"event_key": "event_value"}) == "hello!"
56
+ ```
57
+
58
+ The original function version is made available through the `__wrapped__`
59
+ variable.
60
+
61
+ Alternatively, if required, a support testing client can be used to instantiate
62
+ a version of the web application running the function. To do this the extra
63
+ "testing" must also be installed:
64
+
65
+ ```shell
66
+ pip install ucam-faas[testing]
67
+ ```
68
+
69
+ Then tests can register the provided `pytest` fixture and use it in tests:
70
+
71
+ ```python
72
+ # test_my_event_handler.py
73
+ pytest_plugins = ["ucam_faas.testing"]
74
+
75
+ def test_say_hello(event_app_client):
76
+ # Provide the target function for the test webapp
77
+ eac = event_app_client("say_hello")
78
+ response = eac.get("/")
79
+ assert response.status_code == 200
80
+ ```
81
+
82
+ Note that with this approach it is not necessary to import the function under
83
+ test, it is discovered and imported during the test webapp setup.
84
+
85
+ ### Example
86
+
87
+ An example application and example tests can be found in this repository in the
88
+ `example` directory.
89
+
90
+ Note that the example dockerfile uses a relative file `FROM` - this means it
91
+ must be built in the context of its parent directory:
92
+
93
+ ```console
94
+ docker build -f example/Dockerfile .
95
+ ```
96
+
97
+ ## Local Development
98
+
99
+ Install poetry via:
100
+
101
+ ```console
102
+ pipx install poetry
103
+ pipx inject poetry poethepoet[poetry_plugin]
104
+ ```
105
+
106
+ Install dependencies via:
107
+
108
+ ```console
109
+ poetry install
110
+ ```
111
+
112
+ Build the library via:
113
+
114
+ ```console
115
+ poetry build
116
+ ```
117
+
118
+ Run the example application via:
119
+
120
+ ```console
121
+ poetry poe example
122
+ ```
123
+
124
+ Run the tests via:
125
+
126
+ ```console
127
+ poetry poe tox
128
+ ```
129
+
130
+ Note that the tests are found under the example directory, there are *currently*
131
+ no tests in the root library as the code is predominantly configuration and
132
+ setup, and example testing has been deemed sufficient.
133
+
134
+ ### Dependencies
135
+
136
+ > **IMPORTANT:** if you add a new dependency to the application as described
137
+ > below you will need to run `docker compose build` or add `--build` to the
138
+ > `docker compose run` and/or `docker compose up` command at least once for
139
+ > changes to take effect when running code inside containers. The poe tasks have
140
+ > already got `--build` appended to the command line.
141
+
142
+ To add a new dependency _for the application itself_:
143
+
144
+ ```console
145
+ poetry add {dependency}
146
+ ```
147
+
148
+ To add a new development-time dependency _used only when the application is
149
+ running locally in development or in testing_:
150
+
151
+ ```console
152
+ poetry add -G dev {dependency}
153
+ ```
154
+
155
+ To remove a dependency which is no longer needed:
156
+
157
+ ```console
158
+ poetry remove {dependency}
159
+ ```
160
+
161
+ ## CI configuration
162
+
163
+ The project is configured with Gitlab AutoDevOps via Gitlab CI using the .gitlab-ci.yml file.
@@ -0,0 +1,105 @@
1
+ [tool.poetry]
2
+ name = "ucam-faas"
3
+ version = "0.1.2"
4
+ description = "Opinionated FaaS support framework extending Google's functions-framework"
5
+ authors = ["University of Cambridge Information Services <devops-wilson@uis.cam.ac.uk>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ repository = "https://gitlab.developers.cam.ac.uk/uis/devops/ucam-faas-python/ucam-faas"
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "License :: OSI Approved :: MIT License",
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+
18
+ [tool.poetry.scripts]
19
+ ucam-faas = "ucam_faas:_cli"
20
+
21
+ [tool.poe.tasks.example]
22
+ help = "Run the example application"
23
+ cmd = "docker compose up --build"
24
+ env = { COMPOSE_PROFILES = "example" }
25
+
26
+ [tool.poe.tasks.fix]
27
+ help = "Run pre-commit checks to fix formatting errors"
28
+ cmd = "pre-commit run --all-files"
29
+
30
+ [tool.poe.tasks.tox]
31
+ help = "Run the Python test suite via tox"
32
+ cmd = "docker compose run --build --rm tox"
33
+
34
+ [tool.poe.tasks.down]
35
+ help = "Stop any running containers"
36
+ cmd = "docker compose down"
37
+ env = { COMPOSE_PROFILES = "example,tox" }
38
+
39
+ [tool.poe.tasks."compose:build"]
40
+ help = "Build or rebuild all container images"
41
+ cmd = "docker compose build"
42
+ env = { COMPOSE_PROFILES = "example,tox" }
43
+
44
+ [tool.poe.tasks."compose:pull"]
45
+ help = "Pull any upstream container images"
46
+ cmd = "docker compose pull --ignore-buildable --ignore-pull-failures"
47
+ env = { COMPOSE_PROFILES = "example,tox" }
48
+
49
+ [tool.poe.tasks."tox:local"]
50
+ help = "Run the Python test suite via tox using the locally installed Python version"
51
+ cmd = "tox"
52
+
53
+ [tool.poe.tasks."pytest:local"]
54
+ help = "Run the Python test suite via pytest using the locally installed Python version"
55
+ cmd = "pytest example/tests.py"
56
+
57
+ [tool.poetry.dependencies]
58
+ python = "^3.9"
59
+ requests = "^2.31.0"
60
+ structlog = "^24.1.0"
61
+ functions-framework = "^3.5.0"
62
+ click = "^8.1.7"
63
+ gunicorn = "^22.0.0"
64
+ flask = "^3.0.3"
65
+ pytest = {version = "^8.1.1", optional = true}
66
+ cloudevents = "^1.10.1"
67
+
68
+ [tool.poetry.group.dev.dependencies]
69
+ pytest-cov = "^4.1.0"
70
+ pre-commit = "^3.6.2"
71
+ tox = "^4.14.2"
72
+
73
+ [tool.poetry.extras]
74
+ testing = ["pytest"]
75
+
76
+ [build-system]
77
+ requires = ["poetry-core"]
78
+ build-backend = "poetry.core.masonry.api"
79
+
80
+ [tool.mypy]
81
+ ignore_missing_imports = true
82
+
83
+ [tool.black]
84
+ line-length = 99
85
+
86
+ [tool.coverage.run]
87
+ omit= [
88
+ ".tox/*",
89
+ "setup.py",
90
+ "manage.py",
91
+ "gunicorn.conf.py",
92
+ "example/*",
93
+ "*/test/*",
94
+ "*/tests/*",
95
+ ]
96
+
97
+ [tool.isort]
98
+ profile = "black"
99
+
100
+ [tool.pytest.ini_options]
101
+ testpaths = [
102
+ "example/test.py",
103
+ "**/tests/*.py",
104
+ "**/test/*.py",
105
+ ]
@@ -0,0 +1,187 @@
1
+ import logging
2
+ import logging.config
3
+ import os.path
4
+
5
+ import click
6
+ import flask
7
+ import functions_framework
8
+ import gunicorn.app.base
9
+ import structlog
10
+ from cloudevents.http.event import CloudEvent
11
+ from gunicorn.config import get_default_config_file
12
+
13
+
14
+ def raw_event(function):
15
+ def _raw_event_internal(request: flask.Request) -> flask.typing.ResponseReturnValue:
16
+ return function(request.data)
17
+
18
+ _raw_event_internal.__name__ = function.__name__
19
+ _raw_event_internal = functions_framework.http(_raw_event_internal)
20
+
21
+ _raw_event_internal.__wrapped__ = function
22
+ return _raw_event_internal
23
+
24
+
25
+ def cloud_event(function):
26
+ def _cloud_event_internal(event: CloudEvent) -> None:
27
+ return function(event.data)
28
+
29
+ _cloud_event_internal.__name__ = function.__name__
30
+ _cloud_event_internal = functions_framework.cloud_event(_cloud_event_internal)
31
+
32
+ _cloud_event_internal.__wrapped__ = function
33
+ return _cloud_event_internal
34
+
35
+
36
+ class FaaSGunicornApplication(gunicorn.app.base.Application):
37
+ _BASE_CONFIG = "/etc/gunicorn.conf.py"
38
+
39
+ def __init__(self, app, host, port):
40
+ self.host = host
41
+ self.port = port
42
+ self.app = app
43
+
44
+ super().__init__()
45
+
46
+ def load_config(self):
47
+ if os.path.isfile(self._BASE_CONFIG):
48
+ self.load_config_from_file(self._BASE_CONFIG)
49
+
50
+ default_config = get_default_config_file()
51
+ if default_config is not None:
52
+ self.load_config_from_file(default_config)
53
+
54
+ self.cfg.set("bind", f"{self.host}:{self.port}")
55
+
56
+ def load(self):
57
+ return self.app
58
+
59
+
60
+ def _add_log_severity(logger, method_name, event_dict): # pragma: no cover
61
+ """
62
+ Add the log level to the event dict under the "severity" key.
63
+
64
+ This is used as a structlog log processor, and is necessary as severity is used by GCP instead
65
+ of level.
66
+
67
+ Based on the structlog.stdlib.add_log_level processor.
68
+ """
69
+ if method_name == "warn":
70
+ method_name = "warning"
71
+ event_dict["severity"] = method_name
72
+ return event_dict
73
+
74
+
75
+ def _configure_structlog(): # pragma: no cover
76
+ """
77
+ Internal function to configure structlog with a standard set of options.
78
+ """
79
+ structlog.configure(
80
+ processors=[
81
+ structlog.contextvars.merge_contextvars,
82
+ structlog.stdlib.filter_by_level,
83
+ structlog.processors.TimeStamper(fmt="iso"),
84
+ structlog.stdlib.add_logger_name,
85
+ _add_log_severity,
86
+ structlog.stdlib.PositionalArgumentsFormatter(),
87
+ structlog.processors.StackInfoRenderer(),
88
+ structlog.processors.format_exc_info,
89
+ structlog.processors.UnicodeDecoder(),
90
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
91
+ ],
92
+ logger_factory=structlog.stdlib.LoggerFactory(),
93
+ cache_logger_on_first_use=True,
94
+ )
95
+
96
+
97
+ def _configure_logging(debug): # pragma: no cover
98
+ """
99
+ Internal function to configure python logging with standard options, and integrate with
100
+ structlog.
101
+ """
102
+ structlog_foreign_pre_chain = [
103
+ structlog.stdlib.add_log_level,
104
+ structlog.stdlib.add_logger_name,
105
+ structlog.processors.TimeStamper(fmt="iso"),
106
+ ]
107
+
108
+ logging.config.dictConfig(
109
+ {
110
+ "version": 1,
111
+ "disable_existing_loggers": True,
112
+ "formatters": {
113
+ # This formatter logs as structured JSON suitable for use in Cloud hosting
114
+ # environments.
115
+ "json_formatter": {
116
+ "()": structlog.stdlib.ProcessorFormatter,
117
+ "processor": structlog.processors.JSONRenderer(),
118
+ "foreign_pre_chain": structlog_foreign_pre_chain,
119
+ },
120
+ # This formatter logs as coloured text suitable for use by humans.
121
+ "console_formatter": {
122
+ "()": structlog.stdlib.ProcessorFormatter,
123
+ "processor": structlog.dev.ConsoleRenderer(colors=True),
124
+ "foreign_pre_chain": structlog_foreign_pre_chain,
125
+ },
126
+ },
127
+ "handlers": {
128
+ "console": {
129
+ "class": "logging.StreamHandler",
130
+ "formatter": "console_formatter",
131
+ },
132
+ "json": {
133
+ "class": "logging.StreamHandler",
134
+ "formatter": "json_formatter",
135
+ },
136
+ },
137
+ "loggers": {
138
+ "": {
139
+ "handlers": ["console" if debug else "json"],
140
+ "propagate": True,
141
+ "level": "INFO",
142
+ },
143
+ },
144
+ }
145
+ )
146
+
147
+
148
+ def _initialize_logging(debug): # pragma: no cover
149
+ """
150
+ Internal function to initialise logging, configuring python logging and structlog.
151
+ """
152
+ _configure_structlog()
153
+ _configure_logging(debug)
154
+ logger = logging.getLogger()
155
+ return logger.handlers[:], structlog.wrap_logger(logger)
156
+
157
+
158
+ def _initialize_ucam_faas_app(target, source, debug):
159
+ handlers, logger = _initialize_logging(debug)
160
+ app = functions_framework.create_app(target, source)
161
+ app.logger.handlers = handlers
162
+
163
+ @app.route("/healthy")
164
+ @app.route("/status")
165
+ def get_status():
166
+ return "ok"
167
+
168
+ return app
169
+
170
+
171
+ def run_ucam_faas(target, source, host, port, debug): # pragma: no cover
172
+ app = _initialize_ucam_faas_app(target, source, debug)
173
+ if debug:
174
+ app.run(host, port, debug)
175
+ else:
176
+ server = FaaSGunicornApplication(app, host, port)
177
+ server.run()
178
+
179
+
180
+ @click.command()
181
+ @click.option("--target", envvar="FUNCTION_TARGET", type=click.STRING, required=True)
182
+ @click.option("--source", envvar="FUNCTION_SOURCE", type=click.Path(), default=None)
183
+ @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0")
184
+ @click.option("--port", envvar="PORT", type=click.INT, default=8080)
185
+ @click.option("--debug", envvar="DEBUG", is_flag=True)
186
+ def _cli(target, source, host, port, debug): # pragma: no cover
187
+ run_ucam_faas(target, source, host, port, debug)
@@ -0,0 +1,15 @@
1
+ from . import _initialize_ucam_faas_app
2
+
3
+ try:
4
+ from pytest import fixture
5
+
6
+ @fixture
7
+ def event_app_client():
8
+ def _event_app_client(target, source=None):
9
+ test_app = _initialize_ucam_faas_app(target, source, True)
10
+ return test_app.test_client()
11
+
12
+ return _event_app_client
13
+
14
+ except ImportError:
15
+ pass
File without changes
@@ -0,0 +1,18 @@
1
+ from flask import Flask
2
+
3
+ from ucam_faas import FaaSGunicornApplication
4
+
5
+
6
+ def test_faas_gunicorn_application_setup():
7
+ app = Flask(__name__)
8
+
9
+ application = FaaSGunicornApplication(app, "0.0.0.0", "8080")
10
+
11
+ # Set by __init__:
12
+ assert application.cfg.bind == ["0.0.0.0:8080"]
13
+ # Read from the default 'gunicorn.conf.py' file:
14
+ assert application.cfg.logconfig_dict["version"] == 1
15
+ assert (
16
+ len(application.cfg.logconfig_dict["formatters"]["json_formatter"]["foreign_pre_chain"])
17
+ == 3
18
+ )