better-auth-server 0.1.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,40 @@
1
+ """better-auth-py: framework-agnostic authentication for Python.
2
+
3
+ A port of `better-auth <https://better-auth.com>`_ with a FastAPI integration.
4
+ """
5
+
6
+ from .adapters import BaseAdapter, MemoryAdapter, Where
7
+ from .auth import BetterAuth
8
+ from .config import EmailAndPassword, EmailVerification, RateLimit, SessionOptions
9
+ from .oauth import Discord, GitHub, Google, OAuthProvider, OAuthTokens, OAuthUserInfo
10
+ from .plugins import Plugin
11
+ from .schema import CORE_SCHEMA, Field, Schema
12
+ from .types import APIError, AuthRequest, AuthResponse, Ctx
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "CORE_SCHEMA",
18
+ "APIError",
19
+ "AuthRequest",
20
+ "AuthResponse",
21
+ "BaseAdapter",
22
+ "BetterAuth",
23
+ "Ctx",
24
+ "Discord",
25
+ "EmailAndPassword",
26
+ "EmailVerification",
27
+ "Field",
28
+ "GitHub",
29
+ "Google",
30
+ "MemoryAdapter",
31
+ "OAuthProvider",
32
+ "OAuthTokens",
33
+ "OAuthUserInfo",
34
+ "Plugin",
35
+ "RateLimit",
36
+ "Schema",
37
+ "SessionOptions",
38
+ "Where",
39
+ "__version__",
40
+ ]
@@ -0,0 +1,4 @@
1
+ from .base import BaseAdapter, Where
2
+ from .memory import MemoryAdapter
3
+
4
+ __all__ = ["BaseAdapter", "MemoryAdapter", "Where"]
@@ -0,0 +1,44 @@
1
+ """Database adapter interface: generic CRUD over named models, like better-auth adapters.
2
+
3
+ Rows are plain dicts with camelCase keys matching better_auth.schema. Plugins can define
4
+ their own models without adapter changes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ..schema import Schema
12
+
13
+
14
+ class Where:
15
+ """A single condition. operator: eq | ne | in | contains | gt | gte | lt | lte."""
16
+
17
+ __slots__ = ("field", "operator", "value")
18
+
19
+ def __init__(self, field: str, value: Any, operator: str = "eq"):
20
+ self.field = field
21
+ self.value = value
22
+ self.operator = operator
23
+
24
+
25
+ class BaseAdapter:
26
+ def init(self, schema: Schema) -> None:
27
+ """Called once by BetterAuth with the merged (core + plugins) schema."""
28
+
29
+ async def create(self, model: str, data: dict[str, Any]) -> dict[str, Any]:
30
+ raise NotImplementedError
31
+
32
+ async def find_one(self, model: str, where: list[Where]) -> dict[str, Any] | None:
33
+ raise NotImplementedError
34
+
35
+ async def find_many(self, model: str, where: list[Where] | None = None) -> list[dict[str, Any]]:
36
+ raise NotImplementedError
37
+
38
+ async def update(
39
+ self, model: str, where: list[Where], data: dict[str, Any]
40
+ ) -> dict[str, Any] | None:
41
+ raise NotImplementedError
42
+
43
+ async def delete_many(self, model: str, where: list[Where]) -> int:
44
+ raise NotImplementedError
@@ -0,0 +1,55 @@
1
+ """In-memory adapter for development and tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from .base import BaseAdapter, Where
10
+
11
+ _OPS: dict[str, Callable[[Any, Any], bool]] = {
12
+ "eq": lambda a, b: a == b,
13
+ "ne": lambda a, b: a != b,
14
+ "in": lambda a, b: a in b,
15
+ "contains": lambda a, b: isinstance(a, str) and b in a,
16
+ "gt": lambda a, b: a is not None and a > b,
17
+ "gte": lambda a, b: a is not None and a >= b,
18
+ "lt": lambda a, b: a is not None and a < b,
19
+ "lte": lambda a, b: a is not None and a <= b,
20
+ }
21
+
22
+
23
+ def _matches(row: dict[str, Any], where: list[Where] | None) -> bool:
24
+ return all(_OPS[c.operator](row.get(c.field), c.value) for c in where or [])
25
+
26
+
27
+ class MemoryAdapter(BaseAdapter):
28
+ def __init__(self) -> None:
29
+ self._store: dict[str, list[dict[str, Any]]] = defaultdict(list)
30
+
31
+ async def create(self, model: str, data: dict[str, Any]) -> dict[str, Any]:
32
+ self._store[model].append(dict(data))
33
+ return dict(data)
34
+
35
+ async def find_one(self, model: str, where: list[Where]) -> dict[str, Any] | None:
36
+ return next((dict(r) for r in self._store[model] if _matches(r, where)), None)
37
+
38
+ async def find_many(self, model: str, where: list[Where] | None = None) -> list[dict[str, Any]]:
39
+ return [dict(r) for r in self._store[model] if _matches(r, where)]
40
+
41
+ async def update(
42
+ self, model: str, where: list[Where], data: dict[str, Any]
43
+ ) -> dict[str, Any] | None:
44
+ for row in self._store[model]:
45
+ if _matches(row, where):
46
+ row.update(data)
47
+ return dict(row)
48
+ return None
49
+
50
+ async def delete_many(self, model: str, where: list[Where]) -> int:
51
+ rows = self._store[model]
52
+ kept = [r for r in rows if not _matches(r, where)]
53
+ deleted = len(rows) - len(kept)
54
+ self._store[model] = kept
55
+ return deleted
@@ -0,0 +1,166 @@
1
+ """SQLAlchemy adapter (async). Tables are generated from the merged schema.
2
+
3
+ Install with: ``pip install better-auth-py[sqlalchemy]``.
4
+
5
+ Datetimes are stored as naive UTC (portable across SQLite/Postgres/MySQL) and always
6
+ returned timezone-aware (UTC).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ from sqlalchemy import Boolean, Column, DateTime, ForeignKey, MetaData, String, Table, Text, and_
15
+ from sqlalchemy.ext.asyncio import AsyncEngine
16
+ from sqlalchemy.sql import ColumnElement
17
+
18
+ from ..schema import Schema
19
+ from .base import BaseAdapter, Where
20
+
21
+ _TYPES = {
22
+ "string": lambda: String(255),
23
+ "text": lambda: Text(),
24
+ "boolean": lambda: Boolean(),
25
+ "datetime": lambda: DateTime(),
26
+ }
27
+
28
+
29
+ def _to_storage(value: Any) -> Any:
30
+ if isinstance(value, datetime) and value.tzinfo is not None:
31
+ return value.astimezone(timezone.utc).replace(tzinfo=None)
32
+ return value
33
+
34
+
35
+ def _from_storage(value: Any) -> Any:
36
+ if isinstance(value, datetime) and value.tzinfo is None:
37
+ return value.replace(tzinfo=timezone.utc)
38
+ return value
39
+
40
+
41
+ class SQLAlchemyAdapter(BaseAdapter):
42
+ def __init__(self, engine: AsyncEngine, metadata: MetaData | None = None):
43
+ self.engine = engine
44
+ self.metadata = metadata or MetaData()
45
+ self._tables: dict[str, Table] = {}
46
+
47
+ def init(self, schema: Schema) -> None:
48
+ for model, fields in schema.items():
49
+ if model in self._tables:
50
+ continue
51
+ columns: list[Column] = []
52
+ for name, spec in fields.items():
53
+ args: list[Any] = [_TYPES[spec.type]()]
54
+ if spec.references:
55
+ ref_model, ref_col = spec.references.split(".")
56
+ args.append(ForeignKey(f"{ref_model}.{ref_col}"))
57
+ columns.append(
58
+ Column(
59
+ name,
60
+ *args,
61
+ primary_key=name == "id",
62
+ nullable=not spec.required,
63
+ unique=spec.unique and name != "id",
64
+ )
65
+ )
66
+ self._tables[model] = Table(model, self.metadata, *columns)
67
+
68
+ async def create_tables(self) -> None:
69
+ """Create missing tables (dev convenience — use real migrations in production)."""
70
+ async with self.engine.begin() as conn:
71
+ await conn.run_sync(self.metadata.create_all)
72
+
73
+ def _table(self, model: str) -> Table:
74
+ try:
75
+ return self._tables[model]
76
+ except KeyError:
77
+ raise KeyError(f"Unknown model {model!r} — was the adapter initialized?") from None
78
+
79
+ def _condition(self, table: Table, where: list[Where] | None) -> ColumnElement | None:
80
+ if not where:
81
+ return None
82
+ parts = []
83
+ for c in where:
84
+ col = table.c[c.field]
85
+ value = _to_storage(c.value)
86
+ if c.operator == "eq":
87
+ parts.append(col == value)
88
+ elif c.operator == "ne":
89
+ parts.append(col != value)
90
+ elif c.operator == "in":
91
+ parts.append(col.in_([_to_storage(v) for v in c.value]))
92
+ elif c.operator == "contains":
93
+ parts.append(col.contains(c.value))
94
+ elif c.operator == "gt":
95
+ parts.append(col > value)
96
+ elif c.operator == "gte":
97
+ parts.append(col >= value)
98
+ elif c.operator == "lt":
99
+ parts.append(col < value)
100
+ elif c.operator == "lte":
101
+ parts.append(col <= value)
102
+ else:
103
+ raise ValueError(f"Unsupported operator {c.operator!r}")
104
+ return and_(*parts)
105
+
106
+ @staticmethod
107
+ def _row(row: Any) -> dict[str, Any]:
108
+ return {key: _from_storage(value) for key, value in row._mapping.items()}
109
+
110
+ async def create(self, model: str, data: dict[str, Any]) -> dict[str, Any]:
111
+ table = self._table(model)
112
+ values = {k: _to_storage(v) for k, v in data.items()}
113
+ async with self.engine.begin() as conn:
114
+ await conn.execute(table.insert().values(**values))
115
+ return dict(data)
116
+
117
+ async def find_one(self, model: str, where: list[Where]) -> dict[str, Any] | None:
118
+ table = self._table(model)
119
+ stmt = table.select().limit(1)
120
+ cond = self._condition(table, where)
121
+ if cond is not None:
122
+ stmt = stmt.where(cond)
123
+ async with self.engine.connect() as conn:
124
+ row = (await conn.execute(stmt)).first()
125
+ return self._row(row) if row is not None else None
126
+
127
+ async def find_many(self, model: str, where: list[Where] | None = None) -> list[dict[str, Any]]:
128
+ table = self._table(model)
129
+ stmt = table.select()
130
+ cond = self._condition(table, where)
131
+ if cond is not None:
132
+ stmt = stmt.where(cond)
133
+ async with self.engine.connect() as conn:
134
+ rows = (await conn.execute(stmt)).all()
135
+ return [self._row(r) for r in rows]
136
+
137
+ async def update(
138
+ self, model: str, where: list[Where], data: dict[str, Any]
139
+ ) -> dict[str, Any] | None:
140
+ table = self._table(model)
141
+ cond = self._condition(table, where)
142
+ values = {k: _to_storage(v) for k, v in data.items()}
143
+ stmt = table.update().values(**values)
144
+ if cond is not None:
145
+ stmt = stmt.where(cond)
146
+ async with self.engine.begin() as conn:
147
+ await conn.execute(stmt)
148
+ return await self.find_one(model, self._refind(where, data))
149
+
150
+ @staticmethod
151
+ def _refind(where: list[Where], data: dict[str, Any]) -> list[Where]:
152
+ # if an updated column was part of the lookup, look it up by its new value
153
+ return [
154
+ Where(c.field, data[c.field]) if c.operator == "eq" and c.field in data else c
155
+ for c in where
156
+ ]
157
+
158
+ async def delete_many(self, model: str, where: list[Where]) -> int:
159
+ table = self._table(model)
160
+ cond = self._condition(table, where)
161
+ stmt = table.delete()
162
+ if cond is not None:
163
+ stmt = stmt.where(cond)
164
+ async with self.engine.begin() as conn:
165
+ result = await conn.execute(stmt)
166
+ return int(result.rowcount or 0)
better_auth/auth.py ADDED
@@ -0,0 +1,239 @@
1
+ """BetterAuth: configuration, routing and request dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import Awaitable, Callable, Mapping
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ import httpx
12
+
13
+ from .adapters.base import BaseAdapter
14
+ from .adapters.memory import MemoryAdapter
15
+ from .config import EmailAndPassword, EmailVerification, RateLimit, SessionOptions
16
+ from .endpoints import ROUTES
17
+ from .oauth import OAuthProvider
18
+ from .plugins import Plugin
19
+ from .schema import CORE_SCHEMA, Schema, merge_schema
20
+ from .session import get_session as _get_session
21
+ from .types import APIError, AuthRequest, AuthResponse, Ctx
22
+
23
+ logger = logging.getLogger("better_auth")
24
+
25
+ # better-auth's default special rate-limit rules: (window seconds, max requests)
26
+ _SPECIAL_RATE_RULES: list[tuple[Callable[[str], bool], tuple[int, int]]] = [
27
+ (
28
+ lambda p: p.startswith(("/sign-in", "/sign-up", "/change-password", "/change-email")),
29
+ (10, 3),
30
+ ),
31
+ (
32
+ lambda p: (
33
+ p in ("/request-password-reset", "/send-verification-email")
34
+ or p.startswith("/forget-password")
35
+ ),
36
+ (60, 3),
37
+ ),
38
+ ]
39
+
40
+
41
+ class BetterAuth:
42
+ """The auth instance. Mount it with an integration (e.g. better_auth.integrations.fastapi)
43
+ or call :meth:`handle` with an :class:`~better_auth.types.AuthRequest` directly."""
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ secret: str,
49
+ adapter: BaseAdapter | None = None,
50
+ base_url: str = "http://localhost:8000",
51
+ base_path: str = "/api/auth",
52
+ email_and_password: EmailAndPassword | None = None,
53
+ email_verification: EmailVerification | None = None,
54
+ social_providers: Mapping[str, OAuthProvider] | None = None,
55
+ session: SessionOptions | None = None,
56
+ rate_limit: RateLimit | None = None,
57
+ trusted_origins: list[str] | None = None,
58
+ plugins: list[Plugin] | None = None,
59
+ hooks: dict[str, Callable[..., Awaitable[None]]] | None = None,
60
+ http_client: httpx.AsyncClient | None = None,
61
+ cookie_prefix: str = "better-auth",
62
+ use_secure_cookies: bool | None = None,
63
+ skip_state_cookie_check: bool = False,
64
+ ):
65
+ if not secret or len(secret) < 32:
66
+ raise ValueError(
67
+ "secret must be at least 32 characters — generate one with"
68
+ " `openssl rand -base64 32`"
69
+ )
70
+ self.secret = secret
71
+ self.base_url = base_url.rstrip("/")
72
+ stripped = base_path.strip("/")
73
+ self.base_path = f"/{stripped}" if stripped else ""
74
+ self.email_and_password = email_and_password or EmailAndPassword()
75
+ self.email_verification = email_verification or EmailVerification()
76
+ self.session_options = session or SessionOptions()
77
+ self.rate_limit = rate_limit or RateLimit()
78
+ self.trusted_origins = [origin.rstrip("/") for origin in trusted_origins or []]
79
+ self.plugins = list(plugins or [])
80
+ self.hooks = dict(hooks or {})
81
+ self.cookie_prefix = cookie_prefix
82
+ self.use_secure_cookies = (
83
+ use_secure_cookies
84
+ if use_secure_cookies is not None
85
+ else self.base_url.startswith("https://")
86
+ )
87
+ self.skip_state_cookie_check = skip_state_cookie_check
88
+
89
+ self.social_providers = dict(social_providers or {})
90
+ for provider_id, provider in self.social_providers.items():
91
+ if not provider.provider_id:
92
+ provider.provider_id = provider_id
93
+
94
+ self.schema: Schema = merge_schema(CORE_SCHEMA, *(p.schema for p in self.plugins))
95
+ self.adapter = adapter if adapter is not None else MemoryAdapter()
96
+ self.adapter.init(self.schema)
97
+
98
+ self._http = http_client
99
+ self._rate_buckets: dict[str, tuple[float, int]] = {}
100
+ self._routes: list[tuple[str, tuple[str, ...], Any]] = []
101
+ for method, path, handler in [
102
+ *ROUTES,
103
+ *(route for plugin in self.plugins for route in plugin.routes()),
104
+ ]:
105
+ self._routes.append((method, tuple(path.strip("/").split("/")), handler))
106
+
107
+ # --- helpers ----------------------------------------------------------------------
108
+
109
+ @property
110
+ def http(self) -> httpx.AsyncClient:
111
+ if self._http is None:
112
+ self._http = httpx.AsyncClient(timeout=10)
113
+ return self._http
114
+
115
+ async def run_hook(self, name: str, *args: Any) -> None:
116
+ hook = self.hooks.get(name)
117
+ if hook is not None:
118
+ await hook(*args)
119
+
120
+ def _allowed_origins(self) -> set[str]:
121
+ origins = {self._origin(self.base_url)}
122
+ origins.update(self._origin(origin) for origin in self.trusted_origins)
123
+ origins.discard("")
124
+ return origins
125
+
126
+ @staticmethod
127
+ def _origin(url: str) -> str:
128
+ parts = urlsplit(url)
129
+ return f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else ""
130
+
131
+ def is_trusted_url(self, url: str) -> bool:
132
+ """Relative paths and URLs on the base/trusted origins are allowed redirect targets."""
133
+ if url.startswith("/") and not url.startswith("//"):
134
+ return True
135
+ return self._origin(url) in self._allowed_origins()
136
+
137
+ def ensure_trusted_url(self, url: str) -> None:
138
+ if not self.is_trusted_url(url):
139
+ raise APIError(403, "INVALID_CALLBACK_URL", "Callback URL is not trusted")
140
+
141
+ async def load_session(self, request: AuthRequest) -> dict[str, Any] | None:
142
+ """``{"session": ..., "user": ...}`` for the request, or None. Used by integrations."""
143
+ result, _cookies = await _get_session(self, request)
144
+ return result
145
+
146
+ # --- dispatch ---------------------------------------------------------------------
147
+
148
+ async def handle(self, request: AuthRequest) -> AuthResponse:
149
+ try:
150
+ return await self._dispatch(request)
151
+ except APIError as error:
152
+ return AuthResponse(
153
+ status=error.status, body={"code": error.code, "message": error.message}
154
+ )
155
+ except Exception:
156
+ logger.exception("better-auth error handling %s %s", request.method, request.path)
157
+ return AuthResponse(status=500, body={"message": "Internal Server Error"})
158
+
159
+ async def _dispatch(self, request: AuthRequest) -> AuthResponse:
160
+ request.path = "/" + request.path.strip("/")
161
+
162
+ retry_after = self._check_rate_limit(request)
163
+ if retry_after is not None:
164
+ return AuthResponse(
165
+ status=429,
166
+ body={"message": "Too many requests. Please try again later."},
167
+ headers=[("x-retry-after", str(retry_after))],
168
+ )
169
+ self._check_origin(request)
170
+
171
+ match = self._match(request.method, request.path)
172
+ if match is None:
173
+ return AuthResponse(status=404, body={"message": "Not Found"})
174
+ handler, params = match
175
+
176
+ ctx = Ctx(auth=self, request=request, params=params)
177
+ for plugin in self.plugins:
178
+ short_circuit = await plugin.before(ctx)
179
+ if short_circuit is not None:
180
+ return short_circuit
181
+
182
+ result = await handler(ctx)
183
+ response = result if isinstance(result, AuthResponse) else AuthResponse(body=result)
184
+
185
+ for plugin in self.plugins:
186
+ replacement = await plugin.after(ctx, response)
187
+ if replacement is not None:
188
+ response = replacement
189
+ return response
190
+
191
+ def _match(self, method: str, path: str) -> tuple[Any, dict[str, str]] | None:
192
+ parts = tuple(path.strip("/").split("/"))
193
+ for route_method, segments, handler in self._routes:
194
+ if route_method != method or len(segments) != len(parts):
195
+ continue
196
+ params: dict[str, str] = {}
197
+ for segment, part in zip(segments, parts, strict=True):
198
+ if segment.startswith("{") and segment.endswith("}"):
199
+ params[segment[1:-1]] = part
200
+ elif segment != part:
201
+ break
202
+ else:
203
+ return handler, params
204
+ return None
205
+
206
+ def _check_origin(self, request: AuthRequest) -> None:
207
+ """Reject state-changing requests from untrusted browser origins (CSRF)."""
208
+ if request.method == "GET":
209
+ return
210
+ origin = request.headers.get("origin")
211
+ if not origin: # non-browser clients (no Origin header) pass through
212
+ return
213
+ if origin.rstrip("/") not in self._allowed_origins():
214
+ raise APIError(403, "INVALID_ORIGIN", "Origin not trusted")
215
+
216
+ def _check_rate_limit(self, request: AuthRequest) -> int | None:
217
+ """Returns seconds to wait when limited, else None. Fixed window, in-memory."""
218
+ limit = self.rate_limit
219
+ if not limit.enabled:
220
+ return None
221
+ window, maximum = limit.window, limit.max
222
+ for matches, rule in _SPECIAL_RATE_RULES:
223
+ if matches(request.path):
224
+ window, maximum = rule
225
+ break
226
+ custom = limit.custom_rules.get(request.path)
227
+ if custom is not None:
228
+ window, maximum = custom
229
+
230
+ key = f"{request.client_ip or 'no-ip'}-{request.path}"
231
+ now = time.time()
232
+ start, count = self._rate_buckets.get(key, (now, 0))
233
+ if now - start >= window:
234
+ start, count = now, 0
235
+ count += 1
236
+ self._rate_buckets[key] = (start, count)
237
+ if count > maximum:
238
+ return max(1, int(window - (now - start)))
239
+ return None
better_auth/config.py ADDED
@@ -0,0 +1,55 @@
1
+ """Configuration dataclasses, mirroring better-auth's options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ #: async callback(user, url, token) used for verification / reset-password emails.
10
+ SendEmail = Callable[[dict[str, Any], str, str], Awaitable[None]]
11
+
12
+ DAY = 60 * 60 * 24
13
+
14
+
15
+ @dataclass
16
+ class EmailAndPassword:
17
+ enabled: bool = False
18
+ min_password_length: int = 8
19
+ max_password_length: int = 128
20
+ require_email_verification: bool = False
21
+ #: create a session right after sign-up (ignored when email verification is required)
22
+ auto_sign_in: bool = True
23
+ send_reset_password: SendEmail | None = None
24
+ reset_password_token_expires_in: int = 60 * 60
25
+ revoke_sessions_on_password_reset: bool = False
26
+
27
+
28
+ @dataclass
29
+ class EmailVerification:
30
+ send_verification_email: SendEmail | None = None
31
+ send_on_sign_up: bool = False
32
+ auto_sign_in_after_verification: bool = False
33
+ expires_in: int = 60 * 60
34
+
35
+
36
+ @dataclass
37
+ class SessionOptions:
38
+ expires_in: int = 7 * DAY
39
+ #: refresh `expiresAt` when the session is older than this
40
+ update_age: int = 1 * DAY
41
+
42
+
43
+ @dataclass
44
+ class RateLimit:
45
+ """Fixed-window in-memory rate limiting, keyed by client IP + path.
46
+
47
+ ponytail: in-memory only — use one worker or put stricter limits at the proxy;
48
+ a shared-storage backend can come later if needed.
49
+ """
50
+
51
+ enabled: bool = False
52
+ window: int = 10 # seconds
53
+ max: int = 100
54
+ #: per-path overrides, e.g. {"/sign-in/email": (10, 3)}
55
+ custom_rules: dict[str, tuple[int, int]] = field(default_factory=dict)