File: types_base.py

package info (click to toggle)
python-mastodon 2.1.3-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 22,836 kB
  • sloc: python: 9,438; makefile: 206; sql: 98; sh: 27
file content (764 lines) | stat: -rw-r--r-- 34,685 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
from __future__ import annotations # python < 3.9 compat
import typing
from typing import List, Union, Optional, Dict, Any, Tuple, Callable, get_type_hints, TypeVar, IO, Generic, ForwardRef
from datetime import datetime, timezone
import dateutil
import dateutil.parser
from collections import OrderedDict
from mastodon.compat import PurePath
import sys
import json
import copy

# A type representing a file name as a PurePath or string, or a file-like object, for convenience
PathOrFile = Union[str, PurePath, IO[bytes]]

BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
def base62_to_int(base62: str) -> int:
    """
    internal helper for *oma compat: convert a base62 string to an int since
    that is what that software uses as ID type.

    we don't convert IDs in general, but this is needed for snowflake ID
    calculations.
    """
    str_len = len(base62)
    val = 0
    base62 = base62.lower()
    for idx, char in enumerate(base62):
        power = (str_len - (idx + 1))
        val += BASE62_ALPHABET.index(char) * (62 ** power)
    return val

def int_to_base62(val: int) -> str:
    """
    Internal helper to convert an int to a base62 string.
    """
    if val == 0:
        return BASE62_ALPHABET[0]

    base62 = []
    while val:
        val, digit = divmod(val, 62)
        base62.append(BASE62_ALPHABET[digit])
    return ''.join(reversed(base62))


PrimitiveIdType = Union[str, int]
"""
The base type for all non-snowflake IDs. This is a union of int and str 
because while Mastodon mostly uses IDs that are ints, it doesn't guarantee
this and other implementations do not use integer IDs.

In a change from previous versions, string IDs now take precedence over ints.
This is a breaking change, and I'm sorry about it, but this will make every piece
of software using Mastodon.py more robust in the long run.
"""

def _str_to_type(mastopy_type):
    """
    String name to internal type resolver
    """
    # See if we need to parse a sub-type (i.e. [<something>] in the type name
    sub_type = None
    if "[" in mastopy_type and "]" in mastopy_type:
        mastopy_type, sub_type = mastopy_type.split("[")
        sub_type = sub_type[:-1]
        if mastopy_type not in ["PaginatableList", "NonPaginatableList", "typing.Optional", "typing.Union"]:
            raise ValueError(f"Subtype not allowed for type {mastopy_type} and subtype {sub_type}")
    if "[" in mastopy_type or "]" in mastopy_type:
        raise ValueError(f"Invalid type {mastopy_type}")
    if sub_type is not None and ("[" in sub_type or "]" in sub_type):
        raise ValueError(f"Invalid subtype {sub_type}")
    
    # Build the actual type object. 
    from mastodon.return_types import ENTITY_NAME_MAP
    full_type = None
    if sub_type is not None:
        if not mastopy_type == "typing.Union":
            sub_type = ENTITY_NAME_MAP.get(sub_type, None)
        else:
            sub_type_list = []
            for sub_type_part in sub_type.split(","):
                sub_type_part = sub_type_part.strip()
                if sub_type_part:
                    sub_type_part_type = ENTITY_NAME_MAP.get(sub_type_part, None)
                    if sub_type_part_type is not None:
                        sub_type_list.append(sub_type_part_type)
        if mastopy_type == "PaginatableList":
            full_type = PaginatableList[sub_type]
        elif mastopy_type == "NonPaginatableList":
            full_type = NonPaginatableList[sub_type]
        elif mastopy_type == "typing.Optional":
            full_type = Optional[sub_type]
        elif mastopy_type == "typing.Union":
            full_type = Union.__getitem__(tuple(sub_type_list))
    else:
        full_type = ENTITY_NAME_MAP.get(mastopy_type, None)
    if full_type is None:
        raise ValueError(f"Unknown type {mastopy_type}")
    return full_type

