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.
@@ -0,0 +1,16 @@
1
+ # Byte-compiled / cache directories
2
+ __pycache__/
3
+ loopengine/__pycache__/
4
+
5
+ # Build artifacts
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+
10
+ # uv cache and lock files
11
+ .uv/
12
+ uv.lock
13
+ poetry.lock
14
+ pipenv.lock
15
+ *.pyc
16
+
@@ -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
+
@@ -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,3 @@
1
+ BASE_URL = "https://api.loopengine.dev"
2
+ FEEDBACK_PATH = "/feedback"
3
+
@@ -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
+