loopengine 1.0.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.
- loopengine-1.0.0/.gitignore +16 -0
- loopengine-1.0.0/CHANGELOG.md +11 -0
- loopengine-1.0.0/LICENSE +22 -0
- loopengine-1.0.0/PKG-INFO +157 -0
- loopengine-1.0.0/README.md +131 -0
- loopengine-1.0.0/examples/clienttest.py +42 -0
- loopengine-1.0.0/loopengine/__init__.py +17 -0
- loopengine-1.0.0/loopengine/async_client.py +39 -0
- loopengine-1.0.0/loopengine/client.py +129 -0
- loopengine-1.0.0/loopengine/constants.py +3 -0
- loopengine-1.0.0/loopengine/exceptions.py +29 -0
- loopengine-1.0.0/loopengine/sign.py +44 -0
- loopengine-1.0.0/loopengine/types.py +20 -0
- loopengine-1.0.0/pyproject.toml +35 -0
- loopengine-1.0.0/tests/test_async_client.py +29 -0
- loopengine-1.0.0/tests/test_client.py +85 -0
- loopengine-1.0.0/tests/test_sign.py +26 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
- Initial release of the Python LoopEngine SDK.
|
|
8
|
+
- Synchronous `LoopEngine` client and asynchronous `AsyncLoopEngine` wrapper.
|
|
9
|
+
- HMAC-SHA256 signing and stdlib-only HTTP implementation.
|
|
10
|
+
- `clienttest` example app and documentation for running via `uv`.
|
|
11
|
+
|
loopengine-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LoopEngine
|
|
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.
|
|
22
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loopengine
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official LoopEngine SDK for sending feedback to the Ingest API
|
|
5
|
+
Project-URL: Homepage, https://loopengine.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/LoopEngine-dev/loopengine-sdks
|
|
7
|
+
Author-email: LoopEngine <support@loopengine.dev>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: feedback,loopengine,sdk
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
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: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# LoopEngine
|
|
28
|
+
|
|
29
|
+
Official LoopEngine SDK for sending feedback to the Ingest API. Two-line usage: create a client with your credentials, then call `send` with your payload.
|
|
30
|
+
|
|
31
|
+
- **No external dependencies** — uses the Python standard library for HTTP and crypto
|
|
32
|
+
- **Small surface** — one main client (`LoopEngine`) plus an async wrapper (`AsyncLoopEngine`)
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install loopengine
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage (sync)
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from loopengine import LoopEngine
|
|
44
|
+
|
|
45
|
+
client = LoopEngine(
|
|
46
|
+
project_key="pk_live_...",
|
|
47
|
+
project_secret="psk_live_...",
|
|
48
|
+
project_id="proj_...",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
result = client.send({"message": "User reported a bug"})
|
|
52
|
+
if result.ok:
|
|
53
|
+
print(result.body) # e.g. {"id": "fb_...", "analysis_status": "pending"}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage (async)
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import asyncio
|
|
60
|
+
from loopengine import AsyncLoopEngine
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def main() -> None:
|
|
64
|
+
client = AsyncLoopEngine(
|
|
65
|
+
project_key="pk_live_...",
|
|
66
|
+
project_secret="psk_live_...",
|
|
67
|
+
project_id="proj_...",
|
|
68
|
+
)
|
|
69
|
+
result = await client.send({"message": "User reported a bug"})
|
|
70
|
+
if result.ok:
|
|
71
|
+
print(result.body)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Config
|
|
79
|
+
|
|
80
|
+
Obtain `project_key`, `project_secret`, and `project_id` from your [LoopEngine dashboard](https://loopengine.dev). A typical configuration pattern is to read them from environment variables:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import os
|
|
84
|
+
from loopengine import LoopEngine
|
|
85
|
+
|
|
86
|
+
client = LoopEngine(
|
|
87
|
+
project_key=os.environ["LOOPENGINE_PROJECT_KEY"],
|
|
88
|
+
project_secret=os.environ["LOOPENGINE_PROJECT_SECRET"],
|
|
89
|
+
project_id=os.environ["LOOPENGINE_PROJECT_ID"],
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Payload
|
|
94
|
+
|
|
95
|
+
The payload object you send must match the fields and constraints you defined when creating your project in the LoopEngine dashboard (required fields, allowed keys, value types, etc.). At a minimum, it should include all the required fields according to your project's schema.
|
|
96
|
+
|
|
97
|
+
You **do not** need to pass `project_id` in the payload; it is automatically injected from the client configuration.
|
|
98
|
+
|
|
99
|
+
Payloads can be:
|
|
100
|
+
|
|
101
|
+
- A mapping/dict (`dict[str, object]`)
|
|
102
|
+
- Any JSON-serializable object (for example a dataclass) that encodes to a JSON object
|
|
103
|
+
|
|
104
|
+
## Quick test with `uv` and `clienttest`
|
|
105
|
+
|
|
106
|
+
This repository includes a small `clienttest` example app you can run to verify your credentials and connectivity.
|
|
107
|
+
|
|
108
|
+
1. **Install `uv`** (a fast Python package manager/runner):
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install uv
|
|
112
|
+
# or: pipx install uv
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
2. **From the `loopengine-python` directory, run the example**:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Using environment variables (recommended)
|
|
119
|
+
export LOOPENGINE_PROJECT_KEY="pk_live_..."
|
|
120
|
+
export LOOPENGINE_PROJECT_SECRET="psk_live_..."
|
|
121
|
+
export LOOPENGINE_PROJECT_ID="proj_..."
|
|
122
|
+
|
|
123
|
+
uv run examples/clienttest.py
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`uv run` creates an isolated environment, resolves dependencies, and executes the script in one step. Because this SDK has no runtime dependencies beyond the standard library, `uv` mainly provides a fast, reproducible way to run the example without managing a separate virtualenv.
|
|
127
|
+
|
|
128
|
+
3. **Alternatively, edit placeholders directly** in `examples/clienttest.py`:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
project_key = "<your_project_key_here>"
|
|
132
|
+
project_secret = "<your_project_secret_here>"
|
|
133
|
+
project_id = "<your_project_id_here>"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Then run:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
uv run examples/clienttest.py
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
To run tests locally:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
uv run pytest
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Requirements
|
|
151
|
+
|
|
152
|
+
- Python **>= 3.9**
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
157
|
+
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# LoopEngine
|
|
2
|
+
|
|
3
|
+
Official LoopEngine SDK for sending feedback to the Ingest API. Two-line usage: create a client with your credentials, then call `send` with your payload.
|
|
4
|
+
|
|
5
|
+
- **No external dependencies** — uses the Python standard library for HTTP and crypto
|
|
6
|
+
- **Small surface** — one main client (`LoopEngine`) plus an async wrapper (`AsyncLoopEngine`)
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install loopengine
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage (sync)
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from loopengine import LoopEngine
|
|
18
|
+
|
|
19
|
+
client = LoopEngine(
|
|
20
|
+
project_key="pk_live_...",
|
|
21
|
+
project_secret="psk_live_...",
|
|
22
|
+
project_id="proj_...",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
result = client.send({"message": "User reported a bug"})
|
|
26
|
+
if result.ok:
|
|
27
|
+
print(result.body) # e.g. {"id": "fb_...", "analysis_status": "pending"}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage (async)
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from loopengine import AsyncLoopEngine
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main() -> None:
|
|
38
|
+
client = AsyncLoopEngine(
|
|
39
|
+
project_key="pk_live_...",
|
|
40
|
+
project_secret="psk_live_...",
|
|
41
|
+
project_id="proj_...",
|
|
42
|
+
)
|
|
43
|
+
result = await client.send({"message": "User reported a bug"})
|
|
44
|
+
if result.ok:
|
|
45
|
+
print(result.body)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
asyncio.run(main())
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Config
|
|
53
|
+
|
|
54
|
+
Obtain `project_key`, `project_secret`, and `project_id` from your [LoopEngine dashboard](https://loopengine.dev). A typical configuration pattern is to read them from environment variables:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import os
|
|
58
|
+
from loopengine import LoopEngine
|
|
59
|
+
|
|
60
|
+
client = LoopEngine(
|
|
61
|
+
project_key=os.environ["LOOPENGINE_PROJECT_KEY"],
|
|
62
|
+
project_secret=os.environ["LOOPENGINE_PROJECT_SECRET"],
|
|
63
|
+
project_id=os.environ["LOOPENGINE_PROJECT_ID"],
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Payload
|
|
68
|
+
|
|
69
|
+
The payload object you send must match the fields and constraints you defined when creating your project in the LoopEngine dashboard (required fields, allowed keys, value types, etc.). At a minimum, it should include all the required fields according to your project's schema.
|
|
70
|
+
|
|
71
|
+
You **do not** need to pass `project_id` in the payload; it is automatically injected from the client configuration.
|
|
72
|
+
|
|
73
|
+
Payloads can be:
|
|
74
|
+
|
|
75
|
+
- A mapping/dict (`dict[str, object]`)
|
|
76
|
+
- Any JSON-serializable object (for example a dataclass) that encodes to a JSON object
|
|
77
|
+
|
|
78
|
+
## Quick test with `uv` and `clienttest`
|
|
79
|
+
|
|
80
|
+
This repository includes a small `clienttest` example app you can run to verify your credentials and connectivity.
|
|
81
|
+
|
|
82
|
+
1. **Install `uv`** (a fast Python package manager/runner):
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install uv
|
|
86
|
+
# or: pipx install uv
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
2. **From the `loopengine-python` directory, run the example**:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Using environment variables (recommended)
|
|
93
|
+
export LOOPENGINE_PROJECT_KEY="pk_live_..."
|
|
94
|
+
export LOOPENGINE_PROJECT_SECRET="psk_live_..."
|
|
95
|
+
export LOOPENGINE_PROJECT_ID="proj_..."
|
|
96
|
+
|
|
97
|
+
uv run examples/clienttest.py
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`uv run` creates an isolated environment, resolves dependencies, and executes the script in one step. Because this SDK has no runtime dependencies beyond the standard library, `uv` mainly provides a fast, reproducible way to run the example without managing a separate virtualenv.
|
|
101
|
+
|
|
102
|
+
3. **Alternatively, edit placeholders directly** in `examples/clienttest.py`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
project_key = "<your_project_key_here>"
|
|
106
|
+
project_secret = "<your_project_secret_here>"
|
|
107
|
+
project_id = "<your_project_id_here>"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Then run:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv run examples/clienttest.py
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
To run tests locally:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
uv run pytest
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Requirements
|
|
125
|
+
|
|
126
|
+
- Python **>= 3.9**
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
131
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from loopengine import LoopEngine
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_client() -> LoopEngine:
|
|
11
|
+
# Replace these placeholders with your real credentials from the LoopEngine dashboard,
|
|
12
|
+
# or set the corresponding environment variables.
|
|
13
|
+
project_key = os.getenv("LOOPENGINE_PROJECT_KEY", "pk_live_ecbf535af9d385b22f1673093d7af46bab31371dbae59f5f")
|
|
14
|
+
project_secret = os.getenv("LOOPENGINE_PROJECT_SECRET", "psk_live_3fd06e2b438e8fac678f2b975366156a066021d31088b8a7473aabcdf5a3c8b7")
|
|
15
|
+
project_id = os.getenv("LOOPENGINE_PROJECT_ID", "proj_451fd28bcf165a25e9141c7c67b1ac76")
|
|
16
|
+
|
|
17
|
+
return LoopEngine(
|
|
18
|
+
project_key=project_key,
|
|
19
|
+
project_secret=project_secret,
|
|
20
|
+
project_id=project_id,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
client = build_client()
|
|
26
|
+
|
|
27
|
+
payload: Mapping[str, Any] = {
|
|
28
|
+
"message": "Hello from loopengine clienttest in python",
|
|
29
|
+
"app": "python",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
print("Sending test payload to LoopEngine...")
|
|
33
|
+
result = client.send(payload)
|
|
34
|
+
|
|
35
|
+
print(f"Status: {result.status} (ok={result.ok})")
|
|
36
|
+
print("Body:")
|
|
37
|
+
print(json.dumps(result.body, indent=2))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
main()
|
|
42
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncLoopEngine
|
|
4
|
+
from .client import LoopEngine
|
|
5
|
+
from .exceptions import LoopEngineError
|
|
6
|
+
from .types import FeedbackPayload, SendResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"LoopEngine",
|
|
10
|
+
"AsyncLoopEngine",
|
|
11
|
+
"LoopEngineError",
|
|
12
|
+
"FeedbackPayload",
|
|
13
|
+
"SendResult",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
from .client import LoopEngine
|
|
7
|
+
from .types import SendResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncLoopEngine:
|
|
11
|
+
"""Asynchronous wrapper around the synchronous LoopEngine client.
|
|
12
|
+
|
|
13
|
+
This uses asyncio.to_thread to avoid extra HTTP dependencies while providing an async-friendly API.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
project_key: str,
|
|
19
|
+
project_secret: str,
|
|
20
|
+
project_id: str,
|
|
21
|
+
*,
|
|
22
|
+
base_url: Optional[str] = None,
|
|
23
|
+
timeout: Optional[float] = 10.0,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._client = LoopEngine(
|
|
26
|
+
project_key=project_key,
|
|
27
|
+
project_secret=project_secret,
|
|
28
|
+
project_id=project_id,
|
|
29
|
+
base_url=base_url,
|
|
30
|
+
timeout=timeout,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
async def send(self, payload: Any | None) -> SendResult:
|
|
34
|
+
"""Asynchronously send a feedback payload to LoopEngine."""
|
|
35
|
+
return await asyncio.to_thread(self._client.send, payload)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["AsyncLoopEngine"]
|
|
39
|
+
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from http.client import HTTPSConnection
|
|
5
|
+
from typing import Any, Callable, Mapping, MutableMapping, Optional
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from .constants import BASE_URL, FEEDBACK_PATH
|
|
9
|
+
from .exceptions import LoopEngineError
|
|
10
|
+
from .sign import build_auth_headers
|
|
11
|
+
from .types import FeedbackPayload, SendResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_Transport = Callable[[str, bytes, Mapping[str, str], Optional[float]], tuple[int, str, bytes]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _default_transport(url: str, body: bytes, headers: Mapping[str, str], timeout: Optional[float]) -> tuple[int, str, bytes]:
|
|
18
|
+
"""Minimal HTTPS POST using the standard library."""
|
|
19
|
+
parsed = urlparse(url)
|
|
20
|
+
if parsed.scheme != "https":
|
|
21
|
+
raise LoopEngineError(message=f"loopengine: only https is supported (got {parsed.scheme!r})")
|
|
22
|
+
|
|
23
|
+
conn = HTTPSConnection(parsed.hostname, parsed.port or 443, timeout=timeout)
|
|
24
|
+
try:
|
|
25
|
+
path = parsed.path or "/"
|
|
26
|
+
if parsed.query:
|
|
27
|
+
path = f"{path}?{parsed.query}"
|
|
28
|
+
conn.request("POST", path, body=body, headers=dict(headers))
|
|
29
|
+
resp = conn.getresponse()
|
|
30
|
+
status = resp.status
|
|
31
|
+
reason = resp.reason or ""
|
|
32
|
+
data = resp.read()
|
|
33
|
+
return status, reason, data
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
raise LoopEngineError(message="loopengine: send failed", cause=exc) from exc
|
|
36
|
+
finally:
|
|
37
|
+
conn.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LoopEngine:
|
|
41
|
+
"""Synchronous LoopEngine client for sending feedback to the Ingest API."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
project_key: str,
|
|
46
|
+
project_secret: str,
|
|
47
|
+
project_id: str,
|
|
48
|
+
*,
|
|
49
|
+
base_url: Optional[str] = None,
|
|
50
|
+
timeout: Optional[float] = 10.0,
|
|
51
|
+
transport: Optional[_Transport] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
project_key = (project_key or "").strip()
|
|
54
|
+
project_secret = (project_secret or "").strip()
|
|
55
|
+
project_id = (project_id or "").strip()
|
|
56
|
+
if not project_key or not project_secret or not project_id:
|
|
57
|
+
raise ValueError("loopengine: project_key, project_secret, and project_id are required")
|
|
58
|
+
|
|
59
|
+
self._project_key = project_key
|
|
60
|
+
self._project_secret = project_secret
|
|
61
|
+
self._project_id = project_id
|
|
62
|
+
self._base_url = (base_url or BASE_URL).rstrip("/")
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self._transport: _Transport = transport or _default_transport
|
|
65
|
+
|
|
66
|
+
def _build_body(self, payload: Any) -> bytes:
|
|
67
|
+
if payload is None:
|
|
68
|
+
m: MutableMapping[str, Any] = {}
|
|
69
|
+
elif isinstance(payload, Mapping):
|
|
70
|
+
m = dict(payload)
|
|
71
|
+
else:
|
|
72
|
+
try:
|
|
73
|
+
# Convert arbitrary objects into a dict via JSON round-trip, mirroring Go behavior.
|
|
74
|
+
serialized = json.dumps(payload)
|
|
75
|
+
decoded = json.loads(serialized)
|
|
76
|
+
except (TypeError, ValueError) as exc:
|
|
77
|
+
raise LoopEngineError(message="loopengine: payload must be JSON-serializable") from exc
|
|
78
|
+
if not isinstance(decoded, Mapping):
|
|
79
|
+
raise LoopEngineError(message="loopengine: payload must deserialize to an object")
|
|
80
|
+
m = dict(decoded)
|
|
81
|
+
|
|
82
|
+
# Ensure project_id is set from the client configuration.
|
|
83
|
+
m["project_id"] = self._project_id
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
body_bytes = json.dumps(m, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
87
|
+
except (TypeError, ValueError) as exc:
|
|
88
|
+
raise LoopEngineError(message="loopengine: failed to encode JSON body") from exc
|
|
89
|
+
return body_bytes
|
|
90
|
+
|
|
91
|
+
def send(self, payload: FeedbackPayload | Any | None) -> SendResult:
|
|
92
|
+
"""Send a feedback payload to LoopEngine.
|
|
93
|
+
|
|
94
|
+
The payload must be JSON-serializable and conform to your project's schema.
|
|
95
|
+
`project_id` is injected from the client configuration.
|
|
96
|
+
"""
|
|
97
|
+
body = self._build_body(payload)
|
|
98
|
+
url = f"{self._base_url}{FEEDBACK_PATH}"
|
|
99
|
+
headers = {
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
**build_auth_headers(
|
|
102
|
+
project_key=self._project_key,
|
|
103
|
+
project_secret=self._project_secret,
|
|
104
|
+
method="POST",
|
|
105
|
+
path=FEEDBACK_PATH,
|
|
106
|
+
body=body,
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
status, reason, data = self._transport(url, body, headers, self._timeout)
|
|
111
|
+
text = data.decode("utf-8", errors="replace")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
parsed = json.loads(text) if text else {}
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
parsed = {"raw": text}
|
|
117
|
+
|
|
118
|
+
if 200 <= status < 300:
|
|
119
|
+
return SendResult(ok=True, status=status, body=parsed)
|
|
120
|
+
|
|
121
|
+
raise LoopEngineError(
|
|
122
|
+
status=status,
|
|
123
|
+
body=text,
|
|
124
|
+
message=f"loopengine: {status} {reason} {text}",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["LoopEngine"]
|
|
129
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class LoopEngineError(Exception):
|
|
9
|
+
"""Error raised when the LoopEngine API responds with a non-2xx status or a request fails."""
|
|
10
|
+
|
|
11
|
+
status: Optional[int] = None
|
|
12
|
+
body: Optional[str] = None
|
|
13
|
+
message: Optional[str] = None
|
|
14
|
+
cause: Optional[BaseException] = None
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
prefix = "loopengine"
|
|
18
|
+
parts: list[str] = [prefix]
|
|
19
|
+
if self.status is not None:
|
|
20
|
+
parts.append(str(self.status))
|
|
21
|
+
if self.body:
|
|
22
|
+
snippet = self.body.strip()
|
|
23
|
+
if len(snippet) > 200:
|
|
24
|
+
snippet = snippet[:197] + "..."
|
|
25
|
+
parts.append(snippet)
|
|
26
|
+
if self.message:
|
|
27
|
+
parts.append(self.message)
|
|
28
|
+
return ": ".join(part for part in parts if part)
|
|
29
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import time
|
|
7
|
+
from typing import Dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sha256_hex(body: bytes) -> str:
|
|
11
|
+
"""Return the SHA-256 hash of the given bytes as a lowercase hex string."""
|
|
12
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
13
|
+
return digest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sign_request(secret: str, method: str, path: str, timestamp: str, body_hex: str) -> str:
|
|
17
|
+
"""Sign the canonical request string with HMAC-SHA256 and return base64url (no padding)."""
|
|
18
|
+
canonical = f"{method}\n{path}\n{timestamp}\n{body_hex}"
|
|
19
|
+
mac = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256)
|
|
20
|
+
sig_bytes = mac.digest()
|
|
21
|
+
b64 = base64.urlsafe_b64encode(sig_bytes).rstrip(b"=")
|
|
22
|
+
return b64.decode("ascii")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_auth_headers(
|
|
26
|
+
project_key: str,
|
|
27
|
+
project_secret: str,
|
|
28
|
+
method: str,
|
|
29
|
+
path: str,
|
|
30
|
+
body: bytes,
|
|
31
|
+
) -> Dict[str, str]:
|
|
32
|
+
"""Build authentication headers for the LoopEngine API request."""
|
|
33
|
+
timestamp = str(int(time.time()))
|
|
34
|
+
body_hex = sha256_hex(body)
|
|
35
|
+
signature = sign_request(project_secret, method, path, timestamp, body_hex)
|
|
36
|
+
return {
|
|
37
|
+
"X-Project-Key": project_key,
|
|
38
|
+
"X-Timestamp": timestamp,
|
|
39
|
+
"X-Signature": f"v1={signature}",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["sha256_hex", "sign_request", "build_auth_headers"]
|
|
44
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
FeedbackPayload = Mapping[str, Any]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SendResult:
|
|
11
|
+
ok: bool
|
|
12
|
+
status: int
|
|
13
|
+
body: Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FeedbackPayload",
|
|
18
|
+
"SendResult",
|
|
19
|
+
]
|
|
20
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "loopengine"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official LoopEngine SDK for sending feedback to the Ingest API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "LoopEngine", email = "support@loopengine.dev" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["loopengine", "feedback", "sdk"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://loopengine.dev"
|
|
31
|
+
Repository = "https://github.com/LoopEngine-dev/loopengine-sdks"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = ["pytest", "pytest-cov"]
|
|
35
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, Mapping, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from loopengine.async_client import AsyncLoopEngine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_async_send_wraps_sync_client(monkeypatch) -> None:
|
|
10
|
+
calls: list[Tuple[Any, ...]] = []
|
|
11
|
+
|
|
12
|
+
async def run() -> None:
|
|
13
|
+
client = AsyncLoopEngine("pk", "secret", "proj_123")
|
|
14
|
+
|
|
15
|
+
# Patch the underlying synchronous client's send to track calls and return a sentinel.
|
|
16
|
+
orig_send = client._client.send # type: ignore[attr-defined]
|
|
17
|
+
|
|
18
|
+
def fake_send(payload: Any):
|
|
19
|
+
calls.append((payload,))
|
|
20
|
+
return "ok" # sentinel
|
|
21
|
+
|
|
22
|
+
setattr(client._client, "send", fake_send) # type: ignore[attr-defined]
|
|
23
|
+
|
|
24
|
+
result = await client.send({"message": "hi"})
|
|
25
|
+
assert result == "ok"
|
|
26
|
+
|
|
27
|
+
asyncio.run(run())
|
|
28
|
+
assert calls == [({"message": "hi"},)]
|
|
29
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Mapping, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from loopengine.client import LoopEngine
|
|
9
|
+
from loopengine.constants import FEEDBACK_PATH
|
|
10
|
+
from loopengine.exceptions import LoopEngineError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_mock_transport(expected_status: int = 200, expected_body: Mapping[str, Any] | None = None):
|
|
14
|
+
calls: list[Tuple[str, bytes, Mapping[str, str], Optional[float]]] = []
|
|
15
|
+
|
|
16
|
+
def transport(url: str, body: bytes, headers: Mapping[str, str], timeout: Optional[float]):
|
|
17
|
+
calls.append((url, body, headers, timeout))
|
|
18
|
+
payload = expected_body if expected_body is not None else {"ok": True}
|
|
19
|
+
return expected_status, "OK", json.dumps(payload).encode("utf-8")
|
|
20
|
+
|
|
21
|
+
return transport, calls
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_send_injects_project_id_and_uses_auth_headers() -> None:
|
|
25
|
+
transport, calls = make_mock_transport()
|
|
26
|
+
client = LoopEngine("pk", "secret", "proj_123", transport=transport)
|
|
27
|
+
|
|
28
|
+
result = client.send({"message": "hello"})
|
|
29
|
+
|
|
30
|
+
assert result.ok is True
|
|
31
|
+
assert result.status == 200
|
|
32
|
+
assert isinstance(result.body, dict)
|
|
33
|
+
|
|
34
|
+
assert len(calls) == 1
|
|
35
|
+
url, body, headers, timeout = calls[0]
|
|
36
|
+
|
|
37
|
+
assert FEEDBACK_PATH in url
|
|
38
|
+
data = json.loads(body.decode("utf-8"))
|
|
39
|
+
assert data["message"] == "hello"
|
|
40
|
+
assert data["project_id"] == "proj_123"
|
|
41
|
+
|
|
42
|
+
# Auth headers should be present.
|
|
43
|
+
assert "X-Project-Key" in headers
|
|
44
|
+
assert headers["X-Project-Key"] == "pk"
|
|
45
|
+
assert "X-Timestamp" in headers
|
|
46
|
+
assert "X-Signature" in headers
|
|
47
|
+
assert headers["Content-Type"] == "application/json"
|
|
48
|
+
assert timeout == 10.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_send_raises_on_non_2xx() -> None:
|
|
52
|
+
def failing_transport(url: str, body: bytes, headers: Mapping[str, str], timeout: Optional[float]):
|
|
53
|
+
return 400, "Bad Request", b'{"error":"invalid"}'
|
|
54
|
+
|
|
55
|
+
client = LoopEngine("pk", "secret", "proj_123", transport=failing_transport)
|
|
56
|
+
|
|
57
|
+
with pytest.raises(LoopEngineError) as excinfo:
|
|
58
|
+
client.send({"message": "bad"})
|
|
59
|
+
|
|
60
|
+
err = excinfo.value
|
|
61
|
+
assert err.status == 400
|
|
62
|
+
assert "invalid" in (err.body or "")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_send_accepts_non_mapping_payloads() -> None:
|
|
66
|
+
transport, calls = make_mock_transport()
|
|
67
|
+
client = LoopEngine("pk", "secret", "proj_123", transport=transport)
|
|
68
|
+
|
|
69
|
+
class Payload:
|
|
70
|
+
def __init__(self, message: str) -> None:
|
|
71
|
+
self.message = message
|
|
72
|
+
|
|
73
|
+
result = client.send(Payload("hello"))
|
|
74
|
+
assert result.ok is True
|
|
75
|
+
|
|
76
|
+
_, body, _, _ = calls[0]
|
|
77
|
+
data = json.loads(body.decode("utf-8"))
|
|
78
|
+
assert data["message"] == "hello"
|
|
79
|
+
assert data["project_id"] == "proj_123"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_constructor_rejects_missing_credentials() -> None:
|
|
83
|
+
with pytest.raises(ValueError):
|
|
84
|
+
LoopEngine("", "secret", "proj")
|
|
85
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from loopengine.sign import sha256_hex, sign_request
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_sha256_hex_matches_known_value() -> None:
|
|
7
|
+
body = b"hello world"
|
|
8
|
+
# Precomputed using Python's hashlib.sha256.
|
|
9
|
+
assert sha256_hex(body) == "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_sign_request_produces_stable_output() -> None:
|
|
13
|
+
secret = "test-secret"
|
|
14
|
+
method = "POST"
|
|
15
|
+
path = "/feedback"
|
|
16
|
+
timestamp = "1700000000"
|
|
17
|
+
body_hex = "deadbeef"
|
|
18
|
+
|
|
19
|
+
sig1 = sign_request(secret, method, path, timestamp, body_hex)
|
|
20
|
+
sig2 = sign_request(secret, method, path, timestamp, body_hex)
|
|
21
|
+
|
|
22
|
+
assert sig1 == sig2
|
|
23
|
+
# Base64url: only URL-safe characters and no padding.
|
|
24
|
+
assert "=" not in sig1
|
|
25
|
+
assert all(ch.isalnum() or ch in "-_" for ch in sig1)
|
|
26
|
+
|