class MaybeSnowflakeIdType(str):
    """
    Represents, maybe, a snowflake ID.

    Contains what a regular ID can contain (int or str) and will convert to int if
    containing an int or a str that naturally converts to an int (e.g. "123").

    Can *also* contain a *datetime* which gets converted to a  timestamp.

    It's also just *maybe* a snowflake ID, because some implementations may not use those.

    This may seem annoyingly complex, but the goal here is:
    1) If constructed with some ID, return that ID unchanged, so equality and hashing work
    2) Allow casting to int and str, just like a regular ID
    3) Halfway transparently convert to and from datetime with the correct format for the server we're talking to
    """
    def __new__(cls, value, *args, **kwargs):
        try:
            return super(cls, cls).__new__(cls, value)
        except:
            return object.__new__(cls)

    def __init__(self, val: Union[PrimitiveIdType, datetime], assume_pleroma: bool = False):
        try:
            super(MaybeSnowflakeIdType, self).__init__()
        except:
            pass
        if isinstance(val, (int, str)):
            self.__val = val
        elif isinstance(val, datetime):
            self.__val = (int(val.timestamp()) << 16) * 1000
            if assume_pleroma:
                self.__val = int_to_base62(self.__val)
        else:
            raise TypeError(f"Expected int or str, got {type(val).__name__}")
        self.assume_pleroma = assume_pleroma
        
    def to_datetime(self) -> Optional[datetime]:
        """
        Convert to datetime. This *can* fail because not every implementation of
        the masto API is guaranteed to actually use snowflake IDs where masto uses
        snowflake IDs, so it can in fact return None.
        """
        val = self.__val
        try:
            # Pleroma ID compat. First, try to just cast to int. If that fails *or*
            # if we are told to assume Pleroma, try to convert from base62.
            if isinstance(self.__val, str):
                try_base62 = False
                try:
                    val = int(self.__val)
                except:
                    try_base62 = True
                if try_base62 or self.assume_pleroma:
                    val = base62_to_int(self.__val)
        except:
            return None
        
        # TODO: This matches the masto approach, whether this matches the
        # Pleroma approach is to be verified.
        timestamp_s = int(int(val) / 1000) >> 16
        return datetime.fromtimestamp(timestamp_s)

    def __str__(self) -> str:
        """
        Return as string representation.
        """
        return str(self.__val)

    def __int__(self) -> int:
        """
        Return as int representation.

        This is not guaranteed to work, because the ID might be a string,
        though on Mastodon it is generally going to be an int.
        """
        if isinstance(self.__val, str):
            return int(self.__val)
        return self.__val
    
    def __repr__(self) -> str:
        """
        Overriden so that the integer representation doesn't take precedence
        """
        return str(self.__val)

# Forward reference resolution for < 3.9
if sys.version_info < (3, 9):
    def resolve_type(t):
        # I'm sorry about this, but I cannot think of another way to make this work properly in versions below 3.9 that
        # cannot resolve forward references in a sane way
        from mastodon.return_types import Account, AccountField, Role, CredentialAccountSource, \
            Status, Quote, ShallowQuote, StatusEdit, FilterResult, StatusMention, \
            ScheduledStatus, ScheduledStatusParams, Poll, PollOption, Conversation, Tag, \
            TagHistory, CustomEmoji, Application, Relationship, Filter, FilterV2, \
            Notification, Context, UserList, MediaAttachment, MediaAttachmentMetadataContainer, MediaAttachmentImageMetadata, \
            MediaAttachmentVideoMetadata, MediaAttachmentAudioMetadata, MediaAttachmentFocusPoint, MediaAttachmentColors, PreviewCard, TrendingLinkHistory, \
            PreviewCardAuthor, Search, SearchV2, Instance, InstanceConfiguration, InstanceURLs, \
            InstanceV2, InstanceIcon, InstanceConfigurationV2, InstanceVapidKey, InstanceURLsV2, InstanceThumbnail, \
            InstanceThumbnailVersions, InstanceStatistics, InstanceUsage, InstanceUsageUsers, RuleTranslation, Rule, \
            InstanceRegistrations, InstanceContact, InstanceAccountConfiguration, InstanceStatusConfiguration, InstanceTranslationConfiguration, InstanceMediaConfiguration, \
            InstancePollConfiguration, Nodeinfo, NodeinfoSoftware, NodeinfoServices, NodeinfoUsage, NodeinfoUsageUsers, \
            NodeinfoMetadata, Activity, Report, AdminReport, WebPushSubscription, WebPushSubscriptionAlerts, \
            PushNotification, Preferences, FeaturedTag, Marker, Announcement, Reaction, \
            StreamReaction, FamiliarFollowers, AdminAccount, AdminIp, AdminMeasure, AdminMeasureData, \
            AdminDimension, AdminDimensionData, AdminRetention, AdminCohort, AdminDomainBlock, AdminCanonicalEmailBlock, \
            AdminDomainAllow, AdminEmailDomainBlock, AdminEmailDomainBlockHistory, AdminIpBlock, DomainBlock, ExtendedDescription, \
            FilterKeyword, FilterStatus, IdentityProof, StatusSource, Suggestion, Translation, \
            AccountCreationError, AccountCreationErrorDetails, AccountCreationErrorDetailsField, NotificationPolicy, NotificationPolicySummary, RelationshipSeveranceEvent, \
            GroupedNotificationsResults, PartialAccountWithAvatar, NotificationGroup, AccountWarning, UnreadNotificationsCount, Appeal, \
            NotificationRequest, SupportedLocale, OAuthServerInfo, OAuthUserInfo, TermsOfService
        if isinstance(t, ForwardRef):
            try:
                t = t._evaluate(globals(), locals(), frozenset())
            except:
                t = t._evaluate(globals(), locals())
        return t
