Skip to content

Commit 2366b85

Browse files
Use AppKey in aiohttp 3.9 (#808)
1 parent 55c7714 commit 2366b85

16 files changed

+311
-262
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ per-file-ignores =
1616
examples/*:I900,S105
1717

1818
# flake8-import-order
19-
application-import-names = aiohttp_admin, _auth, _auth_helpers, _models, _resources
19+
application-import-names = aiohttp_admin, conftest, _auth, _auth_helpers, _models, _resources
2020
import-order-style = pycharm
2121

2222
# flake8-quotes

aiohttp_admin/__init__.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111

1212
from .routes import setup_resources, setup_routes
1313
from .security import AdminAuthorizationPolicy, Permissions, TokenIdentityPolicy, check
14-
from .types import Schema, UserDetails
14+
from .types import Schema, State, UserDetails, check_credentials_key, permission_re_key, state_key
1515

16-
__all__ = ("Permissions", "Schema", "UserDetails", "setup")
16+
__all__ = ("Permissions", "Schema", "UserDetails", "permission_re_key", "setup")
1717
__version__ = "0.1.0a2"
1818

1919

@@ -51,7 +51,7 @@ async def on_startup(admin: web.Application) -> None:
5151
enclosing scope later.
5252
"""
5353
storage._cookie_params["path"] = prefixed_subapp.canonical
54-
admin["state"]["urls"] = {
54+
admin[state_key]["urls"] = {
5555
"token": str(admin.router["token"].url_for()),
5656
"logout": str(admin.router["logout"].url_for())
5757
}
@@ -65,7 +65,8 @@ def value(r: web.RouteDef) -> tuple[str, str]:
6565

6666
for res in schema["resources"]:
6767
m = res["model"]
68-
admin["state"]["resources"][m.name]["urls"] = {key(r): value(r) for r in m.routes}
68+
urls = admin[state_key]["resources"][m.name]["urls"]
69+
urls.update((key(r), value(r)) for r in m.routes)
6970

7071
schema = check(Schema, schema)
7172
if secret is None:
@@ -74,9 +75,9 @@ def value(r: web.RouteDef) -> tuple[str, str]:
7475
admin = web.Application()
7576
admin.middlewares.append(pydantic_middleware)
7677
admin.on_startup.append(on_startup)
77-
admin["check_credentials"] = schema["security"]["check_credentials"]
78-
admin["identity_callback"] = schema["security"].get("identity_callback")
79-
admin["state"] = {"view": schema.get("view", {}), "js_module": schema.get("js_module")}
78+
admin[check_credentials_key] = schema["security"]["check_credentials"]
79+
admin[state_key] = State({"view": schema.get("view", {}), "js_module": schema.get("js_module"),
80+
"urls": {}, "resources": {}})
8081

8182
max_age = schema["security"].get("max_age")
8283
secure = schema["security"].get("secure", True)
@@ -90,7 +91,7 @@ def value(r: web.RouteDef) -> tuple[str, str]:
9091
setup_resources(admin, schema)
9192

9293
resource_patterns = []
93-
for r, state in admin["state"]["resources"].items():
94+
for r, state in admin[state_key]["resources"].items():
9495
fields = state["fields"].keys()
9596
resource_patterns.append(
9697
r"(?#Resource name){r}"
@@ -102,7 +103,7 @@ def value(r: web.RouteDef) -> tuple[str, str]:
102103
p_re = (r"(?#Global admin permission)~?admin\.(view|edit|add|delete|\*)"
103104
r"|"
104105
r"(?#Resource permission)(~)?admin\.({})").format("|".join(resource_patterns))
105-
admin["permission_re"] = re.compile(p_re)
106+
admin[permission_re_key] = re.compile(p_re)
106107

107108
prefixed_subapp = app.add_subapp(path, admin)
108109
return admin

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
import sys
66
from collections.abc import Callable, Coroutine, Iterator, Sequence
77
from types import MappingProxyType as MPT
8-
from typing import Any, Literal, Optional, TypeVar, Union
8+
from typing import Any, Literal, Optional, TypeVar, Union, cast
99

1010
import sqlalchemy as sa
1111
from aiohttp import web
1212
from sqlalchemy.ext.asyncio import AsyncEngine
13-
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
13+
from sqlalchemy.orm import DeclarativeBase, DeclarativeBaseNoMeta, Mapper, QueryableAttribute
1414
from sqlalchemy.sql.roles import ExpressionElementRole
1515

1616
from .abc import AbstractAdminResource, GetListParams, Meta, Record
@@ -26,6 +26,7 @@
2626
_FValues = Union[bool, int, str]
2727
_Filters = dict[Union[sa.Column[object], QueryableAttribute[Any]],
2828
Union[_FValues, Sequence[_FValues]]]
29+
_ModelOrTable = Union[sa.Table, type[DeclarativeBase], type[DeclarativeBaseNoMeta]]
2930

3031
logger = logging.getLogger(__name__)
3132

@@ -155,7 +156,7 @@ def create_filters(columns: sa.ColumnCollection[str, sa.Column[object]],
155156

156157
# ID is based on PK, which we can't infer from types, so must use Any here.
157158
class SAResource(AbstractAdminResource[Any]):
158-
def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[DeclarativeBase]]):
159+
def __init__(self, db: AsyncEngine, model_or_table: _ModelOrTable):
159160
if isinstance(model_or_table, sa.Table):
160161
table = model_or_table
161162
else:
@@ -221,7 +222,9 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[Declara
221222

222223
if not isinstance(model_or_table, sa.Table):
223224
# Append fields to represent ORM relationships.
224-
mapper = sa.inspect(model_or_table)
225+
# Mypy doesn't handle union well here.
226+
mapper = cast(Union[Mapper[DeclarativeBase], Mapper[DeclarativeBaseNoMeta]],
227+
sa.inspect(model_or_table))
225228
assert mapper is not None # noqa: S101
226229
for name, relationship in mapper.relationships.items():
227230
# https://github.com/sqlalchemy/sqlalchemy/discussions/10161#discussioncomment-6583442

aiohttp_admin/routes.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@
66
from aiohttp import web
77

88
from . import views
9-
from .types import Schema
9+
from .types import Schema, _ResourceState, resources_key, state_key
1010

1111

1212
def setup_resources(admin: web.Application, schema: Schema) -> None:
13-
admin["resources"] = []
14-
admin["state"]["resources"] = {}
13+
admin[resources_key] = []
1514

1615
for r in schema["resources"]:
1716
m = r["model"]
18-
admin["resources"].append(m)
17+
admin[resources_key].append(m)
1918
admin.router.add_routes(m.routes)
2019

2120
try:
@@ -47,11 +46,12 @@ def setup_resources(admin: web.Application, schema: Schema) -> None:
4746
for name, props in r.get("field_props", {}).items():
4847
fields[name]["props"].update(props)
4948

50-
state = {"fields": fields, "inputs": inputs, "list_omit": tuple(omit_fields),
51-
"repr": repr_field, "label": r.get("label"), "icon": r.get("icon"),
52-
"bulk_update": r.get("bulk_update", {}),
53-
"show_actions": r.get("show_actions", ())}
54-
admin["state"]["resources"][m.name] = state
49+
state: _ResourceState = {
50+
"fields": fields, "inputs": inputs, "list_omit": tuple(omit_fields),
51+
"repr": repr_field, "label": r.get("label"), "icon": r.get("icon"),
52+
"bulk_update": r.get("bulk_update", {}), "urls": {},
53+
"show_actions": r.get("show_actions", ())}
54+
admin[state_key]["resources"][m.name] = state
5555

5656

5757
def setup_routes(admin: web.Application) -> None:

aiohttp_admin/security.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,20 @@ def permissions_as_dict(permissions: Collection[str]) -> dict[str, dict[str, lis
7171
return p_dict
7272

7373

74-
class AdminAuthorizationPolicy(AbstractAuthorizationPolicy): # type: ignore[misc,no-any-unimported]
74+
class AdminAuthorizationPolicy(AbstractAuthorizationPolicy):
7575
def __init__(self, schema: Schema):
7676
super().__init__()
7777
self._identity_callback = schema["security"].get("identity_callback")
7878

7979
async def authorized_userid(self, identity: str) -> str:
8080
return identity
8181

82-
async def permits(self, identity: Optional[str], permission: Union[str, Enum],
83-
context: tuple[web.Request, Optional[Mapping[str, object]]]) -> bool:
82+
async def permits(
83+
self, identity: Optional[str], permission: Union[str, Enum],
84+
context: Optional[tuple[web.Request, Optional[Mapping[str, object]]]] = None
85+
) -> bool:
86+
# TODO: https://github.com/aio-libs/aiohttp-security/issues/677
87+
assert context is not None # noqa: S101
8488
if identity is None:
8589
return False
8690

@@ -101,7 +105,7 @@ async def permits(self, identity: Optional[str], permission: Union[str, Enum],
101105
return has_permission(permission, permissions_as_dict(permissions), record)
102106

103107

104-
class TokenIdentityPolicy(SessionIdentityPolicy): # type: ignore[misc,no-any-unimported]
108+
class TokenIdentityPolicy(SessionIdentityPolicy):
105109
def __init__(self, fernet: Fernet, schema: Schema):
106110
super().__init__()
107111
self._fernet = fernet
@@ -130,17 +134,17 @@ async def identify(self, request: web.Request) -> Optional[str]:
130134
# Both identites must match.
131135
return token_identity if token_identity == cookie_identity else None
132136

133-
async def remember(self, request: web.Request, response: web.Response,
137+
async def remember(self, request: web.Request, response: web.StreamResponse,
134138
identity: str, **kwargs: object) -> None:
135139
"""Send auth tokens to client for authentication."""
136140
# For proper security we send a token for JS to store and an HTTP only cookie:
137141
# https://www.redotheweb.com/2015/11/09/api-security.html
138142
# Send token that will be saved in local storage by the JS client.
139143
response.headers["X-Token"] = json.dumps(await self.user_identity_dict(request, identity))
140144
# Send httponly cookie, which will be invisible to JS.
141-
await super().remember(request, response, identity, **kwargs)
145+
await super().remember(request, response, identity, **kwargs) # type: ignore[arg-type]
142146

143-
async def forget(self, request: web.Request, response: web.Response) -> None:
147+
async def forget(self, request: web.Request, response: web.StreamResponse) -> None:
144148
"""Delete session cookie (JS client should choose to delete its token)."""
145149
await super().forget(request, response)
146150

aiohttp_admin/types.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import re
12
import sys
23
from collections.abc import Callable, Collection, Sequence
34
from typing import Any, Awaitable, Literal, Mapping, Optional
45

6+
from aiohttp.web import AppKey
7+
58
if sys.version_info >= (3, 12):
69
from typing import TypedDict
710
else:
@@ -110,14 +113,15 @@ class Schema(_Schema):
110113

111114

112115
class _ResourceState(TypedDict):
113-
display: Sequence[str]
114116
fields: dict[str, ComponentState]
115117
inputs: dict[str, InputState]
116118
show_actions: Sequence[ComponentState]
117119
repr: str
118120
icon: Optional[str]
119121
urls: dict[str, tuple[str, str]] # (method, url)
120122
bulk_update: dict[str, dict[str, Any]]
123+
list_omit: tuple[str, ...]
124+
label: Optional[str]
121125

122126

123127
class State(TypedDict):
@@ -146,3 +150,9 @@ def func(name: str, args: Optional[Sequence[object]] = None) -> FunctionState:
146150
def regex(value: str) -> RegexState:
147151
"""Convert value to a RegExp object on the frontend."""
148152
return {"__type__": "regexp", "value": value}
153+
154+
155+
check_credentials_key = AppKey[Callable[[str, str], Awaitable[bool]]]("check_credentials")
156+
permission_re_key = AppKey("permission_re", re.Pattern[str])
157+
resources_key = AppKey("resources", list[Any]) # TODO(pydantic): AbstractAdminResource
158+
state_key = AppKey("state", State)

aiohttp_admin/views.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pydantic import Json
88

99
from .security import check
10+
from .types import check_credentials_key, state_key
1011

1112
if sys.version_info >= (3, 12):
1213
from typing import TypedDict
@@ -38,15 +39,15 @@ async def index(request: web.Request) -> web.Response:
3839
"""Root page which loads react-admin."""
3940
static = request.app.router["static"]
4041
js = static.url_for(filename="admin.js")
41-
state = json.dumps(request.app["state"])
42+
state = json.dumps(request.app[state_key])
4243

4344
# __package__ can be None, despite what the documentation claims.
4445
package_name = __main__.__package__ or "My"
4546
# Common convention is to have _app suffix for package name, so try and strip that.
4647
package_name = package_name.removesuffix("_app").replace("_", " ").title()
47-
name = request.app["state"]["view"].get("name", package_name)
48+
name = request.app[state_key]["view"].get("name", package_name)
4849

49-
icon = request.app["state"]["view"].get("icon", static.url_for(filename="favicon.svg"))
50+
icon = request.app[state_key]["view"].get("icon", static.url_for(filename="favicon.svg"))
5051

5152
output = INDEX_TEMPLATE.format(name=name, icon=icon, js=js, state=state)
5253
return web.Response(text=output, content_type="text/html")
@@ -56,7 +57,7 @@ async def token(request: web.Request) -> web.Response:
5657
"""Validate user credentials and log the user in."""
5758
data = check(Json[_Login], await request.read())
5859

59-
check_credentials = request.app["check_credentials"]
60+
check_credentials = request.app[check_credentials_key]
6061
if not await check_credentials(data["username"], data["password"]):
6162
raise web.HTTPUnauthorized(text="Wrong username or password")
6263

examples/permissions.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111

1212
import sqlalchemy as sa
1313
from aiohttp import web
14-
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
14+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
1515
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
1616

1717
import aiohttp_admin
18-
from aiohttp_admin import Permissions, UserDetails
18+
from aiohttp_admin import Permissions, UserDetails, permission_re_key
1919
from aiohttp_admin.backends.sqlalchemy import SAResource, permission_for as p
2020

21+
db = web.AppKey("db", async_sessionmaker[AsyncSession])
22+
2123

2224
class Base(DeclarativeBase):
2325
"""Base model."""
@@ -49,14 +51,16 @@ class User(Base):
4951

5052
async def check_credentials(app: web.Application, username: str, password: str) -> bool:
5153
"""Allow login to any user account regardless of password."""
52-
async with app["db"]() as sess:
54+
async with app[db]() as sess:
5355
user = await sess.get(User, username.lower())
5456
return user is not None
5557

5658

5759
async def identity_callback(app: web.Application, identity: str) -> UserDetails:
58-
async with app["db"]() as sess:
60+
async with app[db]() as sess:
5961
user = await sess.get(User, identity)
62+
if not user:
63+
raise ValueError("No user found for given identity")
6064
return {"permissions": json.loads(user.permissions), "fullName": user.username.title()}
6165

6266

@@ -79,7 +83,7 @@ async def create_app() -> web.Application:
7983
sess.add(SimpleParent(id=p_simple.id, date=datetime(2023, 2, 13, 19, 4)))
8084

8185
app = web.Application()
82-
app["db"] = session
86+
app[db] = session
8387

8488
# This is the setup required for aiohttp-admin.
8589
schema: aiohttp_admin.Schema = {
@@ -123,7 +127,7 @@ async def create_app() -> web.Application:
123127
filters={Simple.num: 5}))
124128
}
125129
for name, permissions in users.items():
126-
if any(admin["permission_re"].fullmatch(p) is None for p in permissions):
130+
if any(admin[permission_re_key].fullmatch(p) is None for p in permissions):
127131
raise ValueError("Not a valid permission.")
128132
sess.add(User(username=name, permissions=json.dumps(permissions)))
129133

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
-e .
2-
aiohttp==3.8.6
3-
aiohttp-security==0.4.0
2+
aiohttp==3.9.0
3+
aiohttp-security==0.5.0
44
aiohttp-session[secure]==2.12.0
55
aiosqlite==0.19.0
66
cryptography==41.0.5

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def read_version():
4040
download_url="https://github.com/aio-libs/aiohttp-admin",
4141
license="Apache 2",
4242
packages=find_packages(),
43-
install_requires=("aiohttp>=3.8.2", "aiohttp_security", "aiohttp_session",
43+
install_requires=("aiohttp>=3.9", "aiohttp_security", "aiohttp_session",
4444
"cryptography", "pydantic>2,<3",
4545
'typing_extensions>=3.10; python_version<"3.12"'),
4646
extras_require={"sa": ["sqlalchemy>=2.0.4,<3"]},

0 commit comments

Comments
 (0)