pyweber 1.5.0.dev3__tar.gz → 1.6.0.dev0__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 (125) hide show
  1. {pyweber-1.5.0.dev3/pyweber.egg-info → pyweber-1.6.0.dev0}/PKG-INFO +14 -1
  2. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyproject.toml +7 -1
  3. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/__init__.py +36 -2
  4. pyweber-1.6.0.dev0/pyweber/auth/__init__.py +44 -0
  5. pyweber-1.6.0.dev0/pyweber/auth/decorators.py +201 -0
  6. pyweber-1.6.0.dev0/pyweber/auth/passwords.py +52 -0
  7. pyweber-1.6.0.dev0/pyweber/auth/rbac.py +184 -0
  8. pyweber-1.6.0.dev0/pyweber/auth/session.py +146 -0
  9. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/cli/commands.py +9 -0
  10. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/components/__init__.py +0 -2
  11. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/components/form.py +4 -2
  12. pyweber-1.6.0.dev0/pyweber/components/input.py +663 -0
  13. pyweber-1.6.0.dev0/pyweber/connection/session.py +123 -0
  14. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/connection/websocket.py +43 -1
  15. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/core/window.py +55 -25
  16. pyweber-1.6.0.dev0/pyweber/db/__init__.py +133 -0
  17. pyweber-1.6.0.dev0/pyweber/db/cli.py +236 -0
  18. pyweber-1.6.0.dev0/pyweber/db/config.py +125 -0
  19. pyweber-1.6.0.dev0/pyweber/db/engine.py +64 -0
  20. pyweber-1.6.0.dev0/pyweber/db/model.py +44 -0
  21. pyweber-1.6.0.dev0/pyweber/db/query.py +26 -0
  22. pyweber-1.6.0.dev0/pyweber/db/session.py +49 -0
  23. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/context.py +15 -1
  24. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/cookies.py +18 -0
  25. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/element.py +8 -4
  26. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/field_storage.py +13 -4
  27. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/openapi.py +1 -0
  28. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/request.py +38 -4
  29. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/routes.py +62 -4
  30. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/pyweber/pyweber.py +63 -368
  31. pyweber-1.6.0.dev0/pyweber/services/__init__.py +13 -0
  32. pyweber-1.6.0.dev0/pyweber/services/openapi_setup.py +73 -0
  33. pyweber-1.6.0.dev0/pyweber/services/response_pipeline.py +239 -0
  34. pyweber-1.6.0.dev0/pyweber/services/static_files.py +68 -0
  35. pyweber-1.6.0.dev0/pyweber/services/template_service.py +126 -0
  36. pyweber-1.6.0.dev0/pyweber/session_store/__init__.py +244 -0
  37. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/config.toml +15 -9
  38. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/security.py +43 -0
  39. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/types.py +8 -0
  40. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0/pyweber.egg-info}/PKG-INFO +14 -1
  41. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber.egg-info/SOURCES.txt +18 -0
  42. pyweber-1.6.0.dev0/pyweber.egg-info/requires.txt +31 -0
  43. pyweber-1.5.0.dev3/pyweber/components/input.py +0 -814
  44. pyweber-1.5.0.dev3/pyweber/connection/session.py +0 -50
  45. pyweber-1.5.0.dev3/pyweber.egg-info/requires.txt +0 -12
  46. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/LICENSE +0 -0
  47. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/MANIFEST.in +0 -0
  48. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/NOTICE +0 -0
  49. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/README.md +0 -0
  50. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/admin/index.html +0 -0
  51. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/admin/src/script.js +0 -0
  52. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/admin/src/style.css +0 -0
  53. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/cli/__init__.py +0 -0
  54. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/components/general.py +0 -0
  55. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/config/__init__.py +0 -0
  56. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/config/config.py +0 -0
  57. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/connection/__init__.py +0 -0
  58. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/connection/http.py +0 -0
  59. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/connection/reload.py +0 -0
  60. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/connection/selector.py +0 -0
  61. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/core/__init__.py +0 -0
  62. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/core/element.py +0 -0
  63. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/core/events.py +0 -0
  64. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/core/html_parser.py +0 -0
  65. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/core/template.py +0 -0
  66. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/__init__.py +0 -0
  67. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/create_app.py +0 -0
  68. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/error_pages.py +0 -0
  69. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/field.py +0 -0
  70. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/file.py +0 -0
  71. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/file_stream.py +0 -0
  72. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/handoff.py +0 -0
  73. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/headers.py +0 -0
  74. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/middleware.py +0 -0
  75. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/rate_limit.py +0 -0
  76. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/response.py +0 -0
  77. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/run.py +0 -0
  78. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/security.py +0 -0
  79. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/stream_stats.py +0 -0
  80. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/strem_stats.py +0 -0
  81. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/task_manager.py +0 -0
  82. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/template_diff.py +0 -0
  83. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/models/ws_message.py +0 -0
  84. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/pyweber/__init__.py +0 -0
  85. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/.gitignore +0 -0
  86. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/css.css +0 -0
  87. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/docs.html +0 -0
  88. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/favicon/android-chrome-192x192.png +0 -0
  89. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/favicon/android-chrome-512x512.png +0 -0
  90. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/favicon/apple-touch-icon.png +0 -0
  91. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/favicon/favicon-16x16.png +0 -0
  92. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/favicon/favicon-32x32.png +0 -0
  93. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/favicon/favicon.ico +0 -0
  94. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/favicon/pyweber.png +0 -0
  95. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/favicon/site.webmanifest +0 -0
  96. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/handlers.js +0 -0
  97. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/html.html +0 -0
  98. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/html401.html +0 -0
  99. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/html404.html +0 -0
  100. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/html500.html +0 -0
  101. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/js.js +0 -0
  102. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/loading.html +0 -0
  103. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/main.py +0 -0
  104. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/pyweber.css +0 -0
  105. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/static/update.py +0 -0
  106. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/testing/__init__.py +0 -0
  107. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/testing/client.py +0 -0
  108. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/__init__.py +0 -0
  109. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/async_utils.py +0 -0
  110. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/deprecation.py +0 -0
  111. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/exceptions.py +0 -0
  112. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/icons.py +0 -0
  113. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/loads.py +0 -0
  114. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/mime.py +0 -0
  115. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber/utils/utils.py +0 -0
  116. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber.egg-info/dependency_links.txt +0 -0
  117. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber.egg-info/entry_points.txt +0 -0
  118. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/pyweber.egg-info/top_level.txt +0 -0
  119. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/setup.cfg +0 -0
  120. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/tests/test_config.py +0 -0
  121. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/tests/test_context.py +0 -0
  122. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/tests/test_cookies.py +0 -0
  123. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/tests/test_public_api.py +0 -0
  124. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/tests/test_response.py +0 -0
  125. {pyweber-1.5.0.dev3 → pyweber-1.6.0.dev0}/tests/test_template_diff.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyweber
