aioghost 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.
- aioghost-0.1.0/.github/workflows/publish.yml +44 -0
- aioghost-0.1.0/.github/workflows/tests.yml +39 -0
- aioghost-0.1.0/.gitignore +31 -0
- aioghost-0.1.0/LICENSE +21 -0
- aioghost-0.1.0/PKG-INFO +136 -0
- aioghost-0.1.0/README.md +103 -0
- aioghost-0.1.0/pyproject.toml +66 -0
- aioghost-0.1.0/src/aioghost/__init__.py +20 -0
- aioghost-0.1.0/src/aioghost/client.py +434 -0
- aioghost-0.1.0/src/aioghost/exceptions.py +21 -0
- aioghost-0.1.0/tests/__init__.py +1 -0
- aioghost-0.1.0/tests/test_client.py +106 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Set up Python
|
|
15
|
+
uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: '3.12'
|
|
18
|
+
|
|
19
|
+
- name: Install build dependencies
|
|
20
|
+
run: pip install build
|
|
21
|
+
|
|
22
|
+
- name: Build package
|
|
23
|
+
run: python -m build
|
|
24
|
+
|
|
25
|
+
- name: Upload artifact
|
|
26
|
+
uses: actions/upload-artifact@v4
|
|
27
|
+
with:
|
|
28
|
+
name: dist
|
|
29
|
+
path: dist/
|
|
30
|
+
|
|
31
|
+
publish:
|
|
32
|
+
needs: build
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
permissions:
|
|
35
|
+
id-token: write
|
|
36
|
+
steps:
|
|
37
|
+
- name: Download artifact
|
|
38
|
+
uses: actions/download-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: dist
|
|
41
|
+
path: dist/
|
|
42
|
+
|
|
43
|
+
- name: Publish to PyPI
|
|
44
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
pip install -e ".[dev]"
|
|
28
|
+
|
|
29
|
+
- name: Lint with ruff
|
|
30
|
+
run: |
|
|
31
|
+
ruff check src/ tests/
|
|
32
|
+
|
|
33
|
+
- name: Type check with mypy
|
|
34
|
+
run: |
|
|
35
|
+
mypy src/
|
|
36
|
+
|
|
37
|
+
- name: Run tests
|
|
38
|
+
run: |
|
|
39
|
+
pytest tests/ -v --tb=short
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
venv/
|
|
14
|
+
.venv/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Testing
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.coverage
|
|
25
|
+
htmlcov/
|
|
26
|
+
|
|
27
|
+
# mypy
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
|
|
30
|
+
# ruff
|
|
31
|
+
.ruff_cache/
|
aioghost-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vitrixbot
|
|
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.
|
aioghost-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aioghost
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the Ghost Admin API
|
|
5
|
+
Project-URL: Homepage, https://github.com/vitrixbot/aioghost
|
|
6
|
+
Project-URL: Documentation, https://github.com/vitrixbot/aioghost#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/vitrixbot/aioghost
|
|
8
|
+
Project-URL: Issues, https://github.com/vitrixbot/aioghost/issues
|
|
9
|
+
Author-email: vitrixbot <vitrixclawd@icloud.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: aiohttp,api,async,asyncio,cms,ghost
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
24
|
+
Requires-Dist: pyjwt>=2.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: aioresponses>=0.7.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-aiohttp>=1.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# aioghost
|
|
35
|
+
|
|
36
|
+
Async Python client for the [Ghost Admin API](https://ghost.org/docs/admin-api/).
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install aioghost
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from aioghost import GhostAdminAPI
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
async with GhostAdminAPI(
|
|
52
|
+
site_url="https://your-site.ghost.io",
|
|
53
|
+
admin_api_key="your-admin-api-key"
|
|
54
|
+
) as api:
|
|
55
|
+
# Get site info
|
|
56
|
+
site = await api.get_site()
|
|
57
|
+
print(f"Site: {site['title']}")
|
|
58
|
+
|
|
59
|
+
# Get member counts
|
|
60
|
+
members = await api.get_members_count()
|
|
61
|
+
print(f"Total members: {members['total']}")
|
|
62
|
+
print(f"Paid members: {members['paid']}")
|
|
63
|
+
|
|
64
|
+
# Get MRR
|
|
65
|
+
mrr = await api.get_mrr()
|
|
66
|
+
print(f"MRR: ${mrr.get('usd', 0) / 100:.2f}")
|
|
67
|
+
|
|
68
|
+
asyncio.run(main())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Features
|
|
72
|
+
|
|
73
|
+
- **Fully async** — Built on `aiohttp` for non-blocking I/O
|
|
74
|
+
- **Type hints** — Full type annotations for IDE support
|
|
75
|
+
- **Context manager** — Automatic session cleanup with `async with`
|
|
76
|
+
- **Parallel requests** — Uses `asyncio.gather()` for efficient batching
|
|
77
|
+
- **Proper exceptions** — Typed exceptions for different error cases
|
|
78
|
+
|
|
79
|
+
## API Coverage
|
|
80
|
+
|
|
81
|
+
| Endpoint | Method |
|
|
82
|
+
|----------|--------|
|
|
83
|
+
| Site info | `get_site()` |
|
|
84
|
+
| Posts count | `get_posts_count()` |
|
|
85
|
+
| Latest post | `get_latest_post()` |
|
|
86
|
+
| Members count | `get_members_count()` |
|
|
87
|
+
| MRR | `get_mrr()` |
|
|
88
|
+
| Newsletters | `get_newsletters()` |
|
|
89
|
+
| Latest email | `get_latest_email()` |
|
|
90
|
+
| Comments count | `get_comments_count()` |
|
|
91
|
+
| Tiers | `get_tiers()` |
|
|
92
|
+
| ActivityPub stats | `get_activitypub_stats()` |
|
|
93
|
+
| Webhooks | `create_webhook()`, `delete_webhook()` |
|
|
94
|
+
| Validate credentials | `validate_credentials()` |
|
|
95
|
+
|
|
96
|
+
## Getting Your Admin API Key
|
|
97
|
+
|
|
98
|
+
1. Log in to your Ghost Admin panel
|
|
99
|
+
2. Go to **Settings → Integrations**
|
|
100
|
+
3. Click **Add custom integration**
|
|
101
|
+
4. Copy the **Admin API Key** (format: `id:secret`)
|
|
102
|
+
|
|
103
|
+
## Exceptions
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from aioghost import (
|
|
107
|
+
GhostError, # Base exception
|
|
108
|
+
GhostAuthError, # Invalid API key
|
|
109
|
+
GhostConnectionError, # Network error
|
|
110
|
+
GhostNotFoundError, # 404 response
|
|
111
|
+
GhostValidationError, # Invalid request
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Passing Your Own Session
|
|
116
|
+
|
|
117
|
+
If you want to reuse an existing `aiohttp.ClientSession`:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import aiohttp
|
|
121
|
+
from aioghost import GhostAdminAPI
|
|
122
|
+
|
|
123
|
+
async def main():
|
|
124
|
+
async with aiohttp.ClientSession() as session:
|
|
125
|
+
api = GhostAdminAPI(
|
|
126
|
+
site_url="https://your-site.ghost.io",
|
|
127
|
+
admin_api_key="your-key",
|
|
128
|
+
session=session,
|
|
129
|
+
)
|
|
130
|
+
site = await api.get_site()
|
|
131
|
+
# Session is NOT closed when api goes out of scope
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
aioghost-0.1.0/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# aioghost
|
|
2
|
+
|
|
3
|
+
Async Python client for the [Ghost Admin API](https://ghost.org/docs/admin-api/).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install aioghost
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from aioghost import GhostAdminAPI
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
async with GhostAdminAPI(
|
|
19
|
+
site_url="https://your-site.ghost.io",
|
|
20
|
+
admin_api_key="your-admin-api-key"
|
|
21
|
+
) as api:
|
|
22
|
+
# Get site info
|
|
23
|
+
site = await api.get_site()
|
|
24
|
+
print(f"Site: {site['title']}")
|
|
25
|
+
|
|
26
|
+
# Get member counts
|
|
27
|
+
members = await api.get_members_count()
|
|
28
|
+
print(f"Total members: {members['total']}")
|
|
29
|
+
print(f"Paid members: {members['paid']}")
|
|
30
|
+
|
|
31
|
+
# Get MRR
|
|
32
|
+
mrr = await api.get_mrr()
|
|
33
|
+
print(f"MRR: ${mrr.get('usd', 0) / 100:.2f}")
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Fully async** — Built on `aiohttp` for non-blocking I/O
|
|
41
|
+
- **Type hints** — Full type annotations for IDE support
|
|
42
|
+
- **Context manager** — Automatic session cleanup with `async with`
|
|
43
|
+
- **Parallel requests** — Uses `asyncio.gather()` for efficient batching
|
|
44
|
+
- **Proper exceptions** — Typed exceptions for different error cases
|
|
45
|
+
|
|
46
|
+
## API Coverage
|
|
47
|
+
|
|
48
|
+
| Endpoint | Method |
|
|
49
|
+
|----------|--------|
|
|
50
|
+
| Site info | `get_site()` |
|
|
51
|
+
| Posts count | `get_posts_count()` |
|
|
52
|
+
| Latest post | `get_latest_post()` |
|
|
53
|
+
| Members count | `get_members_count()` |
|
|
54
|
+
| MRR | `get_mrr()` |
|
|
55
|
+
| Newsletters | `get_newsletters()` |
|
|
56
|
+
| Latest email | `get_latest_email()` |
|
|
57
|
+
| Comments count | `get_comments_count()` |
|
|
58
|
+
| Tiers | `get_tiers()` |
|
|
59
|
+
| ActivityPub stats | `get_activitypub_stats()` |
|
|
60
|
+
| Webhooks | `create_webhook()`, `delete_webhook()` |
|
|
61
|
+
| Validate credentials | `validate_credentials()` |
|
|
62
|
+
|
|
63
|
+
## Getting Your Admin API Key
|
|
64
|
+
|
|
65
|
+
1. Log in to your Ghost Admin panel
|
|
66
|
+
2. Go to **Settings → Integrations**
|
|
67
|
+
3. Click **Add custom integration**
|
|
68
|
+
4. Copy the **Admin API Key** (format: `id:secret`)
|
|
69
|
+
|
|
70
|
+
## Exceptions
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from aioghost import (
|
|
74
|
+
GhostError, # Base exception
|
|
75
|
+
GhostAuthError, # Invalid API key
|
|
76
|
+
GhostConnectionError, # Network error
|
|
77
|
+
GhostNotFoundError, # 404 response
|
|
78
|
+
GhostValidationError, # Invalid request
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Passing Your Own Session
|
|
83
|
+
|
|
84
|
+
If you want to reuse an existing `aiohttp.ClientSession`:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import aiohttp
|
|
88
|
+
from aioghost import GhostAdminAPI
|
|
89
|
+
|
|
90
|
+
async def main():
|
|
91
|
+
async with aiohttp.ClientSession() as session:
|
|
92
|
+
api = GhostAdminAPI(
|
|
93
|
+
site_url="https://your-site.ghost.io",
|
|
94
|
+
admin_api_key="your-key",
|
|
95
|
+
session=session,
|
|
96
|
+
)
|
|
97
|
+
site = await api.get_site()
|
|
98
|
+
# Session is NOT closed when api goes out of scope
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aioghost"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python client for the Ghost Admin API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "vitrixbot", email = "vitrixclawd@icloud.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["ghost", "cms", "api", "async", "asyncio", "aiohttp"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Framework :: AsyncIO",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"aiohttp>=3.8.0",
|
|
29
|
+
"PyJWT>=2.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0.0",
|
|
35
|
+
"pytest-asyncio>=0.21.0",
|
|
36
|
+
"pytest-aiohttp>=1.0.0",
|
|
37
|
+
"aioresponses>=0.7.0",
|
|
38
|
+
"ruff>=0.1.0",
|
|
39
|
+
"mypy>=1.0.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/vitrixbot/aioghost"
|
|
44
|
+
Documentation = "https://github.com/vitrixbot/aioghost#readme"
|
|
45
|
+
Repository = "https://github.com/vitrixbot/aioghost"
|
|
46
|
+
Issues = "https://github.com/vitrixbot/aioghost/issues"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/aioghost"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
target-version = "py311"
|
|
53
|
+
line-length = 100
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "UP", "B", "C4", "W"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
python_version = "3.11"
|
|
60
|
+
warn_return_any = true
|
|
61
|
+
warn_unused_ignores = true
|
|
62
|
+
disallow_untyped_defs = true
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
asyncio_mode = "auto"
|
|
66
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Async Python client for the Ghost Admin API."""
|
|
2
|
+
|
|
3
|
+
from .client import GhostAdminAPI
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
GhostAuthError,
|
|
6
|
+
GhostConnectionError,
|
|
7
|
+
GhostError,
|
|
8
|
+
GhostNotFoundError,
|
|
9
|
+
GhostValidationError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"GhostAdminAPI",
|
|
15
|
+
"GhostError",
|
|
16
|
+
"GhostAuthError",
|
|
17
|
+
"GhostConnectionError",
|
|
18
|
+
"GhostNotFoundError",
|
|
19
|
+
"GhostValidationError",
|
|
20
|
+
]
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Ghost Admin API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
import jwt
|
|
12
|
+
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
GhostAuthError,
|
|
15
|
+
GhostConnectionError,
|
|
16
|
+
GhostError,
|
|
17
|
+
GhostNotFoundError,
|
|
18
|
+
GhostValidationError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
JWT_EXPIRY_MINUTES = 5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GhostAdminAPI:
|
|
27
|
+
"""Async client for the Ghost Admin API."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
site_url: str,
|
|
32
|
+
admin_api_key: str,
|
|
33
|
+
session: aiohttp.ClientSession | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Initialize the API client.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
site_url: The Ghost site URL (e.g., https://example.ghost.io)
|
|
39
|
+
admin_api_key: The Admin API key (format: id:secret)
|
|
40
|
+
session: Optional aiohttp session. If not provided, one will be created.
|
|
41
|
+
"""
|
|
42
|
+
self.site_url = site_url.rstrip("/")
|
|
43
|
+
self.admin_api_key = admin_api_key
|
|
44
|
+
self._session = session
|
|
45
|
+
self._owns_session = session is None
|
|
46
|
+
|
|
47
|
+
def _generate_token(self) -> str:
|
|
48
|
+
"""Generate a JWT token for Ghost Admin API authentication."""
|
|
49
|
+
try:
|
|
50
|
+
key_id, secret = self.admin_api_key.split(":")
|
|
51
|
+
except ValueError as err:
|
|
52
|
+
raise GhostAuthError("Invalid API key format. Expected 'id:secret'") from err
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
secret_bytes = bytes.fromhex(secret)
|
|
56
|
+
except ValueError as err:
|
|
57
|
+
raise GhostAuthError("Invalid API key secret. Expected hex string.") from err
|
|
58
|
+
|
|
59
|
+
now = datetime.now(UTC)
|
|
60
|
+
payload = {
|
|
61
|
+
"iat": int(now.timestamp()),
|
|
62
|
+
"exp": int((now + timedelta(minutes=JWT_EXPIRY_MINUTES)).timestamp()),
|
|
63
|
+
"aud": "/admin/",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
headers = {
|
|
67
|
+
"alg": "HS256",
|
|
68
|
+
"kid": key_id,
|
|
69
|
+
"typ": "JWT",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return jwt.encode(payload, secret_bytes, algorithm="HS256", headers=headers)
|
|
73
|
+
|
|
74
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
75
|
+
"""Get or create aiohttp session."""
|
|
76
|
+
if self._session is None or self._session.closed:
|
|
77
|
+
self._session = aiohttp.ClientSession()
|
|
78
|
+
self._owns_session = True
|
|
79
|
+
return self._session
|
|
80
|
+
|
|
81
|
+
async def close(self) -> None:
|
|
82
|
+
"""Close the session if we own it."""
|
|
83
|
+
if self._owns_session and self._session and not self._session.closed:
|
|
84
|
+
await self._session.close()
|
|
85
|
+
|
|
86
|
+
async def __aenter__(self) -> GhostAdminAPI:
|
|
87
|
+
"""Async context manager entry."""
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
91
|
+
"""Async context manager exit."""
|
|
92
|
+
await self.close()
|
|
93
|
+
|
|
94
|
+
def _get_auth_headers(self) -> dict[str, str]:
|
|
95
|
+
"""Get authorization headers for Ghost Admin API."""
|
|
96
|
+
return {
|
|
97
|
+
"Authorization": f"Ghost {self._generate_token()}",
|
|
98
|
+
"Accept-Version": "v5.0",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async def _request(
|
|
102
|
+
self,
|
|
103
|
+
method: str,
|
|
104
|
+
endpoint: str,
|
|
105
|
+
params: dict[str, Any] | None = None,
|
|
106
|
+
json: dict[str, Any] | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""Make an authenticated request to the Ghost Admin API.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
method: HTTP method (GET, POST, DELETE, etc.)
|
|
112
|
+
endpoint: API endpoint (e.g., /ghost/api/admin/posts/)
|
|
113
|
+
params: Query parameters
|
|
114
|
+
json: JSON body for POST/PUT requests
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
JSON response as a dictionary
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
GhostAuthError: If authentication fails
|
|
121
|
+
GhostNotFoundError: If the resource is not found
|
|
122
|
+
GhostValidationError: If the request is invalid
|
|
123
|
+
GhostConnectionError: If the connection fails
|
|
124
|
+
GhostError: For other API errors
|
|
125
|
+
"""
|
|
126
|
+
session = await self._get_session()
|
|
127
|
+
url = f"{self.site_url}{endpoint}"
|
|
128
|
+
headers = self._get_auth_headers()
|
|
129
|
+
|
|
130
|
+
if json:
|
|
131
|
+
headers["Content-Type"] = "application/json"
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
async with session.request(
|
|
135
|
+
method, url, headers=headers, params=params, json=json
|
|
136
|
+
) as response:
|
|
137
|
+
if response.status == 401:
|
|
138
|
+
raise GhostAuthError("Authentication failed. Check your API key.")
|
|
139
|
+
if response.status == 404:
|
|
140
|
+
raise GhostNotFoundError(f"Resource not found: {endpoint}")
|
|
141
|
+
if response.status == 422:
|
|
142
|
+
data = await response.json()
|
|
143
|
+
errors = data.get("errors", [{}])
|
|
144
|
+
message = errors[0].get("message", "Validation failed")
|
|
145
|
+
raise GhostValidationError(message)
|
|
146
|
+
if response.status >= 400:
|
|
147
|
+
text = await response.text()
|
|
148
|
+
raise GhostError(f"API error {response.status}: {text}")
|
|
149
|
+
|
|
150
|
+
result: dict[str, Any] = await response.json()
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
except aiohttp.ClientError as err:
|
|
154
|
+
raise GhostConnectionError(f"Connection failed: {err}") from err
|
|
155
|
+
|
|
156
|
+
async def _get(
|
|
157
|
+
self, endpoint: str, params: dict[str, Any] | None = None
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""Make a GET request."""
|
|
160
|
+
return await self._request("GET", endpoint, params=params)
|
|
161
|
+
|
|
162
|
+
async def _post(
|
|
163
|
+
self, endpoint: str, json: dict[str, Any] | None = None
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""Make a POST request."""
|
|
166
|
+
return await self._request("POST", endpoint, json=json)
|
|
167
|
+
|
|
168
|
+
async def _delete(self, endpoint: str) -> dict[str, Any]:
|
|
169
|
+
"""Make a DELETE request."""
|
|
170
|
+
return await self._request("DELETE", endpoint)
|
|
171
|
+
|
|
172
|
+
# -------------------------------------------------------------------------
|
|
173
|
+
# Site
|
|
174
|
+
# -------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
async def get_site(self) -> dict[str, Any]:
|
|
177
|
+
"""Get site information.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Site info dict with title, description, url, etc.
|
|
181
|
+
"""
|
|
182
|
+
data = await self._get("/ghost/api/admin/site/")
|
|
183
|
+
return cast(dict[str, Any], data.get("site", {}))
|
|
184
|
+
|
|
185
|
+
# -------------------------------------------------------------------------
|
|
186
|
+
# Posts
|
|
187
|
+
# -------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
async def get_posts_count(self) -> dict[str, int]:
|
|
190
|
+
"""Get post counts by status.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Dict with 'published', 'drafts', 'scheduled' counts.
|
|
194
|
+
"""
|
|
195
|
+
published, drafts, scheduled = await asyncio.gather(
|
|
196
|
+
self._get("/ghost/api/admin/posts/", {"limit": 1, "filter": "status:published"}),
|
|
197
|
+
self._get("/ghost/api/admin/posts/", {"limit": 1, "filter": "status:draft"}),
|
|
198
|
+
self._get("/ghost/api/admin/posts/", {"limit": 1, "filter": "status:scheduled"}),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
"published": int(published.get("meta", {}).get("pagination", {}).get("total", 0)),
|
|
203
|
+
"drafts": int(drafts.get("meta", {}).get("pagination", {}).get("total", 0)),
|
|
204
|
+
"scheduled": int(scheduled.get("meta", {}).get("pagination", {}).get("total", 0)),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async def get_latest_post(self) -> dict[str, Any] | None:
|
|
208
|
+
"""Get the most recently published post.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Post dict or None if no posts exist.
|
|
212
|
+
"""
|
|
213
|
+
data = await self._get(
|
|
214
|
+
"/ghost/api/admin/posts/",
|
|
215
|
+
{"limit": 1, "order": "published_at desc", "filter": "status:published"},
|
|
216
|
+
)
|
|
217
|
+
posts = data.get("posts", [])
|
|
218
|
+
return posts[0] if posts else None
|
|
219
|
+
|
|
220
|
+
# -------------------------------------------------------------------------
|
|
221
|
+
# Members
|
|
222
|
+
# -------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
async def get_members_count(self) -> dict[str, int]:
|
|
225
|
+
"""Get member counts from stats endpoint.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Dict with 'total', 'paid', 'free', 'comped' counts.
|
|
229
|
+
"""
|
|
230
|
+
data = await self._get("/ghost/api/admin/members/stats/count/")
|
|
231
|
+
|
|
232
|
+
total = data.get("total", 0)
|
|
233
|
+
history = data.get("data", [])
|
|
234
|
+
|
|
235
|
+
if history:
|
|
236
|
+
latest = history[-1]
|
|
237
|
+
return {
|
|
238
|
+
"total": total,
|
|
239
|
+
"paid": latest.get("paid", 0),
|
|
240
|
+
"free": latest.get("free", 0),
|
|
241
|
+
"comped": latest.get("comped", 0),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {"total": total, "paid": 0, "free": 0, "comped": 0}
|
|
245
|
+
|
|
246
|
+
async def get_mrr(self) -> dict[str, int]:
|
|
247
|
+
"""Get MRR (Monthly Recurring Revenue) data.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Dict with currency keys and MRR values in cents.
|
|
251
|
+
E.g., {"usd": 12284, "eur": 5000}
|
|
252
|
+
"""
|
|
253
|
+
data = await self._get("/ghost/api/admin/members/stats/mrr/")
|
|
254
|
+
|
|
255
|
+
result: dict[str, int] = {}
|
|
256
|
+
for currency_data in data.get("data", []):
|
|
257
|
+
currency = currency_data.get("currency", "usd")
|
|
258
|
+
values = currency_data.get("data", [])
|
|
259
|
+
if values:
|
|
260
|
+
latest = values[-1]
|
|
261
|
+
result[currency] = latest.get("value", 0)
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
# -------------------------------------------------------------------------
|
|
266
|
+
# Newsletters
|
|
267
|
+
# -------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
async def get_newsletters(self) -> list[dict[str, Any]]:
|
|
270
|
+
"""Get newsletters with subscriber counts.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
List of newsletter dicts.
|
|
274
|
+
"""
|
|
275
|
+
data = await self._get(
|
|
276
|
+
"/ghost/api/admin/newsletters/",
|
|
277
|
+
{"include": "count.members"},
|
|
278
|
+
)
|
|
279
|
+
return cast(list[dict[str, Any]], data.get("newsletters", []))
|
|
280
|
+
|
|
281
|
+
# -------------------------------------------------------------------------
|
|
282
|
+
# Email / Latest Email
|
|
283
|
+
# -------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def _build_email_stats(self, post: dict[str, Any]) -> dict[str, Any]:
|
|
286
|
+
"""Build email stats dict from post with email data."""
|
|
287
|
+
email = post["email"]
|
|
288
|
+
email_count = email.get("email_count", 0)
|
|
289
|
+
opened_count = email.get("opened_count", 0)
|
|
290
|
+
count_data = post.get("count", {})
|
|
291
|
+
clicked_count = count_data.get("clicks", 0) or 0
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
"title": post.get("title"),
|
|
295
|
+
"slug": post.get("slug"),
|
|
296
|
+
"published_at": post.get("published_at"),
|
|
297
|
+
"email_count": email_count,
|
|
298
|
+
"delivered_count": email.get("delivered_count", 0),
|
|
299
|
+
"opened_count": opened_count,
|
|
300
|
+
"clicked_count": clicked_count,
|
|
301
|
+
"failed_count": email.get("failed_count", 0),
|
|
302
|
+
"open_rate": round(opened_count / email_count * 100) if email_count > 0 else 0,
|
|
303
|
+
"click_rate": round(clicked_count / email_count * 100) if email_count > 0 else 0,
|
|
304
|
+
"subject": email.get("subject"),
|
|
305
|
+
"submitted_at": email.get("submitted_at"),
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async def get_latest_email(self) -> dict[str, Any] | None:
|
|
309
|
+
"""Get the most recently sent email newsletter.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Email stats dict or None if no emails have been sent.
|
|
313
|
+
"""
|
|
314
|
+
data = await self._get(
|
|
315
|
+
"/ghost/api/admin/posts/",
|
|
316
|
+
{
|
|
317
|
+
"limit": 10,
|
|
318
|
+
"order": "published_at desc",
|
|
319
|
+
"filter": "status:published",
|
|
320
|
+
"include": "email,count.clicks",
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
for post in data.get("posts", []):
|
|
325
|
+
if post.get("email"):
|
|
326
|
+
return self._build_email_stats(post)
|
|
327
|
+
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
# -------------------------------------------------------------------------
|
|
331
|
+
# Comments
|
|
332
|
+
# -------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
async def get_comments_count(self) -> int:
|
|
335
|
+
"""Get total comments count.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Total number of comments.
|
|
339
|
+
"""
|
|
340
|
+
data = await self._get("/ghost/api/admin/comments/", {"limit": 1})
|
|
341
|
+
return int(data.get("meta", {}).get("pagination", {}).get("total", 0))
|
|
342
|
+
|
|
343
|
+
# -------------------------------------------------------------------------
|
|
344
|
+
# Tiers
|
|
345
|
+
# -------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
async def get_tiers(self) -> list[dict[str, Any]]:
|
|
348
|
+
"""Get subscription tiers.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
List of tier dicts.
|
|
352
|
+
"""
|
|
353
|
+
data = await self._get("/ghost/api/admin/tiers/")
|
|
354
|
+
return cast(list[dict[str, Any]], data.get("tiers", []))
|
|
355
|
+
|
|
356
|
+
# -------------------------------------------------------------------------
|
|
357
|
+
# ActivityPub / Social Web
|
|
358
|
+
# -------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
async def get_activitypub_stats(self) -> dict[str, int]:
|
|
361
|
+
"""Get ActivityPub follower/following counts (public endpoints).
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Dict with 'followers' and 'following' counts.
|
|
365
|
+
"""
|
|
366
|
+
session = await self._get_session()
|
|
367
|
+
headers = {"Accept": "application/activity+json"}
|
|
368
|
+
|
|
369
|
+
stats = {"followers": 0, "following": 0}
|
|
370
|
+
|
|
371
|
+
async def fetch_count(endpoint: str, key: str) -> None:
|
|
372
|
+
try:
|
|
373
|
+
url = f"{self.site_url}/.ghost/activitypub/{endpoint}/index"
|
|
374
|
+
async with session.get(url, headers=headers) as response:
|
|
375
|
+
if response.ok:
|
|
376
|
+
data = await response.json()
|
|
377
|
+
stats[key] = data.get("totalItems", 0)
|
|
378
|
+
except Exception as err:
|
|
379
|
+
_LOGGER.debug("ActivityPub %s not available: %s", endpoint, err)
|
|
380
|
+
|
|
381
|
+
await asyncio.gather(
|
|
382
|
+
fetch_count("followers", "followers"),
|
|
383
|
+
fetch_count("following", "following"),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return stats
|
|
387
|
+
|
|
388
|
+
# -------------------------------------------------------------------------
|
|
389
|
+
# Webhooks
|
|
390
|
+
# -------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
async def create_webhook(self, event: str, target_url: str) -> dict[str, Any]:
|
|
393
|
+
"""Create a webhook in Ghost.
|
|
394
|
+
|
|
395
|
+
Ghost automatically associates the webhook with the integration
|
|
396
|
+
that owns the API key being used.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
event: Event type (e.g., 'member.added', 'post.published')
|
|
400
|
+
target_url: URL to POST webhook payloads to
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Created webhook dict.
|
|
404
|
+
"""
|
|
405
|
+
data = await self._post(
|
|
406
|
+
"/ghost/api/admin/webhooks/",
|
|
407
|
+
{"webhooks": [{"event": event, "target_url": target_url}]},
|
|
408
|
+
)
|
|
409
|
+
webhooks = cast(list[dict[str, Any]], data.get("webhooks", [{}]))
|
|
410
|
+
return webhooks[0]
|
|
411
|
+
|
|
412
|
+
async def delete_webhook(self, webhook_id: str) -> None:
|
|
413
|
+
"""Delete a webhook from Ghost.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
webhook_id: The webhook ID to delete.
|
|
417
|
+
"""
|
|
418
|
+
await self._delete(f"/ghost/api/admin/webhooks/{webhook_id}/")
|
|
419
|
+
|
|
420
|
+
# -------------------------------------------------------------------------
|
|
421
|
+
# Validation
|
|
422
|
+
# -------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
async def validate_credentials(self) -> bool:
|
|
425
|
+
"""Validate the API credentials.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
True if credentials are valid, False otherwise.
|
|
429
|
+
"""
|
|
430
|
+
try:
|
|
431
|
+
await self.get_site()
|
|
432
|
+
return True
|
|
433
|
+
except GhostError:
|
|
434
|
+
return False
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exceptions for the Ghost Admin API client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GhostError(Exception):
|
|
5
|
+
"""Base exception for Ghost API errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GhostAuthError(GhostError):
|
|
9
|
+
"""Authentication failed (invalid API key or expired token)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GhostConnectionError(GhostError):
|
|
13
|
+
"""Failed to connect to the Ghost API."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GhostNotFoundError(GhostError):
|
|
17
|
+
"""Requested resource was not found."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GhostValidationError(GhostError):
|
|
21
|
+
"""Request validation failed."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for aioghost."""
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Tests for the Ghost Admin API client."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from aioresponses import aioresponses
|
|
5
|
+
|
|
6
|
+
from aioghost import GhostAdminAPI, GhostAuthError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def api():
|
|
11
|
+
"""Create a test API client."""
|
|
12
|
+
return GhostAdminAPI(
|
|
13
|
+
site_url="https://test.ghost.io",
|
|
14
|
+
admin_api_key="650b7a9f8e8c1234567890ab:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.asyncio
|
|
19
|
+
async def test_get_site(api: GhostAdminAPI):
|
|
20
|
+
"""Test getting site info."""
|
|
21
|
+
with aioresponses() as m:
|
|
22
|
+
m.get(
|
|
23
|
+
"https://test.ghost.io/ghost/api/admin/site/",
|
|
24
|
+
payload={"site": {"title": "Test Site", "url": "https://test.ghost.io"}},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
async with api:
|
|
28
|
+
site = await api.get_site()
|
|
29
|
+
|
|
30
|
+
assert site["title"] == "Test Site"
|
|
31
|
+
assert site["url"] == "https://test.ghost.io"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_get_members_count(api: GhostAdminAPI):
|
|
36
|
+
"""Test getting member counts."""
|
|
37
|
+
with aioresponses() as m:
|
|
38
|
+
m.get(
|
|
39
|
+
"https://test.ghost.io/ghost/api/admin/members/stats/count/",
|
|
40
|
+
payload={
|
|
41
|
+
"total": 100,
|
|
42
|
+
"data": [{"paid": 10, "free": 85, "comped": 5}],
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async with api:
|
|
47
|
+
members = await api.get_members_count()
|
|
48
|
+
|
|
49
|
+
assert members["total"] == 100
|
|
50
|
+
assert members["paid"] == 10
|
|
51
|
+
assert members["free"] == 85
|
|
52
|
+
assert members["comped"] == 5
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_invalid_api_key_format():
|
|
57
|
+
"""Test that invalid API key format raises error."""
|
|
58
|
+
api = GhostAdminAPI(
|
|
59
|
+
site_url="https://test.ghost.io",
|
|
60
|
+
admin_api_key="invalid-key-no-colon",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with pytest.raises(GhostAuthError, match="Invalid API key format"):
|
|
64
|
+
api._generate_token()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_invalid_api_key_secret():
|
|
69
|
+
"""Test that invalid API key secret raises error."""
|
|
70
|
+
api = GhostAdminAPI(
|
|
71
|
+
site_url="https://test.ghost.io",
|
|
72
|
+
admin_api_key="validid:not-hex-string",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
with pytest.raises(GhostAuthError, match="Invalid API key secret"):
|
|
76
|
+
api._generate_token()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.asyncio
|
|
80
|
+
async def test_validate_credentials_success(api: GhostAdminAPI):
|
|
81
|
+
"""Test credential validation success."""
|
|
82
|
+
with aioresponses() as m:
|
|
83
|
+
m.get(
|
|
84
|
+
"https://test.ghost.io/ghost/api/admin/site/",
|
|
85
|
+
payload={"site": {"title": "Test"}},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async with api:
|
|
89
|
+
valid = await api.validate_credentials()
|
|
90
|
+
|
|
91
|
+
assert valid is True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_validate_credentials_failure(api: GhostAdminAPI):
|
|
96
|
+
"""Test credential validation failure."""
|
|
97
|
+
with aioresponses() as m:
|
|
98
|
+
m.get(
|
|
99
|
+
"https://test.ghost.io/ghost/api/admin/site/",
|
|
100
|
+
status=401,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async with api:
|
|
104
|
+
valid = await api.validate_credentials()
|
|
105
|
+
|
|
106
|
+
assert valid is False
|