biznet 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.
Files changed (42) hide show
  1. biznet-0.1.0/.github/workflows/ci.yml +25 -0
  2. biznet-0.1.0/.github/workflows/publish.yml +45 -0
  3. biznet-0.1.0/.gitignore +8 -0
  4. biznet-0.1.0/PKG-INFO +140 -0
  5. biznet-0.1.0/README.md +120 -0
  6. biznet-0.1.0/biznet/__init__.py +31 -0
  7. biznet-0.1.0/biznet/auth.py +22 -0
  8. biznet-0.1.0/biznet/cli.py +157 -0
  9. biznet-0.1.0/biznet/client.py +201 -0
  10. biznet-0.1.0/biznet/exceptions.py +118 -0
  11. biznet-0.1.0/biznet/merchant_node.py +193 -0
  12. biznet-0.1.0/biznet/models/__init__.py +4 -0
  13. biznet-0.1.0/biznet/models/job.py +25 -0
  14. biznet-0.1.0/biznet/models/product.py +21 -0
  15. biznet-0.1.0/biznet/resources/__init__.py +1 -0
  16. biznet-0.1.0/biznet/resources/adapters.py +66 -0
  17. biznet-0.1.0/biznet/resources/chat.py +71 -0
  18. biznet-0.1.0/biznet/resources/deploy.py +93 -0
  19. biznet-0.1.0/biznet/resources/jobs.py +69 -0
  20. biznet-0.1.0/biznet/resources/keys.py +48 -0
  21. biznet-0.1.0/biznet/resources/policies.py +80 -0
  22. biznet-0.1.0/biznet/resources/products.py +115 -0
  23. biznet-0.1.0/biznet/resources/ucp.py +52 -0
  24. biznet-0.1.0/biznet/resources/users.py +40 -0
  25. biznet-0.1.0/biznet/retry.py +43 -0
  26. biznet-0.1.0/biznet/streaming.py +91 -0
  27. biznet-0.1.0/pyproject.toml +42 -0
  28. biznet-0.1.0/tests/conftest.py +26 -0
  29. biznet-0.1.0/tests/test_adapters.py +61 -0
  30. biznet-0.1.0/tests/test_auth.py +45 -0
  31. biznet-0.1.0/tests/test_chat.py +71 -0
  32. biznet-0.1.0/tests/test_cli.py +102 -0
  33. biznet-0.1.0/tests/test_exceptions.py +66 -0
  34. biznet-0.1.0/tests/test_jobs.py +65 -0
  35. biznet-0.1.0/tests/test_keys.py +59 -0
  36. biznet-0.1.0/tests/test_merchant_node.py +116 -0
  37. biznet-0.1.0/tests/test_policies.py +70 -0
  38. biznet-0.1.0/tests/test_products.py +89 -0
  39. biznet-0.1.0/tests/test_retry.py +60 -0
  40. biznet-0.1.0/tests/test_streaming.py +70 -0
  41. biznet-0.1.0/tests/test_ucp_deploy.py +84 -0
  42. biznet-0.1.0/tests/test_users.py +49 -0
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+
21
+ - name: Install package with dev dependencies
22
+ run: pip install -e ".[dev]"
23
+
24
+ - name: Run tests
25
+ run: pytest -q
@@ -0,0 +1,45 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes exactly what a version tag points at. Tests gate the upload:
4
+ # a red suite means no release.
5
+ on:
6
+ push:
7
+ tags:
8
+ - "v*"
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+
20
+ - name: Install package with dev dependencies
21
+ run: pip install -e ".[dev]"
22
+
23
+ - name: Run tests
24
+ run: pytest -q
25
+
26
+ - name: Check tag matches package version
27
+ run: |
28
+ pkg_version=$(python -c "import biznet; print(biznet.__version__)")
29
+ tag_version="${GITHUB_REF_NAME#v}"
30
+ if [ "$pkg_version" != "$tag_version" ]; then
31
+ echo "Tag $GITHUB_REF_NAME does not match biznet.__version__ ($pkg_version)"
32
+ exit 1
33
+ fi
34
+
35
+ - name: Build sdist and wheel
36
+ run: |
37
+ pip install build twine
38
+ python -m build
39
+ twine check dist/*
40
+
41
+ - name: Publish to PyPI
42
+ env:
43
+ TWINE_USERNAME: __token__
44
+ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
45
+ run: twine upload dist/*
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .env
biznet-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: biznet
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the BizNetAI merchant API
5
+ Author: ConsumerGenie Corporation
6
+ License: Proprietary
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: pydantic>=2.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: fastapi>=0.110; extra == 'dev'
13
+ Requires-Dist: mcp>=1.0; extra == 'dev'
14
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: respx>=0.21; extra == 'dev'
17
+ Provides-Extra: mcp
18
+ Requires-Dist: mcp>=1.0; extra == 'mcp'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # biznet
22
+
23
+ Python SDK for the BizNetAI merchant API. Part of ConsumerGenie Corporation.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install biznet
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ from biznet import BizNet
35
+
36
+ client = BizNet(api_key="sk_live_...", base_url="https://<api-id>.execute-api.us-east-2.amazonaws.com")
37
+ ```
38
+
39
+ Both values fall back to environment variables: `BIZNET_API_KEY` and `BIZNET_BASE_URL`.
40
+
41
+ An async client is available as `AsyncBizNet` with the same constructor.
42
+
43
+ Resources available now:
44
+
45
+ ```python
46
+ client.users.me() # GET /v1/users/me
47
+ client.users.status() # GET /v1/users/me/status
48
+ client.users.deactivate() # DELETE /v1/users/me
49
+
50
+ client.keys.issue(env="test", scopes=[]) # POST /v1/keys, returns the secret once
51
+ client.keys.list() # GET /v1/keys
52
+ client.keys.revoke(key_id) # DELETE /v1/keys/{key_id}
53
+
54
+ client.products.create(title, description, price) # POST /v1/products -> Product
55
+ client.products.get(product_id) # GET /v1/products/{id} -> Product
56
+ client.products.delete(product_id) # DELETE, soft delete
57
+ job_id = client.products.bulk_create([{...}]) # POST /v1/products/bulk
58
+
59
+ client.jobs.status(job_id) # GET /v1/jobs/{id} -> Job
60
+ client.jobs.wait(job_id, timeout=300) # poll with backoff until done/failed
61
+
62
+ client.adapters.connect("woocommerce", store_url=..., consumer_key=..., consumer_secret=...)
63
+ job_id = client.adapters.sync("woocommerce") # POST .../sync
64
+ client.adapters.sync_status("woocommerce", job_id) # GET .../sync/status -> Job
65
+
66
+ client.policies.upload("returns.md") # multipart; path or (name, bytes)
67
+ client.policies.list() # GET /v1/rag/policies
68
+ client.policies.delete("returns.md") # DELETE by filename
69
+ ```
70
+
71
+ Chat, UCP, and deploy live on the Merchant-node service (Cloud Run), a separate
72
+ host from the AWS API. Set `node_url` on the client or `BIZNET_NODE_URL`:
73
+
74
+ ```python
75
+ client.chat.complete(session_id, message) # POST {node}/chat-sync
76
+ async for event in aclient.chat.stream(session_id, message):
77
+ ... # typed SSE events
78
+
79
+ client.ucp.configure(slug, business_name, stripe_account_id)
80
+ client.ucp.status()
81
+
82
+ client.deploy.shared() # provision on shared infra (default)
83
+ client.deploy.dedicated() # dedicated Cloud Run services
84
+ client.deploy.status()
85
+ client.deploy.deprovision()
86
+ ```
87
+
88
+ `biznet.resources.deploy.create_ucp_router(api_key)` returns a FastAPI router
89
+ serving `GET /.well-known/ucp` on a merchant's own domain (requires fastapi).
90
+
91
+ ## MerchantNode
92
+
93
+ Run an MCP server on your own infra. Override only what your system supports;
94
+ the 8 operational tools ship with mock defaults, and the 4 UCP checkout tools
95
+ call the BizNet UCP service (set `BIZNET_UCP_URL`). Requires the mcp extra:
96
+ `pip install "biznet[mcp]"`.
97
+
98
+ ```python
99
+ from biznet import MerchantNode
100
+
101
+ class MyStore(MerchantNode):
102
+ async def get_stock_level(self, product_id: str) -> int:
103
+ return my_inventory.count(product_id)
104
+
105
+ MyStore(api_key="sk_live_...").run() # stdio; or run(transport="sse")
106
+ ```
107
+
108
+ ## CLI
109
+
110
+ `biznetai deploy` finds the MerchantNode subclass in the current directory,
111
+ packages it with requirements.txt, and provisions it on BizNet infrastructure:
112
+
113
+ ```bash
114
+ export BIZNET_API_KEY=sk_live_...
115
+ export BIZNET_NODE_URL=https://<merchant-node-host>
116
+ biznetai deploy # shared infra (default)
117
+ biznetai deploy --tier dedicated
118
+ ```
119
+
120
+ It streams provisioning status and prints the service URLs when live.
121
+
122
+ ## Behavior
123
+
124
+ - Every request carries `X-API-Key`. Set `client.api_key` to rotate the key in place.
125
+ - Every request carries an `X-Request-Id` (uuid4). The same id is reused across
126
+ retries and echoed back by the server for tracing.
127
+ - 429 and 5xx responses retry up to 3 attempts with jittered exponential backoff.
128
+ - Non-2xx responses raise typed exceptions mapped from the API error envelope
129
+ `{code, message, request_id}`: `AuthError`, `NotFoundError`, `ValidationError`,
130
+ `RateLimitError`, `InternalError`.
131
+ - SSE chat streams parse into typed `StreamEvent`s: `text`, `products`, `checkout`,
132
+ `tool_call`, `error`, `done`.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ python -m venv .venv && source .venv/bin/activate
138
+ pip install -e ".[dev]"
139
+ pytest
140
+ ```
biznet-0.1.0/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # biznet
2
+
3
+ Python SDK for the BizNetAI merchant API. Part of ConsumerGenie Corporation.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install biznet
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from biznet import BizNet
15
+
16
+ client = BizNet(api_key="sk_live_...", base_url="https://<api-id>.execute-api.us-east-2.amazonaws.com")
17
+ ```
18
+
19
+ Both values fall back to environment variables: `BIZNET_API_KEY` and `BIZNET_BASE_URL`.
20
+
21
+ An async client is available as `AsyncBizNet` with the same constructor.
22
+
23
+ Resources available now:
24
+
25
+ ```python
26
+ client.users.me() # GET /v1/users/me
27
+ client.users.status() # GET /v1/users/me/status
28
+ client.users.deactivate() # DELETE /v1/users/me
29
+
30
+ client.keys.issue(env="test", scopes=[]) # POST /v1/keys, returns the secret once
31
+ client.keys.list() # GET /v1/keys
32
+ client.keys.revoke(key_id) # DELETE /v1/keys/{key_id}
33
+
34
+ client.products.create(title, description, price) # POST /v1/products -> Product
35
+ client.products.get(product_id) # GET /v1/products/{id} -> Product
36
+ client.products.delete(product_id) # DELETE, soft delete
37
+ job_id = client.products.bulk_create([{...}]) # POST /v1/products/bulk
38
+
39
+ client.jobs.status(job_id) # GET /v1/jobs/{id} -> Job
40
+ client.jobs.wait(job_id, timeout=300) # poll with backoff until done/failed
41
+
42
+ client.adapters.connect("woocommerce", store_url=..., consumer_key=..., consumer_secret=...)
43
+ job_id = client.adapters.sync("woocommerce") # POST .../sync
44
+ client.adapters.sync_status("woocommerce", job_id) # GET .../sync/status -> Job
45
+
46
+ client.policies.upload("returns.md") # multipart; path or (name, bytes)
47
+ client.policies.list() # GET /v1/rag/policies
48
+ client.policies.delete("returns.md") # DELETE by filename
49
+ ```
50
+
51
+ Chat, UCP, and deploy live on the Merchant-node service (Cloud Run), a separate
52
+ host from the AWS API. Set `node_url` on the client or `BIZNET_NODE_URL`:
53
+
54
+ ```python
55
+ client.chat.complete(session_id, message) # POST {node}/chat-sync
56
+ async for event in aclient.chat.stream(session_id, message):
57
+ ... # typed SSE events
58
+
59
+ client.ucp.configure(slug, business_name, stripe_account_id)
60
+ client.ucp.status()
61
+
62
+ client.deploy.shared() # provision on shared infra (default)
63
+ client.deploy.dedicated() # dedicated Cloud Run services
64
+ client.deploy.status()
65
+ client.deploy.deprovision()
66
+ ```
67
+
68
+ `biznet.resources.deploy.create_ucp_router(api_key)` returns a FastAPI router
69
+ serving `GET /.well-known/ucp` on a merchant's own domain (requires fastapi).
70
+
71
+ ## MerchantNode
72
+
73
+ Run an MCP server on your own infra. Override only what your system supports;
74
+ the 8 operational tools ship with mock defaults, and the 4 UCP checkout tools
75
+ call the BizNet UCP service (set `BIZNET_UCP_URL`). Requires the mcp extra:
76
+ `pip install "biznet[mcp]"`.
77
+
78
+ ```python
79
+ from biznet import MerchantNode
80
+
81
+ class MyStore(MerchantNode):
82
+ async def get_stock_level(self, product_id: str) -> int:
83
+ return my_inventory.count(product_id)
84
+
85
+ MyStore(api_key="sk_live_...").run() # stdio; or run(transport="sse")
86
+ ```
87
+
88
+ ## CLI
89
+
90
+ `biznetai deploy` finds the MerchantNode subclass in the current directory,
91
+ packages it with requirements.txt, and provisions it on BizNet infrastructure:
92
+
93
+ ```bash
94
+ export BIZNET_API_KEY=sk_live_...
95
+ export BIZNET_NODE_URL=https://<merchant-node-host>
96
+ biznetai deploy # shared infra (default)
97
+ biznetai deploy --tier dedicated
98
+ ```
99
+
100
+ It streams provisioning status and prints the service URLs when live.
101
+
102
+ ## Behavior
103
+
104
+ - Every request carries `X-API-Key`. Set `client.api_key` to rotate the key in place.
105
+ - Every request carries an `X-Request-Id` (uuid4). The same id is reused across
106
+ retries and echoed back by the server for tracing.
107
+ - 429 and 5xx responses retry up to 3 attempts with jittered exponential backoff.
108
+ - Non-2xx responses raise typed exceptions mapped from the API error envelope
109
+ `{code, message, request_id}`: `AuthError`, `NotFoundError`, `ValidationError`,
110
+ `RateLimitError`, `InternalError`.
111
+ - SSE chat streams parse into typed `StreamEvent`s: `text`, `products`, `checkout`,
112
+ `tool_call`, `error`, `done`.
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ python -m venv .venv && source .venv/bin/activate
118
+ pip install -e ".[dev]"
119
+ pytest
120
+ ```
@@ -0,0 +1,31 @@
1
+ # biznet: Python SDK for the BizNetAI merchant API.
2
+ from .client import AsyncBizNet, BizNet
3
+ from .exceptions import (
4
+ AuthError,
5
+ BizNetError,
6
+ InternalError,
7
+ NotFoundError,
8
+ RateLimitError,
9
+ ValidationError,
10
+ )
11
+ from .merchant_node import MerchantNode
12
+ from .models import Job, Product
13
+ from .streaming import StreamEvent
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "AsyncBizNet",
19
+ "BizNet",
20
+ "BizNetError",
21
+ "AuthError",
22
+ "NotFoundError",
23
+ "ValidationError",
24
+ "RateLimitError",
25
+ "InternalError",
26
+ "StreamEvent",
27
+ "Job",
28
+ "MerchantNode",
29
+ "Product",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,22 @@
1
+ # auth.py
2
+ # X-API-Key injection for every request. The key is read through a callable at
3
+ # request time, not captured at construction, so setting client.api_key rotates
4
+ # the key without rebuilding the httpx client or its connection pool.
5
+ from __future__ import annotations
6
+
7
+ from typing import Callable, Generator
8
+
9
+ import httpx
10
+
11
+ API_KEY_HEADER = "X-API-Key"
12
+
13
+
14
+ class ApiKeyAuth(httpx.Auth):
15
+ def __init__(self, get_key: Callable[[], str]):
16
+ self._get_key = get_key
17
+
18
+ def auth_flow(
19
+ self, request: httpx.Request
20
+ ) -> Generator[httpx.Request, httpx.Response, None]:
21
+ request.headers[API_KEY_HEADER] = self._get_key()
22
+ yield request
@@ -0,0 +1,157 @@
1
+ # cli.py
2
+ # `biznetai` console entry point. `biznetai deploy` packages the MerchantNode
3
+ # subclass in the current directory and ships it to the Merchant-node deploy
4
+ # endpoint, then follows provisioning until the service is live.
5
+ from __future__ import annotations
6
+
7
+ import importlib.util
8
+ import io
9
+ import os
10
+ import sys
11
+ import tarfile
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional, Tuple
15
+
16
+ import click
17
+ import httpx
18
+
19
+ from .merchant_node import MerchantNode
20
+
21
+ _TERMINAL_STATUSES = {"running", "ready", "failed", "error"}
22
+ _SKIP_PREFIXES = ("test_", "_", ".")
23
+
24
+
25
+ def discover_merchant_node(
26
+ directory: Path, file: Optional[Path] = None
27
+ ) -> Tuple[Path, str]:
28
+ """Find the MerchantNode subclass to deploy. Returns (module_path, class_name).
29
+
30
+ Importing executes the candidate modules, same as any deploy tool that
31
+ loads user code (uvicorn, gunicorn). Files prefixed test_/_ are skipped.
32
+ """
33
+ candidates = [file] if file else sorted(
34
+ p for p in directory.glob("*.py") if not p.name.startswith(_SKIP_PREFIXES)
35
+ )
36
+ failures = []
37
+ for path in candidates:
38
+ spec = importlib.util.spec_from_file_location(path.stem, path)
39
+ module = importlib.util.module_from_spec(spec)
40
+ try:
41
+ spec.loader.exec_module(module)
42
+ except Exception as e:
43
+ failures.append(f"{path.name}: {e}")
44
+ continue
45
+ for obj in vars(module).values():
46
+ if (
47
+ isinstance(obj, type)
48
+ and issubclass(obj, MerchantNode)
49
+ and obj is not MerchantNode
50
+ ):
51
+ return path, obj.__name__
52
+ detail = f" (import failures: {'; '.join(failures)})" if failures else ""
53
+ raise click.ClickException(
54
+ f"No MerchantNode subclass found in {directory}{detail}. "
55
+ "Create a class that subclasses biznet.MerchantNode, or pass --file."
56
+ )
57
+
58
+
59
+ def package_node(module_path: Path) -> bytes:
60
+ """tar.gz of the node module plus requirements.txt when present."""
61
+ buffer = io.BytesIO()
62
+ with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
63
+ tar.add(module_path, arcname=module_path.name)
64
+ requirements = module_path.parent / "requirements.txt"
65
+ if requirements.exists():
66
+ tar.add(requirements, arcname="requirements.txt")
67
+ return buffer.getvalue()
68
+
69
+
70
+ @click.group()
71
+ @click.version_option(package_name="biznet", prog_name="biznetai")
72
+ def main() -> None:
73
+ """BizNetAI merchant tooling."""
74
+
75
+
76
+ @main.command()
77
+ @click.option(
78
+ "--tier", "deployment_type",
79
+ type=click.Choice(["shared", "dedicated"]), default="shared",
80
+ help="shared (default) or dedicated Cloud Run services.",
81
+ )
82
+ @click.option(
83
+ "--file", "file_path", type=click.Path(exists=True, path_type=Path),
84
+ default=None, help="Module containing the MerchantNode subclass.",
85
+ )
86
+ @click.option("--api-key", envvar="BIZNET_API_KEY", required=True,
87
+ help="Defaults to BIZNET_API_KEY.")
88
+ @click.option("--node-url", envvar="BIZNET_NODE_URL", required=True,
89
+ help="Merchant-node service URL. Defaults to BIZNET_NODE_URL.")
90
+ @click.option("--timeout", default=600.0, show_default=True,
91
+ help="Seconds to wait for provisioning.")
92
+ @click.option("--poll-interval", default=2.0, hidden=True)
93
+ def deploy(
94
+ deployment_type: str,
95
+ file_path: Optional[Path],
96
+ api_key: str,
97
+ node_url: str,
98
+ timeout: float,
99
+ poll_interval: float,
100
+ ) -> None:
101
+ """Package the MerchantNode in this directory and deploy it."""
102
+ node_url = node_url.rstrip("/")
103
+ module_path, class_name = discover_merchant_node(Path.cwd(), file_path)
104
+ click.echo(f"Deploying {class_name} from {module_path.name} ({deployment_type})")
105
+
106
+ package = package_node(module_path)
107
+ headers = {"X-API-Key": api_key}
108
+
109
+ with httpx.Client(timeout=60.0, headers=headers) as http:
110
+ response = http.post(
111
+ f"{node_url}/v1/deploy",
112
+ data={
113
+ "deployment_type": deployment_type,
114
+ "module": module_path.name,
115
+ "node_class": class_name,
116
+ },
117
+ files={"package": (f"{module_path.stem}.tar.gz", package,
118
+ "application/gzip")},
119
+ )
120
+ if response.status_code >= 400:
121
+ raise click.ClickException(
122
+ f"Deploy failed ({response.status_code}): {response.text[:500]}"
123
+ )
124
+ click.echo("Provisioning started.")
125
+
126
+ # Follow provisioning until the service reaches a terminal state.
127
+ deadline = time.monotonic() + timeout
128
+ last_status = None
129
+ while True:
130
+ status_response = http.get(f"{node_url}/v1/deploy/status")
131
+ if status_response.status_code >= 400:
132
+ raise click.ClickException(
133
+ f"Status check failed ({status_response.status_code})."
134
+ )
135
+ payload = status_response.json()
136
+ status = payload.get("status", "unknown")
137
+ if status != last_status:
138
+ click.echo(f" status: {status}")
139
+ last_status = status
140
+ if status in _TERMINAL_STATUSES:
141
+ if status in ("failed", "error"):
142
+ raise click.ClickException(
143
+ payload.get("error") or "Provisioning failed."
144
+ )
145
+ for name, url in (payload.get("service_urls") or {}).items():
146
+ click.echo(f" {name}: {url}")
147
+ click.echo("Deployed.")
148
+ return
149
+ if time.monotonic() >= deadline:
150
+ raise click.ClickException(
151
+ f"Provisioning still '{status}' after {timeout:.0f}s."
152
+ )
153
+ time.sleep(poll_interval)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()