3
- Version: 1.5.0.dev3
3
+ Version: 1.6.0.dev0
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
@@ -24,6 +24,19 @@ Requires-Dist: pytest-cov; extra == "dev"
24
24
  Requires-Dist: pytest-asyncio; extra == "dev"
25
25
  Provides-Extra: fast-html
26
26
  Requires-Dist: lxml; extra == "fast-html"
27
+ Provides-Extra: db
28
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == "db"
29
+ Requires-Dist: alembic>=1.13; extra == "db"
30
+ Provides-Extra: db-sqlite
31
+ Requires-Dist: aiosqlite>=0.20; extra == "db-sqlite"
32
+ Provides-Extra: db-pg
33
+ Requires-Dist: asyncpg>=0.29; extra == "db-pg"
34
+ Provides-Extra: db-mysql
35
+ Requires-Dist: aiomysql>=0.2; extra == "db-mysql"
36
+ Provides-Extra: db-mssql
37
+ Requires-Dist: aioodbc>=0.5; extra == "db-mssql"
38
+ Provides-Extra: redis
39
+ Requires-Dist: redis[hiredis]>=8.1.0; extra == "redis"
27
40
  Dynamic: license-file
28
41
 
29
42
  # Pyweber Framework
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyweber"
7
- version = "1.5.0.dev3"
7
+ version = "1.6.0.dev0"
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" }]
@@ -29,6 +29,12 @@ include = ["pyweber*"]
29
29
  [project.optional-dependencies]
30
30
  dev = ["pytest", "pytest-cov", "pytest-asyncio"]
31
31
  fast-html = ["lxml"]
