ske-bridge 0.1.0__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.
- ske_bridge-0.1.0/.github/workflows/publish.yml +74 -0
- ske_bridge-0.1.0/.github/workflows/tests.yml +37 -0
- ske_bridge-0.1.0/.gitignore +17 -0
- ske_bridge-0.1.0/LICENSE +21 -0
- ske_bridge-0.1.0/PKG-INFO +196 -0
- ske_bridge-0.1.0/README.md +168 -0
- ske_bridge-0.1.0/pyproject.toml +54 -0
- ske_bridge-0.1.0/src/ske_bridge/__init__.py +14 -0
- ske_bridge-0.1.0/src/ske_bridge/celery/__init__.py +163 -0
- ske_bridge-0.1.0/src/ske_bridge/celery/cloudtasks.py +86 -0
- ske_bridge-0.1.0/src/ske_bridge/celery/message.py +167 -0
- ske_bridge-0.1.0/src/ske_bridge/celery/outcome.py +53 -0
- ske_bridge-0.1.0/src/ske_bridge/celery/runner.py +368 -0
- ske_bridge-0.1.0/src/ske_bridge/celery/scheduler.py +190 -0
- ske_bridge-0.1.0/src/ske_bridge/django/__init__.py +125 -0
- ske_bridge-0.1.0/src/ske_bridge/django/apps.py +72 -0
- ske_bridge-0.1.0/src/ske_bridge/django/management/__init__.py +0 -0
- ske_bridge-0.1.0/src/ske_bridge/django/management/commands/__init__.py +0 -0
- ske_bridge-0.1.0/src/ske_bridge/django/management/commands/ske_schedule.py +16 -0
- ske_bridge-0.1.0/src/ske_bridge/django/middleware.py +96 -0
- ske_bridge-0.1.0/src/ske_bridge/environment.py +27 -0
- ske_bridge-0.1.0/src/ske_bridge/flask.py +158 -0
- ske_bridge-0.1.0/src/ske_bridge/forwarded.py +39 -0
- ske_bridge-0.1.0/src/ske_bridge/logging.py +135 -0
- ske_bridge-0.1.0/src/ske_bridge/mail.py +38 -0
- ske_bridge-0.1.0/src/ske_bridge/maintenance.py +122 -0
- ske_bridge-0.1.0/src/ske_bridge/routes.py +132 -0
- ske_bridge-0.1.0/src/ske_bridge/static.py +28 -0
- ske_bridge-0.1.0/src/ske_bridge/wsgi.py +77 -0
- ske_bridge-0.1.0/tests/__init__.py +0 -0
- ske_bridge-0.1.0/tests/celery_app.py +106 -0
- ske_bridge-0.1.0/tests/conftest.py +133 -0
- ske_bridge-0.1.0/tests/django_project/__init__.py +0 -0
- ske_bridge-0.1.0/tests/django_project/settings.py +24 -0
- ske_bridge-0.1.0/tests/django_project/templates/503.html +1 -0
- ske_bridge-0.1.0/tests/django_project/urls.py +25 -0
- ske_bridge-0.1.0/tests/fixtures/sqs-celery-event.json +20 -0
- ske_bridge-0.1.0/tests/fixtures/static/.hidden/app.css +1 -0
- ske_bridge-0.1.0/tests/fixtures/static/.secret +1 -0
- ske_bridge-0.1.0/tests/fixtures/static/app.css +1 -0
- ske_bridge-0.1.0/tests/fixtures/static/run.sh +1 -0
- ske_bridge-0.1.0/tests/fixtures/static/settings.py +1 -0
- ske_bridge-0.1.0/tests/templates/503.html +1 -0
- ske_bridge-0.1.0/tests/test_celery_settings.py +206 -0
- ske_bridge-0.1.0/tests/test_cloudtasks.py +100 -0
- ske_bridge-0.1.0/tests/test_database.py +102 -0
- ske_bridge-0.1.0/tests/test_django.py +349 -0
- ske_bridge-0.1.0/tests/test_flask.py +401 -0
- ske_bridge-0.1.0/tests/test_logging.py +170 -0
- ske_bridge-0.1.0/tests/test_maintenance.py +90 -0
- ske_bridge-0.1.0/tests/test_message.py +64 -0
- ske_bridge-0.1.0/tests/test_routes.py +99 -0
- ske_bridge-0.1.0/tests/test_runner.py +387 -0
- ske_bridge-0.1.0/tests/test_scheduler.py +220 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# A published GitHub release goes to PyPI; a manual run goes to TestPyPI, to
|
|
4
|
+
# try a build before releasing it. Both use trusted publishing: each index
|
|
5
|
+
# trusts this workflow and its environment, so no API token is stored.
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
build:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- name: Checkout code
|
|
20
|
+
uses: actions/checkout@v7
|
|
21
|
+
with:
|
|
22
|
+
persist-credentials: false
|
|
23
|
+
|
|
24
|
+
- name: Setup uv
|
|
25
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
26
|
+
|
|
27
|
+
# PyPI never takes a version twice, so a release tag that disagrees with
|
|
28
|
+
# pyproject.toml stops here rather than publishing the wrong version.
|
|
29
|
+
- name: Check the release tag
|
|
30
|
+
if: github.event_name == 'release'
|
|
31
|
+
env:
|
|
32
|
+
TAG: ${{ github.event.release.tag_name }}
|
|
33
|
+
run: |
|
|
34
|
+
version="$(uv version --short)"
|
|
35
|
+
if [ "$TAG" != "v$version" ]; then
|
|
36
|
+
echo "::error::Release tag $TAG does not match pyproject.toml version v$version"
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
- name: Build
|
|
41
|
+
run: uv build
|
|
42
|
+
|
|
43
|
+
- name: Upload distributions
|
|
44
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
45
|
+
with:
|
|
46
|
+
name: dist
|
|
47
|
+
path: dist/
|
|
48
|
+
retention-days: 7
|
|
49
|
+
|
|
50
|
+
publish:
|
|
51
|
+
needs: build
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
environment: ${{ github.event_name == 'release' && 'pypi' || 'testpypi' }}
|
|
54
|
+
|
|
55
|
+
permissions:
|
|
56
|
+
id-token: write
|
|
57
|
+
|
|
58
|
+
steps:
|
|
59
|
+
- name: Download distributions
|
|
60
|
+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
61
|
+
with:
|
|
62
|
+
name: dist
|
|
63
|
+
path: dist/
|
|
64
|
+
|
|
65
|
+
- name: Setup uv
|
|
66
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
67
|
+
|
|
68
|
+
- name: Publish to PyPI
|
|
69
|
+
if: github.event_name == 'release'
|
|
70
|
+
run: uv publish --trusted-publishing always
|
|
71
|
+
|
|
72
|
+
- name: Publish to TestPyPI
|
|
73
|
+
if: github.event_name != 'release'
|
|
74
|
+
run: uv publish --trusted-publishing always --publish-url https://test.pypi.org/legacy/
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- '*.*.x'
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
ci:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
|
|
16
|
+
# Every Python the package supports (pyproject.toml's requires-python and
|
|
17
|
+
# classifiers). The library ships no uv.lock, so each run resolves the
|
|
18
|
+
# newest development dependencies, as a tenant app would.
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
python: ['3.11', '3.12', '3.13', '3.14']
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- name: Checkout code
|
|
26
|
+
uses: actions/checkout@v7
|
|
27
|
+
with:
|
|
28
|
+
persist-credentials: false
|
|
29
|
+
|
|
30
|
+
- name: Setup uv
|
|
31
|
+
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
|
|
32
|
+
with:
|
|
33
|
+
python-version: ${{ matrix.python }}
|
|
34
|
+
enable-cache: true
|
|
35
|
+
|
|
36
|
+
- name: Run tests
|
|
37
|
+
run: uv run pytest
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
/.pytest_cache/
|
|
5
|
+
/dist/
|
|
6
|
+
/build/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
|
|
9
|
+
# A library does not ship a lock file: consumers resolve against their own
|
|
10
|
+
# application's constraints, so committing one here only creates churn.
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
.DS_Store
|
|
14
|
+
/.idea
|
|
15
|
+
/.vscode
|
|
16
|
+
/.zed
|
|
17
|
+
/.nova
|
ske_bridge-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SKE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ske-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runs a Django or Flask application's queued Celery tasks and beat schedule on SKE.
|
|
5
|
+
Project-URL: Homepage, https://ske.io
|
|
6
|
+
Project-URL: Documentation, https://app.ske.io/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/ske-io/bridge-python
|
|
8
|
+
Project-URL: Issues, https://github.com/ske-io/bridge-python/issues
|
|
9
|
+
Author: SKE
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Framework :: Celery
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Flask
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Provides-Extra: celery
|
|
22
|
+
Requires-Dist: celery[sqs]>=5.3; extra == 'celery'
|
|
23
|
+
Provides-Extra: django
|
|
24
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
25
|
+
Provides-Extra: flask
|
|
26
|
+
Requires-Dist: flask>=3.0; extra == 'flask'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# ske-bridge
|
|
30
|
+
|
|
31
|
+
Runs a Django or Flask application on SKE: it answers the platform's internal routes inside the application, runs delivered Celery tasks, and turns the Celery beat schedule into the command the platform runs every minute.
|
|
32
|
+
|
|
33
|
+
Everything is inert off the platform (when neither `SKE_PROJECT_TYPE` nor `SKE_ENVIRONMENT` is set), so the package is safe to install unconditionally.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install "ske-bridge[django,celery]" # or [flask,celery]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Python 3.11–3.14. The `celery` extra installs `celery[sqs]`; leave it out if the application has no background tasks.
|
|
42
|
+
|
|
43
|
+
## Django
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# settings.py
|
|
47
|
+
import ske_bridge.django
|
|
48
|
+
|
|
49
|
+
INSTALLED_APPS = [
|
|
50
|
+
# ...
|
|
51
|
+
"ske_bridge.django",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
DATABASES = ske_bridge.django.database(default="sqlite:///db.sqlite3")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
On the platform the app puts its middleware at the front of `MIDDLEWARE`, so `urls.py` needs no change and host validation, CSRF and authentication never see the platform's requests. List `ske_bridge.django.middleware.InternalRoutesMiddleware` yourself to choose its position.
|
|
58
|
+
|
|
59
|
+
`database()` turns `DATABASE_URL` into `DATABASES`: `postgres://`, `postgresql://`, `mysql://`, `mariadb://` and `sqlite://` URLs, credentials percent-decoded, query parameters into `OPTIONS` (`?sslmode=require`). It takes `conn_max_age` and `conn_health_checks`. With neither `DATABASE_URL` nor `default` it returns `{}`, Django's setting for no database, which also lets `collectstatic` run at build time.
|
|
60
|
+
|
|
61
|
+
On the platform the middleware also sets `REMOTE_ADDR` to the client's address, and the app sets `SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")` unless your settings set it, so `request.is_secure()` and `build_absolute_uri()` report HTTPS. Both are safe there and only there; see [Client address and scheme](#client-address-and-scheme).
|
|
62
|
+
|
|
63
|
+
## Flask
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from flask import Flask
|
|
67
|
+
from ske_bridge.flask import SkeBridge
|
|
68
|
+
|
|
69
|
+
app = Flask(__name__)
|
|
70
|
+
SkeBridge(app)
|
|
71
|
+
|
|
72
|
+
# or, with an application factory
|
|
73
|
+
ske = SkeBridge()
|
|
74
|
+
|
|
75
|
+
def create_app():
|
|
76
|
+
app = Flask(__name__)
|
|
77
|
+
ske.init_app(app)
|
|
78
|
+
return app
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
On the platform the extension wraps `app.wsgi_app`, so the internal routes are answered before Flask's routing and `before_request` hooks. It also applies Werkzeug's `ProxyFix` for one trusted proxy, so `request.remote_addr`, `request.scheme`, `request.host` and external URLs are the client's; see [Client address and scheme](#client-address-and-scheme). Don't add a `ProxyFix` of your own for the platform. Flask's static routes (the app's and each blueprint's) answer 404 for dotfiles, files in dot-directories and source files (`.py`, `.pyc`, `.sh`, `.php` and the like), as the platform's own static file server does; off the platform they serve as usual. Each Celery task runs inside an application context.
|
|
82
|
+
|
|
83
|
+
## Client address and scheme
|
|
84
|
+
|
|
85
|
+
Your app listens on loopback behind the container's entry process, so every connection it sees comes from `127.0.0.1`. The platform forwards each request with:
|
|
86
|
+
|
|
87
|
+
- `X-Forwarded-For`: the chain the client sent, with the address the platform saw the request come from appended last. Only that last entry is trusted, so a client can't choose its own address by sending the header.
|
|
88
|
+
- `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Port`: replaced on every request with the scheme, host and port the client connected with.
|
|
89
|
+
- `Forwarded`, `X-Real-IP` and `X-Forwarded-Prefix`: removed.
|
|
90
|
+
|
|
91
|
+
The bridge trusts exactly that one hop, and only on the platform; off it, nothing is changed, because the same headers would be whatever a client sent. If a CDN sits in front of your app, the last entry is the CDN's address, and reading the client from it is your app's own trusted-proxy configuration, as it would be behind any other proxy.
|
|
92
|
+
|
|
93
|
+
## Maintenance mode
|
|
94
|
+
|
|
95
|
+
Turn maintenance mode on for an environment in the SKE dashboard. The platform redeploys it with `MAINTENANCE_MODE=true`, and every web request is answered `503 Service Unavailable` with your `503.html` template (Django's template loaders, or Flask's `templates/` folder), or a minimal page when you have none. The readiness check and queued tasks keep working; the scheduler sends no beat entries, except those marked to run anyway:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
app.conf.beat_schedule = {
|
|
99
|
+
"cleanup": {"task": "proj.tasks.cleanup", "schedule": 3600, "options": {"ske_even_in_maintenance": True}},
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Set `MAINTENANCE_MODE_SECRET` to look at the site while it's down: visiting `https://your-site/<secret>` sets a cookie and redirects to `/`, and that browser is let in for 12 hours. The cookie is signed with the secret, so changing the secret ends every one handed out.
|
|
104
|
+
|
|
105
|
+
## Logs
|
|
106
|
+
|
|
107
|
+
On the platform your app's logs are written to stderr as one JSON line per record, which the logs page reads into level, message, context and exception, with its traceback. Records keep the logger's name, the fields you pass with `extra=`, and the request's id as `request_id`. The root logger gets a stderr handler in this format, other loggers' console handlers (Django's, Flask's, gunicorn's) take the format too, and a console handler on a logger whose records reach the root logger anyway is removed, so nothing is written twice. Levels stay as you set them.
|
|
108
|
+
|
|
109
|
+
Set `SKE_LOG_FORMAT=plain` to keep your logging exactly as you configured it.
|
|
110
|
+
|
|
111
|
+
## Mail
|
|
112
|
+
|
|
113
|
+
When the environment has the platform's mail service attached, the platform runs an SMTP server in the container that catches every mail your app sends, so none reaches a real inbox, and sets `MAIL_HOST` and `MAIL_PORT` to it. It speaks plain SMTP, with no TLS and no login. On the platform the bridge points your app at it:
|
|
114
|
+
|
|
115
|
+
- Django: when `EMAIL_BACKEND` is the SMTP backend (the default), `EMAIL_HOST` and `EMAIL_PORT` are set to the service, `EMAIL_USE_TLS` and `EMAIL_USE_SSL` to `False`, and `EMAIL_HOST_USER` and `EMAIL_HOST_PASSWORD` are cleared. Other backends (console, locmem, a provider's API) are left alone.
|
|
116
|
+
- Flask: `MAIL_SERVER`, `MAIL_PORT`, `MAIL_USE_TLS=False`, `MAIL_USE_SSL=False`, and `MAIL_USERNAME` and `MAIL_PASSWORD` cleared, which Flask-Mail and Flask-Mailman read. An extension initialised before `SkeBridge(app)` is updated too. Flask-Mailman with a non-SMTP backend is left alone.
|
|
117
|
+
|
|
118
|
+
These override your own SMTP server settings: the service is attached to keep mail from going out, and your real server would let it out. Without the service, and off the platform, your settings are used as they are. An app that sends with `smtplib` directly should connect to `MAIL_HOST` and `MAIL_PORT` itself, without `starttls()` or `login()`.
|
|
119
|
+
|
|
120
|
+
## Celery
|
|
121
|
+
|
|
122
|
+
Point Celery at the platform's queue after the application's own configuration:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
# proj/celery.py
|
|
126
|
+
import ske_bridge.celery
|
|
127
|
+
from celery import Celery
|
|
128
|
+
|
|
129
|
+
app = Celery("proj")
|
|
130
|
+
app.config_from_object("django.conf:settings", namespace="CELERY")
|
|
131
|
+
ske_bridge.celery.configure(app)
|
|
132
|
+
app.autodiscover_tasks()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
On AWS that is the environment's SQS queues: `broker_url = "sqs://"`, the queues added to `broker_transport_options["predefined_queues"]` (the application's other transport options and predefined queues are kept) and `task_default_queue` set. Credentials come from the function's role.
|
|
136
|
+
|
|
137
|
+
On GCP it publishes Celery envelopes to the environment's Cloud Tasks queues through the bridge's Kombu transport. The platform sets `SKE_GCP_PROJECT`, `SKE_REGION`, `SKE_QUEUE_PREFIX` and `SKE_QUEUES`; Cloud Run's metadata server supplies the access token. The queues' HTTP targets select and authenticate the receiving service. No Google SDK or Celery worker is needed. A countdown becomes a Cloud Tasks scheduled task, at most 30 days ahead. An unknown queue or a longer countdown fails to publish. Off the platform it changes nothing.
|
|
138
|
+
|
|
139
|
+
GCP currently has no dead-letter store in the platform. A task that fails permanently is acknowledged after Celery's failure handling, so use a Celery result backend to retain its failure. AWS retains it in the SQS dead-letter queue.
|
|
140
|
+
|
|
141
|
+
Every queue the environment has is known to Celery, from `SQS_QUEUES` on AWS and `SKE_QUEUES` on GCP, with `default` as the default queue. Route a task to another one as usual, with `queue="emails"` on the task or the call, or in `task_routes`. A task routed to a queue the environment doesn't have fails to send rather than go to a queue nothing reads.
|
|
142
|
+
|
|
143
|
+
The platform delivers each message to the application; no worker process runs. The bridge finds the Celery app, in order, from `SkeBridge(app, celery=...)`, from `SKE_CELERY_APP` (or `CELERY_APP`) named as for `celery -A` (`proj`, `proj.celery:app`), from Flask's `app.extensions["celery"]`, and last from the app the project created, which Django's usual `proj/__init__.py` import does. With none of these the queue answers 500 and the platform delivers the batch again, rather than run it against Celery's empty default app.
|
|
144
|
+
|
|
145
|
+
A delivered task runs through Celery's own tracer, so signals, result backends, links and chains behave as under `celery worker`. What becomes of the message:
|
|
146
|
+
|
|
147
|
+
| The task | The message |
|
|
148
|
+
|---|---|
|
|
149
|
+
| returns, or raises `Ignore` | acknowledged |
|
|
150
|
+
| calls `self.retry()`, or retries through `autoretry_for` | the retry publishes a new message (its arguments, its countdown as an eta, `retries + 1`) and this one is acknowledged, as under a worker; if that publish fails, this one is delivered again |
|
|
151
|
+
| raises anything else, or runs out of retries | kept in the queue's dead-letter queue |
|
|
152
|
+
| raises `Reject(requeue=True)` | delivered again |
|
|
153
|
+
| has an `eta`/`countdown` still ahead | held unrun until then, at most 12 hours at a time (SQS's limit), and checked again on each delivery |
|
|
154
|
+
| has `expires` in the past | revoked unrun (`task_revoked` is sent and the result backend records it) |
|
|
155
|
+
| succeeded, but its result could not be saved | acknowledged, with the error logged: the task already ran |
|
|
156
|
+
|
|
157
|
+
The retry count is the message's own `retries`, so a delivery held for an eta never uses up a retry.
|
|
158
|
+
|
|
159
|
+
Retries and backoff are Celery's per-task settings: `max_retries`, `default_retry_delay`, `autoretry_for`, `retry_backoff`. The queue's own `tries` and `backoff` settings are not applied to Celery tasks. `tries` is the SQS queue's `maxReceiveCount`, which counts deliveries of one message; each Celery retry is a new message, so it starts that count again. The count matters only for a message that keeps being delivered without an outcome (a crash, a timeout of the whole invocation) and for a message held for a long eta, which is delivered once per 12 hours; set `tries` high enough for the longest eta a task uses.
|
|
160
|
+
|
|
161
|
+
Time limits are a worker's: at a task's soft limit it gets `SoftTimeLimitExceeded`, and at its hard limit `TimeLimitExceeded`. The batch has one deadline as well, a few seconds before the platform cuts it off (the time the platform gives, or else the queue's timeout counted from arrival): each task's hard limit is cut to the time left. A task stopped by that deadline rather than its own limit is delivered again in a later invocation, with no failure handling run, and so is a message reached with too little time left. Interrupting a task needs gunicorn's default sync worker, which runs requests on the main thread.
|
|
162
|
+
|
|
163
|
+
Only task protocol 2 (Celery's default since 4.0) is supported, and the message body is decoded with the app's `accept_content`.
|
|
164
|
+
|
|
165
|
+
## Beat schedule
|
|
166
|
+
|
|
167
|
+
No beat process runs. Once a minute the platform runs the scheduler command for your framework:
|
|
168
|
+
|
|
169
|
+
- Django: `python manage.py ske_schedule`
|
|
170
|
+
- Flask: `flask ske schedule`
|
|
171
|
+
|
|
172
|
+
Each run sends the `beat_schedule` entries due in the minute it was scheduled for (the platform passes it as `SKE_SCHEDULED_AT`; without it, the current minute) (including those added with `add_periodic_task`) and exits; it exits non-zero if an entry could not be sent. Due is decided from the minute alone:
|
|
173
|
+
|
|
174
|
+
- `crontab` entries are due when the minute matches, in the app's `timezone`.
|
|
175
|
+
- Interval entries (seconds, `timedelta`) are due when the minutes since Monday 1970-01-05 00:00 UTC are a multiple of the interval, rounded down to whole minutes: hourly at the top of the hour, daily at 00:00 UTC, weekly on Mondays at 00:00 UTC; 90 seconds runs every minute. An interval under a minute runs once a minute, with a warning in the log.
|
|
176
|
+
- Other schedules (`solar`, custom classes) are due when their next event falls in the minute, its start included and its end excluded, so an event is sent by one tick only.
|
|
177
|
+
|
|
178
|
+
Daylight saving time applies to `crontab` entries in a timezone that has it. When clocks go back, the repeated hour's minutes match twice, so a `crontab(hour=1, minute=30)` entry runs twice that night. When clocks go forward, the skipped hour never happens, so an entry in it does not run that day. Intervals count UTC minutes and are not affected. Set `timezone = "UTC"` to avoid both. Because each run uses the minute it was scheduled for, a run that starts late on either night still decides by that minute, not by when it started.
|
|
179
|
+
|
|
180
|
+
The schedule cannot be finer than a minute. A run that starts late or is retried sends the entries of the minute it was scheduled for, not of the minute it started in. A tick delivered twice still sends its entries twice, so a task that must never run twice guards itself, as it would under more than one beat.
|
|
181
|
+
|
|
182
|
+
## Commands
|
|
183
|
+
|
|
184
|
+
New Django environments start with `python manage.py migrate --noinput` as their deploy hook. Change or disable it in the environment settings when the application needs a different migration step or has no database. Flask environments start without deploy hooks.
|
|
185
|
+
|
|
186
|
+
Commands from the CLI or the console (`python manage.py migrate`, `flask db upgrade`) run as plain processes in the container; they need nothing from the bridge.
|
|
187
|
+
|
|
188
|
+
## Maintenance and logs
|
|
189
|
+
|
|
190
|
+
On the platform, `MAINTENANCE_MODE=true` pauses web requests with a 503 response. Internal queue and health routes keep working, as does the application's `/up` readiness route. Visiting `/{MAINTENANCE_MODE_SECRET}` sets a signed, HTTP-only bypass cookie valid for 12 hours; changing the secret invalidates existing cookies. Responses are not cached.
|
|
191
|
+
|
|
192
|
+
Console log handlers write JSON records with the message, severity, logger name and exception frames, so errors and tracebacks appear correctly in the SKE logs viewer. File handlers keep their own format. Both behaviors are inactive off the platform.
|
|
193
|
+
|
|
194
|
+
## Environment
|
|
195
|
+
|
|
196
|
+
Every process inherits the platform's variables in `os.environ`, so there is nothing to normalise. Dotenv loaders leave a variable that is already set alone unless told to override it, so a `.env` in the image cannot replace what the platform set.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# ske-bridge
|
|
2
|
+
|
|
3
|
+
Runs a Django or Flask application on SKE: it answers the platform's internal routes inside the application, runs delivered Celery tasks, and turns the Celery beat schedule into the command the platform runs every minute.
|
|
4
|
+
|
|
5
|
+
Everything is inert off the platform (when neither `SKE_PROJECT_TYPE` nor `SKE_ENVIRONMENT` is set), so the package is safe to install unconditionally.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install "ske-bridge[django,celery]" # or [flask,celery]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Python 3.11–3.14. The `celery` extra installs `celery[sqs]`; leave it out if the application has no background tasks.
|
|
14
|
+
|
|
15
|
+
## Django
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
# settings.py
|
|
19
|
+
import ske_bridge.django
|
|
20
|
+
|
|
21
|
+
INSTALLED_APPS = [
|
|
22
|
+
# ...
|
|
23
|
+
"ske_bridge.django",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
DATABASES = ske_bridge.django.database(default="sqlite:///db.sqlite3")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
On the platform the app puts its middleware at the front of `MIDDLEWARE`, so `urls.py` needs no change and host validation, CSRF and authentication never see the platform's requests. List `ske_bridge.django.middleware.InternalRoutesMiddleware` yourself to choose its position.
|
|
30
|
+
|
|
31
|
+
`database()` turns `DATABASE_URL` into `DATABASES`: `postgres://`, `postgresql://`, `mysql://`, `mariadb://` and `sqlite://` URLs, credentials percent-decoded, query parameters into `OPTIONS` (`?sslmode=require`). It takes `conn_max_age` and `conn_health_checks`. With neither `DATABASE_URL` nor `default` it returns `{}`, Django's setting for no database, which also lets `collectstatic` run at build time.
|
|
32
|
+
|
|
33
|
+
On the platform the middleware also sets `REMOTE_ADDR` to the client's address, and the app sets `SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")` unless your settings set it, so `request.is_secure()` and `build_absolute_uri()` report HTTPS. Both are safe there and only there; see [Client address and scheme](#client-address-and-scheme).
|
|
34
|
+
|
|
35
|
+
## Flask
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from flask import Flask
|
|
39
|
+
from ske_bridge.flask import SkeBridge
|
|
40
|
+
|
|
41
|
+
app = Flask(__name__)
|
|
42
|
+
SkeBridge(app)
|
|
43
|
+
|
|
44
|
+
# or, with an application factory
|
|
45
|
+
ske = SkeBridge()
|
|
46
|
+
|
|
47
|
+
def create_app():
|
|
48
|
+
app = Flask(__name__)
|
|
49
|
+
ske.init_app(app)
|
|
50
|
+
return app
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
On the platform the extension wraps `app.wsgi_app`, so the internal routes are answered before Flask's routing and `before_request` hooks. It also applies Werkzeug's `ProxyFix` for one trusted proxy, so `request.remote_addr`, `request.scheme`, `request.host` and external URLs are the client's; see [Client address and scheme](#client-address-and-scheme). Don't add a `ProxyFix` of your own for the platform. Flask's static routes (the app's and each blueprint's) answer 404 for dotfiles, files in dot-directories and source files (`.py`, `.pyc`, `.sh`, `.php` and the like), as the platform's own static file server does; off the platform they serve as usual. Each Celery task runs inside an application context.
|
|
54
|
+
|
|
55
|
+
## Client address and scheme
|
|
56
|
+
|
|
57
|
+
Your app listens on loopback behind the container's entry process, so every connection it sees comes from `127.0.0.1`. The platform forwards each request with:
|
|
58
|
+
|
|
59
|
+
- `X-Forwarded-For`: the chain the client sent, with the address the platform saw the request come from appended last. Only that last entry is trusted, so a client can't choose its own address by sending the header.
|
|
60
|
+
- `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Port`: replaced on every request with the scheme, host and port the client connected with.
|
|
61
|
+
- `Forwarded`, `X-Real-IP` and `X-Forwarded-Prefix`: removed.
|
|
62
|
+
|
|
63
|
+
The bridge trusts exactly that one hop, and only on the platform; off it, nothing is changed, because the same headers would be whatever a client sent. If a CDN sits in front of your app, the last entry is the CDN's address, and reading the client from it is your app's own trusted-proxy configuration, as it would be behind any other proxy.
|
|
64
|
+
|
|
65
|
+
## Maintenance mode
|
|
66
|
+
|
|
67
|
+
Turn maintenance mode on for an environment in the SKE dashboard. The platform redeploys it with `MAINTENANCE_MODE=true`, and every web request is answered `503 Service Unavailable` with your `503.html` template (Django's template loaders, or Flask's `templates/` folder), or a minimal page when you have none. The readiness check and queued tasks keep working; the scheduler sends no beat entries, except those marked to run anyway:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
app.conf.beat_schedule = {
|
|
71
|
+
"cleanup": {"task": "proj.tasks.cleanup", "schedule": 3600, "options": {"ske_even_in_maintenance": True}},
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Set `MAINTENANCE_MODE_SECRET` to look at the site while it's down: visiting `https://your-site/<secret>` sets a cookie and redirects to `/`, and that browser is let in for 12 hours. The cookie is signed with the secret, so changing the secret ends every one handed out.
|
|
76
|
+
|
|
77
|
+
## Logs
|
|
78
|
+
|
|
79
|
+
On the platform your app's logs are written to stderr as one JSON line per record, which the logs page reads into level, message, context and exception, with its traceback. Records keep the logger's name, the fields you pass with `extra=`, and the request's id as `request_id`. The root logger gets a stderr handler in this format, other loggers' console handlers (Django's, Flask's, gunicorn's) take the format too, and a console handler on a logger whose records reach the root logger anyway is removed, so nothing is written twice. Levels stay as you set them.
|
|
80
|
+
|
|
81
|
+
Set `SKE_LOG_FORMAT=plain` to keep your logging exactly as you configured it.
|
|
82
|
+
|
|
83
|
+
## Mail
|
|
84
|
+
|
|
85
|
+
When the environment has the platform's mail service attached, the platform runs an SMTP server in the container that catches every mail your app sends, so none reaches a real inbox, and sets `MAIL_HOST` and `MAIL_PORT` to it. It speaks plain SMTP, with no TLS and no login. On the platform the bridge points your app at it:
|
|
86
|
+
|
|
87
|
+
- Django: when `EMAIL_BACKEND` is the SMTP backend (the default), `EMAIL_HOST` and `EMAIL_PORT` are set to the service, `EMAIL_USE_TLS` and `EMAIL_USE_SSL` to `False`, and `EMAIL_HOST_USER` and `EMAIL_HOST_PASSWORD` are cleared. Other backends (console, locmem, a provider's API) are left alone.
|
|
88
|
+
- Flask: `MAIL_SERVER`, `MAIL_PORT`, `MAIL_USE_TLS=False`, `MAIL_USE_SSL=False`, and `MAIL_USERNAME` and `MAIL_PASSWORD` cleared, which Flask-Mail and Flask-Mailman read. An extension initialised before `SkeBridge(app)` is updated too. Flask-Mailman with a non-SMTP backend is left alone.
|
|
89
|
+
|
|
90
|
+
These override your own SMTP server settings: the service is attached to keep mail from going out, and your real server would let it out. Without the service, and off the platform, your settings are used as they are. An app that sends with `smtplib` directly should connect to `MAIL_HOST` and `MAIL_PORT` itself, without `starttls()` or `login()`.
|
|
91
|
+
|
|
92
|
+
## Celery
|
|
93
|
+
|
|
94
|
+
Point Celery at the platform's queue after the application's own configuration:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
# proj/celery.py
|
|
98
|
+
import ske_bridge.celery
|
|
99
|
+
from celery import Celery
|
|
100
|
+
|
|
101
|
+
app = Celery("proj")
|
|
102
|
+
app.config_from_object("django.conf:settings", namespace="CELERY")
|
|
103
|
+
ske_bridge.celery.configure(app)
|
|
104
|
+
app.autodiscover_tasks()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
On AWS that is the environment's SQS queues: `broker_url = "sqs://"`, the queues added to `broker_transport_options["predefined_queues"]` (the application's other transport options and predefined queues are kept) and `task_default_queue` set. Credentials come from the function's role.
|
|
108
|
+
|
|
109
|
+
On GCP it publishes Celery envelopes to the environment's Cloud Tasks queues through the bridge's Kombu transport. The platform sets `SKE_GCP_PROJECT`, `SKE_REGION`, `SKE_QUEUE_PREFIX` and `SKE_QUEUES`; Cloud Run's metadata server supplies the access token. The queues' HTTP targets select and authenticate the receiving service. No Google SDK or Celery worker is needed. A countdown becomes a Cloud Tasks scheduled task, at most 30 days ahead. An unknown queue or a longer countdown fails to publish. Off the platform it changes nothing.
|
|
110
|
+
|
|
111
|
+
GCP currently has no dead-letter store in the platform. A task that fails permanently is acknowledged after Celery's failure handling, so use a Celery result backend to retain its failure. AWS retains it in the SQS dead-letter queue.
|
|
112
|
+
|
|
113
|
+
Every queue the environment has is known to Celery, from `SQS_QUEUES` on AWS and `SKE_QUEUES` on GCP, with `default` as the default queue. Route a task to another one as usual, with `queue="emails"` on the task or the call, or in `task_routes`. A task routed to a queue the environment doesn't have fails to send rather than go to a queue nothing reads.
|
|
114
|
+
|
|
115
|
+
The platform delivers each message to the application; no worker process runs. The bridge finds the Celery app, in order, from `SkeBridge(app, celery=...)`, from `SKE_CELERY_APP` (or `CELERY_APP`) named as for `celery -A` (`proj`, `proj.celery:app`), from Flask's `app.extensions["celery"]`, and last from the app the project created, which Django's usual `proj/__init__.py` import does. With none of these the queue answers 500 and the platform delivers the batch again, rather than run it against Celery's empty default app.
|
|
116
|
+
|
|
117
|
+
A delivered task runs through Celery's own tracer, so signals, result backends, links and chains behave as under `celery worker`. What becomes of the message:
|
|
118
|
+
|
|
119
|
+
| The task | The message |
|
|
120
|
+
|---|---|
|
|
121
|
+
| returns, or raises `Ignore` | acknowledged |
|
|
122
|
+
| calls `self.retry()`, or retries through `autoretry_for` | the retry publishes a new message (its arguments, its countdown as an eta, `retries + 1`) and this one is acknowledged, as under a worker; if that publish fails, this one is delivered again |
|
|
123
|
+
| raises anything else, or runs out of retries | kept in the queue's dead-letter queue |
|
|
124
|
+
| raises `Reject(requeue=True)` | delivered again |
|
|
125
|
+
| has an `eta`/`countdown` still ahead | held unrun until then, at most 12 hours at a time (SQS's limit), and checked again on each delivery |
|
|
126
|
+
| has `expires` in the past | revoked unrun (`task_revoked` is sent and the result backend records it) |
|
|
127
|
+
| succeeded, but its result could not be saved | acknowledged, with the error logged: the task already ran |
|
|
128
|
+
|
|
129
|
+
The retry count is the message's own `retries`, so a delivery held for an eta never uses up a retry.
|
|
130
|
+
|
|
131
|
+
Retries and backoff are Celery's per-task settings: `max_retries`, `default_retry_delay`, `autoretry_for`, `retry_backoff`. The queue's own `tries` and `backoff` settings are not applied to Celery tasks. `tries` is the SQS queue's `maxReceiveCount`, which counts deliveries of one message; each Celery retry is a new message, so it starts that count again. The count matters only for a message that keeps being delivered without an outcome (a crash, a timeout of the whole invocation) and for a message held for a long eta, which is delivered once per 12 hours; set `tries` high enough for the longest eta a task uses.
|
|
132
|
+
|
|
133
|
+
Time limits are a worker's: at a task's soft limit it gets `SoftTimeLimitExceeded`, and at its hard limit `TimeLimitExceeded`. The batch has one deadline as well, a few seconds before the platform cuts it off (the time the platform gives, or else the queue's timeout counted from arrival): each task's hard limit is cut to the time left. A task stopped by that deadline rather than its own limit is delivered again in a later invocation, with no failure handling run, and so is a message reached with too little time left. Interrupting a task needs gunicorn's default sync worker, which runs requests on the main thread.
|
|
134
|
+
|
|
135
|
+
Only task protocol 2 (Celery's default since 4.0) is supported, and the message body is decoded with the app's `accept_content`.
|
|
136
|
+
|
|
137
|
+
## Beat schedule
|
|
138
|
+
|
|
139
|
+
No beat process runs. Once a minute the platform runs the scheduler command for your framework:
|
|
140
|
+
|
|
141
|
+
- Django: `python manage.py ske_schedule`
|
|
142
|
+
- Flask: `flask ske schedule`
|
|
143
|
+
|
|
144
|
+
Each run sends the `beat_schedule` entries due in the minute it was scheduled for (the platform passes it as `SKE_SCHEDULED_AT`; without it, the current minute) (including those added with `add_periodic_task`) and exits; it exits non-zero if an entry could not be sent. Due is decided from the minute alone:
|
|
145
|
+
|
|
146
|
+
- `crontab` entries are due when the minute matches, in the app's `timezone`.
|
|
147
|
+
- Interval entries (seconds, `timedelta`) are due when the minutes since Monday 1970-01-05 00:00 UTC are a multiple of the interval, rounded down to whole minutes: hourly at the top of the hour, daily at 00:00 UTC, weekly on Mondays at 00:00 UTC; 90 seconds runs every minute. An interval under a minute runs once a minute, with a warning in the log.
|
|
148
|
+
- Other schedules (`solar`, custom classes) are due when their next event falls in the minute, its start included and its end excluded, so an event is sent by one tick only.
|
|
149
|
+
|
|
150
|
+
Daylight saving time applies to `crontab` entries in a timezone that has it. When clocks go back, the repeated hour's minutes match twice, so a `crontab(hour=1, minute=30)` entry runs twice that night. When clocks go forward, the skipped hour never happens, so an entry in it does not run that day. Intervals count UTC minutes and are not affected. Set `timezone = "UTC"` to avoid both. Because each run uses the minute it was scheduled for, a run that starts late on either night still decides by that minute, not by when it started.
|
|
151
|
+
|
|
152
|
+
The schedule cannot be finer than a minute. A run that starts late or is retried sends the entries of the minute it was scheduled for, not of the minute it started in. A tick delivered twice still sends its entries twice, so a task that must never run twice guards itself, as it would under more than one beat.
|
|
153
|
+
|
|
154
|
+
## Commands
|
|
155
|
+
|
|
156
|
+
New Django environments start with `python manage.py migrate --noinput` as their deploy hook. Change or disable it in the environment settings when the application needs a different migration step or has no database. Flask environments start without deploy hooks.
|
|
157
|
+
|
|
158
|
+
Commands from the CLI or the console (`python manage.py migrate`, `flask db upgrade`) run as plain processes in the container; they need nothing from the bridge.
|
|
159
|
+
|
|
160
|
+
## Maintenance and logs
|
|
161
|
+
|
|
162
|
+
On the platform, `MAINTENANCE_MODE=true` pauses web requests with a 503 response. Internal queue and health routes keep working, as does the application's `/up` readiness route. Visiting `/{MAINTENANCE_MODE_SECRET}` sets a signed, HTTP-only bypass cookie valid for 12 hours; changing the secret invalidates existing cookies. Responses are not cached.
|
|
163
|
+
|
|
164
|
+
Console log handlers write JSON records with the message, severity, logger name and exception frames, so errors and tracebacks appear correctly in the SKE logs viewer. File handlers keep their own format. Both behaviors are inactive off the platform.
|
|
165
|
+
|
|
166
|
+
## Environment
|
|
167
|
+
|
|
168
|
+
Every process inherits the platform's variables in `os.environ`, so there is nothing to normalise. Dotenv loaders leave a variable that is already set alone unless told to override it, so a `.env` in the image cannot replace what the platform set.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ske-bridge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Runs a Django or Flask application's queued Celery tasks and beat schedule on SKE."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "SKE" }]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = []
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Framework :: Django",
|
|
16
|
+
"Framework :: Flask",
|
|
17
|
+
"Framework :: Celery",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://ske.io"
|
|
27
|
+
Documentation = "https://app.ske.io/docs"
|
|
28
|
+
Repository = "https://github.com/ske-io/bridge-python"
|
|
29
|
+
Issues = "https://github.com/ske-io/bridge-python/issues"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
django = ["django>=4.2"]
|
|
33
|
+
flask = ["flask>=3.0"]
|
|
34
|
+
celery = ["celery[sqs]>=5.3"]
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"django>=4.2",
|
|
40
|
+
"flask>=3.0",
|
|
41
|
+
"celery[sqs]>=5.3",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/ske_bridge"]
|
|
46
|
+
exclude = ["AGENTS.md", "CLAUDE.md"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
# Local working notes, never published.
|
|
50
|
+
exclude = ["/AGENTS.md", "/CLAUDE.md", "/.venv", "uv.lock"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
addopts = "-ra"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""SKE's bridge for Python applications.
|
|
2
|
+
|
|
3
|
+
It answers the platform's internal routes inside your application
|
|
4
|
+
(``/__ske/queue`` runs delivered Celery tasks, ``/__ske/health`` answers
|
|
5
|
+
readiness) and turns the Celery beat schedule into a command the platform runs
|
|
6
|
+
every minute. Commands themselves (``python manage.py …``, ``flask …``) need
|
|
7
|
+
nothing from here: the platform runs them as plain processes in the
|
|
8
|
+
container.
|
|
9
|
+
|
|
10
|
+
Everything is inert off-platform, so the package is safe to install
|
|
11
|
+
unconditionally.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|