Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ client = VolcanoClient(
service_key="your-service-key",
)

sign_up = client.auth.sign_up(
email="new-user@example.com",
password="secret",
metadata={"display_name": "New User"},
)
if sign_up.confirmation_required:
print(sign_up.message)

session = client.auth.sign_in(email="user@example.com", password="secret")
current_session = client.auth.get_session()
assert current_session == session
Expand All @@ -32,6 +40,10 @@ lease = client.locks.acquire("build", ttl=30)
client.locks.release("build", lease)
```

`sign_up()` returns an immutable acknowledgement and never creates or replaces a session. The
response is identical for new and existing email addresses. Call `sign_in()` separately after the
account is ready to establish a session.

`get_session()` reads immutable local state. It does not refresh or validate the token.

Copy a complete native session into another client's memory:
Expand Down
3 changes: 2 additions & 1 deletion src/volcano_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
ValidationError,
VolcanoError,
)
from .models import LockLease, Session
from .models import LockLease, Session, SignUpResult

__all__ = [
"AuthenticationError",
Expand All @@ -23,6 +23,7 @@
"ServerError",
"Session",
"SessionChangedError",
"SignUpResult",
"TransportError",
"ValidationError",
"VolcanoClient",
Expand Down
39 changes: 38 additions & 1 deletion src/volcano_sdk/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@

import httpx

from ._generated.api.authentication import auth_logout, auth_refresh, auth_signin
from ._generated.api.authentication import (
auth_logout,
auth_refresh,
auth_signin,
auth_signup,
)
from ._generated.api.database_queries import query_database_select
from ._generated.api.locks import acquire_project_lock, release_project_lock
from ._generated.api.storage_objects import (
Expand All @@ -22,6 +27,10 @@
from ._generated.models.auth_logout_body import AuthLogoutBody
from ._generated.models.auth_refresh_body import AuthRefreshBody
from ._generated.models.auth_signin_body import AuthSigninBody
from ._generated.models.auth_signup_body import AuthSignupBody
from ._generated.models.auth_signup_body_user_metadata import (
AuthSignupBodyUserMetadata,
)
from ._generated.models.database_select_request import DatabaseSelectRequest
from ._generated.models.project_lock_lease_request import ProjectLockLeaseRequest
from ._generated.models.upload_storage_object_files_body import (
Expand Down Expand Up @@ -98,6 +107,17 @@ def auth_logout(
) -> TransportResponse: ...


class AuthSignUpTransport(Protocol):
def auth_signup(
self,
*,
authorization: str,
email: str,
password: str,
metadata: dict[str, object],
) -> TransportResponse: ...


class Transport(Protocol):
def auth_signin(
self,
Expand Down Expand Up @@ -263,6 +283,23 @@ def auth_signin(
)
return self._response(response)

def auth_signup(
self,
*,
authorization: str,
email: str,
password: str,
metadata: dict[str, object],
) -> TransportResponse:
body = AuthSignupBody(
email=email,
password=password,
user_metadata=AuthSignupBodyUserMetadata.from_dict(metadata),
)
with self._client(authorization) as client:
response = auth_signup.sync_detailed(client=client, body=body)
return self._response(response)

def auth_refresh(
self,
*,
Expand Down
36 changes: 35 additions & 1 deletion src/volcano_sdk/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@
from ._transport import (
AuthLogoutTransport,
AuthRefreshTransport,
AuthSignUpTransport,
Transport,
invoke,
response_payload,
)
from .errors import AuthenticationError, SessionChangedError, VolcanoError
from .models import Session
from .models import Session, SignUpResult

_INCOMPLETE_SESSION = "Expected a complete Session"
_INVALID_SIGN_UP_RESULT = "Expected a complete sign-up acknowledgement"
_NO_ACTIVE_SESSION = "No active session"


Expand Down Expand Up @@ -63,6 +65,20 @@ def _session_from_payload(payload: object) -> Session:
)


def _sign_up_result_from_payload(payload: object) -> SignUpResult:
values: Mapping[object, object] = (
cast("Mapping[object, object]", payload) if isinstance(payload, Mapping) else {}
)
confirmation_required = values.get("confirmation_required")
message = values.get("message")
if not isinstance(confirmation_required, bool) or not isinstance(message, str):
raise TypeError(_INVALID_SIGN_UP_RESULT)
return SignUpResult(
confirmation_required=confirmation_required,
message=message,
)


class AuthContext(Protocol):
"""Client capabilities required by the authentication facade."""

Expand Down Expand Up @@ -101,6 +117,24 @@ def set_session(self, session: Session) -> Session:
self._client._set_session(owned)
return owned

def sign_up(
self,
*,
email: str,
password: str,
metadata: Mapping[str, object] | None = None,
) -> SignUpResult:
"""Create an account without creating or replacing a local session."""
transport = cast("AuthSignUpTransport", self._client._transport)
response = invoke(
transport.auth_signup,
authorization=self._client._anon_token(),
email=email,
password=password,
metadata=dict(metadata or {}),
)
return _sign_up_result_from_payload(response_payload(response, 201))

def sign_in(self, *, email: str, password: str) -> Session:
"""Sign in a user and store the returned session."""
response = invoke(
Expand Down
8 changes: 8 additions & 0 deletions src/volcano_sdk/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ class Session:
user_id: str


@dataclass(frozen=True, slots=True)
class SignUpResult:
"""Session-less acknowledgement returned after sign-up."""

confirmation_required: bool
message: str


@dataclass(frozen=True, slots=True)
class LockLease:
"""Lease returned for an acquired distributed lock."""
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/test_generated_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,46 @@
from volcano_sdk._transport import GeneratedTransport


def test_generated_transport_signs_up_with_the_anon_key_and_metadata() -> None:
requests: list[httpx.Request] = []

def handle(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
201,
json={
"confirmation_required": True,
"message": "Check your email to confirm your account",
},
)

transport = GeneratedTransport(
api_url="https://api.test.volcano.dev",
httpx_transport=httpx.MockTransport(handle),
)

response = transport.auth_signup(
authorization="anon-key",
email="user@example.com",
password="secret",
metadata={"display_name": "New User"},
)

assert response.status_code == 201
assert response.payload == {
"confirmation_required": True,
"message": "Check your email to confirm your account",
}
assert requests[0].method == "POST"
assert requests[0].url.path == "/auth/signup"
assert requests[0].headers["authorization"] == "Bearer anon-key"
assert json.loads(requests[0].content) == {
"email": "user@example.com",
"password": "secret",
"user_metadata": {"display_name": "New User"},
}


def test_generated_transport_logs_out_with_the_anon_key_and_refresh_token() -> None:
requests: list[httpx.Request] = []

Expand Down
9 changes: 8 additions & 1 deletion tests/unit/test_import.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
from volcano_sdk import VolcanoClient
from volcano_sdk import SignUpResult, VolcanoClient


def test_package_exports_client() -> None:
assert VolcanoClient.__name__ == "VolcanoClient"


def test_package_exports_sign_up_result() -> None:
result = SignUpResult(confirmation_required=True, message="Check your email")

assert result.confirmation_required is True
assert result.message == "Check your email"
64 changes: 63 additions & 1 deletion tests/unit/test_state.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from dataclasses import dataclass
from dataclasses import FrozenInstanceError, dataclass
from typing import TYPE_CHECKING, Any

import pytest
Expand Down Expand Up @@ -28,6 +28,14 @@ class Response:
class StateTransport:
def __init__(self) -> None:
self.next_access_token = "access-1"
self.signup_response = Response(
201,
{
"confirmation_required": True,
"message": "Check your email to confirm your account",
},
)
self.signup_calls: list[dict[str, Any]] = []
self.refresh_response = Response(
200,
{
Expand All @@ -53,6 +61,11 @@ def auth_signin(self, **kwargs: Any) -> Response:
},
)

def auth_signup(self, **kwargs: Any) -> Response:
self.authorizations.append(("signup", kwargs["authorization"]))
self.signup_calls.append(kwargs)
return self.signup_response

def auth_refresh(self, **kwargs: Any) -> Response:
self.authorizations.append(("refresh", kwargs["authorization"]))
if self.on_refresh is not None:
Expand Down Expand Up @@ -151,6 +164,55 @@ def test_auth_facade_reads_an_empty_session_without_transport() -> None:
assert transport.authorizations == []


def test_sign_up_returns_immutable_acknowledgement_without_session_change() -> None:
transport = StateTransport()
client = VolcanoClient(anon_key="anon", _transport=transport)
established = client.auth.sign_in(email="user@example.com", password="secret")

result = client.auth.sign_up(
email="new@example.com",
password="secret",
metadata={"display_name": "New User"},
)

assert result.confirmation_required is True
assert result.message == "Check your email to confirm your account"
assert client.auth.get_session() is established
assert transport.signup_calls == [
{
"authorization": "anon",
"email": "new@example.com",
"password": "secret",
"metadata": {"display_name": "New User"},
}
]
mutable_result: Any = result
with pytest.raises(FrozenInstanceError):
mutable_result.message = "changed"


def test_sign_up_uses_empty_metadata_without_creating_a_session() -> None:
transport = StateTransport()
client = VolcanoClient(anon_key="anon", _transport=transport)

client.auth.sign_up(email="new@example.com", password="secret")

assert client.auth.get_session() is None
assert transport.signup_calls[0]["metadata"] == {}


def test_sign_up_raises_typed_errors_without_changing_session() -> None:
transport = StateTransport()
client = VolcanoClient(anon_key="anon", _transport=transport)
established = client.auth.sign_in(email="user@example.com", password="secret")
transport.signup_response = Response(403, {"error": "Signups are disabled"})

with pytest.raises(AuthenticationError, match="Signups are disabled"):
client.auth.sign_up(email="new@example.com", password="secret")

assert client.auth.get_session() is established


def test_auth_facade_reads_established_immutable_session_without_transport() -> None:
transport = StateTransport()
client = VolcanoClient(anon_key="anon", _transport=transport)
Expand Down
Loading