else:
    def resolve_type(t):
        return t

# Type to string that is more robust than repr
def stringify_type(tp):
    try:
        origin = typing.get_origin(tp)
        args = typing.get_args(tp)
        if origin is not None:
            origin_module = origin.__module__
            origin_name = origin.__qualname__
            if origin in [list, EntityList, PaginatableList, NonPaginatableList]:
                if origin_module in ("mastodon.return_types", "mastodon.types_base"):
                    type_str = origin_name
                else:
                    type_str = f"{origin_module}.{origin_name}"
                if args:
                    arg_strs = [stringify_type(arg) for arg in args]
                    type_str += f"[{', '.join(arg_strs)}]"
            elif origin in [Union, Optional]:
                type_str = stringify_type(args[0])
            return type_str
        else:
            module = getattr(tp, "__module__", "")
            qualname = getattr(tp, "__qualname__", str(tp))
            if module in ("mastodon.return_types", "mastodon.types_base"):
                return qualname
            return f"{module}.{qualname}"
    except Exception:
        return str(tp)

# Function that gets a type class but doesn't break in lower python versions as much
def get_type_class(typ):
    try:
        return typ.__extra__
    except AttributeError:
        try:
            return typ.__origin__
        except AttributeError:
            pass
    return typ

# Restore behaviour that was removed from python for mysterious reasons
def real_issubclass(type1, type2orig):
    type1 = get_type_class(type1)
    type2 = get_type_class(type2orig)
    valid_types = []
    if type2 is Union:
        valid_types = type2orig.__args__
    elif type2 is Generic:
        valid_types = [type2orig.__args__[0]]
    else:
        valid_types = [type2]
    return issubclass(type1, tuple(valid_types))

