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.
- biznet-0.1.0/.github/workflows/ci.yml +25 -0
- biznet-0.1.0/.github/workflows/publish.yml +45 -0
- biznet-0.1.0/.gitignore +8 -0
- biznet-0.1.0/PKG-INFO +140 -0
- biznet-0.1.0/README.md +120 -0
- biznet-0.1.0/biznet/__init__.py +31 -0
- biznet-0.1.0/biznet/auth.py +22 -0
- biznet-0.1.0/biznet/cli.py +157 -0
- biznet-0.1.0/biznet/client.py +201 -0
- biznet-0.1.0/biznet/exceptions.py +118 -0
- biznet-0.1.0/biznet/merchant_node.py +193 -0
- biznet-0.1.0/biznet/models/__init__.py +4 -0
- biznet-0.1.0/biznet/models/job.py +25 -0
- biznet-0.1.0/biznet/models/product.py +21 -0
- biznet-0.1.0/biznet/resources/__init__.py +1 -0
- biznet-0.1.0/biznet/resources/adapters.py +66 -0
- biznet-0.1.0/biznet/resources/chat.py +71 -0
- biznet-0.1.0/biznet/resources/deploy.py +93 -0
- biznet-0.1.0/biznet/resources/jobs.py +69 -0
- biznet-0.1.0/biznet/resources/keys.py +48 -0
- biznet-0.1.0/biznet/resources/policies.py +80 -0
- biznet-0.1.0/biznet/resources/products.py +115 -0
- biznet-0.1.0/biznet/resources/ucp.py +52 -0
- biznet-0.1.0/biznet/resources/users.py +40 -0
- biznet-0.1.0/biznet/retry.py +43 -0
- biznet-0.1.0/biznet/streaming.py +91 -0
- biznet-0.1.0/pyproject.toml +42 -0
- biznet-0.1.0/tests/conftest.py +26 -0
- biznet-0.1.0/tests/test_adapters.py +61 -0
- biznet-0.1.0/tests/test_auth.py +45 -0
- biznet-0.1.0/tests/test_chat.py +71 -0
- biznet-0.1.0/tests/test_cli.py +102 -0
- biznet-0.1.0/tests/test_exceptions.py +66 -0
- biznet-0.1.0/tests/test_jobs.py +65 -0
- biznet-0.1.0/tests/test_keys.py +59 -0
- biznet-0.1.0/tests/test_merchant_node.py +116 -0
- biznet-0.1.0/tests/test_policies.py +70 -0
- biznet-0.1.0/tests/test_products.py +89 -0
- biznet-0.1.0/tests/test_retry.py +60 -0
- biznet-0.1.0/tests/test_streaming.py +70 -0
- biznet-0.1.0/tests/test_ucp_deploy.py +84 -0
- 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/*
|
biznet-0.1.0/.gitignore
ADDED
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()
|