cyclos-sdk-py 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.
Files changed (56) hide show
  1. angstrom_cyclos/__init__.py +40 -0
  2. angstrom_cyclos/__main__.py +82 -0
  3. angstrom_cyclos/accounts/__init__.py +6 -0
  4. angstrom_cyclos/accounts/client.py +109 -0
  5. angstrom_cyclos/accounts/client_async.py +102 -0
  6. angstrom_cyclos/accounts/models.py +57 -0
  7. angstrom_cyclos/async_client.py +74 -0
  8. angstrom_cyclos/auth/__init__.py +6 -0
  9. angstrom_cyclos/auth/client.py +68 -0
  10. angstrom_cyclos/auth/client_async.py +48 -0
  11. angstrom_cyclos/auth/models.py +27 -0
  12. angstrom_cyclos/client.py +77 -0
  13. angstrom_cyclos/config.py +85 -0
  14. angstrom_cyclos/constants.py +37 -0
  15. angstrom_cyclos/exceptions.py +103 -0
  16. angstrom_cyclos/members/__init__.py +23 -0
  17. angstrom_cyclos/members/client.py +209 -0
  18. angstrom_cyclos/members/client_async.py +183 -0
  19. angstrom_cyclos/members/models.py +75 -0
  20. angstrom_cyclos/models.py +185 -0
  21. angstrom_cyclos/operators/__init__.py +23 -0
  22. angstrom_cyclos/operators/client.py +295 -0
  23. angstrom_cyclos/operators/client_async.py +231 -0
  24. angstrom_cyclos/operators/models.py +67 -0
  25. angstrom_cyclos/organizations/__init__.py +15 -0
  26. angstrom_cyclos/organizations/client.py +90 -0
  27. angstrom_cyclos/organizations/client_async.py +70 -0
  28. angstrom_cyclos/organizations/models.py +19 -0
  29. angstrom_cyclos/permissions/__init__.py +6 -0
  30. angstrom_cyclos/permissions/client.py +124 -0
  31. angstrom_cyclos/permissions/client_async.py +107 -0
  32. angstrom_cyclos/permissions/models.py +31 -0
  33. angstrom_cyclos/transactions/__init__.py +6 -0
  34. angstrom_cyclos/transactions/client.py +69 -0
  35. angstrom_cyclos/transactions/client_async.py +60 -0
  36. angstrom_cyclos/transactions/models.py +34 -0
  37. angstrom_cyclos/transfers/__init__.py +6 -0
  38. angstrom_cyclos/transfers/client.py +168 -0
  39. angstrom_cyclos/transfers/client_async.py +135 -0
  40. angstrom_cyclos/transfers/models.py +75 -0
  41. angstrom_cyclos/transport/__init__.py +11 -0
  42. angstrom_cyclos/transport/http.py +715 -0
  43. angstrom_cyclos/transport/retry.py +99 -0
  44. angstrom_cyclos/users/__init__.py +6 -0
  45. angstrom_cyclos/users/client.py +67 -0
  46. angstrom_cyclos/users/client_async.py +65 -0
  47. angstrom_cyclos/users/models.py +20 -0
  48. angstrom_cyclos/webservices/__init__.py +15 -0
  49. angstrom_cyclos/webservices/client.py +167 -0
  50. angstrom_cyclos/webservices/client_async.py +128 -0
  51. angstrom_cyclos/webservices/models.py +57 -0
  52. cyclos_sdk_py-0.1.0.dist-info/METADATA +298 -0
  53. cyclos_sdk_py-0.1.0.dist-info/RECORD +56 -0
  54. cyclos_sdk_py-0.1.0.dist-info/WHEEL +4 -0
  55. cyclos_sdk_py-0.1.0.dist-info/entry_points.txt +2 -0
  56. cyclos_sdk_py-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,40 @@
