liyaengine 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.
- liyaengine-0.1.0/.github/workflows/ci.yml +151 -0
- liyaengine-0.1.0/.gitignore +10 -0
- liyaengine-0.1.0/LICENSE +21 -0
- liyaengine-0.1.0/PKG-INFO +119 -0
- liyaengine-0.1.0/README.md +90 -0
- liyaengine-0.1.0/pyproject.toml +55 -0
- liyaengine-0.1.0/src/liyaengine/__init__.py +12 -0
- liyaengine-0.1.0/src/liyaengine/_http.py +96 -0
- liyaengine-0.1.0/src/liyaengine/client.py +50 -0
- liyaengine-0.1.0/src/liyaengine/errors.py +29 -0
- liyaengine-0.1.0/src/liyaengine/py.typed +0 -0
- liyaengine-0.1.0/src/liyaengine/resources/__init__.py +0 -0
- liyaengine-0.1.0/src/liyaengine/resources/collections.py +129 -0
- liyaengine-0.1.0/tests/test_collections.py +114 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
# Job 1 — Test & Typecheck
|
|
13
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
test:
|
|
15
|
+
name: Test (py${{ matrix.python-version }})
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
timeout-minutes: 10
|
|
18
|
+
strategy:
|
|
19
|
+
matrix:
|
|
20
|
+
python-version: ['3.9', '3.12']
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Setup Python
|
|
26
|
+
uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: ${{ matrix.python-version }}
|
|
29
|
+
|
|
30
|
+
- name: Install package + dev dependencies
|
|
31
|
+
run: pip install -e ".[dev]"
|
|
32
|
+
|
|
33
|
+
- name: Typecheck
|
|
34
|
+
run: mypy src
|
|
35
|
+
|
|
36
|
+
- name: Run tests
|
|
37
|
+
run: pytest
|
|
38
|
+
|
|
39
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
# Job 2 — Build
|
|
41
|
+
# Validates the package builds a real sdist + wheel, uploads them so the
|
|
42
|
+
# release job (below) doesn't need to rebuild.
|
|
43
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
build:
|
|
45
|
+
name: Build
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
needs: test
|
|
48
|
+
timeout-minutes: 10
|
|
49
|
+
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
|
|
53
|
+
- name: Setup Python
|
|
54
|
+
uses: actions/setup-python@v5
|
|
55
|
+
with:
|
|
56
|
+
python-version: '3.12'
|
|
57
|
+
|
|
58
|
+
- name: Install build tooling
|
|
59
|
+
run: pip install build
|
|
60
|
+
|
|
61
|
+
- name: Build sdist + wheel
|
|
62
|
+
run: python -m build
|
|
63
|
+
|
|
64
|
+
- name: Upload dist artifact
|
|
65
|
+
uses: actions/upload-artifact@v4
|
|
66
|
+
with:
|
|
67
|
+
name: dist
|
|
68
|
+
path: dist/
|
|
69
|
+
retention-days: 1
|
|
70
|
+
|
|
71
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
# Job 3 — Release & Publish
|
|
73
|
+
# Creates a GitHub Release and publishes to PyPI on every push to main when
|
|
74
|
+
# the version in pyproject.toml changes. Uses PyPI Trusted Publishing (OIDC)
|
|
75
|
+
# — no long-lived API token to manage, but the PyPI project "liyaengine"
|
|
76
|
+
# needs a trusted-publisher entry configured for this repo + workflow
|
|
77
|
+
# before the first publish will succeed (pypi.org → project → Publishing).
|
|
78
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
release:
|
|
80
|
+
name: Release & Publish
|
|
81
|
+
runs-on: ubuntu-latest
|
|
82
|
+
needs: build
|
|
83
|
+
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
84
|
+
timeout-minutes: 10
|
|
85
|
+
|
|
86
|
+
permissions:
|
|
87
|
+
contents: write
|
|
88
|
+
id-token: write # required for PyPI Trusted Publishing
|
|
89
|
+
|
|
90
|
+
environment:
|
|
91
|
+
name: pypi
|
|
92
|
+
url: https://pypi.org/project/liyaengine/
|
|
93
|
+
|
|
94
|
+
steps:
|
|
95
|
+
- name: Checkout
|
|
96
|
+
uses: actions/checkout@v4
|
|
97
|
+
|
|
98
|
+
- name: Setup Python
|
|
99
|
+
uses: actions/setup-python@v5
|
|
100
|
+
with:
|
|
101
|
+
python-version: '3.12'
|
|
102
|
+
|
|
103
|
+
# Read the version from pyproject.toml and check, independently,
|
|
104
|
+
# whether (a) the release tag already exists and (b) this version is
|
|
105
|
+
# actually on PyPI. These can disagree — e.g. the tag/release gets
|
|
106
|
+
# created, then the publish step fails (auth, network) — so a re-run
|
|
107
|
+
# must not treat "tag exists" as "PyPI publish also happened" or a
|
|
108
|
+
# partial-failure release becomes permanently stuck (the tag blocks
|
|
109
|
+
# re-creating the release, while nothing actually retries the publish).
|
|
110
|
+
- name: Check release + publish status
|
|
111
|
+
id: version_check
|
|
112
|
+
run: |
|
|
113
|
+
PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
114
|
+
echo "version=$PKG_VERSION" >> $GITHUB_OUTPUT
|
|
115
|
+
|
|
116
|
+
if git ls-remote --tags origin "refs/tags/v$PKG_VERSION" | grep -q .; then
|
|
117
|
+
echo "tag_exists=true" >> $GITHUB_OUTPUT
|
|
118
|
+
echo "⏭️ v$PKG_VERSION tag already exists — skipping release creation"
|
|
119
|
+
else
|
|
120
|
+
echo "tag_exists=false" >> $GITHUB_OUTPUT
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/liyaengine/$PKG_VERSION/json")
|
|
124
|
+
if [ "$HTTP_STATUS" = "200" ]; then
|
|
125
|
+
echo "published=true" >> $GITHUB_OUTPUT
|
|
126
|
+
echo "⏭️ $PKG_VERSION already on PyPI — skipping publish"
|
|
127
|
+
else
|
|
128
|
+
echo "published=false" >> $GITHUB_OUTPUT
|
|
129
|
+
echo "🚀 $PKG_VERSION not yet on PyPI — publishing"
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
- name: Download dist artifact
|
|
133
|
+
if: steps.version_check.outputs.tag_exists == 'false' || steps.version_check.outputs.published == 'false'
|
|
134
|
+
uses: actions/download-artifact@v4
|
|
135
|
+
with:
|
|
136
|
+
name: dist
|
|
137
|
+
path: dist/
|
|
138
|
+
|
|
139
|
+
- name: Create GitHub Release
|
|
140
|
+
if: steps.version_check.outputs.tag_exists == 'false'
|
|
141
|
+
uses: softprops/action-gh-release@v2
|
|
142
|
+
with:
|
|
143
|
+
tag_name: v${{ steps.version_check.outputs.version }}
|
|
144
|
+
name: "liyaengine v${{ steps.version_check.outputs.version }}"
|
|
145
|
+
generate_release_notes: true
|
|
146
|
+
draft: false
|
|
147
|
+
prerelease: ${{ contains(steps.version_check.outputs.version, '-') }}
|
|
148
|
+
|
|
149
|
+
- name: Publish to PyPI
|
|
150
|
+
if: steps.version_check.outputs.published == 'false'
|
|
151
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
liyaengine-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LiyaEngine
|
|
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,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: liyaengine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Liya Engine public API
|
|
5
|
+
Project-URL: Homepage, https://liyaengine.ai
|
|
6
|
+
Project-URL: Documentation, https://liyaengine.ai/docs/sdks/python
|
|
7
|
+
Project-URL: Repository, https://github.com/liyaengine/sdk-python
|
|
8
|
+
Author-email: Liya Engine <support@liyaengine.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai,client,liyaengine,sdk
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: httpx<1,>=0.27
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# liyaengine
|
|
31
|
+
|
|
32
|
+
Official Python client for the [Liya Engine](https://liyaengine.ai) public API.
|
|
33
|
+
|
|
34
|
+
> **Status: early access.** This SDK currently covers the Collections resource. More resources (Domains, Run, Agents, Workflows, Evals) ship incrementally — see [Roadmap](#roadmap).
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install liyaengine
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from liyaengine import LiyaEngine
|
|
46
|
+
|
|
47
|
+
client = LiyaEngine(api_key="liya_...")
|
|
48
|
+
|
|
49
|
+
collection = client.collections.create(
|
|
50
|
+
slug="contracts",
|
|
51
|
+
label="Contracts",
|
|
52
|
+
domain_keys=["legal-ops"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
collections = client.collections.list()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or as a context manager (closes the underlying HTTP connection pool automatically):
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
with LiyaEngine(api_key="liya_...") as client:
|
|
62
|
+
collections = client.collections.list()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Get an API key from your [Liya Engine dashboard](https://app.liyaengine.ai) under Settings → API Keys.
|
|
66
|
+
|
|
67
|
+
## Error handling
|
|
68
|
+
|
|
69
|
+
Every failed request raises `LiyaEngineAPIError`, carrying the API's `code`, `message`, and HTTP `status`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from liyaengine import LiyaEngineAPIError
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
client.collections.create(slug="contracts", label="Contracts", domain_keys=["legal-ops"])
|
|
76
|
+
except LiyaEngineAPIError as err:
|
|
77
|
+
if err.code == "SLUG_CONFLICT":
|
|
78
|
+
# handle the conflict
|
|
79
|
+
pass
|
|
80
|
+
raise
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Network failures and timeouts raise `LiyaEngineNetworkError` instead. Requests are retried automatically on `429`/`5xx` responses and transient network errors (2 retries by default).
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
LiyaEngine(
|
|
89
|
+
api_key="liya_...",
|
|
90
|
+
base_url="https://api.liyaengine.ai", # override for local/staging
|
|
91
|
+
timeout_s=30.0,
|
|
92
|
+
max_retries=2,
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Roadmap
|
|
97
|
+
|
|
98
|
+
- [x] Collections
|
|
99
|
+
- [ ] Domains (custom domain + intent CRUD)
|
|
100
|
+
- [ ] Run / Run (streaming)
|
|
101
|
+
- [ ] Agents
|
|
102
|
+
- [ ] Workflows
|
|
103
|
+
- [ ] Evaluations
|
|
104
|
+
- [ ] Async client
|
|
105
|
+
|
|
106
|
+
Full docs: https://liyaengine.ai/docs/sdks/python
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
112
|
+
pip install -e ".[dev]"
|
|
113
|
+
mypy src
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# liyaengine
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Liya Engine](https://liyaengine.ai) public API.
|
|
4
|
+
|
|
5
|
+
> **Status: early access.** This SDK currently covers the Collections resource. More resources (Domains, Run, Agents, Workflows, Evals) ship incrementally — see [Roadmap](#roadmap).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install liyaengine
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from liyaengine import LiyaEngine
|
|
17
|
+
|
|
18
|
+
client = LiyaEngine(api_key="liya_...")
|
|
19
|
+
|
|
20
|
+
collection = client.collections.create(
|
|
21
|
+
slug="contracts",
|
|
22
|
+
label="Contracts",
|
|
23
|
+
domain_keys=["legal-ops"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
collections = client.collections.list()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or as a context manager (closes the underlying HTTP connection pool automatically):
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
with LiyaEngine(api_key="liya_...") as client:
|
|
33
|
+
collections = client.collections.list()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Get an API key from your [Liya Engine dashboard](https://app.liyaengine.ai) under Settings → API Keys.
|
|
37
|
+
|
|
38
|
+
## Error handling
|
|
39
|
+
|
|
40
|
+
Every failed request raises `LiyaEngineAPIError`, carrying the API's `code`, `message`, and HTTP `status`:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from liyaengine import LiyaEngineAPIError
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
client.collections.create(slug="contracts", label="Contracts", domain_keys=["legal-ops"])
|
|
47
|
+
except LiyaEngineAPIError as err:
|
|
48
|
+
if err.code == "SLUG_CONFLICT":
|
|
49
|
+
# handle the conflict
|
|
50
|
+
pass
|
|
51
|
+
raise
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Network failures and timeouts raise `LiyaEngineNetworkError` instead. Requests are retried automatically on `429`/`5xx` responses and transient network errors (2 retries by default).
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
LiyaEngine(
|
|
60
|
+
api_key="liya_...",
|
|
61
|
+
base_url="https://api.liyaengine.ai", # override for local/staging
|
|
62
|
+
timeout_s=30.0,
|
|
63
|
+
max_retries=2,
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Roadmap
|
|
68
|
+
|
|
69
|
+
- [x] Collections
|
|
70
|
+
- [ ] Domains (custom domain + intent CRUD)
|
|
71
|
+
- [ ] Run / Run (streaming)
|
|
72
|
+
- [ ] Agents
|
|
73
|
+
- [ ] Workflows
|
|
74
|
+
- [ ] Evaluations
|
|
75
|
+
- [ ] Async client
|
|
76
|
+
|
|
77
|
+
Full docs: https://liyaengine.ai/docs/sdks/python
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
83
|
+
pip install -e ".[dev]"
|
|
84
|
+
mypy src
|
|
85
|
+
pytest
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "liyaengine"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the Liya Engine public API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Liya Engine", email = "support@liyaengine.ai" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["liyaengine", "ai", "sdk", "client"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.27,<1",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://liyaengine.ai"
|
|
33
|
+
Documentation = "https://liyaengine.ai/docs/sdks/python"
|
|
34
|
+
Repository = "https://github.com/liyaengine/sdk-python"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"pytest>=8.0",
|
|
39
|
+
"pytest-asyncio>=0.23",
|
|
40
|
+
"respx>=0.21",
|
|
41
|
+
"mypy>=1.10",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/liyaengine"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
strict = true
|
|
52
|
+
# mypy's simulated-version floor is 3.10 even though this package still
|
|
53
|
+
# supports 3.9 at runtime (see project.requires-python) — mypy dropped 3.9
|
|
54
|
+
# simulation, this doesn't change what actually runs.
|
|
55
|
+
python_version = "3.10"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .client import LiyaEngine
|
|
2
|
+
from .errors import LiyaEngineAPIError, LiyaEngineNetworkError
|
|
3
|
+
from .resources.collections import Collection
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"LiyaEngine",
|
|
7
|
+
"LiyaEngineAPIError",
|
|
8
|
+
"LiyaEngineNetworkError",
|
|
9
|
+
"Collection",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Thin httpx wrapper: bearer auth, JSON in/out, the {success,data} /
|
|
2
|
+
{success,error} envelope unwrapped into a return value or a raised
|
|
3
|
+
LiyaEngineAPIError, and retry-with-backoff on 429/5xx (not on 4xx, which
|
|
4
|
+
are the caller's own mistake and won't succeed on retry).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import LiyaEngineAPIError, LiyaEngineNetworkError
|
|
14
|
+
|
|
15
|
+
_DEFAULT_TIMEOUT_S = 30.0
|
|
16
|
+
_DEFAULT_MAX_RETRIES = 2
|
|
17
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HttpClient:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
api_key: str,
|
|
24
|
+
base_url: str,
|
|
25
|
+
timeout_s: float = _DEFAULT_TIMEOUT_S,
|
|
26
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
27
|
+
client: Optional[httpx.Client] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._max_retries = max_retries
|
|
30
|
+
self._client = client or httpx.Client(
|
|
31
|
+
base_url=base_url.rstrip("/"),
|
|
32
|
+
timeout=timeout_s,
|
|
33
|
+
headers={
|
|
34
|
+
"Authorization": f"Bearer {api_key}",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def request(self, method: str, path: str, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
40
|
+
last_error: Optional[BaseException] = None
|
|
41
|
+
|
|
42
|
+
for attempt in range(self._max_retries + 1):
|
|
43
|
+
try:
|
|
44
|
+
response = self._client.request(method, path, json=json_body)
|
|
45
|
+
except httpx.TimeoutException as exc:
|
|
46
|
+
last_error = LiyaEngineNetworkError(f"Request timed out: {exc}", exc)
|
|
47
|
+
if attempt >= self._max_retries:
|
|
48
|
+
raise last_error from exc
|
|
49
|
+
time.sleep(2**attempt * 0.25)
|
|
50
|
+
continue
|
|
51
|
+
except httpx.RequestError as exc:
|
|
52
|
+
last_error = LiyaEngineNetworkError(f"Network request failed: {exc}", exc)
|
|
53
|
+
if attempt >= self._max_retries:
|
|
54
|
+
raise last_error from exc
|
|
55
|
+
time.sleep(2**attempt * 0.25)
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if response.status_code in _RETRYABLE_STATUS and attempt < self._max_retries:
|
|
59
|
+
time.sleep(2**attempt * 0.25)
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
payload = response.json()
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise LiyaEngineNetworkError(
|
|
66
|
+
f"Invalid JSON response (status {response.status_code})", exc
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
if not payload.get("success"):
|
|
70
|
+
error = payload.get("error", {})
|
|
71
|
+
raise LiyaEngineAPIError(
|
|
72
|
+
response.status_code,
|
|
73
|
+
error.get("code", "UNKNOWN_ERROR"),
|
|
74
|
+
error.get("message", "Unknown error"),
|
|
75
|
+
error.get("details"),
|
|
76
|
+
)
|
|
77
|
+
return payload.get("data")
|
|
78
|
+
|
|
79
|
+
if last_error is not None:
|
|
80
|
+
raise last_error
|
|
81
|
+
raise LiyaEngineNetworkError("Request failed")
|
|
82
|
+
|
|
83
|
+
def get(self, path: str) -> Any:
|
|
84
|
+
return self.request("GET", path)
|
|
85
|
+
|
|
86
|
+
def post(self, path: str, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
87
|
+
return self.request("POST", path, json_body)
|
|
88
|
+
|
|
89
|
+
def patch(self, path: str, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
90
|
+
return self.request("PATCH", path, json_body)
|
|
91
|
+
|
|
92
|
+
def delete(self, path: str) -> Any:
|
|
93
|
+
return self.request("DELETE", path)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
self._client.close()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._http import HttpClient
|
|
8
|
+
from .resources.collections import CollectionsResource
|
|
9
|
+
|
|
10
|
+
_DEFAULT_BASE_URL = "https://api.liyaengine.ai"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LiyaEngine:
|
|
14
|
+
"""Client for the Liya Engine public API.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> client = LiyaEngine(api_key="liya_...")
|
|
18
|
+
>>> collection = client.collections.create(
|
|
19
|
+
... slug="contracts", label="Contracts", domain_keys=["legal-ops"]
|
|
20
|
+
... )
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
api_key: str,
|
|
26
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
27
|
+
timeout_s: float = 30.0,
|
|
28
|
+
max_retries: int = 2,
|
|
29
|
+
http_client: Optional[httpx.Client] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
if not api_key:
|
|
32
|
+
raise ValueError("LiyaEngine: api_key is required.")
|
|
33
|
+
|
|
34
|
+
self._http = HttpClient(
|
|
35
|
+
api_key=api_key,
|
|
36
|
+
base_url=base_url,
|
|
37
|
+
timeout_s=timeout_s,
|
|
38
|
+
max_retries=max_retries,
|
|
39
|
+
client=http_client,
|
|
40
|
+
)
|
|
41
|
+
self.collections = CollectionsResource(self._http)
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
self._http.close()
|
|
45
|
+
|
|
46
|
+
def __enter__(self) -> "LiyaEngine":
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
50
|
+
self.close()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Every non-2xx /v1 response carries {"success": false, "error": {"code", "message"}}
|
|
2
|
+
(see liyaengine-api's ErrorEnvelope in openapi.yaml). This maps that envelope onto
|
|
3
|
+
real exceptions instead of a plain dict, so callers can `except LiyaEngineAPIError`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LiyaEngineAPIError(Exception):
|
|
11
|
+
"""The API returned a well-formed error envelope."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status: int, code: str, message: str, details: Optional[Any] = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status = status
|
|
16
|
+
self.code = code
|
|
17
|
+
self.message = message
|
|
18
|
+
self.details = details
|
|
19
|
+
|
|
20
|
+
def __repr__(self) -> str:
|
|
21
|
+
return f"LiyaEngineAPIError(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LiyaEngineNetworkError(Exception):
|
|
25
|
+
"""The request never reached the server, timed out, or the response wasn't valid JSON."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.cause = cause
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .._http import HttpClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Collection:
|
|
11
|
+
id: str
|
|
12
|
+
slug: str
|
|
13
|
+
label: str
|
|
14
|
+
color: str
|
|
15
|
+
created_at: str
|
|
16
|
+
domain_keys: List[str]
|
|
17
|
+
tags: List[str]
|
|
18
|
+
visibility: str
|
|
19
|
+
last_synced_at: Optional[str]
|
|
20
|
+
retrieval_config: Optional[Dict[str, Any]]
|
|
21
|
+
default_embedding_model: Optional[str]
|
|
22
|
+
default_chunking_strategy: Optional[str]
|
|
23
|
+
default_chunk_size: Optional[int]
|
|
24
|
+
default_chunk_overlap: Optional[int]
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def _from_dict(cls, data: Dict[str, Any]) -> "Collection":
|
|
28
|
+
return cls(
|
|
29
|
+
id=data["id"],
|
|
30
|
+
slug=data["slug"],
|
|
31
|
+
label=data["label"],
|
|
32
|
+
color=data["color"],
|
|
33
|
+
created_at=data["created_at"],
|
|
34
|
+
domain_keys=data.get("domain_keys", []),
|
|
35
|
+
tags=data.get("tags", []),
|
|
36
|
+
visibility=data.get("visibility", "workspace"),
|
|
37
|
+
last_synced_at=data.get("last_synced_at"),
|
|
38
|
+
retrieval_config=data.get("retrieval_config"),
|
|
39
|
+
default_embedding_model=data.get("default_embedding_model"),
|
|
40
|
+
default_chunking_strategy=data.get("default_chunking_strategy"),
|
|
41
|
+
default_chunk_size=data.get("default_chunk_size"),
|
|
42
|
+
default_chunk_overlap=data.get("default_chunk_overlap"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CollectionsResource:
|
|
47
|
+
"""Tenant-wide knowledge collections — organize documents, scope
|
|
48
|
+
retrieval, attach to one or many domains. Mirrors GET/POST
|
|
49
|
+
/v1/collections and GET/PATCH/DELETE /v1/collections/{id} exactly
|
|
50
|
+
(see liyaengine-api's openapi.yaml).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, http: HttpClient) -> None:
|
|
54
|
+
self._http = http
|
|
55
|
+
|
|
56
|
+
def list(self) -> List[Collection]:
|
|
57
|
+
data = self._http.get("/v1/collections")
|
|
58
|
+
return [Collection._from_dict(c) for c in data["collections"]]
|
|
59
|
+
|
|
60
|
+
def get(self, id: str) -> Collection:
|
|
61
|
+
data = self._http.get(f"/v1/collections/{id}")
|
|
62
|
+
return Collection._from_dict(data["collection"])
|
|
63
|
+
|
|
64
|
+
def create(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
slug: str,
|
|
68
|
+
label: str,
|
|
69
|
+
domain_keys: List[str],
|
|
70
|
+
color: Optional[str] = None,
|
|
71
|
+
default_embedding_model: Optional[str] = None,
|
|
72
|
+
default_chunking_strategy: Optional[str] = None,
|
|
73
|
+
default_chunk_size: Optional[int] = None,
|
|
74
|
+
default_chunk_overlap: Optional[int] = None,
|
|
75
|
+
) -> Collection:
|
|
76
|
+
body: Dict[str, Any] = {"slug": slug, "label": label, "domain_keys": domain_keys}
|
|
77
|
+
if color is not None:
|
|
78
|
+
body["color"] = color
|
|
79
|
+
if default_embedding_model is not None:
|
|
80
|
+
body["default_embedding_model"] = default_embedding_model
|
|
81
|
+
if default_chunking_strategy is not None:
|
|
82
|
+
body["default_chunking_strategy"] = default_chunking_strategy
|
|
83
|
+
if default_chunk_size is not None:
|
|
84
|
+
body["default_chunk_size"] = default_chunk_size
|
|
85
|
+
if default_chunk_overlap is not None:
|
|
86
|
+
body["default_chunk_overlap"] = default_chunk_overlap
|
|
87
|
+
|
|
88
|
+
data = self._http.post("/v1/collections", body)
|
|
89
|
+
return Collection._from_dict(data["collection"])
|
|
90
|
+
|
|
91
|
+
def update(
|
|
92
|
+
self,
|
|
93
|
+
id: str,
|
|
94
|
+
*,
|
|
95
|
+
label: Optional[str] = None,
|
|
96
|
+
color: Optional[str] = None,
|
|
97
|
+
tags: Optional[List[str]] = None,
|
|
98
|
+
visibility: Optional[str] = None,
|
|
99
|
+
retrieval_config: Optional[Dict[str, Any]] = None,
|
|
100
|
+
default_embedding_model: Optional[str] = None,
|
|
101
|
+
default_chunking_strategy: Optional[str] = None,
|
|
102
|
+
default_chunk_size: Optional[int] = None,
|
|
103
|
+
default_chunk_overlap: Optional[int] = None,
|
|
104
|
+
) -> Collection:
|
|
105
|
+
body: Dict[str, Any] = {}
|
|
106
|
+
if label is not None:
|
|
107
|
+
body["label"] = label
|
|
108
|
+
if color is not None:
|
|
109
|
+
body["color"] = color
|
|
110
|
+
if tags is not None:
|
|
111
|
+
body["tags"] = tags
|
|
112
|
+
if visibility is not None:
|
|
113
|
+
body["visibility"] = visibility
|
|
114
|
+
if retrieval_config is not None:
|
|
115
|
+
body["retrieval_config"] = retrieval_config
|
|
116
|
+
if default_embedding_model is not None:
|
|
117
|
+
body["default_embedding_model"] = default_embedding_model
|
|
118
|
+
if default_chunking_strategy is not None:
|
|
119
|
+
body["default_chunking_strategy"] = default_chunking_strategy
|
|
120
|
+
if default_chunk_size is not None:
|
|
121
|
+
body["default_chunk_size"] = default_chunk_size
|
|
122
|
+
if default_chunk_overlap is not None:
|
|
123
|
+
body["default_chunk_overlap"] = default_chunk_overlap
|
|
124
|
+
|
|
125
|
+
data = self._http.patch(f"/v1/collections/{id}", body)
|
|
126
|
+
return Collection._from_dict(data["collection"])
|
|
127
|
+
|
|
128
|
+
def delete(self, id: str) -> None:
|
|
129
|
+
self._http.delete(f"/v1/collections/{id}")
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
import respx
|
|
4
|
+
|
|
5
|
+
from liyaengine import LiyaEngine, LiyaEngineAPIError
|
|
6
|
+
|
|
7
|
+
BASE_URL = "https://api.test.liyaengine.ai"
|
|
8
|
+
|
|
9
|
+
FIXTURE_COLLECTION = {
|
|
10
|
+
"id": "col_123",
|
|
11
|
+
"slug": "contracts",
|
|
12
|
+
"label": "Contracts",
|
|
13
|
+
"color": "#6366f1",
|
|
14
|
+
"created_at": "2026-01-01T00:00:00.000Z",
|
|
15
|
+
"domain_keys": ["legal-ops"],
|
|
16
|
+
"tags": [],
|
|
17
|
+
"visibility": "workspace",
|
|
18
|
+
"last_synced_at": None,
|
|
19
|
+
"retrieval_config": None,
|
|
20
|
+
"default_embedding_model": None,
|
|
21
|
+
"default_chunking_strategy": None,
|
|
22
|
+
"default_chunk_size": None,
|
|
23
|
+
"default_chunk_overlap": None,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def client():
|
|
29
|
+
with LiyaEngine(api_key="liya_test_key", base_url=BASE_URL) as c:
|
|
30
|
+
yield c
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_constructor_requires_api_key():
|
|
34
|
+
with pytest.raises(ValueError, match="api_key is required"):
|
|
35
|
+
LiyaEngine(api_key="")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@respx.mock
|
|
39
|
+
def test_list_unwraps_envelope(client):
|
|
40
|
+
respx.get(f"{BASE_URL}/v1/collections").mock(
|
|
41
|
+
return_value=httpx.Response(200, json={"success": True, "data": {"collections": [FIXTURE_COLLECTION]}})
|
|
42
|
+
)
|
|
43
|
+
collections = client.collections.list()
|
|
44
|
+
assert len(collections) == 1
|
|
45
|
+
assert collections[0].slug == "contracts"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@respx.mock
|
|
49
|
+
def test_get_returns_single_collection(client):
|
|
50
|
+
respx.get(f"{BASE_URL}/v1/collections/col_123").mock(
|
|
51
|
+
return_value=httpx.Response(200, json={"success": True, "data": {"collection": FIXTURE_COLLECTION}})
|
|
52
|
+
)
|
|
53
|
+
collection = client.collections.get("col_123")
|
|
54
|
+
assert collection.id == "col_123"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@respx.mock
|
|
58
|
+
def test_get_raises_typed_error_on_404(client):
|
|
59
|
+
respx.get(f"{BASE_URL}/v1/collections/missing").mock(
|
|
60
|
+
return_value=httpx.Response(
|
|
61
|
+
404, json={"success": False, "error": {"code": "NOT_FOUND", "message": "Collection not found."}}
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
with pytest.raises(LiyaEngineAPIError) as exc_info:
|
|
65
|
+
client.collections.get("missing")
|
|
66
|
+
assert exc_info.value.code == "NOT_FOUND"
|
|
67
|
+
assert exc_info.value.status == 404
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@respx.mock
|
|
71
|
+
def test_create_returns_new_collection(client):
|
|
72
|
+
respx.post(f"{BASE_URL}/v1/collections").mock(
|
|
73
|
+
return_value=httpx.Response(
|
|
74
|
+
201, json={"success": True, "data": {"collection": {**FIXTURE_COLLECTION, "id": "col_new"}}}
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
collection = client.collections.create(slug="contracts", label="Contracts", domain_keys=["legal-ops"])
|
|
78
|
+
assert collection.id == "col_new"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@respx.mock
|
|
82
|
+
def test_create_raises_typed_409_on_conflict(client):
|
|
83
|
+
respx.post(f"{BASE_URL}/v1/collections").mock(
|
|
84
|
+
return_value=httpx.Response(
|
|
85
|
+
409,
|
|
86
|
+
json={
|
|
87
|
+
"success": False,
|
|
88
|
+
"error": {"code": "SLUG_CONFLICT", "message": "A collection named 'contracts' already exists."},
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
with pytest.raises(LiyaEngineAPIError) as exc_info:
|
|
93
|
+
client.collections.create(slug="contracts", label="Contracts", domain_keys=["legal-ops"])
|
|
94
|
+
assert exc_info.value.code == "SLUG_CONFLICT"
|
|
95
|
+
assert exc_info.value.status == 409
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@respx.mock
|
|
99
|
+
def test_update_patches_collection(client):
|
|
100
|
+
respx.patch(f"{BASE_URL}/v1/collections/col_123").mock(
|
|
101
|
+
return_value=httpx.Response(
|
|
102
|
+
200, json={"success": True, "data": {"collection": {**FIXTURE_COLLECTION, "label": "Renamed"}}}
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
updated = client.collections.update("col_123", label="Renamed")
|
|
106
|
+
assert updated.label == "Renamed"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@respx.mock
|
|
110
|
+
def test_delete_does_not_raise(client):
|
|
111
|
+
respx.delete(f"{BASE_URL}/v1/collections/col_123").mock(
|
|
112
|
+
return_value=httpx.Response(200, json={"success": True})
|
|
113
|
+
)
|
|
114
|
+
client.collections.delete("col_123") # no exception = pass
|