diff --git a/python/src/numerous/experimental/marimo/_cookies/fastapi.py b/python/src/numerous/experimental/marimo/_cookies/fastapi.py index 8d9d68e..74af4b8 100644 --- a/python/src/numerous/experimental/marimo/_cookies/fastapi.py +++ b/python/src/numerous/experimental/marimo/_cookies/fastapi.py @@ -39,7 +39,7 @@ def add_marimo_cookie_middleware( cookies = FileCookieStorage(cookies_dir, session_ident or _ident) use_cookie_storage(cookies) - @app.middleware("http") # type: ignore[misc] + @app.middleware("http") # type: ignore[misc, unused-ignore] async def middleware( request: Request, call_next: t.Callable[[Request], t.Awaitable[Response]], diff --git a/python/src/numerous/experimental/marimo/_field.py b/python/src/numerous/experimental/marimo/_field.py index 6fe84df..92769bc 100644 --- a/python/src/numerous/experimental/marimo/_field.py +++ b/python/src/numerous/experimental/marimo/_field.py @@ -13,7 +13,7 @@ """ -from typing import Any, Dict, Type, TypeVar, Union +from typing import Any, Type, TypeVar, Union import marimo as mo from marimo._runtime.state import State as MoState @@ -50,7 +50,7 @@ def __init__( self, default: Union[str, float, None] = None, annotation: Union[type, None] = None, - **kwargs: Dict[str, Any], + **kwargs: dict[str, Any], ) -> None: """ Field with a state that can be used in a Marimo app. diff --git a/python/src/numerous/experimental/model.py b/python/src/numerous/experimental/model.py index 5f1d3ae..8a2ea93 100644 --- a/python/src/numerous/experimental/model.py +++ b/python/src/numerous/experimental/model.py @@ -23,7 +23,7 @@ - Field: Class representing a field in the model. """ -from typing import Any, Dict, Tuple, Union +from typing import Any, Tuple, Union from pydantic import BaseModel as PydanticBaseModel from pydantic import Field as PydanticField @@ -94,7 +94,7 @@ class BaseModel(_ModelInterface): """ - def __init__(self, **kwargs: Dict[str, Any]) -> None: + def __init__(self, **kwargs: dict[str, Any]) -> None: """ Initialize a model object with the given fields. @@ -195,7 +195,7 @@ def __init__( self, default: Union[str, float, None] = None, annotation: Union[type, None] = None, - **kwargs: Dict[str, Any], + **kwargs: dict[str, Any], ) -> None: """ Initialize a Field object. diff --git a/python/src/numerous/frameworks/__init__.py b/python/src/numerous/frameworks/__init__.py new file mode 100644 index 0000000..7acc000 --- /dev/null +++ b/python/src/numerous/frameworks/__init__.py @@ -0,0 +1 @@ +"""Module for integrating Numerous with various frameworks.""" diff --git a/python/src/numerous/frameworks/dash.py b/python/src/numerous/frameworks/dash.py new file mode 100644 index 0000000..d1b9eb3 --- /dev/null +++ b/python/src/numerous/frameworks/dash.py @@ -0,0 +1,19 @@ +"""Module for integrating Numerous with Dash.""" + +from numerous import user_session +from numerous.frameworks.flask import FlaskCookieGetter + + +class DashCookieGetter(FlaskCookieGetter): + pass + + +def get_session() -> user_session.Session: + """ + Get the session for the current user. + + Returns: + Session: The session for the current user. + + """ + return user_session.Session(cg=DashCookieGetter()) diff --git a/python/src/numerous/frameworks/fastapi.py b/python/src/numerous/frameworks/fastapi.py new file mode 100644 index 0000000..8ba59ec --- /dev/null +++ b/python/src/numerous/frameworks/fastapi.py @@ -0,0 +1,30 @@ +"""Module for integrating Numerous with FastAPI.""" + +from fastapi import Request + +from numerous import user_session +from numerous.local import is_local_mode, local_user + + +class FastAPICookieGetter: + def __init__(self, request: Request) -> None: + self.request = request + + def cookies(self) -> dict[str, str]: + """Get the cookies associated with the current request.""" + if is_local_mode(): + # Update the cookies on the fastapi server + user_session.set_user_info_cookie(self.request.cookies, local_user) + + return {str(key): str(val) for key, val in self.request.cookies.items()} + + +def get_session(request: Request) -> user_session.Session: + """ + Get the session for the current user. + + Returns: + Session: The session for the current user. + + """ + return user_session.Session(cg=FastAPICookieGetter(request)) diff --git a/python/src/numerous/frameworks/flask.py b/python/src/numerous/frameworks/flask.py new file mode 100644 index 0000000..7831ec5 --- /dev/null +++ b/python/src/numerous/frameworks/flask.py @@ -0,0 +1,27 @@ +"""Module for integrating Numerous with Flask.""" + +from flask import request + +from numerous import user_session +from numerous.local import is_local_mode, local_user + + +class FlaskCookieGetter: + def cookies(self) -> dict[str, str]: + """Get the cookies associated with the current request.""" + cookies = {key: str(val) for key, val in request.cookies.items()} + if is_local_mode(): + # Update the cookies on the flask server + user_session.set_user_info_cookie(cookies, local_user) + return cookies + + +def get_session() -> user_session.Session: + """ + Get the session for the current user. + + Returns: + Session: The session for the current user. + + """ + return user_session.Session(cg=FlaskCookieGetter()) diff --git a/python/src/numerous/frameworks/marimo.py b/python/src/numerous/frameworks/marimo.py new file mode 100644 index 0000000..389c2e4 --- /dev/null +++ b/python/src/numerous/frameworks/marimo.py @@ -0,0 +1,27 @@ +"""Module for integrating Numerous with Marimo.""" + +from typing import Any + +from numerous import user_session +from numerous.experimental import marimo +from numerous.local import is_local_mode, local_user + + +class MarimoCookieGetter: + def cookies(self) -> dict[str, Any]: + """Get the cookies associated with the current request.""" + if is_local_mode(): + # Update the cookies on the marimo server + user_session.set_user_info_cookie(marimo.cookies(), local_user) + return {key: str(val) for key, val in marimo.cookies().items()} + + +def get_session() -> user_session.Session: + """ + Get the session for the current user. + + Returns: + Session: The session for the current user. + + """ + return user_session.Session(cg=MarimoCookieGetter()) diff --git a/python/src/numerous/frameworks/panel.py b/python/src/numerous/frameworks/panel.py new file mode 100644 index 0000000..2244981 --- /dev/null +++ b/python/src/numerous/frameworks/panel.py @@ -0,0 +1,30 @@ +"""Module for integrating Numerous with Panel.""" + +import panel as pn + +from numerous import user_session +from numerous.local import is_local_mode, local_user + + +class PanelCookieGetter: + @staticmethod + def cookies() -> dict[str, str]: + """Get the cookies associated with the current request.""" + if is_local_mode(): + # Add the user info to the cookies on panel server + user_session.set_user_info_cookie(pn.state.cookies, local_user) + + if pn.state.curdoc and pn.state.curdoc.session_context: + return {key: str(val) for key, val in pn.state.cookies.items()} + return {} + + +def get_session() -> user_session.Session: + """ + Get the session for the current user. + + Returns: + Session: The session for the current user. + + """ + return user_session.Session(cg=PanelCookieGetter()) diff --git a/python/src/numerous/frameworks/streamlit.py b/python/src/numerous/frameworks/streamlit.py new file mode 100644 index 0000000..49afb9c --- /dev/null +++ b/python/src/numerous/frameworks/streamlit.py @@ -0,0 +1,27 @@ +"""Module for integrating Numerous with Streamlit.""" + +import streamlit as st + +from numerous import user_session +from numerous.local import is_local_mode, local_user + + +class StreamlitCookieGetter: + def cookies(self) -> dict[str, str]: + """Get the cookies associated with the current request.""" + cookies = {key: str(val) for key, val in st.context.cookies.items()} + if is_local_mode(): + # Update the cookies on the streamlit server + user_session.set_user_info_cookie(cookies, local_user) + return cookies + + +def get_session() -> user_session.Session: + """ + Get the session for the current user. + + Returns: + Session: The session for the current user. + + """ + return user_session.Session(cg=StreamlitCookieGetter()) diff --git a/python/src/numerous/generated/graphql/async_base_client.py b/python/src/numerous/generated/graphql/async_base_client.py index 311e672..10872b1 100644 --- a/python/src/numerous/generated/graphql/async_base_client.py +++ b/python/src/numerous/generated/graphql/async_base_client.py @@ -63,12 +63,12 @@ class AsyncBaseClient: def __init__( self, url: str = "", - headers: Optional[Dict[str, str]] = None, + headers: Optional[dict[str, str]] = None, http_client: Optional[httpx.AsyncClient] = None, ws_url: str = "", - ws_headers: Optional[Dict[str, Any]] = None, + ws_headers: Optional[dict[str, Any]] = None, ws_origin: Optional[str] = None, - ws_connection_init_payload: Optional[Dict[str, Any]] = None, + ws_connection_init_payload: Optional[dict[str, Any]] = None, ) -> None: self.url = url self.headers = headers @@ -96,7 +96,7 @@ async def execute( self, query: str, operation_name: Optional[str] = None, - variables: Optional[Dict[str, Any]] = None, + variables: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> httpx.Response: processed_variables, files, files_map = self._process_variables(variables) @@ -118,7 +118,7 @@ async def execute( **kwargs, ) - def get_data(self, response: httpx.Response) -> Dict[str, Any]: + def get_data(self, response: httpx.Response) -> dict[str, Any]: if not response.is_success: raise GraphQLClientHttpError( status_code=response.status_code, response=response @@ -142,19 +142,19 @@ def get_data(self, response: httpx.Response) -> Dict[str, Any]: errors_dicts=errors, data=data ) - return cast(Dict[str, Any], data) + return cast(dict[str, Any], data) async def execute_ws( self, query: str, operation_name: Optional[str] = None, - variables: Optional[Dict[str, Any]] = None, + variables: Optional[dict[str, Any]] = None, **kwargs: Any, - ) -> AsyncIterator[Dict[str, Any]]: + ) -> AsyncIterator[dict[str, Any]]: headers = self.ws_headers.copy() headers.update(kwargs.get("extra_headers", {})) - merged_kwargs: Dict[str, Any] = {"origin": self.ws_origin} + merged_kwargs: dict[str, Any] = {"origin": self.ws_origin} merged_kwargs.update(kwargs) merged_kwargs["extra_headers"] = headers @@ -185,9 +185,9 @@ async def execute_ws( yield data def _process_variables( - self, variables: Optional[Dict[str, Any]] + self, variables: Optional[dict[str, Any]] ) -> Tuple[ - Dict[str, Any], Dict[str, Tuple[str, IO[bytes], str]], Dict[str, List[str]] + dict[str, Any], dict[str, Tuple[str, IO[bytes], str]], dict[str, List[str]] ]: if not variables: return {}, {}, {} @@ -196,8 +196,8 @@ def _process_variables( return self._get_files_from_variables(serializable_variables) def _convert_dict_to_json_serializable( - self, dict_: Dict[str, Any] - ) -> Dict[str, Any]: + self, dict_: dict[str, Any] + ) -> dict[str, Any]: return { key: self._convert_value(value) for key, value in dict_.items() @@ -212,11 +212,11 @@ def _convert_value(self, value: Any) -> Any: return value def _get_files_from_variables( - self, variables: Dict[str, Any] + self, variables: dict[str, Any] ) -> Tuple[ - Dict[str, Any], Dict[str, Tuple[str, IO[bytes], str]], Dict[str, List[str]] + dict[str, Any], dict[str, Tuple[str, IO[bytes], str]], dict[str, List[str]] ]: - files_map: Dict[str, List[str]] = {} + files_map: dict[str, List[str]] = {} files_list: List[Upload] = [] def separate_files(path: str, obj: Any) -> Any: @@ -247,7 +247,7 @@ def separate_files(path: str, obj: Any) -> Any: return obj nulled_variables = separate_files("variables", variables) - files: Dict[str, Tuple[str, IO[bytes], str]] = { + files: dict[str, Tuple[str, IO[bytes], str]] = { str(i): (file_.filename, cast(IO[bytes], file_.content), file_.content_type) for i, file_ in enumerate(files_list) } @@ -257,9 +257,9 @@ async def _execute_multipart( self, query: str, operation_name: Optional[str], - variables: Dict[str, Any], - files: Dict[str, Tuple[str, IO[bytes], str]], - files_map: Dict[str, List[str]], + variables: dict[str, Any], + files: dict[str, Tuple[str, IO[bytes], str]], + files_map: dict[str, List[str]], **kwargs: Any, ) -> httpx.Response: data = { @@ -282,13 +282,13 @@ async def _execute_json( self, query: str, operation_name: Optional[str], - variables: Dict[str, Any], + variables: dict[str, Any], **kwargs: Any, ) -> httpx.Response: - headers: Dict[str, str] = {"Content-Type": "application/json"} + headers: dict[str, str] = {"Content-Type": "application/json"} headers.update(kwargs.get("headers", {})) - merged_kwargs: Dict[str, Any] = kwargs.copy() + merged_kwargs: dict[str, Any] = kwargs.copy() merged_kwargs["headers"] = headers return await self.http_client.post( @@ -305,7 +305,7 @@ async def _execute_json( ) async def _send_connection_init(self, websocket: WebSocketClientProtocol) -> None: - payload: Dict[str, Any] = { + payload: dict[str, Any] = { "type": GraphQLTransportWSMessageType.CONNECTION_INIT.value } if self.ws_connection_init_payload: @@ -318,9 +318,9 @@ async def _send_subscribe( operation_id: str, query: str, operation_name: Optional[str] = None, - variables: Optional[Dict[str, Any]] = None, + variables: Optional[dict[str, Any]] = None, ) -> None: - payload: Dict[str, Any] = { + payload: dict[str, Any] = { "id": operation_id, "type": GraphQLTransportWSMessageType.SUBSCRIBE.value, "payload": {"query": query, "operationName": operation_name}, @@ -336,7 +336,7 @@ async def _handle_ws_message( message: Data, websocket: WebSocketClientProtocol, expected_type: Optional[GraphQLTransportWSMessageType] = None, - ) -> Optional[Dict[str, Any]]: + ) -> Optional[dict[str, Any]]: try: message_dict = json.loads(message) except json.JSONDecodeError as exc: @@ -356,7 +356,7 @@ async def _handle_ws_message( if type_ == GraphQLTransportWSMessageType.NEXT: if "data" not in payload: raise GraphQLClientInvalidMessageFormat(message=message) - return cast(Dict[str, Any], payload["data"]) + return cast(dict[str, Any], payload["data"]) if type_ == GraphQLTransportWSMessageType.COMPLETE: await websocket.close() diff --git a/python/src/numerous/generated/graphql/exceptions.py b/python/src/numerous/generated/graphql/exceptions.py index 9fbe116..3640370 100644 --- a/python/src/numerous/generated/graphql/exceptions.py +++ b/python/src/numerous/generated/graphql/exceptions.py @@ -30,10 +30,10 @@ class GraphQLClientGraphQLError(GraphQLClientError): def __init__( self, message: str, - locations: Optional[List[Dict[str, int]]] = None, + locations: Optional[List[dict[str, int]]] = None, path: Optional[List[str]] = None, - extensions: Optional[Dict[str, object]] = None, - orginal: Optional[Dict[str, object]] = None, + extensions: Optional[dict[str, object]] = None, + orginal: Optional[dict[str, object]] = None, ): self.message = message self.locations = locations @@ -45,7 +45,7 @@ def __str__(self) -> str: return self.message @classmethod - def from_dict(cls, error: Dict[str, Any]) -> "GraphQLClientGraphQLError": + def from_dict(cls, error: dict[str, Any]) -> "GraphQLClientGraphQLError": return cls( message=error["message"], locations=error.get("locations"), @@ -59,7 +59,7 @@ class GraphQLClientGraphQLMultiError(GraphQLClientError): def __init__( self, errors: List[GraphQLClientGraphQLError], - data: Optional[Dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, ): self.errors = errors self.data = data @@ -69,7 +69,7 @@ def __str__(self) -> str: @classmethod def from_errors_dicts( - cls, errors_dicts: List[Dict[str, Any]], data: Optional[Dict[str, Any]] = None + cls, errors_dicts: List[dict[str, Any]], data: Optional[dict[str, Any]] = None ) -> "GraphQLClientGraphQLMultiError": return cls( errors=[GraphQLClientGraphQLError.from_dict(e) for e in errors_dicts], diff --git a/python/src/numerous/local.py b/python/src/numerous/local.py new file mode 100644 index 0000000..488b574 --- /dev/null +++ b/python/src/numerous/local.py @@ -0,0 +1,13 @@ +"""Local mode utilities.""" + +from os import getenv + +from numerous.user import User + + +local_user = User(id="local_user", name="Local User") + + +def is_local_mode() -> bool: + url = getenv("NUMEROUS_API_URL") + return url is None diff --git a/python/src/numerous/user.py b/python/src/numerous/user.py new file mode 100644 index 0000000..045c89a --- /dev/null +++ b/python/src/numerous/user.py @@ -0,0 +1,43 @@ +"""Module for handling user-related functionality.""" + +from dataclasses import dataclass +from typing import Any, Optional + +from numerous._client._graphql_client import GraphQLClient +from numerous.collection import NumerousCollection, collection + + +@dataclass +class User: + id: str + name: str + _client: Optional[GraphQLClient] = None + + @property + def collection(self) -> Optional["NumerousCollection"]: + """ + Get the NumerousCollection associated with this user. + + Returns: + NumerousCollection | None: The collection for this user, + or None if not found. + + """ + return collection("users", self._client).collection(self.id) + + @staticmethod + def from_user_info( + user_info: dict[str, Any], _client: Optional[GraphQLClient] = None + ) -> "User": + """ + Create a User instance from a dictionary of user information. + + Args: + user_info (dict[str, Any]): A dictionary containing user information. + _client (GraphQLClient | None): A GraphQL client instance. + + Returns: + User: A new User instance created from the provided information. + + """ + return User(id=user_info["user_id"], name=user_info["name"], _client=_client) diff --git a/python/src/numerous/user_session.py b/python/src/numerous/user_session.py new file mode 100644 index 0000000..d82664b --- /dev/null +++ b/python/src/numerous/user_session.py @@ -0,0 +1,62 @@ +"""Module for managing user sessions and cookie-based authentication.""" + +import base64 +import json +from typing import Any, Optional, Protocol + +from numerous._client._graphql_client import GraphQLClient +from numerous.user import User + + +class CookieGetter(Protocol): + def cookies(self) -> dict[str, Any]: + """Get the cookies associated with the current session.""" + ... + + +def encode_user_info(user_id: str, name: str) -> str: + user_info_json = json.dumps({"user_id": user_id, "name": name}) + return base64.b64encode(user_info_json.encode("utf-8")).decode("utf-8") + + +def set_user_info_cookie(cookies: dict[str, str], user: User) -> None: + cookies["numerous_user_info"] = encode_user_info(user.id, user.name) + + +class Session: + """A session with Numerous.""" + + def __init__( + self, cg: CookieGetter, _client: Optional[GraphQLClient] = None + ) -> None: + self._cg = cg + self._user: Optional[User] = None + self._client = _client + + def _user_info(self) -> dict[str, str]: + cookies = self._cg.cookies() + user_info_b64 = cookies.get("numerous_user_info") + if not user_info_b64: + msg = "Invalid user info in cookie or cookie is missing" + raise ValueError(msg) + try: + user_info_json = base64.b64decode(user_info_b64).decode("utf-8") + return { + str(key): str(val) for key, val in json.loads(user_info_json).items() + } + except ValueError as err: + msg = "Invalid user info in cookie or cookie is missing" + raise ValueError(msg) from err + + @property + def user(self) -> Optional[User]: + """The user associated with the current session.""" + if self._user is None: + user_info = self._user_info() + self._user = User.from_user_info(user_info, self._client) + return self._user + + @property + def cookies(self) -> dict[str, str]: + """The cookies associated with the current session.""" + return self._cg.cookies() diff --git a/python/tests/test_user.py b/python/tests/test_user.py new file mode 100644 index 0000000..b9b9e95 --- /dev/null +++ b/python/tests/test_user.py @@ -0,0 +1,75 @@ +from typing import Optional +from unittest.mock import Mock, call + +import pytest + +from numerous.collection._client import Client +from numerous.generated.graphql.fragments import CollectionReference +from numerous.user import User + + +TEST_ORGANIZATION_ID = "test-organization-id" +TEST_USER_COLLECTION_KEY = "users" +TEST_USER_COLLECTION_ID = "test-user-collection-id" +TEST_USER_ID = "test-collection-id" +TEST_COLLECTION_KEY = "test-collection-key" + + +@pytest.fixture(autouse=True) +def _set_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NUMEROUS_API_URL", "url_value") + monkeypatch.setenv("NUMEROUS_ORGANIZATION_ID", TEST_ORGANIZATION_ID) + monkeypatch.setenv("NUMEROUS_API_ACCESS_TOKEN", "token") + + +@pytest.fixture +def client() -> Mock: + def mock_get_collection_reference( + collection_key: str, parent_collection_id: Optional[str] = None + ) -> CollectionReference: + ref = (collection_key, parent_collection_id) + if ref == (TEST_USER_COLLECTION_KEY, None): + return CollectionReference( + id=TEST_USER_COLLECTION_ID, key=TEST_USER_COLLECTION_KEY + ) + if ref == (TEST_USER_ID, TEST_USER_COLLECTION_ID): + return CollectionReference(id=TEST_USER_ID, key=TEST_COLLECTION_KEY) + pytest.fail("unexpected mock call") + + client = Mock(Client) + client.get_collection_reference.side_effect = mock_get_collection_reference + + return client + + +def test_user_collection_property_returns_expected_collection(client: Mock) -> None: + user = User(id=TEST_USER_ID, name="John Doe", _client=client) + + assert user.collection is not None + assert user.collection.id == TEST_USER_ID + assert user.collection.key == TEST_COLLECTION_KEY + + +def test_user_collection_property_makes_expected_calls(client: Mock) -> None: + user = User(id=TEST_USER_ID, name="John Doe", _client=client) + + user.collection # noqa: B018 + + client.get_collection_reference.assert_has_calls( + [ + call("users"), + call( + collection_key=TEST_USER_ID, + parent_collection_id=TEST_USER_COLLECTION_ID, + ), + ] + ) + + +def test_from_user_info_returns_user_with_correct_attributes(client: Mock) -> None: + user_info = {"user_id": TEST_USER_ID, "name": "Jane Smith"} + + user = User.from_user_info(user_info, _client=client) + + assert user.id == TEST_USER_ID + assert user.name == "Jane Smith" diff --git a/python/tests/test_user_session.py b/python/tests/test_user_session.py new file mode 100644 index 0000000..7eed31b --- /dev/null +++ b/python/tests/test_user_session.py @@ -0,0 +1,66 @@ +import base64 +import json +from unittest import mock + +import pytest + +from numerous._client._graphql_client import GraphQLClient +from numerous.generated.graphql.client import Client as GQLClient +from numerous.user_session import Session + + +class CookieGetterStub: + def __init__(self, cookies: dict[str, str]) -> None: + self._cookies = cookies + + def cookies(self) -> dict[str, str]: + """Get the cookies associated with the current request.""" + return self._cookies + + +ORGANIZATION_ID = "test_org" + + +@pytest.fixture(autouse=True) +def _set_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("NUMEROUS_API_URL", "url_value") + monkeypatch.setenv("NUMEROUS_ORGANIZATION_ID", ORGANIZATION_ID) + monkeypatch.setenv("NUMEROUS_API_ACCESS_TOKEN", "token") + + +@pytest.fixture +def mock_gql_client() -> GQLClient: + return mock.Mock(GQLClient) + + +@pytest.fixture +def mock_graphql_client(mock_gql_client: GQLClient) -> GraphQLClient: + return GraphQLClient(mock_gql_client) + + +def test_user_property_raises_value_error_when_no_cookie( + mock_graphql_client: GraphQLClient, +) -> None: + cg = CookieGetterStub({}) + + session = Session(cg, _client=mock_graphql_client) + + with pytest.raises( + ValueError, match="Invalid user info in cookie or cookie is missing" + ): + # ruff: noqa: B018 + session.user + + +def test_user_property_returns_user_when_valid_cookie( + mock_graphql_client: GraphQLClient, +) -> None: + user_info = {"user_id": "1", "name": "Test User"} + encoded_info = base64.b64encode(json.dumps(user_info).encode()).decode() + cg = CookieGetterStub({"numerous_user_info": encoded_info}) + + session = Session(cg, _client=mock_graphql_client) + + assert session.user is not None + assert session.user.id == "1" + assert session.user.name == "Test User"