veil-api-kit 0.10.0__py3-none-any.whl

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,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: veil-api-kit
3
+ Version: 0.10.0
4
+ Summary: Helpers for veil-api: cookies, sessions, CORS, security, static files, GraphQL, gRPC client, DI, tasks
5
+ Author: aofusa
6
+ License-Expression: Apache-2.0 OR MIT
7
+ Project-URL: Documentation, https://veil-api.readthedocs.io
8
+ Project-URL: Source, https://github.com/aofusa/veil-proxy
9
+ Project-URL: Changelog, https://github.com/aofusa/veil-proxy/releases
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: veil-api==0.10.0
17
+ Provides-Extra: jwt
18
+ Requires-Dist: PyJWT>=2; extra == "jwt"
19
+ Provides-Extra: graphql
20
+ Requires-Dist: strawberry-graphql>=0.200; extra == "graphql"
21
+ Provides-Extra: grpc
22
+ Requires-Dist: protobuf>=4; extra == "grpc"
23
+ Provides-Extra: grpcio
24
+ Requires-Dist: grpcio; extra == "grpcio"
25
+ Requires-Dist: grpcio-tools; extra == "grpcio"
26
+ Requires-Dist: protobuf>=4; extra == "grpcio"
27
+ Provides-Extra: redis
28
+ Requires-Dist: redis>=4.2; extra == "redis"
29
+ Provides-Extra: cron
30
+ Requires-Dist: croniter; extra == "cron"
31
+ Provides-Extra: all
32
+ Requires-Dist: PyJWT>=2; extra == "all"
33
+ Requires-Dist: strawberry-graphql>=0.200; extra == "all"
34
+ Requires-Dist: protobuf>=4; extra == "all"
35
+ Requires-Dist: grpcio; extra == "all"
36
+ Requires-Dist: grpcio-tools; extra == "all"
37
+ Requires-Dist: redis>=4.2; extra == "all"
38
+ Requires-Dist: croniter; extra == "all"
39
+
40
+ # veil-api-kit
41
+
42
+ Helpers for [veil-api](https://pypi.org/project/veil-api/) applications (`import veil_kit`). It is a pure-Python
43
+ package with the same version as `veil-api`. Optional dependencies come with extras:
44
+
45
+ ```sh
46
+ pip install veil-api-kit # cookies, sessions, CORS, security, static files, DI, tasks
47
+ pip install "veil-api-kit[jwt]" # JWT (PyJWT)
48
+ pip install "veil-api-kit[graphql]" # GraphQL (strawberry)
49
+ pip install "veil-api-kit[grpc]" # gRPC client and server helpers (protobuf)
50
+ pip install "veil-api-kit[grpcio]" # + grpcio and grpcio-tools (code generation, grpc.RpcError)
51
+ pip install "veil-api-kit[redis]" # Redis session store
52
+ pip install "veil-api-kit[cron]" # croniter (more cron syntax)
53
+ pip install "veil-api-kit[all]"
54
+ ```
55
+
56
+ | Module | What it does |
57
+ |---|---|
58
+ | `veil_kit.cookies` | Signed cookies (itsdangerous-compatible format, key rotation) |
59
+ | `veil_kit.sessions` | Cookie sessions (Starlette `SessionMiddleware` format) and server-side stores (memory, Redis) |
60
+ | `veil_kit.cors` | `CORSMiddleware` with Starlette's arguments, answered natively by the server |
61
+ | `veil_kit.security` | `HTTPBasic`, `HTTPBearer`, `APIKeyHeader`, `OAuth2PasswordBearer`, `OAuth2PasswordRequestForm`, `JWT` |
62
+ | `veil_kit.redirects` | `safe_redirect`: redirect only to this site or to listed hosts |
63
+ | `veil_kit.staticfiles` | `StaticFiles(directory=...)` served by the server's file backend (sendfile) |
64
+ | `veil_kit.graphql` | strawberry schemas over HTTP, SSE and WebSocket, with GraphiQL |
65
+ | `veil_kit.grpc` | A channel for `grpc_tools` stubs (sync and `grpc.aio` style), server helpers |
66
+ | `veil_kit.di` | A dependency injection container (singleton, scoped, transient) |
67
+ | `veil_kit.tasks` | Periodic, cron and resident tasks |
68
+ | `veil_kit.testing` | `TestClient` and dependency overrides |
69
+
70
+ ## Example
71
+
72
+ ```python
73
+ import veil
74
+ from veil_kit.cors import CORSMiddleware
75
+ from veil_kit.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm, JWT
76
+ from veil_kit.staticfiles import StaticFiles
77
+
78
+ app = veil.App()
79
+ app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"])
80
+ app.mount("/static", StaticFiles(directory="public"))
81
+
82
+ oauth2 = OAuth2PasswordBearer(tokenUrl="/token")
83
+ tokens = JWT(secret="change-me")
84
+
85
+ @app.post("/token")
86
+ def token(form: OAuth2PasswordRequestForm = veil.Depends()):
87
+ if form.password != "secret":
88
+ raise veil.HTTPException(400, "bad credentials")
89
+ return {"access_token": tokens.encode({"sub": form.username}), "token_type": "bearer"}
90
+
91
+ def current_user(token: str = veil.Depends(oauth2)):
92
+ return tokens.decode(token)["sub"]
93
+
94
+ @app.get("/me")
95
+ def me(user: str = veil.Depends(current_user)):
96
+ return {"user": user}
97
+
98
+ app.run(listen_cleartext="127.0.0.1:8000")
99
+ ```
100
+
101
+ Documentation: <https://veil-api.readthedocs.io> (the "Kit" and "FastAPI compatibility" pages).
102
+
103
+ License: Apache-2.0 OR MIT.
@@ -0,0 +1,17 @@
1
+ veil_kit/__init__.py,sha256=W8SKuZsebVKp9PEt0bs-ppJCm0BR2eF8-eJ5irIWQUM,1132
2
+ veil_kit/cookies.py,sha256=fckRTNRKqdAHahxsFfksCRQK-GcwmsFYWyA7MPQuJPM,5445
3
+ veil_kit/cors.py,sha256=30Ba4xNTrqA6MgVOyHpWv87vDYF6kJ3pyQo5A8eUElU,1536
4
+ veil_kit/di.py,sha256=LaZNOedUnbn5fnPl666d39mcYIavOWgR3_MR2LLjvaI,13240
5
+ veil_kit/graphql.py,sha256=dUWBUBqV7cyaJ29pWwOjJqO5te5HWhzNbSnojvMDoNw,9561
6
+ veil_kit/grpc.py,sha256=1C59xV2gsGySNfoc5fP5M2N6UKJv2LRiE61wACjjWd0,17468
7
+ veil_kit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ veil_kit/redirects.py,sha256=pQ-76L-0sJfGHq_IJXAh06XhO5BdFpaiTOB1IwQkgPQ,1258
9
+ veil_kit/security.py,sha256=f91cG7OoMbWlIp1mCAo2b8lkamWdv9wHcaIiCH3BO_E,14166
10
+ veil_kit/sessions.py,sha256=TVXuY30W4WoknfRHtWCmBF96DDzwVIf5GO3BGU8yRQw,8326
11
+ veil_kit/staticfiles.py,sha256=F3luFEUMbswyIKFXKPh02UG-UKYC5AGF0j4CoWaSGDM,1525
12
+ veil_kit/tasks.py,sha256=KGoAi5hW_FVVCezYq6Sz2jhdLuxAuvd_KVyZtLW_4b0,7152
13
+ veil_kit/testing.py,sha256=qR9HBxaIJaMNQArPd-zUsfBdh-ti0iWWq4M-EhAkwYc,783
14
+ veil_api_kit-0.10.0.dist-info/METADATA,sha256=TotXcHXe7fWks4aJXZuYGS46O-dTyKVqKKpIAPNShzo,4454
15
+ veil_api_kit-0.10.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
16
+ veil_api_kit-0.10.0.dist-info/top_level.txt,sha256=r5W7RUXJPq_6VTKI-FoF_E_Az6Ik-FjF2SC6afETDkY,9
17
+ veil_api_kit-0.10.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ veil_kit
veil_kit/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Helpers for veil-api applications.
2
+
3
+ Each module is imported on its own (``from veil_kit.security import OAuth2PasswordBearer``); optional
4
+ dependencies are installed with extras (``pip install "veil-api-kit[jwt,graphql,grpc]"``):
5
+
6
+ * ``veil_kit.cookies`` - signed cookies (itsdangerous-compatible format, key rotation)
7
+ * ``veil_kit.sessions`` - cookie sessions (Starlette-compatible) and server-side stores (memory, Redis)
8
+ * ``veil_kit.cors`` - ``CORSMiddleware`` on top of the native ``app.cors()``
9
+ * ``veil_kit.security`` - HTTP Basic / Bearer / Digest, API keys, OAuth2, JWT (PyJWT)
10
+ * ``veil_kit.redirects`` - ``safe_redirect`` to allowed hosts only
11
+ * ``veil_kit.staticfiles`` - ``StaticFiles`` served natively (sendfile, no Python per request)
12
+ * ``veil_kit.graphql`` - strawberry schemas over HTTP, SSE and WebSocket
13
+ * ``veil_kit.grpc`` - a channel for ``grpc_tools`` stubs and the server helpers
14
+ * ``veil_kit.di`` - a dependency injection container
15
+ * ``veil_kit.tasks`` - periodic, cron and resident tasks
16
+ * ``veil_kit.testing`` - ``TestClient`` and dependency overrides
17
+ """
18
+
19
+ __version__ = "0.10.0"
20
+
21
+ __all__ = ["__version__"]
veil_kit/cookies.py ADDED
@@ -0,0 +1,145 @@
1
+ """Signed cookies.
2
+
3
+ :class:`Signer` produces and checks the format of itsdangerous' ``TimestampSigner`` (``value.timestamp.signature``,
4
+ HMAC-SHA1 with the ``django-concat`` key derivation), so cookies written by Flask or Starlette's ``SessionMiddleware``
5
+ with the same secret are accepted and the other way round. Several secrets rotate keys: the last one signs, all of them
6
+ verify.
7
+
8
+ .. code-block:: python
9
+
10
+ from veil_kit.cookies import SignedCookies
11
+
12
+ cookies = SignedCookies("change-me", max_age=3600)
13
+
14
+ @app.get("/login")
15
+ def login(response: veil.Response):
16
+ cookies.set(response, "user", "alice", httponly=True)
17
+ return "ok"
18
+
19
+ @app.get("/me")
20
+ def me(request: veil.Request):
21
+ return {"user": cookies.get(request, "user")}
22
+ """
23
+
24
+ # F-268: itsdangerous の TimestampSigner と同じ形式(Starlette の SessionMiddleware の Cookie と互換)
25
+
26
+ import base64
27
+ import hashlib
28
+ import hmac
29
+ import time
30
+
31
+ __all__ = ["Signer", "BadSignature", "SignatureExpired", "SignedCookies", "parse_cookies"]
32
+
33
+
34
+ class BadSignature(Exception):
35
+ """The value was not signed with one of the secrets (or was changed)."""
36
+
37
+
38
+ class SignatureExpired(BadSignature):
39
+ """The signature is valid but older than ``max_age``."""
40
+
41
+
42
+ def _b64e(b):
43
+ return base64.urlsafe_b64encode(b).rstrip(b"=")
44
+
45
+
46
+ def _b64d(b):
47
+ return base64.urlsafe_b64decode(b + b"=" * (-len(b) % 4))
48
+
49
+
50
+ def _int_to_bytes(n):
51
+ return n.to_bytes((n.bit_length() + 7) // 8 or 1, "big")
52
+
53
+
54
+ class Signer:
55
+ """Signs values with a timestamp (the format of itsdangerous' ``TimestampSigner``)."""
56
+
57
+ def __init__(self, secret_key, salt=b"itsdangerous.Signer", sep=b"."):
58
+ """``secret_key`` is a ``str`` / ``bytes`` or a list of them (oldest first)."""
59
+ keys = secret_key if isinstance(secret_key, (list, tuple)) else [secret_key]
60
+ if not keys:
61
+ raise ValueError("at least one secret key is needed")
62
+ salt = salt.encode() if isinstance(salt, str) else salt
63
+ self.sep = sep
64
+ # django-concat: sha1(salt + b"signer" + secret)
65
+ self._keys = [hashlib.sha1(salt + b"signer" + (k.encode() if isinstance(k, str) else k)).digest() for k in keys]
66
+
67
+ def _sig(self, key, value):
68
+ return _b64e(hmac.new(key, value, hashlib.sha1).digest())
69
+
70
+ def sign(self, value):
71
+ """Return ``value.timestamp.signature`` (``bytes``)."""
72
+ value = value.encode() if isinstance(value, str) else value
73
+ v = value + self.sep + _b64e(_int_to_bytes(int(time.time())))
74
+ return v + self.sep + self._sig(self._keys[-1], v)
75
+
76
+ def unsign(self, signed, max_age=None):
77
+ """Return the value of ``signed``; raise :class:`BadSignature` or :class:`SignatureExpired`."""
78
+ signed = signed.encode() if isinstance(signed, str) else signed
79
+ v, sep, sig = signed.rpartition(self.sep)
80
+ if not sep:
81
+ raise BadSignature("no separator")
82
+ if not any(hmac.compare_digest(self._sig(k, v), sig) for k in reversed(self._keys)):
83
+ raise BadSignature("signature does not match")
84
+ value, sep, ts = v.rpartition(self.sep)
85
+ if not sep:
86
+ raise BadSignature("no timestamp")
87
+ try:
88
+ t = int.from_bytes(_b64d(ts), "big")
89
+ except Exception: # noqa: BLE001
90
+ raise BadSignature("bad timestamp")
91
+ if max_age is not None and time.time() - t > max_age:
92
+ raise SignatureExpired("signature expired")
93
+ return value
94
+
95
+
96
+ def parse_cookies(header):
97
+ """Parse a ``Cookie`` header into a dict (the first value of a repeated name wins; quotes are removed)."""
98
+ out = {}
99
+ for part in (header or "").split(";"):
100
+ name, eq, value = part.partition("=")
101
+ if not eq:
102
+ continue
103
+ name = name.strip()
104
+ value = value.strip()
105
+ if len(value) >= 2 and value[0] == value[-1] == '"':
106
+ value = value[1:-1]
107
+ if name and name not in out:
108
+ out[name] = value
109
+ return out
110
+
111
+
112
+ def _request_cookies(request):
113
+ c = getattr(request, "cookies", None)
114
+ if c is not None:
115
+ return c
116
+ return parse_cookies(", ".join(request.headers.getlist("cookie")).replace(", ", "; "))
117
+
118
+
119
+ class SignedCookies:
120
+ """Reads and writes cookies signed by a :class:`Signer`."""
121
+
122
+ def __init__(self, secret_key, max_age=None, salt="veil_kit.cookies"):
123
+ """``max_age`` (seconds) rejects older signatures; ``salt`` separates the uses of one secret."""
124
+ self.signer = Signer(secret_key, salt=salt)
125
+ self.max_age = max_age
126
+
127
+ def get(self, request, name, default=None):
128
+ """The verified value of cookie ``name`` (``default`` when absent, unsigned, changed or expired)."""
129
+ raw = _request_cookies(request).get(name)
130
+ if raw is None:
131
+ return default
132
+ try:
133
+ return self.signer.unsign(raw, self.max_age).decode("utf-8")
134
+ except (BadSignature, UnicodeDecodeError):
135
+ return default
136
+
137
+ def set(self, response, name, value, **options):
138
+ """Set cookie ``name`` to the signed ``value`` (``options`` as for ``Response.set_cookie``)."""
139
+ if self.max_age is not None:
140
+ options.setdefault("max_age", int(self.max_age))
141
+ response.set_cookie(name, self.signer.sign(str(value)).decode("ascii"), **options)
142
+
143
+ def delete(self, response, name, **options):
144
+ """Delete cookie ``name``."""
145
+ response.delete_cookie(name, **options)
veil_kit/cors.py ADDED
@@ -0,0 +1,36 @@
1
+ """CORS with the arguments of Starlette's ``CORSMiddleware``.
2
+
3
+ .. code-block:: python
4
+
5
+ from veil_kit.cors import CORSMiddleware
6
+
7
+ app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"], allow_credentials=True)
8
+
9
+ The headers are added by the server (Rust; the same implementation as the Rust ``veil-proxy-kit``): preflight requests
10
+ are answered without calling Python, and the header values are built once at startup.
11
+ """
12
+
13
+ # F-268: 中身は Rust(veil_kit::cors)。Python の層で要求ごとにヘッダを作らない。
14
+
15
+ __all__ = ["CORSMiddleware"]
16
+
17
+
18
+ class CORSMiddleware:
19
+ """Pass to ``app.add_middleware`` (or call ``app.cors(...)`` with the same keyword arguments).
20
+
21
+ ``allow_origins`` lists exact origins or ``https://*.example.com`` patterns (``["*"]`` allows any origin, not with
22
+ ``allow_credentials``); ``allow_methods`` defaults to ``["GET"]`` (``["*"]`` = all), ``allow_headers`` to none,
23
+ ``expose_headers`` to none and ``max_age`` to 600 seconds. ``prefix`` limits CORS to the routes below a path.
24
+ ``allow_origin_regex`` is not supported.
25
+ """
26
+
27
+ def __init__(self, app=None, **options):
28
+ self.options = options
29
+
30
+ @staticmethod
31
+ def _veil_install(app, **options):
32
+ """Called by ``app.add_middleware``."""
33
+ if options.get("allow_origin_regex"):
34
+ raise ValueError("allow_origin_regex is not supported; list the origins (\"https://*.example.com\" works)")
35
+ options.pop("allow_origin_regex", None)
36
+ app.cors(**options)