marpledata 3.2.0__tar.gz → 3.2.2__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.
Files changed (48) hide show
  1. marpledata-3.2.2/CHANGELOG.md +39 -0
  2. {marpledata-3.2.0 → marpledata-3.2.2}/LICENSE +1 -1
  3. {marpledata-3.2.0 → marpledata-3.2.2}/PKG-INFO +3 -1
  4. {marpledata-3.2.0 → marpledata-3.2.2}/README.md +2 -0
  5. {marpledata-3.2.0 → marpledata-3.2.2}/pyproject.toml +1 -1
  6. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/__init__.py +1 -1
  7. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/db/datastream.py +10 -2
  8. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/insight.py +38 -1
  9. marpledata-3.2.2/src/marple/profile.json +95 -0
  10. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/utils.py +52 -1
  11. marpledata-3.2.2/tests/test_reliability.py +102 -0
  12. {marpledata-3.2.0 → marpledata-3.2.2}/uv.lock +1 -1
  13. marpledata-3.2.0/.uv-cache/sdists-v9/.git +0 -0
  14. {marpledata-3.2.0 → marpledata-3.2.2}/.gitignore +0 -0
  15. {marpledata-3.2.0 → marpledata-3.2.2}/.python-version +0 -0
  16. {marpledata-3.2.0 → marpledata-3.2.2}/.uv-cache/.gitignore +0 -0
  17. {marpledata-3.2.0 → marpledata-3.2.2}/.uv-cache/.lock +0 -0
  18. {marpledata-3.2.0 → marpledata-3.2.2}/.uv-cache/CACHEDIR.TAG +0 -0
  19. {marpledata-3.2.0 → marpledata-3.2.2}/.uv-cache/interpreter-v4/c2c2931c8a99aae1/588e1e7c128e2872.msgpack +0 -0
  20. {marpledata-3.2.0 → marpledata-3.2.2}/.uv-cache/sdists-v9/.gitignore +0 -0
  21. {marpledata-3.2.0 → marpledata-3.2.2}/AGENTS.md +0 -0
  22. {marpledata-3.2.0 → marpledata-3.2.2}/CONTRIBUTING.md +0 -0
  23. {marpledata-3.2.0 → marpledata-3.2.2}/docs/DEPLOYMENT.md +0 -0
  24. {marpledata-3.2.0 → marpledata-3.2.2}/docs/_static/custom.css +0 -0
  25. {marpledata-3.2.0 → marpledata-3.2.2}/docs/_static/favicon.png +0 -0
  26. {marpledata-3.2.0 → marpledata-3.2.2}/docs/_static/logo.png +0 -0
  27. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api/db.rst +0 -0
  28. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api/insight.rst +0 -0
  29. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api/marple.Insight.rst +0 -0
  30. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api/marple.db.DB.rst +0 -0
  31. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api/marple.db.DataStream.rst +0 -0
  32. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api/marple.db.Dataset.rst +0 -0
  33. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api/marple.db.DatasetList.rst +0 -0
  34. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api/marple.db.Signal.rst +0 -0
  35. {marpledata-3.2.0 → marpledata-3.2.2}/docs/api.rst +0 -0
  36. {marpledata-3.2.0 → marpledata-3.2.2}/docs/conf.py +0 -0
  37. {marpledata-3.2.0 → marpledata-3.2.2}/docs/getting-started.rst +0 -0
  38. {marpledata-3.2.0 → marpledata-3.2.2}/docs/index.rst +0 -0
  39. {marpledata-3.2.0 → marpledata-3.2.2}/docs/tutorials.rst +0 -0
  40. {marpledata-3.2.0 → marpledata-3.2.2}/example.py +0 -0
  41. {marpledata-3.2.0 → marpledata-3.2.2}/examples_race.csv +0 -0
  42. {marpledata-3.2.0 → marpledata-3.2.2}/pytest.xml +0 -0
  43. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/db/__init__.py +0 -0
  44. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/db/constants.py +0 -0
  45. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/db/dataset.py +0 -0
  46. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/db/signal.py +0 -0
  47. {marpledata-3.2.0 → marpledata-3.2.2}/src/marple/py.typed +0 -0
  48. {marpledata-3.2.0 → marpledata-3.2.2}/tests/test_sdk.py +0 -0
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Python SDK package `marpledata` will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [3.2.2] - 2026-06-01
9
+
10
+ ### Changed
11
+
12
+ - Added default request timeouts and bounded retries to improve SDK reliability on spotty networks.
13
+ - Retried idempotent direct-storage uploads while keeping Marple API POST retries conservative.
14
+
15
+ ## [3.2.1] - 2026-05-22
16
+
17
+ ### Changed
18
+
19
+ - Included the SDK version in the `X-Request-Source` header sent by the Marple Insight client, matching the Marple DB client.
20
+
21
+ ## [3.2.0] - 2026-05-22
22
+
23
+ ### Added
24
+
25
+ - Added the current Marple DB ingestion flow to `DataStream.push_file`.
26
+ - Added automatic upload mode handling for server upload, Azure direct upload, single presigned upload, and multipart upload.
27
+ - Added `concurrency` to control parallel direct-storage uploads.
28
+ - Added `upload_mode="server"` to force uploads through the Marple DB API server when direct storage URLs are blocked.
29
+ - Added `azure-storage-blob` as a runtime dependency for Azure-backed direct uploads.
30
+
31
+ ### Changed
32
+
33
+ - Updated upload error handling to abort failed ingestions with a reason.
34
+ - Undeprecated `DB.delete_stream` and `DB.delete_dataset` convenience methods.
35
+ - Expanded README and Sphinx documentation for large-file uploads, forced server uploads, and integration test configuration.
36
+
37
+ ### Notes
38
+
39
+ - `DB.push_file`, `DB.download_signal`, and `DB.update_metadata` remain deprecated compatibility paths. Prefer `DataStream`, `Dataset`, and `Signal` methods for new code.
@@ -1,4 +1,4 @@
1
- Copyright [2025] Marple BV
1
+ Copyright 2022-2026 Marple BV
2
2
 
