healthcloud-tenantadmin 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,81 @@
1
+ # Logs
2
+ logs/
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+
9
+ # Environment files
10
+ .env
11
+ .env.*
12
+ !.env.example
13
+ **/live-state.json
14
+
15
+ # Node / npm
16
+ node_modules/
17
+ .npm/
18
+ .pnpm-store/
19
+ dist/
20
+ !packages/npm/dist/
21
+ !packages/npm/dist/**
22
+ build/
23
+
24
+ # packages/cli/npm bundles the manifest from packages/cli/contracts at build/
25
+ # test time (scripts/copy-manifest.cjs) — that copy is generated, not source.
26
+ packages/cli/npm/contracts/
27
+ coverage/
28
+ *.tsbuildinfo
29
+
30
+ # Python / pip
31
+ __pycache__/
32
+ *.py[cod]
33
+ *.pyo
34
+ .Python
35
+ .venv/
36
+ venv/
37
+ env/
38
+ pip-wheel-metadata/
39
+ *.egg-info/
40
+ .pytest_cache/
41
+ .mypy_cache/
42
+ .ruff_cache/
43
+ htmlcov/
44
+
45
+ # Java / Maven
46
+ target/
47
+ .mvn/timing.properties
48
+ .mvn/wrapper/maven-wrapper.jar
49
+ *.class
50
+ *.jar
51
+ *.war
52
+ *.ear
53
+
54
+ # .NET / NuGet
55
+ # Scoped to packages/nuget/ specifically — an unscoped `bin/`/`obj/` matches
56
+ # a directory of that name at ANY depth (gitignore has no implicit anchor),
57
+ # which silently swallowed the real source file packages/cli/npm/src/bin/hc.ts
58
+ # and broke that package's CI build (the file was never committed).
59
+ packages/nuget/**/bin/
60
+ packages/nuget/**/obj/
61
+ TestResults/
62
+ *.nupkg
63
+ *.snupkg
64
+ *.user
65
+ *.suo
66
+
67
+ # IDEs and editors
68
+ .idea/
69
+ .vscode/
70
+ *.swp
71
+ *.swo
72
+ .DS_Store
73
+ Thumbs.db
74
+
75
+ # Build outputs and caches
76
+ out/
77
+ tmp/
78
+ temp/
79
+ .cache/
80
+ .parcel-cache/
81
+ .turbo/
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: healthcloud-tenantadmin
3
+ Version: 0.1.0
4
+ Summary: HealthCloud Tenant Admin SDK for Python — manage tenant admins and auth.
5
+ Project-URL: Repository, https://github.com/healthcloudservices/healthcloud-sdk
6
+ Author: Healthcheck Systems Inc
7
+ License-Expression: MIT
8
+ Keywords: api,health-cloud,healthcare,healthcloud,sdk,tenant-admin
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: httpx>=0.27.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: respx>=0.21; extra == 'dev'
14
+ Requires-Dist: ruff>=0.6; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # healthcloud-tenantadmin
18
+
19
+ Python SDK for the HealthCloud Tenant Admin API — Python counterpart to the
20
+ `@healthcloudai/hc-tenantadmin` npm package (`packages/npm-tenantadmin`).
21
+ Same routes, same behavior, same field names — kept in sync with
22
+ `HEALTH_CLOUD/healthcloud-services/hc/apis/tenantadmin/src/app.py`.
23
+
24
+ ## Install
25
+
26
+ This public package is published to PyPI alongside the other HealthCloud SDKs:
27
+
28
+ ```bash
29
+ pip install healthcloud-tenantadmin
30
+ ```
31
+
32
+ Pin the version in applications and CI when reproducible installs are required:
33
+
34
+ ```
35
+ healthcloud-tenantadmin==0.1.0
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ from healthcloud_tenantadmin import HCTenantAdminSDK
42
+
43
+ sdk = HCTenantAdminSDK(environment="dev", tenant_id="acme")
44
+ sdk.admin.login(email="admin@acme.com", password="...")
45
+
46
+ # List all Tenant Admins for the caller's own tenant (resolved from the
47
+ # verified JWT — tenant_id is never accepted as a parameter)
48
+ admins = sdk.admin.get_admins_for_tenant()
49
+ for a in admins:
50
+ print(a.admin_id, a.email, a.status)
51
+ ```
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ pip install -e ".[dev]"
57
+ pytest
58
+ ```
@@ -0,0 +1,42 @@
1
+ # healthcloud-tenantadmin
2
+
3
+ Python SDK for the HealthCloud Tenant Admin API — Python counterpart to the
4
+ `@healthcloudai/hc-tenantadmin` npm package (`packages/npm-tenantadmin`).
5
+ Same routes, same behavior, same field names — kept in sync with
6
+ `HEALTH_CLOUD/healthcloud-services/hc/apis/tenantadmin/src/app.py`.
7
+
8
+ ## Install
9
+
10
+ This public package is published to PyPI alongside the other HealthCloud SDKs:
11
+
12
+ ```bash
13
+ pip install healthcloud-tenantadmin
14
+ ```
15
+
16
+ Pin the version in applications and CI when reproducible installs are required:
17
+
18
+ ```
19
+ healthcloud-tenantadmin==0.1.0
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from healthcloud_tenantadmin import HCTenantAdminSDK
26
+
27
+ sdk = HCTenantAdminSDK(environment="dev", tenant_id="acme")
28
+ sdk.admin.login(email="admin@acme.com", password="...")
29
+
30
+ # List all Tenant Admins for the caller's own tenant (resolved from the
31
+ # verified JWT — tenant_id is never accepted as a parameter)
32
+ admins = sdk.admin.get_admins_for_tenant()
33
+ for a in admins:
34
+ print(a.admin_id, a.email, a.status)
35
+ ```
36
+
37
+ ## Development
38
+
39
+ ```bash
40
+ pip install -e ".[dev]"
41
+ pytest
42
+ ```
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from .config import Environment, TenantAdminSDKConfig
4
+ from .errors import SDKError
5
+ from .models import TenantAdmin, TenantAdminProfile, TenantAccountProfile
6
+ from .sdk import HCTenantAdminSDK
7
+
8
+ __all__ = [
9
+ "Environment",
10
+ "TenantAdminSDKConfig",
11
+ "SDKError",
12
+ "TenantAdmin",
13
+ "TenantAdminProfile",
14
+ "TenantAccountProfile",
15
+ "HCTenantAdminSDK",
16
+ ]
@@ -0,0 +1,3 @@
1
+ from .tenant_admin import TenantAdminClient
2
+
3
+ __all__ = ["TenantAdminClient"]
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from ..config import TenantAdminSDKConfig, with_tenant_id
6
+ from ..http import HttpClient
7
+ from ..models import TenantAdmin, TenantAdminProfile
8
+
9
+
10
+ class TenantAdminClient:
11
+ """Service client for the HC Tenant Admin API.
12
+
13
+ Covers:
14
+ - Tenant/account registration (public — also creates the first admin)
15
+ - Tenant Admin auth: self-registration, invite, login, refresh,
16
+ verify-email, reset-password
17
+ - Profile: current admin (JWT identity) and admin CRUD by id, plus
18
+ listing all admins for the caller's own tenant
19
+ """
20
+
21
+ def __init__(self, http: HttpClient, config: TenantAdminSDKConfig):
22
+ self._http = http
23
+ self._config = config
24
+
25
+ # ─── Tenant/account registration (public) ────────────────────────────
26
+
27
+ def register_tenant_account(self, **data: Any) -> dict:
28
+ """Create a tenant/account, provision all four Cognito pools
29
+ (patient, provider, person, tenantadmin), and create the tenant's
30
+ first (primary) Tenant Admin user.
31
+ POST /tenants/register (public)"""
32
+ return self._http.post("/tenants/register", auth=False, json=data)
33
+
34
+ # ─── Tenant Admin registration — self vs. invite ─────────────────────
35
+
36
+ def self_register_tenant_admin(
37
+ self, *, tenant_id: str, first_name: str, last_name: str, email: str,
38
+ password: str, phone: Optional[str] = None,
39
+ ) -> dict:
40
+ """Public self-registration into an existing tenant. Requires the
41
+ tenant and its tenantadmin Cognito pool to already exist (404
42
+ otherwise). No JWT required.
43
+ POST /auth/register, mode=self (public)"""
44
+ body = {
45
+ "tenant_id": tenant_id, "first_name": first_name, "last_name": last_name,
46
+ "email": email, "password": password, "phone": phone, "mode": "self",
47
+ }
48
+ return self._http.post("/auth/register", auth=False, json=body)
49
+
50
+ def invite_tenant_admin(self, *, first_name: str, last_name: str, email: str) -> dict:
51
+ """Invite another Tenant Admin into the caller's own tenant. Requires
52
+ a Tenant Admin JWT (tenantadmin pool only); the tenant is always the
53
+ inviter's own.
54
+ POST /auth/register, mode=invite (Bearer required)"""
55
+ body = {"first_name": first_name, "last_name": last_name, "email": email, "mode": "invite"}
56
+ return self._http.post("/auth/register", json=body)
57
+
58
+ # ─── Auth (public, no Bearer token) ──────────────────────────────────
59
+
60
+ def login(self, *, email: str, password: str, tenant_id: Optional[str] = None) -> dict:
61
+ """Log in an existing tenant admin. Returns access, ID and refresh
62
+ tokens.
63
+ POST /auth/login (public)"""
64
+ body = with_tenant_id({"email": email, "password": password}, tenant_id or self._config.tenant_id)
65
+ response = self._http.post("/auth/login", auth=False, json=body)
66
+ if response and response.get("access_token"):
67
+ self._http.set_access_token(response["access_token"])
68
+ return response
69
+
70
+ def login_tenant_admin(self, *, email: str, password: str, tenant_id: Optional[str] = None) -> dict:
71
+ """Alias of :meth:`login`, matching the SDK's cross-package naming."""
72
+ return self.login(email=email, password=password, tenant_id=tenant_id)
73
+
74
+ def refresh_tenant_admin_token(self, *, refresh_token: str, tenant_id: Optional[str] = None) -> dict:
75
+ """Renew an access token via Cognito's refresh-token flow.
76
+ POST /auth/refresh (public)"""
77
+ body = with_tenant_id({"refresh_token": refresh_token}, tenant_id or self._config.tenant_id)
78
+ return self._http.post("/auth/refresh", auth=False, json=body)
79
+
80
+ def verify_email(self, *, email: str, otp: str, tenant_id: Optional[str] = None) -> dict:
81
+ """Verify a tenant admin's email address with OTP.
82
+ POST /auth/verify-email (public)"""
83
+ body = with_tenant_id({"email": email, "otp": otp}, tenant_id or self._config.tenant_id)
84
+ return self._http.post("/auth/verify-email", auth=False, json=body)
85
+
86
+ def reset_password(self, *, email: str, tenant_id: Optional[str] = None) -> dict:
87
+ """Initiate password reset flow (sends code to email).
88
+ POST /auth/reset-password (public)"""
89
+ body = with_tenant_id({"email": email}, tenant_id or self._config.tenant_id)
90
+ return self._http.post("/auth/reset-password", auth=False, json=body)
91
+
92
+ def confirm_password(
93
+ self, *, email: str, new_password: str, confirmation_code: str, tenant_id: Optional[str] = None,
94
+ ) -> dict:
95
+ """Confirm password reset with code.
96
+ POST /auth/confirm-reset-password (public)"""
97
+ body = with_tenant_id(
98
+ {"email": email, "new_password": new_password, "confirmation_code": confirmation_code},
99
+ tenant_id or self._config.tenant_id,
100
+ )
101
+ return self._http.post("/auth/confirm-reset-password", auth=False, json=body)
102
+
103
+ # ─── Current admin (authenticated — identity from the verified JWT) ──
104
+
105
+ def get_current_tenant_admin(self) -> TenantAdminProfile:
106
+ """Get the currently authenticated Tenant Admin's own profile.
107
+ Identity is resolved server-side from the verified access token's
108
+ claims — there is no admin id to pass.
109
+ GET /admins/me (Bearer required, tenantadmin pool only)"""
110
+ return TenantAdminProfile.from_dict(self._http.get("/admins/me"))
111
+
112
+ # ─── Admin CRUD (authenticated) ───────────────────────────────────────
113
+
114
+ def get_admins_for_tenant(self) -> list[TenantAdmin]:
115
+ """List all Tenant Admins for the caller's own tenant, resolved from
116
+ the verified Tenant Admin JWT — the tenant is never accepted as a
117
+ parameter. Sorted by email. Per-item `account` is not populated
118
+ here (unlike :meth:`get_admin`); fetch it via `get_admin` if needed.
119
+ GET /tenant-admins (Bearer required, tenantadmin pool only)"""
120
+ return [TenantAdmin.from_dict(item) for item in self._http.get("/tenant-admins")]
121
+
122
+ def get_admin(self, admin_id: str) -> TenantAdmin:
123
+ """Get a tenant admin by ID, including the tenant's business info
124
+ (account.name/status/tier).
125
+ GET /admins/{admin_id}"""
126
+ data = self._http.get("/admins/{admin_id}", path_params={"admin_id": admin_id})
127
+ return TenantAdmin.from_dict(data)
128
+
129
+ def update_admin(self, admin_id: str, **fields: Any) -> dict:
130
+ """Update a tenant admin's profile fields (first_name, last_name, phone).
131
+ PATCH /admins/{admin_id}"""
132
+ return self._http.patch(
133
+ "/admins/{admin_id}", path_params={"admin_id": admin_id}, json=fields,
134
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal, Optional
5
+
6
+ Environment = Literal["dev", "uat", "prod"]
7
+
8
+ SERVICE_NAME = "tenantadmin"
9
+
10
+
11
+ @dataclass
12
+ class TenantAdminSDKConfig:
13
+ environment: Environment = "dev"
14
+ tenant_id: Optional[str] = None
15
+ access_token: Optional[str] = None
16
+ timeout: float = 30.0
17
+
18
+
19
+ def assert_environment(environment: Environment) -> None:
20
+ if environment not in ("dev", "uat", "prod"):
21
+ raise ValueError(f'Unsupported HealthCloud environment: "{environment}"')
22
+
23
+
24
+ def resolve_base_url(environment: Environment) -> str:
25
+ assert_environment(environment)
26
+ prefix = "" if environment == "prod" else f"{environment}-"
27
+ return f"https://{prefix}api-{SERVICE_NAME}.health.cloud"
28
+
29
+
30
+ def with_tenant_id(data: dict, tenant_id: Optional[str]) -> dict:
31
+ """Always enforces the SDK-configured tenant on request bodies — never
32
+ lets a caller-supplied tenant_id override it."""
33
+ if not tenant_id:
34
+ return dict(data)
35
+ out = dict(data)
36
+ out["tenant_id"] = tenant_id
37
+ return out
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class SDKError(Exception):
7
+ def __init__(self, message: str, status_code: int, response: Optional[Any] = None):
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+ self.response = response
11
+
12
+ def __str__(self) -> str:
13
+ return f"[SDKError {self.status_code}] {super().__str__()}"
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, Optional
4
+
5
+ import httpx
6
+
7
+ from .config import TenantAdminSDKConfig, resolve_base_url
8
+ from .errors import SDKError
9
+
10
+
11
+ class HttpClient:
12
+ def __init__(self, config: TenantAdminSDKConfig):
13
+ self._config = config
14
+ self._client = httpx.Client(timeout=config.timeout)
15
+
16
+ def set_access_token(self, token: Optional[str]) -> None:
17
+ self._config.access_token = token
18
+
19
+ def close(self) -> None:
20
+ self._client.close()
21
+
22
+ def _headers(self, auth: bool, headers: Optional[Mapping[str, str]] = None) -> dict:
23
+ out = {"Accept": "application/json", "Content-Type": "application/json"}
24
+ if headers:
25
+ out.update(dict(headers))
26
+ if auth and self._config.access_token:
27
+ out["Authorization"] = f"Bearer {self._config.access_token}"
28
+ return out
29
+
30
+ def request(
31
+ self,
32
+ method: str,
33
+ path: str,
34
+ *,
35
+ auth: bool = True,
36
+ path_params: Optional[Mapping[str, str]] = None,
37
+ params: Optional[Mapping[str, Any]] = None,
38
+ json: Optional[Any] = None,
39
+ headers: Optional[Mapping[str, str]] = None,
40
+ ) -> Any:
41
+ url = f"{resolve_base_url(self._config.environment)}{path}"
42
+ if path_params:
43
+ for key, value in path_params.items():
44
+ url = url.replace(f"{{{key}}}", str(value))
45
+
46
+ resp = self._client.request(
47
+ method, url, params=params, json=json, headers=self._headers(auth, headers)
48
+ )
49
+ if resp.status_code >= 400:
50
+ try:
51
+ detail: Any = resp.json()
52
+ except Exception:
53
+ detail = resp.text
54
+ raise SDKError(f"HealthCloud API error {resp.status_code}: {detail}", resp.status_code, detail)
55
+ if not resp.content:
56
+ return None
57
+ try:
58
+ return resp.json()
59
+ except Exception:
60
+ return resp.text
61
+
62
+ def get(self, path: str, **kwargs: Any) -> Any:
63
+ return self.request("GET", path, **kwargs)
64
+
65
+ def post(self, path: str, **kwargs: Any) -> Any:
66
+ return self.request("POST", path, **kwargs)
67
+
68
+ def patch(self, path: str, **kwargs: Any) -> Any:
69
+ return self.request("PATCH", path, **kwargs)
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Optional
5
+
6
+
7
+ @dataclass
8
+ class TenantAccountProfile:
9
+ tenant_id: str
10
+ name: str
11
+ status: str
12
+ tier: str
13
+
14
+ @classmethod
15
+ def from_dict(cls, data: Optional[dict]) -> Optional["TenantAccountProfile"]:
16
+ if not data:
17
+ return None
18
+ return cls(
19
+ tenant_id=data["tenant_id"],
20
+ name=data["name"],
21
+ status=data["status"],
22
+ tier=data["tier"],
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class TenantAdmin:
28
+ """A Tenant Admin resource, as returned by GET /tenant-admins (list) and
29
+ GET /admins/{admin_id} (single). Fields present differ slightly by
30
+ route — see field docstrings."""
31
+
32
+ admin_id: str
33
+ email: str
34
+ tenant_id: str
35
+ first_name: str
36
+ last_name: str
37
+ status: str
38
+ phone: Optional[str] = None
39
+ title: Optional[str] = None
40
+ # Cognito User Pool sub. Only present on GET /tenant-admins (list).
41
+ cognito_sub: Optional[str] = None
42
+ permissions: list[str] = field(default_factory=list)
43
+ is_primary: bool = False
44
+ created_at: Optional[str] = None
45
+ updated_at: Optional[str] = None
46
+ last_login_at: Optional[str] = None
47
+ # admin_id of the creator/inviter. Only present on GET /tenant-admins (list).
48
+ created_by: Optional[str] = None
49
+ invited_by: Optional[str] = None
50
+ account: Optional[TenantAccountProfile] = None
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: dict) -> "TenantAdmin":
54
+ return cls(
55
+ admin_id=data["admin_id"],
56
+ email=data["email"],
57
+ tenant_id=data["tenant_id"],
58
+ first_name=data["first_name"],
59
+ last_name=data["last_name"],
60
+ status=data["status"],
61
+ phone=data.get("phone"),
62
+ title=data.get("title"),
63
+ cognito_sub=data.get("cognito_sub"),
64
+ permissions=data.get("permissions", []),
65
+ is_primary=data.get("is_primary", False),
66
+ created_at=data.get("created_at"),
67
+ updated_at=data.get("updated_at"),
68
+ last_login_at=data.get("last_login_at"),
69
+ created_by=data.get("created_by"),
70
+ invited_by=data.get("invited_by"),
71
+ account=TenantAccountProfile.from_dict(data.get("account")),
72
+ )
73
+
74
+
75
+ @dataclass
76
+ class TenantAdminProfile:
77
+ """GET /admins/me — identity resolved from the verified access token's
78
+ claims; no admin_id is ever accepted from the caller for this route."""
79
+
80
+ admin_id: str
81
+ tenant_id: str
82
+ email: str
83
+ first_name: str
84
+ last_name: str
85
+ status: str
86
+ is_primary: bool = False
87
+ permissions: list[str] = field(default_factory=list)
88
+ account: Optional[TenantAccountProfile] = None
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict) -> "TenantAdminProfile":
92
+ return cls(
93
+ admin_id=data["admin_id"],
94
+ tenant_id=data["tenant_id"],
95
+ email=data["email"],
96
+ first_name=data["first_name"],
97
+ last_name=data["last_name"],
98
+ status=data["status"],
99
+ is_primary=data.get("is_primary", False),
100
+ permissions=data.get("permissions", []),
101
+ account=TenantAccountProfile.from_dict(data.get("account")),
102
+ )
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from .clients.tenant_admin import TenantAdminClient
6
+ from .config import Environment, TenantAdminSDKConfig, assert_environment
7
+ from .http import HttpClient
8
+
9
+
10
+ class HCTenantAdminSDK:
11
+ """Entry point for the HealthCloud Tenant Admin SDK.
12
+
13
+ >>> sdk = HCTenantAdminSDK(environment="dev", tenant_id="acme")
14
+ >>> sdk.login(email="admin@acme.com", password="...")
15
+ >>> sdk.admin.get_admins_for_tenant()
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ environment: Environment = "dev",
21
+ tenant_id: Optional[str] = None,
22
+ access_token: Optional[str] = None,
23
+ timeout: float = 30.0,
24
+ ):
25
+ assert_environment(environment)
26
+ self._config = TenantAdminSDKConfig(
27
+ environment=environment, tenant_id=tenant_id, access_token=access_token, timeout=timeout,
28
+ )
29
+ self._http = HttpClient(self._config)
30
+
31
+ # Tenant admin auth + profile management
32
+ self.admin = TenantAdminClient(self._http, self._config)
33
+
34
+ @property
35
+ def environment(self) -> str:
36
+ return self._config.environment
37
+
38
+ @property
39
+ def tenant_id(self) -> Optional[str]:
40
+ return self._config.tenant_id
41
+
42
+ def set_access_token(self, token: str) -> None:
43
+ self._http.set_access_token(token)
44
+
45
+ def clear_access_token(self) -> None:
46
+ self._http.set_access_token(None)
47
+
48
+ def close(self) -> None:
49
+ self._http.close()
50
+
51
+ def __enter__(self) -> "HCTenantAdminSDK":
52
+ return self
53
+
54
+ def __exit__(self, *exc: object) -> None:
55
+ self.close()
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "healthcloud-tenantadmin"
7
+ version = "0.1.0"
8
+ description = "HealthCloud Tenant Admin SDK for Python — manage tenant admins and auth."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Healthcheck Systems Inc" }]
13
+ keywords = ["healthcloud", "health-cloud", "tenant-admin", "sdk", "api", "healthcare"]
14
+ dependencies = ["httpx>=0.27.0"]
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["pytest>=8.0", "respx>=0.21", "ruff>=0.6"]
18
+
19
+ [project.urls]
20
+ Repository = "https://github.com/healthcloudservices/healthcloud-sdk"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["healthcloud_tenantadmin"]
24
+
25
+ [tool.ruff]
26
+ line-length = 100
27
+
28
+ [tool.pytest.ini_options]
29
+ markers = [
30
+ "live: live HealthCloud API integration tests, skipped unless HC_RUN_INTEGRATION=true",
31
+ ]
File without changes
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import pytest
5
+ import respx
6
+
7
+ from healthcloud_tenantadmin import HCTenantAdminSDK
8
+
9
+ BASE_URL = "https://dev-api-tenantadmin.health.cloud"
10
+
11
+
12
+ @pytest.fixture
13
+ def sdk() -> HCTenantAdminSDK:
14
+ return HCTenantAdminSDK(environment="dev", tenant_id="acme", access_token="test-token")
15
+
16
+
17
+ @respx.mock
18
+ def test_get_admins_for_tenant_maps_all_fields(sdk: HCTenantAdminSDK) -> None:
19
+ payload = [
20
+ {
21
+ "admin_id": "11111111-1111-1111-1111-111111111111",
22
+ "tenant_id": "acme",
23
+ "email": "a@acme.com",
24
+ "first_name": "Ana",
25
+ "last_name": "Admin",
26
+ "phone": "+15551234567",
27
+ "title": "Owner",
28
+ "cognito_sub": "22222222-2222-2222-2222-222222222222",
29
+ "status": "active",
30
+ "is_primary": True,
31
+ "permissions": ["users:read", "users:write"],
32
+ "created_at": "2026-01-01T00:00:00",
33
+ "updated_at": "2026-01-02T00:00:00",
34
+ "last_login_at": None,
35
+ "created_by": None,
36
+ "invited_by": None,
37
+ }
38
+ ]
39
+ route = respx.get(f"{BASE_URL}/tenant-admins").mock(
40
+ return_value=httpx.Response(200, json=payload)
41
+ )
42
+
43
+ admins = sdk.admin.get_admins_for_tenant()
44
+
45
+ assert route.called
46
+ assert route.calls.last.request.headers["Authorization"] == "Bearer test-token"
47
+ assert len(admins) == 1
48
+ admin = admins[0]
49
+ assert admin.admin_id == payload[0]["admin_id"]
50
+ assert admin.email == "a@acme.com"
51
+ assert admin.cognito_sub == payload[0]["cognito_sub"]
52
+ assert admin.is_primary is True
53
+
54
+
55
+ @respx.mock
56
+ def test_get_admins_for_tenant_empty(sdk: HCTenantAdminSDK) -> None:
57
+ respx.get(f"{BASE_URL}/tenant-admins").mock(return_value=httpx.Response(200, json=[]))
58
+ assert sdk.admin.get_admins_for_tenant() == []
59
+
60
+
61
+ @respx.mock
62
+ def test_get_admins_for_tenant_unauthorized_raises(sdk: HCTenantAdminSDK) -> None:
63
+ from healthcloud_tenantadmin import SDKError
64
+
65
+ respx.get(f"{BASE_URL}/tenant-admins").mock(
66
+ return_value=httpx.Response(401, json={"error": "Authorization required"})
67
+ )
68
+ with pytest.raises(SDKError) as exc_info:
69
+ sdk.admin.get_admins_for_tenant()
70
+ assert exc_info.value.status_code == 401