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.
@@ -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.
@@ -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.
@@ -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