Integrate mypy static type checker into CI

This commit is contained in:
Alexander Graf 2019-05-07 13:01:16 +02:00
parent 4f9d64a284
commit 4c72077976
5 changed files with 48 additions and 31 deletions

View File

@ -1,14 +1,15 @@
dist: xenial
language: python language: python
python: python:
- "3.5" - "3.5"
- "3.5-dev"
- "3.6" - "3.6"
- "3.6-dev" - "3.7"
install: install:
- pip install pylint~=2.3.1 requests - pip install pylint~=2.3.1 requests mypy
- pip install -r docs/requirements.txt - pip install -r docs/requirements.txt
script: script:
- python3 -m pylint -r n -d bad-whitespace,missing-docstring,too-many-arguments,locally-disabled,line-too-long,no-else-raise,too-many-public-methods,too-many-lines,too-many-instance-attributes,too-many-locals,too-many-branches,too-many-statements,inconsistent-return-statements,invalid-name,wildcard-import,unused-wildcard-import,no-else-return,cyclic-import,unnecessary-pass instaloader - python3 -m pylint -r n -d bad-whitespace,missing-docstring,too-many-arguments,locally-disabled,line-too-long,no-else-raise,too-many-public-methods,too-many-lines,too-many-instance-attributes,too-many-locals,too-many-branches,too-many-statements,inconsistent-return-statements,invalid-name,wildcard-import,unused-wildcard-import,no-else-return,cyclic-import,unnecessary-pass instaloader
- python3 -m mypy -m instaloader
- make -C docs html SPHINXOPTS="-W -n" - make -C docs html SPHINXOPTS="-W -n"
deploy: deploy:
- provider: pypi - provider: pypi

View File

@ -6,7 +6,7 @@ __version__ = '4.2.4'
try: try:
# pylint:disable=wrong-import-position # pylint:disable=wrong-import-position
import win_unicode_console import win_unicode_console # type: ignore
except ImportError: except ImportError:
pass pass
else: else:

View File