1
+ """angstrom-cyclos: Python SDK for Cyclos 4.16 REST API."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from angstrom_cyclos.async_client import AsyncCyclosClient
6
+ from angstrom_cyclos.client import CyclosClient
7
+ from angstrom_cyclos.config import CyclosConfig
8
+ from angstrom_cyclos.exceptions import (
9
+ CyclosAuthenticationError,
10
+ CyclosAuthorizationError,
11
+ CyclosConflictError,
12
+ CyclosConnectionError,
13
+ CyclosError,
14
+ CyclosIdempotencyError,
15
+ CyclosNotFoundError,
16
+ CyclosRateLimitError,
17
+ CyclosServerError,
18
+ CyclosTimeoutError,
19
+ CyclosTransferError,
20
+ CyclosValidationError,
21
+ )
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "CyclosClient",
26
+ "AsyncCyclosClient",
27
+ "CyclosConfig",
28
+ "CyclosError",
29
+ "CyclosAuthenticationError",
30
+ "CyclosAuthorizationError",
31
+ "CyclosValidationError",
32
+ "CyclosNotFoundError",
33
+ "CyclosConflictError",
34
+ "CyclosRateLimitError",
35
+ "CyclosServerError",
36
+ "CyclosTimeoutError",
37
+ "CyclosConnectionError",
38
+ "CyclosTransferError",
39
+ "CyclosIdempotencyError",
40
+ ]
@@ -0,0 +1,82 @@
1
+ """CLI entry point for angstrom-cyclos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from angstrom_cyclos import CyclosClient, CyclosConfig
9
+
10
+
11
+ def _sync_main(args: argparse.Namespace) -> int:
12
+ config = CyclosConfig.from_env()
13
+ with CyclosClient(config) as client:
14
+ if args.command == "auth" and args.subcommand == "login":
15
+ print("Use application code or environment variables to authenticate.")
16
+ return 0
17
+ if args.command == "members" and args.subcommand == "get":
18
+ member = client.members.get(args.id)
19
+ print(member.model_dump_json(indent=2))
20
+ return 0
21
+ if args.command == "members" and args.subcommand == "search":
22
+ result = client.members.search(query=args.query)
23
+ print(result.model_dump_json(indent=2))
24
+ return 0
25
+ if args.command == "accounts" and args.subcommand == "balance":
26
+ balance = client.accounts.get_balance(args.member)
27
+ print(balance.model_dump_json(indent=2))
28
+ return 0
29
+ if args.command == "transfers" and args.subcommand == "get":
30
+ transfer = client.transfers.get(args.id)
31
+ print(transfer.model_dump_json(indent=2))
32
+ return 0
33
+ if args.command == "transfers" and args.subcommand == "history":
34
+ history = client.transfers.history(
35
+ member_id=args.member, account_type=args.account_type
36
+ )
37
+ print(history.model_dump_json(indent=2))
38
+ return 0
39
+ return 1
40
+
41
+
42
+ def _build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(prog="cyclos", description="Angstrom Cyclos SDK CLI")
44
+ sub = parser.add_subparsers(dest="command")
45
+
46
+ auth = sub.add_parser("auth")
47
+ auth.add_subparsers(dest="subcommand").add_parser("login")
48
+
49
+ members = sub.add_parser("members")
50
+ members_sub = members.add_subparsers(dest="subcommand")
51
+ get_member = members_sub.add_parser("get")
52
+ get_member.add_argument("id")
53
+ search_member = members_sub.add_parser("search")
54
+ search_member.add_argument("query")
55
+
56
+ accounts = sub.add_parser("accounts")
57
+ accounts_sub = accounts.add_subparsers(dest="subcommand")
58
+ balance = accounts_sub.add_parser("balance")
59
+ balance.add_argument("member")
60
+
61
+ transfers = sub.add_parser("transfers")
62
+ transfers_sub = transfers.add_subparsers(dest="subcommand")
63
+ get_transfer = transfers_sub.add_parser("get")
64
+ get_transfer.add_argument("id")
65
+ history = transfers_sub.add_parser("history")
66
+ history.add_argument("member")
67
+ history.add_argument("--account-type", default="mobileWallet")
68
+
69
+ return parser
70
+
71
+
72
+ def main(argv: list[str] | None = None) -> int:
73
+ parser = _build_parser()
74
+ args = parser.parse_args(argv)
75
+ if not args.command:
76
+ parser.print_help()
77
+ return 0
78
+ return _sync_main(args)
79
+
80
+
81
+ if __name__ == "__main__": # pragma: no cover
82
+ sys.exit(main())
@@ -0,0 +1,6 @@
1
+ """Account management module."""
2
+
3
+ from angstrom_cyclos.accounts.client import AccountsClient
4
+ from angstrom_cyclos.accounts.models import Account, AccountHistoryEntry, AccountStatus
5
+
6
+ __all__ = ["AccountsClient", "Account", "AccountStatus", "AccountHistoryEntry"]
@@ -0,0 +1,109 @@
1
+ """Account management client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from angstrom_cyclos.accounts.models import Account, AccountHistoryEntry, AccountStatus
9
+ from angstrom_cyclos.models import AccountBalance, PaginatedResult
10
+
11
+ if TYPE_CHECKING:
12
+ from angstrom_cyclos.client import CyclosClient
13
+
14
+
15
+ class AccountsClient:
16
+ """List accounts, retrieve statuses and balances, and fetch history."""
17
+
18
+ def __init__(self, client: CyclosClient) -> None:
19
+ self._client = client
20
+
21
+ def list(
22
+ self,
23
+ member_id: str,
24
+ *,
25
+ page: int = 0,
26
+ page_size: int = 20,
27
+ ) -> PaginatedResult[Account]:
28
+ """List accounts for an owner/member."""
29
+ response = self._client._transport.get(
30
+ f"/{member_id}/accounts",
31
+ params={"page": page, "pageSize": page_size},
32
+ )
33
+ items = [Account.model_validate(a) for a in response.json()]
34
+ return PaginatedResult[Account](
35
+ items=items,
36
+ page=page,
37
+ page_size=page_size,
38
+ has_next=len(items) >= page_size,
39
+ )
40
+
41
+ def get(self, member_id: str, account_type: str) -> Account:
42
+ """Retrieve the account status for a specific account type."""
43
+ response = self._client._transport.get(f"/{member_id}/accounts/{account_type}")
44
+ return Account.model_validate(response.json())
45
+
46
+ def get_balance(
47
+ self,
48
+ member_id: str,
49
+ *,
50
+ account_type: str = "mobileWallet",
51
+ ) -> AccountBalance:
52
+ """Return a strongly typed balance for the given member and account type."""
53
+ account = self.get(member_id, account_type)
54
+ status = account.status or AccountStatus()
55
+ return AccountBalance(
56
+ account_id=account.id or member_id,
57
+ account_type=account_type,
58
+ currency=account.currency.name if account.currency else None,
59
+ balance=status.balance or Decimal("0"),
60
+ available_balance=status.available_balance,
61
+ credit_limit=status.credit_limit,
62
+ reserved_amount=status.reserved_amount,
63
+ )
64
+
65
+ def history(
66
+ self,
67
+ member_id: str,
68
+ account_type: str,
69
+ *,
70
+ page: int = 0,
71
+ page_size: int = 20,
72
+ date_from: str | None = None,
73
+ date_to: str | None = None,
74
+ amount_min: Decimal | None = None,
75
+ amount_max: Decimal | None = None,
76
+ status: str | None = None,
77
+ transfer_type: str | None = None,
78
+ direction: str | None = None,
79
+ ) -> PaginatedResult[AccountHistoryEntry]:
80
+ """Fetch account history (balance transfers)."""
81
+ params: dict[str, Any] = {
82
+ "page": page,
83
+ "pageSize": page_size,
84
+ }
85
+ if date_from:
86
+ params["datePeriod.begin"] = date_from
87
+ if date_to:
88
+ params["datePeriod.end"] = date_to
89
+ if amount_min is not None:
90
+ params["amountRange.min"] = str(amount_min)
91
+ if amount_max is not None:
92
+ params["amountRange.max"] = str(amount_max)
93
+ if status:
94
+ params["statuses"] = status
95
+ if transfer_type:
96
+ params["transferTypes"] = transfer_type
97
+ if direction:
98
+ params["direction"] = direction
99
+ response = self._client._transport.get(
100
+ f"/{member_id}/accounts/{account_type}/history",
101
+ params=params,
102
+ )
103
+ items = [AccountHistoryEntry.model_validate(entry) for entry in response.json()]
104
+ return PaginatedResult[AccountHistoryEntry](
105
+ items=items,
106
+ page=page,
107
+ page_size=page_size,
108
+ has_next=len(items) >= page_size,
109
+ )
@@ -0,0 +1,102 @@
1
+ """Asynchronous account client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from angstrom_cyclos.accounts.models import Account, AccountHistoryEntry, AccountStatus
9
+ from angstrom_cyclos.models import AccountBalance, PaginatedResult
10
+
11
+ if TYPE_CHECKING:
12
+ from angstrom_cyclos.async_client import AsyncCyclosClient
13
+
14
+
15
+ class AsyncAccountsClient:
16
+ """Async account client."""
17
+
18
+ def __init__(self, client: AsyncCyclosClient) -> None:
19
+ self._client = client
20
+
21
+ async def list(
22
+ self,
23
+ member_id: str,
24
+ *,
25
+ page: int = 0,
26
+ page_size: int = 20,
27
+ ) -> PaginatedResult[Account]:
28
+ response = await self._client._transport.get(
29
+ f"/{member_id}/accounts",
30
+ params={"page": page, "pageSize": page_size},
31
+ )
32
+ items = [Account.model_validate(a) for a in response.json()]
33
+ return PaginatedResult[Account](
34
+ items=items,
35
+ page=page,
36
+ page_size=page_size,
37
+ has_next=len(items) >= page_size,
38
+ )
39
+
40
+ async def get(self, member_id: str, account_type: str) -> Account:
41
+ response = await self._client._transport.get(f"/{member_id}/accounts/{account_type}")
42
+ return Account.model_validate(response.json())
43
+
44
+ async def get_balance(
45
+ self,
46
+ member_id: str,
47
+ *,
48
+ account_type: str = "mobileWallet",
49
+ ) -> AccountBalance:
50
+ account = await self.get(member_id, account_type)
51
+ status = account.status or AccountStatus()
52
+ return AccountBalance(
53
+ account_id=account.id or member_id,
54
+ account_type=account_type,
55
+ currency=account.currency.name if account.currency else None,
56
+ balance=status.balance or Decimal("0"),
57
+ available_balance=status.available_balance,
58
+ credit_limit=status.credit_limit,
59
+ reserved_amount=status.reserved_amount,
60
+ )
61
+
62
+ async def history(
63
+ self,
64
+ member_id: str,
65
+ account_type: str,
66
+ *,
67
+ page: int = 0,
68
+ page_size: int = 20,
69
+ date_from: str | None = None,
70
+ date_to: str | None = None,
71
+ amount_min: Decimal | None = None,
72
+ amount_max: Decimal | None = None,
73
+ status: str | None = None,
74
+ transfer_type: str | None = None,
75
+ direction: str | None = None,
76
+ ) -> PaginatedResult[AccountHistoryEntry]:
77
+ params: dict[str, Any] = {"page": page, "pageSize": page_size}
78
+ if date_from:
79
+ params["datePeriod.begin"] = date_from
80
+ if date_to:
81
+ params["datePeriod.end"] = date_to
82
+ if amount_min is not None:
83
+ params["amountRange.min"] = str(amount_min)
84
+ if amount_max is not None:
85
+ params["amountRange.max"] = str(amount_max)
86
+ if status:
87
+ params["statuses"] = status
88
+ if transfer_type:
89
+ params["transferTypes"] = transfer_type
90
+ if direction:
91
+ params["direction"] = direction
92
+ response = await self._client._transport.get(
93
+ f"/{member_id}/accounts/{account_type}/history",
94
+ params=params,
95
+ )
96
+ items = [AccountHistoryEntry.model_validate(entry) for entry in response.json()]
97
+ return PaginatedResult[AccountHistoryEntry](
98
+ items=items,
99
+ page=page,
100
+ page_size=page_size,
101
+ has_next=len(items) >= page_size,
102
+ )
@@ -0,0 +1,57 @@
1
+ """Account models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+
7
+ from pydantic import Field
8
+
9
+ from angstrom_cyclos.models import (
10
+ AccountReference,
11
+ Currency,
12
+ CyclosBaseModel,
13
+ EntityReference,
14
+ UserReference,
15
+ )
16
+
17
+
18
+ class AccountStatus(CyclosBaseModel):
19
+ """Account status including balances."""
20
+
21
+ balance: Decimal | None = None
22
+ credit_limit: Decimal | None = Field(default=None, alias="creditLimit")
23
+ upper_credit_limit: Decimal | None = Field(default=None, alias="upperCreditLimit")
24
+ reserved_amount: Decimal | None = Field(default=None, alias="reservedAmount")
25
+ available_balance: Decimal | None = Field(default=None, alias="availableBalance")
26
+ negative_since: str | None = Field(default=None, alias="negativeSince")
27
+ begin_date: str | None = Field(default=None, alias="beginDate")
28
+ end_date: str | None = Field(default=None, alias="endDate")
29
+ balance_at_begin: Decimal | None = Field(default=None, alias="balanceAtBegin")
30
+ balance_at_end: Decimal | None = Field(default=None, alias="balanceAtEnd")
31
+
32
+
33
+ class Account(CyclosBaseModel):
34
+ """Cyclos account with owner and currency."""
35
+
36
+ id: str | None = None
37
+ type: EntityReference | None = None
38
+ user: UserReference | None = None
39
+ kind: str | None = None
40
+ currency: Currency | None = None
41
+ status: AccountStatus | None = None
42
+
43
+
44
+ class AccountHistoryEntry(CyclosBaseModel):
45
+ """Single entry from an account history."""
46
+
47
+ id: str | None = None
48
+ date: str | None = None
49
+ amount: Decimal | None = None
50
+ description: str | None = None
51
+ transaction_number: str | None = Field(default=None, alias="transactionNumber")
52
+ related_account: AccountReference | None = Field(default=None, alias="relatedAccount")
53
+ related_name: str | None = Field(default=None, alias="relatedName")
54
+ statuses: dict[str, str] | None = None
55
+
56
+
57
+ __all__ = ["Account", "AccountStatus", "AccountHistoryEntry", "AccountReference", "Currency"]
@@ -0,0 +1,74 @@
1
+ """Asynchronous Cyclos SDK client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import TracebackType
6
+ from typing import Self
7
+
8
+ from angstrom_cyclos.accounts.client_async import AsyncAccountsClient
9
+ from angstrom_cyclos.auth.client_async import AsyncAuthClient
10
+ from angstrom_cyclos.config import CyclosConfig
11
+ from angstrom_cyclos.members.client_async import AsyncMembersClient
12
+ from angstrom_cyclos.operators.client_async import AsyncOperatorsClient
13
+ from angstrom_cyclos.organizations.client_async import AsyncOrganizationsClient
14
+ from angstrom_cyclos.permissions.client_async import AsyncPermissionsClient
15
+ from angstrom_cyclos.transactions.client_async import AsyncTransactionsClient
16
+ from angstrom_cyclos.transfers.client_async import AsyncTransfersClient
17
+ from angstrom_cyclos.transport import AsyncHTTPTransport
18
+ from angstrom_cyclos.users.client_async import AsyncUsersClient
19
+ from angstrom_cyclos.webservices.client_async import AsyncWebServicesClient
20
+
21
+
22
+ class AsyncCyclosClient:
23
+ """Asynchronous client for the Cyclos 4.16 REST API.
24
+
25
+ Example:
26
+ >>> import asyncio
27
+ >>> from angstrom_cyclos import AsyncCyclosClient, CyclosConfig
28
+ >>> config = CyclosConfig.from_env()
29
+ >>> async def main() -> None:
30
+ ... async with AsyncCyclosClient(config) as client:
31
+ ... await client.auth.login("username", "password")
32
+ ... await client.members.get("alice")
33
+ >>> asyncio.run(main())
34
+ """
35
+
36
+ def __init__(self, config: CyclosConfig | None = None) -> None:
37
+ self._config = config or CyclosConfig.from_env()
38
+ self._transport = AsyncHTTPTransport(self._config)
39
+ self.auth = AsyncAuthClient(self)
40
+ self.members = AsyncMembersClient(self)
41
+ self.users = AsyncUsersClient(self)
42
+ self.organizations = AsyncOrganizationsClient(self)
43
+ self.operators = AsyncOperatorsClient(self)
44
+ self.permissions = AsyncPermissionsClient(self)
45
+ self.webservices = AsyncWebServicesClient(self)
46
+ self.accounts = AsyncAccountsClient(self)
47
+ self.transfers = AsyncTransfersClient(self)
48
+ self.transactions = AsyncTransactionsClient(self)
49
+
50
+ @property
51
+ def config(self) -> CyclosConfig:
52
+ return self._config
53
+
54
+ @property
55
+ def _transport(self) -> AsyncHTTPTransport:
56
+ return self.__transport
57
+
58
+ @_transport.setter
59
+ def _transport(self, value: AsyncHTTPTransport) -> None:
60
+ self.__transport = value
61
+
62
+ async def __aenter__(self) -> Self:
63
+ return self
64
+
65
+ async def __aexit__(
66
+ self,
67
+ exc_type: type[BaseException] | None,
68
+ exc_val: BaseException | None,
69
+ exc_tb: TracebackType | None,
70
+ ) -> None:
71
+ await self.close()
72
+
73
+ async def close(self) -> None:
74
+ await self._transport.close()
@@ -0,0 +1,6 @@
1
+ """Authentication module."""
2
+
3
+ from angstrom_cyclos.auth.client import AuthClient
4
+ from angstrom_cyclos.auth.models import Auth, LoginAuth
5
+
6
+ __all__ = ["AuthClient", "Auth", "LoginAuth"]
@@ -0,0 +1,68 @@
1
+ """Authentication client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from angstrom_cyclos.auth.models import Auth, LoginAuth
8
+
9
+ if TYPE_CHECKING:
10
+ from angstrom_cyclos.client import CyclosClient
11
+
12
+
13
+ class AuthClient:
14
+ """Handles Cyclos login, logout, session refresh, and current user lookup."""
15
+
16
+ def __init__(self, client: CyclosClient) -> None:
17
+ self._client = client
18
+
19
+ def login(self, username: str, password: str) -> LoginAuth:
20
+ """Authenticate and store the returned session token.
21
+
22
+ Args:
23
+ username: Cyclos login name.
24
+ password: Cyclos password. Never logged.
25
+
26
+ Returns:
27
+ Parsed login response including ``session_token``.
28
+ """
29
+ response = self._client._transport.post(
30
+ "/auth/session",
31
+ auth=(username, password),
32
+ )
33
+ data = LoginAuth.model_validate(response.json())
34
+ if data.session_token:
35
+ self._client._transport.session_token = data.session_token
36
+ return data
37
+
38
+ def logout(self) -> None:
39
+ """Log out the current session and clear local authentication state."""
40
+ self._client._transport.delete("/auth/session")
41
+ self._client._transport.session_token = None
42
+ self._client._transport.access_client_token = None
43
+
44
+ def current_user(self) -> Auth:
45
+ """Return information about the currently authenticated user."""
46
+ response = self._client._transport.get("/auth")
47
+ return Auth.model_validate(response.json())
48
+
49
+ def is_authenticated(self) -> bool:
50
+ """Return ``True`` if a session token is currently stored."""
51
+ return self._client._transport.session_token is not None
52
+
53
+ def refresh(self) -> LoginAuth:
54
+ """Refresh the current session if supported.
55
+
56
+ Cyclos 4.16 does not expose a dedicated refresh endpoint. This method
57
+ re-authenticates using the credentials stored in the configuration
58
+ (if available). Prefer explicit ``login`` calls if credentials are not
59
+ configured.
60
+ """
61
+ config = self._client._config
62
+ if not config.username or not config.password:
63
+ raise RuntimeError("Cannot refresh session: no credentials configured")
64
+ return self.login(config.username, config.password.get_secret_value())
65
+
66
+ def set_session_token(self, token: str) -> None:
67
+ """Manually set the session token for subsequent requests."""
68
+ self._client._transport.session_token = token
@@ -0,0 +1,48 @@
1
+ """Asynchronous authentication client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from angstrom_cyclos.auth.models import Auth, LoginAuth
8
+
9
+ if TYPE_CHECKING:
10
+ from angstrom_cyclos.async_client import AsyncCyclosClient
11
+
12
+
13
+ class AsyncAuthClient:
14
+ """Async authentication client."""
15
+
16
+ def __init__(self, client: AsyncCyclosClient) -> None:
17
+ self._client = client
18
+
19
+ async def login(self, username: str, password: str) -> LoginAuth:
20
+ response = await self._client._transport.post(
21
+ "/auth/session",
22
+ auth=(username, password),
23
+ )
24
+ data = LoginAuth.model_validate(response.json())
25
+ if data.session_token:
26
+ self._client._transport.session_token = data.session_token
27
+ return data
28
+
29
+ async def logout(self) -> None:
30
+ await self._client._transport.delete("/auth/session")
31
+ self._client._transport.session_token = None
32
+ self._client._transport.access_client_token = None
33
+
34
+ async def current_user(self) -> Auth:
35
+ response = await self._client._transport.get("/auth")
36
+ return Auth.model_validate(response.json())
37
+
38
+ async def is_authenticated(self) -> bool:
39
+ return self._client._transport.session_token is not None
40
+
41
+ async def refresh(self) -> LoginAuth:
42
+ config = self._client._config
43
+ if not config.username or not config.password:
44
+ raise RuntimeError("Cannot refresh session: no credentials configured")
45
+ return await self.login(config.username, config.password.get_secret_value())
46
+
47
+ async def set_session_token(self, token: str) -> None:
48
+ self._client._transport.session_token = token
@@ -0,0 +1,27 @@
1
+ """Authentication-related Pydantic models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field
6
+
7
+ from angstrom_cyclos.models import CyclosBaseModel, EntityReference, UserReference
8
+
9
+
10
+ class LoginAuth(CyclosBaseModel):
11
+ """Response from ``POST /auth/session``."""
12
+
13
+ user: UserReference | None = None
14
+ session_token: str | None = Field(default=None, alias="sessionToken")
15
+ principal: str | None = None
16
+ expired_password: bool | None = Field(default=None, alias="expiredPassword")
17
+ pending_agreements: bool | None = Field(default=None, alias="pendingAgreements")
18
+
19
+
20
+ class Auth(CyclosBaseModel):
21
+ """Response from ``GET /auth``."""
22
+
23
+ user: UserReference | None = None
24
+ principal: str | None = None
25
+ access_client: EntityReference | None = Field(default=None, alias="accessClient")
26
+ expired_password: bool | None = Field(default=None, alias="expiredPassword")
27
+ pending_agreements: bool | None = Field(default=None, alias="pendingAgreements")