loopengine 1.0.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+
loopengine/client.py ADDED
@@ -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
+
loopengine/sign.py ADDED
@@ -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
+
loopengine/types.py ADDED
@@ -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,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,11 @@
1
+ loopengine/__init__.py,sha256=Cs5lhJjDumFk0lL70cEsiP8Aptf3VUx0VcE5_5k8Jco,340
2
+ loopengine/async_client.py,sha256=twFepA41vbRl4IqWZCGEvx11I7HPsuACPo1wJuj0GkU,1024
3
+ loopengine/client.py,sha256=xqOqbx2OIwRtgKf5R6QhFK2W-xXPsdfxXhrWAZjrNEQ,4757
4
+ loopengine/constants.py,sha256=iZp2o2TdkrZwCx8mvOkeAR8P-fve9sXYegl3edYmxtw,69
5
+ loopengine/exceptions.py,sha256=b_KlCDqS94Zdpupe6r-SoJED10vaZ2kNXVyi3SK7hwU,866
6
+ loopengine/sign.py,sha256=Mnz3SSoSgpA7W2ppD8AF_YVsS5HonAqySp7YjhHR3TU,1315
7
+ loopengine/types.py,sha256=U6MWz5VEHDBRrLi04waA17EIy1ci7OXJvTqF9whFo1w,271
8
+ loopengine-1.0.0.dist-info/METADATA,sha256=8u_DlFB1VZJHcmTjQba27j__kCn9cxdf3VU5sqO1OCo,4485
9
+ loopengine-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ loopengine-1.0.0.dist-info/licenses/LICENSE,sha256=3Fyiw8Brk__kyzxEZ4ZSulqa4N8khKbF17tUhzO2OuM,1068
11
+ loopengine-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
+