@ -13,10 +13,10 @@ from datetime import datetime, timezone
from functools import wraps from functools import wraps
from hashlib import md5 from hashlib import md5
from io import BytesIO from io import BytesIO
from typing import Any, Callable, Iterator, List, Optional, Set, Union from typing import Any, Callable, ContextManager, Iterator, List, Optional, Set, Union, cast
import requests import requests
import urllib3 import urllib3 # type: ignore
from .exceptions import * from .exceptions import *
from .instaloadercontext import InstaloaderContext from .instaloadercontext import InstaloaderContext
@ -45,8 +45,8 @@ def _requires_login(func: Callable) -> Callable:
if not instaloader.context.is_logged_in: if not instaloader.context.is_logged_in:
raise LoginRequiredException("--login=USERNAME required.") raise LoginRequiredException("--login=USERNAME required.")
return func(instaloader, *args, **kwargs) return func(instaloader, *args, **kwargs)
# pylint:disable=no-member docstring_text = ":raises LoginRequiredException: If called without being logged in.\n"
call.__doc__ += ":raises LoginRequiredException: If called without being logged in.\n" call.__doc__ = call.__doc__ + docstring_text if call.__doc__ is not None else docstring_text
return call return call
@ -186,7 +186,7 @@ class Instaloader:
raise InvalidArgumentException("Commit mode requires JSON metadata to be saved.") raise InvalidArgumentException("Commit mode requires JSON metadata to be saved.")
# Used to keep state in commit mode # Used to keep state in commit mode
self._committed = None self._committed = None # type: Optional[bool]
@contextmanager @contextmanager
def anonymous_copy(self): def anonymous_copy(self):
@ -299,11 +299,11 @@ class Instaloader:
filename += '.txt' filename += '.txt'
caption += '\n' caption += '\n'
pcaption = _elliptify(caption) pcaption = _elliptify(caption)
caption = caption.encode("UTF-8") bcaption = caption.encode("UTF-8")
with suppress(FileNotFoundError): with suppress(FileNotFoundError):
with open(filename, 'rb') as file: with open(filename, 'rb') as file:
file_caption = file.read() file_caption = file.read()
if file_caption.replace(b'\r\n', b'\n') == caption.replace(b'\r\n', b'\n'): if file_caption.replace(b'\r\n', b'\n') == bcaption.replace(b'\r\n', b'\n'):
try: try:
self.context.log(pcaption + ' unchanged', end=' ', flush=True) self.context.log(pcaption + ' unchanged', end=' ', flush=True)
except UnicodeEncodeError: except UnicodeEncodeError:
@ -327,7 +327,7 @@ class Instaloader:
except UnicodeEncodeError: except UnicodeEncodeError:
self.context.log('txt', end=' ', flush=True) self.context.log('txt', end=' ', flush=True)
with open(filename, 'wb') as text_file: with open(filename, 'wb') as text_file:
shutil.copyfileobj(BytesIO(caption), text_file) shutil.copyfileobj(BytesIO(bcaption), text_file)
os.utime(filename, (datetime.now().timestamp(), mtime.timestamp())) os.utime(filename, (datetime.now().timestamp(), mtime.timestamp()))
def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None: def save_location(self, filename: str, location: PostLocation, mtime: datetime) -> None:
@ -349,12 +349,12 @@ class Instaloader:
return epoch.strftime('%Y-%m-%d_%H-%M-%S_UTC') return epoch.strftime('%Y-%m-%d_%H-%M-%S_UTC')
profile_pic_response = self.context.get_raw(profile.profile_pic_url) profile_pic_response = self.context.get_raw(profile.profile_pic_url)
date_object = None # type: Optional[datetime]
if 'Last-Modified' in profile_pic_response.headers: if 'Last-Modified' in profile_pic_response.headers:
date_object = datetime.strptime(profile_pic_response.headers["Last-Modified"], '%a, %d %b %Y %H:%M:%S GMT') date_object = datetime.strptime(profile_pic_response.headers["Last-Modified"], '%a, %d %b %Y %H:%M:%S GMT')
profile_pic_bytes = None profile_pic_bytes = None
profile_pic_identifier = _epoch_to_string(date_object) profile_pic_identifier = _epoch_to_string(date_object)
else: else:
date_object = None
profile_pic_bytes = profile_pic_response.content profile_pic_bytes = profile_pic_response.content
profile_pic_identifier = md5(profile_pic_bytes).hexdigest()[:16] profile_pic_identifier = md5(profile_pic_bytes).hexdigest()[:16]
profile_pic_extension = 'jpg' profile_pic_extension = 'jpg'
@ -383,6 +383,7 @@ class Instaloader:
:param filename: Filename, or None to use default filename. :param filename: Filename, or None to use default filename.
""" """
if filename is None: if filename is None:
assert self.context.username is not None
filename = get_default_session_filename(self.context.username) filename = get_default_session_filename(self.context.username)
dirname = os.path.dirname(filename) dirname = os.path.dirname(filename)
if dirname != '' and not os.path.exists(dirname): if dirname != '' and not os.path.exists(dirname):
@ -513,6 +514,7 @@ class Instaloader:
userids = list(edge["node"]["id"] for edge in data["feed_reels_tray"]["edge_reels_tray_to_reel"]["edges"]) userids = list(edge["node"]["id"] for edge in data["feed_reels_tray"]["edge_reels_tray_to_reel"]["edges"])
def _userid_chunks(): def _userid_chunks():
assert userids is not None
userids_per_query = 100 userids_per_query = 100
for i in range(0, len(userids), userids_per_query): for i in range(0, len(userids), userids_per_query):
yield userids[i:i + userids_per_query] yield userids[i:i + userids_per_query]
@ -711,6 +713,7 @@ class Instaloader:
""" """
self.context.log("Retrieving saved posts...") self.context.log("Retrieving saved posts...")
count = 1 count = 1
assert self.context.username is not None
for post in Profile.from_username(self.context, self.context.username).get_saved_posts(): for post in Profile.from_username(self.context, self.context.username).get_saved_posts():
if max_count is not None and count > max_count: if max_count is not None and count > max_count:
break break
@ -946,10 +949,12 @@ class Instaloader:
.. versionadded:: 4.1""" .. versionadded:: 4.1"""
@contextmanager
def _error_raiser(_str): def _error_raiser(_str):
yield yield
error_handler = _error_raiser if raise_errors else self.context.error_catcher error_handler = cast(Callable[[Optional[str]], ContextManager[None]],
_error_raiser if raise_errors else self.context.error_catcher)
for profile in profiles: for profile in profiles:
with error_handler(profile.username): with error_handler(profile.username):

View File

@ -10,7 +10,7 @@ import time
import urllib.parse import urllib.parse
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Callable, Dict, Iterator, Optional, Union from typing import Any, Callable, Dict, Iterator, List, Optional, Union
import requests import requests
import requests.utils import requests.utils
@ -22,7 +22,7 @@ def copy_session(session: requests.Session) -> requests.Session:
"""Duplicates a requests.Session.""" """Duplicates a requests.Session."""
new = requests.Session() new = requests.Session()
new.cookies = requests.utils.cookiejar_from_dict(requests.utils.dict_from_cookiejar(session.cookies)) new.cookies = requests.utils.cookiejar_from_dict(requests.utils.dict_from_cookiejar(session.cookies))
new.headers = session.headers.copy() new.headers = session.headers.copy() # type: ignore
return new return new
@ -60,17 +60,17 @@ class InstaloaderContext:
self.two_factor_auth_pending = None self.two_factor_auth_pending = None
# error log, filled with error() and printed at the end of Instaloader.main() # error log, filled with error() and printed at the end of Instaloader.main()
self.error_log = [] self.error_log = [] # type: List[str]
# For the adaption of sleep intervals (rate control) # For the adaption of sleep intervals (rate control)
self._graphql_query_timestamps = dict() self._graphql_query_timestamps = dict() # type: Dict[str, List[float]]
self._graphql_earliest_next_request_time = 0 self._graphql_earliest_next_request_time = 0.0
# Can be set to True for testing, disables supression of InstaloaderContext._error_catcher # Can be set to True for testing, disables supression of InstaloaderContext._error_catcher
self.raise_all_errors = False self.raise_all_errors = False
# Cache profile from id (mapping from id to Profile) # Cache profile from id (mapping from id to Profile)
self.profile_id_cache = dict() self.profile_id_cache = dict() # type: Dict[int, Any]
@contextmanager @contextmanager
def anonymous_copy(self): def anonymous_copy(self):
@ -283,7 +283,7 @@ class InstaloaderContext:
max_reqs = {'1cb6ec562846122743b61e492c85999f': 200, '33ba35852cb50da46f5b5e889df7d159': 200} max_reqs = {'1cb6ec562846122743b61e492c85999f': 200, '33ba35852cb50da46f5b5e889df7d159': 200}
return max_reqs.get(query_hash) or min(max_reqs.values()) return max_reqs.get(query_hash) or min(max_reqs.values())
def _graphql_query_waittime(self, query_hash: str, current_time: float, untracked_queries: bool = False) -> int: def _graphql_query_waittime(self, query_hash: str, current_time: float, untracked_queries: bool = False) -> float:
"""Calculate time needed to wait before GraphQL query can be executed.""" """Calculate time needed to wait before GraphQL query can be executed."""
sliding_window = 660 sliding_window = 660
if query_hash not in self._graphql_query_timestamps: if query_hash not in self._graphql_query_timestamps:

View File

