pawabase 0.3.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.
- pawabase/__init__.py +72 -0
- pawabase/_core.py +78 -0
- pawabase/admin.py +230 -0
- pawabase/auth.py +607 -0
- pawabase/callables.py +101 -0
- pawabase/client.py +301 -0
- pawabase/data.py +584 -0
- pawabase/errors.py +131 -0
- pawabase/manage/__init__.py +90 -0
- pawabase/manage/_base.py +70 -0
- pawabase/manage/access.py +158 -0
- pawabase/manage/automation.py +144 -0
- pawabase/manage/data.py +149 -0
- pawabase/manage/files.py +63 -0
- pawabase/manage/functions.py +61 -0
- pawabase/manage/lifecycle.py +182 -0
- pawabase/manage/mail.py +27 -0
- pawabase/manage/observe.py +170 -0
- pawabase/manage/realtime.py +69 -0
- pawabase/py.typed +0 -0
- pawabase/realtime.py +391 -0
- pawabase/storage.py +166 -0
- pawabase-0.3.0.dist-info/METADATA +183 -0
- pawabase-0.3.0.dist-info/RECORD +26 -0
- pawabase-0.3.0.dist-info/WHEEL +4 -0
- pawabase-0.3.0.dist-info/licenses/LICENSE +29 -0
pawabase/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Pawabase for Python: the client SDK.
|
|
2
|
+
|
|
3
|
+
Talk to a Pawabase environment from an app, a script or a server:
|
|
4
|
+
|
|
5
|
+
from pawabase import Pawabase
|
|
6
|
+
|
|
7
|
+
pb = Pawabase("https://api.example.com", "pb_pk_…") # AsyncPawabase for asyncio
|
|
8
|
+
pb.auth.sign_in_with_password(email="ada@example.com", password="…")
|
|
9
|
+
posts = pb.from_("posts").eq("status", "live").order("-created_at").limit(10).execute()
|
|
10
|
+
pb.storage.from_("avatars").upload("ada.png", data)
|
|
11
|
+
pb.functions.invoke("orders.create", sku="A1")
|
|
12
|
+
|
|
13
|
+
* ``pb.from_(resource)`` records: queries, create, update, delete (:mod:`pawabase.data`)
|
|
14
|
+
* ``pb.auth`` accounts, sessions, MFA, devices, API keys, organizations (:mod:`pawabase.auth`)
|
|
15
|
+
* ``pb.storage`` buckets, objects, signed links, image transforms (:mod:`pawabase.storage`)
|
|
16
|
+
* ``pb.functions`` / ``pb.flows`` / ``pb.routes`` call what you have defined (:mod:`pawabase.callables`)
|
|
17
|
+
* ``pb.realtime`` channels, presence, live record changes (:mod:`pawabase.realtime`)
|
|
18
|
+
* ``pb.admin`` manage users with a secret key (:mod:`pawabase.admin`)
|
|
19
|
+
* ``pb.manage`` configure the environment with a secret key (:mod:`pawabase.manage`)
|
|
20
|
+
|
|
21
|
+
Writing functions that run *on* Pawabase (and the ``pawabase`` command) is a separate package: ``pawabase-functions``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import pkgutil
|
|
27
|
+
|
|
28
|
+
# ``pawabase-functions`` installs its modules (``pawabase.functions``…) into this same package.
|
|
29
|
+
__path__ = pkgutil.extend_path(__path__, __name__)
|
|
30
|
+
|
|
31
|
+
from .auth import FileSessionStore, MemorySessionStore, Session, SessionStore, SignInResult # noqa: E402
|
|
32
|
+
from .client import AsyncPawabase, Pawabase # noqa: E402
|
|
33
|
+
from .data import Page, QueryError # noqa: E402
|
|
34
|
+
from .errors import ( # noqa: E402
|
|
35
|
+
AuthenticationError,
|
|
36
|
+
ConfigError,
|
|
37
|
+
ConflictError,
|
|
38
|
+
ForbiddenError,
|
|
39
|
+
NetworkError,
|
|
40
|
+
NotFoundError,
|
|
41
|
+
PawabaseError,
|
|
42
|
+
QuotaExceededError,
|
|
43
|
+
RateLimitedError,
|
|
44
|
+
RealtimeError,
|
|
45
|
+
ServerError,
|
|
46
|
+
ValidationError,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__version__ = "0.3.0"
|
|
50
|
+
__all__ = [
|
|
51
|
+
"AsyncPawabase",
|
|
52
|
+
"AuthenticationError",
|
|
53
|
+
"ConfigError",
|
|
54
|
+
"ConflictError",
|
|
55
|
+
"FileSessionStore",
|
|
56
|
+
"ForbiddenError",
|
|
57
|
+
"MemorySessionStore",
|
|
58
|
+
"NetworkError",
|
|
59
|
+
"NotFoundError",
|
|
60
|
+
"Page",
|
|
61
|
+
"Pawabase",
|
|
62
|
+
"PawabaseError",
|
|
63
|
+
"QueryError",
|
|
64
|
+
"QuotaExceededError",
|
|
65
|
+
"RateLimitedError",
|
|
66
|
+
"RealtimeError",
|
|
67
|
+
"ServerError",
|
|
68
|
+
"Session",
|
|
69
|
+
"SessionStore",
|
|
70
|
+
"SignInResult",
|
|
71
|
+
"ValidationError",
|
|
72
|
+
]
|
pawabase/_core.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Helpers shared by the blocking and the async client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .errors import ConfigError
|
|
13
|
+
|
|
14
|
+
RETRIED_STATUSES = frozenset({502, 503, 504})
|
|
15
|
+
#: A 429 means the request was *not* processed, so it is safe to wait and send it again whatever the method.
|
|
16
|
+
MAX_RATE_WAITS = 8
|
|
17
|
+
MAX_RATE_WAIT_SECONDS = 20.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def rate_wait(response: httpx.Response) -> float:
|
|
21
|
+
"""How long the gateway asked to wait (``Retry-After`` or the body's ``retry_after``), at least one second, at most 20."""
|
|
22
|
+
asked: Any = response.headers.get("retry-after")
|
|
23
|
+
if asked is None:
|
|
24
|
+
try:
|
|
25
|
+
asked = response.json().get("retry_after")
|
|
26
|
+
except (ValueError, AttributeError):
|
|
27
|
+
asked = None
|
|
28
|
+
try:
|
|
29
|
+
return min(max(float(asked), 1.0), MAX_RATE_WAIT_SECONDS)
|
|
30
|
+
except (TypeError, ValueError):
|
|
31
|
+
return 1.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def then(value: Any, function: Callable[[Any], Any]) -> Any:
|
|
35
|
+
"""Apply *function* to a result that is a value (blocking client) or an awaitable (async client): the logic is written once."""
|
|
36
|
+
if inspect.isawaitable(value):
|
|
37
|
+
|
|
38
|
+
async def finish() -> Any:
|
|
39
|
+
return function(await value)
|
|
40
|
+
|
|
41
|
+
return finish()
|
|
42
|
+
return function(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def chain(value: Any, function: Callable[[Any], Any]) -> Any:
|
|
46
|
+
"""Like :func:`then`, for a *function* that itself returns a value or an awaitable."""
|
|
47
|
+
if inspect.isawaitable(value):
|
|
48
|
+
|
|
49
|
+
async def finish() -> Any:
|
|
50
|
+
out = function(await value)
|
|
51
|
+
return await out if inspect.isawaitable(out) else out
|
|
52
|
+
|
|
53
|
+
return finish()
|
|
54
|
+
return function(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def path_of(*parts: Any) -> str:
|
|
58
|
+
"""Join URL path parts, quoting each (a key may contain ``/``: it stays a path)."""
|
|
59
|
+
return "/".join(quote(str(p), safe="") for p in parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def key_path(key: str) -> str:
|
|
63
|
+
"""An object key as a path: segments quoted, slashes kept."""
|
|
64
|
+
return "/".join(quote(segment, safe="") for segment in key.lstrip("/").split("/"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compact(values: Mapping[str, Any]) -> dict[str, Any]:
|
|
68
|
+
"""*values* without the ``None``\\ s."""
|
|
69
|
+
return {k: v for k, v in values.items() if v is not None}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_secret_key(key: str) -> bool:
|
|
73
|
+
return key.startswith("pb_sk_")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def require_async(client: Any, what: str) -> None:
|
|
77
|
+
if not getattr(client, "is_async", False):
|
|
78
|
+
raise ConfigError(f"{what} needs the async client: use AsyncPawabase.")
|
pawabase/admin.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""``pb.admin``: administer the environment's users, organizations and roles, with the environment's secret key (``pb_sk_…``).
|
|
2
|
+
|
|
3
|
+
These are the people who sign in to *your* application, not the project's own team. Every call is scoped to the client's environment; a
|
|
4
|
+
user of one environment is never visible in another.
|
|
5
|
+
|
|
6
|
+
pb.admin.users.create(email="ada@example.com", password="…", roles=["editor"])
|
|
7
|
+
pb.admin.users.update(user_id, disabled=True)
|
|
8
|
+
pb.admin.orgs.add_member("acme", email="ada@example.com", role="admin")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import _core
|
|
17
|
+
from .manage._base import Service, seg
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Admin(Service):
|
|
21
|
+
def path(self, suffix: str = "") -> str:
|
|
22
|
+
return f"/platform/v1/envs/{_core.path_of(self.env)}/auth{suffix}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Users(_Admin):
|
|
26
|
+
def search(self, *, search: str | None = None, status: str | None = None, limit: int = 50, offset: int = 0) -> Any:
|
|
27
|
+
"""``search`` matches email, username and name. ``status``: ``disabled``, ``unverified``, ``locked``, ``mfa``, ``no_mfa``."""
|
|
28
|
+
return self.get("/users", search=search, status=status, limit=limit, offset=offset)
|
|
29
|
+
|
|
30
|
+
def get_user(self, user_id: str) -> Any:
|
|
31
|
+
return self.get(seg("users", user_id))
|
|
32
|
+
|
|
33
|
+
def create(
|
|
34
|
+
self, *, email: str, password: str | None = None, username: str | None = None, name: str = "", email_verified: bool = True,
|
|
35
|
+
user_metadata: Mapping[str, Any] | None = None, app_metadata: Mapping[str, Any] | None = None, roles: Sequence[str] = (),
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Create an account without the sign-up restrictions (allow-lists, password policy aside). ``app_metadata`` is for your server only."""
|
|
38
|
+
body: dict[str, Any] = {"email": email, "name": name, "email_verified": email_verified, "roles": list(roles), "user_metadata": dict(user_metadata or {}), "app_metadata": dict(app_metadata or {})}
|
|
39
|
+
if password is not None:
|
|
40
|
+
body["password"] = password
|
|
41
|
+
if username is not None:
|
|
42
|
+
body["username"] = username
|
|
43
|
+
return self.post("/users", body)
|
|
44
|
+
|
|
45
|
+
def update(
|
|
46
|
+
self, user_id: str, *, name: str | None = None, email_verified: bool | None = None, disabled: bool | None = None, password: str | None = None,
|
|
47
|
+
user_metadata: Mapping[str, Any] | None = None, app_metadata: Mapping[str, Any] | None = None, roles: Sequence[str] | None = None,
|
|
48
|
+
unlock: bool | None = None,
|
|
49
|
+
) -> Any:
|
|
50
|
+
"""Change what is given. ``disabled=True`` blocks sign-in and ends sessions; ``unlock=True`` clears a lockout; ``roles`` replaces the user's roles."""
|
|
51
|
+
given = {"name": name, "email_verified": email_verified, "disabled": disabled, "password": password, "user_metadata": user_metadata,
|
|
52
|
+
"app_metadata": app_metadata, "roles": None if roles is None else list(roles), "unlock": unlock}
|
|
53
|
+
return self.patch(seg("users", user_id), {k: v for k, v in given.items() if v is not None})
|
|
54
|
+
|
|
55
|
+
def delete_user(self, user_id: str) -> Any:
|
|
56
|
+
return self.delete(seg("users", user_id))
|
|
57
|
+
|
|
58
|
+
def history(self, user_id: str) -> Any:
|
|
59
|
+
"""The user's recent sign-ins and security events."""
|
|
60
|
+
return self.get(seg("users", user_id, "history"))
|
|
61
|
+
|
|
62
|
+
def reset_mfa(self, user_id: str) -> Any:
|
|
63
|
+
"""Remove the user's second factors, so they can sign in with a password alone and enrol again."""
|
|
64
|
+
return self.post(seg("users", user_id, "mfa", "reset"))
|
|
65
|
+
|
|
66
|
+
def grant(self, user_id: str, permissions: Sequence[str]) -> Any:
|
|
67
|
+
"""Give the user permissions directly, beyond their roles' (``posts:write``, ``billing:*``)."""
|
|
68
|
+
return self.post(seg("users", user_id, "permissions"), {"permissions": list(permissions)})
|
|
69
|
+
|
|
70
|
+
def revoke(self, user_id: str, permissions: Sequence[str]) -> Any:
|
|
71
|
+
return self._c._call("DELETE", self.path(seg("users", user_id, "permissions")), json={"permissions": list(permissions)})
|
|
72
|
+
|
|
73
|
+
def revoke_sessions(self, user_id: str) -> Any:
|
|
74
|
+
"""Sign the user out everywhere."""
|
|
75
|
+
return self.post(seg("users", user_id, "sessions", "revoke"))
|
|
76
|
+
|
|
77
|
+
def revoke_session(self, user_id: str, session_id: str) -> Any:
|
|
78
|
+
return self.delete(seg("users", user_id, "sessions", session_id))
|
|
79
|
+
|
|
80
|
+
def devices(self, user_id: str) -> Any:
|
|
81
|
+
return self.get(seg("users", user_id, "devices"))
|
|
82
|
+
|
|
83
|
+
def forget_device(self, user_id: str, device_id: str) -> Any:
|
|
84
|
+
return self.delete(seg("users", user_id, "devices", device_id))
|
|
85
|
+
|
|
86
|
+
def api_keys(self, user_id: str) -> Any:
|
|
87
|
+
"""The user's own API keys (never the secrets)."""
|
|
88
|
+
return self.get(seg("users", user_id, "api-keys"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Roles(_Admin):
|
|
92
|
+
def list(self) -> Any:
|
|
93
|
+
"""Roles with their permissions."""
|
|
94
|
+
return self.get("/roles")
|
|
95
|
+
|
|
96
|
+
def put(self, name: str, permissions: Sequence[str], *, description: str = "") -> Any: # type: ignore[override]
|
|
97
|
+
"""Create or replace a role. Names are lowercase (``editor``, ``org:billing``); permissions are ``resource:action`` patterns with ``*`` wildcards."""
|
|
98
|
+
return Service.put(self, "/roles", {"name": name, "description": description, "permissions": list(permissions)})
|
|
99
|
+
|
|
100
|
+
def remove(self, name: str) -> Any:
|
|
101
|
+
return self.delete(seg("roles", name))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Orgs(_Admin):
|
|
105
|
+
"""Organizations (tenants) and their members. A member's role is ``owner``, ``admin`` or ``member``."""
|
|
106
|
+
|
|
107
|
+
def list(self) -> Any:
|
|
108
|
+
return self.get("/orgs")
|
|
109
|
+
|
|
110
|
+
def get_org(self, slug: str) -> Any:
|
|
111
|
+
return self.get(seg("orgs", slug))
|
|
112
|
+
|
|
113
|
+
def create(self, slug: str, name: str, *, owner_email: str | None = None, metadata: Mapping[str, Any] | None = None) -> Any:
|
|
114
|
+
body: dict[str, Any] = {"slug": slug, "name": name, "metadata": dict(metadata or {})}
|
|
115
|
+
if owner_email:
|
|
116
|
+
body["owner_email"] = owner_email
|
|
117
|
+
return self.post("/orgs", body)
|
|
118
|
+
|
|
119
|
+
def update(self, slug: str, *, name: str | None = None, metadata: Mapping[str, Any] | None = None) -> Any:
|
|
120
|
+
return self.patch(seg("orgs", slug), {k: v for k, v in {"name": name, "metadata": metadata}.items() if v is not None})
|
|
121
|
+
|
|
122
|
+
def remove(self, slug: str) -> Any:
|
|
123
|
+
return self.delete(seg("orgs", slug))
|
|
124
|
+
|
|
125
|
+
def add_member(self, slug: str, *, email: str, role: str = "member") -> Any:
|
|
126
|
+
"""Add an existing user straight in."""
|
|
127
|
+
return self.post(seg("orgs", slug, "members"), {"email": email, "role": role})
|
|
128
|
+
|
|
129
|
+
def set_member_role(self, slug: str, user_id: str, role: str) -> Any:
|
|
130
|
+
return self.patch(seg("orgs", slug, "members", user_id), {"role": role})
|
|
131
|
+
|
|
132
|
+
def remove_member(self, slug: str, user_id: str) -> Any:
|
|
133
|
+
return self.delete(seg("orgs", slug, "members", user_id))
|
|
134
|
+
|
|
135
|
+
def invite(self, slug: str, *, email: str, role: str = "member", redirect_to: str | None = None) -> Any:
|
|
136
|
+
"""Mail an invitation; ``redirect_to`` must be one of the environment's allowed redirect URLs."""
|
|
137
|
+
return self.post(seg("orgs", slug, "invitations"), {"email": email, "role": role, **({"redirect_to": redirect_to} if redirect_to else {})})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Invitations(_Admin):
|
|
141
|
+
def list(self) -> Any:
|
|
142
|
+
"""Pending and past invitations across organizations."""
|
|
143
|
+
return self.get("/invitations")
|
|
144
|
+
|
|
145
|
+
def resend(self, invitation_id: str, *, redirect_to: str | None = None) -> Any:
|
|
146
|
+
return self.post(seg("invitations", invitation_id, "resend"), {"redirect_to": redirect_to} if redirect_to else {})
|
|
147
|
+
|
|
148
|
+
def revoke(self, invitation_id: str) -> Any:
|
|
149
|
+
return self.delete(seg("invitations", invitation_id))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Sessions(_Admin):
|
|
153
|
+
def list(self, *, limit: int | None = None) -> Any:
|
|
154
|
+
"""Active sessions across users."""
|
|
155
|
+
return self.get("/sessions", limit=limit)
|
|
156
|
+
|
|
157
|
+
def revoke(self, family: str) -> Any:
|
|
158
|
+
"""End one session (all its refresh tokens)."""
|
|
159
|
+
return self.delete(seg("sessions", family))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ApiKeys(_Admin):
|
|
163
|
+
"""API keys users made for themselves."""
|
|
164
|
+
|
|
165
|
+
def list(self, *, limit: int | None = None) -> Any:
|
|
166
|
+
return self.get("/api-keys", limit=limit)
|
|
167
|
+
|
|
168
|
+
def revoke(self, key_id: str) -> Any:
|
|
169
|
+
return self.delete(seg("api-keys", key_id))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Devices(_Admin):
|
|
173
|
+
def list(self, *, limit: int | None = None) -> Any:
|
|
174
|
+
"""Recent devices people signed in from."""
|
|
175
|
+
return self.get("/devices", limit=limit)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Insight(_Admin):
|
|
179
|
+
"""Events, statistics, backup and the hosted pages."""
|
|
180
|
+
|
|
181
|
+
def events(self, *, kind: str | None = None, failed: bool = False, limit: int = 100) -> Any:
|
|
182
|
+
"""Authentication events (sign-ins, sign-ups, resets, MFA…). ``failed=True`` for failures only."""
|
|
183
|
+
return self.get("/events", kind=kind, failed="true" if failed else None, limit=limit)
|
|
184
|
+
|
|
185
|
+
def stats(self) -> Any:
|
|
186
|
+
"""Counts of users, sign-ins, MFA use and failures."""
|
|
187
|
+
return self.get("/stats")
|
|
188
|
+
|
|
189
|
+
def backup(self) -> Any:
|
|
190
|
+
"""Every identity (users, organizations, roles, factors) as a document."""
|
|
191
|
+
return self.get("/backup")
|
|
192
|
+
|
|
193
|
+
def restore(self, identities: Mapping[str, Any], *, replace: bool = False, dry_run: bool = False) -> Any:
|
|
194
|
+
"""Apply the ``identities`` of a backup. ``dry_run=True`` reports what would change."""
|
|
195
|
+
return self.post("/restore", {"identities": dict(identities), "replace": replace, "dry_run": dry_run})
|
|
196
|
+
|
|
197
|
+
def preview_token_profile(self, profile: Mapping[str, Any], user_id: str, *, org: str | None = None) -> Any:
|
|
198
|
+
"""The claims a token profile would put in a token for this user (and organization), without issuing one."""
|
|
199
|
+
body = {"profile": dict(profile), "user_id": user_id, **({"org": org} if org else {})}
|
|
200
|
+
return self.post("/token-profiles/preview", body)
|
|
201
|
+
|
|
202
|
+
def hosted_source(self, page: str, *, branding: Mapping[str, Any] | None = None, form: Mapping[str, Any] | None = None) -> Any:
|
|
203
|
+
"""The HTML a hosted sign-in page is served as for the branding being edited (the saved branding when omitted)."""
|
|
204
|
+
return self.post("/hosted-source", {"page": page, **({"branding": dict(branding)} if branding is not None else {}), **({"form": dict(form)} if form is not None else {})})
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Admin:
|
|
208
|
+
users: Users
|
|
209
|
+
roles: Roles
|
|
210
|
+
orgs: Orgs
|
|
211
|
+
invitations: Invitations
|
|
212
|
+
sessions: Sessions
|
|
213
|
+
api_keys: ApiKeys
|
|
214
|
+
devices: Devices
|
|
215
|
+
insight: Insight
|
|
216
|
+
|
|
217
|
+
def __init__(self, client: Any, environment: str | None = None) -> None:
|
|
218
|
+
self._client = client
|
|
219
|
+
self._environment = environment
|
|
220
|
+
self.users = Users(client, environment)
|
|
221
|
+
self.roles = Roles(client, environment)
|
|
222
|
+
self.orgs = Orgs(client, environment)
|
|
223
|
+
self.invitations = Invitations(client, environment)
|
|
224
|
+
self.sessions = Sessions(client, environment)
|
|
225
|
+
self.api_keys = ApiKeys(client, environment)
|
|
226
|
+
self.devices = Devices(client, environment)
|
|
227
|
+
self.insight = Insight(client, environment)
|
|
228
|
+
|
|
229
|
+
def environment(self, name: str) -> Admin:
|
|
230
|
+
return Admin(self._client, name)
|