mirror of
https://github.com/SantaSpeen/anixart.git
synced 2026-02-16 02:20:47 +00:00
Compare commits
35 Commits
v0.2.1-alp
...
0682bdb42b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0682bdb42b | |||
| 63bf159679 | |||
| 8a944319d6 | |||
| aee5c542b4 | |||
| c740242c3f | |||
| 2f18c5ce85 | |||
| 9c94b68d55 | |||
| bb852d2b3a | |||
| d9a577ee14 | |||
| 52ebb42aa2 | |||
| d726088002 | |||
| 577e8dbf2a | |||
| c68735df97 | |||
| dd550855c0 | |||
| 733da877fa | |||
| 14fb575548 | |||
| 8762cca288 | |||
| d288837bb4 | |||
| 3bd27840e1 | |||
| 87fa813700 | |||
| 29205efe4f | |||
| 68e45336ad | |||
| ae1f97a07c | |||
| 85c7605b2c | |||
| 82874814e0 | |||
| 44e0485f99 | |||
| daec2199b6 | |||
| fbb738fd1c | |||
| c4d758f65a | |||
| 04be5ed2a9 | |||
| 87ff4463c4 | |||
| 403e7c8b79 | |||
| 2f0e4e3942 | |||
| 1c8d56dca6 | |||
| 326034fda9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -130,3 +130,6 @@ dmypy.json
|
||||
|
||||
# PyCharm
|
||||
.idea/
|
||||
|
||||
poetry.lock
|
||||
secrets.txt
|
||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2022 Maxim Khomutov
|
||||
Copyright (c) 2025 Maxim Khomutov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without limitation in the rights to use, copy, modify, merge, publish, and/ or distribute copies of the Software in an educational or personal context, subject to the following conditions:
|
||||
|
||||
|
||||
29
README.md
29
README.md
@@ -4,16 +4,25 @@
|
||||
Враппер для использования Anixart API.\
|
||||
Библиотека создана только для ознакомления c API.
|
||||
|
||||
**Автор презирает и не поддерживает создание авторегов / ботов для накрутки / спам ботов.**
|
||||
**Автор не поддерживает и презирает создание авторегов / ботов для накрутки / спам ботов.**
|
||||
|
||||
### Вся документация в папке [docs](./docs)
|
||||
### Установка
|
||||
|
||||
```shell
|
||||
pip install anixart
|
||||
```
|
||||
|
||||
### Как работать с библиотекой
|
||||
#### Вся документация в папке [docs](https://github.com/SantaSpeen/anixart/tree/master/docs)
|
||||
_(Пока что не дошли руки написать доки, гляньте в [examples](https://github.com/SantaSpeen/anixart/tree/master/examples) как работать с библиотекой)_
|
||||
|
||||
|
||||
## Обратная связь
|
||||
|
||||
Если у вас возникли вопросы, или вы хотите каким-либо образом помочь автору вы можете написать ему в телеграм: [@SantaSpeen](https://t.me/SantaSpeen)
|
||||
|
||||
<br/>
|
||||
|
||||
## License
|
||||
```text
|
||||
Copyright (c) 2022 Maxim Khomutov
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without limitation in the rights to use, copy, modify, merge, publish, and/ or distribute copies of the Software in an educational or personal context, subject to the following conditions:
|
||||
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
Permission is granted to sell and/ or distribute copies of the Software in a commercial context, subject to the following conditions:
|
||||
- Substantial changes: adding, removing, or modifying large parts, shall be developed in the Software. Reorganizing logic in the software does not warrant a substantial change.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
|
||||
Проект распространяется под лицензией `FPA License`. Подробнее в [LICENSE](LICENSE)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .__version__ import __license__, __description__
|
||||
from .__version__ import __version__, __url__, __build__, __title__, __author__, __author_email__, __copyright__
|
||||
from .__meta__ import *
|
||||
|
||||
from .api import AnixartAPI
|
||||
from .auth import AnixartAccount, AnixartAccountGuest, AnixartAccountSaved
|
||||
|
||||
from .endpoints import *
|
||||
|
||||
from .api.api import AnixartUserAccount, AnixartAPI
|
||||
from . import enums
|
||||
from . import exceptions
|
||||
|
||||
115
anixart/api.py
Normal file
115
anixart/api.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import requests
|
||||
|
||||
from .__meta__ import __version__, __build__
|
||||
from .auth import AnixartAccount, AnixartAccountGuest
|
||||
from .enums import AnixartApiErrors
|
||||
from .exceptions import AnixartAPIRequestError, AnixartAPIError
|
||||
from .exceptions import AnixartInitError
|
||||
|
||||
debug = True
|
||||
|
||||
class AnixartAPI:
|
||||
API_URL = "https://api.anixart.tv/"
|
||||
|
||||
def __init__(self, account: AnixartAccount = None):
|
||||
if account is None:
|
||||
account = AnixartAccountGuest()
|
||||
self.use_account(account)
|
||||
|
||||
def use_account(self, account: AnixartAccount):
|
||||
if not isinstance(account, AnixartAccount):
|
||||
raise AnixartInitError(f'Use class "AnixartAccount" for user. But {type(account)} given.')
|
||||
self.__account = account
|
||||
self.__account._set_api(self)
|
||||
self.__account.login()
|
||||
|
||||
self.__token = account.token
|
||||
self._session = account.session
|
||||
self._session.headers = {
|
||||
'User-Agent': f'AnixartPyAPI/{__version__}-{__build__} (Linux; Android 15; AnixartPyAPI Build/{__build__})'
|
||||
}
|
||||
|
||||
@property
|
||||
def account(self):
|
||||
return self.__account
|
||||
|
||||
def __parse_response(self, res: requests.Response):
|
||||
if debug:
|
||||
print(f"[D] -> {res.request.method} body='{res.request.body!s}' url='{res.url!s}'")
|
||||
print(f"[D] <- {res.status_code}, {len(res.text)=}")
|
||||
if res.status_code != 200:
|
||||
e = AnixartAPIRequestError("Bad Request: Invalid request parameters.")
|
||||
e.message = (
|
||||
f"Bad Request: Invalid request parameters.\n"
|
||||
f"Request: {res.request.method} {res.url}\n"
|
||||
f"Status code: {res.status_code}\n"
|
||||
f"Response: {res.text}\n"
|
||||
f"Client headers: {self._session.headers}\n"
|
||||
f"Client payload: {res.request.body}"
|
||||
)
|
||||
e.code = 400
|
||||
raise e
|
||||
if not res.text:
|
||||
raise AnixartAPIError("AnixartAPI send unknown error: Empty response. Is provided data correct?")
|
||||
try:
|
||||
response = res.json()
|
||||
except ValueError as e:
|
||||
raise AnixartAPIError("Failed to parse JSON response")
|
||||
|
||||
if debug:
|
||||
print(response)
|
||||
if response['code'] != 0:
|
||||
code = response['code']
|
||||
if code in AnixartApiErrors:
|
||||
e = AnixartAPIError(f"AnixartAPI send error: {AnixartApiErrors(code).name}")
|
||||
e.message = AnixartApiErrors(code).name
|
||||
else:
|
||||
e = AnixartAPIError(f"AnixartAPI send unknown error, code: {response['code']}")
|
||||
e.code = response['code']
|
||||
raise e
|
||||
|
||||
return response
|
||||
|
||||
def _post(self, method: str, _json: bool = False, **kwargs):
|
||||
url = self.API_URL + method
|
||||
kwargs["token"] = kwargs.get("token", self.__token)
|
||||
if kwargs["token"]:
|
||||
url += f"?token={self.__token}"
|
||||
|
||||
req_settings = {"url": url}
|
||||
if _json:
|
||||
self._session.headers["Content-Type"] = "application/json; charset=UTF-8"
|
||||
req_settings.update({"json": kwargs})
|
||||
else:
|
||||
req_settings.update({"data": kwargs})
|
||||
res = self._session.post(**req_settings)
|
||||
return self.__parse_response(res)
|
||||
|
||||
def _get(self, method: str, **kwargs):
|
||||
if self.__token:
|
||||
kwargs["token"] = kwargs.get("token", self.__token)
|
||||
|
||||
res = self._session.get(self.API_URL + method, params=kwargs)
|
||||
return self.__parse_response(res)
|
||||
|
||||
def execute(self, http_method, endpoint, **kwargs):
|
||||
http_method = http_method.upper()
|
||||
if http_method == "GET":
|
||||
return self._get(endpoint, **kwargs)
|
||||
elif http_method == "POST":
|
||||
return self._post(endpoint, **kwargs)
|
||||
else:
|
||||
raise AnixartAPIRequestError("Allow only GET and POST requests.")
|
||||
|
||||
def get(self, endpoint, *args, **kwargs):
|
||||
return self.execute("GET", endpoint.format(*args), **kwargs)
|
||||
|
||||
def post(self, endpoint, *args, **kwargs):
|
||||
return self.execute("POST", endpoint.format(*args), **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f'AnixartAPI(account={self.__account!r})'
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self}>"
|
||||
@@ -1,78 +0,0 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from ..auth import AnixartAuth
|
||||
from ..errors import AnixartInitError, AnixartAPIRequestError
|
||||
from ..request_handler import AnixartRequestsHandler
|
||||
|
||||
_log_name = "file:%-29s -> %s" % ("<anixart.api:%-4i>", "%s")
|
||||
|
||||
|
||||
class AnixartUserAccount:
|
||||
def __init__(self, login, password, config_file="anixart_data.json", **kwargs):
|
||||
self.kwargs = kwargs
|
||||
log_level = logging.CRITICAL
|
||||
log_format = '[%(name)-43s] %(levelname)-5s: %(message)s'
|
||||
if kwargs.get("loglevel") is not None:
|
||||
log_level = kwargs.get("loglevel")
|
||||
if kwargs.get("logformat") is not None:
|
||||
log_format = kwargs.get("logformat")
|
||||
logging.basicConfig(level=log_level, format=log_format)
|
||||
init_log = logging.getLogger("anixart.api.AnixUserAccount")
|
||||
init_log.debug(_log_name, 23, "__init__ - INIT")
|
||||
self.login = login
|
||||
self.password = password
|
||||
if not isinstance(login, str) or not isinstance(password, str):
|
||||
raise AnixartInitError("Use normal auth data. In string.")
|
||||
self.token = None
|
||||
self.id = None
|
||||
self.config_file = config_file
|
||||
self.session = requests.Session()
|
||||
init_log.debug(_log_name, 32, f"{str(self)}")
|
||||
init_log.debug(_log_name, 33, "__init__() - OK")
|
||||
|
||||
def __str__(self):
|
||||
return f'AnixartUserAccount(login="{self.login}", password="{self.password}", ' \
|
||||
f'config_file="{self.config_file}", kwargs="{self.kwargs}")'
|
||||
|
||||
def get_login(self):
|
||||
return self.login
|
||||
|
||||
def get_password(self):
|
||||
return self.password
|
||||
|
||||
def get_token(self):
|
||||
return self.token
|
||||
|
||||
def get_id(self):
|
||||
return self.id
|
||||
|
||||
|
||||
class AnixartAPI:
|
||||
|
||||
def __init__(self, user: AnixartUserAccount):
|
||||
init_log = logging.getLogger("anixart.api.AnixartAPI")
|
||||
init_log.debug(_log_name, 56, "__init__ - INIT")
|
||||
if not isinstance(user, AnixartUserAccount):
|
||||
init_log.critical('Use anixart.api.AnixartUserAccount for user.')
|
||||
raise AnixartInitError('Use class "AnixartUserAccount" for user.')
|
||||
self.auth = AnixartAuth(user)
|
||||
if user.token is None or user.id is None:
|
||||
init_log.debug(_log_name, 62, "Singing in..")
|
||||
self.auth.sing_in()
|
||||
self.user = user
|
||||
init_log.debug(_log_name, 65, "__init__ - OK.")
|
||||
self.http_handler = AnixartRequestsHandler(user.token, user.session)
|
||||
|
||||
def __str__(self):
|
||||
return f'AnixAPI({self.user})'
|
||||
|
||||
def execute(self, http_method, endpoint, **kwargs):
|
||||
http_method = http_method.upper()
|
||||
if http_method == "GET":
|
||||
return self.http_handler.get(endpoint, **kwargs)
|
||||
elif http_method == "POST":
|
||||
return self.http_handler.post(endpoint, **kwargs)
|
||||
else:
|
||||
raise AnixartAPIRequestError("Allow only GET and POST requests.")
|
||||
@@ -1,65 +0,0 @@
|
||||
import requests
|
||||
|
||||
from ..auth import AnixartAuth
|
||||
from ..request_handler import AnixartRequestsHandler
|
||||
|
||||
|
||||
class AnixartUserAccount:
|
||||
def __init__(self, login, password, config_file="anixart_data.json", **kwargs):
|
||||
"""
|
||||
Info:
|
||||
Anixart login class object.
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Usage:
|
||||
>>> user = AnixartUserAccount("login", "password", config_file="anixart_data.json")
|
||||
>>> print(user.login)
|
||||
Availible params:
|
||||
~~~~~~~~~~~~~~~~~
|
||||
* login -> Your anixart nick
|
||||
* password -> Your anixart password
|
||||
* need_reg -> If you need new account, set True
|
||||
* mail -> Real email for registration.
|
||||
* config_file -> Patch to anixart login cache
|
||||
:param login: Your anixart nick
|
||||
:param password: Anixart password
|
||||
:param need_reg: If you need new account, set True
|
||||
:param email: Real email for registration
|
||||
:param config_file: Patch to anixart login cache
|
||||
:type login: str
|
||||
:type password: str
|
||||
:type need_reg: bool
|
||||
:type email: str
|
||||
:type config_file: str
|
||||
:return: :class:`AnixUserAccount <anixart.api.AnixUserAccount>` object
|
||||
"""
|
||||
self.kwargs: dict = kwargs
|
||||
self.login: str = login
|
||||
self.password: str = password
|
||||
self.config_file: str = config_file
|
||||
self.token: str = None
|
||||
self.id: int = None
|
||||
self.session: requests.Session = requests.Session()
|
||||
|
||||
def __str__(self) -> str: ...
|
||||
def get_login(self) -> str: ...
|
||||
def get_password(self) -> str: ...
|
||||
def get_token(self) -> str: ...
|
||||
def get_id(self) -> int: ...
|
||||
|
||||
class AnixartAPI:
|
||||
def __init__(self, user: AnixartUserAccount):
|
||||
"""
|
||||
Info:
|
||||
Anixart API class object.
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Usage:
|
||||
>>> user = AnixartUserAccount("login", "password", config_file="anixart_data.json")
|
||||
>>> anix = AnixartAPI(user)
|
||||
:param user: :class:`AnixUserAccount <anixart.api.AnixUserAccount>` object
|
||||
:return: :class:`AnixAPIRequests <anixart.api.AnixAPIRequests>` object
|
||||
"""
|
||||
self.auth = AnixartAuth(user)
|
||||
self.user = user
|
||||
self.http_handler = AnixartRequestsHandler(user.token, user.session)
|
||||
def __str__(self) -> str: ...
|
||||
def execute(self, http_method: str, endpoint: str) -> requests.Request: ...
|
||||
@@ -1,67 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os.path
|
||||
|
||||
from .endpoints import SING_IN, CHANGE_PASSWORD, PROFILE
|
||||
from .errors import AnixartAuthError
|
||||
from .request_handler import AnixartRequestsHandler
|
||||
|
||||
|
||||
def _parse_response(data):
|
||||
ready = data.json()
|
||||
ready.update({"status_code": data.status_code})
|
||||
code = ready['code']
|
||||
if code != 0:
|
||||
if code == 2:
|
||||
raise AnixartAuthError("Incorrect login.")
|
||||
if code == 3:
|
||||
raise AnixartAuthError("Incorrect password.")
|
||||
print("\n\n" + data.text + "\n\n")
|
||||
raise AnixartAuthError("Unknown auth error.")
|
||||
return ready
|
||||
|
||||
|
||||
class AnixartAuth(AnixartRequestsHandler):
|
||||
|
||||
def __init__(self, user):
|
||||
super(AnixartAuth, self).__init__(None, user.session, "anixart.auth.AnixAuth")
|
||||
self.user = user
|
||||
self.filename = user.config_file
|
||||
|
||||
def _save_config(self, data):
|
||||
with open(self.filename, "w") as f:
|
||||
json.dump(data, f)
|
||||
return data
|
||||
|
||||
def _open_config(self):
|
||||
if os.path.isfile(self.filename):
|
||||
with open(self.filename, "r") as read_file:
|
||||
data = json.load(read_file)
|
||||
return data
|
||||
else:
|
||||
return None
|
||||
|
||||
def sing_in(self):
|
||||
config = self._open_config()
|
||||
if config:
|
||||
uid = config.get("id")
|
||||
token = config.get("token")
|
||||
if not self.get(PROFILE.format(uid), {"token": token}).json().get("is_my_profile") or \
|
||||
self.user.login != config.get("login"):
|
||||
logging.getLogger("anixart.api.AnixAPI").debug("Invalid config file. Re login.")
|
||||
else:
|
||||
self.user.id = uid
|
||||
self.user.token = token
|
||||
return config
|
||||
payload = {"login": self.user.login, "password": self.user.password}
|
||||
res = self.post(SING_IN, payload)
|
||||
ready = _parse_response(res)
|
||||
uid = ready["profile"]["id"]
|
||||
token = ready["profileToken"]["token"]
|
||||
self.user.id = uid
|
||||
self.user.token = token
|
||||
self._save_config({"id": uid, "token": token, "login": self.user.login})
|
||||
return ready
|
||||
|
||||
def change_password(self, old, new):
|
||||
return self.get(CHANGE_PASSWORD, {"current": old, "new": new, "token": self.user.token})
|
||||
1
anixart/auth/__init__.py
Normal file
1
anixart/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .account import AnixartAccount, AnixartAccountSaved, AnixartAccountGuest
|
||||
125
anixart/auth/account.py
Normal file
125
anixart/auth/account.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from anixart import endpoints
|
||||
from anixart.exceptions import AnixartInitError
|
||||
|
||||
|
||||
class AnixartAccount:
|
||||
guest = False
|
||||
def __init__(self, username: str, password: str):
|
||||
self._username = username
|
||||
self._password = password
|
||||
if not isinstance(username, str) or not isinstance(password, str):
|
||||
raise AnixartInitError("Auth data must be strings.")
|
||||
|
||||
self._id = None
|
||||
self._token = None
|
||||
self._session = requests.Session()
|
||||
|
||||
self._api = None
|
||||
|
||||
def _set_api(self, api):
|
||||
self._api = api
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
return self._username
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
return self._token
|
||||
|
||||
def to_file(self, filename: str | Path) -> "AnixartAccountSaved":
|
||||
"""Save the account information to a file."""
|
||||
acc = AnixartAccountSaved.from_account(filename, self)
|
||||
acc.save()
|
||||
return acc
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename: str | Path) -> "AnixartAccountSaved":
|
||||
"""Load the account information from a file."""
|
||||
acc = AnixartAccountSaved(filename)
|
||||
acc.load()
|
||||
return acc
|
||||
|
||||
def login(self):
|
||||
"""Login to Anixart and return the token."""
|
||||
res = self._api.post(endpoints.SING_IN, login=self.username, password=self._password)
|
||||
uid = res["profile"]["id"]
|
||||
token = res["profileToken"]["token"]
|
||||
self._id = uid
|
||||
self._token = token
|
||||
|
||||
def __str__(self):
|
||||
return f'AnixartAccount(login={self._username!r}, password={"*" * len(self._password)!r})'
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self}>"
|
||||
|
||||
class AnixartAccountSaved(AnixartAccount):
|
||||
|
||||
def __init__(self, account_file: str | Path = "anixart_account.json"):
|
||||
super().__init__("", "")
|
||||
self._file = Path(account_file)
|
||||
|
||||
def save(self):
|
||||
"""Save the account information to a file."""
|
||||
data = {
|
||||
"id": self._id,
|
||||
"token": self._token
|
||||
}
|
||||
with open(self._file, 'w') as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
def load(self):
|
||||
"""Load the account information from a file."""
|
||||
if not self._file.exists():
|
||||
raise AnixartInitError(f"Account file {self._file} does not exist.")
|
||||
with open(self._file, 'r') as f:
|
||||
data = json.load(f)
|
||||
self._id = data.get("id")
|
||||
self._username = data.get("username")
|
||||
self._token = data.get("token")
|
||||
if not self._id or not self._token:
|
||||
raise AnixartInitError("id and token must be provided in the account file.")
|
||||
|
||||
@classmethod
|
||||
def from_account(cls, account_file: str | Path, account: AnixartAccount):
|
||||
c = cls(account_file)
|
||||
c._username = account.username
|
||||
c._password = account._password
|
||||
c._token = account.token
|
||||
return c
|
||||
|
||||
def login(self):
|
||||
"""Login to Anixart using the saved credentials."""
|
||||
# Проверяем токен, если просрочен, то логинимся
|
||||
# Если токен валиден, то просто дальше работаем
|
||||
# TODO: Implement login logic here
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return f'AnixartAccountSaved(account_file={self._file!r}")'
|
||||
|
||||
class AnixartAccountGuest(AnixartAccount):
|
||||
guest = True
|
||||
def __init__(self):
|
||||
super().__init__("", "")
|
||||
self._token = ""
|
||||
|
||||
def login(self): ...
|
||||
|
||||
def __str__(self):
|
||||
return f'AnixartAccountGuest(token={self._token!r}")'
|
||||
|
||||
11
anixart/auth/error_handler.py
Normal file
11
anixart/auth/error_handler.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class AnixartAuthErrors(IntEnum):
|
||||
INCORRECT_LOGIN = 2
|
||||
INCORRECT_PASSWORD = 3
|
||||
|
||||
|
||||
def errors_handler(error):
|
||||
"""Handle errors and return a JSON response."""
|
||||
pass
|
||||
7
anixart/auth/objects.py
Normal file
7
anixart/auth/objects.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfileToken:
|
||||
id: int
|
||||
token: str
|
||||
@@ -1,29 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
class AnixartComment:
|
||||
DISLIKE = 1
|
||||
LIKE = 2
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Any
|
||||
|
||||
|
||||
class AnixartProfileVotedSort:
|
||||
LAST_FIRST = 1
|
||||
OLD_FIRST = 2
|
||||
STAR_5 = 3
|
||||
STAR_4 = 4
|
||||
STAR_3 = 5
|
||||
STAR_2 = 6
|
||||
STAR_1 = 7
|
||||
@dataclass
|
||||
class Endpoint:
|
||||
path: str
|
||||
method: Literal["GET", "POST"]
|
||||
required_args: dict[str, type] = field(default_factory=dict)
|
||||
|
||||
_json = False
|
||||
_API_ENDPOINT = "https://api.anixart.tv/"
|
||||
|
||||
class AnixartLists:
|
||||
WATCHING = 1
|
||||
IN_PLANS = 2
|
||||
WATCHED = 3
|
||||
POSTPONED = 4
|
||||
DROPPED = 5
|
||||
def __post_init__(self):
|
||||
if self.method not in ["GET", "POST"]:
|
||||
raise ValueError("Method must be either GET or POST.")
|
||||
if not isinstance(self.required_args, dict):
|
||||
raise ValueError("Required arguments must be a dictionary.")
|
||||
if not all(isinstance(v, type) for v in self.required_args.values()):
|
||||
raise ValueError("All values in required arguments must be types.")
|
||||
|
||||
def _post(self, method: str, **kwargs):
|
||||
headers = {}
|
||||
url = self._API_ENDPOINT + method
|
||||
if token:=kwargs.get("token"):
|
||||
kwargs["token"] = token
|
||||
url += f"?token={token}"
|
||||
|
||||
req_settings = {"url": url}
|
||||
if self._json:
|
||||
headers["Content-Type"] = "application/json; charset=UTF-8"
|
||||
req_settings.update({"json": kwargs})
|
||||
else:
|
||||
req_settings.update({"data": kwargs})
|
||||
return req_settings, headers
|
||||
|
||||
def _get(self, method: str, **kwargs):
|
||||
headers = {}
|
||||
url = self._API_ENDPOINT + method
|
||||
if token:=kwargs.get("token"):
|
||||
kwargs["token"] = token
|
||||
req_settings = {"url": url, "params": kwargs}
|
||||
return req_settings, headers
|
||||
|
||||
def _check_arguments(self, **kwargs):
|
||||
"""Check if all required arguments are present."""
|
||||
missing_args = [] # (arg, reason)
|
||||
for arg, arg_type in self.required_args.items():
|
||||
if arg not in kwargs:
|
||||
missing_args.append((arg, "missing"))
|
||||
elif not isinstance(kwargs[arg], arg_type):
|
||||
missing_args.append((arg, f"invalid type: {type(kwargs[arg])}"))
|
||||
if missing_args:
|
||||
pretty_args = ", ".join(f"{arg} ({reason})" for arg, reason in missing_args)
|
||||
raise ValueError(f"Missing or invalid arguments: {pretty_args}")
|
||||
|
||||
def build_request(self, **kwargs) -> tuple[dict[str, dict[str, Any] | str], dict[str, str]]:
|
||||
"""
|
||||
Build the request for the endpoint.
|
||||
:param kwargs: Arguments to be passed to the endpoint.
|
||||
:return: A tuple containing the HTTP method, headers, and request settings.
|
||||
"""
|
||||
self._check_arguments(**kwargs)
|
||||
if self.method == "POST":
|
||||
return self._post(self.path, **kwargs)
|
||||
if self.method == "GET":
|
||||
return self._get(self.path, **kwargs)
|
||||
|
||||
def endpoint(path: str, method: Literal["GET", "POST"], required_args: dict[str, type]) -> Endpoint:
|
||||
return Endpoint(path, method, required_args)
|
||||
|
||||
class AnixartAuthEndpoints:
|
||||
"""Anixart API authentication endpoints."""
|
||||
login = endpoint("/auth/signIn", "POST", {"login": str, "password": str})
|
||||
|
||||
class AnixartEndpoints:
|
||||
"""Anixart API endpoints."""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
API_URL = "https://api.anixart.tv"
|
||||
|
||||
# ----------- # AUTH # ----------- #
|
||||
|
||||
@@ -42,8 +98,8 @@ _AUTH_SING_IN_WITH_VK = "/auth/vk" # {vkAccessToken}
|
||||
# SETTINGS_RELEASE_TYPE
|
||||
|
||||
# GET
|
||||
PROFILE = "/profile/{}" # + profile id
|
||||
PROFILE_NICK_HISTORY = "/profile/login/history/all/{}/{}" # profile id / page
|
||||
PROFILE = "/profile/{}" # + profile id (Токен нужен только что бы был is_my_profile)
|
||||
PROFILE_NICK_HISTORY = "/profile/login/history/all/{}/{}" # profile id / page (Токен не нужен)
|
||||
|
||||
PROFILE_BLACKLIST = "/profile/blocklist/all/{}" # page
|
||||
PROFILE_BLACKLIST_ADD = "/profile/blocklist/add/{}" # profile id
|
||||
|
||||
28
anixart/enums.py
Normal file
28
anixart/enums.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from enum import IntEnum
|
||||
|
||||
class AnixartApiErrors(IntEnum):
|
||||
""" Error codes for AnixartApi authentication."""
|
||||
INCORRECT_LOGIN = 2
|
||||
INCORRECT_PASSWORD = 3
|
||||
|
||||
class AnixartComment(IntEnum):
|
||||
DISLIKE = 1
|
||||
LIKE = 2
|
||||
|
||||
|
||||
class AnixartProfileVotedSort(IntEnum):
|
||||
LAST_FIRST = 1
|
||||
OLD_FIRST = 2
|
||||
STAR_5 = 3
|
||||
STAR_4 = 4
|
||||
STAR_3 = 5
|
||||
STAR_2 = 6
|
||||
STAR_1 = 7
|
||||
|
||||
|
||||
class AnixartLists(IntEnum):
|
||||
WATCHING = 1
|
||||
IN_PLANS = 2
|
||||
WATCHED = 3
|
||||
POSTPONED = 4
|
||||
DROPPED = 5
|
||||
@@ -1,16 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
class AnixartInitError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AnixartAuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AnixartAPIRequestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AnixartAPIError(Exception):
|
||||
pass
|
||||
17
anixart/exceptions.py
Normal file
17
anixart/exceptions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
class AnixartBasError(Exception): ...
|
||||
|
||||
# Init errors
|
||||
|
||||
class AnixartInitError(AnixartBasError, TypeError): ...
|
||||
|
||||
# API errors
|
||||
class AnixartAPIError(AnixartBasError):
|
||||
message = "unknown error"
|
||||
code = 0
|
||||
|
||||
class AnixartAuthError(AnixartAPIError): ...
|
||||
|
||||
class AnixartAPIRequestError(AnixartAPIError): ...
|
||||
|
||||
1
anixart/profile/__init__.py
Normal file
1
anixart/profile/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .objects import Profile, ProfileFull, ProfileVote, ProfileRoles, ProfileHistory, ProfileFriendsPreview
|
||||
11
anixart/profile/error_handler.py
Normal file
11
anixart/profile/error_handler.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class AnixartProfileErrors(IntEnum):
|
||||
""" Error codes for AnixartApi authentication."""
|
||||
PROFILE_NOT_FOUND = 2
|
||||
|
||||
|
||||
def errors_handler(error):
|
||||
"""Handle errors and return a JSON response."""
|
||||
pass
|
||||
118
anixart/profile/objects.py
Normal file
118
anixart/profile/objects.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ProfileHistory:
|
||||
# TODO: Надо ещё изучить релизы
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class ProfileVote:
|
||||
# TODO: Надо ещё изучить релизы
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class ProfileRoles:
|
||||
# TODO: Надо ещё изучить роли (У меня их нет(()
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
id: int
|
||||
login: str
|
||||
avatar: str
|
||||
|
||||
@dataclass
|
||||
class ProfileFriendsPreview(Profile):
|
||||
friend_count: int
|
||||
friend_status: int
|
||||
is_sponsor: bool
|
||||
is_online: bool
|
||||
is_verified: bool
|
||||
is_social: bool
|
||||
badge_id: None
|
||||
badge_name: None
|
||||
badge_type: None
|
||||
badge_url: None
|
||||
|
||||
@dataclass
|
||||
class ProfileFull(Profile):
|
||||
status: str
|
||||
rating_score: int
|
||||
history: list[ProfileHistory]
|
||||
votes: list[ProfileVote]
|
||||
roles: list[ProfileRoles]
|
||||
friends_preview: list[ProfileFriendsPreview]
|
||||
collections_preview: list # TODO: Коллекции изучить
|
||||
release_comments_preview: list # Тут вообще не понял
|
||||
comments_preview: list # Тут типа превью к коментам
|
||||
release_videos_preview: list # Тут вообще не понял
|
||||
watch_dynamics: list # Тут вообще не понял
|
||||
last_activity_time: int
|
||||
register_date: int
|
||||
vk_page: str
|
||||
tg_page: str
|
||||
inst_page: str
|
||||
tt_page: str
|
||||
discord_page: str
|
||||
ban_expires: int
|
||||
ban_reason: str
|
||||
privilege_level: int
|
||||
watching_count: int
|
||||
plan_count: int
|
||||
completed_count: int
|
||||
hold_on_count: int
|
||||
dropped_count: int
|
||||
favorite_count: int
|
||||
comment_count: int
|
||||
collection_count: int
|
||||
video_count: int
|
||||
friend_count: int
|
||||
subscription_count: int
|
||||
watched_episode_count: int
|
||||
watched_time: int
|
||||
sponsorshipExpires: int
|
||||
is_private: bool
|
||||
is_sponsor: bool
|
||||
is_banned: bool
|
||||
is_perm_banned: bool
|
||||
is_bookmarks_transferred: bool
|
||||
is_sponsor_transferred: bool
|
||||
is_vk_bound: bool
|
||||
is_google_bound: bool
|
||||
is_release_type_notifications_enabled: bool
|
||||
is_episode_notifications_enabled: bool
|
||||
is_first_episode_notification_enabled: bool
|
||||
is_related_release_notifications_enabled: bool
|
||||
is_report_process_notifications_enabled: bool
|
||||
is_comment_notifications_enabled: bool
|
||||
is_my_collection_comment_notifications_enabled: bool
|
||||
is_my_article_comment_notifications_enabled: bool
|
||||
is_verified: bool
|
||||
is_blocked: bool
|
||||
is_me_blocked: bool
|
||||
is_stats_hidden: bool
|
||||
is_counts_hidden: bool
|
||||
is_social_hidden: bool
|
||||
is_friend_requests_disallowed: bool
|
||||
is_online: bool
|
||||
badge: None
|
||||
friend_status: None
|
||||
|
||||
is_my_profile: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_response(cls, response: dict) -> "Profile":
|
||||
profile = {"is_my_profile": response['is_my_profile'], **response['profile']}
|
||||
return cls(**profile)
|
||||
|
||||
class _login:
|
||||
pos_id: int
|
||||
id: int
|
||||
newLogin: str
|
||||
timestamp: int
|
||||
|
||||
class ProfileLoginsHistory:
|
||||
content: list[_login]
|
||||
total_count: int
|
||||
# total_page_count: int
|
||||
# current_page: int
|
||||
@@ -1,91 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from .__version__ import __version__, __build__
|
||||
from .endpoints import API_URL
|
||||
from .errors import AnixartAPIRequestError, AnixartAPIError
|
||||
_log_name = "file:%-28s -> %s" % ("<Anixart.request_handler:%-3i>", "%s")
|
||||
|
||||
|
||||
def _parse_res_code(res, payload, http_method, http_headers):
|
||||
json = res.json()
|
||||
error = json.get("error")
|
||||
code = json.get("code")
|
||||
if res.status_code >= 400:
|
||||
raise AnixartAPIRequestError(f"\n\nAnixartPyAPIWrapper: ERROR\n"
|
||||
f"Send this info to author: https://t.me/SantaSpeen\n"
|
||||
f"URL: {http_method} {res.url}\n"
|
||||
f"Status code: {res.status_code}\n"
|
||||
f"Res headers: {res.headers}\n"
|
||||
f"Req headers: {http_headers}\n"
|
||||
f"Server res: {json}\n"
|
||||
f"Client req: {payload}\n")
|
||||
if error:
|
||||
raise AnixartAPIRequestError(f"Internal server error: {error}; Payload: {payload}")
|
||||
if code:
|
||||
if code == 0:
|
||||
return
|
||||
else:
|
||||
raise AnixartAPIError(f"AnixartAPI send error code: {code}; Json: {json}")
|
||||
|
||||
|
||||
class AnixartRequestsHandler:
|
||||
|
||||
def __init__(self, token: str = None, session: requests.Session = None,
|
||||
_log_class="Anixart.request_handler.AnixartRequestsHandler"):
|
||||
self.__log = logging.getLogger(_log_class)
|
||||
self.__log.debug(_log_name, 44, f"__init__ - INIT from {self}")
|
||||
if session:
|
||||
self.__session = session
|
||||
else:
|
||||
self.__log.debug(_log_name, 48, "Create new session.")
|
||||
self.__session = requests.Session()
|
||||
self.__session.headers = {
|
||||
'User-Agent': f'AnixartPyAPIWrapper/{__version__}-{__build__}'
|
||||
f' (Linux; Android 12; SantaSpeen AnixartPyAPIWrapper Build/{__build__})'}
|
||||
self.__token = token
|
||||
|
||||
def post(self, method: str, payload: dict = None, is_json: bool = False, **kwargs):
|
||||
if payload is None:
|
||||
payload = {}
|
||||
url = API_URL + method
|
||||
if payload.get("token") is None:
|
||||
if self.__token is not None:
|
||||
payload.update({"token": self.__token})
|
||||
url += "?token=" + self.__token
|
||||
else:
|
||||
token = kwargs.get("token")
|
||||
if token is not None:
|
||||
payload.update({"token": token})
|
||||
url += "?token=" + token
|
||||
kwargs = {"url": url}
|
||||
if is_json:
|
||||
self.__session.headers.update({"Content-Type": "application/json; charset=UTF-8"})
|
||||
self.__session.headers.update({"Content-Length": str(len(str(payload)))})
|
||||
kwargs.update({"json": payload})
|
||||
else:
|
||||
kwargs.update({"data": payload})
|
||||
self.__log.debug(_log_name, 79, f"{'json' if is_json else ''} POST {method}; {payload}")
|
||||
res = self.__session.post(**kwargs)
|
||||
_parse_res_code(res, payload, "POST", self.__session.headers)
|
||||
self.__session.headers["Content-Type"] = ""
|
||||
self.__session.headers["Content-Length"] = ""
|
||||
return res
|
||||
|
||||
def get(self, method: str, payload: dict = None, **kwargs):
|
||||
if payload is None:
|
||||
payload = {}
|
||||
if payload.get("token") is None:
|
||||
if self.__token is not None:
|
||||
payload.update({"token": self.__token})
|
||||
else:
|
||||
token = kwargs.get("token")
|
||||
if token is not None:
|
||||
payload.update({"token": token})
|
||||
self.__log.debug(_log_name, 101, f"GET {method}; {payload}")
|
||||
res = self.__session.get(API_URL + method, params=payload)
|
||||
_parse_res_code(res, payload, "GET", self.__session.headers)
|
||||
return res
|
||||
@@ -2,8 +2,31 @@
|
||||
|
||||
## Anixart API Wrapper
|
||||
|
||||
### 02.04.2025
|
||||
#### Version: 0.3.0.1
|
||||
|
||||
##### Changes:
|
||||
|
||||
* Глобальная переборка всего проекта по `SDK-like` принципу
|
||||
* Переработан блок `API`:
|
||||
* `AnixartAPI` - Теперь является основным классом для работы с API.
|
||||
* Добавлен блок `auth`:
|
||||
* Работа с аккаунтом:
|
||||
* `AnixartAccount` - Основной класс взаимодействия с аккаунтом (логин, пароль)
|
||||
* `AnixartAccountSaved` - Работа с сохранённым аккаунтом
|
||||
* `AnixartAccountGuest` - Авторизация без логина и пароля (Как гость)
|
||||
* Добавлены ошибки
|
||||
* `INCORRECT_LOGIN` - Неверный логин
|
||||
* `INCORRECT_PASSWORD` - Неверный пароль
|
||||
* Добавлен блок `profile`:
|
||||
* Добавлены объекты для работы с `/profile/`:
|
||||
* `Profile` - ДатаКласс для работы с профилем (`endpoints.PROFILE`) __не закончены подклассы__
|
||||
* `ProfileLoginsHistory` - ДатаКласс для работы с историей логинов (`endpoints.PROFILE_NICK_HISTORY`) __не закончен__
|
||||
* Добавлены ошибки
|
||||
* `PROFILE_NOT_FOUND` - Профиль не найден (или невалидный)
|
||||
|
||||
### 28.09.2022
|
||||
#### Version: 0.2.1, Build: 3
|
||||
#### Version: 0.2.1
|
||||
|
||||
##### Changes:
|
||||
|
||||
@@ -29,8 +52,7 @@ _Из прошлых версий_
|
||||
- Выявить и удалить не используемые
|
||||
|
||||
### 27.09.2022
|
||||
[//]: # ( Да, я не билдил, это не ошибка )
|
||||
#### Version: 0.1.0, Build: 1
|
||||
#### Version: 0.1.0
|
||||
|
||||
##### Changes:
|
||||
|
||||
@@ -46,7 +68,7 @@ _Из прошлых версий_
|
||||
|
||||
|
||||
### 27.09.2022
|
||||
#### Version: 0.0.1, Build: 1
|
||||
#### Version: 0.0.1
|
||||
|
||||
##### Changes:
|
||||
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
* 2
|
||||
* ...
|
||||
3. [CHANGELOG](./CHANGELOG.md)
|
||||
4. [LICENSE](./License.md)
|
||||
4. [LICENSE](./LICENSE.md)
|
||||
@@ -1,9 +1,18 @@
|
||||
from anixart import AnixartAPI, AnixartUserAccount, PROFILE
|
||||
from anixart import AnixartAPI, AnixartAccount
|
||||
from anixart import endpoints
|
||||
from anixart.exceptions import AnixartAPIRequestError
|
||||
from anixart.profile import Profile
|
||||
|
||||
user = AnixartUserAccount("SantaSpeen", "I_H@ve_Very_Secret_P@ssw0rd!")
|
||||
anix = AnixartAPI(user)
|
||||
anix = AnixartAPI() # По умолчанию используется гость
|
||||
|
||||
# acc = AnixartAccount("SantaSpeen", "I_H@ve_Very_Secret_P@ssw0rd!")
|
||||
# # id у аккаунта появляется только после
|
||||
# anix.use_account(acc)
|
||||
|
||||
if __name__ == '__main__':
|
||||
me = anix.execute("GET", PROFILE.format(user.id))
|
||||
print(me.json())
|
||||
try:
|
||||
raw = anix.get(endpoints.PROFILE, 1)
|
||||
print(Profile.from_response(raw))
|
||||
except AnixartAPIRequestError as e:
|
||||
print(e.message)
|
||||
print(e.code)
|
||||
|
||||
3
examples/readme.md
Normal file
3
examples/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## Директория с примерами
|
||||
|
||||
* Пример авторизации и вывода информации о себе: [auth.py](./auth.py)
|
||||
3
poetry.toml
Normal file
3
poetry.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[virtualenvs]
|
||||
create = true
|
||||
in-project = true
|
||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "anixart"
|
||||
version = "0.3.0.1"
|
||||
description = "Wrapper for using the Anixart API."
|
||||
authors = [
|
||||
{name = "SantaSpeen",email = "santaspeen@gmail.com"}
|
||||
]
|
||||
license = {text = "FPA License"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<4.0"
|
||||
dependencies = ["requests (>=2.32.3,<3.0.0)"]
|
||||
dynamic = [ "classifiers" ]
|
||||
|
||||
[project.urls]
|
||||
repository = "https://github.com/SantaSpeen/anixart"
|
||||
|
||||
[tool.poetry]
|
||||
classifiers = [
|
||||
"Development Status :: 2 - Pre-Alpha",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
@@ -1 +0,0 @@
|
||||
requests~=2.28.1
|
||||
58
setup.py
58
setup.py
@@ -1,58 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
packages = ['anixart']
|
||||
|
||||
requires = ['requests']
|
||||
|
||||
# 'setup.py publish' shortcut.
|
||||
if sys.argv[-1] == 'publish':
|
||||
os.system('py -m build')
|
||||
os.system('py -m twine upload --repository testpypi dist/*')
|
||||
os.system('py -m twine upload --repository pypi dist/*')
|
||||
sys.exit()
|
||||
|
||||
about = {}
|
||||
with open(os.path.join(here, 'anixart', '__version__.py'), 'r', encoding='utf-8') as f:
|
||||
exec(f.read(), about)
|
||||
|
||||
with open('README.md', 'r', encoding='utf-8') as f:
|
||||
readme = f.read()
|
||||
|
||||
setup(
|
||||
name=about['__title__'],
|
||||
version=about['__version__'],
|
||||
description=about['__description__'],
|
||||
long_description=readme,
|
||||
long_description_content_type='text/markdown',
|
||||
author=about['__author__'],
|
||||
author_email=about['__author_email__'],
|
||||
url=about['__url__'],
|
||||
packages=packages,
|
||||
package_data={'': ['LICENSE']},
|
||||
package_dir={'anixart': 'anixart'},
|
||||
include_package_data=True,
|
||||
install_requires=requires,
|
||||
license=about['__license__'],
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Natural Language :: Russian",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"License :: Other/Proprietary License",
|
||||
"Operating System :: OS Independent",
|
||||
],
|
||||
project_urls={
|
||||
'Documentation': 'https://anixart.readthedocs.io/',
|
||||
'Source': 'https://github.com/SantaSpeen/anixart',
|
||||
},
|
||||
python_requires=">=3.7",
|
||||
)
|
||||
Reference in New Issue
Block a user