cubic-sdk 0.3.2__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.
cubic/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ """Cubic Python SDK.
2
+
3
+ Quickstart::
4
+
5
+ from cubic import Cubic
6
+
7
+ client = Cubic(api_key="mxk_...")
8
+ result = client.completions.create(
9
+ cube_id="cbe_a1B2c3D4e5F6g7",
10
+ variables={"customer_name": "Ada"},
11
+ )
12
+ print(result.content)
13
+ """
14
+
15
+ from . import webhooks
16
+ from ._async_client import AsyncCubic
17
+ from ._client import Cubic
18
+ from ._exceptions import (
19
+ APIConnectionError,
20
+ APITimeoutError,
21
+ AuthenticationError,
22
+ CompletionError,
23
+ CompletionNotFoundError,
24
+ CompletionTimeoutError,
25
+ CubeNotFoundError,
26
+ CubicError,
27
+ InsufficientCreditsError,
28
+ InternalServerError,
29
+ InvalidRequestError,
30
+ MissingVariableError,
31
+ ModelNotFoundError,
32
+ NotFoundError,
33
+ PermissionDeniedError,
34
+ ProviderError,
35
+ RateLimitError,
36
+ VersionNotFoundError,
37
+ WaitTimeoutError,
38
+ WebhookSignatureError,
39
+ )
40
+ from ._version import __version__
41
+ from .types import (
42
+ AttemptError,
43
+ CompletionRecord,
44
+ CompletionResult,
45
+ Cube,
46
+ CubeModel,
47
+ Metrics,
48
+ Model,
49
+ PolycubeResult,
50
+ Segment,
51
+ SingleCompletion,
52
+ )
53
+
54
+ __all__ = [
55
+ "Cubic",
56
+ "AsyncCubic",
57
+ "webhooks",
58
+ "__version__",
59
+ # exceptions
60
+ "CubicError",
61
+ "APIConnectionError",
62
+ "APITimeoutError",
63
+ "AuthenticationError",
64
+ "PermissionDeniedError",
65
+ "NotFoundError",
66
+ "ModelNotFoundError",
67
+ "CubeNotFoundError",
68
+ "VersionNotFoundError",
69
+ "CompletionNotFoundError",
70
+ "InvalidRequestError",
71
+ "InsufficientCreditsError",
72
+ "RateLimitError",
73
+ "CompletionTimeoutError",
74
+ "InternalServerError",
75
+ "CompletionError",
76
+ "MissingVariableError",
77
+ "ProviderError",
78
+ "WaitTimeoutError",
79
+ "WebhookSignatureError",
80
+ # types
81
+ "AttemptError",
82
+ "Metrics",
83
+ "SingleCompletion",
84
+ "CompletionResult",
85
+ "PolycubeResult",
86
+ "Segment",
87
+ "CompletionRecord",
88
+ "Cube",
89
+ "CubeModel",
90
+ "Model",
91
+ ]
cubic/_async_client.py ADDED
@@ -0,0 +1,130 @@
1
+ """The asynchronous Cubic client — same surface as :class:`cubic.Cubic`, awaitable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from . import _exceptions as err
12
+ from ._client import (
13
+ DEFAULT_BASE_URL,
14
+ DEFAULT_MAX_RETRIES,
15
+ DEFAULT_TIMEOUT,
16
+ classify_retry,
17
+ error_from_response,
18
+ next_delay,
19
+ )
20
+ from ._version import __version__
21
+
22
+
23
+ class AsyncCubic:
24
+ """Asynchronous client for the Cubic API.
25
+
26
+ Accepts the same arguments as :class:`cubic.Cubic`; every resource method
27
+ is a coroutine. Use it as an async context manager or call ``aclose()``.
28
+
29
+ Args:
30
+ api_key: A ``mxk_…`` API key. Falls back to the ``CUBIC_API_KEY``
31
+ environment variable.
32
+ base_url: API origin. Falls back to ``CUBIC_BASE_URL``, then the
33
+ hosted API (https://api.cubic.zone).
34
+ timeout: httpx timeout for all requests.
35
+ max_retries: Automatic retries for transient failures (connection
36
+ errors, capacity 429s, and — when the request is idempotent —
37
+ 5xx responses).
38
+ http_client: Bring your own ``httpx.AsyncClient`` (proxies, custom
39
+ transports, testing). The SDK will not close it for you.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ api_key: str | None = None,
45
+ *,
46
+ base_url: str | None = None,
47
+ timeout: httpx.Timeout | float | None = None,
48
+ max_retries: int = DEFAULT_MAX_RETRIES,
49
+ http_client: httpx.AsyncClient | None = None,
50
+ backoff_base: float = 0.5,
51
+ ) -> None:
52
+ self.api_key = api_key or os.environ.get("CUBIC_API_KEY")
53
+ if not self.api_key:
54
+ raise err.CubicError(
55
+ "No API key provided. Pass api_key=... or set the CUBIC_API_KEY "
56
+ "environment variable. Keys are created in the Cubic dashboard "
57
+ "and start with 'mxk_'."
58
+ )
59
+ self.base_url = (base_url or os.environ.get("CUBIC_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
60
+ self.max_retries = max_retries
61
+ self._backoff_base = backoff_base
62
+ self._own_http = http_client is None
63
+ self._http = http_client or httpx.AsyncClient(
64
+ timeout=timeout if timeout is not None else DEFAULT_TIMEOUT
65
+ )
66
+ self._kind_cache: dict[str, str] = {}
67
+
68
+ from .resources.completions import AsyncCompletions
69
+ from .resources.cubes import AsyncCubes
70
+ from .resources.models import AsyncModels
71
+
72
+ self.completions = AsyncCompletions(self)
73
+ self.cubes = AsyncCubes(self)
74
+ self.models = AsyncModels(self)
75
+
76
+ # ---- lifecycle ----
77
+ async def aclose(self) -> None:
78
+ if self._own_http:
79
+ await self._http.aclose()
80
+
81
+ async def __aenter__(self) -> "AsyncCubic":
82
+ return self
83
+
84
+ async def __aexit__(self, *exc: Any) -> None:
85
+ await self.aclose()
86
+
87
+ # ---- transport ----
88
+ async def request(
89
+ self,
90
+ method: str,
91
+ path: str,
92
+ *,
93
+ json_body: dict | None = None,
94
+ params: dict | None = None,
95
+ idempotent: bool = False,
96
+ ) -> httpx.Response:
97
+ url = f"{self.base_url}{path}"
98
+ headers = {
99
+ "Authorization": f"Bearer {self.api_key}",
100
+ "User-Agent": f"cubic-python/{__version__}",
101
+ }
102
+ attempt = 0
103
+ while True:
104
+ retry_after: float | None = None
105
+ try:
106
+ response = await self._http.request(
107
+ method, url, json=json_body, params=params, headers=headers
108
+ )
109
+ except (httpx.ConnectError, httpx.ConnectTimeout) as e:
110
+ # The request never reached the server — always safe to retry.
111
+ exc: err.CubicError = err.APIConnectionError(f"Could not reach {self.base_url}: {e}")
112
+ retryable = True
113
+ except httpx.TimeoutException as e:
114
+ exc = err.APITimeoutError(f"Request timed out: {e}")
115
+ retryable = idempotent
116
+ except httpx.TransportError as e:
117
+ exc = err.APIConnectionError(f"Transport error: {e}")
118
+ retryable = idempotent
119
+ else:
120
+ if response.status_code < 400:
121
+ return response
122
+ exc = error_from_response(response)
123
+ retryable, retry_after = classify_retry(response, exc, idempotent)
124
+
125
+ if not retryable or attempt >= self.max_retries:
126
+ raise exc
127
+ attempt += 1
128
+ delay = next_delay(attempt, retry_after, self._backoff_base)
129
+ if delay > 0:
130
+ await asyncio.sleep(delay)
cubic/_client.py ADDED
@@ -0,0 +1,247 @@
1
+ """The synchronous Cubic client: transport, auth, retries, and error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ import time
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from . import _exceptions as err
13
+ from ._version import __version__
14
+
15
+ # The hosted Cubic API. Point CUBIC_BASE_URL or base_url= at another deployment
16
+ # (e.g. http://localhost:8010 for local development).
17
+ DEFAULT_BASE_URL = "https://api.cubic.zone"
18
+
19
+ # Completions can legitimately run for minutes (the server's own execution
20
+ # deadline is ~150s), so the read timeout is generous.
21
+ DEFAULT_TIMEOUT = httpx.Timeout(180.0, connect=5.0)
22
+ DEFAULT_MAX_RETRIES = 2
23
+
24
+ # Statuses safe to retry only when the request carries an idempotency key
25
+ # (client_request_id): the server may or may not have started executing.
26
+ _RETRY_STATUSES_IDEMPOTENT = {500, 502, 503}
27
+
28
+
29
+ def classify_retry(
30
+ response: httpx.Response, exc: err.CubicError, idempotent: bool
31
+ ) -> tuple[bool, float | None]:
32
+ """Decide whether a failed response may be retried, and any server-directed delay."""
33
+ status = response.status_code
34
+ if status == 429:
35
+ # Capacity load-shedding has no error_code and is always safe to
36
+ # retry (the request was never admitted). Credit/quota 429s are
37
+ # caller errors — never retry those.
38
+ if isinstance(exc, err.RateLimitError) and exc.error_code is None:
39
+ return True, exc.retry_after
40
+ return False, None
41
+ if status in _RETRY_STATUSES_IDEMPOTENT and idempotent:
42
+ return True, None
43
+ return False, None
44
+
45
+
46
+ def next_delay(attempt: int, retry_after: float | None, base: float) -> float:
47
+ if retry_after is not None:
48
+ return retry_after
49
+ delay = min(base * (2 ** (attempt - 1)), 8.0)
50
+ return delay + random.uniform(0, delay / 4)
51
+
52
+
53
+ class Cubic:
54
+ """Synchronous client for the Cubic API.
55
+
56
+ Args:
57
+ api_key: A ``mxk_…`` API key. Falls back to the ``CUBIC_API_KEY``
58
+ environment variable.
59
+ base_url: API origin. Falls back to ``CUBIC_BASE_URL``, then the
60
+ hosted API (https://api.cubic.zone).
61
+ timeout: httpx timeout for all requests.
62
+ max_retries: Automatic retries for transient failures (connection
63
+ errors, capacity 429s, and — when the request is idempotent —
64
+ 5xx responses).
65
+ http_client: Bring your own ``httpx.Client`` (proxies, custom
66
+ transports, testing). The SDK will not close it for you.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ api_key: str | None = None,
72
+ *,
73
+ base_url: str | None = None,
74
+ timeout: httpx.Timeout | float | None = None,
75
+ max_retries: int = DEFAULT_MAX_RETRIES,
76
+ http_client: httpx.Client | None = None,
77
+ backoff_base: float = 0.5,
78
+ ) -> None:
79
+ self.api_key = api_key or os.environ.get("CUBIC_API_KEY")
80
+ if not self.api_key:
81
+ raise err.CubicError(
82
+ "No API key provided. Pass api_key=... or set the CUBIC_API_KEY "
83
+ "environment variable. Keys are created in the Cubic dashboard "
84
+ "and start with 'mxk_'."
85
+ )
86
+ self.base_url = (base_url or os.environ.get("CUBIC_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
87
+ self.max_retries = max_retries
88
+ self._backoff_base = backoff_base
89
+ self._own_http = http_client is None
90
+ self._http = http_client or httpx.Client(timeout=timeout if timeout is not None else DEFAULT_TIMEOUT)
91
+ # Remembers which public IDs turned out to be polycubes, so we stop
92
+ # attaching fields the chain path rejects (e.g. client_request_id).
93
+ self._kind_cache: dict[str, str] = {}
94
+
95
+ from .resources.completions import Completions
96
+ from .resources.cubes import Cubes
97
+ from .resources.models import Models
98
+
99
+ self.completions = Completions(self)
100
+ self.cubes = Cubes(self)
101
+ self.models = Models(self)
102
+
103
+ # ---- lifecycle ----
104
+ def close(self) -> None:
105
+ if self._own_http:
106
+ self._http.close()
107
+
108
+ def __enter__(self) -> "Cubic":
109
+ return self
110
+
111
+ def __exit__(self, *exc: Any) -> None:
112
+ self.close()
113
+
114
+ # ---- transport ----
115
+ def request(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ *,
120
+ json_body: dict | None = None,
121
+ params: dict | None = None,
122
+ idempotent: bool = False,
123
+ ) -> httpx.Response:
124
+ url = f"{self.base_url}{path}"
125
+ headers = {
126
+ "Authorization": f"Bearer {self.api_key}",
127
+ "User-Agent": f"cubic-python/{__version__}",
128
+ }
129
+ attempt = 0
130
+ while True:
131
+ retry_after: float | None = None
132
+ try:
133
+ response = self._http.request(method, url, json=json_body, params=params, headers=headers)
134
+ except (httpx.ConnectError, httpx.ConnectTimeout) as e:
135
+ # The request never reached the server — always safe to retry.
136
+ exc: err.CubicError = err.APIConnectionError(f"Could not reach {self.base_url}: {e}")
137
+ retryable = True
138
+ except httpx.TimeoutException as e:
139
+ exc = err.APITimeoutError(f"Request timed out: {e}")
140
+ retryable = idempotent
141
+ except httpx.TransportError as e:
142
+ exc = err.APIConnectionError(f"Transport error: {e}")
143
+ retryable = idempotent
144
+ else:
145
+ if response.status_code < 400:
146
+ return response
147
+ exc = error_from_response(response)
148
+ retryable, retry_after = classify_retry(response, exc, idempotent)
149
+
150
+ if not retryable or attempt >= self.max_retries:
151
+ raise exc
152
+ attempt += 1
153
+ delay = next_delay(attempt, retry_after, self._backoff_base)
154
+ if delay > 0:
155
+ time.sleep(delay)
156
+
157
+
158
+ def _flatten_validation_detail(detail: list[Any]) -> str:
159
+ """Flatten FastAPI's 422 detail array into one readable message."""
160
+ parts = []
161
+ for item in detail:
162
+ if not isinstance(item, dict):
163
+ parts.append(str(item))
164
+ continue
165
+ loc = item.get("loc") or []
166
+ # drop the leading "body" segment — callers think in field names
167
+ field = ".".join(str(x) for x in loc[1:] if x != "__root__") or ".".join(map(str, loc))
168
+ msg = item.get("msg", "invalid")
169
+ parts.append(f"{field}: {msg}" if field else msg)
170
+ return "; ".join(parts) or "Invalid request"
171
+
172
+
173
+ def error_from_response(response: httpx.Response) -> err.CubicError:
174
+ """Map a non-2xx API response onto the SDK exception hierarchy.
175
+
176
+ Dispatch is by ``error_code`` first, HTTP status second — the API surfaces
177
+ e.g. insufficient credits under more than one status.
178
+ """
179
+ status = response.status_code
180
+ request_id = response.headers.get("X-Request-ID")
181
+ try:
182
+ body = response.json()
183
+ except Exception:
184
+ body = None
185
+ detail = body.get("detail") if isinstance(body, dict) else None
186
+ error_code = body.get("error_code") if isinstance(body, dict) else None
187
+ common: dict[str, Any] = {
188
+ "error_code": error_code,
189
+ "status_code": status,
190
+ "request_id": request_id,
191
+ "body": body,
192
+ }
193
+
194
+ # Pydantic validation errors arrive as a list of field errors.
195
+ if isinstance(detail, list):
196
+ return err.InvalidRequestError(_flatten_validation_detail(detail), **common)
197
+
198
+ message = detail if isinstance(detail, str) else f"HTTP {status} from Cubic API"
199
+
200
+ if error_code == "insufficient_credits" or status == 402:
201
+ g = body if isinstance(body, dict) else {}
202
+ return err.InsufficientCreditsError(
203
+ message,
204
+ required=g.get("required"),
205
+ balance=g.get("balance"),
206
+ grace=g.get("grace"),
207
+ topup_allowed=g.get("topup_allowed"),
208
+ **common,
209
+ )
210
+ if status == 401:
211
+ return err.AuthenticationError(message, **common)
212
+ if status == 403:
213
+ return err.PermissionDeniedError(message, **common)
214
+ if status == 404:
215
+ if error_code == "version_not_found":
216
+ return err.VersionNotFoundError(message, **common)
217
+ if error_code in ("cube_not_found", "chain_not_found", "prompt_not_found"):
218
+ return err.CubeNotFoundError(
219
+ message + " (the ID may not exist, or your key may not own it — "
220
+ "marketplace cube definitions are not readable by subscribers)",
221
+ **common,
222
+ )
223
+ if error_code == "completion_not_found":
224
+ return err.CompletionNotFoundError(message, **common)
225
+ return err.NotFoundError(message, **common)
226
+ if status == 422:
227
+ return err.InvalidRequestError(message, **common)
228
+ if status == 429:
229
+ retry_after_header = response.headers.get("Retry-After")
230
+ try:
231
+ retry_after = float(retry_after_header) if retry_after_header else None
232
+ except ValueError:
233
+ retry_after = None
234
+ return err.RateLimitError(message, retry_after=retry_after, **common)
235
+ if status == 504:
236
+ # The deadline response is a CompletionResponse envelope, not the
237
+ # standard error shape — pull the human message out of attempt_errors.
238
+ if isinstance(body, dict):
239
+ for ae in body.get("attempt_errors") or []:
240
+ if isinstance(ae, dict) and ae.get("message"):
241
+ message = ae["message"]
242
+ common["error_code"] = ae.get("error_code")
243
+ break
244
+ return err.CompletionTimeoutError(message, **common)
245
+ if status >= 500:
246
+ return err.InternalServerError(message, **common)
247
+ return err.CubicError(message, **common)
cubic/_exceptions.py ADDED
@@ -0,0 +1,171 @@
1
+ """Exception hierarchy for the Cubic SDK.
2
+
3
+ Every exception carries ``error_code`` (the API's machine-readable code),
4
+ ``status_code`` (HTTP, when the error came from a non-2xx response),
5
+ ``request_id`` (the ``X-Request-ID`` correlation header — quote it in support
6
+ requests), and ``body`` (the raw parsed response body, when available).
7
+
8
+ Pipeline failures — where the API returns HTTP 200 with ``status: "error"`` —
9
+ are raised as :class:`CompletionError` subclasses and additionally carry the
10
+ parsed result object on ``.result``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+
18
+ class CubicError(Exception):
19
+ """Base class for every error raised by the Cubic SDK."""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ error_code: str | None = None,
26
+ status_code: int | None = None,
27
+ request_id: str | None = None,
28
+ body: Any = None,
29
+ ) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ self.error_code = error_code
33
+ self.status_code = status_code
34
+ self.request_id = request_id
35
+ self.body = body
36
+
37
+
38
+ class APIConnectionError(CubicError):
39
+ """The SDK could not reach the Cubic API (DNS, refused connection, TLS…)."""
40
+
41
+
42
+ class APITimeoutError(APIConnectionError):
43
+ """The HTTP request timed out client-side before a response arrived."""
44
+
45
+
46
+ class AuthenticationError(CubicError):
47
+ """401 — the API key is missing, invalid, revoked, or expired."""
48
+
49
+
50
+ class PermissionDeniedError(CubicError):
51
+ """403 — e.g. ``marketplace_subscription_required`` or ``override_forbidden``."""
52
+
53
+
54
+ class NotFoundError(CubicError):
55
+ """404 — the referenced resource does not exist (or is not yours)."""
56
+
57
+
58
+ class CubeNotFoundError(NotFoundError):
59
+ """The cube/polycube ID could not be resolved.
60
+
61
+ The API deliberately returns the same response for an unknown ID and for a
62
+ cube your key does not own, so foreign IDs cannot be probed. Note that
63
+ marketplace subscribers can *run* a listed cube but cannot read its
64
+ definition, and ``cubes.retrieve`` does not (yet) serve polycube IDs.
65
+ """
66
+
67
+
68
+ class VersionNotFoundError(CubeNotFoundError):
69
+ """The cube exists but the pinned ``version`` does not."""
70
+
71
+
72
+ class CompletionNotFoundError(NotFoundError):
73
+ """No completion record exists for the given ``request_id``."""
74
+
75
+
76
+ class ModelNotFoundError(NotFoundError):
77
+ """No catalog model matches the given name (client-side lookup against
78
+ ``models.list()``). The message includes close-match suggestions."""
79
+
80
+
81
+ class InvalidRequestError(CubicError):
82
+ """422 — the request body failed validation (unknown fields, bad types,
83
+ out-of-range parameters, or fields not applicable to a polycube)."""
84
+
85
+
86
+ class InsufficientCreditsError(CubicError):
87
+ """The credit gate rejected the request. Not retryable — top up first."""
88
+
89
+ def __init__(
90
+ self,
91
+ message: str,
92
+ *,
93
+ required: int | None = None,
94
+ balance: int | None = None,
95
+ grace: int | None = None,
96
+ topup_allowed: bool | None = None,
97
+ **kwargs: Any,
98
+ ) -> None:
99
+ super().__init__(message, **kwargs)
100
+ self.required = required
101
+ self.balance = balance
102
+ self.grace = grace
103
+ self.topup_allowed = topup_allowed
104
+
105
+
106
+ class RateLimitError(CubicError):
107
+ """429 — the server is at capacity or a quota was exceeded.
108
+
109
+ Capacity 429s are retried automatically; if you see this, retries were
110
+ exhausted. ``retry_after`` echoes the server's ``Retry-After`` header.
111
+ """
112
+
113
+ def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any) -> None:
114
+ super().__init__(message, **kwargs)
115
+ self.retry_after = retry_after
116
+
117
+
118
+ class CompletionTimeoutError(CubicError):
119
+ """504 — the completion exceeded the server's execution deadline."""
120
+
121
+
122
+ class InternalServerError(CubicError):
123
+ """5xx — an unexpected server-side failure."""
124
+
125
+
126
+ class WaitTimeoutError(CubicError):
127
+ """``wait()`` gave up before the queued completion's result was persisted.
128
+
129
+ The run may still complete server-side — the ``request_id`` stays valid for
130
+ ``completions.retrieve`` and the callback delivery is unaffected.
131
+ """
132
+
133
+
134
+ class WebhookSignatureError(CubicError):
135
+ """The callback payload's ``X-Maxwell-Signature`` header is missing or does
136
+ not match the payload — treat the delivery as unauthenticated."""
137
+
138
+
139
+ class CompletionError(CubicError):
140
+ """The request was accepted but the completion pipeline failed
141
+ (HTTP 200 with ``status: "error"``).
142
+
143
+ ``result`` holds the fully parsed response — including ``attempt_errors``
144
+ and, for polycubes, per-node ``segments`` — for inspection.
145
+ """
146
+
147
+ def __init__(
148
+ self,
149
+ message: str,
150
+ *,
151
+ result: Any = None,
152
+ attempt_errors: list[Any] | None = None,
153
+ **kwargs: Any,
154
+ ) -> None:
155
+ super().__init__(message, **kwargs)
156
+ self.result = result
157
+ self.attempt_errors = attempt_errors or []
158
+
159
+
160
+ class MissingVariableError(CompletionError):
161
+ """A required template variable was not provided (or failed coercion)."""
162
+
163
+ def __init__(self, message: str, *, variable_name: str | None = None, **kwargs: Any) -> None:
164
+ super().__init__(message, **kwargs)
165
+ self.variable_name = variable_name
166
+
167
+
168
+ class ProviderError(CompletionError):
169
+ """Every model attempt failed at the provider stage (rate limits, outages,
170
+ circuit breaker open, missing provider credential…). Check
171
+ ``attempt_errors`` for the per-attempt detail, including ``is_retryable``."""
cubic/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.3.2"
cubic/py.typed ADDED
File without changes
File without changes