kopia lustrzana https://github.com/halcy/Mastodon.py
fix various issues. some remain.
rodzic
826a6f457a
commit
d891589092
|
@ -380,7 +380,7 @@ class Mastodon(Internals):
|
||||||
|
|
||||||
@api_version("4.0.0", "4.0.0", _DICT_VERSION_ADMIN_DOMAIN_BLOCK)
|
@api_version("4.0.0", "4.0.0", _DICT_VERSION_ADMIN_DOMAIN_BLOCK)
|
||||||
def admin_domain_blocks(self, id: Optional[IdType] = None, max_id: Optional[IdType] = None, min_id: Optional[IdType] = None,
|
def admin_domain_blocks(self, id: Optional[IdType] = None, max_id: Optional[IdType] = None, min_id: Optional[IdType] = None,
|
||||||
since_id: Optional[IdType] = None, limit: Optional[int] = None) -> PaginatableList[AdminDomainBlock]:
|
since_id: Optional[IdType] = None, limit: Optional[int] = None) -> Union[AdminDomainBlock, PaginatableList[AdminDomainBlock]]:
|
||||||
"""
|
"""
|
||||||
Fetches a list of blocked domains. Requires scope `admin:read:domain_blocks`.
|
Fetches a list of blocked domains. Requires scope `admin:read:domain_blocks`.
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ from mastodon.errors import MastodonNetworkError, MastodonIllegalArgumentError,
|
||||||
MastodonGatewayTimeoutError, MastodonServerError, MastodonAPIError, MastodonMalformedEventError
|
MastodonGatewayTimeoutError, MastodonServerError, MastodonAPIError, MastodonMalformedEventError
|
||||||
from mastodon.compat import urlparse, magic, PurePath, Path
|
from mastodon.compat import urlparse, magic, PurePath, Path
|
||||||
from mastodon.defaults import _DEFAULT_STREAM_TIMEOUT, _DEFAULT_STREAM_RECONNECT_WAIT_SEC
|
from mastodon.defaults import _DEFAULT_STREAM_TIMEOUT, _DEFAULT_STREAM_RECONNECT_WAIT_SEC
|
||||||
from mastodon.types import AttribAccessDict, try_cast_recurse
|
from mastodon.types import AttribAccessDict, PaginatableList, try_cast_recurse
|
||||||
from mastodon.types import *
|
from mastodon.types import *
|
||||||
|
|
||||||
###
|
###
|
||||||
|
@ -271,10 +271,9 @@ class Mastodon():
|
||||||
response = response_object.content
|
response = response_object.content
|
||||||
|
|
||||||
# Parse link headers
|
# Parse link headers
|
||||||
if isinstance(response, list) and \
|
if isinstance(response, list) and 'Link' in response_object.headers and response_object.headers['Link'] != "":
|
||||||
'Link' in response_object.headers and \
|
if not isinstance(response, PaginatableList):
|
||||||
response_object.headers['Link'] != "":
|
response = PaginatableList(response)
|
||||||
response = AttribAccessList(response)
|
|
||||||
tmp_urls = requests.utils.parse_header_links(
|
tmp_urls = requests.utils.parse_header_links(
|
||||||
response_object.headers['Link'].rstrip('>').replace('>,<', ',<'))
|
response_object.headers['Link'].rstrip('>').replace('>,<', ',<'))
|
||||||
for url in tmp_urls:
|
for url in tmp_urls:
|
||||||
|
@ -301,18 +300,12 @@ class Mastodon():
|
||||||
del next_params['min_id']
|
del next_params['min_id']
|
||||||
response._pagination_next = next_params
|
response._pagination_next = next_params
|
||||||
|
|
||||||
# Maybe other API users rely on the pagination info in the last item
|
|
||||||
# Will be removed in future
|
|
||||||
if isinstance(response[-1], AttribAccessDict):
|
|
||||||
response[-1]._pagination_next = next_params
|
|
||||||
|
|
||||||
if url['rel'] == 'prev':
|
if url['rel'] == 'prev':
|
||||||
# Be paranoid and extract since_id or min_id specifically
|
# Be paranoid and extract since_id or min_id specifically
|
||||||
prev_url = url['url']
|
prev_url = url['url']
|
||||||
|
|
||||||
# Old and busted (pre-2.6.0): since_id pagination
|
# Old and busted (pre-2.6.0): since_id pagination
|
||||||
matchgroups = re.search(
|
matchgroups = re.search(r"[?&]since_id=([^&]+)", prev_url)
|
||||||
r"[?&]since_id=([^&]+)", prev_url)
|
|
||||||
if matchgroups:
|
if matchgroups:
|
||||||
prev_params = copy.deepcopy(params)
|
prev_params = copy.deepcopy(params)
|
||||||
prev_params['_pagination_method'] = method
|
prev_params['_pagination_method'] = method
|
||||||
|
@ -326,14 +319,8 @@ class Mastodon():
|
||||||
del prev_params['max_id']
|
del prev_params['max_id']
|
||||||
response._pagination_prev = prev_params
|
response._pagination_prev = prev_params
|
||||||
|
|
||||||
# Maybe other API users rely on the pagination info in the first item
|
|
||||||
# Will be removed in future
|
|
||||||
if isinstance(response[0], AttribAccessDict):
|
|
||||||
response[0]._pagination_prev = prev_params
|
|
||||||
|
|
||||||
# New and fantastico (post-2.6.0): min_id pagination
|
# New and fantastico (post-2.6.0): min_id pagination
|
||||||
matchgroups = re.search(
|
matchgroups = re.search(r"[?&]min_id=([^&]+)", prev_url)
|
||||||
r"[?&]min_id=([^&]+)", prev_url)
|
|
||||||
if matchgroups:
|
if matchgroups:
|
||||||
prev_params = copy.deepcopy(params)
|
prev_params = copy.deepcopy(params)
|
||||||
prev_params['_pagination_method'] = method
|
prev_params['_pagination_method'] = method
|
||||||
|
@ -346,11 +333,6 @@ class Mastodon():
|
||||||
if "max_id" in prev_params:
|
if "max_id" in prev_params:
|
||||||
del prev_params['max_id']
|
del prev_params['max_id']
|
||||||
response._pagination_prev = prev_params
|
response._pagination_prev = prev_params
|
||||||
|
|
||||||
# Maybe other API users rely on the pagination info in the first item
|
|
||||||
# Will be removed in future
|
|
||||||
if isinstance(response[0], AttribAccessDict):
|
|
||||||
response[0]._pagination_prev = prev_params
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
|
@ -137,72 +137,28 @@ class MaybeSnowflakeIdType(str):
|
||||||
"""
|
"""
|
||||||
return str(self.__val)
|
return str(self.__val)
|
||||||
|
|
||||||
"""
|
|
||||||
IDs returned from Mastodon.py ar either primitive (int or str) or snowflake
|
|
||||||
(still int or str, but potentially convertible to datetime).
|
|
||||||
"""
|
|
||||||
IdType = Union[PrimitiveIdType, MaybeSnowflakeIdType]
|
|
||||||
|
|
||||||
T = TypeVar('T')
|
|
||||||
class PaginatableList(List[T]):
|
|
||||||
"""
|
|
||||||
This is a list with pagination information attached.
|
|
||||||
|
|
||||||
It is returned by the API when a list of items is requested, and the response contains
|
|
||||||
a Link header with pagination information.
|
|
||||||
"""
|
|
||||||
def __getattr__(self, attr):
|
|
||||||
if attr in self:
|
|
||||||
return self[attr]
|
|
||||||
else:
|
|
||||||
raise AttributeError(f"Attribute not found: {attr}")
|
|
||||||
|
|
||||||
def __setattr__(self, attr, val):
|
|
||||||
if attr in self:
|
|
||||||
raise AttributeError("Attribute-style access is read only")
|
|
||||||
super(NonPaginatableList, self).__setattr__(attr, val)
|
|
||||||
# TODO add the pagination housekeeping stuff
|
|
||||||
|
|
||||||
class NonPaginatableList(List[T]):
|
|
||||||
"""
|
|
||||||
This is just a list. I am subclassing the regular list out of pure paranoia about
|
|
||||||
potential oversights that might require me to add things to it later.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Lists in Mastodon.py are either regular or paginatable
|
|
||||||
EntityList = Union[NonPaginatableList[T], PaginatableList[T]]
|
|
||||||
|
|
||||||
# Backwards compat alias
|
|
||||||
AttribAccessList = EntityList
|
|
||||||
|
|
||||||
# Helper functions for typecasting attempts
|
# Helper functions for typecasting attempts
|
||||||
def try_cast(t, value, retry = True):
|
def try_cast(t, value, retry = True):
|
||||||
"""
|
"""
|
||||||
Base case casting function. Handles:
|
Base case casting function. Handles:
|
||||||
* Casting to any AttribAccessDict subclass (directly, no special handling)
|
* Casting to any AttribAccessDict subclass (directly, no special handling)
|
||||||
* Casting to MaybeSnowflakeIdType (directly, no special handling)
|
|
||||||
* Casting to bool (with possible conversion from json bool strings)
|
* Casting to bool (with possible conversion from json bool strings)
|
||||||
* Casting to datetime (with possible conversion from all kinds of funny date formats because unfortunately this is the world we live in)
|
* Casting to datetime (with possible conversion from all kinds of funny date formats because unfortunately this is the world we live in)
|
||||||
|
* Casting to whatever t is
|
||||||
|
* Trying once again to AttribAccessDict as a fallback
|
||||||
Gives up and returns as-is if none of the above work.
|
Gives up and returns as-is if none of the above work.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if issubclass(t, AttribAccessDict) or t is MaybeSnowflakeIdType:
|
if issubclass(t, AttribAccessDict):
|
||||||
try:
|
value = t(**value)
|
||||||
value = t(**value)
|
elif issubclass(t, bool):
|
||||||
except:
|
|
||||||
try:
|
|
||||||
value = AttribAccessDict(**value)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
elif isinstance(t, bool):
|
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
if value.lower() == 'true':
|
if value.lower() == 'true':
|
||||||
value = True
|
value = True
|
||||||
elif value.lower() == 'false':
|
elif value.lower() == 'false':
|
||||||
value = False
|
value = False
|
||||||
value = bool(value)
|
value = bool(value)
|
||||||
elif isinstance(t, datetime):
|
elif issubclass(t, datetime):
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
value = datetime.fromtimestamp(value, timezone.utc)
|
value = datetime.fromtimestamp(value, timezone.utc)
|
||||||
elif isinstance(value, str):
|
elif isinstance(value, str):
|
||||||
|
@ -211,8 +167,11 @@ def try_cast(t, value, retry = True):
|
||||||
value = datetime.fromtimestamp(value_int, timezone.utc)
|
value = datetime.fromtimestamp(value_int, timezone.utc)
|
||||||
except:
|
except:
|
||||||
value = dateutil.parser.parse(value)
|
value = dateutil.parser.parse(value)
|
||||||
except:
|
else:
|
||||||
value = try_cast(AttribAccessDict, value, False)
|
value = t(**value)
|
||||||
|
except Exception as e:
|
||||||
|
if retry:
|
||||||
|
value = try_cast(AttribAccessDict, value, False)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def try_cast_recurse(t, value):
|
def try_cast_recurse(t, value):
|
||||||
|
@ -241,6 +200,38 @@ def try_cast_recurse(t, value):
|
||||||
pass
|
pass
|
||||||
return try_cast(t, value)
|
return try_cast(t, value)
|
||||||
|
|
||||||
|
"""
|
||||||
|
IDs returned from Mastodon.py ar either primitive (int or str) or snowflake
|
||||||
|
(still int or str, but potentially convertible to datetime).
|
||||||
|
"""
|
||||||
|
IdType = Union[PrimitiveIdType, MaybeSnowflakeIdType]
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
class PaginatableList(List[T]):
|
||||||
|
"""
|
||||||
|
This is a list with pagination information attached.
|
||||||
|
|
||||||
|
It is returned by the API when a list of items is requested, and the response contains
|
||||||
|
a Link header with pagination information.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Initializes basic list and adds empty pagination information.
|
||||||
|
"""
|
||||||
|
super(PaginatableList, self).__init__(*args, **kwargs)
|
||||||
|
self._pagination_next = None
|
||||||
|
self._pagination_prev = None
|
||||||
|
|
||||||
|
class NonPaginatableList(List[T]):
|
||||||
|
"""
|
||||||
|
This is just a list. I am subclassing the regular list out of pure paranoia about
|
||||||
|
potential oversights that might require me to add things to it later.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
"""Lists in Mastodon.py are either regular or paginatable"""
|
||||||
|
EntityList = Union[NonPaginatableList[T], PaginatableList[T]]
|
||||||
|
|
||||||
class AttribAccessDict(OrderedDict[str, Any]):
|
class AttribAccessDict(OrderedDict[str, Any]):
|
||||||
"""
|
"""
|
||||||
Base return object class for Mastodon.py.
|
Base return object class for Mastodon.py.
|
||||||
|
@ -256,12 +247,12 @@ class AttribAccessDict(OrderedDict[str, Any]):
|
||||||
Constructor that calls through to dict constructor and then sets attributes for all keys.
|
Constructor that calls through to dict constructor and then sets attributes for all keys.
|
||||||
"""
|
"""
|
||||||
super(AttribAccessDict, self).__init__()
|
super(AttribAccessDict, self).__init__()
|
||||||
if __annotations__ in self.__class__.__dict__:
|
if "__annotations__" in self.__class__.__dict__:
|
||||||
for attr, _ in self.__class__.__annotations__.items():
|
for attr, _ in self.__class__.__annotations__.items():
|
||||||
attr_name = attr
|
attr_name = attr
|
||||||
if hasattr(self.__class__, "_rename_map"):
|
if hasattr(self.__class__, "_rename_map"):
|
||||||
attr_name = getattr(self.__class__, "_rename_map").get(attr, attr)
|
attr_name = getattr(self.__class__, "_rename_map").get(attr, attr)
|
||||||
if attr_name in kwargs:
|
if attr_name in kwargs:
|
||||||
self[attr] = kwargs[attr_name]
|
self[attr] = kwargs[attr_name]
|
||||||
assert not attr in kwargs, f"Duplicate attribute {attr}"
|
assert not attr in kwargs, f"Duplicate attribute {attr}"
|
||||||
elif attr in kwargs:
|
elif attr in kwargs:
|
||||||
|
@ -337,6 +328,23 @@ class AttribAccessDict(OrderedDict[str, Any]):
|
||||||
super(AttribAccessDict, self).__setattr__(key, val)
|
super(AttribAccessDict, self).__setattr__(key, val)
|
||||||
super(AttribAccessDict, self).__setitem__(key, val)
|
super(AttribAccessDict, self).__setitem__(key, val)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
"""
|
||||||
|
Equality checker with casting
|
||||||
|
"""
|
||||||
|
if isinstance(other, self.__class__):
|
||||||
|
return super(AttribAccessDict, self).__eq__(other)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
casted = try_cast_recurse(self.__class__, other)
|
||||||
|
if isinstance(casted, self.__class__):
|
||||||
|
return super(AttribAccessDict, self).__eq__(casted)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
"""An entity returned by the Mastodon API is either a dict or a list"""
|
"""An entity returned by the Mastodon API is either a dict or a list"""
|
||||||
Entity = Union[AttribAccessDict, EntityList]
|
Entity = Union[AttribAccessDict, EntityList]
|
||||||
|
|
||||||
|
@ -344,4 +352,7 @@ Entity = Union[AttribAccessDict, EntityList]
|
||||||
WebpushCryptoParamsPubkey = Dict[str, str]
|
WebpushCryptoParamsPubkey = Dict[str, str]
|
||||||
|
|
||||||
"""A type containing the parameters for a derypting webpush data. Considered opaque / implementation detail."""
|
"""A type containing the parameters for a derypting webpush data. Considered opaque / implementation detail."""
|
||||||
WebpushCryptoParamsPrivkey = Dict[str, str]
|
WebpushCryptoParamsPrivkey = Dict[str, str]
|
||||||
|
|
||||||
|
"""Backwards compatibility alias"""
|
||||||
|
AttribAccessList = PaginatableList
|
|
@ -1,18 +1,42 @@
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from mastodon.types import IdType
|
||||||
|
import typing
|
||||||
|
|
||||||
|
def get_type_class(typ):
|
||||||
|
try:
|
||||||
|
return typ.__extra__
|
||||||
|
except AttributeError:
|
||||||
|
try:
|
||||||
|
return typ.__origin__
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
return typ
|
||||||
|
|
||||||
|
|
||||||
|
def real_issubclass(obj1, type2orig):
|
||||||
|
type1 = get_type_class(type(obj1))
|
||||||
|
type2 = get_type_class(type2orig)
|
||||||
|
valid_types = []
|
||||||
|
if type2 is typing.Union:
|
||||||
|
valid_types = type2orig.__args__
|
||||||
|
elif type2 is typing.Generic:
|
||||||
|
valid_types = [type2orig.__args__[0]]
|
||||||
|
else:
|
||||||
|
valid_types = [type2orig]
|
||||||
|
return issubclass(type1, tuple(valid_types))
|
||||||
|
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
def test_id_hook(status):
|
def test_id_hook(status):
|
||||||
assert isinstance(status['id'], int)
|
assert real_issubclass(status['id'], IdType)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
def test_id_hook_in_reply_to(api, status):
|
def test_id_hook_in_reply_to(api, status):
|
||||||
reply = api.status_post('Reply!', in_reply_to_id=status['id'])
|
reply = api.status_post('Reply!', in_reply_to_id=status['id'])
|
||||||
try:
|
try:
|
||||||
assert isinstance(reply['in_reply_to_id'], int)
|
assert real_issubclass(reply['in_reply_to_id'], IdType)
|
||||||
assert isinstance(reply['in_reply_to_account_id'], int)
|
assert real_issubclass(reply['in_reply_to_account_id'], IdType)
|
||||||
finally:
|
finally:
|
||||||
api.status_delete(reply['id'])
|
api.status_delete(reply['id'])
|
||||||
|
|
||||||
|
@ -21,18 +45,17 @@ def test_id_hook_in_reply_to(api, status):
|
||||||
def test_id_hook_within_reblog(api, status):
|
def test_id_hook_within_reblog(api, status):
|
||||||
reblog = api.status_reblog(status['id'])
|
reblog = api.status_reblog(status['id'])
|
||||||
try:
|
try:
|
||||||
assert isinstance(reblog['reblog']['id'], int)
|
assert real_issubclass(reblog['reblog']['id'], IdType)
|
||||||
finally:
|
finally:
|
||||||
api.status_delete(reblog['id'])
|
api.status_delete(reblog['id'])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
def test_date_hook(status):
|
def test_date_hook(status):
|
||||||
assert isinstance(status['created_at'], datetime)
|
assert real_issubclass(status['created_at'], datetime)
|
||||||
|
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
def test_attribute_access(status):
|
def test_attribute_access(status):
|
||||||
assert status.id is not None
|
assert status.id is not None
|
||||||
with pytest.raises(AttributeError):
|
status.id = 420
|
||||||
status.id = 420
|
|
||||||
|
|
Ładowanie…
Reference in New Issue