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.
Files changed (24) hide show
  1. {predictabl-0.1.0 → predictabl-0.1.1}/CHANGELOG.md +12 -0
  2. {predictabl-0.1.0 → predictabl-0.1.1}/PKG-INFO +28 -14
  3. {predictabl-0.1.0 → predictabl-0.1.1}/README.md +27 -13
  4. {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/__init__.py +20 -6
  5. predictabl-0.1.1/src/predictabl/_version.py +1 -0
  6. {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/client.py +127 -15
  7. {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/errors.py +1 -1
  8. {predictabl-0.1.0 → predictabl-0.1.1}/tests/fake_api.py +12 -0
  9. {predictabl-0.1.0 → predictabl-0.1.1}/tests/test_client.py +80 -12
  10. {predictabl-0.1.0 → predictabl-0.1.1}/tests/test_integration.py +3 -3
  11. predictabl-0.1.0/src/predictabl/_version.py +0 -1
  12. {predictabl-0.1.0 → predictabl-0.1.1}/.github/workflows/ci.yml +0 -0
  13. {predictabl-0.1.0 → predictabl-0.1.1}/.github/workflows/publish.yml +0 -0
  14. {predictabl-0.1.0 → predictabl-0.1.1}/.gitignore +0 -0
  15. {predictabl-0.1.0 → predictabl-0.1.1}/LICENSE +0 -0
  16. {predictabl-0.1.0 → predictabl-0.1.1}/pyproject.toml +0 -0
  17. {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/__main__.py +0 -0
  18. {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/_http.py +0 -0
  19. {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/_progress.py +0 -0
  20. {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/assets/logo-128.png +0 -0
  21. {predictabl-0.1.0 → predictabl-0.1.1}/src/predictabl/mcp.py +0 -0
  22. {predictabl-0.1.0 → predictabl-0.1.1}/tests/__init__.py +0 -0
  23. {predictabl-0.1.0 → predictabl-0.1.1}/tests/test_mcp.py +0 -0
  24. {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.0
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).** Building models from code needs a *Build and
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 = pb.upload("orders.csv")
52
- model = data.predict("Will an order arrive late?")
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 orders.csv · 18.2 MB
54
+ ✓ Uploading customers.csv · 18.2 MB
57
55
  ✓ Reading data 1 table
58
- ✓ Understanding Predict whether an order arrives after its promised date
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
- model.report() # what it predicts, tested score, top features
67
- model.predict(rows=new_orders) # score your own records (DataFrame or dicts)
68
- model.url # the same model in the web app
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 access & usage → New token**.
88
+ In the web app: **Settings → API & MCP → Create token**.
77
89
 
78
- - **Build and score** can upload data, plan and train models, and score them.
79
- - **Score models only** can score your trained models and nothing else.
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).** Building models from code needs a *Build and
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 = pb.upload("orders.csv")
25
- model = data.predict("Will an order arrive late?")
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 orders.csv · 18.2 MB
27
+ ✓ Uploading customers.csv · 18.2 MB
30
28
  ✓ Reading data 1 table
31
- ✓ Understanding Predict whether an order arrives after its promised date
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
- model.report() # what it predicts, tested score, top features
40
- model.predict(rows=new_orders) # score your own records (DataFrame or dicts)
41
- model.url # the same model in the web app
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 access & usage → New token**.
61
+ In the web app: **Settings → API & MCP → Create token**.
50
62
 
51
- - **Build and score** can upload data, plan and train models, and score them.
52
- - **Score models only** can score your trained models and nothing else.
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 = pb.upload("orders.csv")
7
- model = data.predict("Will an order arrive late?")
8
- model.report()
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
- "upload",
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
- return client().upload(source, name, **kw)
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 upload(
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
- """Upload CSV, Excel or Parquet files (or a pandas DataFrame) as a new
110
- dataset. Several files become several tables Predictabl can join.
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
- data = Data._from(self, row)
130
- if wait:
131
- data.wait_ready(progress=progress, timeout=timeout)
132
- return data
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
- head = f"<Predictions {len(self)}"
731
- if self.count and self.count > len(self):
732
- head += f" of {self.count}"
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
- head += f" · as of {self.as_of}"
737
- return head + ">"
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
- Scoring-only (`inference`) keys cannot upload, plan or train."""
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 test_upload_waits_until_the_data_is_read():
128
+ def test_connect_waits_until_the_data_is_read():
129
129
  c, api = make()
130
- data = c.upload(_tmp_csv(), quiet=True)
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 test_upload_accepts_a_dataframe():
137
+ def test_connect_accepts_a_dataframe():
138
138
  pd = pytest.importorskip("pandas")
139
139
  c, api = make()
140
- c.upload(pd.DataFrame({"a": [1, 2]}), name="frame", quiet=True)
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 test_upload_of_a_missing_file_fails_before_any_request():
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.upload("/no/such/file.csv", quiet=True)
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.upload(_tmp_csv(), quiet=True)
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.upload(_tmp_csv(), quiet=True)
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.upload(_tmp_csv(), quiet=True).plan("late?", quiet=True)
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.upload(_tmp_csv(), quiet=True).plan("late?", quiet=True)
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.upload(_tmp_csv(), quiet=True)
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.upload(_tmp_csv(), quiet=True)
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.upload(_tiny_csv(), quiet=True)
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 test_upload_creates_a_dataset_marked_with_the_token(c):
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.upload(_tiny_csv(), name="sdk-integration", quiet=True, timeout=300)
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