32
+ db = ["sqlalchemy[asyncio]>=2.0", "alembic>=1.13"]
33
+ db-sqlite = ["aiosqlite>=0.20"]
34
+ db-pg = ["asyncpg>=0.29"]
35
+ db-mysql = ["aiomysql>=0.2"]
36
+ db-mssql = ["aioodbc>=0.5"]
37
+ redis = ["redis[hiredis]>=8.1.0"]
32
38
 
33
39
  [tool.setuptools.package-data]
34
40
  pyweber = ["static/**/*", "admin/**/*"]
@@ -76,10 +76,29 @@ 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,
@@ -129,7 +148,6 @@ from .components.input import (
129
148
  Input,
130
149
  InputButton,
131
150
  InputCheckbox,
132
- InputCheckbox,
133
151
  InputColor,
134
152
  InputDate,
135
153
  InputEmail,
@@ -200,6 +218,7 @@ __all__ = [
200
218
  'sign_value',
201
219
  'unsign_value',
202
220
  'generate_csrf_token',
221
+ 'get_csrf_token',
203
222
  'ContentTypes',
204
223
  'EventType',
205
224
  'HTMLTag',
@@ -244,6 +263,22 @@ __all__ = [
244
263
  'SecurityEnforcer',
245
264
  'SecurityError',
246
265
  'ForbiddenError',
266
+ 'login_required',
267
+ 'login_user',
268
+ 'logout_user',
269
+ 'current_user',
270
+ 'get_user_id',
271
+ 'hash_password',
272
+ 'check_password',
273
+ 'permission_required',
274
+ 'role_required',
275
+ 'register_roles',
276
+ 'clear_roles',
277
+ 'define_role',
278
+ 'has_role',
279
+ 'has_all_roles',
280
+ 'has_permission',
281
+ 'user_permissions',
247
282
  'Icon',
248
283
  'Style',
249
284
  'Script',
@@ -251,7 +286,6 @@ __all__ = [
251
286
  'Input',
252
287
  'InputButton',
253
288
  'InputCheckbox',
254
- 'InputCheckbox',
255
289
  'InputColor',
256
290
  'InputDate',
257
291
  'InputEmail',
@@ -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)
@@ -0,0 +1,184 @@
1
+ """Lightweight role → permission registry (no ORM).
2
+
3
+ Define roles once, then gate routes with ``permissions=`` / helpers::
4
+
5
+ from pyweber.auth import register_roles, has_permission, login_required
6
+
7
+ register_roles({
8
+ 'admin': ['*'],
9
+ 'editor': ['posts:read', 'posts:write'],
10
+ 'viewer': ['posts:read'],
11
+ })
12
+
13
+ @app.route('/posts', methods=['POST'])
14
+ @login_required(permissions=['posts:write'])
15
+ def create_post():
16
+ ...
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Iterable
22
+
23
+ from pyweber.auth.session import current_user
24
+
25
+ # Wildcard: full access, or ``resource:*`` for a namespace.
26
+ ALL_PERMISSIONS = '*'
27
+
28
+
29
+ class RoleRegistry:
30
+ """Map role names to permission strings."""
31
+
32
+ def __init__(self) -> None:
33
+ self._roles: dict[str, set[str]] = {}
34
+
35
+ def clear(self) -> None:
36
+ self._roles.clear()
37
+
38
+ def role(self, name: str, permissions: Iterable[str] | None = None) -> None:
39
+ self._roles[str(name)] = {str(p) for p in (permissions or [])}
40
+
41
+ def register(self, mapping: dict[str, Iterable[str]]) -> None:
42
+ for name, perms in mapping.items():
43
+ self.role(name, perms)
44
+
45
+ def get(self, name: str) -> set[str]:
46
+ return set(self._roles.get(str(name), set()))
47
+
48
+ def permissions_for(self, roles: Iterable[str] | None) -> set[str]:
49
+ out: set[str] = set()
50
+ for role in roles or []:
51
+ out |= self.get(role)
52
+ return out
53
+
54
+ def has_permission(self, roles: Iterable[str] | None, permission: str) -> bool:
55
+ return permission_matches(self.permissions_for(roles), permission)
56
+
57
+
58
+ def permission_matches(granted: Iterable[str], needed: str) -> bool:
59
+ """Return True if ``needed`` is covered by ``granted`` (supports ``*`` / ``ns:*``)."""
60
+ need = str(needed)
61
+ have = {str(p) for p in granted}
62
+ if ALL_PERMISSIONS in have or need in have:
63
+ return True
64
+ if ':' in need:
65
+ ns = need.split(':', 1)[0]
66
+ if f'{ns}:*' in have:
67
+ return True
68
+ return False
69
+
70
+
71
+ def permissions_match_any(granted: Iterable[str], needed: Iterable[str]) -> bool:
72
+ return any(permission_matches(granted, p) for p in needed)
73
+
74
+
75
+ def permissions_match_all(granted: Iterable[str], needed: Iterable[str]) -> bool:
76
+ return all(permission_matches(granted, p) for p in needed)
77
+
78
+
79
+ _registry = RoleRegistry()
80
+
81
+
82
+ def get_role_registry() -> RoleRegistry:
83
+ return _registry
84
+
85
+
86
+ def register_roles(mapping: dict[str, Iterable[str]]) -> None:
87
+ """Register or replace role → permission mappings (process-wide)."""
88
+ _registry.register(mapping)
89
+
90
+
91
+ def define_role(name: str, permissions: Iterable[str] | None = None) -> None:
92
+ _registry.role(name, permissions)
93
+
94
+
95
+ def clear_roles() -> None:
96
+ """Clear the process-wide role registry (useful in tests)."""
97
+ _registry.clear()
98
+
99
+
100
+ def _user_role_names(user: dict[str, Any] | None = None) -> list[str]:
101
+ u = user if user is not None else current_user()
102
+ if not u:
103
+ return []
104
+ return [str(r) for r in (u.get('roles') or [])]
105
+
106
+
107
+ def _direct_permissions(user: dict[str, Any] | None = None) -> set[str]:
108
+ """Extra permissions stored on the session (``data['permissions']``)."""
109
+ u = user if user is not None else current_user()
110
+ if not u:
111
+ return set()
112
+ data = u.get('data') or {}
113
+ raw = data.get('permissions') or []
114
+ if isinstance(raw, str):
115
+ return {raw}
116
+ return {str(p) for p in raw}
117
+
118
+
119
+ def user_permissions(user: dict[str, Any] | None = None) -> set[str]:
120
+ """Expanded permissions for the current (or given) user."""
121
+ u = user if user is not None else current_user()
122
+ if not u:
123
+ return set()
124
+ return _registry.permissions_for(_user_role_names(u)) | _direct_permissions(u)
125
+
126
+
127
+ def has_role(*roles: str, require_all: bool = False, user: dict[str, Any] | None = None) -> bool:
128
+ """True if the user has any (default) or all of the given roles."""
129
+ u = user if user is not None else current_user()
130
+ if not u or not roles:
131
+ return False
132
+ have = {str(r) for r in (u.get('roles') or [])}
133
+ need = {str(r) for r in roles}
134
+ if require_all:
135
+ return need <= have
136
+ return bool(have & need)
137
+
138
+
139
+ def has_all_roles(*roles: str, user: dict[str, Any] | None = None) -> bool:
140
+ return has_role(*roles, require_all=True, user=user)
141
+
142
+
143
+ def has_permission(
144
+ *permissions: str,
145
+ require_all: bool = False,
146
+ user: dict[str, Any] | None = None,
147
+ ) -> bool:
148
+ """True if the user has any (default) or all of the given permissions."""
149
+ if not permissions:
150
+ return False
151
+ granted = user_permissions(user)
152
+ if require_all:
153
+ return permissions_match_all(granted, permissions)
154
+ return permissions_match_any(granted, permissions)
155
+
156
+
157
+ def user_has_roles(
158
+ user: dict[str, Any],
159
+ roles: list[str] | None = None,
160
+ *,
161
+ roles_all: list[str] | None = None,
162
+ ) -> bool:
163
+ if roles_all:
164
+ if not has_role(*roles_all, require_all=True, user=user):
165
+ return False
166
+ if roles:
167
+ if not has_role(*roles, require_all=False, user=user):
168
+ return False
169
+ return True
170
+
171
+
172
+ def user_has_permissions(
173
+ user: dict[str, Any],
174
+ permissions: list[str] | None = None,
175
+ *,
176
+ permissions_all: list[str] | None = None,
177
+ ) -> bool:
178
+ if permissions_all:
179
+ if not has_permission(*permissions_all, require_all=True, user=user):
180
+ return False
181
+ if permissions:
182
+ if not has_permission(*permissions, require_all=False, user=user):
183
+ return False
184
+ return True