pycellarion 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.
@@ -0,0 +1,26 @@
1
+ """pycellarion — async client for the Cellarion wine-cellar API."""
2
+
3
+ from .client import MAX_BODY_BYTES, CellarionClient, JsonDict
4
+ from .exceptions import (
5
+ CellarionApiError,
6
+ CellarionAuthError,
7
+ CellarionPushForbidden,
8
+ CellarionPushNotSupported,
9
+ CellarionScopeError,
10
+ CellarionTokensNotSupported,
11
+ )
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "MAX_BODY_BYTES",
17
+ "CellarionApiError",
18
+ "CellarionAuthError",
19
+ "CellarionClient",
20
+ "CellarionPushForbidden",
21
+ "CellarionPushNotSupported",
22
+ "CellarionScopeError",
23
+ "CellarionTokensNotSupported",
24
+ "JsonDict",
25
+ "__version__",
26
+ ]
pycellarion/client.py ADDED
@@ -0,0 +1,390 @@
1
+ """Async client for the Cellarion wine-cellar API.
2
+
3
+ The client is deliberately thin: it maps HTTP status codes to a small
4
+ exception hierarchy, retries one expired session once, parses the
5
+ server-sent event stream, and returns JSON objects as plain dicts.
6
+ It never stores a password beyond the login it was created for.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any
14
+ from urllib.parse import quote
15
+
16
+ import aiohttp
17
+
18
+ from .exceptions import (
19
+ CellarionApiError,
20
+ CellarionAuthError,
21
+ CellarionPushForbidden,
22
+ CellarionPushNotSupported,
23
+ CellarionScopeError,
24
+ CellarionTokensNotSupported,
25
+ )
26
+
27
+ _LOGGER = logging.getLogger(__name__)
28
+
29
+ JsonDict = dict[str, Any]
30
+
31
+ # Largest response body the client will read. The stats and bottle lists the
32
+ # integration asks for are a few kilobytes; anything past this is a proxy
33
+ # error page or a misbehaving server, not data worth holding in memory.
34
+ MAX_BODY_BYTES = 4 * 1024 * 1024
35
+
36
+
37
+ async def _read_json(resp: aiohttp.ClientResponse, what: str) -> JsonDict:
38
+ """Decode a JSON object body, mapping a non-JSON answer to an API error.
39
+
40
+ A reverse proxy or SPA fallback can answer a 200 with an HTML page; that
41
+ must surface as a normal (retryable) API error, not an unhandled
42
+ exception that marks the whole update as "unexpected".
43
+ """
44
+ try:
45
+ data = await resp.json()
46
+ except (aiohttp.ClientError, ValueError) as err:
47
+ raise CellarionApiError(f"{what} response was not JSON") from err
48
+ return data if isinstance(data, dict) else {}
49
+
50
+
51
+ class CellarionClient:
52
+ """Async API client for Cellarion."""
53
+
54
+ def __init__(
55
+ self,
56
+ session: aiohttp.ClientSession,
57
+ url: str,
58
+ email: str | None = None,
59
+ password: str | None = None,
60
+ token: str | None = None,
61
+ ) -> None:
62
+ self._session = session
63
+ self._url = url.rstrip("/")
64
+ self._email = email
65
+ self._password = password
66
+ # Personal API token (cel_...) — static, never refreshed via login
67
+ self._api_token = token
68
+ self._token: str | None = token
69
+
70
+ async def authenticate(self) -> bool:
71
+ """Authenticate and store JWT token."""
72
+ if self._api_token:
73
+ # Static API token: there is nothing to refresh. Reaching this
74
+ # means the server rejected it — revoked or invalid.
75
+ raise CellarionAuthError("API token rejected (revoked or invalid)")
76
+
77
+ login_url = f"{self._url}/api/auth/login"
78
+ _LOGGER.debug("Authenticating to %s", login_url)
79
+ try:
80
+ resp = await self._session.post(
81
+ login_url,
82
+ # The Cellarion login endpoint takes "username", which matches
83
+ # against both username and email.
84
+ json={"username": self._email, "password": self._password},
85
+ timeout=aiohttp.ClientTimeout(total=15),
86
+ # Never re-send the password body to a redirect target
87
+ allow_redirects=False,
88
+ )
89
+ except (aiohttp.ClientError, TimeoutError) as err:
90
+ _LOGGER.error("Connection to %s failed: %s", login_url, err)
91
+ raise CellarionApiError(f"Connection failed: {err}") from err
92
+
93
+ if 300 <= resp.status < 400:
94
+ raise CellarionApiError(
95
+ f"Login was redirected (status {resp.status}); configure the final instance URL"
96
+ )
97
+ if resp.status in (400, 401):
98
+ raise CellarionAuthError("Invalid email or password")
99
+ if resp.status == 403:
100
+ raise CellarionAuthError("Account not verified or access denied")
101
+ if resp.status == 429:
102
+ raise CellarionApiError("Rate limited by Cellarion, try again later")
103
+ if resp.status != 200:
104
+ raise CellarionApiError(f"Login failed with status {resp.status}")
105
+
106
+ data = await _read_json(resp, "Login")
107
+ self._token = data.get("token")
108
+ if not self._token:
109
+ raise CellarionApiError("No token in login response")
110
+ return True
111
+
112
+ async def _send(self, method: str, path: str, json: JsonDict | None) -> aiohttp.ClientResponse:
113
+ """Issue one request, mapping transport errors to CellarionApiError."""
114
+ try:
115
+ return await self._session.request(
116
+ method,
117
+ f"{self._url}{path}",
118
+ headers={"Authorization": f"Bearer {self._token}"},
119
+ json=json,
120
+ timeout=aiohttp.ClientTimeout(total=30),
121
+ )
122
+ except (aiohttp.ClientError, TimeoutError) as err:
123
+ raise CellarionApiError(f"Request failed: {err}") from err
124
+
125
+ async def _request(self, method: str, path: str, json: JsonDict | None = None) -> JsonDict:
126
+ """Make an authenticated API request with auto-retry on 401."""
127
+ if not self._token:
128
+ await self.authenticate()
129
+
130
+ resp = await self._send(method, path, json)
131
+ # Always release the connection back to the pool, even on error paths.
132
+ try:
133
+ if resp.status == 401:
134
+ # Token expired — re-authenticate and retry once
135
+ _LOGGER.debug("Token expired, re-authenticating")
136
+ resp.close()
137
+ await self.authenticate()
138
+ resp = await self._send(method, path, json)
139
+ if resp.status == 401:
140
+ raise CellarionAuthError("Authentication rejected after retry")
141
+
142
+ if resp.status == 403 and self._api_token:
143
+ # Valid token, missing scope — a config error; re-login won't help
144
+ raise CellarionScopeError(f"API token lacks the scope for {method} {path}")
145
+
146
+ # Accept the 2xx success range: action endpoints (e.g. consume) may
147
+ # answer 201/204 rather than 200.
148
+ if not 200 <= resp.status < 300:
149
+ detail = ""
150
+ try:
151
+ detail = (await resp.json()).get("error", "")
152
+ except Exception: # best-effort error body
153
+ detail = ""
154
+ raise CellarionApiError(
155
+ f"{method} {path} returned status {resp.status}"
156
+ + (f": {detail}" if detail else "")
157
+ )
158
+
159
+ # getattr keeps this working against test doubles that don't
160
+ # implement content_length; real aiohttp always provides it.
161
+ length = getattr(resp, "content_length", None)
162
+ if resp.status == 204 or length == 0:
163
+ return {}
164
+ if length is not None and length > MAX_BODY_BYTES:
165
+ raise CellarionApiError(
166
+ f"{method} {path} answered with {length} bytes; refusing to read it"
167
+ )
168
+ try:
169
+ data = await resp.json()
170
+ except (aiohttp.ClientError, ValueError):
171
+ # No/!JSON body on a success status — nothing to return
172
+ return {}
173
+ return data if isinstance(data, dict) else {}
174
+ finally:
175
+ resp.close()
176
+
177
+ async def get_stats_overview(self) -> JsonDict:
178
+ """Fetch collection statistics."""
179
+ return await self._request("GET", "/api/stats/overview")
180
+
181
+ async def get_cellars(self) -> JsonDict:
182
+ """Fetch user's cellars."""
183
+ return await self._request("GET", "/api/cellars")
184
+
185
+ async def get_account_id(self) -> str | None:
186
+ """Return a stable account id for the credential, or None.
187
+
188
+ Reads the scoped identity endpoint /api/auth/whoami, which returns
189
+ just {"id": ...}. Returns None when the server or credential can't
190
+ provide one — an older server without the endpoint (404), or a token
191
+ whose scope doesn't include it (403). Callers treat None as "unknown"
192
+ and skip the check, never as an error. The nested "user" fallback keeps
193
+ it working against the fuller /api/auth/me shape too.
194
+ """
195
+ try:
196
+ data = await self._request("GET", "/api/auth/whoami")
197
+ except CellarionApiError:
198
+ return None
199
+ user = data.get("user")
200
+ user = user if isinstance(user, dict) else {}
201
+ account_id = data.get("id") or user.get("id") or user.get("_id")
202
+ return str(account_id) if account_id else None
203
+
204
+ async def get_notifications(self) -> JsonDict:
205
+ """Fetch notifications with unread count."""
206
+ return await self._request("GET", "/api/notifications")
207
+
208
+ async def async_create_api_token(self, name: str, scopes: list[str]) -> str:
209
+ """Mint a personal API token. Requires password-based login.
210
+
211
+ The Cellarion endpoint requires the account password in the body as
212
+ confirmation; the caller stores only the returned token.
213
+ """
214
+ if not self._password:
215
+ raise CellarionApiError("Password login required to mint a token")
216
+ if not self._token:
217
+ await self.authenticate()
218
+
219
+ try:
220
+ resp = await self._session.post(
221
+ f"{self._url}/api/tokens",
222
+ headers={"Authorization": f"Bearer {self._token}"},
223
+ json={
224
+ "name": name,
225
+ "scopes": scopes,
226
+ "password": self._password,
227
+ },
228
+ timeout=aiohttp.ClientTimeout(total=15),
229
+ allow_redirects=False,
230
+ )
231
+ except (aiohttp.ClientError, TimeoutError) as err:
232
+ raise CellarionApiError(f"Token creation failed: {err}") from err
233
+
234
+ if 300 <= resp.status < 400:
235
+ raise CellarionApiError(
236
+ f"Token creation was redirected (status {resp.status}); "
237
+ "configure the final instance URL"
238
+ )
239
+ if resp.status in (404, 405, 501):
240
+ raise CellarionTokensNotSupported("Server does not support API tokens")
241
+ if resp.status in (401, 403):
242
+ raise CellarionAuthError("Password confirmation rejected")
243
+ if resp.status == 429:
244
+ raise CellarionApiError("Rate limited by Cellarion, try again later")
245
+ if resp.status not in (200, 201):
246
+ raise CellarionApiError(f"Token creation returned status {resp.status}")
247
+
248
+ data = await _read_json(resp, "Token creation")
249
+ token = data.get("token")
250
+ if not token:
251
+ raise CellarionApiError("No token in creation response")
252
+ return str(token)
253
+
254
+ async def get_peak_bottles(self, limit: int = 10) -> JsonDict:
255
+ """Fetch bottles currently in their peak drink window."""
256
+ return await self._request("GET", f"/api/bottles?maturity=peak&limit={int(limit)}")
257
+
258
+ async def consume_bottle(
259
+ self,
260
+ bottle_id: str,
261
+ reason: str = "drank",
262
+ rating: float | None = None,
263
+ note: str | None = None,
264
+ ) -> JsonDict:
265
+ """Mark a bottle as consumed (drank/gifted/sold/other)."""
266
+ body: JsonDict = {"reason": reason}
267
+ if rating is not None:
268
+ body["rating"] = rating
269
+ if note:
270
+ body["note"] = note
271
+ # The id is validated upstream; quoting keeps a stray "/", "?" or "#"
272
+ # from steering the request to another path regardless.
273
+ return await self._request(
274
+ "POST", f"/api/bottles/{quote(bottle_id, safe='')}/consume", json=body
275
+ )
276
+
277
+ async def events_stream(self) -> AsyncIterator[str]:
278
+ """Yield push event names from the server's SSE stream.
279
+
280
+ Yields the sentinel "_connected" once the stream is open, then one
281
+ item per server-sent event. Raises CellarionPushNotSupported when
282
+ the server has no stream endpoint (integration falls back to
283
+ polling only).
284
+ """
285
+ if not self._token:
286
+ await self.authenticate()
287
+
288
+ url = f"{self._url}/api/events/stream"
289
+ headers = {
290
+ "Authorization": f"Bearer {self._token}",
291
+ "Accept": "text/event-stream",
292
+ }
293
+ # sock_read=90 → three missed 25s server heartbeats = dead stream
294
+ timeout = aiohttp.ClientTimeout(total=None, connect=15, sock_read=90)
295
+
296
+ try:
297
+ resp = await self._session.get(url, headers=headers, timeout=timeout)
298
+ except (aiohttp.ClientError, TimeoutError) as err:
299
+ raise CellarionApiError(f"Push stream connection failed: {err}") from err
300
+
301
+ if resp.status == 401:
302
+ resp.close()
303
+ await self.authenticate()
304
+ headers["Authorization"] = f"Bearer {self._token}"
305
+ try:
306
+ resp = await self._session.get(url, headers=headers, timeout=timeout)
307
+ except (aiohttp.ClientError, TimeoutError) as err:
308
+ raise CellarionApiError(f"Push stream retry failed: {err}") from err
309
+ if resp.status == 401:
310
+ resp.close()
311
+ raise CellarionAuthError("Push stream rejected credentials")
312
+
313
+ if resp.status == 403:
314
+ # API token without the 'read' scope — a configuration error the
315
+ # user must fix; retrying or falling back silently would hide it
316
+ resp.close()
317
+ raise CellarionPushForbidden("Credential lacks the 'read' scope for the event stream")
318
+ if resp.status in (404, 405, 501):
319
+ resp.close()
320
+ raise CellarionPushNotSupported("Server has no /api/events/stream")
321
+ if resp.status != 200:
322
+ status = resp.status
323
+ resp.close()
324
+ raise CellarionApiError(f"Push stream returned status {status}")
325
+ if not resp.content_type.startswith("text/event-stream"):
326
+ # A proxy or SPA fallback answered instead of the stream route
327
+ resp.close()
328
+ raise CellarionPushNotSupported(f"Expected text/event-stream, got {resp.content_type}")
329
+
330
+ try:
331
+ yield "_connected"
332
+ event_name: str | None = None
333
+ async for raw in resp.content:
334
+ line = raw.decode("utf-8", "ignore").rstrip("\r\n")
335
+ if not line:
336
+ if event_name:
337
+ yield event_name
338
+ event_name = None
339
+ continue
340
+ if line.startswith(":"): # heartbeat comment
341
+ continue
342
+ if line.startswith("event:"):
343
+ event_name = line[6:].strip()
344
+ elif line.startswith("data:") and event_name is None:
345
+ event_name = "message"
346
+ except (aiohttp.ClientError, TimeoutError) as err:
347
+ raise CellarionApiError(f"Push stream read failed: {err}") from err
348
+ except ValueError as err:
349
+ # aiohttp raises ValueError for a line over its 64 KiB limit —
350
+ # a proxy error page mid-stream, not a transport error
351
+ raise CellarionApiError(f"Push stream sent malformed data: {err}") from err
352
+ finally:
353
+ resp.close()
354
+
355
+ async def revoke_own_token(self) -> bool:
356
+ """Ask the server to revoke the API token this client authenticates with.
357
+
358
+ Best effort, never raises: this runs while the integration is being
359
+ deleted, and nothing the user can do at that point would change the
360
+ outcome. Returns True when the token is gone — a 200 (revoked now) or
361
+ a 401 (already revoked or invalid). Anything else means it may still
362
+ be valid: a 403 from a server older than Cellarion 1.220 that has no
363
+ self-revoke route, or an outage. Password-based clients have no token
364
+ of their own and return False.
365
+ """
366
+ if not self._api_token:
367
+ return False
368
+ try:
369
+ resp = await self._send("DELETE", "/api/tokens/self", None)
370
+ except CellarionApiError as err:
371
+ _LOGGER.debug("Token self-revocation failed: %s", err)
372
+ return False
373
+ try:
374
+ if resp.status in (200, 401):
375
+ return True
376
+ _LOGGER.debug("Token self-revocation answered %s", resp.status)
377
+ return False
378
+ finally:
379
+ resp.close()
380
+
381
+ async def get_health(self) -> JsonDict:
382
+ """Fetch service health (no auth required, but we use it anyway)."""
383
+ try:
384
+ resp = await self._session.get(
385
+ f"{self._url}/api/health",
386
+ timeout=aiohttp.ClientTimeout(total=10),
387
+ )
388
+ return await _read_json(resp, "Health")
389
+ except (aiohttp.ClientError, TimeoutError, CellarionApiError):
390
+ return {"status": "unreachable"}
@@ -0,0 +1,27 @@
1
+ """Exceptions raised by the Cellarion client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CellarionApiError(Exception):
7
+ """Base exception for Cellarion API errors."""
8
+
9
+
10
+ class CellarionAuthError(CellarionApiError):
11
+ """Authentication failed."""
12
+
13
+
14
+ class CellarionPushNotSupported(CellarionApiError):
15
+ """The server does not offer the push event stream."""
16
+
17
+
18
+ class CellarionPushForbidden(CellarionApiError):
19
+ """The credential lacks the scope required for the push stream."""
20
+
21
+
22
+ class CellarionScopeError(CellarionApiError):
23
+ """The API token is valid but lacks a required scope."""
24
+
25
+
26
+ class CellarionTokensNotSupported(CellarionApiError):
27
+ """The server does not support personal API tokens."""
pycellarion/py.typed ADDED
File without changes
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.5
2
+ Name: pycellarion
3
+ Version: 0.1.0
4
+ Summary: Async client for the Cellarion wine-cellar API
5
+ Project-URL: Homepage, https://github.com/jagduvi1/pycellarion
6
+ Project-URL: Issues, https://github.com/jagduvi1/pycellarion/issues
7
+ Project-URL: Cellarion, https://cellarion.app
8
+ Author: Johan Eklund
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: asyncio,cellar,cellarion,home-assistant,wine
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Home Automation
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: aiohttp>=3.9
23
+ Provides-Extra: test
24
+ Requires-Dist: mypy>=1.11; extra == 'test'
25
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
26
+ Requires-Dist: pytest-cov>=5; extra == 'test'
27
+ Requires-Dist: pytest>=8; extra == 'test'
28
+ Requires-Dist: ruff>=0.6; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # pycellarion
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/pycellarion)](https://pypi.org/project/pycellarion/)
34
+ [![CI](https://github.com/jagduvi1/pycellarion/actions/workflows/ci.yml/badge.svg)](https://github.com/jagduvi1/pycellarion/actions/workflows/ci.yml)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+
37
+ Async Python client for the [Cellarion](https://cellarion.app) wine-cellar
38
+ API. It is the library behind the
39
+ [Home Assistant integration](https://github.com/jagduvi1/ha-cellarion), and
40
+ usable on its own by anything that wants to read a cellar or mark a bottle
41
+ as consumed.
42
+
43
+ - **Async, typed, small.** One dependency (`aiohttp`), `py.typed`, no I/O
44
+ outside the calls you make.
45
+ - **Two credentials.** A personal API token (`cel_…`, created in Cellarion
46
+ under *Settings → API tokens*), or email and password used once to mint
47
+ such a token. Passwords are never kept beyond the login they were given for.
48
+ - **Push events.** Parses the server's `text/event-stream` and yields event
49
+ names, so callers can refresh on change instead of polling.
50
+ - **Honest errors.** Status codes map to a small exception hierarchy:
51
+ `CellarionAuthError` (re-authenticate), `CellarionScopeError` (the token
52
+ lacks a scope), `CellarionTokensNotSupported` and
53
+ `CellarionPushNotSupported` (older servers), `CellarionPushForbidden`,
54
+ and `CellarionApiError` for everything else.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install pycellarion
60
+ ```
61
+
62
+ ## Use
63
+
64
+ ```python
65
+ import aiohttp
66
+ from pycellarion import CellarionClient
67
+
68
+ async with aiohttp.ClientSession() as session:
69
+ client = CellarionClient(session, "https://cellarion.app", token="cel_…")
70
+
71
+ stats = await client.get_stats_overview()
72
+ print(stats["stats"]["overview"]["totalBottles"])
73
+
74
+ async for event in client.events_stream():
75
+ if event == "_connected":
76
+ continue
77
+ print("something changed:", event)
78
+ ```
79
+
80
+ Minting a token from email and password:
81
+
82
+ ```python
83
+ client = CellarionClient(session, "https://cellarion.app", "me@example.com", "secret")
84
+ await client.authenticate()
85
+ token = await client.async_create_api_token("My script", ["read", "consume"])
86
+ # keep `token`; the password is not needed again
87
+ ```
88
+
89
+ ## API
90
+
91
+ | Method | Purpose |
92
+ |---|---|
93
+ | `authenticate()` | Log in with email and password (session JWT); raises `CellarionAuthError` on bad credentials |
94
+ | `async_create_api_token(name, scopes)` | Mint a personal API token (password login required) |
95
+ | `get_stats_overview()` / `get_cellars()` / `get_notifications()` / `get_peak_bottles(limit)` / `get_health()` | Reads |
96
+ | `get_account_id()` | Stable account id from `/api/auth/whoami`, or `None` on servers without it |
97
+ | `consume_bottle(bottle_id, reason, rating, note)` | Mark a bottle as consumed |
98
+ | `events_stream()` | Async iterator of push event names; yields `"_connected"` first |
99
+ | `revoke_own_token()` | Ask the server to revoke the client's own API token (Cellarion 1.220+) |
100
+
101
+ Responses are returned as plain `dict`s (`JsonDict`); bodies over
102
+ `MAX_BODY_BYTES` (4 MiB) are refused.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ pip install -e ".[test]"
108
+ ruff check . && ruff format --check . && mypy src
109
+ pytest --cov
110
+ ```
111
+
112
+ Releases are published to PyPI from GitHub releases through
113
+ [trusted publishing](https://docs.pypi.org/trusted-publishers/).
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,8 @@
1
+ pycellarion/__init__.py,sha256=Gqrv3JqzDaSg69E55T0y7Iyh6MViv_fxrY84mfeTQNg,619
2
+ pycellarion/client.py,sha256=6-2Ly9mvUqmiWpuTVL3On93y4E25sn7toKtrNiziZVU,16551
3
+ pycellarion/exceptions.py,sha256=eYsg4JkFo_r8tZMVpj_xmKVRdn9NLLZgJ3lzZ3nZTqM,713
4
+ pycellarion/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pycellarion-0.1.0.dist-info/METADATA,sha256=t5q6ukk4Ke_wc-kqLToqJPicYiO1FVHYSlIoXXr4YTM,4444
6
+ pycellarion-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ pycellarion-0.1.0.dist-info/licenses/LICENSE,sha256=7PFYEXLX-TFOo_uYa_FYHwdJkwPl9jOAuud4szTv7ys,1065
8
+ pycellarion-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jagduvi1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.