3
3
  Licensed under the Apache License, Version 2.0 (the "License");
4
4
  you may not use this file except in compliance with the License.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marpledata
3
- Version: 3.2.0
3
+ Version: 3.2.2
4
4
  Summary: Marple SDK for Python
5
5
  Project-URL: Homepage, https://www.marpledata.com/
6
6
  Project-URL: Documentation, https://marpledata.gitlab.io/marple-sdk/
@@ -51,6 +51,8 @@ from marple import DB # Marple DB
51
51
  from marple import Insight # Marple Insight
52
52
  ```
53
53
 
54
+ For release notes, see [`CHANGELOG.md`](CHANGELOG.md).
55
+
54
56
  ## Marple DB
55
57
 
56
58
  To get started:
@@ -17,6 +17,8 @@ from marple import DB # Marple DB
17
17
  from marple import Insight # Marple Insight
18
18
  ```
19
19
 
20
+ For release notes, see [`CHANGELOG.md`](CHANGELOG.md).
21
+
20
22
  ## Marple DB
21
23
 
22
24
  To get started:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "marpledata"
3
- version = "3.2.0"
3
+ version = "3.2.2"
4
4
  description = "Marple SDK for Python"
5
5
  authors = [
6
6
  { name = "Matthias Baert", email = "support@marpledata.com" },
@@ -3,4 +3,4 @@ from .db import DB
3
3
  from .insight import Insight
4
4
 
5
5
  __all__ = ["DB", "Insight", "db"]
6
- __version__ = "3.2.0"
6
+ __version__ = "3.2.2"
@@ -153,7 +153,11 @@ class DataStream(BaseModel):
153
153
  if init.presigned_url is None:
154
154
  raise ValueError("Single upload mode without presigned_url")
155
155
  with path.open("rb") as file:
156
- response = requests.put(init.presigned_url, data=file, headers={"Content-Length": str(file_size)})
156
+ response = self._client.storage_session.put(
157
+ init.presigned_url,
158
+ data=file,
159
+ headers={"Content-Length": str(file_size)},
160
+ )
157
161
  _validate_storage_response(response, "Storage PUT failed")
158
162
 
159
163
  def _upload_multipart(
@@ -183,7 +187,11 @@ class DataStream(BaseModel):
183
187
  file.seek(offset)
184
188
  chunk = file.read(part_len)
185
189
 
186
- response = requests.put(part.url, data=chunk, headers={"Content-Length": str(part_len)})
190
+ response = self._client.storage_session.put(
191
+ part.url,
192
+ data=chunk,
193
+ headers={"Content-Length": str(part_len)},
194
+ )
187
195
  _validate_storage_response(response, f"Part {part.part_number} storage PUT failed")
188
196
 
189
197
  with ThreadPoolExecutor(max_workers=concurrency) as executor:
@@ -2,6 +2,7 @@ from pathlib import Path
2
2
  from typing import Optional
3
3
  from urllib import request
4
4
 
5
+ import marple
5
6
  import requests
6
7
  from marple.utils import validate_response
7
8
  from requests import Response
@@ -25,7 +26,7 @@ class Insight:
25
26
  bearer_token = f"Bearer {api_token}"
26
27
  self.session = requests.Session()
27
28
  self.session.headers.update({"Authorization": bearer_token})
28
- self.session.headers.update({"X-Request-Source": "sdk/python"})
29
+ self.session.headers.update({"X-Request-Source": f"sdk/python:{marple.__version__}"})
29
30
  self.check_connection()
30
31
 
31
32
  # User functions #
@@ -181,3 +182,39 @@ class Insight:
181
182
 
182
183
  request.urlretrieve(download_url, target_path)
183
184
  return target_path
185
+
186
+ You generate Marple analysis plots from user questions.
187
+
188
+ Supported plot types:
189
+ - timeseries: use for one or more signals over time.
190
+ - scatter: use for relationships between signals. Signal order is [x, y, optional color].
191
+ - heatmap: use for histograms of x/y relationships. Signal order is [x, y].
192
+ - map: use for geographic trajectory data. Signal order is [latitude, longitude, optional color].
193
+ - frequency: use for frequency/FFT analysis of one or more signals.
194
+ - state: use for discrete or categorical state signals.
195
+ - message: use for message/log views. Use for sparse text-based data.
196
+ - metadata: use for dataset metadata views. Use an empty signals list.
197
+
198
+ Always call search_signals before add_plot. Only use signal keys returned by search_signals. Search results may be truncated.
199
+ This is a single-turn plot generation task that creates exactly one plot. You cannot ask the user follow-up questions.
200
+ If the request is ambiguous, make a reasonable best effort using search_signals.
201
+ Provide a short description in add_plot summarizing the plot for the user.
202
+
203
+ Examples:
204
+ User: "all speed signals"
205
+ search_signals({"text":"speed"})
206
+ search_signals({"text":"*speed*","glob":true})
207
+
208
+
209
+ User: "speed and rpm over time"
210
+ add_plot({"plotType":"timeseries","signals":["speed_key","rpm_key"],"description":"Created speed and rpm over time."})
211
+
212
+ User: "speed vs throttle colored by rpm"
213
+ add_plot({"plotType":"scatter","signals":["throttle_key","speed_key","rpm_key"],"description":"Created speed versus throttle, colored by rpm."})
214
+
215
+ User: "show me dataset metadata"
216
+ add_plot({"plotType":"metadata","signals":[],"description":"Created a metadata plot."})
217
+
218
+ Dataset profile:
219
+
220
+
@@ -0,0 +1,95 @@
1
+ {
2
+ "dataset": {
3
+ "id": "{\"dataset\": \"SH-01 2026-05-31 Spa\"}",
4
+ "name": "SH-01 2026-05-31 Spa",
5
+ "realtime": false,
6
+ "timestampStart": "31/05/2026 08:27",
7
+ "timestampStop": "31/05/2026 08:48",
8
+ "duration": "21m15s",
9
+ "metadata": {
10
+ "dataset": "SH-01 2026-05-31 Spa",
11
+ "session": "SH",
12
+ "outing": "Outing 1",
13
+ "car": "M42",
14
+ "driver": "JS",
15
+ "track": "Spa",
16
+ "acquisition type": "Cable"
17
+ }
18
+ },
19
+ "signalCount": 126,
20
+ "facets": {
21
+ "signalStatus": {
22
+ "FROZEN_TO_COLD": 0,
23
+ "COLD": 0,
24
+ "HOT": 0,
25
+ "COLD_TO_HOT": 0
26
+ },
27
+ "datatype": { "number": 121, "text": 5 },
28
+ "metadata.count_text": { "0": 121, "11486": 5 },
29
+ "metadata.count_value": {
30
+ "0": 5,
31
+ "1275": 55,
32
+ "2550": 42,
33
+ "6376": 11,
34
+ "11486": 13
35
+ },
36
+ "metadata.group": {
37
+ "acceleration": 3,
38
+ "velocity": 9,
39
+ "brakes": 7,
40
+ "controls": 4,
41
+ "position": 4,
42
+ "lap": 6,
43
+ "drs": 4,
44
+ "damage": 6,
45
+ "engine": 4,
46
+ "general": 2,
47
+ "fuel": 3,
48
+ "gear": 2,
49
+ "gforce": 3,
50
+ "gps": 2,
51
+ "pit": 4,
52
+ "spectating": 2,
53
+ "kers": 2,
54
+ "cars": 2,
55
+ "orientation": 3,
56
+ "lights": 1,
57
+ "sector": 3,
58
+ "session": 2,
59
+ "sli": 1,
60
+ "speed": 1,
61
+ "suspension": 12,
62
+ "team": 1,
63
+ "distance": 1,
64
+ "track": 2,
65
+ "traction": 1,
66
+ "tyres": 17,
67
+ "flags": 2,
68
+ "wheels": 4,
69
+ "displacement": 3,
70
+ "rotation": 3
71
+ },
72
+ "metadata.unit": {
73
+ "rad/s²": 3,
74
+ "rad/s": 3,
75
+ "enum": 10,
76
+ "%": 2,
77
+ "°C": 9,
78
+ "rpm": 3,
79
+ "L": 2,
80
+ "G": 2,
81
+ "g": 1,
82
+ "°": 2,
83
+ "J": 2,
84
+ "m": 9,
85
+ "s": 6,
86
+ "km/h": 1,
87
+ "rad": 7,
88
+ "m/s": 15,
89
+ "m/s²": 4,
90
+ "mm": 4,
91
+ "psi": 4
92
+ }
93
+ },
94
+ "metadataKeys": ["count_text", "count_value", "description", "group", "unit"]
95
+ }
@@ -15,6 +15,8 @@ from marple.db.constants import (
15
15
  COL_VAL_TEXT,
16
16
  COL_VAL_TEXT_IDX,
17
17
  )
18
+ from requests.adapters import HTTPAdapter
19
+ from urllib3.util.retry import Retry
18
20
 
19
21
 
20
22
  def validate_response(response: requests.Response, failure_message: str) -> dict:
@@ -34,7 +36,20 @@ def validate_response(response: requests.Response, failure_message: str) -> dict
34
36
  return r_json
35
37
 
36
38
 
39
+ class TimeoutHTTPAdapter(HTTPAdapter):
40
+ def __init__(self, *args, timeout=None, **kwargs):
41
+ self.timeout = timeout
42
+ super().__init__(*args, **kwargs)
43
+
44
+ def send(self, request, **kwargs):
45
+ if kwargs.get("timeout") is None:
46
+ kwargs["timeout"] = self.timeout
47
+ return super().send(request, **kwargs)
48
+
49
+
37
50
  class DBClient:
51
+ DEFAULT_TIMEOUT = (5, 300)
52
+
38
53
  def __init__(self, api_token: str, api_url: str, datapool: str, cache_folder: str):
39
54
  self.api_token = api_token
40
55
  self.api_url = api_url
@@ -42,10 +57,46 @@ class DBClient:
42
57
  self.cache_folder = cache_folder
43
58
  self._signal_map: dict[str, int] | None = None
44
59
 
45
- self.session = requests.Session()
60
+ self.session = self._create_api_session()
61
+ self.storage_session = self._create_storage_session()
46
62
  self.session.headers.update({"Authorization": f"Bearer {self.api_token}"})
47
63
  self.session.headers.update({"X-Request-Source": f"sdk/python:{marple.__version__}"})
48
64
 
65
+ def _create_api_session(self) -> requests.Session:
66
+ retry = Retry(
67
+ total=5,
68
+ connect=5,
69
+ read=2,
70
+ status=2,
71
+ backoff_factor=0.5,
72
+ status_forcelist=(429, 502, 503, 504),
73
+ allowed_methods=frozenset({"GET", "HEAD", "OPTIONS", "DELETE"}),
74
+ respect_retry_after_header=True,
75
+ raise_on_status=False,
76
+ )
77
+ return self._create_session(retry)
78
+
79
+ def _create_storage_session(self) -> requests.Session:
80
+ retry = Retry(
81
+ total=5,
82
+ connect=5,
83
+ read=5,
84
+ status=5,
85
+ backoff_factor=0.5,
86
+ status_forcelist=(429, 500, 502, 503, 504),
87
+ allowed_methods=frozenset({"PUT", "GET", "HEAD"}),
88
+ respect_retry_after_header=True,
89
+ raise_on_status=False,
90
+ )
91
+ return self._create_session(retry)
92
+
93
+ def _create_session(self, retry: Retry) -> requests.Session:
94
+ session = requests.Session()
95
+ adapter = TimeoutHTTPAdapter(max_retries=retry, timeout=self.DEFAULT_TIMEOUT)
96
+ session.mount("https://", adapter)
97
+ session.mount("http://", adapter)
98
+ return session
99
+
49
100
  def get(self, url: str, *args, **kwargs) -> requests.Response:
50
101
  return self.session.get(f"{self.api_url}{url}", *args, **kwargs)
51
102
 
@@ -0,0 +1,102 @@
1
+ from collections.abc import Iterator
2
+ from contextlib import contextmanager
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+ from threading import Thread
5
+
6
+ import pytest
7
+ from marple.utils import DBClient
8
+ from requests import Response
9
+ from requests.adapters import HTTPAdapter
10
+
11
+
12
+ @contextmanager
13
+ def retry_test_server(statuses: list[int]) -> Iterator[tuple[str, dict[str, int]]]:
14
+ calls = {"GET": 0, "POST": 0, "PUT": 0}
15
+
16
+ class Handler(BaseHTTPRequestHandler):
17
+ def do_GET(self) -> None:
18
+ self._handle()
19
+
20
+ def do_POST(self) -> None:
21
+ self._handle()
22
+
23
+ def do_PUT(self) -> None:
24
+ self._handle()
25
+
26
+ def log_message(self, format: str, *args) -> None:
27
+ return
28
+
29
+ def _handle(self) -> None:
30
+ calls[self.command] += 1
31
+ length = int(self.headers.get("Content-Length", "0"))
32
+ if length:
33
+ self.rfile.read(length)
34
+ status = statuses[min(calls[self.command] - 1, len(statuses) - 1)]
35
+ self.send_response(status)
36
+ self.send_header("Content-Length", "0")
37
+ self.end_headers()
38
+
39
+ server = HTTPServer(("127.0.0.1", 0), Handler)
40
+ thread = Thread(target=server.serve_forever)
41
+ thread.start()
42
+
43
+ try:
44
+ host, port = server.server_address
45
+ yield f"http://{host}:{port}", calls
46
+ finally:
47
+ server.shutdown()
48
+ thread.join()
49
+ server.server_close()
50
+
51
+
52
+ def make_client(api_url: str = "http://example.test") -> DBClient:
53
+ return DBClient(
54
+ api_token="test-token",
55
+ api_url=api_url,
56
+ datapool="default",
57
+ cache_folder="./.test_cache",
58
+ )
59
+
60
+
61
+ def test_api_session_retries_retryable_get_status() -> None:
62
+ with retry_test_server([503, 200]) as (api_url, calls):
63
+ response = make_client(api_url).get("/health")
64
+
65
+ assert response.status_code == 200
66
+ assert calls["GET"] == 2
67
+
68
+
69
+ def test_default_timeout_applied(monkeypatch: pytest.MonkeyPatch) -> None:
70
+ timeouts = []
71
+
72
+ def fake_send(self, request, **kwargs) -> Response:
73
+ timeouts.append(kwargs["timeout"])
74
+ response = Response()
75
+ response.status_code = 200
76
+ response.url = request.url
77
+ response._content = b"{}"
78
+ return response
79
+
80
+ monkeypatch.setattr(HTTPAdapter, "send", fake_send)
81
+
82
+ client = make_client()
83
+ client.get("/health")
84
+ client.get("/health", timeout=(1, 2))
85
+
86
+ assert timeouts == [DBClient.DEFAULT_TIMEOUT, (1, 2)]
87
+
88
+
89
+ def test_storage_put_retries_retryable_status() -> None:
90
+ with retry_test_server([503, 200]) as (storage_url, calls):
91
+ response = make_client().storage_session.put(f"{storage_url}/blob", data=b"payload")
92
+
93
+ assert response.status_code == 200
94
+ assert calls["PUT"] == 2
95
+
96
+
97
+ def test_api_post_body_is_not_replayed_after_status_failure() -> None:
98
+ with retry_test_server([503, 200]) as (api_url, calls):
99
+ response = make_client(api_url).post("/ingestion", data=b"payload")
100
+
101
+ assert response.status_code == 503
102
+ assert calls["POST"] == 1
@@ -599,7 +599,7 @@ wheels = [
599
599
 
600
600
  [[package]]
601
601
  name = "marpledata"
602
- version = "3.2.0"
602
+ version = "3.2.2"
603
603
  source = { editable = "." }
604
604
  dependencies = [
605
605
  { name = "azure-storage-blob" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes