classcharts-api 1.0.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,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2022 James Cook
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: classcharts-api
3
+ Version: 1.0.0
4
+ Summary: Async Python wrapper for the ClassCharts API
5
+ License: ISC
6
+ Keywords: classcharts,school,api,home-assistant
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: ISC License (ISCL)
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Intended Audience :: Developers
11
+ Requires-Python: >=3.11
12
+ License-File: LICENSE
13
+ Requires-Dist: aiohttp>=3.9.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8.0; extra == "dev"
16
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
17
+ Requires-Dist: aioresponses>=0.7; extra == "dev"
18
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
19
+ Dynamic: license-file
@@ -0,0 +1,38 @@
1
+ # classcharts-api
2
+
3
+ A Python port of [classcharts-api-js](https://github.com/classchartsapi/classcharts-api-js) by James Cook.
4
+
5
+ Provides an async Python client for the ClassCharts parent and student APIs.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install classcharts-api
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ import asyncio
17
+ from classcharts_api import ParentClient
18
+
19
+ async def main():
20
+ async with ParentClient("email@example.com", "password") as client:
21
+ await client.login()
22
+ pupils = client.pupils
23
+ homeworks = await client.get_homeworks_for_each_pupil(
24
+ display_date="due_date",
25
+ from_date="2026-01-01",
26
+ to_date="2026-07-01",
27
+ )
28
+ for pupil_id, resp in homeworks.items():
29
+ print(f"Pupil {pupil_id}: {len(resp['data'])} homework items")
30
+
31
+ asyncio.run(main())
32
+ ```
33
+
34
+ ## License
35
+
36
+ ISC License — see [LICENSE](LICENSE).
37
+
38
+ This project is a Python port of [classcharts-api-js](https://github.com/classchartsapi/classcharts-api-js), copyright (c) 2022 James Cook, used under the ISC License.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "classcharts-api"
7
+ version = "1.0.0"
8
+ description = "Async Python wrapper for the ClassCharts API"
9
+ license = { text = "ISC" }
10
+ requires-python = ">=3.11"
11
+ keywords = ["classcharts", "school", "api", "home-assistant"]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: ISC License (ISCL)",
15
+ "Framework :: AsyncIO",
16
+ "Intended Audience :: Developers",
17
+ ]
18
+ dependencies = [
19
+ "aiohttp>=3.9.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest>=8.0",
25
+ "pytest-asyncio>=0.23",
26
+ "aioresponses>=0.7",
27
+ "pytest-cov>=5.0",
28
+ ]
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+ testpaths = ["tests"]
36
+
37
+ [tool.ruff]
38
+ line-length = 100
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,14 @@
1
+ """ClassCharts API Python wrapper."""
2
+
3
+ from .parent_client import ParentClient
4
+ from .student_client import StudentClient
5
+ from .exceptions import ClassChartsAuthError, ClassChartsApiError
6
+ from . import models
7
+
8
+ __all__ = [
9
+ "ParentClient",
10
+ "StudentClient",
11
+ "ClassChartsAuthError",
12
+ "ClassChartsApiError",
13
+ "models",
14
+ ]
@@ -0,0 +1,336 @@
1
+ """Base async client — shared logic for both ParentClient and StudentClient.
2
+
3
+ Mirrors src/core/baseClient.ts from classcharts-api-js.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ from abc import ABC, abstractmethod
11
+ from typing import Any
12
+
13
+ import aiohttp
14
+
15
+ from .const import PING_INTERVAL
16
+ from .exceptions import ClassChartsApiError
17
+
18
+
19
+ class BaseClient(ABC):
20
+ """Shared async HTTP client for ClassCharts."""
21
+
22
+ def __init__(self, api_base: str) -> None:
23
+ self._api_base = api_base
24
+ self.student_id: int = 0
25
+ self.session_id: str = ""
26
+ self.auth_cookies: list[str] = []
27
+ self._last_ping: float = 0.0
28
+ self._session: aiohttp.ClientSession | None = None
29
+
30
+ # ------------------------------------------------------------------
31
+ # Session management
32
+ # ------------------------------------------------------------------
33
+
34
+ def _get_session(self) -> aiohttp.ClientSession:
35
+ if self._session is None or self._session.closed:
36
+ self._session = aiohttp.ClientSession()
37
+ return self._session
38
+
39
+ async def close(self) -> None:
40
+ """Close the underlying aiohttp session."""
41
+ if self._session and not self._session.closed:
42
+ await self._session.close()
43
+
44
+ async def __aenter__(self) -> "BaseClient":
45
+ return self
46
+
47
+ async def __aexit__(self, *_: Any) -> None:
48
+ await self.close()
49
+
50
+ @abstractmethod
51
+ async def login(self) -> None:
52
+ """Authenticate with ClassCharts and store session credentials."""
53
+
54
+ # ------------------------------------------------------------------
55
+ # Session revalidation (ping)
56
+ # ------------------------------------------------------------------
57
+
58
+ async def get_new_session_id(self) -> None:
59
+ """Revalidate the session ID.
60
+
61
+ Called automatically when the session is older than PING_INTERVAL
62
+ seconds, and immediately after login for student clients.
63
+ Mirrors getNewSessionId() in baseClient.ts.
64
+ """
65
+ data = aiohttp.FormData()
66
+ data.add_field("include_data", "true")
67
+ response = await self._make_authed_request(
68
+ f"{self._api_base}/ping",
69
+ method="POST",
70
+ data=data,
71
+ revalidate_token=False,
72
+ )
73
+ self.session_id = response["meta"]["session_id"]
74
+ self._last_ping = time.monotonic()
75
+
76
+ # ------------------------------------------------------------------
77
+ # Core request helper
78
+ # ------------------------------------------------------------------
79
+
80
+ async def _make_authed_request(
81
+ self,
82
+ url: str,
83
+ method: str = "GET",
84
+ data: Any = None,
85
+ params: dict[str, str] | None = None,
86
+ revalidate_token: bool = True,
87
+ ) -> dict[str, Any]:
88
+ """Make an authenticated request to the ClassCharts API.
89
+
90
+ Mirrors makeAuthedRequest() in baseClient.ts.
91
+ """
92
+ if not self.session_id:
93
+ raise ClassChartsApiError("No session ID — call login() first")
94
+
95
+ if revalidate_token and self._last_ping:
96
+ elapsed = time.monotonic() - self._last_ping
97
+ if elapsed + 5 > PING_INTERVAL:
98
+ await self.get_new_session_id()
99
+
100
+ headers = {
101
+ "Cookie": "; ".join(c.split(";")[0] for c in self.auth_cookies),
102
+ "Authorization": f"Basic {self.session_id}",
103
+ "User-Agent": "classcharts-api https://github.com/classchartsapi/classcharts-api-js",
104
+ }
105
+
106
+ session = self._get_session()
107
+ async with session.request(
108
+ method,
109
+ url,
110
+ headers=headers,
111
+ data=data,
112
+ params=params,
113
+ ) as resp:
114
+ try:
115
+ payload: dict[str, Any] = await resp.json(content_type=None)
116
+ except Exception as exc:
117
+ text = await resp.text()
118
+ raise ClassChartsApiError(
119
+ f"Error parsing JSON. Response: {text}"
120
+ ) from exc
121
+
122
+ if payload.get("success") == 0:
123
+ raise ClassChartsApiError(payload.get("error", "Unknown API error"))
124
+
125
+ return payload
126
+
127
+ # ------------------------------------------------------------------
128
+ # Shared endpoints
129
+ # ------------------------------------------------------------------
130
+
131
+ async def get_student_info(self) -> dict[str, Any]:
132
+ """Get general information about the current student.
133
+
134
+ Mirrors getStudentInfo() in baseClient.ts.
135
+ """
136
+ data = aiohttp.FormData()
137
+ data.add_field("include_data", "true")
138
+ return await self._make_authed_request(
139
+ f"{self._api_base}/ping", method="POST", data=data
140
+ )
141
+
142
+ async def get_activity(
143
+ self,
144
+ from_date: str | None = None,
145
+ to_date: str | None = None,
146
+ last_id: str | None = None,
147
+ ) -> dict[str, Any]:
148
+ """Get the current student's activity (paginated).
149
+
150
+ Mirrors getActivity() in baseClient.ts.
151
+ """
152
+ params: dict[str, str] = {}
153
+ if from_date:
154
+ params["from"] = from_date
155
+ if to_date:
156
+ params["to"] = to_date
157
+ if last_id:
158
+ params["last_id"] = last_id
159
+ return await self._make_authed_request(
160
+ f"{self._api_base}/activity/{self.student_id}", params=params
161
+ )
162
+
163
+ async def get_full_activity(
164
+ self, from_date: str, to_date: str
165
+ ) -> list[dict[str, Any]]:
166
+ """Get all activity between two dates, handling pagination automatically.
167
+
168
+ Mirrors getFullActivity() in baseClient.ts.
169
+ """
170
+ data: list[dict[str, Any]] = []
171
+ prev_last: str | None = None
172
+ while True:
173
+ response = await self.get_activity(
174
+ from_date=from_date, to_date=to_date, last_id=prev_last
175
+ )
176
+ fragment = response.get("data") or []
177
+ if not fragment:
178
+ break
179
+ data.extend(fragment)
180
+ prev_last = str(fragment[-1]["id"])
181
+ return data
182
+
183
+ async def get_behaviour(
184
+ self,
185
+ from_date: str | None = None,
186
+ to_date: str | None = None,
187
+ ) -> dict[str, Any]:
188
+ """Get the current student's behaviour.
189
+
190
+ Mirrors getBehaviour() in baseClient.ts.
191
+ """
192
+ params: dict[str, str] = {}
193
+ if from_date:
194
+ params["from"] = from_date
195
+ if to_date:
196
+ params["to"] = to_date
197
+ return await self._make_authed_request(
198
+ f"{self._api_base}/behaviour/{self.student_id}", params=params
199
+ )
200
+
201
+ async def get_homeworks(
202
+ self,
203
+ display_date: str | None = None,
204
+ from_date: str | None = None,
205
+ to_date: str | None = None,
206
+ ) -> dict[str, Any]:
207
+ """Get the current student's homeworks.
208
+
209
+ Mirrors getHomeworks() in baseClient.ts.
210
+
211
+ Args:
212
+ display_date: "due_date" or "issue_date" (default: "issue_date")
213
+ from_date: Start date in YYYY-MM-DD format
214
+ to_date: End date in YYYY-MM-DD format
215
+ """
216
+ params: dict[str, str] = {}
217
+ if display_date:
218
+ params["display_date"] = display_date
219
+ if from_date:
220
+ params["from"] = from_date
221
+ if to_date:
222
+ params["to"] = to_date
223
+ return await self._make_authed_request(
224
+ f"{self._api_base}/homeworks/{self.student_id}", params=params
225
+ )
226
+
227
+ async def get_lessons(self, date: str) -> dict[str, Any]:
228
+ """Get the current student's lessons for a given date.
229
+
230
+ Mirrors getLessons() in baseClient.ts.
231
+
232
+ Args:
233
+ date: Date in YYYY-MM-DD format
234
+ """
235
+ return await self._make_authed_request(
236
+ f"{self._api_base}/timetable/{self.student_id}",
237
+ params={"date": date},
238
+ )
239
+
240
+ async def get_badges(self) -> dict[str, Any]:
241
+ """Get the current student's earned badges.
242
+
243
+ Mirrors getBadges() in baseClient.ts.
244
+ """
245
+ return await self._make_authed_request(
246
+ f"{self._api_base}/eventbadges/{self.student_id}"
247
+ )
248
+
249
+ async def get_announcements(self) -> dict[str, Any]:
250
+ """Get the current student's announcements.
251
+
252
+ Mirrors getAnnouncements() in baseClient.ts.
253
+ """
254
+ return await self._make_authed_request(
255
+ f"{self._api_base}/announcements/{self.student_id}"
256
+ )
257
+
258
+ async def get_detentions(self) -> dict[str, Any]:
259
+ """Get the current student's detentions.
260
+
261
+ Mirrors getDetentions() in baseClient.ts.
262
+ """
263
+ return await self._make_authed_request(
264
+ f"{self._api_base}/detentions/{self.student_id}"
265
+ )
266
+
267
+ async def get_attendance(
268
+ self,
269
+ from_date: str | None = None,
270
+ to_date: str | None = None,
271
+ ) -> dict[str, Any]:
272
+ """Get the current student's attendance.
273
+
274
+ Mirrors getAttendance() in baseClient.ts.
275
+ """
276
+ params: dict[str, str] = {}
277
+ if from_date:
278
+ params["from"] = from_date
279
+ if to_date:
280
+ params["to"] = to_date
281
+ return await self._make_authed_request(
282
+ f"{self._api_base}/attendance/{self.student_id}", params=params
283
+ )
284
+
285
+ async def get_pupil_fields(self) -> dict[str, Any]:
286
+ """Get the current student's custom fields.
287
+
288
+ Mirrors getPupilFields() in baseClient.ts.
289
+ """
290
+ return await self._make_authed_request(
291
+ f"{self._api_base}/customfields/{self.student_id}"
292
+ )
293
+
294
+ async def get_classes(self) -> dict[str, Any]:
295
+ """Get the classes the current student is enrolled in."""
296
+ return await self._make_authed_request(
297
+ f"{self._api_base}/classes/{self.student_id}"
298
+ )
299
+
300
+ async def get_academic_reports(self) -> dict[str, Any]:
301
+ """List available academic reports for the current student."""
302
+ return await self._make_authed_request(
303
+ f"{self._api_base}/getacademicreports"
304
+ )
305
+
306
+ async def get_academic_report(self, report_id: int) -> dict[str, Any]:
307
+ """Get a specific academic report by ID."""
308
+ return await self._make_authed_request(
309
+ f"{self._api_base}/getacademicreport/{report_id}"
310
+ )
311
+
312
+ async def get_report_cards(self) -> dict[str, Any]:
313
+ """List on-report cards for the current student."""
314
+ data = aiohttp.FormData()
315
+ data.add_field("pupil_id", str(self.student_id))
316
+ return await self._make_authed_request(
317
+ f"{self._api_base}/getpupilreportcards", method="POST", data=data
318
+ )
319
+
320
+ async def get_report_card(self, report_card_id: int) -> dict[str, Any]:
321
+ """Get a specific on-report card by ID."""
322
+ return await self._make_authed_request(
323
+ f"{self._api_base}/getpupilreportcard/{report_card_id}"
324
+ )
325
+
326
+ async def get_report_card_summary_comment(self, report_card_id: int) -> dict[str, Any]:
327
+ """Get the summary comment for a specific on-report card."""
328
+ return await self._make_authed_request(
329
+ f"{self._api_base}/getpupilreportcardsummarycomment/{report_card_id}"
330
+ )
331
+
332
+ async def get_report_card_target(self, report_card_id: int) -> dict[str, Any]:
333
+ """Get the target for a specific on-report card."""
334
+ return await self._make_authed_request(
335
+ f"{self._api_base}/getpupilreportcardtarget/{report_card_id}"
336
+ )
@@ -0,0 +1,6 @@
1
+ BASE_URL = "https://www.classcharts.com"
2
+ API_BASE_STUDENT = f"{BASE_URL}/apiv2student"
3
+ API_BASE_PARENT = f"{BASE_URL}/apiv2parent"
4
+
5
+ # Session revalidation interval in seconds (3 minutes)
6
+ PING_INTERVAL = 60 * 3
@@ -0,0 +1,6 @@
1
+ class ClassChartsApiError(Exception):
2
+ """Raised when the ClassCharts API returns an error response."""
3
+
4
+
5
+ class ClassChartsAuthError(ClassChartsApiError):
6
+ """Raised when authentication with ClassCharts fails."""