diff --git a/sdks/implementations/python/README.md b/sdks/implementations/python/README.md new file mode 100644 index 000000000..59f4068a0 --- /dev/null +++ b/sdks/implementations/python/README.md @@ -0,0 +1,27 @@ +# Stack Auth Python SDK + +Python SDK for [Stack Auth](https://stack-auth.com) - authentication, user management, and team management for your Python backend. + +## Installation + +```bash +pip install stack-auth +``` + +## Quick Start + +```python +from stack_auth import StackServerApp + +app = StackServerApp( + project_id="your-project-id", + secret_server_key="your-secret-key", +) + +# Verify a request's access token +user = await app.authenticate_request(request) +``` + +## Requirements + +- Python 3.10+ diff --git a/sdks/implementations/python/pyproject.toml b/sdks/implementations/python/pyproject.toml new file mode 100644 index 000000000..87f064860 --- /dev/null +++ b/sdks/implementations/python/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "stack-auth" +version = "0.1.0" +description = "Python SDK for Stack Auth" +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27,<1.0", + "pyjwt[crypto]>=2.9,<3.0", + "pydantic>=2.7,<3.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=1.0", + "respx>=0.22", + "ruff>=0.11", + "mypy>=1.10", + "pytest-cov>=5.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/stack_auth"] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "UP", "B", "SIM", "TCH"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.mypy] +python_version = "3.10" +strict = true diff --git a/sdks/implementations/python/src/stack_auth/__init__.py b/sdks/implementations/python/src/stack_auth/__init__.py new file mode 100644 index 000000000..83040623b --- /dev/null +++ b/sdks/implementations/python/src/stack_auth/__init__.py @@ -0,0 +1 @@ +# Placeholder - will be populated after errors.py is implemented diff --git a/sdks/implementations/python/src/stack_auth/_constants.py b/sdks/implementations/python/src/stack_auth/_constants.py new file mode 100644 index 000000000..a07fcc0ad --- /dev/null +++ b/sdks/implementations/python/src/stack_auth/_constants.py @@ -0,0 +1,3 @@ +DEFAULT_BASE_URL = "https://api.stack-auth.com" +SDK_NAME = "python" +API_VERSION = "v1" diff --git a/sdks/implementations/python/src/stack_auth/_types.py b/sdks/implementations/python/src/stack_auth/_types.py new file mode 100644 index 000000000..db6bb5fd3 --- /dev/null +++ b/sdks/implementations/python/src/stack_auth/_types.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing import Mapping, Protocol + + +class RequestLike(Protocol): + @property + def headers(self) -> Mapping[str, str]: ... diff --git a/sdks/implementations/python/src/stack_auth/_version.py b/sdks/implementations/python/src/stack_auth/_version.py new file mode 100644 index 000000000..3dc1f76bc --- /dev/null +++ b/sdks/implementations/python/src/stack_auth/_version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/sdks/implementations/python/src/stack_auth/errors.py b/sdks/implementations/python/src/stack_auth/errors.py new file mode 100644 index 000000000..61b6c2d23 --- /dev/null +++ b/sdks/implementations/python/src/stack_auth/errors.py @@ -0,0 +1 @@ +# Placeholder - error hierarchy not yet implemented diff --git a/sdks/implementations/python/src/stack_auth/models/__init__.py b/sdks/implementations/python/src/stack_auth/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sdks/implementations/python/src/stack_auth/models/_base.py b/sdks/implementations/python/src/stack_auth/models/_base.py new file mode 100644 index 000000000..203d61c6e --- /dev/null +++ b/sdks/implementations/python/src/stack_auth/models/_base.py @@ -0,0 +1 @@ +# Placeholder - base model not yet implemented diff --git a/sdks/implementations/python/src/stack_auth/py.typed b/sdks/implementations/python/src/stack_auth/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/sdks/implementations/python/tests/__init__.py b/sdks/implementations/python/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sdks/implementations/python/tests/conftest.py b/sdks/implementations/python/tests/conftest.py new file mode 100644 index 000000000..def615cb8 --- /dev/null +++ b/sdks/implementations/python/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def base_url() -> str: + return "https://api.stack-auth.com" diff --git a/sdks/implementations/python/tests/test_errors.py b/sdks/implementations/python/tests/test_errors.py new file mode 100644 index 000000000..0ba82a563 --- /dev/null +++ b/sdks/implementations/python/tests/test_errors.py @@ -0,0 +1,187 @@ +"""Tests for the Stack Auth error hierarchy and from_response() dispatch.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from stack_auth.errors import ( + AnalyticsError, + ApiKeyError, + AuthenticationError, + CliError, + ConflictError, + EmailError, + NotFoundError, + OAuthError, + PasskeyError, + PaymentError, + PermissionDeniedError, + RateLimitError, + StackAuthError, + ValidationError, +) +from stack_auth.models._base import StackAuthModel + + +class TestStackAuthErrorBasics: + """Test StackAuthError base class storage and string representation.""" + + def test_stores_code_and_message(self) -> None: + err = StackAuthError("CODE", "msg") + assert err.code == "CODE" + assert err.message == "msg" + + def test_str_contains_code_and_message(self) -> None: + err = StackAuthError("CODE", "msg") + s = str(err) + assert "CODE" in s + assert "msg" in s + + def test_stores_details(self) -> None: + err = StackAuthError("CODE", "msg", {"key": "val"}) + assert err.details == {"key": "val"} + + def test_details_default_none(self) -> None: + err = StackAuthError("CODE", "msg") + assert err.details is None + + +class TestErrorSubclasses: + """Test that all category subclasses exist and inherit from StackAuthError.""" + + @pytest.mark.parametrize( + "cls", + [ + AuthenticationError, + NotFoundError, + ValidationError, + PermissionDeniedError, + ConflictError, + OAuthError, + PasskeyError, + ApiKeyError, + PaymentError, + EmailError, + RateLimitError, + CliError, + AnalyticsError, + ], + ) + def test_is_subclass_of_stack_auth_error(self, cls: type) -> None: + assert issubclass(cls, StackAuthError) + + @pytest.mark.parametrize( + "cls", + [ + AuthenticationError, + NotFoundError, + ValidationError, + PermissionDeniedError, + ConflictError, + OAuthError, + PasskeyError, + ApiKeyError, + PaymentError, + EmailError, + RateLimitError, + CliError, + AnalyticsError, + ], + ) + def test_can_be_caught_with_except_stack_auth_error(self, cls: type) -> None: + with pytest.raises(StackAuthError): + raise cls("TEST_CODE", "test message") + + +class TestFromResponse: + """Test from_response() factory dispatch for known and unknown codes.""" + + def test_invalid_access_token_returns_authentication_error(self) -> None: + err = StackAuthError.from_response("INVALID_ACCESS_TOKEN", "bad token") + assert isinstance(err, AuthenticationError) + assert err.code == "INVALID_ACCESS_TOKEN" + assert err.message == "bad token" + + def test_user_not_found_returns_not_found_error(self) -> None: + err = StackAuthError.from_response("USER_NOT_FOUND", "no user") + assert isinstance(err, NotFoundError) + + def test_schema_error_returns_validation_error(self) -> None: + err = StackAuthError.from_response("SCHEMA_ERROR", "bad schema") + assert isinstance(err, ValidationError) + + def test_team_permission_required_returns_permission_error(self) -> None: + err = StackAuthError.from_response("TEAM_PERMISSION_REQUIRED", "denied") + assert isinstance(err, PermissionDeniedError) + + def test_team_already_exists_returns_conflict_error(self) -> None: + err = StackAuthError.from_response("TEAM_ALREADY_EXISTS", "conflict") + assert isinstance(err, ConflictError) + + def test_oauth_connection_not_connected_returns_oauth_error(self) -> None: + err = StackAuthError.from_response("OAUTH_CONNECTION_NOT_CONNECTED_TO_USER", "no conn") + assert isinstance(err, OAuthError) + + def test_passkey_authentication_failed_returns_passkey_error(self) -> None: + err = StackAuthError.from_response("PASSKEY_AUTHENTICATION_FAILED", "failed") + assert isinstance(err, PasskeyError) + + def test_api_key_not_valid_returns_api_key_error(self) -> None: + err = StackAuthError.from_response("API_KEY_NOT_VALID", "invalid") + assert isinstance(err, ApiKeyError) + + def test_product_already_granted_returns_payment_error(self) -> None: + err = StackAuthError.from_response("PRODUCT_ALREADY_GRANTED", "granted") + assert isinstance(err, PaymentError) + + def test_email_rendering_error_returns_email_error(self) -> None: + err = StackAuthError.from_response("EMAIL_RENDERING_ERROR", "render fail") + assert isinstance(err, EmailError) + + def test_unknown_code_returns_base_stack_auth_error(self) -> None: + err = StackAuthError.from_response("TOTALLY_UNKNOWN_CODE", "surprise") + assert type(err) is StackAuthError + assert err.code == "TOTALLY_UNKNOWN_CODE" + assert err.message == "surprise" + + def test_from_response_preserves_details(self) -> None: + err = StackAuthError.from_response("USER_NOT_FOUND", "no user", {"user_id": "abc"}) + assert err.details == {"user_id": "abc"} + + def test_cli_auth_error_returns_cli_error(self) -> None: + err = StackAuthError.from_response("CLI_AUTH_ERROR", "cli fail") + assert isinstance(err, CliError) + + def test_analytics_query_timeout_returns_analytics_error(self) -> None: + err = StackAuthError.from_response("ANALYTICS_QUERY_TIMEOUT", "timeout") + assert isinstance(err, AnalyticsError) + + +class TestErrorCodeMapCoverage: + """Test that the error code map has comprehensive coverage.""" + + def test_error_code_map_has_at_least_90_entries(self) -> None: + from stack_auth.errors import _ERROR_CODE_MAP + + assert len(_ERROR_CODE_MAP) >= 90, f"Expected >= 90 entries, got {len(_ERROR_CODE_MAP)}" + + +class TestStackAuthModel: + """Test the StackAuthModel base class.""" + + def test_model_config_populate_by_name(self) -> None: + assert StackAuthModel.model_config.get("populate_by_name") is True + + def test_model_config_extra_ignore(self) -> None: + assert StackAuthModel.model_config.get("extra") == "ignore" + + def test_millis_to_datetime_converts_correctly(self) -> None: + result = StackAuthModel._millis_to_datetime(1711296000000) + expected = datetime(2024, 3, 24, 16, 0, tzinfo=timezone.utc) + assert result == expected + + def test_millis_to_datetime_none_returns_none(self) -> None: + result = StackAuthModel._millis_to_datetime(None) + assert result is None