pycellarion 0.1.0__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.
- pycellarion-0.1.0/.github/dependabot.yml +11 -0
- pycellarion-0.1.0/.github/workflows/ci.yml +58 -0
- pycellarion-0.1.0/.github/workflows/publish.yml +41 -0
- pycellarion-0.1.0/.gitignore +10 -0
- pycellarion-0.1.0/LICENSE +21 -0
- pycellarion-0.1.0/PKG-INFO +117 -0
- pycellarion-0.1.0/README.md +87 -0
- pycellarion-0.1.0/pyproject.toml +64 -0
- pycellarion-0.1.0/src/pycellarion/__init__.py +26 -0
- pycellarion-0.1.0/src/pycellarion/client.py +390 -0
- pycellarion-0.1.0/src/pycellarion/exceptions.py +27 -0
- pycellarion-0.1.0/src/pycellarion/py.typed +0 -0
- pycellarion-0.1.0/tests/__init__.py +0 -0
- pycellarion-0.1.0/tests/test_client.py +441 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
lint:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
timeout-minutes: 10
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
22
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.13"
|
|
25
|
+
cache: pip
|
|
26
|
+
- run: pip install -e ".[test]"
|
|
27
|
+
- run: ruff check .
|
|
28
|
+
- run: ruff format --check .
|
|
29
|
+
- run: mypy src
|
|
30
|
+
|
|
31
|
+
tests:
|
|
32
|
+
name: tests (py${{ matrix.python }})
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
timeout-minutes: 10
|
|
35
|
+
strategy:
|
|
36
|
+
fail-fast: false
|
|
37
|
+
matrix:
|
|
38
|
+
python: ["3.12", "3.13"]
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
41
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
|
42
|
+
with:
|
|
43
|
+
python-version: ${{ matrix.python }}
|
|
44
|
+
cache: pip
|
|
45
|
+
- run: pip install -e ".[test]"
|
|
46
|
+
- run: pytest -q --cov --cov-report=term-missing
|
|
47
|
+
|
|
48
|
+
build:
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
timeout-minutes: 10
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
53
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
|
54
|
+
with:
|
|
55
|
+
python-version: "3.13"
|
|
56
|
+
- run: pip install build twine
|
|
57
|
+
- run: python -m build
|
|
58
|
+
- run: twine check dist/*
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Runs when a GitHub release is published. Uses PyPI trusted publishing
|
|
4
|
+
# (OpenID Connect): no API token is stored anywhere. The PyPI project must
|
|
5
|
+
# list this repository + workflow as a trusted publisher first.
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
timeout-minutes: 10
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
19
|
+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.13"
|
|
22
|
+
- run: pip install build
|
|
23
|
+
- run: python -m build
|
|
24
|
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
|
25
|
+
with:
|
|
26
|
+
name: dist
|
|
27
|
+
path: dist/
|
|
28
|
+
|
|
29
|
+
publish:
|
|
30
|
+
needs: build
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
timeout-minutes: 10
|
|
33
|
+
environment: pypi
|
|
34
|
+
permissions:
|
|
35
|
+
id-token: write
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
|
38
|
+
with:
|
|
39
|
+
name: dist
|
|
40
|
+
path: dist/
|
|
41
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1, 2026-09
|
|
@@ -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.
|
|
@@ -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
|
+
[](https://pypi.org/project/pycellarion/)
|
|
34
|
+
[](https://github.com/jagduvi1/pycellarion/actions/workflows/ci.yml)
|
|
35
|
+
[](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,87 @@
|
|
|
1
|
+
# pycellarion
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pycellarion/)
|
|
4
|
+
[](https://github.com/jagduvi1/pycellarion/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Async Python client for the [Cellarion](https://cellarion.app) wine-cellar
|
|
8
|
+
API. It is the library behind the
|
|
9
|
+
[Home Assistant integration](https://github.com/jagduvi1/ha-cellarion), and
|
|
10
|
+
usable on its own by anything that wants to read a cellar or mark a bottle
|
|
11
|
+
as consumed.
|
|
12
|
+
|
|
13
|
+
- **Async, typed, small.** One dependency (`aiohttp`), `py.typed`, no I/O
|
|
14
|
+
outside the calls you make.
|
|
15
|
+
- **Two credentials.** A personal API token (`cel_…`, created in Cellarion
|
|
16
|
+
under *Settings → API tokens*), or email and password used once to mint
|
|
17
|
+
such a token. Passwords are never kept beyond the login they were given for.
|
|
18
|
+
- **Push events.** Parses the server's `text/event-stream` and yields event
|
|
19
|
+
names, so callers can refresh on change instead of polling.
|
|
20
|
+
- **Honest errors.** Status codes map to a small exception hierarchy:
|
|
21
|
+
`CellarionAuthError` (re-authenticate), `CellarionScopeError` (the token
|
|
22
|
+
lacks a scope), `CellarionTokensNotSupported` and
|
|
23
|
+
`CellarionPushNotSupported` (older servers), `CellarionPushForbidden`,
|
|
24
|
+
and `CellarionApiError` for everything else.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install pycellarion
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import aiohttp
|
|
36
|
+
from pycellarion import CellarionClient
|
|
37
|
+
|
|
38
|
+
async with aiohttp.ClientSession() as session:
|
|
39
|
+
client = CellarionClient(session, "https://cellarion.app", token="cel_…")
|
|
40
|
+
|
|
41
|
+
stats = await client.get_stats_overview()
|
|
42
|
+
print(stats["stats"]["overview"]["totalBottles"])
|
|
43
|
+
|
|
44
|
+
async for event in client.events_stream():
|
|
45
|
+
if event == "_connected":
|
|
46
|
+
continue
|
|
47
|
+
print("something changed:", event)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Minting a token from email and password:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
client = CellarionClient(session, "https://cellarion.app", "me@example.com", "secret")
|
|
54
|
+
await client.authenticate()
|
|
55
|
+
token = await client.async_create_api_token("My script", ["read", "consume"])
|
|
56
|
+
# keep `token`; the password is not needed again
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
| Method | Purpose |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `authenticate()` | Log in with email and password (session JWT); raises `CellarionAuthError` on bad credentials |
|
|
64
|
+
| `async_create_api_token(name, scopes)` | Mint a personal API token (password login required) |
|
|
65
|
+
| `get_stats_overview()` / `get_cellars()` / `get_notifications()` / `get_peak_bottles(limit)` / `get_health()` | Reads |
|
|
66
|
+
| `get_account_id()` | Stable account id from `/api/auth/whoami`, or `None` on servers without it |
|
|
67
|
+
| `consume_bottle(bottle_id, reason, rating, note)` | Mark a bottle as consumed |
|
|
68
|
+
| `events_stream()` | Async iterator of push event names; yields `"_connected"` first |
|
|
69
|
+
| `revoke_own_token()` | Ask the server to revoke the client's own API token (Cellarion 1.220+) |
|
|
70
|
+
|
|
71
|
+
Responses are returned as plain `dict`s (`JsonDict`); bodies over
|
|
72
|
+
`MAX_BODY_BYTES` (4 MiB) are refused.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install -e ".[test]"
|
|
78
|
+
ruff check . && ruff format --check . && mypy src
|
|
79
|
+
pytest --cov
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Releases are published to PyPI from GitHub releases through
|
|
83
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pycellarion"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async client for the Cellarion wine-cellar API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
authors = [{ name = "Johan Eklund" }]
|
|
14
|
+
keywords = ["cellarion", "wine", "cellar", "home-assistant", "asyncio"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Home Automation",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["aiohttp>=3.9"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/jagduvi1/pycellarion"
|
|
30
|
+
Issues = "https://github.com/jagduvi1/pycellarion/issues"
|
|
31
|
+
Cellarion = "https://cellarion.app"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
test = ["pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6"]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/pycellarion"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
asyncio_mode = "auto"
|
|
42
|
+
|
|
43
|
+
[tool.coverage.run]
|
|
44
|
+
source = ["pycellarion"]
|
|
45
|
+
|
|
46
|
+
[tool.coverage.report]
|
|
47
|
+
fail_under = 95
|
|
48
|
+
show_missing = true
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
target-version = "py312"
|
|
52
|
+
line-length = 100
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "ASYNC", "D"]
|
|
56
|
+
ignore = ["E501", "D203", "D213", "D105", "D107"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.per-file-ignores]
|
|
59
|
+
"tests/*" = ["D"]
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
python_version = "3.12"
|
|
63
|
+
strict = true
|
|
64
|
+
warn_unreachable = true
|
|
@@ -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
|
+
]
|
|
@@ -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."""
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Tests for the client: stream parser, retries, error mapping, self-revocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from pycellarion import (
|
|
12
|
+
CellarionApiError,
|
|
13
|
+
CellarionAuthError,
|
|
14
|
+
CellarionClient,
|
|
15
|
+
CellarionPushForbidden,
|
|
16
|
+
CellarionPushNotSupported,
|
|
17
|
+
CellarionScopeError,
|
|
18
|
+
CellarionTokensNotSupported,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
BASE_URL = "http://cellarion.local"
|
|
22
|
+
TEST_TOKEN = "cel_" + "a" * 64
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FakeResponse:
|
|
26
|
+
"""Just enough of aiohttp.ClientResponse for the client."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
status: int = 200,
|
|
31
|
+
json: Any = None,
|
|
32
|
+
*,
|
|
33
|
+
content_type: str = "application/json",
|
|
34
|
+
lines: Iterable[bytes] = (),
|
|
35
|
+
read_error: Exception | None = None,
|
|
36
|
+
content_length: int | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.status = status
|
|
39
|
+
self._json = json
|
|
40
|
+
self.content_type = content_type
|
|
41
|
+
self._lines = list(lines)
|
|
42
|
+
self._read_error = read_error
|
|
43
|
+
self.content_length = content_length
|
|
44
|
+
self.closed = False
|
|
45
|
+
|
|
46
|
+
async def json(self) -> Any:
|
|
47
|
+
if self._json is None:
|
|
48
|
+
raise ValueError("not json")
|
|
49
|
+
return self._json
|
|
50
|
+
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
self.closed = True
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def content(self) -> FakeResponse:
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __aiter__(self) -> FakeResponse:
|
|
59
|
+
self._iter = iter(self._lines)
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
async def __anext__(self) -> bytes:
|
|
63
|
+
try:
|
|
64
|
+
return next(self._iter)
|
|
65
|
+
except StopIteration:
|
|
66
|
+
if self._read_error:
|
|
67
|
+
err, self._read_error = self._read_error, None
|
|
68
|
+
raise err from None
|
|
69
|
+
raise StopAsyncIteration from None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FakeSession:
|
|
73
|
+
"""Hands out scripted responses in order and records every call."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, responses: Iterable[FakeResponse | Exception]) -> None:
|
|
76
|
+
self._responses = list(responses)
|
|
77
|
+
self.calls: list[tuple[str, str, dict[str, Any]]] = []
|
|
78
|
+
|
|
79
|
+
def _next(self, method: str, url: str, kwargs: dict[str, Any]) -> FakeResponse:
|
|
80
|
+
self.calls.append((method, url, kwargs))
|
|
81
|
+
item = self._responses.pop(0)
|
|
82
|
+
if isinstance(item, Exception):
|
|
83
|
+
raise item
|
|
84
|
+
return item
|
|
85
|
+
|
|
86
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse:
|
|
87
|
+
return self._next(method, url, kwargs)
|
|
88
|
+
|
|
89
|
+
async def get(self, url: str, **kwargs: Any) -> FakeResponse:
|
|
90
|
+
return self._next("GET", url, kwargs)
|
|
91
|
+
|
|
92
|
+
async def post(self, url: str, **kwargs: Any) -> FakeResponse:
|
|
93
|
+
return self._next("POST", url, kwargs)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def token_client(*responses: FakeResponse | Exception) -> tuple[CellarionClient, FakeSession]:
|
|
97
|
+
session = FakeSession(responses)
|
|
98
|
+
return CellarionClient(session, BASE_URL, token=TEST_TOKEN), session # type: ignore[arg-type]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def password_client(
|
|
102
|
+
*responses: FakeResponse | Exception,
|
|
103
|
+
) -> tuple[CellarionClient, FakeSession]:
|
|
104
|
+
session = FakeSession(responses)
|
|
105
|
+
return CellarionClient(session, BASE_URL, "user@example.com", "pw"), session # type: ignore[arg-type]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
SSE_BODY = [
|
|
109
|
+
b": heartbeat\n",
|
|
110
|
+
b"\n",
|
|
111
|
+
b"event: stats_changed\n",
|
|
112
|
+
b"data: {}\n",
|
|
113
|
+
b"\n",
|
|
114
|
+
b"data: bare-data-event\n",
|
|
115
|
+
b"\n",
|
|
116
|
+
b"event: ignored-without-blank-line\n",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def collect(client: CellarionClient) -> list[str]:
|
|
121
|
+
return [event async for event in client.events_stream()]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── SSE stream ───────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def test_stream_parses_events_and_heartbeats() -> None:
|
|
128
|
+
"""Named events, bare data lines and heartbeats map to the right yields."""
|
|
129
|
+
client, session = token_client(
|
|
130
|
+
FakeResponse(200, content_type="text/event-stream", lines=SSE_BODY)
|
|
131
|
+
)
|
|
132
|
+
assert await collect(client) == ["_connected", "stats_changed", "message"]
|
|
133
|
+
assert session.calls[0][2]["headers"]["Accept"] == "text/event-stream"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def test_stream_401_relogs_once_for_password_clients() -> None:
|
|
137
|
+
"""A rejected JWT triggers one login and a retry; a second 401 is fatal."""
|
|
138
|
+
client, session = password_client(
|
|
139
|
+
FakeResponse(200, {"token": "jwt-1"}), # initial login
|
|
140
|
+
FakeResponse(401), # stream rejects
|
|
141
|
+
FakeResponse(200, {"token": "jwt-2"}), # re-login
|
|
142
|
+
FakeResponse(200, content_type="text/event-stream", lines=[]),
|
|
143
|
+
)
|
|
144
|
+
assert await collect(client) == ["_connected"]
|
|
145
|
+
assert session.calls[-1][2]["headers"]["Authorization"] == "Bearer jwt-2"
|
|
146
|
+
|
|
147
|
+
client, _ = password_client(
|
|
148
|
+
FakeResponse(200, {"token": "jwt-1"}),
|
|
149
|
+
FakeResponse(401),
|
|
150
|
+
FakeResponse(200, {"token": "jwt-2"}),
|
|
151
|
+
FakeResponse(401),
|
|
152
|
+
)
|
|
153
|
+
with pytest.raises(CellarionAuthError):
|
|
154
|
+
await collect(client)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@pytest.mark.parametrize(
|
|
158
|
+
("response", "expected"),
|
|
159
|
+
[
|
|
160
|
+
(FakeResponse(403), CellarionPushForbidden),
|
|
161
|
+
(FakeResponse(404), CellarionPushNotSupported),
|
|
162
|
+
(FakeResponse(501), CellarionPushNotSupported),
|
|
163
|
+
(FakeResponse(200, content_type="text/html"), CellarionPushNotSupported),
|
|
164
|
+
(FakeResponse(500), CellarionApiError),
|
|
165
|
+
(aiohttp.ClientError("down"), CellarionApiError),
|
|
166
|
+
],
|
|
167
|
+
)
|
|
168
|
+
async def test_stream_status_mapping(response: Any, expected: type[Exception]) -> None:
|
|
169
|
+
"""Every non-stream answer maps to the exception the listener expects."""
|
|
170
|
+
client, _ = token_client(response)
|
|
171
|
+
with pytest.raises(expected):
|
|
172
|
+
await collect(client)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def test_stream_read_errors_become_api_errors() -> None:
|
|
176
|
+
"""Transport errors and over-long lines both surface as CellarionApiError."""
|
|
177
|
+
for err in (aiohttp.ClientPayloadError("cut"), ValueError("Line is too long")):
|
|
178
|
+
client, _ = token_client(
|
|
179
|
+
FakeResponse(
|
|
180
|
+
200,
|
|
181
|
+
content_type="text/event-stream",
|
|
182
|
+
lines=[b"event: a\n", b"\n"],
|
|
183
|
+
read_error=err,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
got: list[str] = []
|
|
187
|
+
with pytest.raises(CellarionApiError):
|
|
188
|
+
async for event in client.events_stream():
|
|
189
|
+
got.append(event)
|
|
190
|
+
assert got == ["_connected", "a"]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── Authenticated requests ───────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def test_request_relogs_once_on_401_for_password_clients() -> None:
|
|
197
|
+
"""An expired JWT is refreshed transparently, exactly once."""
|
|
198
|
+
client, session = password_client(
|
|
199
|
+
FakeResponse(200, {"token": "jwt-1"}),
|
|
200
|
+
FakeResponse(401),
|
|
201
|
+
FakeResponse(200, {"token": "jwt-2"}),
|
|
202
|
+
FakeResponse(200, {"stats": {}}),
|
|
203
|
+
)
|
|
204
|
+
assert await client.get_stats_overview() == {"stats": {}}
|
|
205
|
+
assert [c[0] for c in session.calls] == ["POST", "GET", "POST", "GET"]
|
|
206
|
+
assert session.calls[-1][2]["headers"]["Authorization"] == "Bearer jwt-2"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def test_request_error_body_detail_is_included() -> None:
|
|
210
|
+
client, _ = token_client(FakeResponse(500, {"error": "boom"}))
|
|
211
|
+
with pytest.raises(CellarionApiError, match="status 500: boom"):
|
|
212
|
+
await client.get_cellars()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@pytest.mark.parametrize(
|
|
216
|
+
"response",
|
|
217
|
+
[
|
|
218
|
+
FakeResponse(204),
|
|
219
|
+
FakeResponse(200, content_length=0),
|
|
220
|
+
FakeResponse(200, content_type="text/plain"), # non-JSON success body
|
|
221
|
+
FakeResponse(200, ["not", "a", "dict"]),
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
async def test_request_empty_or_odd_success_bodies_give_empty_dict(response: FakeResponse) -> None:
|
|
225
|
+
client, _ = token_client(response)
|
|
226
|
+
assert await client.consume_bottle("6a50805b785f507654afdc51") == {}
|
|
227
|
+
assert response.closed
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def test_scope_error_only_for_api_tokens() -> None:
|
|
231
|
+
"""403 is a scope error with a token, a plain API error with a password."""
|
|
232
|
+
client, _ = token_client(FakeResponse(403, {"error": "scope"}))
|
|
233
|
+
with pytest.raises(CellarionScopeError):
|
|
234
|
+
await client.get_cellars()
|
|
235
|
+
|
|
236
|
+
client, _ = password_client(
|
|
237
|
+
FakeResponse(200, {"token": "jwt"}), FakeResponse(403, {"error": "no"})
|
|
238
|
+
)
|
|
239
|
+
with pytest.raises(CellarionApiError) as excinfo:
|
|
240
|
+
await client.get_cellars()
|
|
241
|
+
assert not isinstance(excinfo.value, CellarionScopeError)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.mark.parametrize(
|
|
245
|
+
("body", "expected"),
|
|
246
|
+
[
|
|
247
|
+
({"id": "A"}, "A"),
|
|
248
|
+
({"user": {"id": "B"}}, "B"),
|
|
249
|
+
({"user": {"_id": "C"}}, "C"),
|
|
250
|
+
({"user": "not-a-dict"}, None),
|
|
251
|
+
({}, None),
|
|
252
|
+
],
|
|
253
|
+
)
|
|
254
|
+
async def test_get_account_id_shapes(body: dict[str, Any], expected: str | None) -> None:
|
|
255
|
+
client, _ = token_client(FakeResponse(200, body))
|
|
256
|
+
assert await client.get_account_id() == expected
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── Login and token minting ──────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.mark.parametrize(
|
|
263
|
+
("status", "expected"),
|
|
264
|
+
[
|
|
265
|
+
(400, CellarionAuthError),
|
|
266
|
+
(403, CellarionAuthError),
|
|
267
|
+
(429, CellarionApiError),
|
|
268
|
+
(502, CellarionApiError),
|
|
269
|
+
],
|
|
270
|
+
)
|
|
271
|
+
async def test_login_status_mapping(status: int, expected: type[Exception]) -> None:
|
|
272
|
+
client, _ = password_client(FakeResponse(status, {"error": "x"}))
|
|
273
|
+
with pytest.raises(expected):
|
|
274
|
+
await client.authenticate()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def test_login_without_token_in_body() -> None:
|
|
278
|
+
client, _ = password_client(FakeResponse(200, {"user": {}}))
|
|
279
|
+
with pytest.raises(CellarionApiError, match="No token"):
|
|
280
|
+
await client.authenticate()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def test_login_connection_error() -> None:
|
|
284
|
+
client, _ = password_client(aiohttp.ClientError("refused"))
|
|
285
|
+
with pytest.raises(CellarionApiError, match="Connection failed"):
|
|
286
|
+
await client.authenticate()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@pytest.mark.parametrize(
|
|
290
|
+
("response", "expected"),
|
|
291
|
+
[
|
|
292
|
+
(FakeResponse(401), CellarionAuthError),
|
|
293
|
+
(FakeResponse(403), CellarionAuthError),
|
|
294
|
+
(FakeResponse(405), CellarionTokensNotSupported),
|
|
295
|
+
(FakeResponse(429), CellarionApiError),
|
|
296
|
+
(FakeResponse(500), CellarionApiError),
|
|
297
|
+
(FakeResponse(307), CellarionApiError),
|
|
298
|
+
(FakeResponse(201, {"id": "t1"}), CellarionApiError), # no token in body
|
|
299
|
+
(FakeResponse(201, content_type="text/html"), CellarionApiError),
|
|
300
|
+
(aiohttp.ClientError("down"), CellarionApiError),
|
|
301
|
+
],
|
|
302
|
+
)
|
|
303
|
+
async def test_create_token_error_mapping(response: Any, expected: type[Exception]) -> None:
|
|
304
|
+
client, session = password_client(FakeResponse(200, {"token": "jwt"}), response)
|
|
305
|
+
with pytest.raises(expected):
|
|
306
|
+
await client.async_create_api_token("HA", ["read"])
|
|
307
|
+
if session.calls[1:]:
|
|
308
|
+
# The credential-bearing POST never follows a redirect
|
|
309
|
+
assert session.calls[1][2]["allow_redirects"] is False
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def test_create_token_needs_a_password() -> None:
|
|
313
|
+
client, _ = token_client()
|
|
314
|
+
with pytest.raises(CellarionApiError, match="Password login required"):
|
|
315
|
+
await client.async_create_api_token("HA", ["read"])
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def test_create_token_success_returns_string() -> None:
|
|
319
|
+
client, session = password_client(
|
|
320
|
+
FakeResponse(200, {"token": "jwt"}), FakeResponse(201, {"token": "cel_new"})
|
|
321
|
+
)
|
|
322
|
+
assert await client.async_create_api_token("HA", ["read", "consume"]) == "cel_new"
|
|
323
|
+
assert session.calls[1][2]["json"]["scopes"] == ["read", "consume"]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def test_health_non_json_is_unreachable() -> None:
|
|
327
|
+
client, _ = token_client(FakeResponse(200, content_type="text/html"))
|
|
328
|
+
assert await client.get_health() == {"status": "unreachable"}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ── Self-revocation ──────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@pytest.mark.parametrize("status", [200, 401])
|
|
335
|
+
async def test_revoke_own_token_done_on_200_or_401(status: int) -> None:
|
|
336
|
+
"""200 = revoked now, 401 = already revoked; both mean the token is gone."""
|
|
337
|
+
client, session = token_client(FakeResponse(status, {"message": "Token revoked"}))
|
|
338
|
+
assert await client.revoke_own_token() is True
|
|
339
|
+
method, url, kwargs = session.calls[0]
|
|
340
|
+
assert (method, url) == ("DELETE", f"{BASE_URL}/api/tokens/self")
|
|
341
|
+
assert kwargs["headers"]["Authorization"] == f"Bearer {TEST_TOKEN}"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@pytest.mark.parametrize(
|
|
345
|
+
"response",
|
|
346
|
+
[
|
|
347
|
+
FakeResponse(403, {"error": "scope"}),
|
|
348
|
+
FakeResponse(404),
|
|
349
|
+
FakeResponse(500),
|
|
350
|
+
aiohttp.ClientError("down"),
|
|
351
|
+
],
|
|
352
|
+
)
|
|
353
|
+
async def test_revoke_own_token_is_best_effort(response: Any) -> None:
|
|
354
|
+
"""A pre-1.220 server (403), odd answers and outages mean 'maybe still valid'."""
|
|
355
|
+
client, _ = token_client(response)
|
|
356
|
+
assert await client.revoke_own_token() is False
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async def test_revoke_own_token_needs_an_api_token() -> None:
|
|
360
|
+
"""A password-based client has no token of its own and sends nothing."""
|
|
361
|
+
client, session = password_client()
|
|
362
|
+
assert await client.revoke_own_token() is False
|
|
363
|
+
assert session.calls == []
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def test_oversized_body_is_refused() -> None:
|
|
367
|
+
"""A response far larger than any real payload is an error, not a read."""
|
|
368
|
+
client, _ = token_client(FakeResponse(200, {"stats": {}}, content_length=50_000_000))
|
|
369
|
+
with pytest.raises(CellarionApiError, match="refusing to read"):
|
|
370
|
+
await client.get_stats_overview()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ── Remaining branches ───────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def test_token_client_401_is_fatal_without_retry() -> None:
|
|
377
|
+
"""A static API token cannot be refreshed: one 401 is a revoked token."""
|
|
378
|
+
client, session = token_client(FakeResponse(401))
|
|
379
|
+
with pytest.raises(CellarionAuthError, match="revoked or invalid"):
|
|
380
|
+
await client.get_cellars()
|
|
381
|
+
assert len(session.calls) == 1
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
async def test_login_redirect_is_refused() -> None:
|
|
385
|
+
client, session = password_client(FakeResponse(302))
|
|
386
|
+
with pytest.raises(CellarionApiError, match="redirected"):
|
|
387
|
+
await client.authenticate()
|
|
388
|
+
assert session.calls[0][2]["allow_redirects"] is False
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
async def test_password_client_second_401_is_fatal() -> None:
|
|
392
|
+
client, _ = password_client(
|
|
393
|
+
FakeResponse(200, {"token": "jwt-1"}),
|
|
394
|
+
FakeResponse(401),
|
|
395
|
+
FakeResponse(200, {"token": "jwt-2"}),
|
|
396
|
+
FakeResponse(401),
|
|
397
|
+
)
|
|
398
|
+
with pytest.raises(CellarionAuthError, match="after retry"):
|
|
399
|
+
await client.get_cellars()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def test_error_without_json_body_still_reports_status() -> None:
|
|
403
|
+
client, _ = token_client(FakeResponse(502, content_type="text/html"))
|
|
404
|
+
with pytest.raises(CellarionApiError, match=r"status 502$"):
|
|
405
|
+
await client.get_cellars()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
async def test_get_account_id_on_api_error_is_none() -> None:
|
|
409
|
+
client, _ = token_client(FakeResponse(500, {"error": "x"}))
|
|
410
|
+
assert await client.get_account_id() is None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def test_read_helpers_hit_their_paths() -> None:
|
|
414
|
+
client, session = token_client(
|
|
415
|
+
FakeResponse(200, {"notifications": [], "unreadCount": 0}),
|
|
416
|
+
FakeResponse(200, {"bottles": {"items": []}}),
|
|
417
|
+
)
|
|
418
|
+
await client.get_notifications()
|
|
419
|
+
await client.get_peak_bottles(limit=3)
|
|
420
|
+
assert session.calls[0][1].endswith("/api/notifications")
|
|
421
|
+
assert session.calls[1][1].endswith("/api/bottles?maturity=peak&limit=3")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
async def test_consume_body_carries_rating_and_note() -> None:
|
|
425
|
+
client, session = token_client(FakeResponse(200, {"ok": True}))
|
|
426
|
+
await client.consume_bottle(
|
|
427
|
+
"6a50805b785f507654afdc51", reason="gifted", rating=92, note="lovely"
|
|
428
|
+
)
|
|
429
|
+
assert session.calls[0][2]["json"] == {"reason": "gifted", "rating": 92, "note": "lovely"}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
async def test_stream_retry_connection_error() -> None:
|
|
433
|
+
"""A transport error on the post-login retry is a normal API error."""
|
|
434
|
+
client, _ = password_client(
|
|
435
|
+
FakeResponse(200, {"token": "jwt-1"}),
|
|
436
|
+
FakeResponse(401),
|
|
437
|
+
FakeResponse(200, {"token": "jwt-2"}),
|
|
438
|
+
aiohttp.ClientError("gone"),
|
|
439
|
+
)
|
|
440
|
+
with pytest.raises(CellarionApiError, match="retry failed"):
|
|
441
|
+
await collect(client)
|