pyweber 1.5.0.dev3__tar.gz → 1.6.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.
- {pyweber-1.5.0.dev3/pyweber.egg-info → pyweber-1.6.0}/PKG-INFO +26 -4
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/README.md +11 -3
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyproject.toml +9 -1
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/__init__.py +54 -3
- pyweber-1.6.0/pyweber/auth/__init__.py +44 -0
- pyweber-1.6.0/pyweber/auth/decorators.py +201 -0
- pyweber-1.6.0/pyweber/auth/passwords.py +52 -0
- pyweber-1.6.0/pyweber/auth/rbac.py +184 -0
- pyweber-1.6.0/pyweber/auth/session.py +146 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/cli/commands.py +9 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/components/__init__.py +0 -2
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/components/form.py +4 -2
- pyweber-1.6.0/pyweber/components/input.py +663 -0
- pyweber-1.6.0/pyweber/connection/session.py +134 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/connection/websocket.py +283 -123
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/core/element.py +66 -8
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/core/events.py +70 -7
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/core/template.py +77 -9
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/core/window.py +60 -27
- pyweber-1.6.0/pyweber/db/__init__.py +133 -0
- pyweber-1.6.0/pyweber/db/cli.py +254 -0
- pyweber-1.6.0/pyweber/db/config.py +125 -0
- pyweber-1.6.0/pyweber/db/engine.py +64 -0
- pyweber-1.6.0/pyweber/db/model.py +44 -0
- pyweber-1.6.0/pyweber/db/query.py +26 -0
- pyweber-1.6.0/pyweber/db/session.py +49 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/context.py +15 -1
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/cookies.py +18 -0
- pyweber-1.6.0/pyweber/models/dom_merge.py +92 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/element.py +19 -12
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/field_storage.py +13 -4
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/handoff.py +9 -2
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/openapi.py +1 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/request.py +38 -4
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/routes.py +62 -4
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/ws_message.py +28 -4
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/pyweber/pyweber.py +80 -371
- pyweber-1.6.0/pyweber/services/__init__.py +13 -0
- pyweber-1.6.0/pyweber/services/openapi_setup.py +73 -0
- pyweber-1.6.0/pyweber/services/response_pipeline.py +239 -0
- pyweber-1.6.0/pyweber/services/static_files.py +68 -0
- pyweber-1.6.0/pyweber/services/template_service.py +158 -0
- pyweber-1.6.0/pyweber/session_store/__init__.py +244 -0
- pyweber-1.6.0/pyweber/static/config.toml +54 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/js.js +78 -4
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/security.py +121 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/types.py +8 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0/pyweber.egg-info}/PKG-INFO +26 -4
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber.egg-info/SOURCES.txt +19 -0
- pyweber-1.6.0/pyweber.egg-info/requires.txt +31 -0
- pyweber-1.5.0.dev3/pyweber/components/input.py +0 -814
- pyweber-1.5.0.dev3/pyweber/connection/session.py +0 -50
- pyweber-1.5.0.dev3/pyweber/static/config.toml +0 -45
- pyweber-1.5.0.dev3/pyweber.egg-info/requires.txt +0 -12
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/LICENSE +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/MANIFEST.in +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/NOTICE +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/admin/index.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/admin/src/script.js +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/admin/src/style.css +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/cli/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/components/general.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/config/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/config/config.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/connection/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/connection/http.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/connection/reload.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/connection/selector.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/core/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/core/html_parser.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/create_app.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/error_pages.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/field.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/file.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/file_stream.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/headers.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/middleware.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/rate_limit.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/response.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/run.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/security.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/stream_stats.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/strem_stats.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/task_manager.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/models/template_diff.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/pyweber/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/.gitignore +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/css.css +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/docs.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/favicon/android-chrome-192x192.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/favicon/android-chrome-512x512.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/favicon/apple-touch-icon.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/favicon/favicon-16x16.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/favicon/favicon-32x32.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/favicon/favicon.ico +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/favicon/pyweber.png +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/favicon/site.webmanifest +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/handlers.js +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/html.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/html401.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/html404.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/html500.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/loading.html +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/main.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/pyweber.css +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/static/update.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/testing/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/testing/client.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/__init__.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/async_utils.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/deprecation.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/exceptions.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/icons.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/loads.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/mime.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber/utils/utils.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber.egg-info/dependency_links.txt +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber.egg-info/entry_points.txt +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/pyweber.egg-info/top_level.txt +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/setup.cfg +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/tests/test_config.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/tests/test_context.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/tests/test_cookies.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/tests/test_public_api.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/tests/test_response.py +0 -0
- {pyweber-1.5.0.dev3 → pyweber-1.6.0}/tests/test_template_diff.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyweber
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: A lightweight Python framework for building and managing web applications.
|
|
5
5
|
Author-email: DevPythonMZ <pypi.dev@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -10,6 +10,7 @@ Project-URL: Tracker, https://github.com/pyweber/pyweber/issues
|
|
|
10
10
|
Project-URL: PyPI, https://pypi.org/project/pyweber/
|
|
11
11
|
Project-URL: YouTube, https://youtube.com/@devpythonMZ
|
|
12
12
|
Project-URL: Examples, https://github.com/pyweber-examples
|
|
13
|
+
Project-URL: Security, https://pyweber.dev/guides/supported-versions/
|
|
13
14
|
Requires-Python: >=3.10
|
|
14
15
|
Description-Content-Type: text/markdown
|
|
15
16
|
License-File: LICENSE
|
|
@@ -24,6 +25,19 @@ Requires-Dist: pytest-cov; extra == "dev"
|
|
|
24
25
|
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
25
26
|
Provides-Extra: fast-html
|
|
26
27
|
Requires-Dist: lxml; extra == "fast-html"
|
|
28
|
+
Provides-Extra: db
|
|
29
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == "db"
|
|
30
|
+
Requires-Dist: alembic>=1.13; extra == "db"
|
|
31
|
+
Provides-Extra: db-sqlite
|
|
32
|
+
Requires-Dist: aiosqlite>=0.20; extra == "db-sqlite"
|
|
33
|
+
Provides-Extra: db-pg
|
|
34
|
+
Requires-Dist: asyncpg>=0.29; extra == "db-pg"
|
|
35
|
+
Provides-Extra: db-mysql
|
|
36
|
+
Requires-Dist: aiomysql>=0.2; extra == "db-mysql"
|
|
37
|
+
Provides-Extra: db-mssql
|
|
38
|
+
Requires-Dist: aioodbc>=0.5; extra == "db-mssql"
|
|
39
|
+
Provides-Extra: redis
|
|
40
|
+
Requires-Dist: redis[hiredis]>=8.1.0; extra == "redis"
|
|
27
41
|
Dynamic: license-file
|
|
28
42
|
|
|
29
43
|
# Pyweber Framework
|
|
@@ -42,17 +56,25 @@ Pyweber is a lightweight Python web framework designed to create dynamic, reacti
|
|
|
42
56
|
- **Intuitive DOM Manipulation**: Query and modify elements with familiar selectors
|
|
43
57
|
- **Event-Driven Programming**: Handle user interactions with Python event handlers
|
|
44
58
|
- **WebSocket Integration**: Real-time communication between client and server
|
|
45
|
-
- **Template Handoff (1.3.0)**: WebSocket sessions reuse the HTTP-rendered template — handlers are not executed twice on connect
|
|
59
|
+
- **Template Handoff (1.3.0+)**: WebSocket sessions reuse the HTTP-rendered template — handlers are not executed twice on connect
|
|
60
|
+
- **Identity-preserving sync (1.6.0+)**: `self` / subclass element refs stay valid after WS connect
|
|
61
|
+
- **Optional ORM & Redis sessions (1.6.0+)**: `pyweber[db]`, `pyweber[redis]`
|
|
62
|
+
- **Auth & RBAC (1.5+)**: signed login cookie, roles/permissions
|
|
46
63
|
- **Minimal Configuration**: Get started quickly with sensible defaults
|
|
47
64
|
|
|
48
65
|
## Quick Start
|
|
49
66
|
|
|
50
67
|
### Installation
|
|
51
68
|
|
|
69
|
+
!!! danger "Avoid ≤ 1.3.1"
|
|
70
|
+
Older lines are **not recommended** for production. Use a current release:
|
|
71
|
+
|
|
52
72
|
```bash
|
|
53
|
-
pip install pyweber
|
|
73
|
+
pip install -U 'pyweber>=1.6'
|
|
54
74
|
```
|
|
55
75
|
|
|
76
|
+
See [Supported versions](https://pyweber.dev/guides/supported-versions/) (docs) for yank / security notes. `pyproject.toml` cannot warn when someone pins an old wheel — that requires [PyPI yanking](https://docs.pypi.org/project-management/yanking/).
|
|
77
|
+
|
|
56
78
|
### Create a Project
|
|
57
79
|
|
|
58
80
|
```bash
|
|
@@ -249,7 +271,7 @@ pyweber add-section --section-name database
|
|
|
249
271
|
pyweber --update
|
|
250
272
|
```
|
|
251
273
|
|
|
252
|
-
Visit [Pyweber Docs](https://pyweber.dev/) for complete documentation. See [CHANGELOG.md](CHANGELOG.md) for release notes (latest: **1.
|
|
274
|
+
Visit [Pyweber Docs](https://pyweber.dev/) for complete documentation. See [CHANGELOG.md](CHANGELOG.md) for release notes (latest: **1.6.0** — optional ORM/Redis, identity-preserving WS sync, reactive event fixes).
|
|
253
275
|
|
|
254
276
|
## License
|
|
255
277
|
|
|
@@ -14,17 +14,25 @@ Pyweber is a lightweight Python web framework designed to create dynamic, reacti
|
|
|
14
14
|
- **Intuitive DOM Manipulation**: Query and modify elements with familiar selectors
|
|
15
15
|
- **Event-Driven Programming**: Handle user interactions with Python event handlers
|
|
16
16
|
- **WebSocket Integration**: Real-time communication between client and server
|
|
17
|
-
- **Template Handoff (1.3.0)**: WebSocket sessions reuse the HTTP-rendered template — handlers are not executed twice on connect
|
|
17
|
+
- **Template Handoff (1.3.0+)**: WebSocket sessions reuse the HTTP-rendered template — handlers are not executed twice on connect
|
|
18
|
+
- **Identity-preserving sync (1.6.0+)**: `self` / subclass element refs stay valid after WS connect
|
|
19
|
+
- **Optional ORM & Redis sessions (1.6.0+)**: `pyweber[db]`, `pyweber[redis]`
|
|
20
|
+
- **Auth & RBAC (1.5+)**: signed login cookie, roles/permissions
|
|
18
21
|
- **Minimal Configuration**: Get started quickly with sensible defaults
|
|
19
22
|
|
|
20
23
|
## Quick Start
|
|
21
24
|
|
|
22
25
|
### Installation
|
|
23
26
|
|
|
27
|
+
!!! danger "Avoid ≤ 1.3.1"
|
|
28
|
+
Older lines are **not recommended** for production. Use a current release:
|
|
29
|
+
|
|
24
30
|
```bash
|
|
25
|
-
pip install pyweber
|
|
31
|
+
pip install -U 'pyweber>=1.6'
|
|
26
32
|
```
|
|
27
33
|
|
|
34
|
+
See [Supported versions](https://pyweber.dev/guides/supported-versions/) (docs) for yank / security notes. `pyproject.toml` cannot warn when someone pins an old wheel — that requires [PyPI yanking](https://docs.pypi.org/project-management/yanking/).
|
|
35
|
+
|
|
28
36
|
### Create a Project
|
|
29
37
|
|
|
30
38
|
```bash
|
|
@@ -221,7 +229,7 @@ pyweber add-section --section-name database
|
|
|
221
229
|
pyweber --update
|
|
222
230
|
```
|
|
223
231
|
|
|
224
|
-
Visit [Pyweber Docs](https://pyweber.dev/) for complete documentation. See [CHANGELOG.md](CHANGELOG.md) for release notes (latest: **1.
|
|
232
|
+
Visit [Pyweber Docs](https://pyweber.dev/) for complete documentation. See [CHANGELOG.md](CHANGELOG.md) for release notes (latest: **1.6.0** — optional ORM/Redis, identity-preserving WS sync, reactive event fixes).
|
|
225
233
|
|
|
226
234
|
## License
|
|
227
235
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyweber"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.6.0"
|
|
8
8
|
description = "A lightweight Python framework for building and managing web applications."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "DevPythonMZ", email = "pypi.dev@gmail.com" }]
|
|
@@ -18,6 +18,8 @@ Tracker = "https://github.com/pyweber/pyweber/issues"
|
|
|
18
18
|
PyPI = "https://pypi.org/project/pyweber/"
|
|
19
19
|
YouTube = "https://youtube.com/@devpythonMZ"
|
|
20
20
|
Examples = "https://github.com/pyweber-examples"
|
|
21
|
+
# Supported versions / security policy (≤1.3.1 not recommended; yank on PyPI — not via this file)
|
|
22
|
+
Security = "https://pyweber.dev/guides/supported-versions/"
|
|
21
23
|
|
|
22
24
|
[project.scripts]
|
|
23
25
|
pyweber = "pyweber.cli.commands:app"
|
|
@@ -29,6 +31,12 @@ include = ["pyweber*"]
|
|
|
29
31
|
[project.optional-dependencies]
|
|
30
32
|
dev = ["pytest", "pytest-cov", "pytest-asyncio"]
|
|
31
33
|
fast-html = ["lxml"]
|
|
34
|
+
db = ["sqlalchemy[asyncio]>=2.0", "alembic>=1.13"]
|
|
35
|
+
db-sqlite = ["aiosqlite>=0.20"]
|
|
36
|
+
db-pg = ["asyncpg>=0.29"]
|
|
37
|
+
db-mysql = ["aiomysql>=0.2"]
|
|
38
|
+
db-mssql = ["aioodbc>=0.5"]
|
|
39
|
+
redis = ["redis[hiredis]>=8.1.0"]
|
|
32
40
|
|
|
33
41
|
[tool.setuptools.package-data]
|
|
34
42
|
pyweber = ["static/**/*", "admin/**/*"]
|
|
@@ -76,17 +76,35 @@ from .utils.security import (
|
|
|
76
76
|
sign_value,
|
|
77
77
|
unsign_value,
|
|
78
78
|
generate_csrf_token,
|
|
79
|
+
get_csrf_token,
|
|
79
80
|
)
|
|
80
81
|
from .utils.icons import Icons
|
|
81
82
|
from .utils.deprecation import warn_deprecated
|
|
82
83
|
from .testing import TestClient
|
|
84
|
+
from .auth import (
|
|
85
|
+
login_required,
|
|
86
|
+
login_user,
|
|
87
|
+
logout_user,
|
|
88
|
+
current_user,
|
|
89
|
+
get_user_id,
|
|
90
|
+
hash_password,
|
|
91
|
+
check_password,
|
|
92
|
+
permission_required,
|
|
93
|
+
role_required,
|
|
94
|
+
register_roles,
|
|
95
|
+
clear_roles,
|
|
96
|
+
define_role,
|
|
97
|
+
has_role,
|
|
98
|
+
has_all_roles,
|
|
99
|
+
has_permission,
|
|
100
|
+
user_permissions,
|
|
101
|
+
)
|
|
83
102
|
|
|
84
103
|
from .utils.types import (
|
|
85
104
|
ContentTypes,
|
|
86
105
|
EventType,
|
|
87
106
|
HTMLTag,
|
|
88
107
|
HTTPStatusCode,
|
|
89
|
-
JWTAlgorithms,
|
|
90
108
|
NonSelfClosingHTMLTags,
|
|
91
109
|
WebSocketStatusCode,
|
|
92
110
|
WindowEventType,
|
|
@@ -129,7 +147,6 @@ from .components.input import (
|
|
|
129
147
|
Input,
|
|
130
148
|
InputButton,
|
|
131
149
|
InputCheckbox,
|
|
132
|
-
InputCheckbox,
|
|
133
150
|
InputColor,
|
|
134
151
|
InputDate,
|
|
135
152
|
InputEmail,
|
|
@@ -200,6 +217,7 @@ __all__ = [
|
|
|
200
217
|
'sign_value',
|
|
201
218
|
'unsign_value',
|
|
202
219
|
'generate_csrf_token',
|
|
220
|
+
'get_csrf_token',
|
|
203
221
|
'ContentTypes',
|
|
204
222
|
'EventType',
|
|
205
223
|
'HTMLTag',
|
|
@@ -244,6 +262,22 @@ __all__ = [
|
|
|
244
262
|
'SecurityEnforcer',
|
|
245
263
|
'SecurityError',
|
|
246
264
|
'ForbiddenError',
|
|
265
|
+
'login_required',
|
|
266
|
+
'login_user',
|
|
267
|
+
'logout_user',
|
|
268
|
+
'current_user',
|
|
269
|
+
'get_user_id',
|
|
270
|
+
'hash_password',
|
|
271
|
+
'check_password',
|
|
272
|
+
'permission_required',
|
|
273
|
+
'role_required',
|
|
274
|
+
'register_roles',
|
|
275
|
+
'clear_roles',
|
|
276
|
+
'define_role',
|
|
277
|
+
'has_role',
|
|
278
|
+
'has_all_roles',
|
|
279
|
+
'has_permission',
|
|
280
|
+
'user_permissions',
|
|
247
281
|
'Icon',
|
|
248
282
|
'Style',
|
|
249
283
|
'Script',
|
|
@@ -251,7 +285,6 @@ __all__ = [
|
|
|
251
285
|
'Input',
|
|
252
286
|
'InputButton',
|
|
253
287
|
'InputCheckbox',
|
|
254
|
-
'InputCheckbox',
|
|
255
288
|
'InputColor',
|
|
256
289
|
'InputDate',
|
|
257
290
|
'InputEmail',
|
|
@@ -271,3 +304,21 @@ __all__ = [
|
|
|
271
304
|
'Label',
|
|
272
305
|
'TextArea',
|
|
273
306
|
]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def __getattr__(name: str):
|
|
310
|
+
if name == 'JWTAlgorithms':
|
|
311
|
+
from pyweber.utils.deprecation import warn_deprecated
|
|
312
|
+
from pyweber.utils.types import JWTAlgorithms as _JWTAlgorithms
|
|
313
|
+
|
|
314
|
+
warn_deprecated(
|
|
315
|
+
'pyweber.JWTAlgorithms',
|
|
316
|
+
alternative=(
|
|
317
|
+
'pyjwt (or similar) for encode/decode/verify; '
|
|
318
|
+
'pyweber.auth for session login. '
|
|
319
|
+
'JWTAlgorithms remains an algorithm-name enum only until 2.0'
|
|
320
|
+
),
|
|
321
|
+
removal='2.0',
|
|
322
|
+
)
|
|
323
|
+
return _JWTAlgorithms
|
|
324
|
+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Lightweight session login helpers (Flask-Login style, no ORM) + RBAC."""
|
|
2
|
+
|
|
3
|
+
from pyweber.auth.decorators import login_required, permission_required, role_required
|
|
4
|
+
from pyweber.auth.passwords import check_password, hash_password
|
|
5
|
+
from pyweber.auth.rbac import (
|
|
6
|
+
RoleRegistry,
|
|
7
|
+
clear_roles,
|
|
8
|
+
define_role,
|
|
9
|
+
get_role_registry,
|
|
10
|
+
has_all_roles,
|
|
11
|
+
has_permission,
|
|
12
|
+
has_role,
|
|
13
|
+
register_roles,
|
|
14
|
+
user_permissions,
|
|
15
|
+
)
|
|
16
|
+
from pyweber.auth.session import (
|
|
17
|
+
USER_COOKIE_NAME,
|
|
18
|
+
current_user,
|
|
19
|
+
get_user_id,
|
|
20
|
+
login_user,
|
|
21
|
+
logout_user,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
'USER_COOKIE_NAME',
|
|
26
|
+
'RoleRegistry',
|
|
27
|
+
'check_password',
|
|
28
|
+
'clear_roles',
|
|
29
|
+
'current_user',
|
|
30
|
+
'define_role',
|
|
31
|
+
'get_role_registry',
|
|
32
|
+
'get_user_id',
|
|
33
|
+
'has_all_roles',
|
|
34
|
+
'has_permission',
|
|
35
|
+
'has_role',
|
|
36
|
+
'hash_password',
|
|
37
|
+
'login_required',
|
|
38
|
+
'login_user',
|
|
39
|
+
'logout_user',
|
|
40
|
+
'permission_required',
|
|
41
|
+
'register_roles',
|
|
42
|
+
'role_required',
|
|
43
|
+
'user_permissions',
|
|
44
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""``@login_required`` / ``@permission_required`` / ``@role_required``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from pyweber.auth.rbac import user_has_permissions, user_has_roles
|
|
10
|
+
from pyweber.auth.session import current_user
|
|
11
|
+
from pyweber.models.context import get_current_request
|
|
12
|
+
from pyweber.models.response import Response
|
|
13
|
+
from pyweber.utils.types import ContentTypes
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _wants_json(request) -> bool:
|
|
17
|
+
if request is None:
|
|
18
|
+
return False
|
|
19
|
+
accept = (request.headers.get('accept') or '').lower()
|
|
20
|
+
if 'application/json' in accept:
|
|
21
|
+
if 'text/html' not in accept:
|
|
22
|
+
return True
|
|
23
|
+
return accept.find('application/json') <= accept.find('text/html')
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _unauthorized(*, redirect: str | None, request) -> Response:
|
|
28
|
+
if redirect and not _wants_json(request):
|
|
29
|
+
return Response(
|
|
30
|
+
content=b'',
|
|
31
|
+
status=302,
|
|
32
|
+
request=request,
|
|
33
|
+
route=redirect,
|
|
34
|
+
headers={'Location': redirect},
|
|
35
|
+
content_type=ContentTypes.txt,
|
|
36
|
+
)
|
|
37
|
+
return Response.json({'detail': 'Unauthorized'}, status=401, request=request)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _forbidden(*, request) -> Response:
|
|
41
|
+
return Response.json({'detail': 'Forbidden'}, status=403, request=request)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _authorize(
|
|
45
|
+
user: dict[str, Any] | None,
|
|
46
|
+
*,
|
|
47
|
+
roles: list[str] | None,
|
|
48
|
+
roles_all: list[str] | None,
|
|
49
|
+
permissions: list[str] | None,
|
|
50
|
+
permissions_all: list[str] | None,
|
|
51
|
+
) -> str | None:
|
|
52
|
+
"""Return ``'unauthorized'``, ``'forbidden'``, or ``None`` if allowed."""
|
|
53
|
+
if user is None:
|
|
54
|
+
return 'unauthorized'
|
|
55
|
+
if not user_has_roles(user, roles, roles_all=roles_all):
|
|
56
|
+
return 'forbidden'
|
|
57
|
+
if not user_has_permissions(user, permissions, permissions_all=permissions_all):
|
|
58
|
+
return 'forbidden'
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _wrap_handler(
|
|
63
|
+
func: Callable,
|
|
64
|
+
*,
|
|
65
|
+
redirect: str | None,
|
|
66
|
+
roles: list[str] | None,
|
|
67
|
+
roles_all: list[str] | None,
|
|
68
|
+
permissions: list[str] | None,
|
|
69
|
+
permissions_all: list[str] | None,
|
|
70
|
+
):
|
|
71
|
+
if inspect.iscoroutinefunction(func):
|
|
72
|
+
@wraps(func)
|
|
73
|
+
async def async_wrapper(*args, **kwargs):
|
|
74
|
+
request = get_current_request()
|
|
75
|
+
decision = _authorize(
|
|
76
|
+
current_user(),
|
|
77
|
+
roles=roles,
|
|
78
|
+
roles_all=roles_all,
|
|
79
|
+
permissions=permissions,
|
|
80
|
+
permissions_all=permissions_all,
|
|
81
|
+
)
|
|
82
|
+
if decision == 'unauthorized':
|
|
83
|
+
return _unauthorized(redirect=redirect, request=request)
|
|
84
|
+
if decision == 'forbidden':
|
|
85
|
+
return _forbidden(request=request)
|
|
86
|
+
return await func(*args, **kwargs)
|
|
87
|
+
|
|
88
|
+
return async_wrapper
|
|
89
|
+
|
|
90
|
+
@wraps(func)
|
|
91
|
+
def sync_wrapper(*args, **kwargs):
|
|
92
|
+
request = get_current_request()
|
|
93
|
+
decision = _authorize(
|
|
94
|
+
current_user(),
|
|
95
|
+
roles=roles,
|
|
96
|
+
roles_all=roles_all,
|
|
97
|
+
permissions=permissions,
|
|
98
|
+
permissions_all=permissions_all,
|
|
99
|
+
)
|
|
100
|
+
if decision == 'unauthorized':
|
|
101
|
+
return _unauthorized(redirect=redirect, request=request)
|
|
102
|
+
if decision == 'forbidden':
|
|
103
|
+
return _forbidden(request=request)
|
|
104
|
+
return func(*args, **kwargs)
|
|
105
|
+
|
|
106
|
+
return sync_wrapper
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def login_required(
|
|
110
|
+
fn: Callable | None = None,
|
|
111
|
+
*,
|
|
112
|
+
redirect: str | None = None,
|
|
113
|
+
roles: list[str] | None = None,
|
|
114
|
+
roles_all: list[str] | None = None,
|
|
115
|
+
permissions: list[str] | None = None,
|
|
116
|
+
permissions_all: list[str] | None = None,
|
|
117
|
+
):
|
|
118
|
+
"""Require a logged-in user (cookie) or ``request.auth`` from OpenAPI schemes.
|
|
119
|
+
|
|
120
|
+
RBAC (optional)::
|
|
121
|
+
|
|
122
|
+
@login_required(roles=['admin', 'moderator']) # any role
|
|
123
|
+
@login_required(roles_all=['staff', 'verified']) # all roles
|
|
124
|
+
@login_required(permissions=['posts:write']) # via register_roles()
|
|
125
|
+
@login_required(permissions_all=['posts:read', 'posts:write'])
|
|
126
|
+
|
|
127
|
+
Usage::
|
|
128
|
+
|
|
129
|
+
@app.route('/dash')
|
|
130
|
+
@login_required(redirect='/login')
|
|
131
|
+
def dash():
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
@login_required # bare form
|
|
135
|
+
def api_me():
|
|
136
|
+
...
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def decorator(func: Callable):
|
|
140
|
+
return _wrap_handler(
|
|
141
|
+
func,
|
|
142
|
+
redirect=redirect,
|
|
143
|
+
roles=roles,
|
|
144
|
+
roles_all=roles_all,
|
|
145
|
+
permissions=permissions,
|
|
146
|
+
permissions_all=permissions_all,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if fn is not None and callable(fn):
|
|
150
|
+
return decorator(fn)
|
|
151
|
+
return decorator
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def permission_required(
|
|
155
|
+
*permissions: str,
|
|
156
|
+
redirect: str | None = None,
|
|
157
|
+
require_all: bool = False,
|
|
158
|
+
):
|
|
159
|
+
"""Shorthand for ``@login_required(permissions=…)`` / ``permissions_all=``.
|
|
160
|
+
|
|
161
|
+
::
|
|
162
|
+
|
|
163
|
+
@permission_required('posts:write')
|
|
164
|
+
def create(): ...
|
|
165
|
+
|
|
166
|
+
@permission_required('a', 'b', require_all=True)
|
|
167
|
+
def both(): ...
|
|
168
|
+
"""
|
|
169
|
+
if not permissions:
|
|
170
|
+
raise TypeError('permission_required() needs at least one permission')
|
|
171
|
+
perms = list(permissions)
|
|
172
|
+
return login_required(
|
|
173
|
+
redirect=redirect,
|
|
174
|
+
permissions=None if require_all else perms,
|
|
175
|
+
permissions_all=perms if require_all else None,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def role_required(
|
|
180
|
+
*roles: str,
|
|
181
|
+
redirect: str | None = None,
|
|
182
|
+
require_all: bool = False,
|
|
183
|
+
):
|
|
184
|
+
"""Shorthand for ``@login_required(roles=…)`` / ``roles_all=``.
|
|
185
|
+
|
|
186
|
+
::
|
|
187
|
+
|
|
188
|
+
@role_required('admin')
|
|
189
|
+
def admin_panel(): ...
|
|
190
|
+
|
|
191
|
+
@role_required('staff', 'verified', require_all=True)
|
|
192
|
+
def staff_only(): ...
|
|
193
|
+
"""
|
|
194
|
+
if not roles:
|
|
195
|
+
raise TypeError('role_required() needs at least one role')
|
|
196
|
+
rs = list(roles)
|
|
197
|
+
return login_required(
|
|
198
|
+
redirect=redirect,
|
|
199
|
+
roles=None if require_all else rs,
|
|
200
|
+
roles_all=rs if require_all else None,
|
|
201
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Password hashing helpers (stdlib PBKDF2-HMAC-SHA256)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import secrets
|
|
8
|
+
|
|
9
|
+
DEFAULT_ITERATIONS = 260_000
|
|
10
|
+
_SCHEME = 'pbkdf2_sha256'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def hash_password(password: str, *, iterations: int = DEFAULT_ITERATIONS) -> str:
|
|
14
|
+
"""Hash a password for storage.
|
|
15
|
+
|
|
16
|
+
Format: ``pbkdf2_sha256$<iterations>$<salt_hex>$<digest_hex>``
|
|
17
|
+
"""
|
|
18
|
+
if not isinstance(password, str) or password == '':
|
|
19
|
+
raise ValueError('password must be a non-empty string')
|
|
20
|
+
salt = secrets.token_hex(16)
|
|
21
|
+
digest = hashlib.pbkdf2_hmac(
|
|
22
|
+
'sha256',
|
|
23
|
+
password.encode('utf-8'),
|
|
24
|
+
bytes.fromhex(salt),
|
|
25
|
+
iterations,
|
|
26
|
+
).hex()
|
|
27
|
+
return f'{_SCHEME}${iterations}${salt}${digest}'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_password(password: str, encoded: str) -> bool:
|
|
31
|
+
"""Constant-time verify of ``password`` against ``hash_password`` output."""
|
|
32
|
+
if not password or not encoded or not isinstance(encoded, str):
|
|
33
|
+
return False
|
|
34
|
+
try:
|
|
35
|
+
scheme, iter_s, salt, digest = encoded.split('$', 3)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return False
|
|
38
|
+
if scheme != _SCHEME:
|
|
39
|
+
return False
|
|
40
|
+
try:
|
|
41
|
+
iterations = int(iter_s)
|
|
42
|
+
salt_b = bytes.fromhex(salt)
|
|
43
|
+
expected = bytes.fromhex(digest)
|
|
44
|
+
except (TypeError, ValueError):
|
|
45
|
+
return False
|
|
46
|
+
actual = hashlib.pbkdf2_hmac(
|
|
47
|
+
'sha256',
|
|
48
|
+
password.encode('utf-8'),
|
|
49
|
+
salt_b,
|
|
50
|
+
iterations,
|
|
51
|
+
)
|
|
52
|
+
return hmac.compare_digest(actual, expected)
|