# Helper functions for typecasting attempts
def try_cast(t, value, retry = True, union_specializer = None):
    """
    Base case casting function. Handles:
    * Casting to any AttribAccessDict subclass (directly, no special handling)
    * 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 whatever t is
    * Trying once again to AttribAccessDict as a fallback
    Gives up and returns as-is if none of the above work.
    """
    if value is None: # None early out
        return value
    t = resolve_type(t)
    if type(t) == TypeVar: # TypeVar early out with an attempt at coercing dicts
        if isinstance(value, dict):
            return try_cast(AttribAccessDict, value, False, union_specializer)
        else:
            return value
    try:
        if real_issubclass(t, AttribAccessDict):
            if union_specializer is not None:
                value["__union_specializer"] = union_specializer
            value = t(**value)

            # Did we have type arguments on the dict? If so, we need to try to cast the values
            # This will not work in 3.7 and 3.8, which is unfortunate, but them's the breaks of using
            # very old versions.
            if hasattr(t, '__args__') and len(t.__args__) > 1:
                value_cast_type = t.__args__[1]
                for key, val in value.items():
                    value[key] = try_cast_recurse(value_cast_type, val, union_specializer)
        elif real_issubclass(t, bool):
            if isinstance(value, str):
                if value.lower() == 'true':
                    value = True
                elif value.lower() == 'false':
                    value = False
                else:
                    # Invalid values are None'd
                    value = None
            # We assume that if it's not a string, it validly converts to bool
            # this is a potentially foolish assumption, but :shrug:
            value = bool(value)
        elif real_issubclass(t, datetime):
            if isinstance(value, int):
                value = datetime.fromtimestamp(value, timezone.utc)
            elif isinstance(value, str):
                try:
                    value_int = int(value)
                    value = datetime.fromtimestamp(value_int, timezone.utc)
                except:
                    try:
                        value = dateutil.parser.parse(value)
                    except:
                        # Invalid values are, once again, None'd
                        value = None
        elif real_issubclass(t, int):
            try:
                value = int(value)
            except:
                # You know the drill
                value = None
        elif real_issubclass(t, float):
            try:
                value = float(value)
            except:
                # One last time
                value = None   
        elif real_issubclass(t, list):
            if not t in [PaginatableList, NonPaginatableList]:
                # we never want base case lists
                t = NonPaginatableList
            value = t(value)
        else:
            if real_issubclass(value.__class__, dict):
                value = t(**value)
            else:
                value = t(value)
    except Exception as e:
        # Failures are silently ignored, usually.
        # import traceback
        # traceback.print_exc()
        if retry and isinstance(value, dict):
            value = try_cast(AttribAccessDict, value, False, union_specializer)
    return value

def try_cast_recurse(t, value, union_specializer=None):
    """
    Non-dict compound type casting function. Handles:
    * Casting to list, tuple, EntityList or (Non)PaginatableList, converting all elements to the correct type recursively
    * Casting to Union, use union_specializer to special case the union type to the correct one
    * Casting to Union, special case out Quote vs ShallowQuote by the presence of "quoted_status" or "quoted_status_id" in the value
    * Casting to Union, trying all types in the union until one works
    Gives up and returns as-is if none of the above work.
    """
    if type(t) == str:
        t = _str_to_type(t)
    if value is None:
        return value
    t = resolve_type(t)
    real_type = None
    use_real_type = False
    try:
        if hasattr(t, '__origin__') or hasattr(t, '__extra__'):
            orig_type = get_type_class(t)
            if orig_type in (list, tuple, EntityList, NonPaginatableList, PaginatableList):
                if orig_type == list:
                    orig_type = NonPaginatableList
                value_cast = []
                type_args = t.__args__
                if len(type_args) == 1:
                    type_args = type_args * len(value)
                for element_type, v in zip(type_args, value):
                    value_cast.append(try_cast_recurse(element_type, v, union_specializer))
                value = orig_type(value_cast)
            elif orig_type is Union:
                if union_specializer is not None:
                    from mastodon.return_types import MediaAttachmentImageMetadata, MediaAttachmentVideoMetadata, MediaAttachmentAudioMetadata
                    real_type = {
                        "image": MediaAttachmentImageMetadata,
                        "video": MediaAttachmentVideoMetadata,
                        "audio": MediaAttachmentAudioMetadata,
                        "gifv": MediaAttachmentVideoMetadata,
                    }.get(union_specializer, None)
                if isinstance(value, dict) and "quoted_status_id" in value:
                    from mastodon.return_types import ShallowQuote
                    real_type = ShallowQuote
                elif isinstance(value, dict) and "quoted_status" in value:
                    from mastodon.return_types import Quote
                    real_type = Quote
                if real_type in t.__args__:
                    value = try_cast_recurse(real_type, value, union_specializer)
                    use_real_type = True
                    testing_t = real_type
                    if hasattr(t, '__origin__') or hasattr(t, '__extra__'):
                        testing_t = get_type_class(real_type)
                else:
                    for sub_t in t.__args__:
                        value = try_cast_recurse(sub_t, value, union_specializer)
                        testing_t = sub_t
                        if hasattr(t, '__origin__') or hasattr(t, '__extra__'):
                            testing_t = get_type_class(sub_t)
                        if isinstance(value, testing_t):
                            break
            else:
                # uhhh I don't know how we got here but try to cast to the type anyways
                value = try_cast(t, value, True, union_specializer)
        else:
            value = try_cast(t, value, True, union_specializer)
    except Exception as e:
        # Failures are silently ignored. We care about maximum not breaking here.
        # import traceback
        # traceback.print_exc()
        pass

    if real_issubclass(value.__class__, AttribAccessDict) or real_issubclass(value.__class__, PaginatableList) or real_issubclass(value.__class__, NonPaginatableList) or real_issubclass(value.__class__, MaybeSnowflakeIdType):
        save_type = t
        if real_type is not None and use_real_type:
            save_type = real_type
        try:
            value._mastopy_type = stringify_type(save_type)
        except Exception as e:
            try:
                # If the new robust method doesn't work, try the old and less robust method
                value._mastopy_type = repr(save_type)
            except:
                # Failures are silently ignored. We care about maximum not breaking here.
                pass
        value._mastopy_type = value._mastopy_type.replace("mastodon.return_types.", "").replace("mastodon.types_base.", "")
        if value._mastopy_type.startswith("<class '") and value._mastopy_type.endswith("'>"):
            value._mastopy_type = value._mastopy_type[8:-2]
    return value

