atlas-backend 0.1.0__tar.gz

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,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .pytest_cache/
8
+ build/
9
+ dist/
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: atlas-backend
3
+ Version: 0.1.0
4
+ Summary: Official Python backend SDK for Atlas — a typed client over the sk_ Backend API (BAPI).
5
+ Author: Atlas
6
+ License: MIT
7
+ Keywords: atlas,auth,authentication,backend,bapi,sdk
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx>=0.23
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.5; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
13
+ Requires-Dist: pytest>=7.0; extra == 'dev'
14
+ Requires-Dist: respx>=0.20; extra == 'dev'
15
+ Requires-Dist: ruff>=0.4; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # atlas-backend
19
+
20
+ The official **Python backend SDK** for Atlas — a typed client over the `sk_`
21
+ Backend API (BAPI). It is the Python peer of the TypeScript `@atlas/backend`
22
+ SDK and mirrors it namespace-for-namespace.
23
+
24
+ - Typed (`py.typed`), one namespace per BAPI area.
25
+ - Sync `AtlasClient` **and** async `AsyncAtlasClient`, built on `httpx`.
26
+ - The `{ errors: [{ code, message, param }] }` envelope surfaces as `AtlasError`.
27
+ - Cursor pagination helper, `Idempotency-Key` on creates.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install atlas-backend
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ ```python
38
+ from atlas_backend import AtlasClient, AtlasError, paginate
39
+
40
+ atlas = AtlasClient("sk_live_...", base_url="https://api.atlasauth.net")
41
+
42
+ # Create a user (idempotency key optional, forwarded as Idempotency-Key).
43
+ user = atlas.users.create(
44
+ {"email_address": "ada@example.com", "first_name": "Ada"},
45
+ idempotency_key="signup-ada-001",
46
+ )
47
+ print(user["id"])
48
+
49
+ # List organizations (a single cursor page).
50
+ page = atlas.organizations.list(limit=20)
51
+ print(page["data"], page["has_more"], page["next_cursor"])
52
+
53
+ # Auto-iterate every page.
54
+ for org in paginate(atlas.organizations.list):
55
+ print(org["name"])
56
+
57
+ # Errors carry the HTTP status and the stable BAPI error code.
58
+ try:
59
+ atlas.users.get("user_does_not_exist")
60
+ except AtlasError as err:
61
+ print(err.status) # e.g. 404
62
+ print(err.code) # e.g. "NOT_FOUND"
63
+ print(err.errors[0].get("param"))
64
+ if err.has_code("NOT_FOUND"):
65
+ ...
66
+ ```
67
+
68
+ ### Context manager
69
+
70
+ ```python
71
+ with AtlasClient("sk_live_...") as atlas:
72
+ atlas.users.list(limit=10)
73
+ # underlying httpx.Client is closed on exit
74
+ ```
75
+
76
+ ## Async
77
+
78
+ ```python
79
+ import asyncio
80
+ from atlas_backend import AsyncAtlasClient, acollect
81
+
82
+ async def main() -> None:
83
+ async with AsyncAtlasClient("sk_live_...") as atlas:
84
+ user = await atlas.users.create({"email_address": "grace@example.com"})
85
+ orgs = await acollect(atlas.organizations.list)
86
+ print(user["id"], len(orgs))
87
+
88
+ asyncio.run(main())
89
+ ```
90
+
91
+ ## Configuration
92
+
93
+ | Argument | Default | Notes |
94
+ | ------------- | ------------------------- | ------------------------------------------------ |
95
+ | `secret_key` | — | `sk_...`; sent as `Authorization: Bearer <key>`. |
96
+ | `base_url` | `https://api.atlasauth.net` | The instance's BAPI origin (`BAPI_ORIGIN`). |
97
+ | `timeout` | `30.0` | Per-request timeout, seconds. |
98
+ | `http_client` | a new `httpx.Client` | Pass your own for pooling / proxies / retries. |
99
+
100
+ ## Namespaces
101
+
102
+ `users`, `sessions`, `organizations` (+ `memberships`, `invitations`,
103
+ `domains`, `group_roles`), `roles`, `permissions`, `oauth_clients` (+ `grants`),
104
+ `resource_servers`, `sso_connections`, `scim_tokens`, `domains`, `waitlist`,
105
+ `allowlist`, `blocklist`, `attack_protection`, `actor_tokens`, `invitations`,
106
+ `webhooks` (+ `endpoints`), `sign_in_tokens`, `audit_logs`, `jwt_templates`.
107
+
108
+ ## Pagination
109
+
110
+ `paginate(list_fn, **params)` and `collect(list_fn, **params)` walk every
111
+ cursor page of any `list` that returns `{ data, has_more, next_cursor }`. Async
112
+ peers: `aiterate` / `acollect`.
113
+
114
+ ## Errors
115
+
116
+ Every non-2xx raises `AtlasError`, carrying:
117
+
118
+ - `status` — the HTTP status code.
119
+ - `errors` — the full `[{ code, message, param?, meta? }]` envelope.
120
+ - `code` — the first error's stable code (what you branch on).
121
+ - `has_code(code)` — True if any error in the envelope has that code.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ pip install -e ".[dev]"
127
+ ruff check .
128
+ mypy .
129
+ pytest
130
+ ```
@@ -0,0 +1,113 @@
1
+ # atlas-backend
2
+
3
+ The official **Python backend SDK** for Atlas — a typed client over the `sk_`
4
+ Backend API (BAPI). It is the Python peer of the TypeScript `@atlas/backend`
5
+ SDK and mirrors it namespace-for-namespace.
6
+
7
+ - Typed (`py.typed`), one namespace per BAPI area.
8
+ - Sync `AtlasClient` **and** async `AsyncAtlasClient`, built on `httpx`.
9
+ - The `{ errors: [{ code, message, param }] }` envelope surfaces as `AtlasError`.
10
+ - Cursor pagination helper, `Idempotency-Key` on creates.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install atlas-backend
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ from atlas_backend import AtlasClient, AtlasError, paginate
22
+
23
+ atlas = AtlasClient("sk_live_...", base_url="https://api.atlasauth.net")
24
+
25
+ # Create a user (idempotency key optional, forwarded as Idempotency-Key).
26
+ user = atlas.users.create(
27
+ {"email_address": "ada@example.com", "first_name": "Ada"},
28
+ idempotency_key="signup-ada-001",
29
+ )
30
+ print(user["id"])
31
+
32
+ # List organizations (a single cursor page).
33
+ page = atlas.organizations.list(limit=20)
34
+ print(page["data"], page["has_more"], page["next_cursor"])
35
+
36
+ # Auto-iterate every page.
37
+ for org in paginate(atlas.organizations.list):
38
+ print(org["name"])
39
+
40
+ # Errors carry the HTTP status and the stable BAPI error code.
41
+ try:
42
+ atlas.users.get("user_does_not_exist")
43
+ except AtlasError as err:
44
+ print(err.status) # e.g. 404
45
+ print(err.code) # e.g. "NOT_FOUND"
46
+ print(err.errors[0].get("param"))
47
+ if err.has_code("NOT_FOUND"):
48
+ ...
49
+ ```
50
+
51
+ ### Context manager
52
+
53
+ ```python
54
+ with AtlasClient("sk_live_...") as atlas:
55
+ atlas.users.list(limit=10)
56
+ # underlying httpx.Client is closed on exit
57
+ ```
58
+
59
+ ## Async
60
+
61
+ ```python
62
+ import asyncio
63
+ from atlas_backend import AsyncAtlasClient, acollect
64
+
65
+ async def main() -> None:
66
+ async with AsyncAtlasClient("sk_live_...") as atlas:
67
+ user = await atlas.users.create({"email_address": "grace@example.com"})
68
+ orgs = await acollect(atlas.organizations.list)
69
+ print(user["id"], len(orgs))
70
+
71
+ asyncio.run(main())
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ | Argument | Default | Notes |
77
+ | ------------- | ------------------------- | ------------------------------------------------ |
78
+ | `secret_key` | — | `sk_...`; sent as `Authorization: Bearer <key>`. |
79
+ | `base_url` | `https://api.atlasauth.net` | The instance's BAPI origin (`BAPI_ORIGIN`). |
80
+ | `timeout` | `30.0` | Per-request timeout, seconds. |
81
+ | `http_client` | a new `httpx.Client` | Pass your own for pooling / proxies / retries. |
82
+
83
+ ## Namespaces
84
+
85
+ `users`, `sessions`, `organizations` (+ `memberships`, `invitations`,
86
+ `domains`, `group_roles`), `roles`, `permissions`, `oauth_clients` (+ `grants`),
87
+ `resource_servers`, `sso_connections`, `scim_tokens`, `domains`, `waitlist`,
88
+ `allowlist`, `blocklist`, `attack_protection`, `actor_tokens`, `invitations`,
89
+ `webhooks` (+ `endpoints`), `sign_in_tokens`, `audit_logs`, `jwt_templates`.
90
+
91
+ ## Pagination
92
+
93
+ `paginate(list_fn, **params)` and `collect(list_fn, **params)` walk every
94
+ cursor page of any `list` that returns `{ data, has_more, next_cursor }`. Async
95
+ peers: `aiterate` / `acollect`.
96
+
97
+ ## Errors
98
+
99
+ Every non-2xx raises `AtlasError`, carrying:
100
+
101
+ - `status` — the HTTP status code.
102
+ - `errors` — the full `[{ code, message, param?, meta? }]` envelope.
103
+ - `code` — the first error's stable code (what you branch on).
104
+ - `has_code(code)` — True if any error in the envelope has that code.
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ pip install -e ".[dev]"
110
+ ruff check .
111
+ mypy .
112
+ pytest
113
+ ```
@@ -0,0 +1,55 @@
1
+ """atlas-backend — the official Python backend SDK for Atlas.
2
+
3
+ A typed client over the ``sk_`` Backend API (BAPI), the Python peer of the
4
+ TypeScript ``@atlas/backend`` SDK.
5
+
6
+ from atlas_backend import AtlasClient, AtlasError
7
+
8
+ atlas = AtlasClient("sk_live_...")
9
+ user = atlas.users.create({"email_address": "ada@example.com"})
10
+ for org in paginate(atlas.organizations.list):
11
+ print(org["name"])
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ._client import AsyncAtlasClient, AtlasClient
17
+ from ._errors import AtlasError, ErrorItem
18
+ from ._handshake import (
19
+ HandshakeParams,
20
+ HandshakeSession,
21
+ PKCEPair,
22
+ aredeem_handshake,
23
+ create_pkce_pair,
24
+ read_handshake_params,
25
+ redeem_handshake,
26
+ )
27
+ from ._pagination import acollect, aiterate, collect, paginate
28
+ from ._transport import DEFAULT_BASE_URL
29
+ from ._types import CursorPage, DeletedObject, ListPage, Metadata
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ __all__ = [
34
+ "AtlasClient",
35
+ "AsyncAtlasClient",
36
+ "AtlasError",
37
+ "ErrorItem",
38
+ "DEFAULT_BASE_URL",
39
+ "paginate",
40
+ "collect",
41
+ "aiterate",
42
+ "acollect",
43
+ "CursorPage",
44
+ "ListPage",
45
+ "DeletedObject",
46
+ "Metadata",
47
+ "HandshakeSession",
48
+ "HandshakeParams",
49
+ "PKCEPair",
50
+ "create_pkce_pair",
51
+ "redeem_handshake",
52
+ "aredeem_handshake",
53
+ "read_handshake_params",
54
+ "__version__",
55
+ ]
@@ -0,0 +1,139 @@
1
+ """The typed management clients for the Atlas Backend API.
2
+
3
+ :class:`AtlasClient` is the secret-key surface — the Python peer of the official
4
+ TypeScript SDK's ``createAtlasClient``. Each namespace is one attribute handing
5
+ the shared, config-bound transport to a resource class, so this file reads as a
6
+ table of contents for the whole surface. :class:`AsyncAtlasClient` is the same
7
+ surface over ``asyncio``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from types import TracebackType
13
+
14
+ import httpx
15
+
16
+ from . import aresources as ar
17
+ from . import resources as r
18
+ from ._transport import DEFAULT_BASE_URL, _AsyncTransport, _SyncTransport
19
+
20
+
21
+ class AtlasClient:
22
+ """Synchronous Backend API client.
23
+
24
+ Args:
25
+ secret_key: The instance secret key (``sk_...``). Sent as
26
+ ``Authorization: Bearer <key>`` on every request; never logged,
27
+ never placed in a URL.
28
+ base_url: Base URL of the instance's Backend API. Defaults to
29
+ ``https://api.atlasauth.net``. Trailing slashes are tolerated.
30
+ timeout: Per-request timeout in seconds.
31
+ http_client: An optional pre-configured :class:`httpx.Client` (for
32
+ connection pooling, proxies, retries). One is created if omitted.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ secret_key: str,
38
+ *,
39
+ base_url: str = DEFAULT_BASE_URL,
40
+ timeout: float = 30.0,
41
+ http_client: httpx.Client | None = None,
42
+ ) -> None:
43
+ if not secret_key:
44
+ raise ValueError("AtlasClient requires a secret_key.")
45
+ self._owns_client = http_client is None
46
+ self._http = http_client or httpx.Client(timeout=timeout)
47
+ transport = _SyncTransport(secret_key, base_url, self._http)
48
+
49
+ self.users = r.Users(transport)
50
+ self.sessions = r.Sessions(transport)
51
+ self.organizations = r.Organizations(transport)
52
+ self.roles = r.Roles(transport)
53
+ self.permissions = r.Permissions(transport)
54
+ self.oauth_clients = r.OAuthClients(transport)
55
+ self.resource_servers = r.ResourceServers(transport)
56
+ self.sso_connections = r.SsoConnections(transport)
57
+ self.scim_tokens = r.ScimTokens(transport)
58
+ self.domains = r.Domains(transport)
59
+ self.waitlist = r.Waitlist(transport)
60
+ self.allowlist = r._Restriction(transport, "/v1/allowlist_identifiers")
61
+ self.blocklist = r._Restriction(transport, "/v1/blocklist_identifiers")
62
+ self.attack_protection = r.AttackProtection(transport)
63
+ self.actor_tokens = r.ActorTokens(transport)
64
+ self.invitations = r.Invitations(transport)
65
+ self.webhooks = r.Webhooks(transport)
66
+ self.sign_in_tokens = r.SignInTokens(transport)
67
+ self.audit_logs = r.AuditLogs(transport)
68
+ self.jwt_templates = r.JwtTemplates(transport)
69
+
70
+ def close(self) -> None:
71
+ """Close the underlying HTTP client, if this client created it."""
72
+ if self._owns_client:
73
+ self._http.close()
74
+
75
+ def __enter__(self) -> AtlasClient:
76
+ return self
77
+
78
+ def __exit__(
79
+ self,
80
+ exc_type: type[BaseException] | None,
81
+ exc: BaseException | None,
82
+ tb: TracebackType | None,
83
+ ) -> None:
84
+ self.close()
85
+
86
+
87
+ class AsyncAtlasClient:
88
+ """Asynchronous Backend API client — the ``asyncio`` peer of :class:`AtlasClient`."""
89
+
90
+ def __init__(
91
+ self,
92
+ secret_key: str,
93
+ *,
94
+ base_url: str = DEFAULT_BASE_URL,
95
+ timeout: float = 30.0,
96
+ http_client: httpx.AsyncClient | None = None,
97
+ ) -> None:
98
+ if not secret_key:
99
+ raise ValueError("AsyncAtlasClient requires a secret_key.")
100
+ self._owns_client = http_client is None
101
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
102
+ transport = _AsyncTransport(secret_key, base_url, self._http)
103
+
104
+ self.users = ar.AsyncUsers(transport)
105
+ self.sessions = ar.AsyncSessions(transport)
106
+ self.organizations = ar.AsyncOrganizations(transport)
107
+ self.roles = ar.AsyncRoles(transport)
108
+ self.permissions = ar.AsyncPermissions(transport)
109
+ self.oauth_clients = ar.AsyncOAuthClients(transport)
110
+ self.resource_servers = ar.AsyncResourceServers(transport)
111
+ self.sso_connections = ar.AsyncSsoConnections(transport)
112
+ self.scim_tokens = ar.AsyncScimTokens(transport)
113
+ self.domains = ar.AsyncDomains(transport)
114
+ self.waitlist = ar.AsyncWaitlist(transport)
115
+ self.allowlist = ar._AsyncRestriction(transport, "/v1/allowlist_identifiers")
116
+ self.blocklist = ar._AsyncRestriction(transport, "/v1/blocklist_identifiers")
117
+ self.attack_protection = ar.AsyncAttackProtection(transport)
118
+ self.actor_tokens = ar.AsyncActorTokens(transport)
119
+ self.invitations = ar.AsyncInvitations(transport)
120
+ self.webhooks = ar.AsyncWebhooks(transport)
121
+ self.sign_in_tokens = ar.AsyncSignInTokens(transport)
122
+ self.audit_logs = ar.AsyncAuditLogs(transport)
123
+ self.jwt_templates = ar.AsyncJwtTemplates(transport)
124
+
125
+ async def aclose(self) -> None:
126
+ """Close the underlying async HTTP client, if this client created it."""
127
+ if self._owns_client:
128
+ await self._http.aclose()
129
+
130
+ async def __aenter__(self) -> AsyncAtlasClient:
131
+ return self
132
+
133
+ async def __aexit__(
134
+ self,
135
+ exc_type: type[BaseException] | None,
136
+ exc: BaseException | None,
137
+ tb: TracebackType | None,
138
+ ) -> None:
139
+ await self.aclose()
@@ -0,0 +1,62 @@
1
+ """The single error type every Backend API call raises on a non-2xx.
2
+
3
+ Atlas answers a failed BAPI call with the §9.1 envelope::
4
+
5
+ { "errors": [ { "code", "message", "param?", "meta?" } ] }
6
+
7
+ ``code`` is the stable, machine-readable part of that contract — integrators
8
+ branch on it (``LAST_ADMIN``, ``NOT_FOUND``, ``SCOPE_MISSING``, ...) — so it is
9
+ surfaced first-class here rather than buried in a parsed body. The raw
10
+ ``errors`` list and the HTTP ``status`` are both kept so a caller can inspect
11
+ ``param``/``meta`` (e.g. a rate limit's ``retry_after``) when they need to.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, TypedDict
17
+
18
+
19
+ class ErrorItem(TypedDict, total=False):
20
+ """One entry of the BAPI error envelope."""
21
+
22
+ code: str
23
+ message: str
24
+ param: str
25
+ meta: dict[str, Any]
26
+
27
+
28
+ class AtlasError(Exception):
29
+ """Raised on any non-2xx response from the Backend API.
30
+
31
+ Carries the HTTP ``status`` and the full parsed ``errors`` envelope. Branch
32
+ on :attr:`code` (the first error's stable code) or use :meth:`has_code`.
33
+ """
34
+
35
+ status: int
36
+ errors: list[ErrorItem]
37
+
38
+ def __init__(
39
+ self,
40
+ status: int,
41
+ errors: list[ErrorItem],
42
+ message: str | None = None,
43
+ ) -> None:
44
+ first_message = errors[0].get("message") if errors else None
45
+ super().__init__(
46
+ message
47
+ or first_message
48
+ or f"Atlas API request failed with status {status}"
49
+ )
50
+ self.status = status
51
+ self.errors = errors
52
+
53
+ @property
54
+ def code(self) -> str | None:
55
+ """The first error's stable code, the field callers branch on most."""
56
+ if not self.errors:
57
+ return None
58
+ return self.errors[0].get("code")
59
+
60
+ def has_code(self, code: str) -> bool:
61
+ """True when any error in the envelope carries the given stable code."""
62
+ return any(e.get("code") == code for e in self.errors)