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.
Files changed (54) hide show
  1. ske_bridge-0.1.0/.github/workflows/publish.yml +74 -0
  2. ske_bridge-0.1.0/.github/workflows/tests.yml +37 -0
  3. ske_bridge-0.1.0/.gitignore +17 -0
  4. ske_bridge-0.1.0/LICENSE +21 -0
  5. ske_bridge-0.1.0/PKG-INFO +196 -0
  6. ske_bridge-0.1.0/README.md +168 -0
  7. ske_bridge-0.1.0/pyproject.toml +54 -0
  8. ske_bridge-0.1.0/src/ske_bridge/__init__.py +14 -0
  9. ske_bridge-0.1.0/src/ske_bridge/celery/__init__.py +163 -0
  10. ske_bridge-0.1.0/src/ske_bridge/celery/cloudtasks.py +86 -0
  11. ske_bridge-0.1.0/src/ske_bridge/celery/message.py +167 -0
  12. ske_bridge-0.1.0/src/ske_bridge/celery/outcome.py +53 -0
  13. ske_bridge-0.1.0/src/ske_bridge/celery/runner.py +368 -0
  14. ske_bridge-0.1.0/src/ske_bridge/celery/scheduler.py +190 -0
  15. ske_bridge-0.1.0/src/ske_bridge/django/__init__.py +125 -0
  16. ske_bridge-0.1.0/src/ske_bridge/django/apps.py +72 -0
  17. ske_bridge-0.1.0/src/ske_bridge/django/management/__init__.py +0 -0
  18. ske_bridge-0.1.0/src/ske_bridge/django/management/commands/__init__.py +0 -0
  19. ske_bridge-0.1.0/src/ske_bridge/django/management/commands/ske_schedule.py +16 -0
  20. ske_bridge-0.1.0/src/ske_bridge/django/middleware.py +96 -0
  21. ske_bridge-0.1.0/src/ske_bridge/environment.py +27 -0
  22. ske_bridge-0.1.0/src/ske_bridge/flask.py +158 -0
  23. ske_bridge-0.1.0/src/ske_bridge/forwarded.py +39 -0
  24. ske_bridge-0.1.0/src/ske_bridge/logging.py +135 -0
  25. ske_bridge-0.1.0/src/ske_bridge/mail.py +38 -0
  26. ske_bridge-0.1.0/src/ske_bridge/maintenance.py +122 -0
  27. ske_bridge-0.1.0/src/ske_bridge/routes.py +132 -0
  28. ske_bridge-0.1.0/src/ske_bridge/static.py +28 -0
  29. ske_bridge-0.1.0/src/ske_bridge/wsgi.py +77 -0
  30. ske_bridge-0.1.0/tests/__init__.py +0 -0
  31. ske_bridge-0.1.0/tests/celery_app.py +106 -0
  32. ske_bridge-0.1.0/tests/conftest.py +133 -0
  33. ske_bridge-0.1.0/tests/django_project/__init__.py +0 -0
  34. ske_bridge-0.1.0/tests/django_project/settings.py +24 -0
  35. ske_bridge-0.1.0/tests/django_project/templates/503.html +1 -0
  36. ske_bridge-0.1.0/tests/django_project/urls.py +25 -0
  37. ske_bridge-0.1.0/tests/fixtures/sqs-celery-event.json +20 -0
  38. ske_bridge-0.1.0/tests/fixtures/static/.hidden/app.css +1 -0
  39. ske_bridge-0.1.0/tests/fixtures/static/.secret +1 -0
  40. ske_bridge-0.1.0/tests/fixtures/static/app.css +1 -0
  41. ske_bridge-0.1.0/tests/fixtures/static/run.sh +1 -0
  42. ske_bridge-0.1.0/tests/fixtures/static/settings.py +1 -0
  43. ske_bridge-0.1.0/tests/templates/503.html +1 -0
  44. ske_bridge-0.1.0/tests/test_celery_settings.py +206 -0
  45. ske_bridge-0.1.0/tests/test_cloudtasks.py +100 -0
  46. ske_bridge-0.1.0/tests/test_database.py +102 -0
  47. ske_bridge-0.1.0/tests/test_django.py +349 -0
  48. ske_bridge-0.1.0/tests/test_flask.py +401 -0
  49. ske_bridge-0.1.0/tests/test_logging.py +170 -0
  50. ske_bridge-0.1.0/tests/test_maintenance.py +90 -0
  51. ske_bridge-0.1.0/tests/test_message.py +64 -0
  52. ske_bridge-0.1.0/tests/test_routes.py +99 -0
  53. ske_bridge-0.1.0/tests/test_runner.py +387 -0
  54. 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
@@ -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"