class Entity():
    """
    Base class for everything returned by the API. This is a union of :class:`AttribAccessDict` and :class:`EntityList`.

    Defines two methods: to_json(), and (static) from_json(), for serializing and deserializing to/from JSON.
    """
    def __init__(self):
        self._mastopy_type = None
    
    def to_json(self, pretty=True) -> str:
        """
        Serialize to JSON.

        The returned JSON data includes type information and a version field.
        """
        mastopy_data = copy.deepcopy(self)

        # Recursively walk through the object, find every object with a class that has a _rename_map, and remove the renamed fields
        def remove_renamed_fields(obj):
            if isinstance(obj, dict):
                if hasattr(obj.__class__, "_rename_map"):
                    for field in getattr(obj.__class__, "_rename_map").values():
                        if field in obj:
                            del obj[field]
                for key in obj:
                    remove_renamed_fields(obj[key])
            elif isinstance(obj, list):
                for item in obj:
                    remove_renamed_fields(item)
        remove_renamed_fields(mastopy_data)

        serialize_data = {
            "_mastopy_version": "2.0.1",
            "_mastopy_type": self._mastopy_type,
            "_mastopy_data": mastopy_data,
            "_mastopy_extra_data": {}
        }

        if hasattr(self, "_pagination_next") and self._pagination_next is not None:
            serialize_data["_mastopy_extra_data"]["_pagination_next"] = self._pagination_next
        if hasattr(self, "_pagination_prev") and self._pagination_prev is not None:
            serialize_data["_mastopy_extra_data"]["_pagination_prev"] = self._pagination_prev

        def json_serial(obj):
            if isinstance(obj, datetime):
                return obj.isoformat()

        if pretty:
            return json.dumps(serialize_data, default=json_serial, indent=4)
        else:
            return json.dumps(serialize_data, default=json_serial)

    @staticmethod
    def from_json(json_str: str) -> Entity:
        """
        Deserialize from JSON.

        Parse a JSON string and cast to the to the appropriate type
        by using a special field that is added by serialization.

        This `should` be safe to call on any JSON string (no less safe than json.loads), 
        but I would still recommend to be very careful when using this on untrusted data 
        and to check that the returned value matches your expectations.

        There is currently a bug on specifically python 3.7 and 3.8 where the return value
        is not guaranteed to be of the right type. I will probably not fix this, since the versions
        are out of support, anyways. However, the data will still be loaded correctly.
        """
        # First, parse json normally. Can end up as a dict or a list.
        json_result = json.loads(json_str)

        # Read _mastopy_version field, throw error if not present
        # Not currently used, but we make sure it is there
        if "_mastopy_version" not in json_result:
            raise ValueError("JSON does not contain _mastopy_version field, refusing to parse.")
        
        # Read _mastopy_type field, throw error if not present
        if "_mastopy_type" not in json_result:
            raise ValueError("JSON does not contain _mastopy_type field, refusing to parse.")
        mastopy_type = json_result["_mastopy_type"]
        full_type = _str_to_type(mastopy_type)

        # Finally, try to cast to the generated type
        return_data = try_cast_recurse(full_type, json_result["_mastopy_data"])

        # Fill in pagination information if it is present in the persisted data
        if "_mastopy_extra_data" in json_result:
            if "_pagination_next" in json_result["_mastopy_extra_data"]:
                return_data._pagination_next = try_cast_recurse(PaginationInfo, json_result["_mastopy_extra_data"]["_pagination_next"])
                response_type = return_data._pagination_next.get("_mastopy_type", None)
                if response_type is not None:
                    return_data._pagination_next["_mastopy_type"] = _str_to_type(response_type)
            if "_pagination_prev" in json_result["_mastopy_extra_data"]:
                return_data._pagination_prev = try_cast_recurse(PaginationInfo, json_result["_mastopy_extra_data"]["_pagination_prev"])
                response_type = return_data._pagination_prev.get("_mastopy_type", None)
                if response_type is not None:
                    return_data._pagination_prev["_mastopy_type"] = _str_to_type(response_type)

        return return_data


