alloy-cx 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.
- alloy_cx-0.1.0/LICENSE +21 -0
- alloy_cx-0.1.0/PKG-INFO +98 -0
- alloy_cx-0.1.0/README.md +82 -0
- alloy_cx-0.1.0/pyproject.toml +28 -0
- alloy_cx-0.1.0/setup.cfg +4 -0
- alloy_cx-0.1.0/src/alloy_cx/__init__.py +35 -0
- alloy_cx-0.1.0/src/alloy_cx/_http.py +120 -0
- alloy_cx-0.1.0/src/alloy_cx/databases.py +102 -0
- alloy_cx-0.1.0/src/alloy_cx/py.typed +0 -0
- alloy_cx-0.1.0/src/alloy_cx/storage.py +47 -0
- alloy_cx-0.1.0/src/alloy_cx.egg-info/PKG-INFO +98 -0
- alloy_cx-0.1.0/src/alloy_cx.egg-info/SOURCES.txt +13 -0
- alloy_cx-0.1.0/src/alloy_cx.egg-info/dependency_links.txt +1 -0
- alloy_cx-0.1.0/src/alloy_cx.egg-info/top_level.txt +1 -0
- alloy_cx-0.1.0/tests/test_client.py +130 -0
alloy_cx-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alloy Systems
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
alloy_cx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alloy-cx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for the Alloy private API: organization storage and databases
|
|
5
|
+
Author: Alloy Systems
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://alloy.cx
|
|
8
|
+
Project-URL: Repository, https://github.com/Alloy-Systems/backend
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# alloy-cx
|
|
18
|
+
|
|
19
|
+
Python client for the Alloy private API: organization storage and storage databases. No dependencies beyond the
|
|
20
|
+
standard library; Python 3.9+.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install alloy-cx
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Authentication
|
|
27
|
+
|
|
28
|
+
Code that Alloy runs for a user (the `run_code` workflow step, the `programming.run_code` tool, and commands in an
|
|
29
|
+
AI teammate's sandbox) gets `ALLOY_API_URL` and a temporary `ALLOY_API_TOKEN` with that user's rights in its
|
|
30
|
+
environment, so the client needs no arguments there:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from alloy_cx import AlloyClient
|
|
34
|
+
|
|
35
|
+
client = AlloyClient()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Elsewhere, pass a personal access token from Organization Settings → API Tokens:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
client = AlloyClient(token="alloy_pat_...", base_url="https://api.alloy.cx/api/private")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Never print the token or write it to a file.
|
|
45
|
+
|
|
46
|
+
## Storage
|
|
47
|
+
|
|
48
|
+
Paths are relative to the storage root.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
client.storage.list("reports") # direct children of a folder
|
|
52
|
+
client.storage.upload("summary.csv", "a,b\n1,2\n", folder="reports")
|
|
53
|
+
client.storage.meta("reports/summary.csv") # size, hash, timestamps, MIME type
|
|
54
|
+
client.storage.download("reports/summary.csv") # bytes
|
|
55
|
+
client.storage.download_text("reports/summary.csv") # str
|
|
56
|
+
client.storage.create_folder("2026", parent="reports") # returns the new folder's path
|
|
57
|
+
client.storage.rename("reports/summary.csv", "q1.csv") # returns the new path
|
|
58
|
+
client.storage.move("reports/q1.csv", "archive") # moves into the folder, returns the new path
|
|
59
|
+
client.storage.delete("archive/q1.csv")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Databases
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
client.databases.list()
|
|
66
|
+
client.databases.tables("crm")
|
|
67
|
+
client.databases.table("crm", "deals") # schema
|
|
68
|
+
|
|
69
|
+
page = client.databases.rows(
|
|
70
|
+
"crm", "deals",
|
|
71
|
+
limit=50, order_by="amount", direction="DESC",
|
|
72
|
+
filters=[{"column": "stage", "operator": "equals", "value": "won"}],
|
|
73
|
+
)
|
|
74
|
+
page["rows"], page["total"] # rows: [{"id", "version", "values"}]
|
|
75
|
+
|
|
76
|
+
ids = client.databases.insert("crm", "deals", [{"title": "Acme", "amount": 1200}])
|
|
77
|
+
row = client.databases.row("crm", "deals", ids[0])
|
|
78
|
+
client.databases.update("crm", "deals", row["id"], {"amount": 1500}, expected_version=row["version"])
|
|
79
|
+
client.databases.update_many("crm", "deals", [{"id": ids[0], "values": {"stage": "won"}}])
|
|
80
|
+
client.databases.delete("crm", "deals", ids)
|
|
81
|
+
|
|
82
|
+
client.databases.query("crm", "SELECT stage, SUM(amount) FROM deals WHERE amount > ? GROUP BY stage", [100])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Filter operators: `equals`, `not_equals`, `contains`, `starts_with`, `ends_with`, `greater_than`, `greater_or_equal`,
|
|
86
|
+
`less_than`, `less_or_equal`, `is_empty`, `is_not_empty`. `match="any"` returns rows matching any filter instead of
|
|
87
|
+
all of them. `query` accepts only `SELECT` and `WITH ... SELECT`.
|
|
88
|
+
|
|
89
|
+
## Errors
|
|
90
|
+
|
|
91
|
+
A failed request raises `AlloyApiError` with `status`, `message`, and the response `body`. With `expected_version`,
|
|
92
|
+
an update of a row changed since that version fails with status `409`.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
PYTHONPATH=src python3 -m unittest discover -s tests
|
|
98
|
+
```
|
alloy_cx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# alloy-cx
|
|
2
|
+
|
|
3
|
+
Python client for the Alloy private API: organization storage and storage databases. No dependencies beyond the
|
|
4
|
+
standard library; Python 3.9+.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install alloy-cx
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Authentication
|
|
11
|
+
|
|
12
|
+
Code that Alloy runs for a user (the `run_code` workflow step, the `programming.run_code` tool, and commands in an
|
|
13
|
+
AI teammate's sandbox) gets `ALLOY_API_URL` and a temporary `ALLOY_API_TOKEN` with that user's rights in its
|
|
14
|
+
environment, so the client needs no arguments there:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from alloy_cx import AlloyClient
|
|
18
|
+
|
|
19
|
+
client = AlloyClient()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Elsewhere, pass a personal access token from Organization Settings → API Tokens:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
client = AlloyClient(token="alloy_pat_...", base_url="https://api.alloy.cx/api/private")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Never print the token or write it to a file.
|
|
29
|
+
|
|
30
|
+
## Storage
|
|
31
|
+
|
|
32
|
+
Paths are relative to the storage root.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
client.storage.list("reports") # direct children of a folder
|
|
36
|
+
client.storage.upload("summary.csv", "a,b\n1,2\n", folder="reports")
|
|
37
|
+
client.storage.meta("reports/summary.csv") # size, hash, timestamps, MIME type
|
|
38
|
+
client.storage.download("reports/summary.csv") # bytes
|
|
39
|
+
client.storage.download_text("reports/summary.csv") # str
|
|
40
|
+
client.storage.create_folder("2026", parent="reports") # returns the new folder's path
|
|
41
|
+
client.storage.rename("reports/summary.csv", "q1.csv") # returns the new path
|
|
42
|
+
client.storage.move("reports/q1.csv", "archive") # moves into the folder, returns the new path
|
|
43
|
+
client.storage.delete("archive/q1.csv")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Databases
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
client.databases.list()
|
|
50
|
+
client.databases.tables("crm")
|
|
51
|
+
client.databases.table("crm", "deals") # schema
|
|
52
|
+
|
|
53
|
+
page = client.databases.rows(
|
|
54
|
+
"crm", "deals",
|
|
55
|
+
limit=50, order_by="amount", direction="DESC",
|
|
56
|
+
filters=[{"column": "stage", "operator": "equals", "value": "won"}],
|
|
57
|
+
)
|
|
58
|
+
page["rows"], page["total"] # rows: [{"id", "version", "values"}]
|
|
59
|
+
|
|
60
|
+
ids = client.databases.insert("crm", "deals", [{"title": "Acme", "amount": 1200}])
|
|
61
|
+
row = client.databases.row("crm", "deals", ids[0])
|
|
62
|
+
client.databases.update("crm", "deals", row["id"], {"amount": 1500}, expected_version=row["version"])
|
|
63
|
+
client.databases.update_many("crm", "deals", [{"id": ids[0], "values": {"stage": "won"}}])
|
|
64
|
+
client.databases.delete("crm", "deals", ids)
|
|
65
|
+
|
|
66
|
+
client.databases.query("crm", "SELECT stage, SUM(amount) FROM deals WHERE amount > ? GROUP BY stage", [100])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Filter operators: `equals`, `not_equals`, `contains`, `starts_with`, `ends_with`, `greater_than`, `greater_or_equal`,
|
|
70
|
+
`less_than`, `less_or_equal`, `is_empty`, `is_not_empty`. `match="any"` returns rows matching any filter instead of
|
|
71
|
+
all of them. `query` accepts only `SELECT` and `WITH ... SELECT`.
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
A failed request raises `AlloyApiError` with `status`, `message`, and the response `body`. With `expected_version`,
|
|
76
|
+
an update of a row changed since that version fails with status `409`.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
PYTHONPATH=src python3 -m unittest discover -s tests
|
|
82
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "alloy-cx"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Client for the Alloy private API: organization storage and databases"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Alloy Systems" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://alloy.cx"
|
|
22
|
+
Repository = "https://github.com/Alloy-Systems/backend"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
alloy_cx = ["py.typed"]
|
alloy_cx-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Client for the Alloy private API: organization storage and databases."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ._http import DEFAULT_TIMEOUT_SECONDS, AlloyApiError, HttpClient
|
|
7
|
+
from .databases import DatabasesApi
|
|
8
|
+
from .storage import StorageApi
|
|
9
|
+
|
|
10
|
+
__all__ = ["AlloyApiError", "AlloyClient", "DatabasesApi", "StorageApi"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AlloyClient:
|
|
14
|
+
"""Client for the Alloy private API.
|
|
15
|
+
|
|
16
|
+
Inside an Alloy sandbox it needs no arguments: the token and URL come from ``ALLOY_API_TOKEN``
|
|
17
|
+
and ``ALLOY_API_URL``, and every call runs with the rights of the user the code runs for.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
token: Optional[str] = None,
|
|
23
|
+
base_url: Optional[str] = None,
|
|
24
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
25
|
+
) -> None:
|
|
26
|
+
token = token or os.environ.get("ALLOY_API_TOKEN")
|
|
27
|
+
base_url = base_url or os.environ.get("ALLOY_API_URL")
|
|
28
|
+
if not token or not base_url:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"Alloy API token and URL are required: pass token and base_url, or set ALLOY_API_TOKEN and ALLOY_API_URL"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
http = HttpClient(base_url, token, timeout)
|
|
34
|
+
self.storage = StorageApi(http)
|
|
35
|
+
self.databases = DatabasesApi(http)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Any, Dict, Mapping, Optional, Tuple, Union
|
|
4
|
+
from urllib.error import HTTPError
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
from urllib.request import Request, urlopen
|
|
7
|
+
|
|
8
|
+
QueryValue = Union[str, int, bool, None]
|
|
9
|
+
|
|
10
|
+
DEFAULT_TIMEOUT_SECONDS = 60
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AlloyApiError(Exception):
|
|
14
|
+
"""A non-2xx answer from the API. ``body`` is the parsed response, or its text when it is not JSON."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status: int, message: str, body: Any) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status = status
|
|
19
|
+
self.message = message
|
|
20
|
+
self.body = body
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse(raw: bytes) -> Any:
|
|
24
|
+
text = raw.decode("utf-8", errors="replace")
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(text)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _error_message(body: Any, status: int) -> str:
|
|
32
|
+
"""Reads the message out of either error shape the API answers with."""
|
|
33
|
+
if isinstance(body, dict):
|
|
34
|
+
if isinstance(body.get("message"), str):
|
|
35
|
+
return body["message"]
|
|
36
|
+
error = body.get("error")
|
|
37
|
+
if isinstance(error, str):
|
|
38
|
+
return error
|
|
39
|
+
if isinstance(error, dict) and isinstance(error.get("message"), str):
|
|
40
|
+
return error["message"]
|
|
41
|
+
if isinstance(body, str) and body:
|
|
42
|
+
return body
|
|
43
|
+
return f"Request failed with status {status}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _multipart(fields: Mapping[str, str], file_field: str, file_name: str, content: bytes) -> Tuple[bytes, str]:
|
|
47
|
+
boundary = uuid.uuid4().hex
|
|
48
|
+
# Quotes and line breaks would end the header value early; browsers percent-encode them the same way.
|
|
49
|
+
file_name = file_name.replace('"', "%22").replace("\r", "%0D").replace("\n", "%0A")
|
|
50
|
+
parts = []
|
|
51
|
+
for name, value in fields.items():
|
|
52
|
+
parts.append(
|
|
53
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode("utf-8")
|
|
54
|
+
)
|
|
55
|
+
parts.append(
|
|
56
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; filename="{file_name}"\r\n'
|
|
57
|
+
"Content-Type: application/octet-stream\r\n\r\n".encode("utf-8")
|
|
58
|
+
+ content
|
|
59
|
+
+ b"\r\n"
|
|
60
|
+
)
|
|
61
|
+
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
|
62
|
+
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HttpClient:
|
|
66
|
+
def __init__(self, base_url: str, token: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
|
67
|
+
self._base_url = base_url.rstrip("/")
|
|
68
|
+
self._token = token
|
|
69
|
+
self._timeout = timeout
|
|
70
|
+
|
|
71
|
+
def request(
|
|
72
|
+
self,
|
|
73
|
+
method: str,
|
|
74
|
+
path: str,
|
|
75
|
+
query: Optional[Mapping[str, QueryValue]] = None,
|
|
76
|
+
json_body: Any = None,
|
|
77
|
+
upload: Optional[Tuple[Mapping[str, str], str, bytes]] = None,
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Sends a request and returns the ``data`` of the JSON envelope."""
|
|
80
|
+
body = _parse(self._send(method, path, query, json_body, upload))
|
|
81
|
+
return body.get("data") if isinstance(body, dict) else None
|
|
82
|
+
|
|
83
|
+
def request_bytes(self, method: str, path: str, query: Optional[Mapping[str, QueryValue]] = None) -> bytes:
|
|
84
|
+
"""Sends a request and returns the raw response body."""
|
|
85
|
+
return self._send(method, path, query, None, None)
|
|
86
|
+
|
|
87
|
+
def _send(
|
|
88
|
+
self,
|
|
89
|
+
method: str,
|
|
90
|
+
path: str,
|
|
91
|
+
query: Optional[Mapping[str, QueryValue]],
|
|
92
|
+
json_body: Any,
|
|
93
|
+
upload: Optional[Tuple[Mapping[str, str], str, bytes]],
|
|
94
|
+
) -> bytes:
|
|
95
|
+
url = self._base_url + path
|
|
96
|
+
params = {name: _query_value(value) for name, value in (query or {}).items() if value is not None}
|
|
97
|
+
if params:
|
|
98
|
+
url += "?" + urlencode(params)
|
|
99
|
+
|
|
100
|
+
headers: Dict[str, str] = {"Authorization": f"Bearer {self._token}"}
|
|
101
|
+
data: Optional[bytes] = None
|
|
102
|
+
if upload is not None:
|
|
103
|
+
fields, file_name, content = upload
|
|
104
|
+
data, headers["Content-Type"] = _multipart(fields, "file", file_name, content)
|
|
105
|
+
elif json_body is not None:
|
|
106
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
107
|
+
headers["Content-Type"] = "application/json"
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with urlopen(Request(url, data=data, headers=headers, method=method), timeout=self._timeout) as response:
|
|
111
|
+
return response.read()
|
|
112
|
+
except HTTPError as error:
|
|
113
|
+
body = _parse(error.read())
|
|
114
|
+
raise AlloyApiError(error.code, _error_message(body, error.code), body) from None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _query_value(value: Union[str, int, bool]) -> str:
|
|
118
|
+
if isinstance(value, bool):
|
|
119
|
+
return "true" if value else "false"
|
|
120
|
+
return str(value)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
from ._http import HttpClient
|
|
6
|
+
|
|
7
|
+
CellValue = Union[str, int, float, bool, None]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _segment(value: str) -> str:
|
|
11
|
+
return quote(value, safe="")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _table_path(database: str, table: str) -> str:
|
|
15
|
+
return f"/databases/{_segment(database)}/tables/{_segment(table)}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DatabasesApi:
|
|
19
|
+
"""The organization's storage databases: tables, rows, and read-only SQL."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, http: HttpClient) -> None:
|
|
22
|
+
self._http = http
|
|
23
|
+
|
|
24
|
+
def list(self) -> List[Dict[str, Any]]:
|
|
25
|
+
return self._http.request("GET", "/databases")["databases"]
|
|
26
|
+
|
|
27
|
+
def tables(self, database: str) -> List[Dict[str, Any]]:
|
|
28
|
+
return self._http.request("GET", f"/databases/{_segment(database)}/tables")["tables"]
|
|
29
|
+
|
|
30
|
+
def table(self, database: str, table: str) -> Dict[str, Any]:
|
|
31
|
+
return self._http.request("GET", _table_path(database, table))["table"]
|
|
32
|
+
|
|
33
|
+
def rows(
|
|
34
|
+
self,
|
|
35
|
+
database: str,
|
|
36
|
+
table: str,
|
|
37
|
+
limit: Optional[int] = None,
|
|
38
|
+
offset: Optional[int] = None,
|
|
39
|
+
order_by: Optional[str] = None,
|
|
40
|
+
direction: Optional[str] = None,
|
|
41
|
+
match: Optional[str] = None,
|
|
42
|
+
filters: Optional[Sequence[Mapping[str, CellValue]]] = None,
|
|
43
|
+
) -> Dict[str, Any]:
|
|
44
|
+
"""Returns ``{"rows": [...], "total": n}``; ``total`` counts every row matching the filters.
|
|
45
|
+
|
|
46
|
+
Each filter is ``{"column": ..., "operator": ..., "value": ...}`` with an operator such as
|
|
47
|
+
``equals``, ``contains`` or ``greater_than``; ``match`` is ``"all"`` (the default) or ``"any"``.
|
|
48
|
+
"""
|
|
49
|
+
return self._http.request(
|
|
50
|
+
"GET",
|
|
51
|
+
f"{_table_path(database, table)}/rows",
|
|
52
|
+
query={
|
|
53
|
+
"limit": limit,
|
|
54
|
+
"offset": offset,
|
|
55
|
+
"order_by": order_by,
|
|
56
|
+
"direction": direction,
|
|
57
|
+
"match": match,
|
|
58
|
+
"filters": None if filters is None else json.dumps(list(filters)),
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def row(self, database: str, table: str, row_id: int) -> Dict[str, Any]:
|
|
63
|
+
return self._http.request("GET", f"{_table_path(database, table)}/rows/{row_id}")["row"]
|
|
64
|
+
|
|
65
|
+
def insert(self, database: str, table: str, rows: Sequence[Mapping[str, CellValue]]) -> List[int]:
|
|
66
|
+
"""Inserts rows and returns their ids, in order."""
|
|
67
|
+
body = {"rows": [dict(row) for row in rows]}
|
|
68
|
+
return self._http.request("POST", f"{_table_path(database, table)}/rows", json_body=body)["ids"]
|
|
69
|
+
|
|
70
|
+
def update(
|
|
71
|
+
self,
|
|
72
|
+
database: str,
|
|
73
|
+
table: str,
|
|
74
|
+
row_id: int,
|
|
75
|
+
values: Mapping[str, CellValue],
|
|
76
|
+
expected_version: Optional[int] = None,
|
|
77
|
+
) -> bool:
|
|
78
|
+
"""Updates one row. With ``expected_version`` it fails with a 409 when the row changed since; otherwise the last write wins."""
|
|
79
|
+
body: Dict[str, Any] = {"values": dict(values)}
|
|
80
|
+
if expected_version is not None:
|
|
81
|
+
body["expected_version"] = expected_version
|
|
82
|
+
return self._http.request("PATCH", f"{_table_path(database, table)}/rows/{row_id}", json_body=body)["updated"]
|
|
83
|
+
|
|
84
|
+
def update_many(self, database: str, table: str, updates: Sequence[Mapping[str, Any]]) -> int:
|
|
85
|
+
"""Updates several rows, all or none, and returns how many changed.
|
|
86
|
+
|
|
87
|
+
Each update is ``{"id": ..., "values": {...}}``, optionally with ``"expected_version"``.
|
|
88
|
+
"""
|
|
89
|
+
body = {"updates": [dict(update) for update in updates]}
|
|
90
|
+
return self._http.request("PATCH", f"{_table_path(database, table)}/rows", json_body=body)["updated"]
|
|
91
|
+
|
|
92
|
+
def delete(self, database: str, table: str, ids: Sequence[int]) -> int:
|
|
93
|
+
"""Deletes rows by id and returns how many were deleted."""
|
|
94
|
+
return self._http.request("DELETE", f"{_table_path(database, table)}/rows", json_body={"ids": list(ids)})["deleted"]
|
|
95
|
+
|
|
96
|
+
def query(self, database: str, sql: str, params: Sequence[CellValue] = ()) -> Dict[str, Any]:
|
|
97
|
+
"""Runs a read-only ``SELECT`` (or ``WITH ... SELECT``) with ``?`` placeholders bound to ``params``.
|
|
98
|
+
|
|
99
|
+
Returns ``{"columns": [...], "rows": [[...]], "truncated": bool}``.
|
|
100
|
+
"""
|
|
101
|
+
body = {"sql": sql, "params": list(params)}
|
|
102
|
+
return self._http.request("POST", f"/databases/{_segment(database)}/query", json_body=body)
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Union
|
|
2
|
+
|
|
3
|
+
from ._http import HttpClient
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StorageApi:
|
|
7
|
+
"""Organization storage, with the rights of the token's user. Paths are relative to the storage root."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, http: HttpClient) -> None:
|
|
10
|
+
self._http = http
|
|
11
|
+
|
|
12
|
+
def list(self, path: str = "/") -> List[Dict[str, Any]]:
|
|
13
|
+
"""Lists the direct children of a folder; the root by default."""
|
|
14
|
+
return self._http.request("GET", "/storage", query={"path": path})
|
|
15
|
+
|
|
16
|
+
def upload(self, name: str, content: Union[bytes, str], folder: Optional[str] = None) -> Dict[str, Any]:
|
|
17
|
+
"""Uploads a file named ``name`` into ``folder`` (the root by default), overwriting a file of the same name."""
|
|
18
|
+
data = content.encode("utf-8") if isinstance(content, str) else content
|
|
19
|
+
fields = {} if folder is None else {"path": folder}
|
|
20
|
+
return self._http.request("POST", "/storage/upload", upload=(fields, name, data))
|
|
21
|
+
|
|
22
|
+
def meta(self, path: str) -> Dict[str, Any]:
|
|
23
|
+
return self._http.request("GET", "/storage/meta", query={"path": path})
|
|
24
|
+
|
|
25
|
+
def download(self, path: str) -> bytes:
|
|
26
|
+
return self._http.request_bytes("GET", "/storage/download", query={"path": path})
|
|
27
|
+
|
|
28
|
+
def download_text(self, path: str, encoding: str = "utf-8") -> str:
|
|
29
|
+
return self.download(path).decode(encoding)
|
|
30
|
+
|
|
31
|
+
def delete(self, path: str) -> None:
|
|
32
|
+
"""Deletes a file or a folder with everything in it."""
|
|
33
|
+
self._http.request("DELETE", "/storage/file", query={"path": path})
|
|
34
|
+
|
|
35
|
+
def create_folder(self, name: str, parent: Optional[str] = None) -> str:
|
|
36
|
+
"""Creates folder ``name`` inside ``parent`` (the root by default) and returns its path."""
|
|
37
|
+
body = {"name": name} if parent is None else {"name": name, "path": parent}
|
|
38
|
+
return self._http.request("POST", "/storage/folder", json_body=body)["path"]
|
|
39
|
+
|
|
40
|
+
def rename(self, path: str, new_name: str) -> str:
|
|
41
|
+
"""Renames a file or folder in place and returns its new path."""
|
|
42
|
+
return self._http.request("POST", "/storage/rename", json_body={"path": path, "new_name": new_name})["path"]
|
|
43
|
+
|
|
44
|
+
def move(self, path: str, destination: str) -> str:
|
|
45
|
+
"""Moves a file or folder into the folder ``destination`` and returns its new path."""
|
|
46
|
+
body = {"source_path": path, "dest_path": destination}
|
|
47
|
+
return self._http.request("POST", "/storage/move", json_body=body)["path"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alloy-cx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for the Alloy private API: organization storage and databases
|
|
5
|
+
Author: Alloy Systems
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://alloy.cx
|
|
8
|
+
Project-URL: Repository, https://github.com/Alloy-Systems/backend
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# alloy-cx
|
|
18
|
+
|
|
19
|
+
Python client for the Alloy private API: organization storage and storage databases. No dependencies beyond the
|
|
20
|
+
standard library; Python 3.9+.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install alloy-cx
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Authentication
|
|
27
|
+
|
|
28
|
+
Code that Alloy runs for a user (the `run_code` workflow step, the `programming.run_code` tool, and commands in an
|
|
29
|
+
AI teammate's sandbox) gets `ALLOY_API_URL` and a temporary `ALLOY_API_TOKEN` with that user's rights in its
|
|
30
|
+
environment, so the client needs no arguments there:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from alloy_cx import AlloyClient
|
|
34
|
+
|
|
35
|
+
client = AlloyClient()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Elsewhere, pass a personal access token from Organization Settings → API Tokens:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
client = AlloyClient(token="alloy_pat_...", base_url="https://api.alloy.cx/api/private")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Never print the token or write it to a file.
|
|
45
|
+
|
|
46
|
+
## Storage
|
|
47
|
+
|
|
48
|
+
Paths are relative to the storage root.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
client.storage.list("reports") # direct children of a folder
|
|
52
|
+
client.storage.upload("summary.csv", "a,b\n1,2\n", folder="reports")
|
|
53
|
+
client.storage.meta("reports/summary.csv") # size, hash, timestamps, MIME type
|
|
54
|
+
client.storage.download("reports/summary.csv") # bytes
|
|
55
|
+
client.storage.download_text("reports/summary.csv") # str
|
|
56
|
+
client.storage.create_folder("2026", parent="reports") # returns the new folder's path
|
|
57
|
+
client.storage.rename("reports/summary.csv", "q1.csv") # returns the new path
|
|
58
|
+
client.storage.move("reports/q1.csv", "archive") # moves into the folder, returns the new path
|
|
59
|
+
client.storage.delete("archive/q1.csv")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Databases
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
client.databases.list()
|
|
66
|
+
client.databases.tables("crm")
|
|
67
|
+
client.databases.table("crm", "deals") # schema
|
|
68
|
+
|
|
69
|
+
page = client.databases.rows(
|
|
70
|
+
"crm", "deals",
|
|
71
|
+
limit=50, order_by="amount", direction="DESC",
|
|
72
|
+
filters=[{"column": "stage", "operator": "equals", "value": "won"}],
|
|
73
|
+
)
|
|
74
|
+
page["rows"], page["total"] # rows: [{"id", "version", "values"}]
|
|
75
|
+
|
|
76
|
+
ids = client.databases.insert("crm", "deals", [{"title": "Acme", "amount": 1200}])
|
|
77
|
+
row = client.databases.row("crm", "deals", ids[0])
|
|
78
|
+
client.databases.update("crm", "deals", row["id"], {"amount": 1500}, expected_version=row["version"])
|
|
79
|
+
client.databases.update_many("crm", "deals", [{"id": ids[0], "values": {"stage": "won"}}])
|
|
80
|
+
client.databases.delete("crm", "deals", ids)
|
|
81
|
+
|
|
82
|
+
client.databases.query("crm", "SELECT stage, SUM(amount) FROM deals WHERE amount > ? GROUP BY stage", [100])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Filter operators: `equals`, `not_equals`, `contains`, `starts_with`, `ends_with`, `greater_than`, `greater_or_equal`,
|
|
86
|
+
`less_than`, `less_or_equal`, `is_empty`, `is_not_empty`. `match="any"` returns rows matching any filter instead of
|
|
87
|
+
all of them. `query` accepts only `SELECT` and `WITH ... SELECT`.
|
|
88
|
+
|
|
89
|
+
## Errors
|
|
90
|
+
|
|
91
|
+
A failed request raises `AlloyApiError` with `status`, `message`, and the response `body`. With `expected_version`,
|
|
92
|
+
an update of a row changed since that version fails with status `409`.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
PYTHONPATH=src python3 -m unittest discover -s tests
|
|
98
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/alloy_cx/__init__.py
|
|
5
|
+
src/alloy_cx/_http.py
|
|
6
|
+
src/alloy_cx/databases.py
|
|
7
|
+
src/alloy_cx/py.typed
|
|
8
|
+
src/alloy_cx/storage.py
|
|
9
|
+
src/alloy_cx.egg-info/PKG-INFO
|
|
10
|
+
src/alloy_cx.egg-info/SOURCES.txt
|
|
11
|
+
src/alloy_cx.egg-info/dependency_links.txt
|
|
12
|
+
src/alloy_cx.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
alloy_cx
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import unittest
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from typing import Any, Callable, Dict, List
|
|
7
|
+
from unittest import mock
|
|
8
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
9
|
+
|
|
10
|
+
from alloy_cx import AlloyApiError, AlloyClient
|
|
11
|
+
|
|
12
|
+
Respond = Callable[[Dict[str, Any]], Dict[str, Any]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ok(data: Any) -> Dict[str, Any]:
|
|
16
|
+
return {"json": {"success": True, "data": data}}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StubApi:
|
|
20
|
+
"""A stand-in for the private API: records each request and answers with what ``respond`` returns."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, respond: Respond) -> None:
|
|
23
|
+
self.requests: List[Dict[str, Any]] = []
|
|
24
|
+
api = self
|
|
25
|
+
|
|
26
|
+
class Handler(BaseHTTPRequestHandler):
|
|
27
|
+
def handle_one(self) -> None:
|
|
28
|
+
url = urlsplit(self.path)
|
|
29
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
30
|
+
request = {
|
|
31
|
+
"method": self.command,
|
|
32
|
+
"path": url.path,
|
|
33
|
+
"query": dict(parse_qsl(url.query)),
|
|
34
|
+
"headers": {name.lower(): value for name, value in self.headers.items()},
|
|
35
|
+
"body": self.rfile.read(length),
|
|
36
|
+
}
|
|
37
|
+
api.requests.append(request)
|
|
38
|
+
answer = respond(request)
|
|
39
|
+
raw = answer["raw"] if "raw" in answer else json.dumps(answer["json"]).encode("utf-8")
|
|
40
|
+
self.send_response(answer.get("status", 200))
|
|
41
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
42
|
+
self.end_headers()
|
|
43
|
+
self.wfile.write(raw)
|
|
44
|
+
|
|
45
|
+
do_GET = do_POST = do_PATCH = do_DELETE = handle_one
|
|
46
|
+
|
|
47
|
+
def log_message(self, *args: Any) -> None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
51
|
+
threading.Thread(target=self._server.serve_forever, daemon=True).start()
|
|
52
|
+
self.base_url = f"http://127.0.0.1:{self._server.server_address[1]}/api/private/"
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
self._server.shutdown()
|
|
56
|
+
self._server.server_close()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AlloyClientTest(unittest.TestCase):
|
|
60
|
+
def start(self, respond: Respond) -> StubApi:
|
|
61
|
+
api = StubApi(respond)
|
|
62
|
+
self.addCleanup(api.close)
|
|
63
|
+
return api
|
|
64
|
+
|
|
65
|
+
def client(self, api: StubApi) -> AlloyClient:
|
|
66
|
+
return AlloyClient(token="alloy_tmp_secret", base_url=api.base_url)
|
|
67
|
+
|
|
68
|
+
def test_takes_the_token_and_url_from_the_environment_and_sends_a_bearer_token(self) -> None:
|
|
69
|
+
api = self.start(lambda request: ok([]))
|
|
70
|
+
|
|
71
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
72
|
+
with self.assertRaisesRegex(ValueError, "ALLOY_API_TOKEN and ALLOY_API_URL"):
|
|
73
|
+
AlloyClient()
|
|
74
|
+
|
|
75
|
+
with mock.patch.dict(os.environ, {"ALLOY_API_TOKEN": "alloy_tmp_secret", "ALLOY_API_URL": api.base_url}):
|
|
76
|
+
AlloyClient().storage.list("reports")
|
|
77
|
+
|
|
78
|
+
request = api.requests[0]
|
|
79
|
+
self.assertEqual(request["headers"]["authorization"], "Bearer alloy_tmp_secret")
|
|
80
|
+
self.assertEqual(request["path"], "/api/private/storage")
|
|
81
|
+
self.assertEqual(request["query"], {"path": "reports"})
|
|
82
|
+
|
|
83
|
+
def test_storage_uploads_a_file_as_multipart_and_downloads_it_back(self) -> None:
|
|
84
|
+
uploaded = {"path": "reports/data.csv", "name": "data.csv", "size": 8, "mimetype": "text/csv"}
|
|
85
|
+
api = self.start(lambda request: {"raw": b"a,b\n1,2\n"} if request["path"].endswith("/download") else ok(uploaded))
|
|
86
|
+
client = self.client(api)
|
|
87
|
+
|
|
88
|
+
self.assertEqual(client.storage.upload("data.csv", "a,b\n1,2\n", folder="reports"), uploaded)
|
|
89
|
+
self.assertEqual(client.storage.download_text("reports/data.csv"), "a,b\n1,2\n")
|
|
90
|
+
|
|
91
|
+
upload = api.requests[0]
|
|
92
|
+
self.assertRegex(upload["headers"]["content-type"], r"^multipart/form-data; boundary=")
|
|
93
|
+
self.assertIn(b'name="path"\r\n\r\nreports\r\n', upload["body"])
|
|
94
|
+
self.assertRegex(upload["body"], rb'name="file"; filename="data.csv"\r\n[^\r]*\r\n\r\na,b\n1,2\n\r\n')
|
|
95
|
+
self.assertEqual(api.requests[1]["query"], {"path": "reports/data.csv"})
|
|
96
|
+
|
|
97
|
+
def test_databases_send_filters_as_json_and_the_expected_version(self) -> None:
|
|
98
|
+
page = {"rows": [{"id": 2, "version": 1, "values": {"title": "Beta", "amount": 20}}], "total": 1}
|
|
99
|
+
api = self.start(lambda request: ok(page) if request["method"] == "GET" else ok({"updated": True}))
|
|
100
|
+
client = self.client(api)
|
|
101
|
+
filters = [{"column": "amount", "operator": "greater_than", "value": 15}]
|
|
102
|
+
|
|
103
|
+
self.assertEqual(client.databases.rows("crm", "deals", limit=10, order_by="amount", direction="DESC", filters=filters), page)
|
|
104
|
+
self.assertTrue(client.databases.update("crm", "deals", 2, {"title": "Beta 2"}, expected_version=1))
|
|
105
|
+
|
|
106
|
+
listing, update = api.requests
|
|
107
|
+
self.assertEqual(listing["path"], "/api/private/databases/crm/tables/deals/rows")
|
|
108
|
+
self.assertEqual(listing["query"], {"limit": "10", "order_by": "amount", "direction": "DESC", "filters": json.dumps(filters)})
|
|
109
|
+
self.assertEqual((update["method"], update["path"]), ("PATCH", "/api/private/databases/crm/tables/deals/rows/2"))
|
|
110
|
+
self.assertEqual(json.loads(update["body"]), {"values": {"title": "Beta 2"}, "expected_version": 1})
|
|
111
|
+
|
|
112
|
+
def test_an_error_answer_raises_alloy_api_error_with_the_status_and_message(self) -> None:
|
|
113
|
+
def respond(request: Dict[str, Any]) -> Dict[str, Any]:
|
|
114
|
+
if request["path"].endswith("/rows/1"):
|
|
115
|
+
return {"status": 409, "json": {"success": False, "error": {"message": "Rows changed since they were read: 1"}}}
|
|
116
|
+
return {"status": 401, "json": {"error": "Unauthorized access"}}
|
|
117
|
+
|
|
118
|
+
client = self.client(self.start(respond))
|
|
119
|
+
|
|
120
|
+
with self.assertRaises(AlloyApiError) as conflict:
|
|
121
|
+
client.databases.update("crm", "deals", 1, {"title": "Stale"}, expected_version=1)
|
|
122
|
+
self.assertEqual((conflict.exception.status, conflict.exception.message), (409, "Rows changed since they were read: 1"))
|
|
123
|
+
|
|
124
|
+
with self.assertRaises(AlloyApiError) as unauthorized:
|
|
125
|
+
client.databases.list()
|
|
126
|
+
self.assertEqual((unauthorized.exception.status, unauthorized.exception.message), (401, "Unauthorized access"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
unittest.main()
|