diff --git a/.gitignore b/.gitignore index f458ba0..53cb33f 100644 --- a/.gitignore +++ b/.gitignore @@ -267,3 +267,6 @@ pip-selfcheck.json *.code-workspace # End of https://www.toptal.com/developers/gitignore/api/linux,python,virtualenv,pycharm+all,vscode + + +examples/config/config.json diff --git a/README.md b/README.md index 479295e..9ca4049 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ NetSchoolAPI — это асинхронный клиент для «Сетев [Документация](https://netschoolapi.readthedocs.io/ru/latest/) +А ещё у нас есть примеры в `/examples`. + ## Жалобы и предложения Обсуждение библиотеки ведётся в [чате](https://t.me/netschoolapi/) проекта в телеграме. diff --git a/docs/guide.md b/docs/guide.md index d7fafc0..e27e700 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -26,7 +26,7 @@ * `assignment: Assignment` --- урок, у которого нужно получить прикреплённые файлы -Возвращает список объектов [`Attachment`](reference.md#attachment) или пустой список, если к уроку не прикреплено файлов. Скачать вложение из файла можно с помощью `netschoolapi_client.download_attachment()` или `netschoolapi_client.download_attachment_as_bytes()`. +Возвращает список объектов [`Attachment`](reference.md#attachment) или пустой список, если к уроку не прикреплено файлов. ## Объявления {#announcements} @@ -35,3 +35,7 @@ ## Информация о школе {#school} `.school()` --- возвращает [информацию](reference.md#school) о школе. + +## Скачивание файлов из вложений {#downloading_files_from_attachments} + +Скачать файл из вложения можно с помощью `netschoolapi_client.download_attachment()` или `netschoolapi_client.download_attachment_as_bytes()`. diff --git a/examples/announcements_and_their_attachments.py b/examples/announcements_and_their_attachments.py new file mode 100644 index 0000000..caa7475 --- /dev/null +++ b/examples/announcements_and_their_attachments.py @@ -0,0 +1,21 @@ +import PIL.Image + +import netschoolapi +from examples.preparation.preparation import run_main +from examples.preparation.utils import pprint + + +async def main(client: netschoolapi.NetSchoolAPI): + print("Announcements:") + announcements = await client.announcements() + pprint(announcements) + print() + print("Now showing all images found in announcements.") + for announcement in announcements: + for attachment in announcement.attachments: + if attachment.name.endswith((".png", ".jpg", ".jpeg")): + image = await client.download_attachment_as_bytes(attachment) + PIL.Image.open(image).show() + + +run_main(main) diff --git a/examples/config/config_maker.py b/examples/config/config_maker.py new file mode 100644 index 0000000..01dd1fd --- /dev/null +++ b/examples/config/config_maker.py @@ -0,0 +1,20 @@ +import json +from dataclasses import dataclass + + +@dataclass +class Config: + url: str + user_name: str + password: str + school: str + + +def make_config(): + try: + return Config(**json.load(open("config/config.json", encoding="utf-8"))) + except FileNotFoundError: + raise FileNotFoundError( + "Please create a config/config.json file (use " + "config/config_template.json as a template)" + ) diff --git a/examples/config/config_template.json b/examples/config/config_template.json new file mode 100644 index 0000000..76809df --- /dev/null +++ b/examples/config/config_template.json @@ -0,0 +1,6 @@ +{ + "url": "https://edu.admoblkaluga.ru:444/", + "user_name": "Аркадий Степанович Петроненко", + "password": "||4R0Ль_йцУk3H", + "school": "МБОУ \"СОШ \"Технический лицей\"" +} \ No newline at end of file diff --git a/examples/diary_and_its_attachments.py b/examples/diary_and_its_attachments.py new file mode 100644 index 0000000..7a8ca5b --- /dev/null +++ b/examples/diary_and_its_attachments.py @@ -0,0 +1,23 @@ +import netschoolapi +from examples.preparation import utils +from preparation.preparation import run_main + + +async def main(client: netschoolapi.NetSchoolAPI): + print("Diary:") + diary = await client.diary() + utils.pprint(diary) + for day in diary.schedule: + for lesson in day.lessons: + for assignment in lesson.assignments: + attachments = await client.attachments(assignment) + if attachments: + print( + f"Some attachments found on day {day.day} on " + f"{lesson.subject} homework: {attachments}" + ) + # You can also download them using + # .download_attachment() or .download_attachment_as_bytes() + + +run_main(main) diff --git a/examples/overdue.py b/examples/overdue.py new file mode 100644 index 0000000..bb2b94e --- /dev/null +++ b/examples/overdue.py @@ -0,0 +1,11 @@ +import netschoolapi +from examples.preparation.preparation import run_main +from examples.preparation.utils import pprint + + +async def main(client: netschoolapi.NetSchoolAPI): + print("Overdue:") + pprint(await client.overdue()) + + +run_main(main) diff --git a/examples/preparation/preparation.py b/examples/preparation/preparation.py new file mode 100644 index 0000000..b1a710d --- /dev/null +++ b/examples/preparation/preparation.py @@ -0,0 +1,28 @@ +import asyncio +from typing import Callable, Awaitable + +# noinspection PyUnresolvedReferences +# because working path from an IDE project not always equals to a working path +# when launching the script (so if I launch something from examples, my working +# directory will be "examples", but my IDE thinks that it is "examples/.." +# (project root)) +from config.config_maker import Config, make_config + +import netschoolapi + + +async def login(config: Config) -> netschoolapi.NetSchoolAPI: + client = netschoolapi.NetSchoolAPI(config.url) + await client.login(config.user_name, config.password, config.school) + return client + + +async def _run_main(main): + config = make_config() + client = await login(config) + async with client: + await main(client) + + +def run_main(main: Callable[[netschoolapi.NetSchoolAPI], Awaitable]): + asyncio.get_event_loop().run_until_complete(_run_main(main)) diff --git a/examples/preparation/utils.py b/examples/preparation/utils.py new file mode 100644 index 0000000..c365411 --- /dev/null +++ b/examples/preparation/utils.py @@ -0,0 +1,7 @@ +import rich.console + +console = rich.console.Console(no_color=True, width=80) + + +def pprint(object_): + console.print(object_) diff --git a/examples/school.py b/examples/school.py new file mode 100644 index 0000000..792a5ee --- /dev/null +++ b/examples/school.py @@ -0,0 +1,11 @@ +import netschoolapi +from examples.preparation.preparation import run_main +from examples.preparation.utils import pprint + + +async def main(client: netschoolapi.NetSchoolAPI): + print("School info:") + pprint(await client.school()) + + +run_main(main) diff --git a/netschoolapi/async_client_wrapper.py b/netschoolapi/async_client_wrapper.py new file mode 100644 index 0000000..ff3ceef --- /dev/null +++ b/netschoolapi/async_client_wrapper.py @@ -0,0 +1,55 @@ +import asyncio +import functools +from typing import Optional, Awaitable, Protocol + +import httpx + +from netschoolapi import errors + +DEFAULT_REQUESTS_TIMEOUT = 5 + + +class Requester(Protocol): + + def __call__( + self, path: str, method="GET", params: dict = None, + json: dict = None, data: dict = None) -> Awaitable: + pass + + +class AsyncClientWrapper: + def __init__( + self, async_client: httpx.AsyncClient, + default_requests_timeout: int = None): + self.client = async_client + self._default_requests_timeout = ( + default_requests_timeout or DEFAULT_REQUESTS_TIMEOUT + ) + + def make_requester(self, requests_timeout: Optional[int]) -> Requester: + # noinspection PyTypeChecker + return functools.partial(self.request, requests_timeout) + + async def request( + self, requests_timeout: Optional[int], path: str, + method="GET", params: dict = None, json: dict = None, + data: dict = None): + try: + return await asyncio.wait_for(self._infinite_request( + path, method, params, json, data, + ), requests_timeout or self._default_requests_timeout) + except asyncio.TimeoutError: + raise errors.NoResponseFromServer from None + + async def _infinite_request( + self, path: str, method: str, params: Optional[dict], + json: Optional[dict], data: Optional[dict]): + while True: + try: + response = await self.client.request( + method, path, params=params, json=json, data=data + ) + except httpx.ReadTimeout: + pass + else: + return response diff --git a/netschoolapi/errors.py b/netschoolapi/errors.py index 0d55e96..aac5a66 100644 --- a/netschoolapi/errors.py +++ b/netschoolapi/errors.py @@ -8,3 +8,7 @@ class AuthError(NetSchoolAPIError): class SchoolNotFoundError(NetSchoolAPIError): pass + + +class NoResponseFromServer(NetSchoolAPIError): + pass diff --git a/netschoolapi/netschoolapi.py b/netschoolapi/netschoolapi.py index 36c1d44..3824478 100644 --- a/netschoolapi/netschoolapi.py +++ b/netschoolapi/netschoolapi.py @@ -10,18 +10,24 @@ __all__ = ['NetSchoolAPI'] +from netschoolapi.async_client_wrapper import AsyncClientWrapper, Requester + async def _die_on_bad_status(response: Response): response.raise_for_status() class NetSchoolAPI: - def __init__(self, url: str): + def __init__( + self, url: str, default_requests_timeout: int = None): url = url.rstrip('/') - self._client = AsyncClient( - base_url=f'{url}/webapi', - headers={'user-agent': 'NetSchoolAPI/5.0.3', 'referer': url}, - event_hooks={'response': [_die_on_bad_status]}, + self._wrapped_client = AsyncClientWrapper( + async_client=AsyncClient( + base_url=f'{url}/webapi', + headers={'user-agent': 'NetSchoolAPI/5.0.3', 'referer': url}, + event_hooks={'response': [_die_on_bad_status]}, + ), + default_requests_timeout=default_requests_timeout, ) self._student_id = -1 @@ -37,11 +43,16 @@ async def __aenter__(self) -> 'NetSchoolAPI': async def __aexit__(self, exc_type, exc_val, exc_tb): await self.logout() - async def login(self, user_name: str, password: str, school: Union[str, int]): - response_with_cookies = await self._client.get('logindata') - self._client.cookies.extract_cookies(response_with_cookies) + async def login( + self, user_name: str, password: str, school: str, + requests_timeout: int = None): + requester = self._wrapped_client.make_requester(requests_timeout) + response_with_cookies = await requester('logindata') + self._wrapped_client.client.cookies.extract_cookies( + response_with_cookies + ) - response = await self._client.post('auth/getdata') + response = await requester('auth/getdata', method="POST") login_meta = response.json() salt = login_meta.pop('salt') @@ -52,18 +63,20 @@ async def login(self, user_name: str, password: str, school: Union[str, int]): pw = pw2[: len(password)] try: - response = await self._client.post( + response = await requester( 'login', data={ 'loginType': 1, - **(await self._address(school)), + **(await self._address(school, requester)), 'un': user_name, 'pw': pw, 'pw2': pw2, **login_meta, }, + method="POST" ) except httpx.HTTPStatusError as http_status_error: + await self._wrapped_client.client.aclose() if http_status_error.response.status_code == httpx.codes.CONFLICT: raise errors.AuthError("Incorrect username or password!") else: @@ -73,18 +86,18 @@ async def login(self, user_name: str, password: str, school: Union[str, int]): if 'at' not in auth_result: raise errors.AuthError(auth_result['message']) - self._client.headers['at'] = auth_result['at'] + self._wrapped_client.client.headers['at'] = auth_result['at'] - response = await self._client.get('student/diary/init') + response = await requester('student/diary/init') diary_info = response.json() student = diary_info['students'][diary_info['currentStudentId']] self._student_id = student['studentId'] - response = await self._client.get('years/current') + response = await requester('years/current') year_reference = response.json() self._year_id = year_reference['id'] - response = await self._client.get( + response = await requester( 'grade/assignment/types', params={'all': False} ) assignment_reference = response.json() @@ -95,11 +108,11 @@ async def login(self, user_name: str, password: str, school: Union[str, int]): self._login_data = (user_name, password, school) async def _request_with_optional_relogin( - self, path: str, method="GET", params: dict = None, - json: dict = None): + self, requests_timeout: Optional[int], path: str, + method="GET", params: dict = None, json: dict = None): try: - response = await self._client.request( - method, path, params=params, json=json + response = await self._wrapped_client.request( + requests_timeout, path, method, params, json, ) except httpx.HTTPStatusError as http_status_error: if ( @@ -108,9 +121,6 @@ async def _request_with_optional_relogin( ): if self._login_data: await self.login(*self._login_data) - return await self._client.request( - method, path, params=params, json=json - ) else: raise errors.AuthError( ".login() before making requests that need " @@ -123,7 +133,8 @@ async def _request_with_optional_relogin( async def download_attachment( self, attachment: data.Attachment, - path_or_file: Union[BytesIO, str] = None): + path_or_file: Union[BytesIO, str] = None, + requests_timeout: int = None): """ If `path_to_file` is a string, it should contain absolute path to file """ @@ -135,15 +146,18 @@ async def download_attachment( file = path_or_file file.write(( await self._request_with_optional_relogin( - f"attachments/{attachment.id}" + requests_timeout, + f"attachments/{attachment.id}", ) ).content) async def download_attachment_as_bytes( - self, attachment: data.Attachment) -> BytesIO: + self, attachment: data.Attachment, requests_timeout: int = None, + ) -> BytesIO: attachment_contents_buffer = BytesIO() await self.download_attachment( - attachment, path_or_file=attachment_contents_buffer + attachment, path_or_file=attachment_contents_buffer, + requests_timeout=requests_timeout ) return attachment_contents_buffer @@ -151,6 +165,7 @@ async def diary( self, start: Optional[date] = None, end: Optional[date] = None, + requests_timeout: int = None, ) -> data.Diary: if not start: monday = date.today() - timedelta(days=date.today().weekday()) @@ -159,6 +174,7 @@ async def diary( end = start + timedelta(days=5) response = await self._request_with_optional_relogin( + requests_timeout, 'student/diary', params={ 'studentId': self._student_id, @@ -175,6 +191,7 @@ async def overdue( self, start: Optional[date] = None, end: Optional[date] = None, + requests_timeout: int = None, ) -> List[data.Assignment]: if not start: monday = date.today() - timedelta(days=date.today().weekday()) @@ -183,6 +200,7 @@ async def overdue( end = start + timedelta(days=5) response = await self._request_with_optional_relogin( + requests_timeout, 'student/diary/pastMandatory', params={ 'studentId': self._student_id, @@ -191,13 +209,18 @@ async def overdue( 'weekEnd': end.isoformat(), }, ) - assignments = schemas.Assignment().load(response.json(), many=True) + assignments_schema = schemas.Assignment() + assignments_schema.context['assignment_types'] = self._assignment_types + assignments = assignments_schema.load(response.json(), many=True) return [data.Assignment(**assignment) for assignment in assignments] async def announcements( - self, take: Optional[int] = -1) -> List[data.Announcement]: + self, take: Optional[int] = -1, + requests_timeout: int = None) -> List[data.Announcement]: response = await self._request_with_optional_relogin( - 'announcements', params={'take': take} + requests_timeout, + 'announcements', + params={'take': take}, ) announcements = schemas.Announcement().load(response.json(), many=True) return [ @@ -206,32 +229,41 @@ async def announcements( ] async def attachments( - self, assignment: data.Assignment) -> List[data.Attachment]: + self, assignment: data.Assignment, + requests_timeout: int = None) -> List[data.Attachment]: response = await self._request_with_optional_relogin( + requests_timeout, method="POST", path='student/diary/get-attachments', params={'studentId': self._student_id}, json={'assignId': [assignment.id]}, ) + response = response.json() + if not response: + return [] attachments_json = response.json()[0]['attachments'] attachments = schemas.Attachment().load(attachments_json, many=True) return [data.Attachment(**attachment) for attachment in attachments] - async def school(self): + async def school(self, requests_timeout: int = None): response = await self._request_with_optional_relogin( - 'schools/{0}/card'.format(self._school_id) + requests_timeout, + 'schools/{0}/card'.format(self._school_id), ) school = schemas.School().load(response.json()) return data.School(**school) - async def logout(self): - await self._client.post('auth/logout') - await self._client.aclose() - - async def _address(self, school: Union[str, int]) -> Dict[str, int]: - response = await self._client.get( - 'addresses/schools', params={'funcType': 2} + async def logout(self, requests_timeout: int = None): + await self._wrapped_client.request( + requests_timeout, + 'auth/logout', + method="POST", ) + await self._wrapped_client.client.aclose() + + async def _address( + self, school: str, requester: Requester) -> Dict[str, int]: + response = await requester('addresses/schools', params={'funcType': 2}) schools_reference = response.json() for school_ in schools_reference: diff --git a/netschoolapi/schemas.py b/netschoolapi/schemas.py index 5537494..5310d6f 100644 --- a/netschoolapi/schemas.py +++ b/netschoolapi/schemas.py @@ -56,7 +56,7 @@ class Lesson(NetSchoolAPISchema): day = fields.Date() start = fields.Time(data_key='startTime') end = fields.Time(data_key='endTime') - room = fields.String(allow_none=True, missing='') + room = fields.String(missing='', allow_none=True) number = fields.Integer() subject = fields.String(data_key='subjectName') assignments = fields.List(fields.Nested(Assignment), missing=[])