├── anipy ├── ani │ ├── __init__.py │ ├── enum_.py │ ├── browser.py │ ├── list.py │ ├── animeList.py │ ├── serie.py │ └── user.py ├── __init__.py ├── utils.py ├── exception.py └── core.py ├── requirements.txt ├── docs ├── class.dia └── source │ ├── index.rst │ ├── gettingstarted.rst │ └── conf.py ├── test ├── testAnimeList.py ├── __init__.py ├── testBrowser.py ├── testUser.py └── testAuthentication.py ├── .travis.yml ├── .gitignore ├── setup.py ├── LICENSE └── README.md /anipy/ani/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | setuptools 3 | urllib3 4 | urllib3_mock 5 | -------------------------------------------------------------------------------- /docs/class.dia: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syrup/anipy/master/docs/class.dia -------------------------------------------------------------------------------- /test/testAnimeList.py: -------------------------------------------------------------------------------- 1 | from anipy import User 2 | 3 | 4 | class TestAnimeList(object): 5 | 6 | def testGetLists(self): 7 | user = User.principal() 8 | assert user 9 | 10 | 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.5" 4 | env: 5 | CODACY_PROJECT_TOKEN=343d744f6da04a349a4a831eb93c7a00 6 | 7 | install: 8 | - pip install -r requirements.txt 9 | - pip install coveralls codacy-coverage 10 | 11 | script: 12 | - nosetests -q --where=test --with-coverage --cover-package=anipy --cover-xml 13 | - python-codacy-coverage -r test/coverage.xml 14 | 15 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from anipy import ( 4 | AuthenticationProvider, 5 | Authentication 6 | ) 7 | 8 | assert_msg = '\nActual: {actual}\nExpected: {expected}' 9 | 10 | AuthenticationProvider.config( 11 | os.environ.get('CLIENT_ID'), 12 | os.environ.get('CLIENT_SECRET'), 13 | os.environ.get('CLIENT_REDIRECT_URI') 14 | ) 15 | 16 | Authentication.fromRefreshToken(os.environ.get('REFRESH_TOKEN')) 17 | 18 | 19 | def assertDataType(name, obj, cls): 20 | assert isinstance(obj, cls), '{name} ({value}) is {real}, not {expected}.'.format( 21 | name=name, 22 | value=obj, 23 | real=type(obj), 24 | expected=cls) -------------------------------------------------------------------------------- /anipy/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ( 2 | Authentication, 3 | AuthenticationProvider, 4 | GrantType 5 | ) 6 | from .ani.user import User 7 | 8 | from .ani.enum_ import ( 9 | SeriesType, 10 | ListStatus, 11 | Smiley, 12 | Season, 13 | MediaType, 14 | AnimeStatus, 15 | MangaStatus, 16 | SortBy 17 | ) 18 | from .ani.animeList import AnimeListResource 19 | 20 | from .ani.browser import ( 21 | Browser, 22 | Query 23 | ) 24 | 25 | import logging 26 | 27 | logging.basicConfig( 28 | format='%(levelname)s - %(name)s ln.%(lineno)d - %(message)s', 29 | level=logging.INFO) 30 | 31 | logging.getLogger('anipy.ani.browser').setLevel(logging.ERROR) 32 | 33 | logging.getLogger('requests').setLevel(logging.WARNING) 34 | logging.getLogger('urllib3').setLevel(logging.WARNING) 35 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Anipy documentation master file, created by 2 | sphinx-quickstart on Sun Nov 6 23:23:25 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Anipy's docs! 7 | ======================== 8 | 9 | Anipy is a python library that wraps and organize the 10 | `Anilist `__ rest api into modules, classes and 11 | functions so it can be used quick, easy, and right out of the box. You 12 | can take a look at the api `official 13 | docs `__. Anilist is a `Josh 14 | Star `__'s project. 15 | 16 | 17 | Contents: 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | 22 | gettingstarted 23 | core 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | -------------------------------------------------------------------------------- /anipy/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import re 4 | from json.decoder import JSONDecodeError 5 | 6 | _ENCODING = 'utf-8' 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | def underscore_to_camelcase(value): 11 | first, *rest = value.split('_') 12 | return first + ''.join(word.capitalize() for word in rest) 13 | 14 | 15 | def camelcase_to_underscore(value): 16 | s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', value) 17 | return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() 18 | 19 | 20 | def dic_to_json(dic): 21 | # if dic is None: 22 | # dic = {} 23 | return json.dumps(dic).encode(_ENCODING) 24 | 25 | 26 | def response_to_dic(response): 27 | try: 28 | return json.loads(response.data.decode(_ENCODING)) 29 | except JSONDecodeError as e: 30 | logger.error('There was an error decoding the response.', exc_info=True) 31 | return {'error': 'There was an error decoding the response.'} 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | #pycharm files 65 | .idea/ 66 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | def readme(): 5 | with open('README.md') as f: 6 | return f.read() 7 | 8 | 9 | setup(name='anipy', 10 | version='0.1a2', 11 | description='A python library for the Anilist.co API.', 12 | long_description=readme(), 13 | classifiers=[ 14 | 'Development Status :: 3 - Alpha', 15 | 'License :: OSI Approved :: MIT License', 16 | 'Programming Language :: Python :: 3.4', 17 | 'Programming Language :: Python :: 3.5', 18 | 'Topic :: Internet', 19 | 'Topic :: Utilities', 20 | ], 21 | keywords='anipy twissell anime anilist manga', 22 | url='https://github.com/twissell-/anipy', 23 | author='Damian Maggio Esne', 24 | author_email='dmaggioesne@gmail.com', 25 | license='MIT', 26 | packages=['anipy'], 27 | install_requires=[ 28 | 'urllib3', 29 | 'urllib3_mock', 30 | 'requests' 31 | ], 32 | test_suite='nose.collector', 33 | tests_require=['nose'], 34 | include_package_data=True, 35 | zip_safe=False) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Damian Maggio 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. 22 | -------------------------------------------------------------------------------- /test/testBrowser.py: -------------------------------------------------------------------------------- 1 | from . import assert_msg 2 | 3 | from anipy import ( 4 | Browser, 5 | Query 6 | ) 7 | 8 | from anipy.ani.enum_ import ( 9 | Season, 10 | MediaType, 11 | SeriesType, 12 | AnimeStatus, 13 | MangaStatus, 14 | SortBy 15 | ) 16 | 17 | 18 | class TestBrowser(object): 19 | 20 | def testQuery(self): 21 | 22 | expected = { 23 | 'year': 2014, 24 | 'season': 'fall', 25 | 'type': 0, 26 | 'status': 'finished airing', 27 | 'sort': 'popularity-desc', 28 | 'airing_data': False, 29 | 'full_page': False, 30 | 'page': 1 31 | } 32 | 33 | query = Query(SeriesType.anime) 34 | query\ 35 | .year(2014)\ 36 | .season(Season.fall)\ 37 | .type(MediaType.tv)\ 38 | .status(AnimeStatus.finishedAiring)\ 39 | .sort(SortBy.popularity.desc)\ 40 | .airingData(False).fullPage(False).page(1) 41 | 42 | assert query.query == expected, \ 43 | assert_msg.format(actual=query.query, expected=expected) 44 | assert query.serieType == SeriesType.anime, \ 45 | assert_msg.format(actual=query.serieType, expected=SeriesType.anime) 46 | 47 | def testBrowser(self, ): 48 | 49 | browser = Browser() 50 | browser.executeQuery(Query(SeriesType.anime)) 51 | -------------------------------------------------------------------------------- /test/testUser.py: -------------------------------------------------------------------------------- 1 | from anipy import User 2 | from test import assertDataType 3 | 4 | 5 | # TODO: Imporve this tests 6 | class TestUser(object): 7 | 8 | def testGetPrincipal(self): 9 | 10 | user = User.principal() 11 | 12 | assert user 13 | assert user.displayName == 'aCertainSomeone' 14 | assert user.id == 82369 15 | 16 | def testGetUserById(self): 17 | 18 | user = User.byId(82369) 19 | assert user 20 | assert user.id == 82369 21 | 22 | def testGetUserByDisplayName(self): 23 | 24 | user = User.byDisplayName('aCertainSomeone') 25 | assert user 26 | assert user.displayName == 'aCertainSomeone' 27 | 28 | def testUserProperties(self): 29 | user = User.principal() 30 | 31 | assertDataType('user.animeTime', user.animeTime, int) 32 | assertDataType('user.mangaChap', user.mangaChap, int) 33 | assertDataType('user.about', user.about, str) 34 | assertDataType('user.listOrder', user.listOrder, int) 35 | assertDataType('user.adultContent', user.adultContent, bool) 36 | assertDataType('user.following', user.following, bool) 37 | assertDataType('user.imageUrlLge', user.imageUrlLge, str) 38 | assertDataType('user.imageUrlMed', user.imageUrlMed, str) 39 | assertDataType('user.imageUrlBanner', user.imageUrlBanner, str) 40 | assertDataType('user.titleLanguage', user.titleLanguage, str) 41 | assertDataType('user.scoreType', user.scoreType, int) 42 | assertDataType('user.customListAnime', user.customListAnime, list) 43 | assertDataType('user.customListManga', user.customListManga, list) 44 | assertDataType('user.advancedRating', user.advancedRating, bool) 45 | assertDataType('user.advancedRatingNames', user.advancedRatingNames, list) 46 | assertDataType('user.notifications', user.notifications, int) 47 | assertDataType('user.airingNotifications', user.airingNotifications, int) 48 | assertDataType('user.stats', user.stats, dict) 49 | -------------------------------------------------------------------------------- /anipy/ani/enum_.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class SeriesType(Enum): 5 | """Enumeration for entry's list status.""" 6 | manga = 'manga' 7 | anime = 'anime' 8 | 9 | 10 | class ListStatus(Enum): 11 | """Enumeration for entry's list status.""" 12 | watching = 'watching' 13 | reading = 'watching' 14 | completed = 'completed' 15 | onHold = 'on-hold' 16 | dropped = 'dropped' 17 | planToWatch = 'plan to watch' 18 | planToRead = 'plan to watch' 19 | # none = None # uncomments if errors 20 | 21 | def __str__(self): 22 | return self.value 23 | 24 | 25 | class Smiley(Enum): 26 | """Enumeration for entry's 5 stars score.""" 27 | 28 | like = ':)' 29 | neutral = ':|' 30 | unlike = ':(' 31 | 32 | def __str__(self): 33 | return self.value 34 | 35 | 36 | class Season(Enum): 37 | """Enumeration for entry's list status.""" 38 | winter = 'winter' 39 | spring = 'spring' 40 | summer = 'summer' 41 | fall = 'fall' 42 | 43 | 44 | class MediaType(Enum): 45 | tv = 0 46 | tvShort = 1 47 | movie = 2 48 | special = 3 49 | ova = 4 50 | ona = 5 51 | music = 6 52 | manga = 7 53 | novel = 8 54 | oneShot = 9 55 | doujin = 10 56 | manhua = 11 57 | manhwa = 12 58 | 59 | 60 | class AnimeStatus(Enum): 61 | finishedAiring = 'finished airing' 62 | currentlyAiring = 'currently airing' 63 | notYetAired = 'not yet aired' 64 | cancelled = 'cancelled' 65 | 66 | 67 | class MangaStatus(Enum): 68 | finishedPublishing = 'finished publishing' 69 | publishing = 'publishing' 70 | notYetPublished = 'not yet published' 71 | 72 | # TODO: genres and genres_exclude goes here 73 | 74 | 75 | class SortBy(Enum): 76 | id = 'id' 77 | score = 'score' 78 | popularity = 'popularity' 79 | startDate = 'start_date' 80 | endDate = 'end_date' 81 | 82 | @property 83 | def desc(self): 84 | return self.value + '-desc' 85 | 86 | def __str__(self): 87 | return self.value 88 | 89 | def __repr__(self): 90 | return self.value 91 | 92 | -------------------------------------------------------------------------------- /anipy/exception.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from urllib3.response import HTTPResponse 3 | 4 | from anipy.utils import response_to_dic 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class AniException(Exception): 10 | """There was an ambiguous exception that occurred. 11 | 12 | Also, works as a root exception that all Anipy exceptions must extends 13 | """ 14 | 15 | 16 | class InvalidGrantException(AniException): 17 | """Wraps Anilist 'invalid_grant' error.""" 18 | 19 | 20 | class InvalidRequestException(AniException): 21 | """Wraps Anilist 'invalid_grant' error.""" 22 | 23 | 24 | class UnauthorizedException(AniException): 25 | """Wraps Anilist 'unauthorized' error.""" 26 | 27 | 28 | class AuthenticationException(AniException): 29 | """Some operation that needs authentication was executed without it.""" 30 | 31 | 32 | class InternalServerError(AniException): 33 | """Anilist.co return a 500 Response and a html. No extra information was given.""" 34 | 35 | 36 | def raise_from_response(response): 37 | """Raise an exception according the json data of the response""" 38 | 39 | if not isinstance(response, HTTPResponse): 40 | raise ValueError('Response must be instance of HTTPResponse intead of %s' % type(response)) 41 | 42 | if response.status < 400 or response.status >= 600: 43 | return 44 | 45 | logger.debug('Response status: ' + str(response.status)) 46 | logger.debug('Response content-type: ' + response.headers['content-type']) 47 | 48 | if response.status == 405: 49 | logger.error('HTTP 405 Method not allowed.') 50 | raise AniException('HTTP 405 Method not allowed.') 51 | if response.status == 500: 52 | raise InternalServerError('Anilist.co return a 500 Response and a html. No extra information was given.') 53 | 54 | response = response_to_dic(response) 55 | 56 | if response.get('error') == 'invalid_grant': 57 | raise InvalidGrantException(response.get('error_description')) 58 | elif response.get('error') == 'invalid_request': 59 | raise InvalidRequestException(response.get('error_description')) 60 | elif response.get('error') == 'unauthorized': 61 | if response.get('error_description') is None: 62 | raise UnauthorizedException() 63 | raise UnauthorizedException(response.get('error_description')) 64 | else: 65 | logger.error('Unhandled error: ' + str(response)) 66 | -------------------------------------------------------------------------------- /anipy/ani/browser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | 4 | from ..core import Resource 5 | 6 | from ..utils import camelcase_to_underscore 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def QueryFileld(setter): 13 | """ 14 | This function decorates Query's setters to automatically populate ``_query`` dict. 15 | """ 16 | 17 | _logger = logging.getLogger(__name__ + '.QueryField') 18 | 19 | def wrapper(self, *args): 20 | 21 | setter(self, *args) 22 | 23 | key = camelcase_to_underscore(setter.__name__) 24 | if isinstance(args[0], Enum): 25 | self._query[key] = args[0].value 26 | else: 27 | self._query[key] = args[0] 28 | 29 | _logger.debug('Query field changed: ' + str(self._query)) 30 | 31 | return self 32 | 33 | return wrapper 34 | 35 | 36 | class Browser(Resource): 37 | 38 | _ENDPOINT = '/browse/' 39 | 40 | def __init__(self): 41 | super().__init__() 42 | 43 | def __new__(type): 44 | if not '_instance' in type.__dict__: 45 | type._instance = object.__new__(type) 46 | return type._instance 47 | 48 | def executeQuery(self, query): 49 | endpoint = Browser._ENDPOINT + query.serieType.value 50 | print(endpoint) 51 | self.get(endpoint=endpoint, data=query.query) 52 | 53 | 54 | class Query(object): 55 | 56 | def __init__(self, serieType): 57 | self._query = {} 58 | self._serieType = serieType 59 | 60 | @property 61 | def query(self): 62 | return self._query 63 | 64 | @property 65 | def serieType(self): 66 | return self._serieType 67 | 68 | @QueryFileld 69 | def year(self, value): 70 | self._year = value 71 | 72 | @QueryFileld 73 | def season(self, value): 74 | self._season = value 75 | 76 | @QueryFileld 77 | def type(self, value): 78 | """ 79 | Use with MediaType Enum 80 | """ 81 | self._type = value 82 | 83 | @QueryFileld 84 | def status(self, value): 85 | self._status = value 86 | 87 | # TODO: genres and genres_exlclude 88 | 89 | @QueryFileld 90 | def sort(self, value): 91 | self._sort = value 92 | 93 | @QueryFileld 94 | def airingData(self, value): 95 | """ 96 | If true includes anime airing data in small models 97 | """ 98 | self._airingData = value 99 | 100 | @QueryFileld 101 | def fullPage(self, value): 102 | self._fullPage = value 103 | 104 | @QueryFileld 105 | def page(self, value): 106 | self._page = value 107 | return self 108 | 109 | -------------------------------------------------------------------------------- /docs/source/gettingstarted.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | ============ 5 | Installation 6 | ============ 7 | 8 | For now the only available versions are alphas. You can Instaled the las 9 | by: 10 | 11 | .. code:: bash 12 | 13 | $ git clone https://github.com/twissell-/anipy.git 14 | $ cd anipy 15 | $ python setup.py # Be sure using Python 3 16 | 17 | ===== 18 | Usage 19 | ===== 20 | 21 | I've tried to keep the developer interface as simple as possible. 22 | 23 | Authentication 24 | ~~~~~~~~~~~~~~ 25 | 26 | Before you can access any Anilist resource you have to get 27 | authenticated. Once you have `created a 28 | client `__ 29 | you must configure ``auth.AuthenticationProvider`` class with your 30 | credentials. 31 | 32 | Now you can get authenticated with any of the available `grant 33 | types `__. 34 | Aditionaly, Anipy have a ``GrantType.refreshToken`` in case you have 35 | saved a refresh token from a previous authentication. *Note that only 36 | code and pin authentication gives you a refresh token.* 37 | 38 | .. code:: python 39 | 40 | from anipy import AuthenticationProvider 41 | from anipy import Authentication 42 | from anipy import GrantType 43 | 44 | AuthenticationProvider.config('your-client-id', 'your-client-secret', 'your-redirect-uri') 45 | 46 | auth = Authentication.fromCredentials() 47 | # or 48 | auth = Authentication.fromCode('code') 49 | # or 50 | auth = Authentication.fromPin('pin') 51 | 52 | # Now you can save the refresh token 53 | refresh_token = auth.refreshToken 54 | 55 | auth = Authentication.fromRefreshToken(refresh_token) 56 | 57 | Authentication expires after one hour and will refresh automatically, 58 | nevertheless you can do it manually at any time, ie.: 59 | 60 | .. code:: python 61 | 62 | if auth.isExpired: 63 | auth.refresh() 64 | 65 | Resources 66 | ~~~~~~~~~ 67 | 68 | Resources are one of the most important parts of the library. They are 69 | in charge of go an get the data from the Anilist API. Each domain class 70 | have a resource, you can compare them to *Data Access Objects*. All 71 | resouces are **Singletons**. 72 | 73 | In order to keep things simple you can access the resource from class it 74 | serves 75 | 76 | .. code:: python 77 | 78 | # Current logged user 79 | user = User.resource().principal() 80 | # A user for his Id or Display Name 81 | user = User.resource().byId(3225) 82 | user = User.resource().byDisplayName('demo') 83 | 84 | Some resources are injected in other classes also in order to keep 85 | things simple (ie. ``AnimeListResource``). So if you want to get de 86 | watching list of a user you can do: 87 | 88 | .. code:: python 89 | 90 | # The long way 91 | resource = AnimeListResource() 92 | watching_list = resource.byUserId(user.id) 93 | # Or the short way 94 | watching_list = user.watching 95 | -------------------------------------------------------------------------------- /anipy/ani/list.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import anipy 4 | from ..core import ( 5 | Entity, 6 | Updatable 7 | ) 8 | 9 | 10 | class ListEntry(Entity): 11 | """docstring for ListEntry""" 12 | def __init__(self, **kwargs): 13 | super().__init__(**kwargs) 14 | 15 | listStatus = kwargs.get('listStatus') 16 | if listStatus not in list(anipy.ListStatus) and listStatus is not None: 17 | listStatus = anipy.ListStatus(listStatus) 18 | 19 | self._recordId = kwargs.get('recordId') 20 | self._listStatus = listStatus 21 | self._scoreRaw = kwargs.get('scoreRaw', 0) 22 | self._score = kwargs.get('score', 0) 23 | self._notes = kwargs.get('notes') 24 | self._updatedTime = kwargs.get('updatedTime') 25 | self._addedTime = kwargs.get('addedTime') 26 | self._advancedRatingScores = kwargs.get('advancedRatingScores', [0, 0, 0, 0, 0]) 27 | self._customLists = kwargs.get('customLists', [0, 0, 0, 0, 0]) 28 | 29 | 30 | @property 31 | def recordId(self): 32 | return self._recordId 33 | 34 | @recordId.setter 35 | def recordId(self, recordId): 36 | self._recordId = recordId 37 | 38 | @property 39 | def listStatus(self): 40 | return self._listStatus 41 | 42 | @listStatus.setter 43 | @Updatable 44 | def listStatus(self, listStatus): 45 | self._listStatus = listStatus 46 | 47 | @property 48 | def score(self): 49 | return self._score 50 | 51 | @score.setter 52 | @Updatable 53 | def score(self, score): 54 | if isinstance(score, Enum): 55 | self._score = score.value 56 | else: 57 | self._score = score 58 | 59 | @property 60 | def notes(self): 61 | return self._notes 62 | 63 | @notes.setter 64 | @Updatable 65 | def notes(self, notes): 66 | self._notes = notes 67 | 68 | @property 69 | def updatedTime(self): 70 | return self._updatedTime 71 | 72 | @updatedTime.setter 73 | def updatedTime(self, updatedTime): 74 | self._updatedTime = updatedTime 75 | 76 | @property 77 | def addedTime(self): 78 | return self._addedTime 79 | 80 | @addedTime.setter 81 | def addedTime(self, addedTime): 82 | self._addedTime = addedTime 83 | 84 | @property 85 | def scoreRaw(self): 86 | return self._scoreRaw 87 | 88 | @scoreRaw.setter 89 | def scoreRaw(self, scoreRaw): 90 | self._scoreRaw = scoreRaw 91 | 92 | @property 93 | def advancedRatingScores(self): 94 | return self._advancedRatingScores 95 | 96 | @advancedRatingScores.setter 97 | def advancedRatingScores(self, advancedRatingScores): 98 | self._advancedRatingScores = advancedRatingScores 99 | 100 | @property 101 | def customLists(self): 102 | return self._customLists 103 | 104 | @customLists.setter 105 | def customLists(self, customLists): 106 | self._customLists = customLists 107 | 108 | 109 | class MangaListEntry(ListEntry): 110 | """docstring for MangaListEntry""" 111 | def __init__(self, **kwargs): 112 | super().__init__(**kwargs) 113 | self._manga = kwargs.get('manga') 114 | self._chaptersRead = kwargs.get('chaptersRead') 115 | self._volumesRead = kwargs.get('volumesRead') 116 | self._reread = kwargs.get('reread') 117 | 118 | 119 | -------------------------------------------------------------------------------- /test/testAuthentication.py: -------------------------------------------------------------------------------- 1 | from urllib3_mock import Responses 2 | 3 | from anipy import ( 4 | AuthenticationProvider, 5 | ) 6 | 7 | import os 8 | 9 | # from anipy.exception import AniException 10 | # from anipy.exception import InternalServerError 11 | # from anipy.exception import InvalidGrantException 12 | # from anipy.exception import InvalidRequestException 13 | # from anipy.exception import UnauthorizedException 14 | 15 | 16 | class TestAuthentication(object): 17 | 18 | responses = Responses('requests.packages.urllib3') 19 | 20 | def testRefreshAuthentication(self): 21 | 22 | auth = AuthenticationProvider.currentAuth() 23 | 24 | assert auth.accessToken 25 | assert auth.expires 26 | assert auth.tokenType == 'Bearer' 27 | assert auth.expiresIn == 3600 28 | assert auth.refreshToken == os.environ.get('REFRESH_TOKEN') 29 | assert not auth.isExpired 30 | assert auth is AuthenticationProvider.currentAuth() 31 | 32 | # @responses.activate 33 | # def testInternalServerError(self): 34 | # TestAuthentication.responses.add( 35 | # 'POST', '/api/auth/access_token', 36 | # status=500) 37 | # 38 | # try: 39 | # Authentication.fromCode('authenticationcode') 40 | # except Exception as e: 41 | # assert isinstance(e, InternalServerError) 42 | # else: 43 | # assert False 44 | # 45 | # @responses.activate 46 | # def testMethodNotAllowed(self): 47 | # TestAuthentication.responses.add( 48 | # 'POST', '/api/auth/access_token', 49 | # status=405) 50 | # 51 | # try: 52 | # Authentication.fromCode('authenticationcode') 53 | # except Exception as e: 54 | # assert isinstance(e, AniException) 55 | # assert str(e) == 'HTTP 405 Method not allowed.' 56 | # else: 57 | # assert False 58 | # 59 | # @responses.activate 60 | # def testInvalidGrantException(self): 61 | # TestAuthentication.responses.add( 62 | # 'POST', '/api/auth/access_token', 63 | # body=b'{"error":"invalid_grant"}', 64 | # status=400, 65 | # content_type='application/json') 66 | # 67 | # try: 68 | # Authentication.fromCode('authenticationcode') 69 | # except Exception as e: 70 | # assert isinstance(e, InvalidGrantException) 71 | # else: 72 | # assert False 73 | # 74 | # @responses.activate 75 | # def testInvalidRequest(self): 76 | # TestAuthentication.responses.add( 77 | # 'POST', '/api/auth/access_token', 78 | # body=b'{"error":"invalid_request"}', 79 | # status=400, 80 | # content_type='application/json') 81 | # 82 | # try: 83 | # Authentication.fromCode('authenticationcode') 84 | # except Exception as e: 85 | # assert isinstance(e, InvalidRequestException) 86 | # else: 87 | # assert False 88 | # 89 | # @responses.activate 90 | # def testInvalidUnauthorizedException(self): 91 | # TestAuthentication.responses.add( 92 | # 'POST', '/api/auth/access_token', 93 | # body=b'{"error":"unauthorized"}', 94 | # status=401, 95 | # content_type='application/json') 96 | # 97 | # try: 98 | # Authentication.fromCode('authenticationcode') 99 | # except Exception as e: 100 | # assert isinstance(e, UnauthorizedException) 101 | # else: 102 | # assert False 103 | -------------------------------------------------------------------------------- /anipy/ani/animeList.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from ..core import ( 4 | Resource, 5 | Updatable 6 | ) 7 | from ..utils import underscore_to_camelcase 8 | from .list import ListEntry 9 | from .serie import SmallAnime 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class AnimeListResource(Resource): 15 | """docstring for AnimeListResource""" 16 | 17 | _GET_ENDPOINT = '/api/user/%s/animelist/' 18 | _ENDPOINT = '/api/animelist/' 19 | 20 | _all_lists_key = 'lists' 21 | _watching_key = 'watching' 22 | _completed_key = 'completed' 23 | _on_hold_key = 'on_hold' 24 | _dropped_key = 'dropped' 25 | _plan_to_watch_key = 'plan_to_watch' 26 | 27 | def __init__(self): 28 | super().__init__() 29 | 30 | def __new__(type): 31 | if not '_instance' in type.__dict__: 32 | type._instance = object.__new__(type) 33 | return type._instance 34 | 35 | def watchingByUserId(self, id_): 36 | return self._listByUserIdAndListKey(id_, self._watching_key) 37 | 38 | def completedByUserId(self, id_): 39 | return self._listByUserIdAndListKey(id_, self._completed_key) 40 | 41 | def onHoldByUserId(self, id_): 42 | return self._listByUserIdAndListKey(id_, self._on_hold_key) 43 | 44 | def droppedgByUserId(self, id_): 45 | return self._listByUserIdAndListKey(id_, self._dropped_key) 46 | 47 | def planToWatchByUserId(self, id_): 48 | return self._listByUserIdAndListKey(id_, self._plan_to_watch_key) 49 | 50 | def allByUserId(self, id_): 51 | rtn = {} 52 | listsDic = self._requestByUserIdOrDisplayName(id_).json()[self._all_lists_key] 53 | for k, v in listsDic: 54 | rtn[underscore_to_camelcase(k)] = (ListEntry.fromResponse(item) for item in v) 55 | return rtn 56 | 57 | def _listByUserIdAndListKey(self, id_, key): 58 | try: 59 | return list(AnimeListEntry.fromResponse(item) for item in self._requestByUserIdOrDisplayName(id_)[self._all_lists_key][key]) 60 | except TypeError as e: 61 | logger.warning('User has no anime lists.') 62 | logger.debug(e) 63 | return [] 64 | except KeyError as e: 65 | logger.warning('Anime list \'%s\' is empty.' % key) 66 | logger.debug(e) 67 | return [] 68 | 69 | def _requestByUserIdOrDisplayName(self, displayName, raw=False): 70 | url = self._GET_ENDPOINT % str(displayName) 71 | if raw: 72 | url += 'raw' 73 | 74 | return self.get(endpoint=url) 75 | 76 | 77 | class AnimeListEntry(ListEntry): 78 | """docstring for AnimeListEntry""" 79 | 80 | __composite__ = {'anime': SmallAnime} 81 | _resource = AnimeListResource() 82 | 83 | def __init__(self, **kwargs): 84 | super().__init__(**kwargs) 85 | self._anime = kwargs.get('anime') 86 | self._episodesWatched = kwargs.get('episodesWatched', 0) 87 | self._rewatched = kwargs.get('rewatched', 0) 88 | 89 | self._updateData['id'] = self._anime.id 90 | 91 | def __repr__(self): 92 | return '<%s \'%s\' %d>' % ( 93 | self.__class__.__name__, 94 | self.anime.titleRomaji, 95 | self.episodesWatched) 96 | 97 | @property 98 | def anime(self): 99 | return self._anime 100 | 101 | @property 102 | def episodesWatched(self): 103 | return self._episodesWatched 104 | 105 | @property 106 | def rewatched(self): 107 | return self._rewatched 108 | 109 | @anime.setter 110 | def anime(self, anime): 111 | self._anime = anime 112 | 113 | @episodesWatched.setter 114 | @Updatable 115 | def episodesWatched(self, episodesWatched): 116 | self._episodesWatched = episodesWatched 117 | 118 | @rewatched.setter 119 | @Updatable 120 | def rewatched(self, rewatched): 121 | self._rewatched = rewatched 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Anipy 2 | [![Build Status](https://travis-ci.org/twissell-/anipy.svg?branch=master)](https://travis-ci.org/twissell-/anipy) 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/d811779af6ee4c14a03137894930bb04)](https://www.codacy.com/app/dmaggioesne/anipy?utm_source=github.com&utm_medium=referral&utm_content=twissell-/anipy&utm_campaign=Badge_Grade) 4 | [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/d811779af6ee4c14a03137894930bb04)](https://www.codacy.com/app/dmaggioesne/anipy?utm_source=github.com&utm_medium=referral&utm_content=twissell-/anipy&utm_campaign=Badge_Coverage) 5 | [![Python Version](https://img.shields.io/badge/python-3.5-blue.svg)]() 6 | [![Project License](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/twissell-/anipy/master/LICENSE) 7 | 8 | 9 | Anipy is a python library that wraps and organize the [Anilist] rest api into modules, classes and functions so it can be used quick, easy, and right out of the box. You can take a look at the api [official docs]. **Anilist is a [Josh Star]'s project** 10 | 11 | 12 | ## Table of contents 13 | 14 | * [Installation](#installation) 15 | * [Usage](#usage) 16 | * [Authentication](#authentication) 17 | * [Resources](#resources) 18 | * [Roadmap](#roadmap) 19 | 20 | 21 | ## Installation 22 | 23 | For now the only available versions are alphas. You can Instaled the las by: 24 | ```bash 25 | $ git clone https://github.com/twissell-/anipy.git 26 | $ cd anipy 27 | $ python setup.py # Be sure using Python 3 28 | ``` 29 | 30 | ## Usage 31 | 32 | I've tried to keep the developer interface as simple as possible. 33 | 34 | ### Authentication 35 | 36 | Before you can access any Anilist resource you have to get authenticated. Once you have [created a client] you must configure ```auth.AuthenticationProvider``` class with your credentials. 37 | 38 | Now you can get authenticated with any of the available [grant types]. Aditionaly, Anipy have a ```GrantType.refreshToken``` in case you have saved a refresh token from a previous authentication. *Note that only code and pin authentication gives you a refresh token.* 39 | 40 | ```python 41 | from anipy import AuthenticationProvider 42 | from anipy import Authentication 43 | from anipy import GrantType 44 | 45 | AuthenticationProvider.config('your-client-id', 'your-client-secret', 'your-redirect-uri') 46 | 47 | auth = Authentication.fromCredentials() 48 | # or 49 | auth = Authentication.fromCode('code') 50 | # or 51 | auth = Authentication.fromPin('pin') 52 | 53 | # Now you can save the refresh token 54 | refresh_token = auth.refreshToken 55 | 56 | auth = Authentication.fromRefreshToken(refresh_token) 57 | ``` 58 | 59 | Authentication expires after one hour and will refresh automatically, nevertheless you can do it manually at any time, ie.: 60 | 61 | ```python 62 | if auth.isExpired: 63 | auth.refresh() 64 | 65 | ``` 66 | 67 | ### Resources 68 | 69 | Resources are one of the most important parts of the library. They are in charge of go an get the data from the Anilist API. Each domain class have a resource, you can compare them to *Data Access Objects*. All resouces are **Singletons**. 70 | 71 | In order to keep things simple you can access the resource from class it serves 72 | 73 | ```python 74 | # Current logged user 75 | user = User.resource().principal() 76 | # A user for his Id or Display Name 77 | user = User.resource().byId(3225) 78 | user = User.resource().byDisplayName('demo') 79 | ``` 80 | 81 | Some resources are injected in other classes also in order to keep things simple (ie. ```AnimeListResource```). So if you want to get de watching list of a user you can do: 82 | 83 | ```python 84 | # The long way 85 | resource = AnimeListResource() 86 | watching_list = resource.byUserId(user.id) 87 | # Or the short way 88 | watching_list = user.watching 89 | ``` 90 | 91 | ## Roadmap 92 | 93 | Here is a sumary of the project state. 94 | 95 | ### Next Release: 0.1 96 | 97 | - [x] **Authentication** 98 | - [x] Authorization Code 99 | - [x] Authorization Pin 100 | - [x] Client Credentials 101 | - [x] **User** 102 | - [x] Basics 103 | - [ ] **User Lists** 104 | - [ ] Animelist 105 | - [x] Update watched episodes 106 | - [x] Update rewatched 107 | - [x] Update notes 108 | - [x] Update list status 109 | - [x] Update score (simple) 110 | - [ ] Create a entry 111 | - [ ] Remove entry 112 | - [ ] Mangalist 113 | - [ ] List Scores types 114 | - [ ] **Anime** 115 | - [ ] Basics 116 | - [ ] Browse 117 | - [ ] Search 118 | - [ ] **Manga** 119 | - [ ] Basics 120 | - [ ] Browse 121 | - [ ] Search 122 | 123 | ### Out of Scope 124 | 125 | Thing that I'm not going to do soon. 126 | 127 | - Advance rating score 128 | - Custom lists 129 | 130 | [Anilist]: http://Anilist.co 131 | [official docs]: https://anilist-api.readthedocs.io 132 | [Josh Star]: https://github.com/joshstar 133 | 134 | [created a client]: https://anilist-api.readthedocs.io/en/latest/introduction.html#creating-a-client 135 | [grant types]:https://anilist-api.readthedocs.io/en/latest/authentication.html#which-grant-type-to-use 136 | -------------------------------------------------------------------------------- /anipy/ani/serie.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from .enum_ import SeriesType 4 | from ..core import Entity 5 | 6 | 7 | class SmallSerie(Entity): 8 | def __init__(self, **kwargs): 9 | super().__init__(**kwargs) 10 | 11 | self._id = kwargs.get('id') 12 | self._seriesType = SeriesType(kwargs.get('seriesType')) 13 | self._titleRomaji = kwargs.get('titleRomaji') 14 | self._titleEnglish = kwargs.get('titleEnglish') 15 | self._titleJapanese = kwargs.get('titleJapanese') 16 | # TODO: map type to a MediaType(Enum) 17 | self._type = kwargs.get('type') 18 | self._startDateFuzzy = datetime.strptime(str(kwargs.get('startDateFuzzy')), '%Y%m%d') 19 | self._endDateFuzzy = datetime.strptime(str(kwargs.get('endDateFuzzy')), '%Y%m%d') 20 | self._synonyms = kwargs.get('synonyms', []) 21 | self._genres = kwargs.get('genres', []) 22 | self._adult = kwargs.get('adult') 23 | self._averageScore = kwargs.get('averageScore') 24 | self._popularity = kwargs.get('popularity') 25 | self._imageUrlSml = kwargs.get('imageUrlSml') 26 | self._imageUrlLge = kwargs.get('imageUrlLge') 27 | self._updateAt = datetime.fromtimestamp(kwargs.get('updatedAt')) 28 | 29 | @property 30 | def id(self): 31 | return self._id 32 | 33 | @property 34 | def seriesType(self): 35 | return self._seriesType 36 | 37 | @property 38 | def titleRomaji(self): 39 | return self._titleRomaji 40 | 41 | @property 42 | def titleEnglish(self): 43 | return self._titleEnglish 44 | 45 | @property 46 | def titleJapanese(self): 47 | return self._titleJapanese 48 | 49 | @property 50 | def type(self): 51 | return self._type 52 | 53 | @property 54 | def startDateFuzzy(self): 55 | return self._startDateFuzzy 56 | 57 | @property 58 | def endDateFuzzy(self): 59 | return self._endDateFuzzy 60 | 61 | @property 62 | def synonyms(self): 63 | return self._synonyms 64 | 65 | @property 66 | def genres(self): 67 | return self._genres 68 | 69 | @property 70 | def adult(self): 71 | return self._adult 72 | 73 | @property 74 | def averageScore(self): 75 | return self._averageScore 76 | 77 | @property 78 | def popularity(self): 79 | return self._popularity 80 | 81 | @property 82 | def imageUrlSml(self): 83 | return self._imageUrlSml 84 | 85 | @property 86 | def imageUrlLge(self): 87 | return self._imageUrlLge 88 | 89 | @property 90 | def updateAt(self): 91 | return self._updateAt 92 | 93 | 94 | class Serie(SmallSerie): 95 | 96 | def __init__(self, **kwargs): 97 | super().__init__(**kwargs) 98 | 99 | self._season = kwargs.get('season') 100 | self._description = kwargs.get('description') 101 | self._favourite = kwargs.get('favourite') 102 | self._imageUrlBanner = kwargs.get('image_url_banner') 103 | self._scoreDistribution = kwargs.get('score_distribution') 104 | self._listStats = kwargs.get('list_stats') 105 | 106 | @property 107 | def season(self): 108 | return self._season 109 | 110 | @property 111 | def description(self): 112 | return self._description 113 | 114 | @property 115 | def favourite(self): 116 | return self._favourite 117 | 118 | @property 119 | def imageUrlBanner(self): 120 | return self._imageUrlBanner 121 | 122 | @property 123 | def scoreDistribution(self): 124 | return self._scoreDistribution 125 | 126 | @property 127 | def listStats(self): 128 | return self._listStats 129 | 130 | 131 | class SmallAnime(SmallSerie): 132 | """docstring for SmallAnime""" 133 | 134 | def __init__(self, **kwargs): 135 | super().__init__(**kwargs) 136 | 137 | self._totalEpisodes = kwargs.get('totalEpisodes') 138 | self._airingStatus = kwargs.get('airingStatus') 139 | 140 | def __repr__(self): 141 | return '<%s %s \'%s\'>' % ( 142 | self.__class__.__name__, 143 | self.id, 144 | self.titleRomaji) 145 | 146 | @property 147 | def totalEpisodes(self): 148 | return self._totalEpisodes 149 | 150 | @property 151 | def airingStatus(self): 152 | return self._airingStatus 153 | 154 | 155 | class SmallManga(SmallSerie): 156 | 157 | def __init__(self, **kwargs): 158 | super().__init__(**kwargs) 159 | 160 | self._totalChapters = kwargs.get('totalChapters') 161 | self._publishingStatus = kwargs.get('publishingStatus') 162 | 163 | @property 164 | def totalChapters(self): 165 | return self._totalChapters 166 | 167 | @property 168 | def publishingStatus(self): 169 | return self._publishingStatus 170 | 171 | 172 | class Anime(Serie, SmallAnime): 173 | 174 | def __init__(self, **kwargs): 175 | super().__init__(**kwargs) 176 | self._duration = kwargs.get('duration') 177 | self._youtubeId = kwargs.get('youtube_id') 178 | self._hashtag = kwargs.get('hashtag') 179 | self._source = kwargs.get('source') 180 | self._airingStats = kwargs.get('airing_stats') 181 | 182 | @property 183 | def duration(self): 184 | return self._duration 185 | 186 | @property 187 | def youtubeId(self): 188 | return self._youtubeId 189 | 190 | @property 191 | def hashtag(self): 192 | return self._hashtag 193 | 194 | @property 195 | def source(self): 196 | return self._source 197 | 198 | @property 199 | def airingStats(self): 200 | return self._airingStats 201 | 202 | 203 | class Manga(Serie, SmallManga): 204 | 205 | def __init__(self, **kwargs): 206 | super().__init__(**kwargs) 207 | 208 | self._totalVolumes = kwargs.get('total_volumes') 209 | 210 | @property 211 | def totalVolumes(self): 212 | return self._totalVolumes 213 | -------------------------------------------------------------------------------- /anipy/ani/user.py: -------------------------------------------------------------------------------- 1 | from ..core import ( 2 | Resource, 3 | Entity 4 | ) 5 | from .animeList import AnimeListResource 6 | 7 | 8 | class UserResource(Resource): 9 | """docstring for UserResource""" 10 | 11 | _ENDPOINT = '/api/user/' 12 | 13 | def __init__(self): 14 | super().__init__() 15 | 16 | def __new__(type): 17 | if not '_instance' in type.__dict__: 18 | type._instance = object.__new__(type) 19 | return type._instance 20 | 21 | def principal(self): 22 | return User.fromResponse(self.get()) 23 | 24 | def byDisplayName(self, displayName): 25 | return User.fromResponse(self.get(endpoint=self._ENDPOINT + displayName)) 26 | 27 | def byId(self, id_): 28 | return self.byDisplayName(str(id_)) 29 | 30 | 31 | class User(Entity): 32 | """ 33 | Object representation of an Anilist User response. 34 | """ 35 | 36 | # TODO: remove unnecessary setters 37 | 38 | _userResource = UserResource() 39 | _animeListResource = AnimeListResource() 40 | 41 | def __init__(self, **kwargs): 42 | super().__init__(**kwargs) 43 | self._id = kwargs.get('id') 44 | self._displayName = kwargs.get('displayName') 45 | self._animeTime = kwargs.get('animeTime') 46 | self._mangaChap = kwargs.get('mangaChap') 47 | self._about = kwargs.get('about') 48 | self._listOrder = kwargs.get('listOrder') 49 | self._adultContent = kwargs.get('adultContent') 50 | self._following = kwargs.get('following') 51 | self._imageUrlLge = kwargs.get('imageUrlLge') 52 | self._imageUrlMed = kwargs.get('imageUrlMed') 53 | self._imageUrlBanner = kwargs.get('imageUrlBanner') 54 | self._titleLanguage = kwargs.get('titleLanguage') 55 | self._scoreType = kwargs.get('scoreType') 56 | self._customListAnime = kwargs.get('customListAnime') 57 | self._customListManga = kwargs.get('customListManga') 58 | self._advancedRating = kwargs.get('advancedRating') 59 | self._advancedRatingNames = kwargs.get('advancedRatingNames') 60 | self._notifications = kwargs.get('notifications') 61 | self._airingNotifications = kwargs.get('airingNotifications') 62 | # TODO: make this an object 63 | self._stats = kwargs.get('stats') 64 | 65 | @classmethod 66 | def resource(cls): 67 | return cls._userResource 68 | 69 | @classmethod 70 | def principal(cls): 71 | """ 72 | Shrotcut to UserResource's principal method. 73 | """ 74 | return cls._userResource.principal() 75 | 76 | @classmethod 77 | def byDisplayName(cls, displayName): 78 | """ 79 | Shrotcut to UserResource's byDisplayName method. 80 | """ 81 | return cls._userResource.byDisplayName(displayName) 82 | 83 | @classmethod 84 | def byId(cls, id_): 85 | """ 86 | Shrotcut to UserResource's byId method. 87 | """ 88 | return cls._userResource.byId(id_) 89 | 90 | 91 | @property 92 | def lists(self): 93 | return self._animeListResource.allByUserId(self.id) 94 | 95 | @property 96 | def watching(self): 97 | return self._animeListResource.watchingByUserId(self.id) 98 | 99 | @property 100 | def completed(self): 101 | return self._animeListResource.completedByUserId(self.id) 102 | 103 | @property 104 | def noHold(self): 105 | return self._animeListResource.onHoldByUserId(self.id) 106 | 107 | @property 108 | def dropped(self): 109 | return self._animeListResource.droppedgByUserId(self.id) 110 | 111 | @property 112 | def planToWatch(self): 113 | return self._animeListResource.planToWatchByUserId(self.id) 114 | 115 | @property 116 | def id(self): 117 | return self._id 118 | 119 | @id.setter 120 | def id(self, id): 121 | self._id = id 122 | 123 | @property 124 | def displayName(self): 125 | return self._displayName 126 | 127 | @displayName.setter 128 | def displayName(self, displayName): 129 | self._displayName = displayName 130 | 131 | @property 132 | def animeTime(self): 133 | return self._animeTime 134 | 135 | @animeTime.setter 136 | def animeTime(self, animeTime): 137 | self._animeTime = animeTime 138 | 139 | @property 140 | def mangaChap(self): 141 | return self._mangaChap 142 | 143 | @mangaChap.setter 144 | def mangaChap(self, mangaChap): 145 | self._mangaChap = mangaChap 146 | 147 | @property 148 | def about(self): 149 | return self._about 150 | 151 | @about.setter 152 | def about(self, about): 153 | self._about = about 154 | 155 | @property 156 | def listOrder(self): 157 | return self._listOrder 158 | 159 | @listOrder.setter 160 | def listOrder(self, listOrder): 161 | self._listOrder = listOrder 162 | 163 | @property 164 | def adultContent(self): 165 | return self._adultContent 166 | 167 | @adultContent.setter 168 | def adultContent(self, adultContent): 169 | self._adultContent = adultContent 170 | 171 | @property 172 | def following(self): 173 | return self._following 174 | 175 | @following.setter 176 | def following(self, following): 177 | self._following = following 178 | 179 | @property 180 | def imageUrlLge(self): 181 | return self._imageUrlLge 182 | 183 | @imageUrlLge.setter 184 | def imageUrlLge(self, imageUrlLge): 185 | self._imageUrlLge = imageUrlLge 186 | 187 | @property 188 | def imageUrlMed(self): 189 | return self._imageUrlMed 190 | 191 | @imageUrlMed.setter 192 | def imageUrlMed(self, imageUrlMed): 193 | self._imageUrlMed = imageUrlMed 194 | 195 | @property 196 | def imageUrlBanner(self): 197 | return self._imageUrlBanner 198 | 199 | @imageUrlBanner.setter 200 | def imageUrlBanner(self, imageUrlBanner): 201 | self._imageUrlBanner = imageUrlBanner 202 | 203 | @property 204 | def titleLanguage(self): 205 | return self._titleLanguage 206 | 207 | @titleLanguage.setter 208 | def titleLanguage(self, titleLanguage): 209 | self._titleLanguage = titleLanguage 210 | 211 | @property 212 | def scoreType(self): 213 | return self._scoreType 214 | 215 | @scoreType.setter 216 | def scoreType(self, scoreType): 217 | self._scoreType = scoreType 218 | 219 | @property 220 | def customListAnime(self): 221 | return self._customListAnime 222 | 223 | @customListAnime.setter 224 | def customListAnime(self, customListAnime): 225 | self._customListAnime = customListAnime 226 | 227 | @property 228 | def customListManga(self): 229 | return self._customListManga 230 | 231 | @customListManga.setter 232 | def customListManga(self, customListManga): 233 | self._customListManga = customListManga 234 | 235 | @property 236 | def advancedRating(self): 237 | return self._advancedRating 238 | 239 | @advancedRating.setter 240 | def advancedRating(self, advancedRating): 241 | self._advancedRating = advancedRating 242 | 243 | @property 244 | def advancedRatingNames(self): 245 | return self._advancedRatingNames 246 | 247 | @advancedRatingNames.setter 248 | def advancedRatingNames(self, advancedRatingNames): 249 | self._advancedRatingNames = advancedRatingNames 250 | 251 | @property 252 | def notifications(self): 253 | return self._notifications 254 | 255 | @notifications.setter 256 | def notifications(self, notifications): 257 | self._notifications = notifications 258 | 259 | @property 260 | def airingNotifications(self): 261 | return self._airingNotifications 262 | 263 | @airingNotifications.setter 264 | def airingNotifications(self, airingNotifications): 265 | self._airingNotifications = airingNotifications 266 | 267 | @property 268 | def stats(self): 269 | return self._stats 270 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Anipy documentation build configuration file, created by 5 | # sphinx-quickstart on Sun Nov 6 23:23:25 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('../anipy')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.intersphinx', 37 | 'sphinx.ext.coverage', 38 | 'sphinx.ext.viewcode', 39 | 'sphinx.ext.githubpages', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = '.rst' 50 | 51 | # The encoding of source files. 52 | # 53 | # source_encoding = 'utf-8-sig' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # General information about the project. 59 | project = 'Anipy' 60 | copyright = '2016, Damian Maggio Esne' 61 | author = 'Damian Maggio Esne' 62 | 63 | # The version info for the project you're documenting, acts as replacement for 64 | # |version| and |release|, also used in various other places throughout the 65 | # built documents. 66 | # 67 | # The short X.Y version. 68 | version = '0.1' 69 | # The full version, including alpha/beta/rc tags. 70 | release = '0.1' 71 | 72 | # The language for content autogenerated by Sphinx. Refer to documentation 73 | # for a list of supported languages. 74 | # 75 | # This is also used if you do content translation via gettext catalogs. 76 | # Usually you set "language" from the command line for these cases. 77 | language = None 78 | 79 | # There are two options for replacing |today|: either, you set today to some 80 | # non-false value, then it is used: 81 | # 82 | # today = '' 83 | # 84 | # Else, today_fmt is used as the format for a strftime call. 85 | # 86 | # today_fmt = '%B %d, %Y' 87 | 88 | # List of patterns, relative to source directory, that match files and 89 | # directories to ignore when looking for source files. 90 | # This patterns also effect to html_static_path and html_extra_path 91 | exclude_patterns = [] 92 | 93 | # The reST default role (used for this markup: `text`) to use for all 94 | # documents. 95 | # 96 | # default_role = None 97 | 98 | # If true, '()' will be appended to :func: etc. cross-reference text. 99 | # 100 | # add_function_parentheses = True 101 | 102 | # If true, the current module name will be prepended to all description 103 | # unit titles (such as .. function::). 104 | # 105 | # add_module_names = True 106 | 107 | # If true, sectionauthor and moduleauthor directives will be shown in the 108 | # output. They are ignored by default. 109 | # 110 | # show_authors = False 111 | 112 | # The name of the Pygments (syntax highlighting) style to use. 113 | pygments_style = 'sphinx' 114 | 115 | # A list of ignored prefixes for module index sorting. 116 | # modindex_common_prefix = [] 117 | 118 | # If true, keep warnings as "system message" paragraphs in the built documents. 119 | # keep_warnings = False 120 | 121 | # If true, `todo` and `todoList` produce output, else they produce nothing. 122 | todo_include_todos = False 123 | 124 | 125 | # -- Options for HTML output ---------------------------------------------- 126 | 127 | # The theme to use for HTML and HTML Help pages. See the documentation for 128 | # a list of builtin themes. 129 | # 130 | # html_theme = 'alabaster' 131 | 132 | # Theme options are theme-specific and customize the look and feel of a theme 133 | # further. For a list of options available for each theme, see the 134 | # documentation. 135 | # 136 | html_theme_options = { 137 | 'logo_name': True, 138 | 'github_user': 'twissell-', 139 | 'github_repo': 'anipy', 140 | 'github_banner': True 141 | } 142 | # Add any paths that contain custom themes here, relative to this directory. 143 | # html_theme_path = [] 144 | 145 | # The name for this set of Sphinx documents. 146 | # " v documentation" by default. 147 | # 148 | # html_title = 'Anipy v0.1' 149 | 150 | # A shorter title for the navigation bar. Default is the same as html_title. 151 | # 152 | # html_short_title = None 153 | 154 | # The name of an image file (relative to this directory) to place at the top 155 | # of the sidebar. 156 | # 157 | # html_logo = None 158 | 159 | # The name of an image file (relative to this directory) to use as a favicon of 160 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 161 | # pixels large. 162 | # 163 | # html_favicon = None 164 | 165 | # Add any paths that contain custom static files (such as style sheets) here, 166 | # relative to this directory. They are copied after the builtin static files, 167 | # so a file named "default.css" will overwrite the builtin "default.css". 168 | html_static_path = ['_static'] 169 | 170 | # Add any extra paths that contain custom files (such as robots.txt or 171 | # .htaccess) here, relative to this directory. These files are copied 172 | # directly to the root of the documentation. 173 | # 174 | # html_extra_path = [] 175 | 176 | # If not None, a 'Last updated on:' timestamp is inserted at every page 177 | # bottom, using the given strftime format. 178 | # The empty string is equivalent to '%b %d, %Y'. 179 | # 180 | # html_last_updated_fmt = None 181 | 182 | # If true, SmartyPants will be used to convert quotes and dashes to 183 | # typographically correct entities. 184 | # 185 | # html_use_smartypants = True 186 | 187 | # Custom sidebar templates, maps document names to template names. 188 | # 189 | # html_sidebars = {} 190 | 191 | # Additional templates that should be rendered to pages, maps page names to 192 | # template names. 193 | # 194 | # html_additional_pages = {} 195 | 196 | # If false, no module index is generated. 197 | # 198 | # html_domain_indices = True 199 | 200 | # If false, no index is generated. 201 | # 202 | # html_use_index = True 203 | 204 | # If true, the index is split into individual pages for each letter. 205 | # 206 | # html_split_index = False 207 | 208 | # If true, links to the reST sources are added to the pages. 209 | # 210 | # html_show_sourcelink = True 211 | 212 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 213 | # 214 | # html_show_sphinx = True 215 | 216 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 217 | # 218 | # html_show_copyright = True 219 | 220 | # If true, an OpenSearch description file will be output, and all pages will 221 | # contain a tag referring to it. The value of this option must be the 222 | # base URL from which the finished HTML is served. 223 | # 224 | # html_use_opensearch = '' 225 | 226 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 227 | # html_file_suffix = None 228 | 229 | # Language to be used for generating the HTML full-text search index. 230 | # Sphinx supports the following languages: 231 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 232 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 233 | # 234 | # html_search_language = 'en' 235 | 236 | # A dictionary with options for the search language support, empty by default. 237 | # 'ja' uses this config value. 238 | # 'zh' user can custom change `jieba` dictionary path. 239 | # 240 | # html_search_options = {'type': 'default'} 241 | 242 | # The name of a javascript file (relative to the configuration directory) that 243 | # implements a search results scorer. If empty, the default will be used. 244 | # 245 | # html_search_scorer = 'scorer.js' 246 | 247 | # Output file base name for HTML help builder. 248 | htmlhelp_basename = 'Anipydoc' 249 | 250 | # -- Options for LaTeX output --------------------------------------------- 251 | 252 | latex_elements = { 253 | # The paper size ('letterpaper' or 'a4paper'). 254 | # 255 | # 'papersize': 'letterpaper', 256 | 257 | # The font size ('10pt', '11pt' or '12pt'). 258 | # 259 | # 'pointsize': '10pt', 260 | 261 | # Additional stuff for the LaTeX preamble. 262 | # 263 | # 'preamble': '', 264 | 265 | # Latex figure (float) alignment 266 | # 267 | # 'figure_align': 'htbp', 268 | } 269 | 270 | # Grouping the document tree into LaTeX files. List of tuples 271 | # (source start file, target name, title, 272 | # author, documentclass [howto, manual, or own class]). 273 | latex_documents = [ 274 | (master_doc, 'Anipy.tex', 'Anipy Documentation', 275 | 'Damian Maggio Esne', 'manual'), 276 | ] 277 | 278 | # The name of an image file (relative to this directory) to place at the top of 279 | # the title page. 280 | # 281 | # latex_logo = None 282 | 283 | # For "manual" documents, if this is true, then toplevel headings are parts, 284 | # not chapters. 285 | # 286 | # latex_use_parts = False 287 | 288 | # If true, show page references after internal links. 289 | # 290 | # latex_show_pagerefs = False 291 | 292 | # If true, show URL addresses after external links. 293 | # 294 | # latex_show_urls = False 295 | 296 | # Documents to append as an appendix to all manuals. 297 | # 298 | # latex_appendices = [] 299 | 300 | # It false, will not define \strong, \code, itleref, \crossref ... but only 301 | # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added 302 | # packages. 303 | # 304 | # latex_keep_old_macro_names = True 305 | 306 | # If false, no module index is generated. 307 | # 308 | # latex_domain_indices = True 309 | 310 | 311 | # -- Options for manual page output --------------------------------------- 312 | 313 | # One entry per manual page. List of tuples 314 | # (source start file, name, description, authors, manual section). 315 | man_pages = [ 316 | (master_doc, 'anipy', 'Anipy Documentation', 317 | [author], 1) 318 | ] 319 | 320 | # If true, show URL addresses after external links. 321 | # 322 | # man_show_urls = False 323 | 324 | 325 | # -- Options for Texinfo output ------------------------------------------- 326 | 327 | # Grouping the document tree into Texinfo files. List of tuples 328 | # (source start file, target name, title, author, 329 | # dir menu entry, description, category) 330 | texinfo_documents = [ 331 | (master_doc, 'Anipy', 'Anipy Documentation', 332 | author, 'Anipy', 'One line description of project.', 333 | 'Miscellaneous'), 334 | ] 335 | 336 | # Documents to append as an appendix to all manuals. 337 | # 338 | # texinfo_appendices = [] 339 | 340 | # If false, no module index is generated. 341 | # 342 | # texinfo_domain_indices = True 343 | 344 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 345 | # 346 | # texinfo_show_urls = 'footnote' 347 | 348 | # If true, do not generate a @detailmenu in the "Top" node's menu. 349 | # 350 | # texinfo_no_detailmenu = False 351 | 352 | 353 | # Example configuration for intersphinx: refer to the Python standard library. 354 | # intersphinx_mapping = {'https://docs.python.org/': None} 355 | intersphinx_mapping = { 356 | 'https://docs.python.org/3.6/': None, 357 | 'https://urllib3.readthedocs.io/en/stable/': None 358 | } 359 | -------------------------------------------------------------------------------- /anipy/core.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import urllib3 3 | import logging 4 | import pprint 5 | 6 | from enum import Enum 7 | from datetime import datetime 8 | from abc import ABCMeta 9 | 10 | from .utils import ( 11 | underscore_to_camelcase, 12 | camelcase_to_underscore, 13 | dic_to_json, 14 | response_to_dic 15 | ) 16 | from .exception import ( 17 | raise_from_response, 18 | AuthenticationException 19 | ) 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class Resource(metaclass=ABCMeta): 25 | """ 26 | Abstract resource class. 27 | 28 | Works as a base class for all other resources, keeping the generic and re-usable functionality. 29 | 30 | Provides to the classes that inherit it with a connection pool (:any:`urllib3.connectionpool.HTTPSConnectionPool`) 31 | and methods to make all requests to the anilist api through it. 32 | 33 | All resources **must** be singletons. 34 | 35 | The only request this class doesn't handle are the authentication ones, managed by :any:`AuthenticationProvider` 36 | 37 | """ 38 | 39 | _URL = 'https://anilist.co' 40 | """*Constant.* Base url that is used for all requests. Each resource **must** define it own endpoint based on this 41 | url.""" 42 | 43 | _ENDPOINT = None 44 | """Default endpoint for each resource implementation.""" 45 | 46 | def __init__(self): 47 | super().__init__() 48 | self._pool = urllib3.PoolManager().connection_from_url(Resource._URL) 49 | 50 | @property 51 | def _headers(self): 52 | """ 53 | Generates the default headers with the according credentials. 54 | 55 | Example:: 56 | 57 | { 58 | "Authorization": "BEARER youraccestokenhere", 59 | "Content-Type": "application/json" 60 | } 61 | 62 | :return: (:obj:`dict`) Default headers for common requests. 63 | """ 64 | auth = AuthenticationProvider.currentAuth() 65 | 66 | return { 67 | 'Authorization': '%s %s' % (auth.tokenType, auth.accessToken), 68 | 'Content-Type': 'application/json'} 69 | 70 | def update(self, entity): 71 | return self.put(data=entity.updateData) 72 | 73 | def request(self, method, endpoint=None, data=None, headers=None): 74 | """ 75 | Makes a *method* request to *endpoint* with *data* and *headers*. 76 | 77 | :param method: (:obj:`str`) String for the http method: GET, POST, PUT DELETE. Other methods are not supported. 78 | 79 | :param endpoint: (:obj:`str`, optional) String for the endpoint where the request aims. Remember, all endpoints 80 | refers to :any:`_URL`. 81 | 82 | If none, request will aim to :any:`_ENDPOINT`. 83 | 84 | Example: `'/api/user/demo/animelist/'` 85 | 86 | :param data: (:obj:`dict`, optional) Parameters to be included in the request. 87 | 88 | If none, no parameters will be sent. 89 | 90 | :param headers: (:obj:`dict`, optional) Headers to be included in the request. 91 | 92 | If none, default parameters will be used (see :any:`_headers`). 93 | 94 | :raise: See :any:`raise_from_response` 95 | 96 | :return: (:obj:`dict`) Response. 97 | """ 98 | 99 | headers = headers or self._headers 100 | endpoint = endpoint or self._ENDPOINT 101 | data = dic_to_json(data) 102 | 103 | logger.debug('Resource request: %s %s' % (method, endpoint)) 104 | logger.debug('Resource request body: %s' % str(data)) 105 | logger.debug('Resource request headers: %s' % headers) 106 | 107 | response = self._pool.request( 108 | method, 109 | endpoint, 110 | body=data, 111 | headers=headers) 112 | raise_from_response(response) 113 | 114 | response = response_to_dic(response) 115 | logger.debug('Resource response: \n' + pprint.pformat(response)) 116 | return response 117 | 118 | def get(self, endpoint=None, data=None, headers=None): 119 | """ 120 | *Helper.* Calls :any:`request` with `method='GET'` 121 | 122 | :param endpoint: See :any:`request`. 123 | :param data: See :any:`request`. 124 | :param headers: See :any:`request`. 125 | :return: (:obj:`dict`) Response. 126 | """ 127 | 128 | return self.request('GET', endpoint=endpoint, data=data, headers=headers) 129 | 130 | def post(self, endpoint=None, data=None, headers=None): 131 | """ 132 | *Helper.* Calls :any:`request` with `method='POST'` 133 | 134 | :param endpoint: See :any:`request`. 135 | :param data: See :any:`request`. 136 | :param headers: See :any:`request`. 137 | :return: (:obj:`dict`) Response. 138 | """ 139 | 140 | return self.request('POST', endpoint=endpoint, data=data, headers=headers) 141 | 142 | def put(self, endpoint=None, data=None, headers=None): 143 | """ 144 | *Helper.* Calls :any:`request` with `method='PUT'` 145 | 146 | :param endpoint: See :any:`request`. 147 | :param data: See :any:`request`. 148 | :param headers: See :any:`request`. 149 | :return: (:obj:`dict`) Response. 150 | """ 151 | 152 | return self.request('PUT', endpoint=endpoint, data=data, headers=headers) 153 | 154 | def delete(self, endpoint=None, data=None, headers=None): 155 | """ 156 | *Helper.* Calls :any:`request` with `method='DELETE'` 157 | 158 | :param endpoint: See :any:`request`. 159 | :param data: See :any:`request`. 160 | :param headers: See :any:`request`. 161 | :return: (:obj:`dict`) Response. 162 | """ 163 | 164 | return self.request('DELETE', endpoint=endpoint, data=data, headers=headers) 165 | 166 | 167 | class Entity(metaclass=ABCMeta): 168 | """Abstract base class for al classes that are mapped from/to an anilist response.""" 169 | 170 | __composite__ = {} 171 | """Define how different implementations of this class compose each other. See :any:`fromResponse`""" 172 | _resource = None 173 | 174 | def __init__(self, **kwargs): 175 | # TODO: see if i can remove keyword args 176 | """ 177 | All sub classes **must** override this method. Here is where the json response from the api, converted to a dict 178 | is mapped to the private attributes of each implementation. 179 | 180 | Implementation example:: 181 | 182 | def __init__(self, **kwargs): 183 | super().__init__(**kwargs) 184 | self._id = kwargs.get('id') 185 | self._displayName = kwargs.get('displayName') 186 | 187 | :param kwargs: dict with values from the json entity to be mapped. 188 | """ 189 | super().__init__() 190 | self._updateData = {} 191 | 192 | @classmethod 193 | def fromResponse(cls, response): 194 | """ 195 | Class method that creates an instance of the implementation class, based on a json :obj:`requests.Response` or 196 | a :obj:`dict`. 197 | 198 | The 'magic' here resides in :any:`__composite__` attribute. :any:`__composite__` is a :obj:`dict` that allow an 199 | implementation class to define: each time you find, lets say, 'user' in the json response, take its value pass 200 | it as a parameter of the `fromResponse` method of User class. For this particular example, un the class that 201 | uses User, you **must** define:: 202 | 203 | __composite__ = {'user': User} 204 | 205 | :param response: Base data to create the instance 206 | :return: An instance of the implementation class, composed and populated with the response data. 207 | """ 208 | 209 | if isinstance(response, requests.Response): 210 | response = response.json() 211 | dic = {} 212 | 213 | for k in response: 214 | if k in cls.__composite__: 215 | dic[underscore_to_camelcase(k)] = cls.__composite__[k].fromResponse(response.get(k)) 216 | else: 217 | dic[underscore_to_camelcase(k)] = response.get(k) 218 | 219 | return cls(**dic) 220 | 221 | @property 222 | def updateData(self): 223 | """ 224 | Each time an updatable attribute is updated, implementation class **must** set the corresponding entry in the 225 | :any:`_updateData` dict. This operation should always be made in the setters. 226 | 227 | When the update of a entity is made, the data for the request is obtained through this method so, any change 228 | that isn't in this dict will be ignored. 229 | 230 | For now, keys must be the keys of the json request. This doesn't support composite updates cause they are not 231 | needed yet. 232 | 233 | :return: :obj:`dict` with the new values of the updatable fields. 234 | """ 235 | return self._updateData 236 | 237 | @property 238 | def save(self): 239 | self._resource.put(data=self.updateData) 240 | 241 | 242 | class Updatable(object): 243 | """ 244 | This class decorates setters to automatically add value changes to ``_updateData``. 245 | """ 246 | 247 | _logger = logging.getLogger(__name__ + '.Updatable') 248 | 249 | def __init__(self, setter): 250 | self._setter = setter 251 | self._key = camelcase_to_underscore(self._setter.__name__) 252 | self._logger.debug('Updatable field created: ' + self._setter.__name__) 253 | 254 | def __call__(self, *args): 255 | self._setter(*args) 256 | if isinstance(args[1], Enum): 257 | args[0].updateData[self._key] = args[1].value 258 | else: 259 | args[0].updateData[self._key] = args[1] 260 | 261 | self._logger.debug('Update data changed: ' + str(args[0].updateData)) 262 | 263 | 264 | class GrantType(Enum): 265 | """ 266 | Enum for Authentication grant type. 267 | 268 | Possible values: 269 | - authorizationCode 270 | - authorizationPin 271 | - clientCredentials 272 | - refreshToken 273 | 274 | """ 275 | authorizationCode = 'authorization_code' 276 | authorizationPin = 'authorization_pin' 277 | clientCredentials = 'client_credentials' 278 | refreshToken = 'refresh_token' 279 | 280 | 281 | class AuthenticationProvider(object): 282 | """ 283 | *Singleton*. Builder for the Authentication class. Works like a :any:`Resource` but with many specific behavior. 284 | """ 285 | 286 | _URL = 'https://anilist.co' 287 | _ENDPOINT = '/api/auth/access_token' 288 | _pool = urllib3.PoolManager().connection_from_url(_URL) 289 | _instance = None 290 | 291 | clientId = None 292 | clientSecret = None 293 | redirectUri = None 294 | 295 | _logger = logging.getLogger(__name__ + '.AuthenticationProvider') 296 | 297 | def __new__(cls, grantType): 298 | if cls._instance is None: 299 | cls._instance = object.__new__(cls) 300 | return cls._instance 301 | 302 | def __init__(self, grantType): 303 | super().__init__() 304 | 305 | self._grantType = grantType 306 | self._currentAuth = None 307 | if grantType is GrantType.authorizationCode: 308 | self.authenticate = self._codeRequest 309 | elif grantType is GrantType.authorizationPin: 310 | self.authenticate = self._pinRequest 311 | elif grantType is GrantType.clientCredentials: 312 | self.authenticate = self._clientCredentialsRequest 313 | elif grantType is GrantType.refreshToken: 314 | self.authenticate = self._refreshRequest 315 | else: 316 | raise ValueError('Invalid grant type.') 317 | 318 | @classmethod 319 | def config(cls, clientId, clientSecret, redirectUri): 320 | """ 321 | Sets all configuration params needed for this class to work. All this values are from Anilist web page. 322 | See `how to create a client `_ 323 | 324 | :param clientId: :obj:`str` Anilist client id. 325 | :param clientSecret: :obj:`str` Anilist client secret. 326 | :param redirectUri: :obj:`str` Anilist redirect uri. 327 | """ 328 | 329 | # TODO: make redirectUri not mandatory. 330 | 331 | cls.clientId = clientId 332 | cls.clientSecret = clientSecret 333 | cls.redirectUri = redirectUri 334 | 335 | @classmethod 336 | def currentAuth(cls): 337 | """ 338 | 339 | :return: Current :any:`Authentication` instance. 340 | """ 341 | 342 | if not cls._instance: 343 | raise AuthenticationException('AuthenticationProvider is not instantiated.') 344 | 345 | auth = cls._instance._currentAuth 346 | if not auth: 347 | raise AuthenticationException('Current authentication is None.') 348 | 349 | return auth 350 | 351 | @classmethod 352 | def refresh(cls, refreshToken, clientId=None, clientSecret=None): 353 | """ 354 | Force authentication refresh. 355 | 356 | :return: a refreshed :any:`Authentication` 357 | """ 358 | return cls._instance._refreshRequest(refreshToken, clientId, clientSecret) 359 | 360 | def _authRequest(self, data): 361 | self._logger.debug('Auth request method: ' + 'POST') 362 | self._logger.debug('Auth request url: ' + self._ENDPOINT) 363 | self._logger.debug('Auth request: \n' + pprint.pformat(data)) 364 | response = self._pool.request( 365 | 'POST', 366 | self._ENDPOINT, 367 | fields=data) 368 | raise_from_response(response) 369 | 370 | response = response_to_dic(response) 371 | if data.get('refresh_token') is not None: 372 | response['refresh_token'] = data.get('refresh_token') 373 | 374 | auth = Authentication(**response) 375 | self._currentAuth = auth 376 | 377 | self._logger.debug('Auth response: \n' + pprint.pformat(response)) 378 | return auth 379 | 380 | def _refreshRequest(self, refreshToken, clientId=None, clientSecret=None): 381 | clientId = clientId or self.clientId 382 | clientSecret = clientSecret or self.clientSecret 383 | 384 | data = { 385 | 'grant_type': GrantType.refreshToken.value, 386 | 'client_id': clientId, 387 | 'client_secret': clientSecret, 388 | 'refresh_token': refreshToken} 389 | 390 | return self._authRequest(data) 391 | 392 | def _codeRequest(self, code, clientId=None, clientSecret=None, redirectUri=None): 393 | clientId = clientId or self.clientId 394 | clientSecret = clientSecret or self.clientSecret 395 | redirectUri = redirectUri or self.redirectUri 396 | 397 | data = { 398 | 'grant_type': GrantType.authorizationCode.value, 399 | 'client_id': clientId, 400 | 'client_secret': clientSecret, 401 | 'redirect_uri': redirectUri, 402 | 'code': code} 403 | 404 | return self._authRequest(data) 405 | 406 | def _pinRequest(self, pin, clientId=None, clientSecret=None, redirectUri=None): 407 | clientId = clientId or self.clientId 408 | clientSecret = clientSecret or self.clientSecret 409 | redirectUri = redirectUri or self.redirectUri 410 | 411 | data = { 412 | 'grant_type': GrantType.authorizationPin.value, 413 | 'client_id': clientId, 414 | 'client_secret': clientSecret, 415 | 'redirect_uri': redirectUri, 416 | 'code': pin} 417 | 418 | return self._authRequest(data) 419 | 420 | def _clientCredentialsRequest(self, clientId=None, clientSecret=None): 421 | clientId = clientId or self.clientId 422 | clientSecret = clientSecret or self.clientSecret 423 | 424 | data = { 425 | 'grant_type': GrantType.clientCredentials.value, 426 | 'client_id': clientId, 427 | 'client_secret': clientSecret} 428 | 429 | return self._authRequest(data) 430 | 431 | 432 | class Authentication(object): 433 | """ 434 | Represents a Anilist authentication response. 435 | """ 436 | 437 | def __init__(self, **kwargs): 438 | super().__init__() 439 | self._accessToken = kwargs.get('access_token') 440 | self._tokenType = kwargs.get('token_type') 441 | self._expiresIn = kwargs.get('expires_in') 442 | self._refreshToken = kwargs.get('refresh_token') 443 | exp = kwargs.get('expires') 444 | self._expires = exp if exp is None else datetime.fromtimestamp(exp) 445 | 446 | @classmethod 447 | def fromCode(cls, code, clientId=None, clientSecret=None, redirectUri=None): 448 | """ 449 | Generates a :any:`Authentication` instance from a authentication code. 450 | 451 | :param code: :obj:`str` the authentication code 452 | :param clientId: *Optional.* 453 | :param clientSecret: *Optional.* 454 | :param redirectUri: *Optional.* 455 | :return: :any:`Authentication` 456 | """ 457 | return AuthenticationProvider( 458 | GrantType.authorizationCode).authenticate(code, clientId, clientSecret, redirectUri) 459 | 460 | @classmethod 461 | def fromPin(cls, pin, clientId=None, clientSecret=None, redirectUri=None): 462 | """ 463 | Generates a :any:`Authentication` instance from a authentication pin. 464 | 465 | :param pin: :obj:`str` the authentication pin 466 | :param clientId: *Optional.* 467 | :param clientSecret: *Optional.* 468 | :param redirectUri: *Optional.* 469 | :return: :any:`Authentication` 470 | """ 471 | return AuthenticationProvider( 472 | GrantType.authorizationPin).authenticate(pin, clientId, clientSecret) 473 | 474 | @classmethod 475 | def fromCredentials(cls, clientId=None, clientSecret=None): 476 | """ 477 | Generates a :any:`Authentication` instance based on the client credentials (Read only public content and doesn't 478 | have refresh token). 479 | 480 | :return: :any:`Authentication` 481 | """ 482 | return AuthenticationProvider( 483 | GrantType.clientCredentials).authenticate(clientId, clientSecret) 484 | 485 | @classmethod 486 | def fromRefreshToken(cls, refreshToken, clientId=None, clientSecret=None): 487 | """ 488 | Generates a :any:`Authentication` instance from a refresh token. 489 | 490 | :param refreshToken: :obj:`str` the refresh token 491 | :return: :any:`Authentication` 492 | """ 493 | return AuthenticationProvider(GrantType.refreshToken).authenticate(refreshToken) 494 | 495 | def refresh(self): 496 | """ 497 | Force authentication refresh. 498 | 499 | :return: a refreshed :any:`Authentication` 500 | """ 501 | newAuth = AuthenticationProvider.refresh(self.refreshToken) 502 | self._accessToken = newAuth.accessToken 503 | self._tokenType = newAuth.tokenType 504 | self._expiresIn = newAuth.expiresIn 505 | self._expires = newAuth.expires 506 | 507 | def __repr__(self): 508 | return '<%s \'%s\' %s>' % ( 509 | self.__class__.__name__, 510 | self.accessToken, 511 | 'Expired' if self.isExpired else 'Not expired') 512 | 513 | @property 514 | def isExpired(self): 515 | """ 516 | 517 | :return: True if the authentication is expired, False otherwise. 518 | """ 519 | return self.expires < datetime.now() 520 | 521 | @property 522 | def accessToken(self): 523 | """ 524 | If the authentication has expired, performs a refresh before return de access token. 525 | 526 | :return: A valid access token. 527 | """ 528 | if self.isExpired: 529 | self.refresh() 530 | 531 | return self._accessToken 532 | 533 | @property 534 | def tokenType(self): 535 | return self._tokenType.capitalize() 536 | 537 | @property 538 | def expires(self): 539 | return self._expires 540 | 541 | @property 542 | def expiresIn(self): 543 | return self._expiresIn 544 | 545 | @property 546 | def refreshToken(self): 547 | return self._refreshToken 548 | --------------------------------------------------------------------------------