predictabl 0.1.0__tar.gz → 0.1.1__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.
- {predictabl-0.1.0 → predictabl-0.1.1}/CHANGELOG.md +12 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/PKG-INFO +28 -14
- {predictabl-0.1.0 → predictabl-0.1.1}/README.md +27 -13
- {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/__init__.py +20 -6
- predictabl-0.1.1/src/predictabl/_version.py +1 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/client.py +127 -15
- {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/errors.py +1 -1
- {predictabl-0.1.0 → predictabl-0.1.1}/tests/fake_api.py +12 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/tests/test_client.py +80 -12
- {predictabl-0.1.0 → predictabl-0.1.1}/tests/test_integration.py +3 -3
- predictabl-0.1.0/src/predictabl/_version.py +0 -1
- {predictabl-0.1.0 → predictabl-0.1.1}/.github/workflows/ci.yml +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/.github/workflows/publish.yml +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/.gitignore +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/LICENSE +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/pyproject.toml +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/__main__.py +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/_http.py +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/_progress.py +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/assets/logo-128.png +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/mcp.py +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/tests/__init__.py +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/tests/test_mcp.py +0 -0
- {predictabl-0.1.0 → predictabl-0.1.1}/uv.lock +0 -0
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- `connect()` replaces `upload()`: pass files or a DataFrame as before, or a
|
|
6
|
+
Postgres address (`"postgresql://user:pw@host/db"`), which Predictabl reads
|
|
7
|
+
in place, read-only. The password never appears in progress or errors.
|
|
8
|
+
`upload()` still works and warns; it will be removed in a later version.
|
|
9
|
+
- Predictions print readably: what the number means, then the numbers.
|
|
10
|
+
One prediction prints on one line, e.g.
|
|
11
|
+
`Probability that customer churned: 0.82`.
|
|
12
|
+
- README: churn example with its output; token access is *Full access* or
|
|
13
|
+
*Predictions only*.
|
|
14
|
+
|
|
3
15
|
## 0.1.0
|
|
4
16
|
|
|
5
17
|
First release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: predictabl
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Predictabl from Python: turn a question about your data into a trained model, then score it.
|
|
5
5
|
Project-URL: Homepage, https://predictabl.tech
|
|
6
6
|
Project-URL: Documentation, https://github.com/predictabl/predictabl-python#readme
|
|
@@ -35,9 +35,7 @@ checks for leakage, trains and tests the model, and serves predictions.
|
|
|
35
35
|
agents, the whole documentation as one Markdown file:
|
|
36
36
|
[predictabl.tech/docs/llms-full.txt](https://predictabl.tech/docs/llms-full.txt)
|
|
37
37
|
|
|
38
|
-
> **Status: alpha (0.1).**
|
|
39
|
-
> score* token, which the hosted app starts offering with its next release.
|
|
40
|
-
> Scoring tokens work today.
|
|
38
|
+
> **Status: alpha (0.1).** The API may still change between minor versions.
|
|
41
39
|
|
|
42
40
|
```bash
|
|
43
41
|
pip install predictabl
|
|
@@ -48,14 +46,14 @@ import predictabl as pb
|
|
|
48
46
|
|
|
49
47
|
pb.login("pk_...") # or set PREDICTABL_API_KEY
|
|
50
48
|
|
|
51
|
-
data
|
|
52
|
-
|
|
49
|
+
data = pb.connect("customers.csv") # a file, a DataFrame, or a database
|
|
50
|
+
churn_model = data.predict("Which customers will churn?")
|
|
53
51
|
```
|
|
54
52
|
|
|
55
53
|
```
|
|
56
|
-
✓ Uploading
|
|
54
|
+
✓ Uploading customers.csv · 18.2 MB
|
|
57
55
|
✓ Reading data 1 table
|
|
58
|
-
✓ Understanding Predict
|
|
56
|
+
✓ Understanding Predict which customers will churn
|
|
59
57
|
✓ Planning 31 features · 2 excluded
|
|
60
58
|
✓ Building data
|
|
61
59
|
✓ Training lightgbm: kept 840 trees
|
|
@@ -63,9 +61,23 @@ model = data.predict("Will an order arrive late?")
|
|
|
63
61
|
```
|
|
64
62
|
|
|
65
63
|
```python
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
churn_model.predict(customer) # one customer (a dict), a list, or a DataFrame
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
Probability that customer churned: 0.82
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
churn_model.report() # what it predicts, tested score, top features
|
|
73
|
+
churn_model.url # the same model in the web app
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Your data can be files, a pandas DataFrame, or a Postgres database, which
|
|
77
|
+
Predictabl reads in place, read-only:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
data = pb.connect("postgresql://readonly:pw@db.acme.com/sales")
|
|
69
81
|
```
|
|
70
82
|
|
|
71
83
|
Everything you build from code also appears in the web app, labelled with the
|
|
@@ -73,10 +85,12 @@ token that created it.
|
|
|
73
85
|
|
|
74
86
|
## Get an API key
|
|
75
87
|
|
|
76
|
-
In the web app: **Settings → API
|
|
88
|
+
In the web app: **Settings → API & MCP → Create token**.
|
|
77
89
|
|
|
78
|
-
-
|
|
79
|
-
|
|
90
|
+
- By default a token has **full access**: it can do from code what you do in the
|
|
91
|
+
web app (upload, ask, plan, train and get predictions).
|
|
92
|
+
- Tick **Predictions only** for a read-only token: it can get predictions from
|
|
93
|
+
your trained models and change nothing.
|
|
80
94
|
|
|
81
95
|
A token only ever reaches its owner's data and models. It cannot create other
|
|
82
96
|
tokens. Revoking it stops it immediately.
|
|
@@ -8,9 +8,7 @@ checks for leakage, trains and tests the model, and serves predictions.
|
|
|
8
8
|
agents, the whole documentation as one Markdown file:
|
|
9
9
|
[predictabl.tech/docs/llms-full.txt](https://predictabl.tech/docs/llms-full.txt)
|
|
10
10
|
|
|
11
|
-
> **Status: alpha (0.1).**
|
|
12
|
-
> score* token, which the hosted app starts offering with its next release.
|
|
13
|
-
> Scoring tokens work today.
|
|
11
|
+
> **Status: alpha (0.1).** The API may still change between minor versions.
|
|
14
12
|
|
|
15
13
|
```bash
|
|
16
14
|
pip install predictabl
|
|
@@ -21,14 +19,14 @@ import predictabl as pb
|
|
|
21
19
|
|
|
22
20
|
pb.login("pk_...") # or set PREDICTABL_API_KEY
|
|
23
21
|
|
|
24
|
-
data
|
|
25
|
-
|
|
22
|
+
data = pb.connect("customers.csv") # a file, a DataFrame, or a database
|
|
23
|
+
churn_model = data.predict("Which customers will churn?")
|
|
26
24
|
```
|
|
27
25
|
|
|
28
26
|
```
|
|
29
|
-
✓ Uploading
|
|
27
|
+
✓ Uploading customers.csv · 18.2 MB
|
|
30
28
|
✓ Reading data 1 table
|
|
31
|
-
✓ Understanding Predict
|
|
29
|
+
✓ Understanding Predict which customers will churn
|
|
32
30
|
✓ Planning 31 features · 2 excluded
|
|
33
31
|
✓ Building data
|
|
34
32
|
✓ Training lightgbm: kept 840 trees
|
|
@@ -36,9 +34,23 @@ model = data.predict("Will an order arrive late?")
|
|
|
36
34
|
```
|
|
37
35
|
|
|
38
36
|
```python
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
churn_model.predict(customer) # one customer (a dict), a list, or a DataFrame
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
Probability that customer churned: 0.82
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
churn_model.report() # what it predicts, tested score, top features
|
|
46
|
+
churn_model.url # the same model in the web app
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Your data can be files, a pandas DataFrame, or a Postgres database, which
|
|
50
|
+
Predictabl reads in place, read-only:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
data = pb.connect("postgresql://readonly:pw@db.acme.com/sales")
|
|
42
54
|
```
|
|
43
55
|
|
|
44
56
|
Everything you build from code also appears in the web app, labelled with the
|
|
@@ -46,10 +58,12 @@ token that created it.
|
|
|
46
58
|
|
|
47
59
|
## Get an API key
|
|
48
60
|
|
|
49
|
-
In the web app: **Settings → API
|
|
61
|
+
In the web app: **Settings → API & MCP → Create token**.
|
|
50
62
|
|
|
51
|
-
-
|
|
52
|
-
|
|
63
|
+
- By default a token has **full access**: it can do from code what you do in the
|
|
64
|
+
web app (upload, ask, plan, train and get predictions).
|
|
65
|
+
- Tick **Predictions only** for a read-only token: it can get predictions from
|
|
66
|
+
your trained models and change nothing.
|
|
53
67
|
|
|
54
68
|
A token only ever reaches its owner's data and models. It cannot create other
|
|
55
69
|
tokens. Revoking it stops it immediately.
|
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
import predictabl as pb
|
|
4
4
|
|
|
5
5
|
pb.login("pk_...") # or set PREDICTABL_API_KEY
|
|
6
|
-
data
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
model.predict(rows=new_orders)
|
|
6
|
+
data = pb.connect("customers.csv") # files, a DataFrame, or a Postgres address
|
|
7
|
+
churn_model = data.predict("Which customers will churn?")
|
|
8
|
+
churn_model.predict(customer) # Probability that customer churned: 0.82
|
|
10
9
|
|
|
11
10
|
Module-level functions use one shared client, configured by `login()` or the
|
|
12
11
|
PREDICTABL_API_KEY / PREDICTABL_BASE_URL environment variables. For several
|
|
@@ -34,7 +33,7 @@ __all__ = [
|
|
|
34
33
|
"login",
|
|
35
34
|
"client",
|
|
36
35
|
"account",
|
|
37
|
-
"
|
|
36
|
+
"connect",
|
|
38
37
|
"data",
|
|
39
38
|
"datasets",
|
|
40
39
|
"plan",
|
|
@@ -81,8 +80,23 @@ def account() -> dict:
|
|
|
81
80
|
return client().account()
|
|
82
81
|
|
|
83
82
|
|
|
83
|
+
def connect(source, name: str | None = None, **kw) -> Data:
|
|
84
|
+
"""Connect a Postgres database (by address) or upload files / a DataFrame.
|
|
85
|
+
See `Client.connect`."""
|
|
86
|
+
return client().connect(source, name, **kw)
|
|
87
|
+
|
|
88
|
+
|
|
84
89
|
def upload(source, name: str | None = None, **kw) -> Data:
|
|
85
|
-
|
|
90
|
+
"""Deprecated: renamed to `connect()`."""
|
|
91
|
+
import warnings
|
|
92
|
+
|
|
93
|
+
warnings.warn(
|
|
94
|
+
"predictabl: upload() is now connect(); upload() will be removed in a "
|
|
95
|
+
"future version.",
|
|
96
|
+
DeprecationWarning,
|
|
97
|
+
stacklevel=2,
|
|
98
|
+
)
|
|
99
|
+
return client().connect(source, name, **kw)
|
|
86
100
|
|
|
87
101
|
|
|
88
102
|
def data(data_id: str) -> Data:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import io
|
|
11
11
|
import os
|
|
12
12
|
import time
|
|
13
|
+
import warnings
|
|
13
14
|
from collections.abc import Iterable, Mapping
|
|
14
15
|
from dataclasses import dataclass, field
|
|
15
16
|
from datetime import date, datetime
|
|
@@ -96,7 +97,7 @@ class Client:
|
|
|
96
97
|
return Data._from(self, row)
|
|
97
98
|
raise errors.NotFound(f"No dataset {data_id!r} on this account.")
|
|
98
99
|
|
|
99
|
-
def
|
|
100
|
+
def connect(
|
|
100
101
|
self,
|
|
101
102
|
source: str | Path | Iterable[str | Path] | Any,
|
|
102
103
|
name: str | None = None,
|
|
@@ -106,12 +107,38 @@ class Client:
|
|
|
106
107
|
on_progress: Callback | None = None,
|
|
107
108
|
timeout: float | None = 1800,
|
|
108
109
|
) -> Data:
|
|
109
|
-
"""
|
|
110
|
-
|
|
110
|
+
"""Connect data to Predictabl: a database or files.
|
|
111
|
+
|
|
112
|
+
* A Postgres address (``"postgresql://user:password@host:5432/db"``):
|
|
113
|
+
Predictabl reads the database where it is, read-only. Use a
|
|
114
|
+
read-only user, and make sure the database accepts connections from
|
|
115
|
+
Predictabl's server.
|
|
116
|
+
* CSV, Excel or Parquet files, a list of them, or a pandas DataFrame:
|
|
117
|
+
the files are uploaded to Predictabl. Several files become several
|
|
118
|
+
tables Predictabl can join.
|
|
111
119
|
|
|
112
120
|
With `wait` (default) returns once Predictabl has read the tables."""
|
|
113
|
-
files, label = _files_of(source)
|
|
114
121
|
progress = Progress(quiet=quiet, on_update=on_progress)
|
|
122
|
+
if isinstance(source, str) and _is_database_url(source):
|
|
123
|
+
data = self._connect_database(source, name, progress)
|
|
124
|
+
else:
|
|
125
|
+
data = self._upload_files(source, name, progress)
|
|
126
|
+
if wait:
|
|
127
|
+
data.wait_ready(progress=progress, timeout=timeout)
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
def upload(self, source, name: str | None = None, **kw) -> Data:
|
|
131
|
+
"""Deprecated: renamed to `connect()`, which also takes databases."""
|
|
132
|
+
warnings.warn(
|
|
133
|
+
"predictabl: upload() is now connect(); upload() will be removed in a "
|
|
134
|
+
"future version.",
|
|
135
|
+
DeprecationWarning,
|
|
136
|
+
stacklevel=2,
|
|
137
|
+
)
|
|
138
|
+
return self.connect(source, name, **kw)
|
|
139
|
+
|
|
140
|
+
def _upload_files(self, source: Any, name: str | None, progress: Progress) -> Data:
|
|
141
|
+
files, label = _files_of(source)
|
|
115
142
|
size = sum(len(content) for _, content, _ in files)
|
|
116
143
|
progress.running("Uploading", f"{label} · {_human_bytes(size)}")
|
|
117
144
|
try:
|
|
@@ -126,10 +153,26 @@ class Client:
|
|
|
126
153
|
progress.failed("Uploading")
|
|
127
154
|
raise
|
|
128
155
|
progress.done("Uploading", f"{label} · {_human_bytes(size)}")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
156
|
+
return Data._from(self, row)
|
|
157
|
+
|
|
158
|
+
def _connect_database(self, url: str, name: str | None, progress: Progress) -> Data:
|
|
159
|
+
label = _safe_db_label(url) # never shows the password
|
|
160
|
+
progress.running("Connecting", label)
|
|
161
|
+
try:
|
|
162
|
+
row = self._t.post(
|
|
163
|
+
"/connections",
|
|
164
|
+
json={
|
|
165
|
+
"name": name or label,
|
|
166
|
+
"kind": "postgres",
|
|
167
|
+
"config": {"dsn": url},
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
self._t.post(f"/connections/{row['id']}/introspect")
|
|
171
|
+
except errors.PredictablError:
|
|
172
|
+
progress.failed("Connecting", label)
|
|
173
|
+
raise
|
|
174
|
+
progress.done("Connecting", label)
|
|
175
|
+
return Data._from(self, row)
|
|
133
176
|
|
|
134
177
|
def plan(self, question_id: str) -> Plan:
|
|
135
178
|
"""The latest plan of a question (opportunity) planned earlier, e.g.
|
|
@@ -727,19 +770,88 @@ class Predictions(list):
|
|
|
727
770
|
return pd.DataFrame(flat)
|
|
728
771
|
|
|
729
772
|
def __repr__(self) -> str:
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
if self.meaning:
|
|
734
|
-
head += f" · {self.meaning}"
|
|
773
|
+
# Readable at a glance in a REPL or notebook: what the number means,
|
|
774
|
+
# then the numbers. One prediction prints on one line.
|
|
775
|
+
title = _sentence(self.meaning) or "Predictions"
|
|
735
776
|
if self.as_of:
|
|
736
|
-
|
|
737
|
-
|
|
777
|
+
title += f" (as of {self.as_of})"
|
|
778
|
+
if not self:
|
|
779
|
+
return f"{title}: none" + (f"\n{self.note}" if self.note else "")
|
|
780
|
+
if len(self) == 1:
|
|
781
|
+
return f"{title}: {_value_of(self[0])}"
|
|
782
|
+
shown = self[:10]
|
|
783
|
+
width = max(len(str(p.get("entity_id", ""))) for p in shown)
|
|
784
|
+
lines = [title]
|
|
785
|
+
lines += [
|
|
786
|
+
f" {str(p.get('entity_id', '')):<{width}} {_value_of(p)}" for p in shown
|
|
787
|
+
]
|
|
788
|
+
if len(self) > len(shown):
|
|
789
|
+
lines.append(f" … and {len(self) - len(shown):,} more")
|
|
790
|
+
if self.count > len(self):
|
|
791
|
+
lines.append(
|
|
792
|
+
f" ({len(self):,} of {self.count:,} returned; pass limit= for more)"
|
|
793
|
+
)
|
|
794
|
+
return "\n".join(lines)
|
|
795
|
+
|
|
796
|
+
__str__ = __repr__
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _sentence(text: str | None) -> str:
|
|
800
|
+
return (text[0].upper() + text[1:]) if text else ""
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _value_of(p: Mapping) -> str:
|
|
804
|
+
"""One prediction as text: the class (and its probability) for a
|
|
805
|
+
classifier that names one, otherwise the number, rounded for reading."""
|
|
806
|
+
v = p.get("prediction")
|
|
807
|
+
num = (
|
|
808
|
+
f"{v:.2f}"
|
|
809
|
+
if isinstance(v, float) and abs(v) < 1000
|
|
810
|
+
else (f"{v:,.0f}" if isinstance(v, float) else str(v))
|
|
811
|
+
)
|
|
812
|
+
cls = p.get("predicted_class")
|
|
813
|
+
return f"{cls} ({num})" if cls is not None else num
|
|
738
814
|
|
|
739
815
|
|
|
740
816
|
# ============================================================================
|
|
741
817
|
|
|
742
818
|
|
|
819
|
+
_DB_SCHEMES = (
|
|
820
|
+
"postgres://",
|
|
821
|
+
"postgresql://",
|
|
822
|
+
"postgresql+psycopg://",
|
|
823
|
+
"postgresql+psycopg2://",
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _is_database_url(source: str) -> bool:
|
|
828
|
+
"""True for a database address. Postgres is supported; other databases
|
|
829
|
+
raise a clear error instead of being read as a file name."""
|
|
830
|
+
low = source.strip().lower()
|
|
831
|
+
if low.startswith(_DB_SCHEMES):
|
|
832
|
+
return True
|
|
833
|
+
scheme = low.split("://", 1)[0] if "://" in low else ""
|
|
834
|
+
if scheme and not scheme.startswith(("http", "file")) and "/" not in scheme:
|
|
835
|
+
raise errors.InvalidRequest(
|
|
836
|
+
f"Predictabl connects to Postgres databases ({scheme}:// is not "
|
|
837
|
+
"supported yet). Export the tables to files and pass those instead."
|
|
838
|
+
)
|
|
839
|
+
return False
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _safe_db_label(url: str) -> str:
|
|
843
|
+
"""'sales on db.acme.com' from a database address, without credentials."""
|
|
844
|
+
from urllib.parse import urlsplit
|
|
845
|
+
|
|
846
|
+
try:
|
|
847
|
+
parts = urlsplit(url.strip())
|
|
848
|
+
db = parts.path.lstrip("/") or "database"
|
|
849
|
+
host = parts.hostname or "localhost"
|
|
850
|
+
except ValueError:
|
|
851
|
+
return "database"
|
|
852
|
+
return f"{db} on {host}"
|
|
853
|
+
|
|
854
|
+
|
|
743
855
|
def _files_of(source: Any) -> tuple[list[tuple[str, bytes, str]], str]:
|
|
744
856
|
"""(files, label) for an upload: paths, a list of paths, or a DataFrame."""
|
|
745
857
|
if hasattr(source, "to_csv") and hasattr(source, "columns"): # DataFrame
|
|
@@ -29,7 +29,7 @@ class AuthenticationError(PredictablError):
|
|
|
29
29
|
|
|
30
30
|
class PermissionDenied(PredictablError):
|
|
31
31
|
"""The API key is valid but its scope does not allow this (HTTP 403).
|
|
32
|
-
|
|
32
|
+
Predictions only keys cannot connect data, plan or train."""
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
class NotFound(PredictablError):
|
|
@@ -74,6 +74,18 @@ class FakeApi:
|
|
|
74
74
|
"kind": "duckdb",
|
|
75
75
|
}
|
|
76
76
|
)
|
|
77
|
+
if path == "/connections" and m == "POST":
|
|
78
|
+
return ok(
|
|
79
|
+
{
|
|
80
|
+
"id": "c1",
|
|
81
|
+
"name": body["name"],
|
|
82
|
+
"status": "created",
|
|
83
|
+
"kind": "postgres",
|
|
84
|
+
},
|
|
85
|
+
201,
|
|
86
|
+
)
|
|
87
|
+
if path == "/connections/c1/introspect" and m == "POST":
|
|
88
|
+
return ok({"job": "introspect", "connection_id": "c1"}, 202)
|
|
77
89
|
if path == "/connections" and m == "GET":
|
|
78
90
|
self._conn_polls += 1
|
|
79
91
|
status = (
|
|
@@ -125,33 +125,33 @@ def test_reads_are_retried_on_a_gateway_error_but_writes_are_not(monkeypatch):
|
|
|
125
125
|
# -------------------------------------------------------------- build flow
|
|
126
126
|
|
|
127
127
|
|
|
128
|
-
def
|
|
128
|
+
def test_connect_waits_until_the_data_is_read():
|
|
129
129
|
c, api = make()
|
|
130
|
-
data = c.
|
|
130
|
+
data = c.connect(_tmp_csv(), quiet=True)
|
|
131
131
|
assert data.status == "ready" and data.table_count == 1
|
|
132
132
|
method, path, _, headers = api.calls[0]
|
|
133
133
|
assert (method, path) == ("POST", "/connections/from-csv")
|
|
134
134
|
assert headers["content-type"].startswith("multipart/form-data")
|
|
135
135
|
|
|
136
136
|
|
|
137
|
-
def
|
|
137
|
+
def test_connect_accepts_a_dataframe():
|
|
138
138
|
pd = pytest.importorskip("pandas")
|
|
139
139
|
c, api = make()
|
|
140
|
-
c.
|
|
140
|
+
c.connect(pd.DataFrame({"a": [1, 2]}), name="frame", quiet=True)
|
|
141
141
|
assert api.calls[0][1] == "/connections/from-csv"
|
|
142
142
|
|
|
143
143
|
|
|
144
|
-
def
|
|
144
|
+
def test_connect_of_a_missing_file_fails_before_any_request():
|
|
145
145
|
c, api = make()
|
|
146
146
|
with pytest.raises(errors.InvalidRequest):
|
|
147
|
-
c.
|
|
147
|
+
c.connect("/no/such/file.csv", quiet=True)
|
|
148
148
|
assert api.calls == []
|
|
149
149
|
|
|
150
150
|
|
|
151
151
|
def test_question_to_model_in_one_call_with_progress():
|
|
152
152
|
c, api = make()
|
|
153
153
|
updates = []
|
|
154
|
-
data = c.
|
|
154
|
+
data = c.connect(_tmp_csv(), quiet=True)
|
|
155
155
|
model = data.predict(
|
|
156
156
|
"Will an order arrive late?", quiet=True, on_progress=updates.append
|
|
157
157
|
)
|
|
@@ -171,7 +171,7 @@ def test_an_older_finished_job_is_not_mistaken_for_ours():
|
|
|
171
171
|
"""The first job poll returns an earlier, finished job; the client must
|
|
172
172
|
keep waiting for the job its own request created."""
|
|
173
173
|
c, api = make(FakeApi(older_job_done=True))
|
|
174
|
-
data = c.
|
|
174
|
+
data = c.connect(_tmp_csv(), quiet=True)
|
|
175
175
|
plan = data.plan("late?", quiet=True)
|
|
176
176
|
job_polls = [p for (m, p, _, _) in api.calls if p.endswith("/opportunities/job")]
|
|
177
177
|
assert len(job_polls) >= 3
|
|
@@ -180,7 +180,7 @@ def test_an_older_finished_job_is_not_mistaken_for_ours():
|
|
|
180
180
|
|
|
181
181
|
def test_plan_shows_target_features_and_exclusions():
|
|
182
182
|
c, _ = make()
|
|
183
|
-
plan = c.
|
|
183
|
+
plan = c.connect(_tmp_csv(), quiet=True).plan("late?", quiet=True)
|
|
184
184
|
assert plan.features == ["f1", "f3"] # disabled features are not listed
|
|
185
185
|
text = str(plan)
|
|
186
186
|
assert "Order delivered after promised date" in text
|
|
@@ -189,21 +189,21 @@ def test_plan_shows_target_features_and_exclusions():
|
|
|
189
189
|
|
|
190
190
|
def test_refine_returns_the_next_version_and_leaves_the_old_one():
|
|
191
191
|
c, _ = make()
|
|
192
|
-
plan = c.
|
|
192
|
+
plan = c.connect(_tmp_csv(), quiet=True).plan("late?", quiet=True)
|
|
193
193
|
newer = plan.refine("drop courier", quiet=True)
|
|
194
194
|
assert (plan.version, newer.version) == (1, 2)
|
|
195
195
|
|
|
196
196
|
|
|
197
197
|
def test_a_failed_plan_raises_with_the_servers_reason():
|
|
198
198
|
c, _ = make(FakeApi(fail_plan=True, polls_until_spec=1))
|
|
199
|
-
data = c.
|
|
199
|
+
data = c.connect(_tmp_csv(), quiet=True)
|
|
200
200
|
with pytest.raises(errors.BuildFailed, match="bad SQL"):
|
|
201
201
|
data.plan("late?", quiet=True)
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
def test_a_failed_run_raises_with_code_and_suggestions():
|
|
205
205
|
c, _ = make(FakeApi(fail_run=True))
|
|
206
|
-
data = c.
|
|
206
|
+
data = c.connect(_tmp_csv(), quiet=True)
|
|
207
207
|
with pytest.raises(errors.BuildFailed) as exc:
|
|
208
208
|
data.predict("late?", quiet=True)
|
|
209
209
|
assert exc.value.code == "BUILD_LIMIT_REACHED"
|
|
@@ -346,3 +346,71 @@ def _tmp_csv():
|
|
|
346
346
|
p = Path(tempfile.mkdtemp()) / "orders.csv"
|
|
347
347
|
p.write_text("id,amount,late\n1,10,0\n2,20,1\n")
|
|
348
348
|
return p
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_predictions_print_readably():
|
|
352
|
+
from predictabl.client import Predictions
|
|
353
|
+
|
|
354
|
+
one = Predictions._from(
|
|
355
|
+
{
|
|
356
|
+
"meaning": "probability that customer churned",
|
|
357
|
+
"predictions": [{"entity_id": "0", "prediction": 0.8213}],
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
assert repr(one) == "Probability that customer churned: 0.82"
|
|
361
|
+
many = Predictions._from(
|
|
362
|
+
{
|
|
363
|
+
"meaning": "probability that customer churned",
|
|
364
|
+
"count": 500,
|
|
365
|
+
"predictions": [
|
|
366
|
+
{"entity_id": f"C-{i}", "prediction": 0.5} for i in range(12)
|
|
367
|
+
],
|
|
368
|
+
}
|
|
369
|
+
)
|
|
370
|
+
text = str(many)
|
|
371
|
+
assert text.splitlines()[0] == "Probability that customer churned"
|
|
372
|
+
assert " C-0 0.50" in text and "C-10" not in text
|
|
373
|
+
assert "… and 2 more" in text and "12 of 500 returned" in text
|
|
374
|
+
cls = Predictions._from(
|
|
375
|
+
{
|
|
376
|
+
"meaning": "probability of the predicted class (tier)",
|
|
377
|
+
"predictions": [
|
|
378
|
+
{"entity_id": "a", "prediction": 0.7, "predicted_class": "gold"}
|
|
379
|
+
],
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
assert str(cls).endswith(": gold (0.70)")
|
|
383
|
+
assert list(one) == [{"entity_id": "0", "prediction": 0.8213}] # still a plain list
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_connect_a_postgres_database_reads_it_in_place(capsys):
|
|
387
|
+
c, api = make()
|
|
388
|
+
url = "postgresql://ro_user:s3cret@db.acme.com:5432/sales"
|
|
389
|
+
updates = []
|
|
390
|
+
data = c.connect(url, on_progress=updates.append)
|
|
391
|
+
assert data.status == "ready"
|
|
392
|
+
posts = [(p, b) for m, p, b, _ in api.calls if m == "POST"]
|
|
393
|
+
assert posts[0] == (
|
|
394
|
+
"/connections",
|
|
395
|
+
{"name": "sales on db.acme.com", "kind": "postgres", "config": {"dsn": url}},
|
|
396
|
+
)
|
|
397
|
+
assert posts[1][0] == "/connections/c1/introspect"
|
|
398
|
+
shown = " ".join(f"{u.step} {u.detail}" for u in updates) + capsys.readouterr().err
|
|
399
|
+
assert "s3cret" not in shown and "ro_user" not in shown
|
|
400
|
+
assert "sales on db.acme.com" in shown
|
|
401
|
+
assert not any(p == "/connections/from-csv" for _, p, _, _ in api.calls)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_connect_refuses_unsupported_databases_clearly():
|
|
405
|
+
c, api = make()
|
|
406
|
+
with pytest.raises(pb.InvalidRequest, match="Postgres"):
|
|
407
|
+
c.connect("mysql://u:p@host/db", quiet=True)
|
|
408
|
+
assert api.calls == []
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_upload_still_works_but_warns():
|
|
412
|
+
c, api = make()
|
|
413
|
+
with pytest.warns(DeprecationWarning, match="connect"):
|
|
414
|
+
data = c.upload(_tmp_csv(), quiet=True)
|
|
415
|
+
assert data.status == "ready"
|
|
416
|
+
assert "upload" not in pb.__all__ and "connect" in pb.__all__
|
|
@@ -153,7 +153,7 @@ def test_an_inference_token_scores_but_cannot_build(ids):
|
|
|
153
153
|
with pb.Client(api_key=key, base_url=URL) as inf:
|
|
154
154
|
assert len(inf.model(ids["flat"]).predict(rows=_airline_rows(2))) == 2
|
|
155
155
|
with pytest.raises(errors.PermissionDenied):
|
|
156
|
-
inf.
|
|
156
|
+
inf.connect(_tiny_csv(), quiet=True)
|
|
157
157
|
with pytest.raises(errors.PermissionDenied):
|
|
158
158
|
inf.datasets()
|
|
159
159
|
|
|
@@ -182,11 +182,11 @@ def test_a_bad_key_is_an_authentication_error():
|
|
|
182
182
|
# ------------------------------------------------------------ build path
|
|
183
183
|
|
|
184
184
|
|
|
185
|
-
def
|
|
185
|
+
def test_connect_creates_a_dataset_marked_with_the_token(c):
|
|
186
186
|
"""Introspection needs the LLM; on a server without a key it fails, and
|
|
187
187
|
the SDK must say so instead of hanging."""
|
|
188
188
|
try:
|
|
189
|
-
data = c.
|
|
189
|
+
data = c.connect(_tiny_csv(), name="sdk-integration", quiet=True, timeout=300)
|
|
190
190
|
assert data.status == "ready"
|
|
191
191
|
except errors.BuildFailed as exc:
|
|
192
192
|
assert "Could not read this dataset" in str(exc)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
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
|