camada 0.2.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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .env
camada-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 camada
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.
camada-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,215 @@
1
+ Metadata-Version: 2.5
2
+ Name: camada
3
+ Version: 0.2.0
4
+ Summary: camada SDK for Python: inline enforcement of your snapshot (ordered custom rules, then allow, block, challenge), first-party beacon and proof-of-work challenge, batched event shipping. ASGI and WSGI middleware; Django, Flask and FastAPI integrations.
5
+ Project-URL: Homepage, https://camada.app
6
+ Project-URL: Repository, https://github.com/camada-app/camada-python
7
+ Project-URL: Issues, https://github.com/camada-app/camada-python/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Framework :: Flask
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+
23
+ # camada
24
+
25
+ camada for Python: enforces the tenant snapshot inline (your ordered custom rules, then allow,
26
+ block, challenge), serves a first-party proof-of-work challenge page and beacon, records the
27
+ outcomes your handlers know (`track()`), and ships wire events in batches off the request path.
28
+ One package on PyPI with an ASGI middleware (the first-class tier: it keeps the wire's header
29
+ order), a WSGI middleware, and integrations for FastAPI, Django and Flask — the `sentry-sdk`
30
+ model. Fails open by design: a camada outage or bug never 5xxes your app.
31
+
32
+ Not yet on PyPI — install it from a sibling checkout: `pip install -e ../camada-python` (or a uv
33
+ path dependency, as [`camada-python-example`](../camada-python-example) does); publishing is one
34
+ decision with the npm packages (SDK-G01). Python 3.10 or newer, no runtime dependencies.
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ # FastAPI / Starlette
40
+ from camada.fastapi import CamadaMiddleware
41
+ app.add_middleware(CamadaMiddleware) # last, so it runs outermost
42
+
43
+ # Django — settings.py
44
+ MIDDLEWARE = ["camada.django.CamadaMiddleware", *MIDDLEWARE] # first
45
+
46
+ # Flask
47
+ from camada.flask import init_app
48
+ init_app(app) # wraps app.wsgi_app, so camada answers before routing
49
+
50
+ # any ASGI 3 or WSGI app
51
+ from camada.asgi import CamadaASGI # application = CamadaASGI(application)
52
+ from camada.wsgi import CamadaWSGI # application = CamadaWSGI(application)
53
+ ```
54
+
55
+ Env (printed by camada onboarding / `npm run seed` in dev):
56
+
57
+ ```
58
+ CAMADA_KEY=<ingest_token>.<snap_token>
59
+ CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
60
+ ```
61
+
62
+ The integrations share one lazy engine built from the environment on the first request. That
63
+ build starts the snapshot poll on a thread and never blocks, so the request that triggered it is
64
+ answered cold: it passes (fail open), and so does anything else that arrives before that first
65
+ poll lands (a few hundred milliseconds against a local analyst; snapshot-size and network bound).
66
+ To enforce from request 1, warm the engine in a startup hook by waiting for the boot poll —
67
+ `snap.refresh()` alone is not it, the boot poll already holds the single-in-flight lock:
68
+
69
+ ```python
70
+ import time
71
+ import camada
72
+ from camada.snapshot.match import MatchInput
73
+
74
+ engine = camada.get_default() # builds the engine; the boot poll is already running on its thread
75
+ if engine.snap: # None when CAMADA_KEY is unset or CAMADA_DISABLED=1
76
+ deadline = time.monotonic() + 5
77
+ while engine.snap.verdict(MatchInput(ip="0.0.0.0")).reason == "cold" and time.monotonic() < deadline:
78
+ time.sleep(0.01) # bounded: an unreachable analyst leaves it cold, and the app still fails open
79
+ ```
80
+
81
+ `snap.refresh()` is not the warm-up: the boot poll holds the single-in-flight lock, so a
82
+ synchronous `refresh()` called right after `get_default()` returns at once and the engine is
83
+ still cold.
84
+
85
+ Without `CAMADA_KEY` the engine is inert (one log line, no requests, no enforcement). An app that
86
+ reads its own config builds the engine itself and hands it in:
87
+
88
+ ```python
89
+ from camada import Camada
90
+ engine = Camada(env={"CAMADA_KEY": MY_KEY, "CAMADA_INGEST_URL": MY_INGEST})
91
+ app.add_middleware(CamadaMiddleware, engine=engine) # init_app(app, engine=engine) for Flask
92
+ ```
93
+
94
+ ## What it does per request
95
+
96
+ 1. Keeps the snapshot fresh. A Python server is a long-lived process, so the default is a daemon
97
+ poll thread at the cadence your tenant config sets (`poll_seconds`), with ETag/304 and gzip on
98
+ the wire. `CAMADA_SERVERLESS=1` switches to a per-request staleness check with no thread. Every
99
+ poll and event batch carries `x-camada-sdk: @camada/python/<version>`, and polls ask for
100
+ snapshot v5 (`x-camada-snapshot: 5`) — the container that carries your ordered custom rules.
101
+ 2. Resolves the client from the socket peer (`REMOTE_ADDR` / `scope["client"]`), combined with
102
+ `X-Forwarded-For` only under your tenant's trusted-proxy config (or `CAMADA_TRUSTED_PROXY`
103
+ locally). A forwarded header on its own is never the ip: any caller can set it.
104
+ 3. Enforces before anything else, beacon endpoints included: your ordered custom rules first (first
105
+ match wins; they read ip, path, user-agent and request headers), then allow → block → challenge.
106
+ A block answers `403 Forbidden` with `x-block-reason`, `x-block-version` and, when a rule
107
+ decided, `x-block-rule`; its event ships with `blk` (and `rl`). A `warn` rule passes and stamps
108
+ `wrn`; a `skip` rule passes with nothing stamped. Cold (no snapshot yet) passes: fail open.
109
+ 4. Challenge: a `challenge` verdict gets the self-contained proof-of-work page (or 403 JSON for a
110
+ non-HTML request); `POST /__camada/challenge` verifies the solution, sets `_cch` (bound to the
111
+ ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged.
112
+ 5. Serves the beacon: `GET /_cam/b.js` (the `@camada/browser` build, vendored) and `POST /_cam/fp`
113
+ (≤ 32 KB, relayed onto the event batch as a `sig: 1` row with the ip camada resolved). Both
114
+ fall through to your app when the tenant switched the beacon off.
115
+ 6. Runs your app with `x-rid` and the `_sfp` session cookie on its response, and when the response
116
+ is done ships one redacted event: method, host, path, scrubbed query, status, latency, header
117
+ names/sizes/order, the auth scheme (never the credential), cookie count (never values). An
118
+ exception in your app ships as `st: 500` and propagates unchanged.
119
+
120
+ ## Options
121
+
122
+ `Camada(...)` keyword arguments; everything credential-shaped comes from the environment.
123
+
124
+ | option | default | meaning |
125
+ |---|---|---|
126
+ | `env` | `os.environ` | where `CAMADA_*` are read from |
127
+ | `transport` | urllib | the HTTP callable that reaches the analyst (tests inject a fake) |
128
+ | `refresh_s` | server-steered | poll cadence; set, it is pinned |
129
+ | `challenge` | `True` | serve the proof-of-work page for challenge verdicts (`CAMADA_CHALLENGE=0` too) |
130
+ | `challenge_path` | `/__camada/challenge` | where the page posts its solution |
131
+ | `snapshot_version` | `5` | 4 drops your custom rules; 3 the allow/challenge sides too |
132
+ | `script_path` / `fp_path` | `/_cam/b.js` / `/_cam/fp` | the beacon endpoints; keep them in one directory |
133
+
134
+ Env: `CAMADA_KEY` (or `CAMADA_TOKEN` + `CAMADA_SNAPSHOT_TOKEN`), `CAMADA_INGEST_URL`,
135
+ `CAMADA_SNAPSHOT_URL`, `CAMADA_TRUSTED_PROXY` (`none | vercel | hops:N | cidrs:a,b`),
136
+ `CAMADA_SERVERLESS=1`, `CAMADA_CHALLENGE=0`, and the kill switch `CAMADA_DISABLED=1` (checked per
137
+ request; set at boot, no threads start at all).
138
+
139
+ ## The first-party beacon
140
+
141
+ ```python
142
+ from camada.fastapi import script_tag # camada.django: script_tag(request); camada.flask: script_tag()
143
+ return HTMLResponse(f"<html><head>{script_tag(request)}</head>…")
144
+ ```
145
+
146
+ The tag is `<script src="/_cam/b.js?r=<rid>" async>`, so the beacon joins the page view that
147
+ served it. Move both paths with `script_path` / `fp_path` when `/_cam/` is not yours; the script
148
+ derives the post path from its own URL, so the two must share a directory.
149
+
150
+ ## App-context events
151
+
152
+ ```python
153
+ from camada.fastapi import track # camada.django: track(request, ...); camada.flask: track(event, user=...)
154
+ track(request, "login_failed", user=email)
155
+ ```
156
+
157
+ The identifier is HMAC-hashed in-process with your ingest token; the raw value never reaches the
158
+ queue. `track()` never raises and is a no-op on a request the middleware did not run for. The
159
+ event name is free-form; the analyst's app-context rules read this vocabulary:
160
+
161
+ | event | when |
162
+ |---|---|
163
+ | `login_failed` / `login_succeeded` | a login attempt settled; pass `user=` so attempts per account can be counted |
164
+ | `signup` | an account was created |
165
+ | `password_reset` | a reset was requested |
166
+ | `mfa_failed` | a second factor was rejected |
167
+ | `payment_failed` / `payment_succeeded` | a payment authorisation settled |
168
+ | `coupon_failed` | a promo/voucher code was rejected |
169
+
170
+ A route you gate yourself: `serve_challenge(request)` (Flask: `serve_challenge()`) returns the
171
+ page as a framework response to return from the handler until the browser holds a valid `_cch`,
172
+ then `None`.
173
+
174
+ ## What this tap can see
175
+
176
+ `sdk-python` is an in-app tap: status, latency, session, the beacon's browser signals and your
177
+ outcomes. Under ASGI the event also carries the wire's header order (`hord`); WSGI's `environ`
178
+ loses it, which is why ASGI is the first-class tier. The analyst knows what this tap can see and
179
+ never scores the absence of header order, ASN, country or a TLS fingerprint against a request;
180
+ ASN and country it resolves itself. Enforcement at this position covers ip, path, user-agent and
181
+ header conditions — ASN, country and TLS entries fail open in-app. `matches` patterns are JS
182
+ regexes read by Python's `re` (named groups, `[^]` and `\cX` are translated, `\d`/`\w`/`\b` stay
183
+ ASCII); a spelling `re` still rejects never matches here, while it does at the edge.
184
+
185
+ ## Deploying it
186
+
187
+ - Every worker process polls its own snapshot (about 5 MB resident) and flushes its own batches;
188
+ the tenant's `poll_seconds` keeps the cadence honest across a fleet.
189
+ - Threads do not survive a fork. Under gunicorn `--preload` or uWSGI the SDK notices the fork
190
+ (`os.register_at_fork`) and restarts its poll and flush threads in each worker.
191
+ - Pending events drain at interpreter exit within half a second. No signal handlers are
192
+ installed — an app owns its own shutdown — so a worker killed by SIGTERM without one may drop
193
+ its last batch.
194
+ - Serverless: `CAMADA_SERVERLESS=1`. A cold invocation fails open and catches up on the next one.
195
+
196
+ ## Fail open
197
+
198
+ Every entry point runs inside the fail-open envelope: a dead ingest drops telemetry (logged at
199
+ most once a minute), a corrupt snapshot keeps the previous one, a bug in the package costs the
200
+ request its join, never its response. `CAMADA_DISABLED=1` bypasses everything.
201
+
202
+ ## Development
203
+
204
+ ```
205
+ uv sync && uv run ruff check && uv run mypy && uv run pytest
206
+ ```
207
+
208
+ The suite reads the golden snapshot fixtures from the `camada-core` sibling checkout and pins the
209
+ vendored beacon to `camada-browser/dist/auto.global.js` (`npm run build` there first, then
210
+ `python scripts/sync_beacon.py` after a beacon release). Both fail by name when the checkout is
211
+ missing rather than skipping.
212
+
213
+ [`camada-python-example`](../camada-python-example) is the hand-test bench (FastAPI under uvicorn on
214
+ :3002), and `node scripts/e2e-sdk-python.mjs` in `camada/edge-analyst` drives it against a seeded
215
+ local analyst over real HTTP, cold first request included.
camada-0.2.0/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # camada
2
+
3
+ camada for Python: enforces the tenant snapshot inline (your ordered custom rules, then allow,
4
+ block, challenge), serves a first-party proof-of-work challenge page and beacon, records the
5
+ outcomes your handlers know (`track()`), and ships wire events in batches off the request path.
6
+ One package on PyPI with an ASGI middleware (the first-class tier: it keeps the wire's header
7
+ order), a WSGI middleware, and integrations for FastAPI, Django and Flask — the `sentry-sdk`
8
+ model. Fails open by design: a camada outage or bug never 5xxes your app.
9
+
10
+ Not yet on PyPI — install it from a sibling checkout: `pip install -e ../camada-python` (or a uv
11
+ path dependency, as [`camada-python-example`](../camada-python-example) does); publishing is one
12
+ decision with the npm packages (SDK-G01). Python 3.10 or newer, no runtime dependencies.
13
+
14
+ ## Quickstart
15
+
16
+ ```python
17
+ # FastAPI / Starlette
18
+ from camada.fastapi import CamadaMiddleware
19
+ app.add_middleware(CamadaMiddleware) # last, so it runs outermost
20
+
21
+ # Django — settings.py
22
+ MIDDLEWARE = ["camada.django.CamadaMiddleware", *MIDDLEWARE] # first
23
+
24
+ # Flask
25
+ from camada.flask import init_app
26
+ init_app(app) # wraps app.wsgi_app, so camada answers before routing
27
+
28
+ # any ASGI 3 or WSGI app
29
+ from camada.asgi import CamadaASGI # application = CamadaASGI(application)
30
+ from camada.wsgi import CamadaWSGI # application = CamadaWSGI(application)
31
+ ```
32
+
33
+ Env (printed by camada onboarding / `npm run seed` in dev):
34
+
35
+ ```
36
+ CAMADA_KEY=<ingest_token>.<snap_token>
37
+ CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
38
+ ```
39
+
40
+ The integrations share one lazy engine built from the environment on the first request. That
41
+ build starts the snapshot poll on a thread and never blocks, so the request that triggered it is
42
+ answered cold: it passes (fail open), and so does anything else that arrives before that first
43
+ poll lands (a few hundred milliseconds against a local analyst; snapshot-size and network bound).
44
+ To enforce from request 1, warm the engine in a startup hook by waiting for the boot poll —
45
+ `snap.refresh()` alone is not it, the boot poll already holds the single-in-flight lock:
46
+
47
+ ```python
48
+ import time
49
+ import camada
50
+ from camada.snapshot.match import MatchInput
51
+
52
+ engine = camada.get_default() # builds the engine; the boot poll is already running on its thread
53
+ if engine.snap: # None when CAMADA_KEY is unset or CAMADA_DISABLED=1
54
+ deadline = time.monotonic() + 5
55
+ while engine.snap.verdict(MatchInput(ip="0.0.0.0")).reason == "cold" and time.monotonic() < deadline:
56
+ time.sleep(0.01) # bounded: an unreachable analyst leaves it cold, and the app still fails open
57
+ ```
58
+
59
+ `snap.refresh()` is not the warm-up: the boot poll holds the single-in-flight lock, so a
60
+ synchronous `refresh()` called right after `get_default()` returns at once and the engine is
61
+ still cold.
62
+
63
+ Without `CAMADA_KEY` the engine is inert (one log line, no requests, no enforcement). An app that
64
+ reads its own config builds the engine itself and hands it in:
65
+
66
+ ```python
67
+ from camada import Camada
68
+ engine = Camada(env={"CAMADA_KEY": MY_KEY, "CAMADA_INGEST_URL": MY_INGEST})
69
+ app.add_middleware(CamadaMiddleware, engine=engine) # init_app(app, engine=engine) for Flask
70
+ ```
71
+
72
+ ## What it does per request
73
+
74
+ 1. Keeps the snapshot fresh. A Python server is a long-lived process, so the default is a daemon
75
+ poll thread at the cadence your tenant config sets (`poll_seconds`), with ETag/304 and gzip on
76
+ the wire. `CAMADA_SERVERLESS=1` switches to a per-request staleness check with no thread. Every
77
+ poll and event batch carries `x-camada-sdk: @camada/python/<version>`, and polls ask for
78
+ snapshot v5 (`x-camada-snapshot: 5`) — the container that carries your ordered custom rules.
79
+ 2. Resolves the client from the socket peer (`REMOTE_ADDR` / `scope["client"]`), combined with
80
+ `X-Forwarded-For` only under your tenant's trusted-proxy config (or `CAMADA_TRUSTED_PROXY`
81
+ locally). A forwarded header on its own is never the ip: any caller can set it.
82
+ 3. Enforces before anything else, beacon endpoints included: your ordered custom rules first (first
83
+ match wins; they read ip, path, user-agent and request headers), then allow → block → challenge.
84
+ A block answers `403 Forbidden` with `x-block-reason`, `x-block-version` and, when a rule
85
+ decided, `x-block-rule`; its event ships with `blk` (and `rl`). A `warn` rule passes and stamps
86
+ `wrn`; a `skip` rule passes with nothing stamped. Cold (no snapshot yet) passes: fail open.
87
+ 4. Challenge: a `challenge` verdict gets the self-contained proof-of-work page (or 403 JSON for a
88
+ non-HTML request); `POST /__camada/challenge` verifies the solution, sets `_cch` (bound to the
89
+ ip, one hour) and 302s back. A request whose ip cannot be resolved is never challenged.
90
+ 5. Serves the beacon: `GET /_cam/b.js` (the `@camada/browser` build, vendored) and `POST /_cam/fp`
91
+ (≤ 32 KB, relayed onto the event batch as a `sig: 1` row with the ip camada resolved). Both
92
+ fall through to your app when the tenant switched the beacon off.
93
+ 6. Runs your app with `x-rid` and the `_sfp` session cookie on its response, and when the response
94
+ is done ships one redacted event: method, host, path, scrubbed query, status, latency, header
95
+ names/sizes/order, the auth scheme (never the credential), cookie count (never values). An
96
+ exception in your app ships as `st: 500` and propagates unchanged.
97
+
98
+ ## Options
99
+
100
+ `Camada(...)` keyword arguments; everything credential-shaped comes from the environment.
101
+
102
+ | option | default | meaning |
103
+ |---|---|---|
104
+ | `env` | `os.environ` | where `CAMADA_*` are read from |
105
+ | `transport` | urllib | the HTTP callable that reaches the analyst (tests inject a fake) |
106
+ | `refresh_s` | server-steered | poll cadence; set, it is pinned |
107
+ | `challenge` | `True` | serve the proof-of-work page for challenge verdicts (`CAMADA_CHALLENGE=0` too) |
108
+ | `challenge_path` | `/__camada/challenge` | where the page posts its solution |
109
+ | `snapshot_version` | `5` | 4 drops your custom rules; 3 the allow/challenge sides too |
110
+ | `script_path` / `fp_path` | `/_cam/b.js` / `/_cam/fp` | the beacon endpoints; keep them in one directory |
111
+
112
+ Env: `CAMADA_KEY` (or `CAMADA_TOKEN` + `CAMADA_SNAPSHOT_TOKEN`), `CAMADA_INGEST_URL`,
113
+ `CAMADA_SNAPSHOT_URL`, `CAMADA_TRUSTED_PROXY` (`none | vercel | hops:N | cidrs:a,b`),
114
+ `CAMADA_SERVERLESS=1`, `CAMADA_CHALLENGE=0`, and the kill switch `CAMADA_DISABLED=1` (checked per
115
+ request; set at boot, no threads start at all).
116
+
117
+ ## The first-party beacon
118
+
119
+ ```python
120
+ from camada.fastapi import script_tag # camada.django: script_tag(request); camada.flask: script_tag()
121
+ return HTMLResponse(f"<html><head>{script_tag(request)}</head>…")
122
+ ```
123
+
124
+ The tag is `<script src="/_cam/b.js?r=<rid>" async>`, so the beacon joins the page view that
125
+ served it. Move both paths with `script_path` / `fp_path` when `/_cam/` is not yours; the script
126
+ derives the post path from its own URL, so the two must share a directory.
127
+
128
+ ## App-context events
129
+
130
+ ```python
131
+ from camada.fastapi import track # camada.django: track(request, ...); camada.flask: track(event, user=...)
132
+ track(request, "login_failed", user=email)
133
+ ```
134
+
135
+ The identifier is HMAC-hashed in-process with your ingest token; the raw value never reaches the
136
+ queue. `track()` never raises and is a no-op on a request the middleware did not run for. The
137
+ event name is free-form; the analyst's app-context rules read this vocabulary:
138
+
139
+ | event | when |
140
+ |---|---|
141
+ | `login_failed` / `login_succeeded` | a login attempt settled; pass `user=` so attempts per account can be counted |
142
+ | `signup` | an account was created |
143
+ | `password_reset` | a reset was requested |
144
+ | `mfa_failed` | a second factor was rejected |
145
+ | `payment_failed` / `payment_succeeded` | a payment authorisation settled |
146
+ | `coupon_failed` | a promo/voucher code was rejected |
147
+
148
+ A route you gate yourself: `serve_challenge(request)` (Flask: `serve_challenge()`) returns the
149
+ page as a framework response to return from the handler until the browser holds a valid `_cch`,
150
+ then `None`.
151
+
152
+ ## What this tap can see
153
+
154
+ `sdk-python` is an in-app tap: status, latency, session, the beacon's browser signals and your
155
+ outcomes. Under ASGI the event also carries the wire's header order (`hord`); WSGI's `environ`
156
+ loses it, which is why ASGI is the first-class tier. The analyst knows what this tap can see and
157
+ never scores the absence of header order, ASN, country or a TLS fingerprint against a request;
158
+ ASN and country it resolves itself. Enforcement at this position covers ip, path, user-agent and
159
+ header conditions — ASN, country and TLS entries fail open in-app. `matches` patterns are JS
160
+ regexes read by Python's `re` (named groups, `[^]` and `\cX` are translated, `\d`/`\w`/`\b` stay
161
+ ASCII); a spelling `re` still rejects never matches here, while it does at the edge.
162
+
163
+ ## Deploying it
164
+
165
+ - Every worker process polls its own snapshot (about 5 MB resident) and flushes its own batches;
166
+ the tenant's `poll_seconds` keeps the cadence honest across a fleet.
167
+ - Threads do not survive a fork. Under gunicorn `--preload` or uWSGI the SDK notices the fork
168
+ (`os.register_at_fork`) and restarts its poll and flush threads in each worker.
169
+ - Pending events drain at interpreter exit within half a second. No signal handlers are
170
+ installed — an app owns its own shutdown — so a worker killed by SIGTERM without one may drop
171
+ its last batch.
172
+ - Serverless: `CAMADA_SERVERLESS=1`. A cold invocation fails open and catches up on the next one.
173
+
174
+ ## Fail open
175
+
176
+ Every entry point runs inside the fail-open envelope: a dead ingest drops telemetry (logged at
177
+ most once a minute), a corrupt snapshot keeps the previous one, a bug in the package costs the
178
+ request its join, never its response. `CAMADA_DISABLED=1` bypasses everything.
179
+
180
+ ## Development
181
+
182
+ ```
183
+ uv sync && uv run ruff check && uv run mypy && uv run pytest
184
+ ```
185
+
186
+ The suite reads the golden snapshot fixtures from the `camada-core` sibling checkout and pins the
187
+ vendored beacon to `camada-browser/dist/auto.global.js` (`npm run build` there first, then
188
+ `python scripts/sync_beacon.py` after a beacon release). Both fail by name when the checkout is
189
+ missing rather than skipping.
190
+
191
+ [`camada-python-example`](../camada-python-example) is the hand-test bench (FastAPI under uvicorn on
192
+ :3002), and `node scripts/e2e-sdk-python.mjs` in `camada/edge-analyst` drives it against a seeded
193
+ local analyst over real HTTP, cold first request included.
@@ -0,0 +1,88 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "camada"
7
+ dynamic = ["version"]
8
+ description = "camada SDK for Python: inline enforcement of your snapshot (ordered custom rules, then allow, block, challenge), first-party beacon and proof-of-work challenge, batched event shipping. ASGI and WSGI middleware; Django, Flask and FastAPI integrations."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = []
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware",
20
+ "Topic :: Security",
21
+ "Framework :: Django",
22
+ "Framework :: Flask",
23
+ "Framework :: FastAPI",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://camada.app"
28
+ Repository = "https://github.com/camada-app/camada-python"
29
+ Issues = "https://github.com/camada-app/camada-python/issues"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest>=8",
34
+ "ruff>=0.6",
35
+ "mypy>=1.11",
36
+ "django>=4.2",
37
+ "flask>=3",
38
+ "fastapi>=0.110",
39
+ "httpx>=0.27",
40
+ ]
41
+
42
+ [tool.hatch.version]
43
+ path = "src/camada/version.py"
44
+
45
+ [tool.uv]
46
+ # uv fingerprints a path dependency by its pyproject alone; the version lives in version.py (hatch dynamic),
47
+ # so a consumer's `uv lock` would keep the old number without this
48
+ cache-keys = [{ file = "pyproject.toml" }, { file = "src/camada/version.py" }]
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/camada"]
52
+
53
+ [tool.hatch.build.targets.sdist]
54
+ include = ["src/camada", "README.md", "LICENSE"]
55
+
56
+ [tool.ruff]
57
+ line-length = 140
58
+ target-version = "py310"
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "I", "B", "UP", "W"]
62
+
63
+ [tool.ruff.lint.isort]
64
+ known-first-party = ["camada"]
65
+
66
+ [tool.ruff.lint.per-file-ignores]
67
+ "src/camada/beacon_js.py" = ["E501"] # generated: one vendored JS string
68
+ "tests/*" = ["E501"]
69
+
70
+ [tool.mypy]
71
+ strict = true
72
+ files = ["src"]
73
+ python_version = "3.10"
74
+
75
+ [[tool.mypy.overrides]]
76
+ module = ["django.*", "flask.*", "starlette.*", "fastapi.*"]
77
+ ignore_missing_imports = true
78
+
79
+ [tool.pytest.ini_options]
80
+ testpaths = ["tests"]
81
+ addopts = "-q"
82
+ filterwarnings = [
83
+ "error",
84
+ # third-party test-client deprecations (starlette's httpx2 nudge, anyio alias), not ours
85
+ "ignore:.*httpx2.*",
86
+ "ignore::DeprecationWarning:starlette.*",
87
+ "ignore::DeprecationWarning:anyio.*",
88
+ ]
@@ -0,0 +1,47 @@
1
+ """camada for Python: inline enforcement of your tenant snapshot (ordered custom rules, then
2
+ allow, block, challenge), a first-party proof-of-work challenge and beacon, app-context events,
3
+ and batched wire events shipped off the request path. Fails open by design.
4
+
5
+ Quickstart (env: CAMADA_KEY, plus CAMADA_INGEST_URL in dev):
6
+
7
+ # FastAPI / Starlette
8
+ from camada.fastapi import CamadaMiddleware
9
+ app.add_middleware(CamadaMiddleware)
10
+
11
+ # Django: MIDDLEWARE = ["camada.django.CamadaMiddleware", ...]
12
+ # Flask: camada.flask.init_app(app)
13
+ # Any ASGI / WSGI app: camada.asgi.CamadaASGI(app) / camada.wsgi.CamadaWSGI(app)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Mapping
18
+ from typing import Any
19
+
20
+ from .engine import Answer, Camada, Passed, Req, create_camada, engine_of
21
+ from .version import SDK_ID, __version__
22
+
23
+ __all__ = ["Answer", "Camada", "Passed", "Req", "SDK_ID", "__version__", "configure", "create_camada", "engine_for", "get_default"]
24
+
25
+ _default: Camada | None = None
26
+
27
+
28
+ def get_default(**opts: Any) -> Camada:
29
+ """The lazy singleton wired from the environment on first use (what the integrations share)."""
30
+ global _default
31
+ if _default is None:
32
+ _default = create_camada(**opts)
33
+ return _default
34
+
35
+
36
+ def engine_for(ctx: Mapping[str, Any] | None) -> Camada:
37
+ """The engine that produced a request context, else the default: what the integrations resolve through."""
38
+ return engine_of(ctx) or get_default()
39
+
40
+
41
+ def configure(**opts: Any) -> Camada:
42
+ """Replaces the default engine (stopping the old one) — for tests and explicit wiring."""
43
+ global _default
44
+ if _default is not None:
45
+ _default.stop()
46
+ _default = create_camada(**opts)
47
+ return _default