@ -23,10 +23,10 @@ PostCommentAnswer.created_at_utc.__doc__ = ":class:`~datetime.datetime` when com
PostCommentAnswer.text.__doc__ = "Comment text." PostCommentAnswer.text.__doc__ = "Comment text."
PostCommentAnswer.owner.__doc__ = "Owner :class:`Profile` of the comment." PostCommentAnswer.owner.__doc__ = "Owner :class:`Profile` of the comment."
PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) PostComment = namedtuple('PostComment', (*PostCommentAnswer._fields, 'answers')) # type: ignore
for field in PostCommentAnswer._fields: for field in PostCommentAnswer._fields:
getattr(PostComment, field).__doc__ = getattr(PostCommentAnswer, field).__doc__ getattr(PostComment, field).__doc__ = getattr(PostCommentAnswer, field).__doc__
PostComment.answers.__doc__ = r"Iterator which yields all :class:`PostCommentAnswer`\ s for the comment." PostComment.answers.__doc__ = r"Iterator which yields all :class:`PostCommentAnswer`\ s for the comment." # type: ignore
PostLocation = namedtuple('PostLocation', ['id', 'name', 'slug', 'has_public_page', 'lat', 'lng']) PostLocation = namedtuple('PostLocation', ['id', 'name', 'slug', 'has_public_page', 'lat', 'lng'])
PostLocation.id.__doc__ = "ID number of location." PostLocation.id.__doc__ = "ID number of location."
@ -67,9 +67,9 @@ class Post:
self._context = context self._context = context
self._node = node self._node = node
self._owner_profile = owner_profile self._owner_profile = owner_profile
self._full_metadata_dict = None self._full_metadata_dict = None # type: Optional[Dict[str, Any]]
self._rhx_gis_str = None self._rhx_gis_str = None # type: Optional[str]
self._location = None self._location = None # type: Optional[PostLocation]
@classmethod @classmethod
def from_shortcode(cls, context: InstaloaderContext, shortcode: str): def from_shortcode(cls, context: InstaloaderContext, shortcode: str):
@ -140,11 +140,13 @@ class Post:
@property @property
def _full_metadata(self) -> Dict[str, Any]: def _full_metadata(self) -> Dict[str, Any]:
self._obtain_metadata() self._obtain_metadata()
assert self._full_metadata_dict is not None
return self._full_metadata_dict return self._full_metadata_dict
@property @property
def _rhx_gis(self) -> str: def _rhx_gis(self) -> str:
self._obtain_metadata() self._obtain_metadata()
assert self._rhx_gis_str is not None
return self._rhx_gis_str return self._rhx_gis_str
def _field(self, *keys) -> Any: def _field(self, *keys) -> Any:
@ -231,6 +233,7 @@ class Post:
return self._node["edge_media_to_caption"]["edges"][0]["node"]["text"] return self._node["edge_media_to_caption"]["edges"][0]["node"]["text"]
elif "caption" in self._node: elif "caption" in self._node:
return self._node["caption"] return self._node["caption"]
return None
@property @property
def caption_hashtags(self) -> List[str]: def caption_hashtags(self) -> List[str]:
@ -279,6 +282,7 @@ class Post:
"""URL of the video, or None.""" """URL of the video, or None."""
if self.is_video: if self.is_video:
return self._field('video_url') return self._field('video_url')
return None
@property @property
def viewer_has_liked(self) -> Optional[bool]: def viewer_has_liked(self) -> Optional[bool]:
@ -424,7 +428,7 @@ class Profile:
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]): def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
assert 'username' in node assert 'username' in node
self._context = context self._context = context
self._has_public_story = None self._has_public_story = None # type: Optional[bool]
self._node = node self._node = node
self._rhx_gis = None self._rhx_gis = None
self._iphone_struct_ = None self._iphone_struct_ = None
@ -604,6 +608,7 @@ class Profile:
'https://www.instagram.com/{}/'.format(self.username), 'https://www.instagram.com/{}/'.format(self.username),
self._rhx_gis) self._rhx_gis)
self._has_public_story = data['data']['user']['has_public_story'] self._has_public_story = data['data']['user']['has_public_story']
assert self._has_public_story is not None
return self._has_public_story return self._has_public_story
@property @property
@ -770,6 +775,7 @@ class StoryItem:
""":class:`Profile` instance of the story item's owner.""" """:class:`Profile` instance of the story item's owner."""
if not self._owner_profile: if not self._owner_profile:
self._owner_profile = Profile.from_id(self._context, self._node['owner']['id']) self._owner_profile = Profile.from_id(self._context, self._node['owner']['id'])
assert self._owner_profile is not None
return self._owner_profile return self._owner_profile
@property @property
@ -832,6 +838,7 @@ class StoryItem:
"""URL of the video, or None.""" """URL of the video, or None."""
if self.is_video: if self.is_video:
return self._node['video_resources'][-1]['src'] return self._node['video_resources'][-1]['src']
return None
class Story: class Story:
@ -858,8 +865,8 @@ class Story:
def __init__(self, context: InstaloaderContext, node: Dict[str, Any]): def __init__(self, context: InstaloaderContext, node: Dict[str, Any]):
self._context = context self._context = context
self._node = node self._node = node
self._unique_id = None self._unique_id = None # type: Optional[str]
self._owner_profile = None self._owner_profile = None # type: Optional[Profile]
def __repr__(self): def __repr__(self):
return '<Story by {} changed {:%Y-%m-%d_%H-%M-%S_UTC}>'.format(self.owner_username, self.latest_media_utc) return '<Story by {} changed {:%Y-%m-%d_%H-%M-%S_UTC}>'.format(self.owner_username, self.latest_media_utc)
@ -873,7 +880,7 @@ class Story:
return hash(self.unique_id) return hash(self.unique_id)
@property @property
def unique_id(self) -> str: def unique_id(self) -> Union[str, int]:
""" """
This ID only equals amongst :class:`Story` instances which have the same owner and the same set of This ID only equals amongst :class:`Story` instances which have the same owner and the same set of
:class:`StoryItem`. For all other :class:`Story` instances this ID is different. :class:`StoryItem`. For all other :class:`Story` instances this ID is different.
@ -889,12 +896,14 @@ class Story:
"""Timestamp when the story has last been watched or None (local time zone).""" """Timestamp when the story has last been watched or None (local time zone)."""
if self._node['seen']: if self._node['seen']:
return datetime.fromtimestamp(self._node['seen']) return datetime.fromtimestamp(self._node['seen'])
return None
@property @property
def last_seen_utc(self) -> Optional[datetime]: def last_seen_utc(self) -> Optional[datetime]:
"""Timestamp when the story has last been watched or None (UTC).""" """Timestamp when the story has last been watched or None (UTC)."""
if self._node['seen']: if self._node['seen']:
return datetime.utcfromtimestamp(self._node['seen']) return datetime.utcfromtimestamp(self._node['seen'])
return None
@property @property
def latest_media_local(self) -> datetime: def latest_media_local(self) -> datetime:
@ -959,7 +968,7 @@ class Highlight(Story):
def __init__(self, context: InstaloaderContext, node: Dict[str, Any], owner: Optional[Profile] = None): def __init__(self, context: InstaloaderContext, node: Dict[str, Any], owner: Optional[Profile] = None):
super().__init__(context, node) super().__init__(context, node)
self._owner_profile = owner self._owner_profile = owner
self._items = None self._items = None # type: Optional[List[Dict[str, Any]]]
def __repr__(self): def __repr__(self):
return '<Highlight by {}: {}>'.format(self.owner_username, self.title) return '<Highlight by {}: {}>'.format(self.owner_username, self.title)
@ -1002,11 +1011,13 @@ class Highlight(Story):
def itemcount(self) -> int: def itemcount(self) -> int:
"""Count of items associated with the :class:`Highlight` instance.""" """Count of items associated with the :class:`Highlight` instance."""
self._fetch_items() self._fetch_items()
assert self._items is not None
return len(self._items) return len(self._items)
def get_items(self) -> Iterator[StoryItem]: def get_items(self) -> Iterator[StoryItem]:
"""Retrieve all associated highlight items.""" """Retrieve all associated highlight items."""
self._fetch_items() self._fetch_items()
assert self._items is not None
yield from (StoryItem(self._context, item, self.owner_profile) for item in self._items) yield from (StoryItem(self._context, item, self.owner_profile) for item in self._items)