polnor 0.1.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.
polnor/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Polnor Python SDK.
2
+
3
+ Today this exposes the MLflow-compatible tracking surface
4
+ (:mod:`polnor.mlflow`). More modules — auth, jobs, runs, datasets —
5
+ land here as the API stabilises.
6
+
7
+ The SDK is auto-configured from environment variables when running
8
+ inside a Polnor job. Outside a job, set them yourself::
9
+
10
+ export POLNOR_API_URL=https://api.polnor.net
11
+ export POLNOR_TOKEN=<personal access token>
12
+ export POLNOR_EXPERIMENT_ID=<uuid of an experiment>
13
+
14
+ Then::
15
+
16
+ from polnor import mlflow
17
+ with mlflow.start_run() as run:
18
+ mlflow.log_param("lr", 0.01)
19
+ mlflow.log_metric("accuracy", 0.93)
20
+ """
21
+
22
+ from . import mlflow
23
+
24
+ __all__ = ["mlflow"]
25
+ __version__ = "0.1.0"
polnor/_client.py ADDED
@@ -0,0 +1,132 @@
1
+ """Thin HTTP client used by polnor.mlflow.
2
+
3
+ Kept separate so future SDK modules (jobs, datasets) can share the
4
+ same auth + retry plumbing without dragging the MLflow surface in.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ import urllib.parse
12
+ from dataclasses import dataclass
13
+ from typing import Any, Mapping
14
+
15
+ try:
16
+ import requests
17
+ except ImportError as e: # pragma: no cover — caught early in install
18
+ raise ImportError(
19
+ "polnor SDK requires the 'requests' package. "
20
+ "Install with `pip install polnor` or `pip install requests`."
21
+ ) from e
22
+
23
+
24
+ class PolnorAPIError(RuntimeError):
25
+ """Raised on a non-2xx API response with the body verbatim."""
26
+
27
+ def __init__(self, status: int, body: str, *, method: str, url: str):
28
+ super().__init__(f"{method} {url} → {status}: {body}")
29
+ self.status = status
30
+ self.body = body
31
+
32
+
33
+ @dataclass
34
+ class Config:
35
+ """SDK configuration. Read from env by default; override for tests."""
36
+
37
+ api_url: str
38
+ token: str
39
+ workspace_slug: str | None = None
40
+ timeout: float = 30.0
41
+ max_retries: int = 3
42
+ backoff_base: float = 0.5
43
+
44
+ @classmethod
45
+ def from_env(cls) -> "Config":
46
+ api_url = os.environ.get("POLNOR_API_URL", "").rstrip("/")
47
+ token = os.environ.get("POLNOR_TOKEN", "")
48
+ workspace = os.environ.get("POLNOR_WORKSPACE_SLUG") or None
49
+ if not api_url:
50
+ raise RuntimeError(
51
+ "POLNOR_API_URL is not set — point it at https://api.polnor.net "
52
+ "(or your self-hosted control plane)."
53
+ )
54
+ if not token:
55
+ raise RuntimeError(
56
+ "POLNOR_TOKEN is not set — generate a personal access token "
57
+ "from /settings/tokens and export POLNOR_TOKEN."
58
+ )
59
+ return cls(api_url=api_url, token=token, workspace_slug=workspace)
60
+
61
+
62
+ class Client:
63
+ """HTTP client with token auth + retry on 5xx / connection errors.
64
+
65
+ Idempotency: every method here is safe to retry — they're either
66
+ pure GETs or POST/PATCH endpoints that the server side already
67
+ handles idempotently (start_run with the same body would create a
68
+ second run, but that's a deliberate design — MLflow does the same).
69
+ """
70
+
71
+ def __init__(self, cfg: Config | None = None):
72
+ self.cfg = cfg or Config.from_env()
73
+ self._session = requests.Session()
74
+ self._session.headers.update({
75
+ "Authorization": f"Bearer {self.cfg.token}",
76
+ "Content-Type": "application/json",
77
+ "User-Agent": "polnor-sdk-python/0.1.0",
78
+ })
79
+ if self.cfg.workspace_slug:
80
+ self._session.headers["X-Workspace-Slug"] = self.cfg.workspace_slug
81
+
82
+ # --- low-level ---
83
+
84
+ def request(self, method: str, path: str, *, json: Any | None = None,
85
+ params: Mapping[str, Any] | None = None) -> Any:
86
+ url = self._build_url(path, params)
87
+ last_err: Exception | None = None
88
+ for attempt in range(self.cfg.max_retries):
89
+ try:
90
+ resp = self._session.request(
91
+ method, url, json=json, timeout=self.cfg.timeout
92
+ )
93
+ except requests.RequestException as e:
94
+ last_err = e
95
+ self._sleep(attempt)
96
+ continue
97
+
98
+ if resp.status_code >= 500:
99
+ last_err = PolnorAPIError(resp.status_code, resp.text, method=method, url=url)
100
+ self._sleep(attempt)
101
+ continue
102
+ if resp.status_code >= 400:
103
+ raise PolnorAPIError(resp.status_code, resp.text, method=method, url=url)
104
+
105
+ if resp.status_code == 204 or not resp.content:
106
+ return None
107
+ return resp.json()
108
+
109
+ if last_err:
110
+ raise last_err
111
+ return None
112
+
113
+ def _build_url(self, path: str, params: Mapping[str, Any] | None) -> str:
114
+ base = f"{self.cfg.api_url}/api/v1{path}"
115
+ if not params:
116
+ return base
117
+ return f"{base}?{urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})}"
118
+
119
+ def _sleep(self, attempt: int) -> None:
120
+ delay = self.cfg.backoff_base * (2 ** attempt)
121
+ time.sleep(delay)
122
+
123
+ # --- convenience wrappers ---
124
+
125
+ def post(self, path: str, json: Any | None = None) -> Any:
126
+ return self.request("POST", path, json=json)
127
+
128
+ def patch(self, path: str, json: Any | None = None) -> Any:
129
+ return self.request("PATCH", path, json=json)
130
+
131
+ def get(self, path: str, params: Mapping[str, Any] | None = None) -> Any:
132
+ return self.request("GET", path, params=params)
polnor/mlflow.py ADDED
@@ -0,0 +1,300 @@
1
+ """MLflow-compatible tracking surface for Polnor.
2
+
3
+ Drop-in shim for the bits of MLflow most jobs use::
4
+
5
+ from polnor import mlflow
6
+
7
+ with mlflow.start_run() as run:
8
+ mlflow.log_param("learning_rate", 0.01)
9
+ mlflow.log_param("optimizer", "adam")
10
+
11
+ for epoch in range(10):
12
+ mlflow.log_metric("loss", train_loss, step=epoch)
13
+ mlflow.log_metric("accuracy", val_acc, step=epoch)
14
+
15
+ mlflow.set_tag("git_commit", os.environ["CI_COMMIT_SHA"])
16
+
17
+ The active run is stored in a thread-local so nested calls in the
18
+ same job don't fight over a global; calling ``start_run`` twice
19
+ without ``end_run`` between them stacks runs the way MLflow's nested
20
+ runs do.
21
+
22
+ The following MLflow API is *not* implemented yet and will raise
23
+ ``NotImplementedError``:
24
+
25
+ * ``log_artifact`` — needs a presigned-URL upload flow
26
+ * ``log_image`` / ``log_dict`` — derived from artifacts
27
+ * ``mlflow.search_runs`` — read-side, use the REST API directly
28
+
29
+ Server-side, metrics are stored as the *latest* value per key (JSONB
30
+ merge). A full time-series of ``(step, value, timestamp)`` per metric
31
+ is tracked separately in a future migration; until then,
32
+ ``log_metric(..., step=...)`` records the value but not the step.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+ import os
39
+ import threading
40
+ from dataclasses import dataclass
41
+ from typing import Any, Iterator, Mapping
42
+
43
+ from ._client import Client
44
+
45
+
46
+ # Thread-local stack of active runs; supports the nested-run pattern
47
+ # used by frameworks that spawn child runs per CV fold.
48
+ _local = threading.local()
49
+
50
+
51
+ def _stack() -> list["ActiveRun"]:
52
+ if not hasattr(_local, "runs"):
53
+ _local.runs = []
54
+ return _local.runs
55
+
56
+
57
+ def _client() -> Client:
58
+ """Single Client per thread, lazily constructed.
59
+
60
+ Reusing the same Session across calls means TLS handshakes and
61
+ auth resolution amortize across log_metric calls in a tight loop.
62
+ """
63
+ if not hasattr(_local, "client"):
64
+ _local.client = Client()
65
+ return _local.client
66
+
67
+
68
+ @dataclass
69
+ class ActiveRun:
70
+ """Handle to a running experiment_run on the server.
71
+
72
+ Returned by :func:`start_run`; usable as a context manager so the
73
+ run is finalized on scope exit even when an exception escapes.
74
+ """
75
+
76
+ experiment_id: str
77
+ run_id: str
78
+ name: str
79
+
80
+ def __enter__(self) -> "ActiveRun":
81
+ _stack().append(self)
82
+ return self
83
+
84
+ def __exit__(self, exc_type, exc, tb) -> None:
85
+ # End with status='failed' when an exception escaped, otherwise
86
+ # 'completed'. Errors in end_run itself are swallowed so they
87
+ # don't replace the user's exception.
88
+ status = "failed" if exc_type is not None else "completed"
89
+ try:
90
+ end_run(status=status)
91
+ except Exception:
92
+ pass
93
+
94
+
95
+ def _experiment_id_from_env() -> str:
96
+ exp_id = os.environ.get("POLNOR_EXPERIMENT_ID")
97
+ if not exp_id:
98
+ raise RuntimeError(
99
+ "No active experiment. Either:\n"
100
+ " - export POLNOR_EXPERIMENT_ID=<uuid>, or\n"
101
+ " - call mlflow.start_run(experiment_id=...)."
102
+ )
103
+ return exp_id
104
+
105
+
106
+ def start_run(
107
+ *,
108
+ experiment_id: str | None = None,
109
+ run_name: str | None = None,
110
+ params: Mapping[str, Any] | None = None,
111
+ tags: Mapping[str, str] | None = None,
112
+ ) -> ActiveRun:
113
+ """Start a new run and make it the active one.
114
+
115
+ The returned :class:`ActiveRun` is usable as a context manager. If
116
+ ``experiment_id`` is omitted, ``POLNOR_EXPERIMENT_ID`` is used.
117
+ """
118
+ exp_id = experiment_id or _experiment_id_from_env()
119
+ body: dict[str, Any] = {}
120
+ if run_name:
121
+ body["name"] = run_name
122
+ if params:
123
+ body["params"] = dict(params)
124
+ if tags:
125
+ body["tags"] = dict(tags)
126
+ resp = _client().post(f"/experiments/{exp_id}/runs", body)
127
+ run = ActiveRun(experiment_id=exp_id, run_id=resp["id"], name=resp.get("name", ""))
128
+ _stack().append(run)
129
+ return run
130
+
131
+
132
+ def active_run() -> ActiveRun | None:
133
+ """Return the run on top of the active stack, or None."""
134
+ s = _stack()
135
+ return s[-1] if s else None
136
+
137
+
138
+ def _require_active() -> ActiveRun:
139
+ run = active_run()
140
+ if run is None:
141
+ raise RuntimeError("No active run. Call start_run() first.")
142
+ return run
143
+
144
+
145
+ def end_run(status: str = "completed") -> None:
146
+ """Finish the active run with the given status.
147
+
148
+ Pops the top of the active stack — nested runs continue to use the
149
+ next-outer parent.
150
+ """
151
+ s = _stack()
152
+ if not s:
153
+ return # no-op when called outside a run
154
+ run = s.pop()
155
+ _client().patch(
156
+ f"/experiments/{run.experiment_id}/runs/{run.run_id}",
157
+ {"status": status},
158
+ )
159
+
160
+
161
+ def log_metric(key: str, value: float, step: int | None = None) -> None:
162
+ """Record a single metric value on the active run.
163
+
164
+ ``step`` is accepted for MLflow API compatibility but currently
165
+ ignored server-side (latest-value-wins). The server will track
166
+ full history once the per-step migration lands.
167
+ """
168
+ run = _require_active()
169
+ body: dict[str, Any] = {"key": key, "value": float(value)}
170
+ if step is not None:
171
+ body["step"] = int(step)
172
+ _client().post(
173
+ f"/experiments/{run.experiment_id}/runs/{run.run_id}/metrics",
174
+ body,
175
+ )
176
+
177
+
178
+ def log_metrics(metrics: Mapping[str, float], step: int | None = None) -> None:
179
+ """Batch variant — single round-trip per dict."""
180
+ run = _require_active()
181
+ _client().post(
182
+ f"/experiments/{run.experiment_id}/runs/{run.run_id}/metrics/batch",
183
+ {"metrics": {k: float(v) for k, v in metrics.items()}},
184
+ )
185
+ _ = step # see log_metric for why we accept-and-ignore
186
+
187
+
188
+ def log_param(key: str, value: Any) -> None:
189
+ """Record a hyperparameter on the active run."""
190
+ run = _require_active()
191
+ _client().post(
192
+ f"/experiments/{run.experiment_id}/runs/{run.run_id}/params",
193
+ {"key": key, "value": value},
194
+ )
195
+
196
+
197
+ def log_params(params: Mapping[str, Any]) -> None:
198
+ """Convenience: log_param in a loop."""
199
+ for k, v in params.items():
200
+ log_param(k, v)
201
+
202
+
203
+ def set_tag(key: str, value: str) -> None:
204
+ run = _require_active()
205
+ _client().post(
206
+ f"/experiments/{run.experiment_id}/runs/{run.run_id}/tags",
207
+ {"key": key, "value": str(value)},
208
+ )
209
+
210
+
211
+ def set_tags(tags: Mapping[str, str]) -> None:
212
+ for k, v in tags.items():
213
+ set_tag(k, v)
214
+
215
+
216
+ def log_artifact(local_path: str, artifact_path: str | None = None) -> None:
217
+ """Upload a local file as an artifact of the active run.
218
+
219
+ Two-step flow: ask the server for a presigned PUT URL, then
220
+ stream the file to S3 directly. The server records the artifact
221
+ path in the run's JSONB ``artifacts`` list. Files larger than
222
+ ~100 MB should ideally be chunked; we send a single PUT here and
223
+ let the underlying TLS connection take care of the streaming.
224
+ """
225
+ import os
226
+ if not os.path.isfile(local_path):
227
+ raise FileNotFoundError(local_path)
228
+ target = artifact_path or os.path.basename(local_path)
229
+ run = _require_active()
230
+ presign = _client().post(
231
+ f"/experiments/{run.experiment_id}/runs/{run.run_id}/artifacts/presigned-url",
232
+ {"path": target, "content_type": "application/octet-stream"},
233
+ )
234
+ upload_url = presign["upload_url"]
235
+
236
+ # We bypass the SDK's auth-bearing Session for the actual PUT —
237
+ # presigned URLs carry their own signature and don't accept the
238
+ # Polnor Bearer header. Use a stripped-down requests call.
239
+ import requests
240
+ with open(local_path, "rb") as f:
241
+ resp = requests.put(upload_url, data=f, timeout=300)
242
+ if resp.status_code >= 400:
243
+ raise RuntimeError(
244
+ f"artifact upload failed ({resp.status_code}): {resp.text[:200]}"
245
+ )
246
+
247
+
248
+ def get_metric_history(key: str) -> list[dict]:
249
+ """Return every (step, value, timestamp) observation for a metric
250
+ on the active run. Useful for building local plots or comparing
251
+ across runs.
252
+ """
253
+ run = _require_active()
254
+ resp = _client().get(
255
+ f"/experiments/{run.experiment_id}/runs/{run.run_id}/metrics/{key}"
256
+ )
257
+ return resp.get("data", [])
258
+
259
+
260
+ @contextlib.contextmanager
261
+ def autolog(framework: str | None = None) -> Iterator[None]:
262
+ """Lightweight autolog: open a run on enter, close on exit.
263
+
264
+ The full MLflow autolog hooks (sklearn estimator inspection, etc.)
265
+ aren't here yet. This context just gives users the convenient
266
+ ``with mlflow.autolog():`` shape that frameworks tend to wrap.
267
+ """
268
+ run = start_run()
269
+ if framework:
270
+ try:
271
+ set_tag("autolog.framework", framework)
272
+ except Exception:
273
+ pass
274
+ try:
275
+ yield
276
+ finally:
277
+ try:
278
+ end_run("completed")
279
+ except Exception:
280
+ # Make sure the stack stays clean even if the network call fails.
281
+ s = _stack()
282
+ if s and s[-1] is run:
283
+ s.pop()
284
+
285
+
286
+ __all__ = [
287
+ "ActiveRun",
288
+ "start_run",
289
+ "end_run",
290
+ "active_run",
291
+ "log_metric",
292
+ "log_metrics",
293
+ "log_param",
294
+ "log_params",
295
+ "set_tag",
296
+ "set_tags",
297
+ "log_artifact",
298
+ "get_metric_history",
299
+ "autolog",
300
+ ]
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: polnor
3
+ Version: 0.1.0
4
+ Summary: Polnor Python SDK — query Iceberg lakehouses, manage notebooks/jobs/models, MLflow-compatible tracking.
5
+ Author-email: Polnor <contact@polnor.net>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://polnor.net
8
+ Project-URL: Documentation, https://docs.polnor.net/sdk/python
9
+ Project-URL: Source, https://github.com/polnor/polnor
10
+ Keywords: polnor,lakehouse,iceberg,sql,mlflow,data-platform
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: License :: OSI Approved :: Apache Software License
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: requests>=2.28
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=7; extra == "test"
25
+ Requires-Dist: responses>=0.24; extra == "test"
26
+
27
+ # Polnor Python SDK
28
+
29
+ MLflow-compatible tracking for jobs running on the Polnor lakehouse.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install polnor
35
+ ```
36
+
37
+ Or, from a local checkout (contributors / pre-release):
38
+
39
+ ```bash
40
+ pip install -e ./sdk/python
41
+ ```
42
+
43
+ ## Configure
44
+
45
+ Set three environment variables before importing the SDK:
46
+
47
+ | Variable | Required | Notes |
48
+ |---|---|---|
49
+ | `POLNOR_API_URL` | yes | e.g. `https://api.polnor.net` |
50
+ | `POLNOR_TOKEN` | yes | Personal access token from `/settings/tokens` |
51
+ | `POLNOR_EXPERIMENT_ID` | optional | Default experiment for `start_run()` |
52
+ | `POLNOR_WORKSPACE_SLUG` | optional | When the token isn't workspace-scoped |
53
+
54
+ Inside a Polnor job these are injected automatically.
55
+
56
+ ## Quickstart
57
+
58
+ ```python
59
+ from polnor import mlflow
60
+
61
+ with mlflow.start_run(run_name="train-resnet50") as run:
62
+ mlflow.log_params({"lr": 0.01, "batch_size": 32, "optimizer": "adam"})
63
+
64
+ for epoch in range(10):
65
+ loss, acc = train_one_epoch()
66
+ mlflow.log_metrics({"loss": loss, "accuracy": acc}, step=epoch)
67
+
68
+ mlflow.set_tag("git_commit", os.environ.get("CI_COMMIT_SHA", "dev"))
69
+ ```
70
+
71
+ The run handle is also usable without the `with` block:
72
+
73
+ ```python
74
+ run = mlflow.start_run()
75
+ try:
76
+ mlflow.log_metric("score", 0.93)
77
+ finally:
78
+ mlflow.end_run("completed")
79
+ ```
80
+
81
+ ## What's supported
82
+
83
+ | API | Status |
84
+ |---|---|
85
+ | `start_run` / `end_run` / `active_run` | ✅ |
86
+ | `log_metric` / `log_metrics` (latest value wins) | ✅ |
87
+ | `log_param` / `log_params` | ✅ |
88
+ | `set_tag` / `set_tags` | ✅ |
89
+ | `autolog()` context manager | ✅ |
90
+ | Per-step metric history (time series) | ⏳ planned |
91
+ | `log_artifact` (presigned-URL upload) | ⏳ planned |
92
+ | Sklearn / PyTorch / TF auto-instrumentation | ⏳ planned |
93
+
94
+ ## How it differs from upstream MLflow
95
+
96
+ * Server-side, metrics merge into a single JSONB blob — the latest
97
+ value per key wins. The full `(step, value, timestamp)` history is
98
+ in scope for a follow-up migration.
99
+ * No server-side parameter immutability check yet — calling
100
+ `log_param("lr", 0.02)` after `log_param("lr", 0.01)` overwrites
101
+ silently. Upstream MLflow rejects with 400.
102
+ * No `mlflow.search_runs()` — use the REST API
103
+ (`GET /api/v1/experiments/:id/runs`) directly until we wrap it.
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ pip install -e ".[test]"
109
+ pytest
110
+ ```
111
+
112
+ Tests use [responses](https://github.com/getsentry/responses) to mock
113
+ HTTP — no live API needed.
@@ -0,0 +1,7 @@
1
+ polnor/__init__.py,sha256=3M_AsT8n2SOftHXlVK_wCFAXXRFkDfwDibakOCXZPZk,690
2
+ polnor/_client.py,sha256=SNKx-MFPnECcD8AopAql3jbjqIklrF1boDDLoosQSCY,4641
3
+ polnor/mlflow.py,sha256=e4HjrA7i_Hi2PXK7z-ct2Z21FQAT6u3lqlwq-82vcOI,9180
4
+ polnor-0.1.0.dist-info/METADATA,sha256=obC3mNnV6I2YHCysBVzC_CuryTkcJaM3kGykhUquEWo,3493
5
+ polnor-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ polnor-0.1.0.dist-info/top_level.txt,sha256=SRaiPv1RjbeCmlsA5dXXw-gsS_oTbIvbD90RgdJm0EU,7
7
+ polnor-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ polnor