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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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()