ventimath 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.
ventimath/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """VentiMath REST client. No inference providers or runtime dependencies."""
2
+
3
+ from .client import (
4
+ DEFAULT_BASE_URL,
5
+ VentiMathAPIError,
6
+ VentiMathClient,
7
+ VentiMathConnectionError,
8
+ VentiMathError,
9
+ VentiMathImportError,
10
+ VentiMathProtocolError,
11
+ load_classification_files,
12
+ load_vocabulary_csv,
13
+ )
14
+
15
+ __all__ = [
16
+ "DEFAULT_BASE_URL",
17
+ "VentiMathAPIError",
18
+ "VentiMathClient",
19
+ "VentiMathConnectionError",
20
+ "VentiMathError",
21
+ "VentiMathImportError",
22
+ "VentiMathProtocolError",
23
+ "load_classification_files",
24
+ "load_vocabulary_csv",
25
+ ]
ventimath/client.py ADDED
@@ -0,0 +1,441 @@
1
+ """Thin JSON client for the answerKeys and problemTags Cloud Functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ import os
8
+ from collections.abc import Iterable, Mapping
9
+ from http.client import HTTPException
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.parse import quote, urlencode, urlsplit
14
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
15
+
16
+ DEFAULT_BASE_URL = "https://us-central1-ventimath.cloudfunctions.net"
17
+ MAX_IMPORT_RECORDS = 200
18
+ MAX_IMPORT_BYTES = 2 * 1024 * 1024
19
+
20
+
21
+ class _NoRedirectHandler(HTTPRedirectHandler):
22
+ def redirect_request(self, request, response, code, message, headers, new_url):
23
+ # Function endpoints are direct: never forward a token or change a write's method.
24
+ raise HTTPError(request.full_url, code, message, headers, response)
25
+
26
+
27
+ urlopen = build_opener(_NoRedirectHandler()).open
28
+
29
+
30
+ class VentiMathError(Exception):
31
+ """Base class for transport, protocol, and aggregate import failures."""
32
+
33
+
34
+ class VentiMathAPIError(VentiMathError):
35
+ def __init__(self, status: int, payload: Any, url: str):
36
+ self.status = status
37
+ self.payload = payload
38
+ self.url = url
39
+ super().__init__(f"HTTP {status}: {payload}")
40
+
41
+
42
+ class VentiMathConnectionError(VentiMathError):
43
+ """The request could not complete; the server may have received it."""
44
+
45
+
46
+ class VentiMathProtocolError(VentiMathError):
47
+ """The server returned an unexpected response shape."""
48
+
49
+
50
+ class VentiMathImportError(VentiMathError):
51
+ """An import stopped; report contains counts from completed requests only."""
52
+
53
+ def __init__(self, report: dict[str, Any], cause: VentiMathError):
54
+ self.report = report
55
+ self.cause = cause
56
+ self.status = getattr(cause, "status", None)
57
+ self.payload = getattr(cause, "payload", None)
58
+ super().__init__(
59
+ f"Import stopped after {report['processed']}/{report['total']} records: {cause}. "
60
+ "The last request may have committed. Retry the same records safely; "
61
+ "unchanged classifications are not written again."
62
+ )
63
+
64
+
65
+ def _json_bytes(value: Any) -> bytes:
66
+ return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode(
67
+ "utf-8"
68
+ )
69
+
70
+
71
+ def _set_path(set_id: str) -> str:
72
+ if not isinstance(set_id, str) or not set_id.strip() or "/" in set_id:
73
+ raise ValueError("set_id must be a nonempty document ID without slashes")
74
+ return f"problemTags/sets/{quote(set_id, safe='')}"
75
+
76
+
77
+ def _page_query(limit: int | None, cursor: str | None) -> dict[str, Any]:
78
+ if limit is not None and (type(limit) is not int or not 1 <= limit <= 100):
79
+ raise ValueError("limit must be an integer from 1 to 100")
80
+ return {"limit": limit, "cursor": cursor}
81
+
82
+
83
+ def load_vocabulary_csv(path: str | os.PathLike[str]) -> list[dict[str, str]]:
84
+ """Read category/skill/description CSV, including BOM and quoted newlines."""
85
+ with Path(path).open(encoding="utf-8-sig", newline="") as source:
86
+ reader = csv.reader(source, strict=True)
87
+ try:
88
+ headers = [value.strip().lower() for value in next(reader)]
89
+ fields = ("skill", "category", "description")
90
+ if any(headers.count(field) != 1 for field in fields):
91
+ raise ValueError("CSV needs one each of skill, category, description headers")
92
+ entries = []
93
+ for row in reader:
94
+ if not any(value.strip() for value in row):
95
+ continue
96
+ if len(row) != len(headers):
97
+ raise ValueError(f"CSV line {reader.line_num} has an incorrect column count")
98
+ entry = {field: row[headers.index(field)].strip() for field in fields}
99
+ if not entry["skill"] or not entry["category"]:
100
+ raise ValueError(f"CSV line {reader.line_num} needs a skill and category")
101
+ entries.append(entry)
102
+ except StopIteration as error:
103
+ raise ValueError("vocabulary CSV is empty") from error
104
+ except csv.Error as error:
105
+ raise ValueError(f"invalid vocabulary CSV: {error}") from error
106
+ if not 1 <= len(entries) <= 500:
107
+ raise ValueError("vocabulary CSV must contain 1–500 skills")
108
+ return entries
109
+
110
+
111
+ def load_classification_files(
112
+ paths: Iterable[str | os.PathLike[str]],
113
+ ) -> list[Any]:
114
+ """Load individual {result} records or combined arrays with {llmResult}."""
115
+ records = []
116
+ for path in paths:
117
+ with Path(path).open(encoding="utf-8-sig") as source:
118
+ data = json.load(source)
119
+ if isinstance(data, list):
120
+ records.extend(data)
121
+ elif isinstance(data, dict) and "problemId" in data:
122
+ records.append(data)
123
+ else:
124
+ raise ValueError(f"{path} must hold a classifier record or an array of records")
125
+ return records
126
+
127
+
128
+ class VentiMathClient:
129
+ """Explicit-token client; token=None is anonymous and never reads credentials."""
130
+
131
+ def __init__(
132
+ self,
133
+ token: str | None = None,
134
+ *,
135
+ base_url: str = DEFAULT_BASE_URL,
136
+ timeout: float = 60,
137
+ ):
138
+ parsed = urlsplit(base_url)
139
+ if parsed.scheme not in {"https", "http"} or not parsed.netloc:
140
+ raise ValueError("base_url must be an HTTP(S) URL")
141
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
142
+ raise ValueError("base_url cannot contain credentials, a query, or a fragment")
143
+ if timeout <= 0:
144
+ raise ValueError("timeout must be positive")
145
+ self.base_url = base_url.rstrip("/")
146
+ self.timeout = timeout
147
+ self._token = token
148
+
149
+ @classmethod
150
+ def from_env(cls, *, timeout: float = 60) -> VentiMathClient:
151
+ """Read VENTI_API_KEY/VENTI_API_URL without writing a credential file."""
152
+ return cls(
153
+ token=os.environ.get("VENTI_API_KEY"),
154
+ base_url=os.environ.get("VENTI_API_URL") or DEFAULT_BASE_URL,
155
+ timeout=timeout,
156
+ )
157
+
158
+ def _request(
159
+ self,
160
+ method: str,
161
+ path: str,
162
+ *,
163
+ query: Mapping[str, Any] | None = None,
164
+ body: Any = None,
165
+ public: bool = False,
166
+ require_token: bool = False,
167
+ ) -> dict[str, Any]:
168
+ if require_token and not self._token:
169
+ raise ValueError("this write requires your own VentiMath token with tags.write")
170
+ url = f"{self.base_url}/{path}"
171
+ if query:
172
+ encoded = urlencode({key: value for key, value in query.items() if value is not None})
173
+ if encoded:
174
+ url += f"?{encoded}"
175
+ headers = {"Accept": "application/json", "User-Agent": "ventimath-python/0.1.0"}
176
+ if self._token and not public:
177
+ headers["Authorization"] = f"Bearer {self._token}"
178
+ data = None
179
+ if body is not None:
180
+ data = _json_bytes(body)
181
+ headers["Content-Type"] = "application/json"
182
+ request = Request(url, data=data, headers=headers, method=method)
183
+ try:
184
+ with urlopen(request, timeout=self.timeout) as response:
185
+ text = response.read().decode("utf-8")
186
+ except HTTPError as error:
187
+ try:
188
+ with error:
189
+ text = error.read().decode("utf-8", errors="replace")
190
+ except (URLError, OSError, HTTPException) as read_error:
191
+ raise VentiMathConnectionError(
192
+ f"request to {url} failed while reading HTTP {error.code}: {read_error}"
193
+ ) from read_error
194
+ try:
195
+ payload = json.loads(text)
196
+ except ValueError:
197
+ payload = text
198
+ raise VentiMathAPIError(error.code, payload, url) from error
199
+ except (URLError, OSError, HTTPException) as error:
200
+ raise VentiMathConnectionError(f"request to {url} failed: {error}") from error
201
+ except UnicodeError as error:
202
+ raise VentiMathProtocolError(f"invalid UTF-8 response from {url}") from error
203
+ try:
204
+ payload = json.loads(text)
205
+ except ValueError as error:
206
+ raise VentiMathProtocolError(f"invalid JSON response from {url}") from error
207
+ if not isinstance(payload, dict):
208
+ raise VentiMathProtocolError(f"expected a JSON object from {url}")
209
+ return payload
210
+
211
+ def answer_keys(
212
+ self, *, ids: Iterable[str] | str | None = None, exam: str | None = None
213
+ ) -> dict[str, Any]:
214
+ """Public {problems, missing, unavailable}; exactly one selector is required."""
215
+ if (ids is None) == (exam is None):
216
+ raise ValueError("provide exactly one of ids or exam")
217
+ query = {"exam": exam}
218
+ if ids is not None:
219
+ values = ids.split(",") if isinstance(ids, str) else list(ids)
220
+ if not 1 <= len(values) <= 100 or any(
221
+ not isinstance(value, str) or not value.strip() or "," in value for value in values
222
+ ):
223
+ raise ValueError("ids must contain 1–100 nonempty canonical problem IDs")
224
+ query = {"ids": ",".join(value.strip() for value in values)}
225
+ elif not isinstance(exam, str) or not exam.strip():
226
+ raise ValueError("exam must be a nonempty exam ID")
227
+ return self._request("GET", "answerKeys", query=query, public=True)
228
+
229
+ def download_answer_key(
230
+ self,
231
+ path: str | os.PathLike[str],
232
+ *,
233
+ ids: Iterable[str] | str | None = None,
234
+ exam: str | None = None,
235
+ ) -> dict[str, Any]:
236
+ """Save JSON accepted by the classifier's existing local --answer-key option."""
237
+ payload = self.answer_keys(ids=ids, exam=exam)
238
+ destination = Path(path)
239
+ destination.parent.mkdir(parents=True, exist_ok=True)
240
+ destination.write_text(
241
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
242
+ )
243
+ return payload
244
+
245
+ def list_tag_sets(
246
+ self,
247
+ *,
248
+ owner: str | None = None,
249
+ limit: int | None = None,
250
+ cursor: str | None = None,
251
+ public: bool = False,
252
+ ) -> dict[str, Any]:
253
+ """One {sets, nextCursor} page; omit vocabulary until get_tag_set is called."""
254
+ return self._request(
255
+ "GET",
256
+ "problemTags/sets",
257
+ query={"owner": owner, **_page_query(limit, cursor)},
258
+ public=public,
259
+ )
260
+
261
+ def list_public_tag_sets(self, **kwargs: Any) -> dict[str, Any]:
262
+ """Discover public sets anonymously, even if this client has a token."""
263
+ return self.list_tag_sets(**{**kwargs, "public": True})
264
+
265
+ def get_tag_set(self, set_id: str, *, public: bool = False) -> dict[str, Any]:
266
+ """Return {set}, including vocabulary."""
267
+ return self._request("GET", _set_path(set_id), public=public)
268
+
269
+ def create_tag_set(
270
+ self,
271
+ slug: str,
272
+ name: str,
273
+ *,
274
+ public: bool = True,
275
+ vocabulary: Iterable[Mapping[str, str]] | None = None,
276
+ ) -> dict[str, Any]:
277
+ """Create a public set on the token owner's profile; use public=False for private."""
278
+ if type(public) is not bool:
279
+ raise ValueError("public must be a boolean")
280
+ body: dict[str, Any] = {
281
+ "slug": slug,
282
+ "name": name,
283
+ "visibility": "public" if public else "private",
284
+ }
285
+ if vocabulary is not None:
286
+ body["vocabulary"] = list(vocabulary)
287
+ return self._request("POST", "problemTags/sets", body=body, require_token=True)
288
+
289
+ def update_tag_set(
290
+ self,
291
+ set_id: str,
292
+ *,
293
+ name: str | None = None,
294
+ public: Literal[True] | None = None,
295
+ vocabulary: Iterable[Mapping[str, str]] | None = None,
296
+ ) -> dict[str, Any]:
297
+ """Update metadata or publish with public=True; publication is permanent.
298
+
299
+ Vocabulary replaces the list. Private visibility is only chosen at creation.
300
+ """
301
+ body: dict[str, Any] = {}
302
+ if name is not None:
303
+ body["name"] = name
304
+ if public is not None:
305
+ if public is not True:
306
+ raise ValueError("public must be True to publish; choose private at creation")
307
+ body["visibility"] = "public"
308
+ if vocabulary is not None:
309
+ body["vocabulary"] = list(vocabulary)
310
+ if not body:
311
+ raise ValueError("provide name, public, or vocabulary to update")
312
+ return self._request("PATCH", _set_path(set_id), body=body, require_token=True)
313
+
314
+ def import_classifications(
315
+ self,
316
+ set_id: str,
317
+ records: Iterable[Any] | Mapping[str, Any],
318
+ *,
319
+ vocabulary: Iterable[Mapping[str, str]] | None = None,
320
+ dry_run: bool = False,
321
+ model: str | None = None,
322
+ reasoning_effort: str | None = None,
323
+ ) -> dict[str, Any]:
324
+ """Import saved records with bounded requests and one aggregate report.
325
+
326
+ Individual `result` and combined `llmResult` shapes pass through unchanged.
327
+ Row-level rejection details pass through, with indices adjusted globally.
328
+ Dry runs preview each batch against currently persisted state.
329
+ """
330
+ if not self._token:
331
+ raise ValueError("imports require your own VentiMath token with tags.write")
332
+ if type(dry_run) is not bool:
333
+ raise ValueError("dry_run must be a boolean")
334
+ path = f"{_set_path(set_id)}/import"
335
+ items = [records] if isinstance(records, Mapping) else list(records)
336
+ if not items:
337
+ raise ValueError("no classifier records supplied")
338
+ options: dict[str, Any] = {"dryRun": dry_run}
339
+ if vocabulary is not None:
340
+ options["vocabulary"] = list(vocabulary)
341
+ if model is not None:
342
+ options["model"] = model
343
+ if reasoning_effort is not None:
344
+ options["reasoningEffort"] = reasoning_effort
345
+ batches: list[list[Any]] = []
346
+ batch: list[Any] = []
347
+ for index, record in enumerate(items):
348
+ if len(_json_bytes({**options, "records": [record]})) > MAX_IMPORT_BYTES:
349
+ raise ValueError(f"record {index} plus vocabulary exceeds the 2 MiB request limit")
350
+ candidate = [*batch, record]
351
+ if batch and (
352
+ len(candidate) > MAX_IMPORT_RECORDS
353
+ or len(_json_bytes({**options, "records": candidate})) > MAX_IMPORT_BYTES
354
+ ):
355
+ batches.append(batch)
356
+ batch = []
357
+ batch.append(record)
358
+ batches.append(batch)
359
+ report: dict[str, Any] = {
360
+ "imported": 0,
361
+ "unchanged": 0,
362
+ "rejected": [],
363
+ "dryRun": dry_run,
364
+ "total": len(items),
365
+ "processed": 0,
366
+ "remaining": len(items),
367
+ }
368
+ for chunk in batches:
369
+ try:
370
+ result = self._request(
371
+ "POST", path, body={**options, "records": chunk}, require_token=True
372
+ )
373
+ if (
374
+ type(result.get("imported")) is not int
375
+ or type(result.get("unchanged")) is not int
376
+ or result["imported"] < 0
377
+ or result["unchanged"] < 0
378
+ or not isinstance(result.get("rejected"), list)
379
+ or result["imported"] + result["unchanged"] + len(result["rejected"])
380
+ != len(chunk)
381
+ or any(
382
+ not isinstance(rejected, dict)
383
+ or type(rejected.get("index")) is not int
384
+ or not 0 <= rejected["index"] < len(chunk)
385
+ for rejected in result["rejected"]
386
+ )
387
+ ):
388
+ raise VentiMathProtocolError(
389
+ "invalid import response; inspect server state before retrying"
390
+ )
391
+ except VentiMathError as error:
392
+ raise VentiMathImportError(report, error) from error
393
+ report["imported"] += result["imported"]
394
+ report["unchanged"] += result["unchanged"]
395
+ report["rejected"].extend(
396
+ {**rejected, "index": report["processed"] + rejected["index"]}
397
+ for rejected in result["rejected"]
398
+ )
399
+ report["processed"] += len(chunk)
400
+ report["remaining"] = len(items) - report["processed"]
401
+ return report
402
+
403
+ def tagged_problems(
404
+ self,
405
+ set_id: str,
406
+ *,
407
+ skill: str | None = None,
408
+ importance: str | None = None,
409
+ limit: int | None = None,
410
+ cursor: str | None = None,
411
+ public: bool = False,
412
+ ) -> dict[str, Any]:
413
+ """One {set, problems, nextCursor} page with each problem's classification."""
414
+ if importance is not None and importance not in {"essential", "time-saving", "helpful"}:
415
+ raise ValueError("importance must be essential, time-saving, or helpful")
416
+ if importance and not skill:
417
+ raise ValueError("importance requires a skill")
418
+ return self._request(
419
+ "GET",
420
+ f"{_set_path(set_id)}/problems",
421
+ query={"skill": skill, "importance": importance, **_page_query(limit, cursor)},
422
+ public=public,
423
+ )
424
+
425
+ def iter_tagged_problems(self, set_id: str, **kwargs: Any) -> Iterable[dict[str, Any]]:
426
+ """Yield problems across pages, including empty pages with a next cursor."""
427
+ cursor = kwargs.pop("cursor", None)
428
+ seen = {cursor} if cursor else set()
429
+ while True:
430
+ page = self.tagged_problems(set_id, cursor=cursor, **kwargs)
431
+ if not isinstance(page.get("problems"), list):
432
+ raise VentiMathProtocolError("tagged-problem response needs a problems list")
433
+ yield from page["problems"]
434
+ cursor = page.get("nextCursor")
435
+ if not cursor:
436
+ break
437
+ if not isinstance(cursor, str) or cursor in seen:
438
+ raise VentiMathProtocolError(
439
+ "server returned an invalid or repeated pagination cursor"
440
+ )
441
+ seen.add(cursor)
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: ventimath
3
+ Version: 0.1.0
4
+ Summary: Small standard-library client for VentiMath answer keys and profile skill tags
5
+ Project-URL: Repository, https://github.com/VentiMath/venti-sdk
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+
9
+ # VentiMath Python SDK
10
+
11
+ Download answer keys, import your problem classifications, and find problems by skill.
12
+ Requires Python 3.10 or newer, with no runtime dependencies.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pip install ventimath
18
+ # Or add it to a uv project:
19
+ uv add ventimath
20
+ ```
21
+
22
+ The default API base URL is
23
+ `https://us-central1-ventimath.cloudfunctions.net`; use `base_url=` for a deployed
24
+ test environment or functions emulator.
25
+
26
+ ## Download answer keys for the existing classifier
27
+
28
+ Answer-key reads are public and always omit authentication, even when the client
29
+ has a token. Download a file; the classifier's existing `--answer-key` reads a
30
+ local JSON path, not a URL.
31
+
32
+ ```python
33
+ from ventimath import VentiMathClient
34
+
35
+ public = VentiMathClient()
36
+ result = public.download_answer_key("answer-key.json", exam="EXAM_ID")
37
+ print("Missing problems:", result["missing"])
38
+ print("No usable key:", result["unavailable"])
39
+
40
+ # Or select up to 100 canonical problem IDs:
41
+ keys = public.answer_keys(ids=["PROBLEM_ID_1", "PROBLEM_ID_2"])
42
+ ```
43
+
44
+ ```sh
45
+ python classify_skills.py PROBLEM_ID --answer-key answer-key.json
46
+ ```
47
+
48
+ The response and saved file have the same shape:
49
+
50
+ ```json
51
+ {
52
+ "problems": {
53
+ "PROBLEM_ID": {"correctChoice": "B", "answerText": "2"}
54
+ },
55
+ "missing": [],
56
+ "unavailable": []
57
+ }
58
+ ```
59
+
60
+ Text-answer records carry `answerText`; accepted alternatives may also appear in
61
+ `allowedAnswers`. Exam requests are bounded to 500 placements. Unknown canonical
62
+ IDs appear in `missing`; proofs, absent keys, and placeholders appear in
63
+ `unavailable`.
64
+
65
+ ## Import classifications on your own profile
66
+
67
+ Create your VentiMath account and mint **your own** personal access token in
68
+ Settings → Developer. Select `tags.read` and `tags.write`. Keep the token in your
69
+ environment rather than in source files. The authenticated account owns the set;
70
+ there is no option to assign your upload to someone else's account.
71
+
72
+ `VentiMathClient(token=...)` uses an explicit token. `VentiMathClient.from_env()`
73
+ reads `VENTI_API_KEY` and optionally `VENTI_API_URL`. Neither stores credentials.
74
+ The ordinary `VentiMathClient()` constructor remains anonymous even if a token
75
+ environment variable exists.
76
+
77
+ Sets are **public by default**. Pass `public=False` at creation to keep a set
78
+ private to your account. Publishing a private set is permanent; public sets cannot
79
+ be made private. This import example creates a private set:
80
+
81
+ ```python
82
+ from pathlib import Path
83
+ from ventimath import (
84
+ VentiMathClient,
85
+ load_classification_files,
86
+ load_vocabulary_csv,
87
+ )
88
+
89
+ client = VentiMathClient.from_env()
90
+ vocabulary = load_vocabulary_csv("skill-lists/skills-merged.csv")
91
+ created = client.create_tag_set(
92
+ "amc-skills",
93
+ "My AMC skill classifications",
94
+ public=False,
95
+ vocabulary=vocabulary,
96
+ )
97
+ set_id = created["set"]["id"]
98
+
99
+ # Use saved per-problem output from a single classifier run folder.
100
+ records = load_classification_files(sorted(Path("output/skills/MODEL_DIRECTORY").glob("*.json")))
101
+
102
+ # Or use the classifier's combined output instead:
103
+ # records = load_classification_files(["combined.json"])
104
+
105
+ preview = client.import_classifications(set_id, records, dry_run=True)
106
+ print(preview)
107
+
108
+ # After inspecting the preview, upload the same records.
109
+ report = client.import_classifications(set_id, records)
110
+ print("Imported:", report["imported"], "unchanged:", report["unchanged"])
111
+ for rejected in report["rejected"]:
112
+ print(rejected)
113
+ ```
114
+
115
+ Individual records use `{problemId, result, model, reasoningEffort}`. Combined
116
+ files use `{problemId, llmResult}` and usually omit model provenance; supply
117
+ `model="MODEL_USED"` and `reasoning_effort="EFFORT_USED"` to `import_classifications`
118
+ when those values are known. Per-record values take precedence. Both shapes pass
119
+ through to the API unchanged. The server validates each classification and
120
+ discards copied prompts, raw replies, statements, and answer fields.
121
+
122
+ Vocabulary CSV headers are `category`, `skill`, and `description`, in any order.
123
+ Quoted commas/newlines, escaped quotes, and a UTF-8 BOM are supported. On import,
124
+ `vocabulary=` merges by skill name; supplied entries update matching descriptions
125
+ and categories. Accepted records add unseen skill names. To explicitly replace
126
+ the vocabulary, use `update_tag_set(set_id, vocabulary=...)`.
127
+
128
+ ## Retrieve problems by skill
129
+
130
+ ```python
131
+ # Your own sets, including private sets; needs tags.read.
132
+ mine = client.list_tag_sets()
133
+
134
+ # Public discovery always omits the token, even on an authenticated client.
135
+ shared = client.list_public_tag_sets(owner="OWNER_NUMERIC_ID", limit=20)
136
+ # Pass shared["nextCursor"] as cursor= to request the next page.
137
+
138
+ # Set listing returns metadata. Fetch a set to get its vocabulary.
139
+ tag_set = client.get_tag_set(set_id)
140
+ print(tag_set["set"]["vocabulary"])
141
+
142
+ # One page, preserving the response envelope and pagination cursor:
143
+ page = client.tagged_problems(
144
+ set_id, skill="Casework", importance="essential", limit=25
145
+ )
146
+
147
+ # Or iterate across every page:
148
+ for problem in client.iter_tagged_problems(set_id, skill="Casework"):
149
+ print(problem["id"], problem["details"]["statement"])
150
+ print(problem["classification"])
151
+ ```
152
+
153
+ Skill names match exactly. Importance is `essential`, `time-saving`, or `helpful`
154
+ and requires a skill filter.
155
+ Use `public=True` on `get_tag_set`, `tagged_problems`, or `iter_tagged_problems`
156
+ to read a public set without sending the client's token. Public problem records
157
+ omit catalog answer fields; their attached classifications remain attributed to
158
+ the set owner.
159
+
160
+ ## Reports, errors, and retries
161
+
162
+ Imports are split into requests of at most 200 records and 2 MiB of UTF-8 JSON.
163
+ All input is checked for JSON encodability and individual request size before
164
+ any request is sent. The aggregate response contains `imported`, `unchanged`,
165
+ `rejected`, `dryRun`, `total`, `processed`, and `remaining`. Rejected entries retain
166
+ the server's error code and problem ID; their zero-based `index` refers to your
167
+ complete input sequence. Row rejections do not raise an exception; inspect them.
168
+
169
+ Dry runs validate each batch against the currently stored set without writing.
170
+ For imports spanning batches, repeated problem IDs or the cumulative vocabulary
171
+ limit can make actual counts differ from the preview. Inspect the final report.
172
+
173
+ ```python
174
+ from ventimath import VentiMathAPIError, VentiMathImportError
175
+
176
+ try:
177
+ report = client.import_classifications(set_id, records)
178
+ except VentiMathImportError as error:
179
+ print("Completed requests:", error.report)
180
+ print("HTTP status, if available:", error.status)
181
+ print("Server payload, if available:", error.payload)
182
+ # A failed response does not prove the last request was uncommitted.
183
+ # Retrying the identical input is safe; unchanged records are not rewritten.
184
+ except VentiMathAPIError as error:
185
+ print(error.status, error.payload)
186
+ ```
187
+
188
+ Other methods raise `VentiMathAPIError` with the original HTTP status and server
189
+ payload for HTTP failures. `VentiMathConnectionError` covers transport failures,
190
+ including interrupted response bodies;
191
+ `VentiMathProtocolError` covers unexpected responses. Invalid local arguments
192
+ raise `ValueError`. Imports wrap transport/protocol/HTTP failures in
193
+ `VentiMathImportError` to preserve the counts from completed requests.
194
+
195
+ These methods call the actual REST routes: `GET /answerKeys`,
196
+ `GET|POST /problemTags/sets`, `GET|PATCH /problemTags/sets/{id}`,
197
+ `POST /problemTags/sets/{id}/import`, and
198
+ `GET /problemTags/sets/{id}/problems`. You can call those endpoints directly from
199
+ your existing Python HTTP code; the SDK is optional.
200
+
201
+ ## Development
202
+
203
+ Install from a source checkout with `python -m pip install .`.
204
+
205
+ From this package directory, without installing or contacting a server:
206
+
207
+ ```sh
208
+ PYTHONPATH=src python -B -m unittest discover -s tests -v
209
+ ```
210
+
211
+ All transport calls in these tests are mocked. They exercise exact URLs and
212
+ headers, anonymous discovery, private ownership request shaping, both classifier
213
+ record formats, CSV parsing, Unicode batch limits, pagination, rejected redirects,
214
+ and partial-failure reports.
@@ -0,0 +1,6 @@
1
+ ventimath/__init__.py,sha256=m7hDvQqqguH6xPRMU-OdLEuSKy8TL2qt57PBmnPMoUg,587
2
+ ventimath/client.py,sha256=9Un81c8Q-Al77T6sRclDnrb3NViCKu-pXJM9uJVf15w,18305
3
+ ventimath-0.1.0.dist-info/METADATA,sha256=I8FLapqHmBI1ZBq4Katu2Fnm7aT2b_2LY6cXTTTcu7I,8283
4
+ ventimath-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ ventimath-0.1.0.dist-info/top_level.txt,sha256=0G5b1NL6nlsVycAkb2myss6CWJymFE0j7eRzzI6mor8,10
6
+ ventimath-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ ventimath