class PaginationInfo(OrderedDict):
    """
    Pagination info

    Not likely to change, but very much implementation (Mastodon.py) and implementation (Mastodon server) defined. It would be best
    if you treated this as opaque.
    """
    pass

IdType = Union[PrimitiveIdType, MaybeSnowflakeIdType, datetime]
"""
IDs returned from Mastodon.py ar either primitive (int or str) or snowflake
(still int or str, but potentially convertible to datetime), but also
a datetime (which will get converted to a snowflake id).
"""

T = TypeVar('T')
class PaginatableList(List[T], Entity):
    """
    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.
    """
    _pagination_next: Optional[PaginationInfo]
    _pagination_prev: Optional[PaginationInfo]

    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], Entity):
    """
    This is just a list, without pagination information but
    annotated for serialization and deserialization.
    """
    def __init__(self, *args, **kwargs):
        super(NonPaginatableList, self).__init__(*args, **kwargs)

EntityList = Union[NonPaginatableList[T], PaginatableList[T]]
"""Lists in Mastodon.py are either regular or paginatable, so this is a union of
   :class:`NonPaginatableList` and :class:`PaginatableList`."""

try:
    OrderedStrDict = OrderedDict[str, Any]
except:
    OrderedStrDict = OrderedDict

class AttribAccessDict(OrderedStrDict, Entity):
    """
    Base return object class for Mastodon.py.

    Inherits from dict, but allows access via attributes as well as if it was a dataclass.

    While the subclasses implement specific fields with proper typing, parsing and documentation,
    they all inherit from this class, and parsing is extremely permissive to allow for forward and
    backward compatibility as well as compatibility with other implementations of the Mastodon API.

    This class can ALSO have pagination information attached, for paginating lists *inside* the object,
    because that's what Mastodon 4.3.0 does for groupee notifications. This is special cased in the class
    definition, though.
    """
    def __init__(self, **kwargs):
        """
        Constructor that calls through to dict constructor and then sets attributes for all keys.
        """
        super(AttribAccessDict, self).__init__()
        if "__union_specializer" in kwargs:
            self.__union_specializer = kwargs["__union_specializer"]
            del kwargs["__union_specializer"]
        if "__annotations__" in self.__class__.__dict__:
            for attr, _ in self.__class__.__annotations__.items():
                attr_name = attr
                if hasattr(self.__class__, "_rename_map"):
                    attr_name = getattr(self.__class__, "_rename_map").get(attr, attr)
                    if attr_name in kwargs:
                        self[attr] = kwargs[attr_name]
                        assert not attr in kwargs, f"Duplicate attribute {attr}"
                elif attr in kwargs:
                    self[attr] = kwargs[attr]
                else:
                    self[attr] = None
        for attr in kwargs:
            if not attr in self:
                self[attr] = kwargs[attr]
                
    def __getattribute__(self, attr):
        """
        Override to force access of normal attributes to go through __getattr__
        """
        if attr in ["_AttribAccessDict__union_specializer", "_mastopy_type", "__class__"]:
            return super(AttribAccessDict, self).__getattribute__(attr)
        if attr in self.__class__.__annotations__:
            return self.__getattr__(attr)
        return super(AttribAccessDict, self).__getattribute__(attr)

    def __getattr__(self, attr):
        """
        Basic attribute getter that throws if attribute is not in dict and supports redirecting access.
        """        
        if not hasattr(self.__class__, "_access_map"):
            # Base case: no redirecting
            if attr in self:
                return self[attr]
            else:
                return super(AttribAccessDict, self).__getattribute__(attr)
        else:
            if attr in self and self[attr] is not None:
                return self[attr]
            elif attr in getattr(self.__class__, "_access_map"):
                try:
                    attr_path = getattr(self.__class__, "_access_map")[attr].split('.')
                    cur_attr = self
                    for attr_path_part in attr_path:
                        cur_attr = getattr(cur_attr, attr_path_part)
                    return cur_attr
                except:
                    raise AttributeError(f"Attribute not found: {attr}")
            else:
                return super(AttribAccessDict, self).__getattribute__(attr)
            
    def __setattr__(self, attr, val):
        """
        Attribute setter that calls through to dict setter but will throw if attribute is not in dict
        """
        if attr in self or attr in ["_AttribAccessDict__union_specializer", "_mastopy_type"]:
            if attr == "_mastopy_type":
                super(AttribAccessDict, self).__setattr__(attr, val)
            else:
                self[attr] = val
        else:
            raise AttributeError(f"Attribute not found: {attr}")

    def __setitem__(self, key, val):
        """
        Dict setter that also sets attributes and tries to typecast if we have an 
        AttribAccessDict, EntityList or MaybeSnowflakeIdType type hint.

        For Unions, we special case explicitly to specialize.
        """
        # If we're already an AttribAccessDict subclass, skip all the casting
        if not isinstance(val, AttribAccessDict):
            # Collate type hints that we may have
            type_hints = {}
            try:
                type_hints = get_type_hints(self.__class__)
            except:
                pass
            init_hints = {}
            try:
                init_hints = get_type_hints(self.__class__.__init__)
            except:
                pass
            type_hints.update(init_hints)

            # Ugly hack: We have to specialize unions by hand because you can't just guess by content generally
            # Note for developers: This means type MUST be set before meta. fortunately, we can enforce this via
            # the type hints (assuming that the order of annotations is not changed, which python does not guarantee,
            # if it ever does: we'll have to add another hack to the constructor)
            from mastodon.return_types import MediaAttachment
            if type(self) == MediaAttachment and key == "type":
                self.__union_specializer = val

            # Do we have a union specializer attribute?
            union_specializer = None
            if hasattr(self, "_AttribAccessDict__union_specializer"):
                union_specializer = self.__union_specializer

            # Do typecasting, possibly iterating over a list or tuple
            if key in type_hints:
                type_hint = type_hints[key]
                val = try_cast_recurse(type_hint, val, union_specializer)
            else:
                if isinstance(val, dict):
                    val = try_cast_recurse(AttribAccessDict, val, union_specializer)
                elif isinstance(val, list):
                    val = try_cast_recurse(EntityList, val, union_specializer)

        # Finally, call out to setattr and setitem proper
        super(AttribAccessDict, self).__setattr__(key, val)
        super(AttribAccessDict, self).__setitem__(key, val)

        # Remove union specializer if we have one
        if "_AttribAccessDict__union_specializer" in self:
            del self["_AttribAccessDict__union_specializer"]

    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

WebpushCryptoParamsPubkey = Dict[str, str]
"""A type containing the parameters for a encrypting webpush data. Considered opaque / implementation detail."""

WebpushCryptoParamsPrivkey = Dict[str, str]
"""A type containing the parameters for a derypting webpush data. Considered opaque / implementation detail."""

AttribAccessList = PaginatableList
"""Backwards compatibility alias"""