+91
-1
poetry.lock
+91
-1
poetry.lock
···
285
285
]
286
286
287
287
[[package]]
288
+
name = "colorama"
289
+
version = "0.4.6"
290
+
description = "Cross-platform colored terminal text."
291
+
optional = false
292
+
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
293
+
groups = ["main"]
294
+
markers = "sys_platform == \"win32\""
295
+
files = [
296
+
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
297
+
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
298
+
]
299
+
300
+
[[package]]
288
301
name = "cryptography"
289
302
version = "45.0.7"
290
303
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
···
428
441
429
442
[package.extras]
430
443
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
444
+
445
+
[[package]]
446
+
name = "iniconfig"
447
+
version = "2.1.0"
448
+
description = "brain-dead simple config-ini parsing"
449
+
optional = false
450
+
python-versions = ">=3.8"
451
+
groups = ["main"]
452
+
files = [
453
+
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
454
+
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
455
+
]
431
456
432
457
[[package]]
433
458
name = "jsonpath-ng"
···
705
730
]
706
731
707
732
[[package]]
733
+
name = "packaging"
734
+
version = "25.0"
735
+
description = "Core utilities for Python packages"
736
+
optional = false
737
+
python-versions = ">=3.8"
738
+
groups = ["main"]
739
+
files = [
740
+
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
741
+
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
742
+
]
743
+
744
+
[[package]]
745
+
name = "pluggy"
746
+
version = "1.6.0"
747
+
description = "plugin and hook calling mechanisms for python"
748
+
optional = false
749
+
python-versions = ">=3.9"
750
+
groups = ["main"]
751
+
files = [
752
+
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
753
+
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
754
+
]
755
+
756
+
[package.extras]
757
+
dev = ["pre-commit", "tox"]
758
+
testing = ["coverage", "pytest", "pytest-benchmark"]
759
+
760
+
[[package]]
708
761
name = "ply"
709
762
version = "3.11"
710
763
description = "Python Lex & Yacc"
···
787
840
]
788
841
789
842
[[package]]
843
+
name = "pygments"
844
+
version = "2.19.2"
845
+
description = "Pygments is a syntax highlighting package written in Python."
846
+
optional = false
847
+
python-versions = ">=3.8"
848
+
groups = ["main"]
849
+
files = [
850
+
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
851
+
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
852
+
]
853
+
854
+
[package.extras]
855
+
windows-terminal = ["colorama (>=0.4.6)"]
856
+
857
+
[[package]]
790
858
name = "pyld"
791
859
version = "2.0.4"
792
860
description = "Python implementation of the JSON-LD API"
···
827
895
sha3 = ["pysha3"]
828
896
829
897
[[package]]
898
+
name = "pytest"
899
+
version = "8.4.2"
900
+
description = "pytest: simple powerful testing with Python"
901
+
optional = false
902
+
python-versions = ">=3.9"
903
+
groups = ["main"]
904
+
files = [
905
+
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
906
+
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
907
+
]
908
+
909
+
[package.dependencies]
910
+
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
911
+
iniconfig = ">=1"
912
+
packaging = ">=20"
913
+
pluggy = ">=1.5,<2"
914
+
pygments = ">=2.7.2"
915
+
916
+
[package.extras]
917
+
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
918
+
919
+
[[package]]
830
920
name = "python-baseconv"
831
921
version = "1.2.2"
832
922
description = "Convert numbers from base 10 integers to base X strings and back again."
···
903
993
[metadata]
904
994
lock-version = "2.1"
905
995
python-versions = ">=3.13"
906
-
content-hash = "5f4e5fd166bf6b2010ec5acaf545a0cfe376dcc5437530f3add4b58f10ce439f"
996
+
content-hash = "959bc6b01857f65b67d47c12c46abc7af95aecf9913baf195392232500f2253a"
+1
pyproject.toml
+1
pyproject.toml
···
14
14
"jsonpath-ng (>=1.7.0,<2.0.0)", # for URI fragment support
15
15
"cryptography (>=45.0.7,<46.0.0)", # just keep it
16
16
"langcodes (>=3.5.0,<4.0.0)", # language codes support
17
+
"pytest (>=8.4.2,<9.0.0)",
17
18
]
18
19
license = "MIT OR Apache-2.0"
19
20
license-files = ["LICEN[CS]E.*"]
+49
-1
src/atpasser/data/__init__.py
+49
-1
src/atpasser/data/__init__.py
···
1
-
from ._wrapper import *
1
+
"""
2
+
JSON module wrapper for ATProto data model.
3
+
4
+
This module provides a JSON encoder and decoder that handle ATProto-specific
5
+
data types, including bytes, CID links, and typed objects.
6
+
"""
7
+
8
+
from .encoder import JsonEncoder
9
+
from .decoder import JsonDecoder, TypedObject
10
+
from .hooks import TypeHookRegistry, type_handler
11
+
from .types import (
12
+
TypeProcessor,
13
+
TypeProcessorRegistry,
14
+
register_type,
15
+
register_type_encoder,
16
+
register_type_class,
17
+
unregister_type,
18
+
get_type_decoder,
19
+
get_type_encoder,
20
+
has_type_processor,
21
+
clear_type_processors,
22
+
get_registered_types,
23
+
create_processor_registry,
24
+
)
25
+
from .wrapper import dump, dumps, load, loads
26
+
27
+
__all__ = [
28
+
"JsonEncoder",
29
+
"JsonDecoder",
30
+
"TypedObject",
31
+
"TypeHookRegistry",
32
+
"type_handler",
33
+
"TypeProcessor",
34
+
"TypeProcessorRegistry",
35
+
"register_type",
36
+
"register_type_encoder",
37
+
"register_type_class",
38
+
"unregister_type",
39
+
"get_type_decoder",
40
+
"get_type_encoder",
41
+
"has_type_processor",
42
+
"clear_type_processors",
43
+
"get_registered_types",
44
+
"create_processor_registry",
45
+
"dump",
46
+
"dumps",
47
+
"load",
48
+
"loads",
49
+
]
-76
src/atpasser/data/_data.py
-76
src/atpasser/data/_data.py
···
1
-
import base64
2
-
from cid import CIDv0, CIDv1, cid, make_cid
3
-
import json
4
-
5
-
6
-
class Data:
7
-
"""
8
-
A class representing data with "$type" key.
9
-
10
-
Attributes:
11
-
type (str): The type of the data.
12
-
json (str): Original object in JSON format.
13
-
"""
14
-
15
-
def __init__(self, dataType: str, json: str = "{}") -> None:
16
-
"""
17
-
Initalizes data object.
18
-
19
-
Parameters:
20
-
type (str): The type of the data.
21
-
json (str): Original object in JSON format.
22
-
"""
23
-
self.type = dataType
24
-
self.json = json
25
-
26
-
def data(self):
27
-
"""
28
-
Loads data as a Python-friendly format.
29
-
30
-
Returns:
31
-
dict: Converted data from JSON object.
32
-
"""
33
-
return json.loads(self.json, object_hook=dataHook)
34
-
35
-
36
-
def dataHook(data: dict):
37
-
"""
38
-
Treated as `JSONDecoder`'s `object_hook`
39
-
40
-
Parameters:
41
-
data: data in format that `JSONDecoder` like ;)
42
-
"""
43
-
if "$bytes" in data:
44
-
return base64.b64decode(data["$bytes"])
45
-
elif "$link" in data:
46
-
return make_cid(data["$link"])
47
-
elif "$type" in data:
48
-
dataType = data["$type"]
49
-
del data["$type"]
50
-
return Data(dataType, json.dumps(data))
51
-
else:
52
-
return data
53
-
54
-
55
-
def _convertDataToFakeJSON(data):
56
-
if isinstance(data, bytes):
57
-
return {"$bytes": base64.b64encode(data)}
58
-
elif isinstance(data, (CIDv0, CIDv1)):
59
-
return {"link": data.encode()}
60
-
elif isinstance(data, dict):
61
-
for item in data:
62
-
data[item] = _convertDataToFakeJSON(data[item])
63
-
elif isinstance(data, (tuple, list, set)):
64
-
return [_convertDataToFakeJSON(item) for item in data]
65
-
else:
66
-
return data
67
-
68
-
69
-
class DataEncoder(json.JSONEncoder):
70
-
"""
71
-
A superset of `JSONEncoder` to support ATProto data.
72
-
"""
73
-
74
-
def default(self, o):
75
-
result = _convertDataToFakeJSON(o)
76
-
return super().default(result)
-61
src/atpasser/data/_wrapper.py
-61
src/atpasser/data/_wrapper.py
···
1
-
from json import loads
2
-
from typing import Callable, Any
3
-
from ._data import *
4
-
import functools
5
-
6
-
# Pyright did the whole job. Thank it.
7
-
8
-
9
-
class DataDecoder(json.JSONDecoder):
10
-
"""
11
-
A superset of `JSONDecoder` to support ATProto data.
12
-
"""
13
-
14
-
def __init__(
15
-
self,
16
-
*,
17
-
object_hook: Callable[[dict[str, Any]], Any] | None = dataHook,
18
-
parse_float: Callable[[str], Any] | None = None,
19
-
parse_int: Callable[[str], Any] | None = None,
20
-
parse_constant: Callable[[str], Any] | None = None,
21
-
strict: bool = True,
22
-
object_pairs_hook: Callable[[list[tuple[str, Any]]], Any] | None = None,
23
-
) -> None:
24
-
super().__init__(
25
-
object_hook=object_hook,
26
-
parse_float=parse_float,
27
-
parse_int=parse_int,
28
-
parse_constant=parse_constant,
29
-
strict=strict,
30
-
object_pairs_hook=object_pairs_hook,
31
-
)
32
-
33
-
34
-
# Screw it. I have to make 4 `json`-like functions.
35
-
36
-
37
-
def _dataDecoratorForDump(func):
38
-
@functools.wraps(func)
39
-
def wrapper(obj, *args, **kwargs):
40
-
kwargs.setdefault("cls", DataEncoder)
41
-
return func(obj, *args, **kwargs)
42
-
43
-
return wrapper
44
-
45
-
46
-
def _dataDecoratorForLoad(func):
47
-
@functools.wraps(func)
48
-
def wrapper(obj, *args, **kwargs):
49
-
kwargs.setdefault("cls", DataDecoder)
50
-
return func(obj, *args, **kwargs)
51
-
52
-
return wrapper
53
-
54
-
55
-
dump = _dataDecoratorForDump(json.dump)
56
-
dumps = _dataDecoratorForDump(json.dumps)
57
-
load = _dataDecoratorForLoad(json.load)
58
-
loads = _dataDecoratorForLoad(json.loads)
59
-
"""
60
-
Wrapper of the JSON functions to support ATProto data.
61
-
"""
-137
src/atpasser/data/cbor.py
-137
src/atpasser/data/cbor.py
···
1
-
from datetime import tzinfo
2
-
import typing
3
-
import cbor2
4
-
import cid
5
-
6
-
from .data import dataHook, Data
7
-
8
-
9
-
def tagHook(decoder: cbor2.CBORDecoder, tag: cbor2.CBORTag, shareable_index=None):
10
-
"""
11
-
A simple tag hook for CID support.
12
-
"""
13
-
return cid.from_bytes(tag.value) if tag.tag == 42 else tag
14
-
15
-
16
-
class CBOREncoder(cbor2.CBOREncoder):
17
-
"""
18
-
Wrapper of cbor2.CBOREncoder.
19
-
"""
20
-
21
-
def __init__(
22
-
self,
23
-
fp: typing.IO[bytes],
24
-
datetime_as_timestamp: bool = False,
25
-
timezone: tzinfo | None = None,
26
-
value_sharing: bool = False,
27
-
default: (
28
-
typing.Callable[[cbor2.CBOREncoder, typing.Any], typing.Any] | None
29
-
) = None,
30
-
canonical: bool = False,
31
-
date_as_datetime: bool = False,
32
-
string_referencing: bool = False,
33
-
indefinite_containers: bool = False,
34
-
):
35
-
super().__init__(
36
-
fp,
37
-
datetime_as_timestamp,
38
-
timezone,
39
-
value_sharing,
40
-
default,
41
-
canonical,
42
-
date_as_datetime,
43
-
string_referencing,
44
-
indefinite_containers,
45
-
)
46
-
47
-
@cbor2.shareable_encoder
48
-
def cidOrDataEncoder(self: cbor2.CBOREncoder, value: cid.CIDv0 | cid.CIDv1 | Data):
49
-
"""
50
-
Encode CID or Data to CBOR Tag.
51
-
"""
52
-
if isinstance(value, (cid.CIDv0, cid.CIDv1)):
53
-
self.encode(cbor2.CBORTag(42, value.encode()))
54
-
elif isinstance(value, Data):
55
-
self.encode(value.data())
56
-
57
-
58
-
def _cborObjectHook(decoder: cbor2.CBORDecoder, value):
59
-
return dataHook(value)
60
-
61
-
62
-
class CBORDecoder(cbor2.CBORDecoder):
63
-
"""
64
-
Wrapper of cbor2.CBORDecoder.
65
-
"""
66
-
67
-
def __init__(
68
-
self,
69
-
fp: typing.IO[bytes],
70
-
tag_hook: (
71
-
typing.Callable[[cbor2.CBORDecoder, cbor2.CBORTag], typing.Any] | None
72
-
) = tagHook,
73
-
object_hook: (
74
-
typing.Callable[
75
-
[cbor2.CBORDecoder, dict[typing.Any, typing.Any]], typing.Any
76
-
]
77
-
| None
78
-
) = _cborObjectHook,
79
-
str_errors: typing.Literal["strict", "error", "replace"] = "strict",
80
-
):
81
-
super().__init__(fp, tag_hook, object_hook, str_errors)
82
-
83
-
84
-
# Make things for CBOR again.
85
-
86
-
from io import BytesIO
87
-
88
-
89
-
def dumps(
90
-
obj: object,
91
-
datetime_as_timestamp: bool = False,
92
-
timezone: tzinfo | None = None,
93
-
value_sharing: bool = False,
94
-
default: typing.Callable[[cbor2.CBOREncoder, typing.Any], typing.Any] | None = None,
95
-
canonical: bool = False,
96
-
date_as_datetime: bool = False,
97
-
string_referencing: bool = False,
98
-
indefinite_containers: bool = False,
99
-
) -> bytes:
100
-
with BytesIO() as fp:
101
-
CBOREncoder(
102
-
fp,
103
-
datetime_as_timestamp=datetime_as_timestamp,
104
-
timezone=timezone,
105
-
value_sharing=value_sharing,
106
-
default=default,
107
-
canonical=canonical,
108
-
date_as_datetime=date_as_datetime,
109
-
string_referencing=string_referencing,
110
-
indefinite_containers=indefinite_containers,
111
-
).encode(obj)
112
-
return fp.getvalue()
113
-
114
-
115
-
def dump(
116
-
obj: object,
117
-
fp: typing.IO[bytes],
118
-
datetime_as_timestamp: bool = False,
119
-
timezone: tzinfo | None = None,
120
-
value_sharing: bool = False,
121
-
default: typing.Callable[[cbor2.CBOREncoder, typing.Any], typing.Any] | None = None,
122
-
canonical: bool = False,
123
-
date_as_datetime: bool = False,
124
-
string_referencing: bool = False,
125
-
indefinite_containers: bool = False,
126
-
) -> None:
127
-
CBOREncoder(
128
-
fp,
129
-
datetime_as_timestamp=datetime_as_timestamp,
130
-
timezone=timezone,
131
-
value_sharing=value_sharing,
132
-
default=default,
133
-
canonical=canonical,
134
-
date_as_datetime=date_as_datetime,
135
-
string_referencing=string_referencing,
136
-
indefinite_containers=indefinite_containers,
137
-
).encode(obj)
+178
src/atpasser/data/decoder.py
+178
src/atpasser/data/decoder.py
···
1
+
"""
2
+
JSON decoder for ATProto data model.
3
+
4
+
This module provides a JSON decoder that handles ATProto-specific data types,
5
+
including bytes, CID links, and typed objects.
6
+
"""
7
+
8
+
import base64
9
+
import json
10
+
from typing import Any, Callable, Dict, Optional
11
+
from cid import CIDv0, CIDv1, make_cid
12
+
13
+
14
+
class JsonDecoder(json.JSONDecoder):
15
+
"""A JSON decoder that supports ATProto data types.
16
+
17
+
This decoder extends the standard JSON decoder to handle ATProto-specific
18
+
data types, including bytes, CID links, and typed objects.
19
+
20
+
Attributes:
21
+
type_hook_registry: Registry for type-specific hooks.
22
+
encoding: The encoding to use for string deserialization.
23
+
"""
24
+
25
+
def __init__(
26
+
self,
27
+
*,
28
+
object_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
29
+
type_hook_registry: Optional[Any] = None,
30
+
type_processor_registry: Optional[Any] = None,
31
+
encoding: str = "utf-8",
32
+
**kwargs: Any
33
+
) -> None:
34
+
"""Initialize the JSON decoder.
35
+
36
+
Args:
37
+
object_hook: Optional function to call with each decoded object.
38
+
type_hook_registry: Registry for type-specific hooks.
39
+
type_processor_registry: Registry for type-specific processors.
40
+
encoding: The encoding to use for string deserialization.
41
+
**kwargs: Additional keyword arguments to pass to the parent class.
42
+
"""
43
+
# Use the type processor registry if provided, otherwise use the type hook registry
44
+
if type_processor_registry is not None:
45
+
type_hook_registry = type_processor_registry.to_hook_registry()
46
+
elif type_hook_registry is None:
47
+
from .hooks import get_global_registry
48
+
type_hook_registry = get_global_registry()
49
+
50
+
# Create a combined object hook that calls both the custom hook and our hook
51
+
combined_hook = self._create_combined_hook(object_hook, type_hook_registry)
52
+
53
+
super().__init__(object_hook=combined_hook, **kwargs)
54
+
self.type_hook_registry = type_hook_registry
55
+
self.type_processor_registry = type_processor_registry
56
+
self.encoding = encoding
57
+
58
+
def _create_combined_hook(
59
+
self,
60
+
custom_hook: Optional[Callable[[Dict[str, Any]], Any]],
61
+
type_hook_registry: Optional[Any]
62
+
) -> Callable[[Dict[str, Any]], Any]:
63
+
"""Create a combined object hook function.
64
+
65
+
Args:
66
+
custom_hook: Optional custom object hook function.
67
+
type_hook_registry: Registry for type-specific hooks.
68
+
69
+
Returns:
70
+
A combined object hook function.
71
+
"""
72
+
def combined_hook(obj: Dict[str, Any]) -> Any:
73
+
# First, apply our ATProto-specific decoding
74
+
decoded_obj = self._atproto_object_hook(obj)
75
+
76
+
# Then, apply the custom hook if provided
77
+
if custom_hook is not None:
78
+
decoded_obj = custom_hook(decoded_obj)
79
+
80
+
return decoded_obj
81
+
82
+
return combined_hook
83
+
84
+
def _atproto_object_hook(self, obj: Dict[str, Any]) -> Any:
85
+
"""Handle ATProto-specific object decoding.
86
+
87
+
Args:
88
+
obj: The object to decode.
89
+
90
+
Returns:
91
+
The decoded object.
92
+
"""
93
+
# Handle $bytes key (RFC-4648 base64 decoding)
94
+
if "$bytes" in obj:
95
+
if len(obj) != 1:
96
+
# If there are other keys, this is invalid
97
+
raise ValueError(f"Invalid $bytes object: {obj}")
98
+
return base64.b64decode(obj["$bytes"].encode(self.encoding))
99
+
100
+
# Handle $link key (CID parsing)
101
+
elif "$link" in obj:
102
+
if len(obj) != 1:
103
+
# If there are other keys, this is invalid
104
+
raise ValueError(f"Invalid $link object: {obj}")
105
+
return make_cid(obj["$link"])
106
+
107
+
# Handle $type key (typed objects)
108
+
elif "$type" in obj:
109
+
type_value = obj["$type"]
110
+
remaining_obj = {k: v for k, v in obj.items() if k != "$type"}
111
+
112
+
# Check if there's a registered type handler
113
+
if self.type_hook_registry is not None:
114
+
handler = self.type_hook_registry.get_handler(type_value)
115
+
if handler is not None:
116
+
return handler(remaining_obj)
117
+
118
+
# If no handler is registered, return a typed object
119
+
return TypedObject(type_value, remaining_obj)
120
+
121
+
# Handle nested objects recursively
122
+
elif isinstance(obj, dict):
123
+
return {k: self._atproto_object_hook(v) if isinstance(v, dict) else v
124
+
for k, v in obj.items()}
125
+
126
+
return obj
127
+
128
+
129
+
class TypedObject:
130
+
"""A typed object in the ATProto data model.
131
+
132
+
This class represents an object with a $type field in the ATProto data model.
133
+
134
+
Attributes:
135
+
type: The type of the object.
136
+
data: The data associated with the object.
137
+
"""
138
+
139
+
def __init__(self, type_name: str, data: Dict[str, Any]) -> None:
140
+
"""Initialize a typed object.
141
+
142
+
Args:
143
+
type_name: The type of the object.
144
+
data: The data associated with the object.
145
+
"""
146
+
self.type_name = type_name
147
+
self.data = data
148
+
149
+
def __repr__(self) -> str:
150
+
"""Return a string representation of the typed object.
151
+
152
+
Returns:
153
+
A string representation of the typed object.
154
+
"""
155
+
return f"TypedObject(type_name={self.type_name!r}, data={self.data!r})"
156
+
157
+
def __eq__(self, other: Any) -> bool:
158
+
"""Check if two typed objects are equal.
159
+
160
+
Args:
161
+
other: The object to compare with.
162
+
163
+
Returns:
164
+
True if the objects are equal, False otherwise.
165
+
"""
166
+
if not isinstance(other, TypedObject):
167
+
return False
168
+
return self.type_name == other.type_name and self.data == other.data
169
+
170
+
def __atproto_json_encode__(self) -> Dict[str, Any]:
171
+
"""Encode the typed object to a JSON-serializable format.
172
+
173
+
Returns:
174
+
A JSON-serializable representation of the typed object.
175
+
"""
176
+
result = {"$type": self.type_name}
177
+
result.update(self.data)
178
+
return result
+82
src/atpasser/data/encoder.py
+82
src/atpasser/data/encoder.py
···
1
+
"""
2
+
JSON encoder for ATProto data model.
3
+
4
+
This module provides a JSON encoder that handles ATProto-specific data types,
5
+
including bytes, CID links, and typed objects.
6
+
"""
7
+
8
+
import base64
9
+
import json
10
+
from typing import Any, Optional
11
+
from cid import CIDv0, CIDv1
12
+
13
+
14
+
class JsonEncoder(json.JSONEncoder):
15
+
"""A JSON encoder that supports ATProto data types.
16
+
17
+
This encoder extends the standard JSON encoder to handle ATProto-specific
18
+
data types, including bytes, CID links, and typed objects.
19
+
20
+
Attributes:
21
+
encoding (str): The encoding to use for string serialization.
22
+
type_processor_registry: Registry for type-specific processors.
23
+
"""
24
+
25
+
def __init__(
26
+
self,
27
+
*,
28
+
encoding: str = "utf-8",
29
+
type_processor_registry: Optional[Any] = None,
30
+
**kwargs: Any
31
+
) -> None:
32
+
"""Initialize the JSON encoder.
33
+
34
+
Args:
35
+
encoding: The encoding to use for string serialization.
36
+
type_processor_registry: Registry for type-specific processors.
37
+
**kwargs: Additional keyword arguments to pass to the parent class.
38
+
"""
39
+
super().__init__(**kwargs)
40
+
self.encoding = encoding
41
+
self.type_processor_registry = type_processor_registry
42
+
43
+
def default(self, o: Any) -> Any:
44
+
"""Convert an object to a serializable format.
45
+
46
+
Args:
47
+
o: The object to serialize.
48
+
49
+
Returns:
50
+
A serializable representation of the object.
51
+
52
+
Raises:
53
+
TypeError: If the object is not serializable.
54
+
"""
55
+
if isinstance(o, bytes):
56
+
# Handle bytes using RFC-4648 base64 encoding
57
+
return {"$bytes": base64.b64encode(o).decode(self.encoding)}
58
+
elif isinstance(o, (CIDv0, CIDv1)):
59
+
# Handle CID objects
60
+
return {"$link": str(o)}
61
+
elif hasattr(o, "__atproto_json_encode__"):
62
+
# Handle objects with custom ATProto encoding
63
+
return o.__atproto_json_encode__()
64
+
elif self.type_processor_registry is not None:
65
+
# Try to find a type processor for this object
66
+
obj_type_name = type(o).__name__
67
+
encoder = self.type_processor_registry.get_encoder(obj_type_name)
68
+
if encoder is not None:
69
+
result = encoder(o)
70
+
# Add $type field if not already present
71
+
if isinstance(result, dict) and "$type" not in result:
72
+
result["$type"] = obj_type_name
73
+
return result
74
+
elif isinstance(o, dict):
75
+
# Handle dictionaries recursively
76
+
return {k: self.default(v) for k, v in o.items()}
77
+
elif isinstance(o, (list, tuple)):
78
+
# Handle lists and tuples recursively
79
+
return [self.default(item) for item in o]
80
+
else:
81
+
# Use the parent class for other types
82
+
return super().default(o)
+222
src/atpasser/data/hooks.py
+222
src/atpasser/data/hooks.py
···
1
+
"""
2
+
Type hook system for ATProto JSON decoder.
3
+
4
+
This module provides a decorator-based system for registering custom type handlers
5
+
for objects with $type keys in the ATProto data model.
6
+
"""
7
+
8
+
import functools
9
+
from typing import Any, Callable, Dict, Optional, TypeVar, Union
10
+
11
+
# Type variable for the decorated function
12
+
F = TypeVar('F', bound=Callable[..., Any])
13
+
14
+
15
+
class TypeHookRegistry:
16
+
"""Registry for type-specific hooks in the ATProto JSON decoder.
17
+
18
+
This class maintains a registry of type-specific hooks that can be used
19
+
to customize the decoding of objects with $type keys in the ATProto data model.
20
+
21
+
Attributes:
22
+
_handlers: Dictionary mapping type names to handler functions.
23
+
"""
24
+
25
+
def __init__(self) -> None:
26
+
"""Initialize the type hook registry."""
27
+
self._handlers: Dict[str, Callable[[Dict[str, Any]], Any]] = {}
28
+
29
+
def register(self, type_name: str) -> Callable[[F], F]:
30
+
"""Register a type handler function.
31
+
32
+
This method can be used as a decorator to register a function as a handler
33
+
for a specific type.
34
+
35
+
Args:
36
+
type_name: The name of the type to handle.
37
+
38
+
Returns:
39
+
A decorator function that registers the decorated function as a handler.
40
+
41
+
Example:
42
+
>>> registry = TypeHookRegistry()
43
+
>>>
44
+
>>> @registry.register("app.bsky.feed.post")
45
+
... def handle_post(data: Dict[str, Any]) -> Any:
46
+
... return Post(**data)
47
+
"""
48
+
def decorator(func: F) -> F:
49
+
self._handlers[type_name] = func
50
+
return func
51
+
52
+
return decorator
53
+
54
+
def register_handler(self, type_name: str, handler: Callable[[Dict[str, Any]], Any]) -> None:
55
+
"""Register a type handler function directly.
56
+
57
+
Args:
58
+
type_name: The name of the type to handle.
59
+
handler: The function to call when decoding objects of this type.
60
+
61
+
Example:
62
+
>>> registry = TypeHookRegistry()
63
+
>>>
64
+
>>> def handle_post(data: Dict[str, Any]) -> Any:
65
+
... return Post(**data)
66
+
>>>
67
+
>>> registry.register_handler("app.bsky.feed.post", handle_post)
68
+
"""
69
+
self._handlers[type_name] = handler
70
+
71
+
def unregister(self, type_name: str) -> None:
72
+
"""Unregister a type handler function.
73
+
74
+
Args:
75
+
type_name: The name of the type to unregister.
76
+
"""
77
+
if type_name in self._handlers:
78
+
del self._handlers[type_name]
79
+
80
+
def get_handler(self, type_name: str) -> Optional[Callable[[Dict[str, Any]], Any]]:
81
+
"""Get the handler function for a specific type.
82
+
83
+
Args:
84
+
type_name: The name of the type to get the handler for.
85
+
86
+
Returns:
87
+
The handler function for the specified type, or None if no handler
88
+
is registered.
89
+
"""
90
+
return self._handlers.get(type_name)
91
+
92
+
def has_handler(self, type_name: str) -> bool:
93
+
"""Check if a handler is registered for a specific type.
94
+
95
+
Args:
96
+
type_name: The name of the type to check.
97
+
98
+
Returns:
99
+
True if a handler is registered for the specified type, False otherwise.
100
+
"""
101
+
return type_name in self._handlers
102
+
103
+
def clear(self) -> None:
104
+
"""Clear all registered handlers."""
105
+
self._handlers.clear()
106
+
107
+
def get_registered_types(self) -> set:
108
+
"""Get the set of all registered type names.
109
+
110
+
Returns:
111
+
A set of all registered type names.
112
+
"""
113
+
return set(self._handlers.keys())
114
+
115
+
116
+
# Global registry instance
117
+
_global_registry = TypeHookRegistry()
118
+
119
+
120
+
def type_handler(type_name: str) -> Callable[[F], F]:
121
+
"""Register a global type handler function.
122
+
123
+
This decorator registers a function as a global handler for a specific type
124
+
in the ATProto data model.
125
+
126
+
Args:
127
+
type_name: The name of the type to handle.
128
+
129
+
Returns:
130
+
A decorator function that registers the decorated function as a handler.
131
+
132
+
Example:
133
+
>>> @type_handler("app.bsky.feed.post")
134
+
... def handle_post(data: Dict[str, Any]) -> Any:
135
+
... return Post(**data)
136
+
"""
137
+
return _global_registry.register(type_name)
138
+
139
+
140
+
def get_global_registry() -> TypeHookRegistry:
141
+
"""Get the global type hook registry.
142
+
143
+
Returns:
144
+
The global TypeHookRegistry instance.
145
+
"""
146
+
return _global_registry
147
+
148
+
149
+
def register_type_handler(type_name: str, handler: Callable[[Dict[str, Any]], Any]) -> None:
150
+
"""Register a global type handler function directly.
151
+
152
+
Args:
153
+
type_name: The name of the type to handle.
154
+
handler: The function to call when decoding objects of this type.
155
+
156
+
Example:
157
+
>>> def handle_post(data: Dict[str, Any]) -> Any:
158
+
... return Post(**data)
159
+
>>>
160
+
>>> register_type_handler("app.bsky.feed.post", handle_post)
161
+
"""
162
+
_global_registry.register_handler(type_name, handler)
163
+
164
+
165
+
def unregister_type_handler(type_name: str) -> None:
166
+
"""Unregister a global type handler function.
167
+
168
+
Args:
169
+
type_name: The name of the type to unregister.
170
+
"""
171
+
_global_registry.unregister(type_name)
172
+
173
+
174
+
def get_type_handler(type_name: str) -> Optional[Callable[[Dict[str, Any]], Any]]:
175
+
"""Get the global handler function for a specific type.
176
+
177
+
Args:
178
+
type_name: The name of the type to get the handler for.
179
+
180
+
Returns:
181
+
The handler function for the specified type, or None if no handler
182
+
is registered.
183
+
"""
184
+
return _global_registry.get_handler(type_name)
185
+
186
+
187
+
def has_type_handler(type_name: str) -> bool:
188
+
"""Check if a global handler is registered for a specific type.
189
+
190
+
Args:
191
+
type_name: The name of the type to check.
192
+
193
+
Returns:
194
+
True if a handler is registered for the specified type, False otherwise.
195
+
"""
196
+
return _global_registry.has_handler(type_name)
197
+
198
+
199
+
def clear_type_handlers() -> None:
200
+
"""Clear all globally registered handlers."""
201
+
_global_registry.clear()
202
+
203
+
204
+
def get_registered_types() -> set:
205
+
"""Get the set of all globally registered type names.
206
+
207
+
Returns:
208
+
A set of all registered type names.
209
+
"""
210
+
return _global_registry.get_registered_types()
211
+
212
+
213
+
def create_registry() -> TypeHookRegistry:
214
+
"""Create a new type hook registry.
215
+
216
+
This function creates a new, independent registry that can be used
217
+
instead of the global registry.
218
+
219
+
Returns:
220
+
A new TypeHookRegistry instance.
221
+
"""
222
+
return TypeHookRegistry()
+504
src/atpasser/data/types.py
+504
src/atpasser/data/types.py
···
1
+
"""
2
+
Type processor system for ATProto JSON decoder.
3
+
4
+
This module provides an advanced type processor system that allows users to
5
+
register custom type converters for objects with $type keys in the ATProto data model.
6
+
"""
7
+
8
+
import inspect
9
+
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union
10
+
from .hooks import TypeHookRegistry
11
+
12
+
# Type variable for the decorated class
13
+
T = TypeVar('T')
14
+
15
+
16
+
class TypeProcessor:
17
+
"""A type processor for ATProto JSON objects.
18
+
19
+
This class represents a processor for a specific type in the ATProto data model.
20
+
It contains information about how to convert JSON data to Python objects and
21
+
vice versa.
22
+
23
+
Attributes:
24
+
type_name: The name of the type this processor handles.
25
+
decoder: The function to decode JSON data to a Python object.
26
+
encoder: The function to encode a Python object to JSON data.
27
+
priority: The priority of this processor (higher values = higher priority).
28
+
"""
29
+
30
+
def __init__(
31
+
self,
32
+
type_name: str,
33
+
decoder: Optional[Callable[[Dict[str, Any]], Any]] = None,
34
+
encoder: Optional[Callable[[Any], Dict[str, Any]]] = None,
35
+
priority: int = 0
36
+
) -> None:
37
+
"""Initialize a type processor.
38
+
39
+
Args:
40
+
type_name: The name of the type this processor handles.
41
+
decoder: The function to decode JSON data to a Python object.
42
+
encoder: The function to encode a Python object to JSON data.
43
+
priority: The priority of this processor (higher values = higher priority).
44
+
"""
45
+
self.type_name = type_name
46
+
self.decoder = decoder
47
+
self.encoder = encoder
48
+
self.priority = priority
49
+
50
+
def decode(self, data: Dict[str, Any]) -> Any:
51
+
"""Decode JSON data to a Python object.
52
+
53
+
Args:
54
+
data: The JSON data to decode.
55
+
56
+
Returns:
57
+
The decoded Python object.
58
+
59
+
Raises:
60
+
ValueError: If no decoder is registered.
61
+
"""
62
+
if self.decoder is None:
63
+
raise ValueError(f"No decoder registered for type {self.type_name}")
64
+
return self.decoder(data)
65
+
66
+
def encode(self, obj: Any) -> Dict[str, Any]:
67
+
"""Encode a Python object to JSON data.
68
+
69
+
Args:
70
+
obj: The Python object to encode.
71
+
72
+
Returns:
73
+
The encoded JSON data.
74
+
75
+
Raises:
76
+
ValueError: If no encoder is registered.
77
+
"""
78
+
if self.encoder is None:
79
+
raise ValueError(f"No encoder registered for type {self.type_name}")
80
+
return self.encoder(obj)
81
+
82
+
83
+
class TypeProcessorRegistry:
84
+
"""Registry for type processors in the ATProto JSON decoder.
85
+
86
+
This class maintains a registry of type processors that can be used
87
+
to customize the encoding and decoding of objects with $type keys in
88
+
the ATProto data model.
89
+
90
+
Attributes:
91
+
_processors: Dictionary mapping type names to processor lists.
92
+
"""
93
+
94
+
def __init__(self) -> None:
95
+
"""Initialize the type processor registry."""
96
+
self._processors: Dict[str, List[TypeProcessor]] = {}
97
+
98
+
def register_processor(self, processor: TypeProcessor) -> None:
99
+
"""Register a type processor.
100
+
101
+
Args:
102
+
processor: The type processor to register.
103
+
"""
104
+
if processor.type_name not in self._processors:
105
+
self._processors[processor.type_name] = []
106
+
107
+
self._processors[processor.type_name].append(processor)
108
+
# Sort processors by priority (descending)
109
+
self._processors[processor.type_name].sort(key=lambda p: p.priority, reverse=True)
110
+
111
+
def register(
112
+
self,
113
+
type_name: str,
114
+
priority: int = 0
115
+
) -> Callable[[Callable[[Dict[str, Any]], Any]], Callable[[Dict[str, Any]], Any]]:
116
+
"""Register a type decoder function.
117
+
118
+
This method can be used as a decorator to register a function as a decoder
119
+
for a specific type.
120
+
121
+
Args:
122
+
type_name: The name of the type to handle.
123
+
priority: The priority of this processor (higher values = higher priority).
124
+
125
+
Returns:
126
+
A decorator function that registers the decorated function as a decoder.
127
+
128
+
Example:
129
+
>>> registry = TypeProcessorRegistry()
130
+
>>>
131
+
>>> @registry.register("app.bsky.feed.post", priority=10)
132
+
... def decode_post(data: Dict[str, Any]) -> Any:
133
+
... return Post(**data)
134
+
"""
135
+
def decorator(func: Callable[[Dict[str, Any]], Any]) -> Callable[[Dict[str, Any]], Any]:
136
+
processor = TypeProcessor(type_name, decoder=func, priority=priority)
137
+
self.register_processor(processor)
138
+
return func
139
+
140
+
return decorator
141
+
142
+
def register_encoder(
143
+
self,
144
+
type_name: str,
145
+
priority: int = 0
146
+
) -> Callable[[Callable[[Any], Dict[str, Any]]], Callable[[Any], Dict[str, Any]]]:
147
+
"""Register a type encoder function.
148
+
149
+
This method can be used as a decorator to register a function as an encoder
150
+
for a specific type.
151
+
152
+
Args:
153
+
type_name: The name of the type to handle.
154
+
priority: The priority of this processor (higher values = higher priority).
155
+
156
+
Returns:
157
+
A decorator function that registers the decorated function as an encoder.
158
+
159
+
Example:
160
+
>>> registry = TypeProcessorRegistry()
161
+
>>>
162
+
>>> @registry.register_encoder("app.bsky.feed.post", priority=10)
163
+
... def encode_post(post: Post) -> Dict[str, Any]:
164
+
... return {"text": post.text, "createdAt": post.created_at}
165
+
"""
166
+
def decorator(func: Callable[[Any], Dict[str, Any]]) -> Callable[[Any], Dict[str, Any]]:
167
+
# Check if a processor for this type already exists
168
+
if type_name in self._processors:
169
+
for processor in self._processors[type_name]:
170
+
if processor.decoder is not None:
171
+
# Update the existing processor with the encoder
172
+
processor.encoder = func
173
+
break
174
+
else:
175
+
# No decoder found, create a new processor
176
+
processor = TypeProcessor(type_name, encoder=func, priority=priority)
177
+
self.register_processor(processor)
178
+
else:
179
+
# No processor exists, create a new one
180
+
processor = TypeProcessor(type_name, encoder=func, priority=priority)
181
+
self.register_processor(processor)
182
+
183
+
return func
184
+
185
+
return decorator
186
+
187
+
def register_class(
188
+
self,
189
+
type_name: str,
190
+
priority: int = 0
191
+
) -> Callable[[Type[T]], Type[T]]:
192
+
"""Register a class for both encoding and decoding.
193
+
194
+
This method can be used as a decorator to register a class for both
195
+
encoding and decoding of a specific type.
196
+
197
+
The class must have a class method `from_json` that takes a dictionary
198
+
and returns an instance of the class, and an instance method `to_json`
199
+
that returns a dictionary.
200
+
201
+
Args:
202
+
type_name: The name of the type to handle.
203
+
priority: The priority of this processor (higher values = higher priority).
204
+
205
+
Returns:
206
+
A decorator function that registers the decorated class.
207
+
208
+
Example:
209
+
>>> registry = TypeProcessorRegistry()
210
+
>>>
211
+
>>> @registry.register_class("app.bsky.feed.post", priority=10)
212
+
... class Post:
213
+
... def __init__(self, text: str, created_at: str) -> None:
214
+
... self.text = text
215
+
... self.created_at = created_at
216
+
...
217
+
... @classmethod
218
+
... def from_json(cls, data: Dict[str, Any]) -> "Post":
219
+
... return cls(data["text"], data["createdAt"])
220
+
...
221
+
... def to_json(self) -> Dict[str, Any]:
222
+
... return {"text": self.text, "createdAt": self.created_at}
223
+
"""
224
+
def decorator(cls: Type[T]) -> Type[T]:
225
+
# Create decoder from class method
226
+
if hasattr(cls, "from_json"):
227
+
decoder = lambda data: getattr(cls, "from_json")(data)
228
+
else:
229
+
# Try to create a decoder from the constructor
230
+
init_signature = inspect.signature(cls.__init__)
231
+
if init_signature.parameters:
232
+
# Create a decoder that passes the data as keyword arguments
233
+
decoder = lambda data: cls(**data)
234
+
else:
235
+
raise ValueError(f"Class {cls.__name__} has no from_json method or compatible __init__")
236
+
237
+
# Create encoder from instance method
238
+
if hasattr(cls, "to_json"):
239
+
encoder = lambda obj: obj.to_json()
240
+
else:
241
+
raise ValueError(f"Class {cls.__name__} has no to_json method")
242
+
243
+
# Register the processor
244
+
processor = TypeProcessor(type_name, decoder=decoder, encoder=encoder, priority=priority)
245
+
self.register_processor(processor)
246
+
247
+
return cls
248
+
249
+
return decorator
250
+
251
+
def unregister(self, type_name: str, priority: Optional[int] = None) -> None:
252
+
"""Unregister type processors.
253
+
254
+
Args:
255
+
type_name: The name of the type to unregister.
256
+
priority: If specified, only unregister processors with this priority.
257
+
"""
258
+
if type_name in self._processors:
259
+
if priority is not None:
260
+
# Remove processors with the specified priority
261
+
self._processors[type_name] = [
262
+
p for p in self._processors[type_name] if p.priority != priority
263
+
]
264
+
else:
265
+
# Remove all processors for this type
266
+
del self._processors[type_name]
267
+
268
+
def get_decoder(self, type_name: str) -> Optional[Callable[[Dict[str, Any]], Any]]:
269
+
"""Get the decoder function for a specific type.
270
+
271
+
Args:
272
+
type_name: The name of the type to get the decoder for.
273
+
274
+
Returns:
275
+
The decoder function for the specified type, or None if no decoder
276
+
is registered.
277
+
"""
278
+
if type_name in self._processors and self._processors[type_name]:
279
+
# Return the decoder of the highest priority processor
280
+
return self._processors[type_name][0].decoder
281
+
return None
282
+
283
+
def get_encoder(self, type_name: str) -> Optional[Callable[[Any], Dict[str, Any]]]:
284
+
"""Get the encoder function for a specific type.
285
+
286
+
Args:
287
+
type_name: The name of the type to get the encoder for.
288
+
289
+
Returns:
290
+
The encoder function for the specified type, or None if no encoder
291
+
is registered.
292
+
"""
293
+
if type_name in self._processors and self._processors[type_name]:
294
+
# Return the encoder of the highest priority processor
295
+
return self._processors[type_name][0].encoder
296
+
return None
297
+
298
+
def has_processor(self, type_name: str) -> bool:
299
+
"""Check if a processor is registered for a specific type.
300
+
301
+
Args:
302
+
type_name: The name of the type to check.
303
+
304
+
Returns:
305
+
True if a processor is registered for the specified type, False otherwise.
306
+
"""
307
+
return type_name in self._processors and bool(self._processors[type_name])
308
+
309
+
def clear(self) -> None:
310
+
"""Clear all registered processors."""
311
+
self._processors.clear()
312
+
313
+
def get_registered_types(self) -> set:
314
+
"""Get the set of all registered type names.
315
+
316
+
Returns:
317
+
A set of all registered type names.
318
+
"""
319
+
return set(self._processors.keys())
320
+
321
+
def to_hook_registry(self) -> TypeHookRegistry:
322
+
"""Convert this processor registry to a hook registry.
323
+
324
+
This method creates a TypeHookRegistry that uses the decoders from
325
+
this processor registry.
326
+
327
+
Returns:
328
+
A TypeHookRegistry with the same decoders as this processor registry.
329
+
"""
330
+
hook_registry = TypeHookRegistry()
331
+
332
+
for type_name, processors in self._processors.items():
333
+
if processors and processors[0].decoder is not None:
334
+
hook_registry.register_handler(type_name, processors[0].decoder)
335
+
336
+
return hook_registry
337
+
338
+
339
+
# Global registry instance
340
+
_global_processor_registry = TypeProcessorRegistry()
341
+
342
+
343
+
def register_type(
344
+
type_name: str,
345
+
priority: int = 0
346
+
) -> Callable[[Callable[[Dict[str, Any]], Any]], Callable[[Dict[str, Any]], Any]]:
347
+
"""Register a global type decoder function.
348
+
349
+
This decorator registers a function as a global decoder for a specific type
350
+
in the ATProto data model.
351
+
352
+
Args:
353
+
type_name: The name of the type to handle.
354
+
priority: The priority of this processor (higher values = higher priority).
355
+
356
+
Returns:
357
+
A decorator function that registers the decorated function as a decoder.
358
+
359
+
Example:
360
+
>>> @register_type("app.bsky.feed.post", priority=10)
361
+
... def decode_post(data: Dict[str, Any]) -> Any:
362
+
... return Post(**data)
363
+
"""
364
+
return _global_processor_registry.register(type_name, priority)
365
+
366
+
367
+
def get_global_processor_registry() -> TypeProcessorRegistry:
368
+
"""Get the global type processor registry.
369
+
370
+
Returns:
371
+
The global TypeProcessorRegistry instance.
372
+
"""
373
+
return _global_processor_registry
374
+
375
+
376
+
def register_type_encoder(
377
+
type_name: str,
378
+
priority: int = 0
379
+
) -> Callable[[Callable[[Any], Dict[str, Any]]], Callable[[Any], Dict[str, Any]]]:
380
+
"""Register a global type encoder function.
381
+
382
+
This decorator registers a function as a global encoder for a specific type
383
+
in the ATProto data model.
384
+
385
+
Args:
386
+
type_name: The name of the type to handle.
387
+
priority: The priority of this processor (higher values = higher priority).
388
+
389
+
Returns:
390
+
A decorator function that registers the decorated function as an encoder.
391
+
392
+
Example:
393
+
>>> @register_type_encoder("app.bsky.feed.post", priority=10)
394
+
... def encode_post(post: Post) -> Dict[str, Any]:
395
+
... return {"text": post.text, "createdAt": post.created_at}
396
+
"""
397
+
return _global_processor_registry.register_encoder(type_name, priority)
398
+
399
+
400
+
def register_type_class(
401
+
type_name: str,
402
+
priority: int = 0
403
+
) -> Callable[[Type[T]], Type[T]]:
404
+
"""Register a class for both global encoding and decoding.
405
+
406
+
This decorator registers a class for both encoding and decoding of a specific type
407
+
in the ATProto data model.
408
+
409
+
Args:
410
+
type_name: The name of the type to handle.
411
+
priority: The priority of this processor (higher values = higher priority).
412
+
413
+
Returns:
414
+
A decorator function that registers the decorated class.
415
+
416
+
Example:
417
+
>>> @register_type_class("app.bsky.feed.post", priority=10)
418
+
... class Post:
419
+
... def __init__(self, text: str, created_at: str) -> None:
420
+
... self.text = text
421
+
... self.created_at = created_at
422
+
...
423
+
... @classmethod
424
+
... def from_json(cls, data: Dict[str, Any]) -> "Post":
425
+
... return cls(data["text"], data["createdAt"])
426
+
...
427
+
... def to_json(self) -> Dict[str, Any]:
428
+
... return {"text": self.text, "createdAt": self.created_at}
429
+
"""
430
+
return _global_processor_registry.register_class(type_name, priority)
431
+
432
+
433
+
def unregister_type(type_name: str, priority: Optional[int] = None) -> None:
434
+
"""Unregister global type processors.
435
+
436
+
Args:
437
+
type_name: The name of the type to unregister.
438
+
priority: If specified, only unregister processors with this priority.
439
+
"""
440
+
_global_processor_registry.unregister(type_name, priority)
441
+
442
+
443
+
def get_type_decoder(type_name: str) -> Optional[Callable[[Dict[str, Any]], Any]]:
444
+
"""Get the global decoder function for a specific type.
445
+
446
+
Args:
447
+
type_name: The name of the type to get the decoder for.
448
+
449
+
Returns:
450
+
The decoder function for the specified type, or None if no decoder
451
+
is registered.
452
+
"""
453
+
return _global_processor_registry.get_decoder(type_name)
454
+
455
+
456
+
def get_type_encoder(type_name: str) -> Optional[Callable[[Any], Dict[str, Any]]]:
457
+
"""Get the global encoder function for a specific type.
458
+
459
+
Args:
460
+
type_name: The name of the type to get the encoder for.
461
+
462
+
Returns:
463
+
The encoder function for the specified type, or None if no encoder
464
+
is registered.
465
+
"""
466
+
return _global_processor_registry.get_encoder(type_name)
467
+
468
+
469
+
def has_type_processor(type_name: str) -> bool:
470
+
"""Check if a global processor is registered for a specific type.
471
+
472
+
Args:
473
+
type_name: The name of the type to check.
474
+
475
+
Returns:
476
+
True if a processor is registered for the specified type, False otherwise.
477
+
"""
478
+
return _global_processor_registry.has_processor(type_name)
479
+
480
+
481
+
def clear_type_processors() -> None:
482
+
"""Clear all globally registered processors."""
483
+
_global_processor_registry.clear()
484
+
485
+
486
+
def get_registered_types() -> set:
487
+
"""Get the set of all globally registered type names.
488
+
489
+
Returns:
490
+
A set of all registered type names.
491
+
"""
492
+
return _global_processor_registry.get_registered_types()
493
+
494
+
495
+
def create_processor_registry() -> TypeProcessorRegistry:
496
+
"""Create a new type processor registry.
497
+
498
+
This function creates a new, independent registry that can be used
499
+
instead of the global registry.
500
+
501
+
Returns:
502
+
A new TypeProcessorRegistry instance.
503
+
"""
504
+
return TypeProcessorRegistry()
+339
src/atpasser/data/wrapper.py
+339
src/atpasser/data/wrapper.py
···
1
+
"""
2
+
JSON wrapper functions for ATProto data model.
3
+
4
+
This module provides wrapper functions that mirror the standard json module
5
+
but with support for ATProto-specific data types.
6
+
"""
7
+
8
+
import json
9
+
import io
10
+
from typing import Any, Callable, Dict, Optional, TextIO, Union
11
+
from .encoder import JsonEncoder
12
+
from .decoder import JsonDecoder
13
+
from .hooks import TypeHookRegistry
14
+
from .types import TypeProcessorRegistry
15
+
16
+
17
+
def dump(
18
+
obj: Any,
19
+
fp: TextIO,
20
+
*,
21
+
skipkeys: bool = False,
22
+
ensure_ascii: bool = True,
23
+
check_circular: bool = True,
24
+
allow_nan: bool = True,
25
+
cls: Optional[type[JsonEncoder]] = None,
26
+
indent: Optional[Union[int, str]] = None,
27
+
separators: Optional[tuple[str, str]] = None,
28
+
default: Optional[Callable[[Any], Any]] = None,
29
+
sort_keys: bool = False,
30
+
encoding: str = "utf-8",
31
+
type_processor_registry: Optional[TypeProcessorRegistry] = None,
32
+
**kwargs: Any
33
+
) -> None:
34
+
"""Serialize obj as a JSON formatted stream to fp.
35
+
36
+
This function is similar to json.dump() but supports ATProto-specific
37
+
data types, including bytes, CID links, and typed objects.
38
+
39
+
Args:
40
+
obj: The object to serialize.
41
+
fp: A file-like object with a write() method.
42
+
skipkeys: If True, dict keys that are not basic types (str, int, float,
43
+
bool, None) will be skipped instead of raising a TypeError.
44
+
ensure_ascii: If True, the output is guaranteed to have all incoming
45
+
non-ASCII characters escaped. If False, these characters will be
46
+
output as-is.
47
+
check_circular: If True, circular references will be checked and
48
+
a CircularReferenceError will be raised if one is found.
49
+
allow_nan: If True, NaN, Infinity, and -Infinity will be encoded as
50
+
such. This behavior is not JSON specification compliant, but it
51
+
is consistent with most JavaScript based encoders and decoders.
52
+
Otherwise, it will raise a ValueError.
53
+
cls: A custom JSONEncoder subclass. If not specified, JsonEncoder is used.
54
+
indent: If indent is a non-negative integer or string, then JSON array
55
+
elements and object members will be pretty-printed with that indent
56
+
level. An indent level of 0, negative, or "" will only insert newlines.
57
+
None (the default) selects the most compact representation.
58
+
separators: If specified, separators should be an (item_separator, key_separator)
59
+
tuple. The default is (', ', ': ') if indent is None and (',', ': ') otherwise.
60
+
To get the most compact JSON representation, you should specify (',', ':')
61
+
to eliminate whitespace.
62
+
default: If specified, default should be a function that gets called for
63
+
objects that can't otherwise be serialized. It should return a JSON
64
+
encodable version of the object or raise a TypeError.
65
+
sort_keys: If sort_keys is True, then the output of dictionaries will be
66
+
sorted by key.
67
+
encoding: The encoding to use for string serialization.
68
+
type_processor_registry: Registry for type-specific processors.
69
+
**kwargs: Additional keyword arguments to pass to the JSON encoder.
70
+
"""
71
+
if cls is None:
72
+
cls = JsonEncoder
73
+
74
+
# Use the global type processor registry if none is provided
75
+
if type_processor_registry is None:
76
+
from .types import get_global_processor_registry
77
+
type_processor_registry = get_global_processor_registry()
78
+
79
+
# Create an encoder instance with the specified encoding and type processor registry
80
+
encoder = cls(encoding=encoding, type_processor_registry=type_processor_registry, **kwargs)
81
+
82
+
# Use the standard json.dump with our custom encoder
83
+
json.dump(
84
+
obj,
85
+
fp,
86
+
skipkeys=skipkeys,
87
+
ensure_ascii=ensure_ascii,
88
+
check_circular=check_circular,
89
+
allow_nan=allow_nan,
90
+
cls=cls,
91
+
indent=indent,
92
+
separators=separators,
93
+
default=default,
94
+
sort_keys=sort_keys,
95
+
**kwargs
96
+
)
97
+
98
+
99
+
def dumps(
100
+
obj: Any,
101
+
*,
102
+
skipkeys: bool = False,
103
+
ensure_ascii: bool = True,
104
+
check_circular: bool = True,
105
+
allow_nan: bool = True,
106
+
cls: Optional[type[JsonEncoder]] = None,
107
+
indent: Optional[Union[int, str]] = None,
108
+
separators: Optional[tuple[str, str]] = None,
109
+
default: Optional[Callable[[Any], Any]] = None,
110
+
sort_keys: bool = False,
111
+
encoding: str = "utf-8",
112
+
type_processor_registry: Optional[TypeProcessorRegistry] = None,
113
+
**kwargs: Any
114
+
) -> str:
115
+
"""Serialize obj to a JSON formatted string.
116
+
117
+
This function is similar to json.dumps() but supports ATProto-specific
118
+
data types, including bytes, CID links, and typed objects.
119
+
120
+
Args:
121
+
obj: The object to serialize.
122
+
skipkeys: If True, dict keys that are not basic types (str, int, float,
123
+
bool, None) will be skipped instead of raising a TypeError.
124
+
ensure_ascii: If True, the output is guaranteed to have all incoming
125
+
non-ASCII characters escaped. If False, these characters will be
126
+
output as-is.
127
+
check_circular: If True, circular references will be checked and
128
+
a CircularReferenceError will be raised if one is found.
129
+
allow_nan: If True, NaN, Infinity, and -Infinity will be encoded as
130
+
such. This behavior is not JSON specification compliant, but it
131
+
is consistent with most JavaScript based encoders and decoders.
132
+
Otherwise, it will raise a ValueError.
133
+
cls: A custom JSONEncoder subclass. If not specified, JsonEncoder is used.
134
+
indent: If indent is a non-negative integer or string, then JSON array
135
+
elements and object members will be pretty-printed with that indent
136
+
level. An indent level of 0, negative, or "" will only insert newlines.
137
+
None (the default) selects the most compact representation.
138
+
separators: If specified, separators should be an (item_separator, key_separator)
139
+
tuple. The default is (', ', ': ') if indent is None and (',', ': ') otherwise.
140
+
To get the most compact JSON representation, you should specify (',', ':')
141
+
to eliminate whitespace.
142
+
default: If specified, default should be a function that gets called for
143
+
objects that can't otherwise be serialized. It should return a JSON
144
+
encodable version of the object or raise a TypeError.
145
+
sort_keys: If sort_keys is True, then the output of dictionaries will be
146
+
sorted by key.
147
+
encoding: The encoding to use for string serialization.
148
+
type_processor_registry: Registry for type-specific processors.
149
+
**kwargs: Additional keyword arguments to pass to the JSON encoder.
150
+
151
+
Returns:
152
+
A JSON formatted string.
153
+
"""
154
+
if cls is None:
155
+
cls = JsonEncoder
156
+
157
+
# Create an encoder instance with the specified encoding and type processor registry
158
+
encoder = cls(encoding=encoding, type_processor_registry=type_processor_registry, **kwargs)
159
+
160
+
# Use the standard json.dumps with our custom encoder
161
+
return json.dumps(
162
+
obj,
163
+
skipkeys=skipkeys,
164
+
ensure_ascii=ensure_ascii,
165
+
check_circular=check_circular,
166
+
allow_nan=allow_nan,
167
+
cls=cls,
168
+
indent=indent,
169
+
separators=separators,
170
+
default=default,
171
+
sort_keys=sort_keys,
172
+
**kwargs
173
+
)
174
+
175
+
176
+
def load(
177
+
fp: TextIO,
178
+
*,
179
+
cls: Optional[type[JsonDecoder]] = None,
180
+
object_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
181
+
parse_float: Optional[Callable[[str], Any]] = None,
182
+
parse_int: Optional[Callable[[str], Any]] = None,
183
+
parse_constant: Optional[Callable[[str], Any]] = None,
184
+
object_pairs_hook: Optional[Callable[[list[tuple[str, Any]]], Any]] = None,
185
+
type_hook_registry: Optional[TypeHookRegistry] = None,
186
+
type_processor_registry: Optional[TypeProcessorRegistry] = None,
187
+
encoding: str = "utf-8",
188
+
**kwargs: Any
189
+
) -> Any:
190
+
"""Deserialize fp (a .read()-supporting text file or binary file containing
191
+
a JSON document) to a Python object.
192
+
193
+
This function is similar to json.load() but supports ATProto-specific
194
+
data types, including bytes, CID links, and typed objects.
195
+
196
+
Args:
197
+
fp: A .read()-supporting text file or binary file containing a JSON document.
198
+
cls: A custom JSONDecoder subclass. If not specified, JsonDecoder is used.
199
+
object_hook: Optional function that will be called with the result of
200
+
every JSON object decoded and its return value will be used in place
201
+
of the given dict.
202
+
parse_float: Optional function that will be called with the string of
203
+
every JSON float to be decoded. By default, this is equivalent to
204
+
float(num_str). This can be used to use another datatype or parser
205
+
for JSON floats (e.g. decimal.Decimal).
206
+
parse_int: Optional function that will be called with the string of
207
+
every JSON int to be decoded. By default, this is equivalent to
208
+
int(num_str). This can be used to use another datatype or parser
209
+
for JSON integers (e.g. float).
210
+
parse_constant: Optional function that will be called with the string of
211
+
every JSON constant to be decoded. By default, this is equivalent to
212
+
constant_mapping[constant_str]. This can be used to use another
213
+
datatype or parser for JSON constants (e.g. decimal.Decimal).
214
+
object_pairs_hook: Optional function that will be called with the result
215
+
of every JSON object decoded with an ordered list of pairs. The return
216
+
value of object_pairs_hook will be used instead of the dict. This
217
+
feature can be used to implement custom decoders. If object_hook is
218
+
also defined, the object_pairs_hook takes priority.
219
+
type_hook_registry: Registry for type-specific hooks.
220
+
type_processor_registry: Registry for type-specific processors.
221
+
encoding: The encoding to use for string deserialization.
222
+
**kwargs: Additional keyword arguments to pass to the JSON decoder.
223
+
224
+
Returns:
225
+
A Python object.
226
+
"""
227
+
if cls is None:
228
+
cls = JsonDecoder
229
+
230
+
# Use the global type hook registry if none is provided
231
+
if type_hook_registry is None and type_processor_registry is None:
232
+
from .hooks import get_global_registry
233
+
type_hook_registry = get_global_registry()
234
+
elif type_processor_registry is not None:
235
+
# Convert the type processor registry to a hook registry
236
+
type_hook_registry = type_processor_registry.to_hook_registry()
237
+
238
+
# Create a decoder instance with the specified parameters
239
+
decoder = cls(
240
+
object_hook=object_hook,
241
+
type_hook_registry=type_hook_registry,
242
+
encoding=encoding,
243
+
**kwargs
244
+
)
245
+
246
+
# Use the standard json.load with our custom decoder
247
+
return json.load(
248
+
fp,
249
+
cls=cls,
250
+
object_hook=object_hook,
251
+
parse_float=parse_float,
252
+
parse_int=parse_int,
253
+
parse_constant=parse_constant,
254
+
object_pairs_hook=object_pairs_hook,
255
+
**kwargs
256
+
)
257
+
258
+
259
+
def loads(
260
+
s: Union[str, bytes],
261
+
*,
262
+
cls: Optional[type[JsonDecoder]] = None,
263
+
object_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
264
+
parse_float: Optional[Callable[[str], Any]] = None,
265
+
parse_int: Optional[Callable[[str], Any]] = None,
266
+
parse_constant: Optional[Callable[[str], Any]] = None,
267
+
object_pairs_hook: Optional[Callable[[list[tuple[str, Any]]], Any]] = None,
268
+
type_hook_registry: Optional[TypeHookRegistry] = None,
269
+
type_processor_registry: Optional[TypeProcessorRegistry] = None,
270
+
encoding: str = "utf-8",
271
+
**kwargs: Any
272
+
) -> Any:
273
+
"""Deserialize s (a str, bytes or bytearray instance containing a JSON document)
274
+
to a Python object.
275
+
276
+
This function is similar to json.loads() but supports ATProto-specific
277
+
data types, including bytes, CID links, and typed objects.
278
+
279
+
Args:
280
+
s: A str, bytes or bytearray instance containing a JSON document.
281
+
cls: A custom JSONDecoder subclass. If not specified, JsonDecoder is used.
282
+
object_hook: Optional function that will be called with the result of
283
+
every JSON object decoded and its return value will be used in place
284
+
of the given dict.
285
+
parse_float: Optional function that will be called with the string of
286
+
every JSON float to be decoded. By default, this is equivalent to
287
+
float(num_str). This can be used to use another datatype or parser
288
+
for JSON floats (e.g. decimal.Decimal).
289
+
parse_int: Optional function that will be called with the string of
290
+
every JSON int to be decoded. By default, this is equivalent to
291
+
int(num_str). This can be used to use another datatype or parser
292
+
for JSON integers (e.g. float).
293
+
parse_constant: Optional function that will be called with the string of
294
+
every JSON constant to be decoded. By default, this is equivalent to
295
+
constant_mapping[constant_str]. This can be used to use another
296
+
datatype or parser for JSON constants (e.g. decimal.Decimal).
297
+
object_pairs_hook: Optional function that will be called with the result
298
+
of every JSON object decoded with an ordered list of pairs. The return
299
+
value of object_pairs_hook will be used instead of the dict. This
300
+
feature can be used to implement custom decoders. If object_hook is
301
+
also defined, the object_pairs_hook takes priority.
302
+
type_hook_registry: Registry for type-specific hooks.
303
+
type_processor_registry: Registry for type-specific processors.
304
+
encoding: The encoding to use for string deserialization.
305
+
**kwargs: Additional keyword arguments to pass to the JSON decoder.
306
+
307
+
Returns:
308
+
A Python object.
309
+
"""
310
+
if cls is None:
311
+
cls = JsonDecoder
312
+
313
+
# Use the global type hook registry if none is provided
314
+
if type_hook_registry is None and type_processor_registry is None:
315
+
from .hooks import get_global_registry
316
+
type_hook_registry = get_global_registry()
317
+
elif type_processor_registry is not None:
318
+
# Convert the type processor registry to a hook registry
319
+
type_hook_registry = type_processor_registry.to_hook_registry()
320
+
321
+
# Create a decoder instance with the specified parameters
322
+
decoder = cls(
323
+
object_hook=object_hook,
324
+
type_hook_registry=type_hook_registry,
325
+
encoding=encoding,
326
+
**kwargs
327
+
)
328
+
329
+
# Use the standard json.loads with our custom decoder
330
+
return json.loads(
331
+
s,
332
+
cls=cls,
333
+
object_hook=object_hook,
334
+
parse_float=parse_float,
335
+
parse_int=parse_int,
336
+
parse_constant=parse_constant,
337
+
object_pairs_hook=object_pairs_hook,
338
+
**kwargs
339
+
)
-4
tests/__init__.py
-4
tests/__init__.py
-179
tests/_strings.py
-179
tests/_strings.py
···
1
-
from atpasser import did, handle, nsid, rKey, uri
2
-
3
-
4
-
testStrings, testMethods = {}, {}
5
-
6
-
7
-
testStrings[
8
-
"did"
9
-
10
-
] = """did:plc:z72i7hdynmk6r22z27h6tvur
11
-
12
-
did:web:blueskyweb.xyz
13
-
14
-
did:method:val:two
15
-
16
-
did:m:v
17
-
18
-
did:method::::val
19
-
20
-
did:method:-:_:.
21
-
22
-
did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N
23
-
24
-
did:METHOD:val
25
-
26
-
did:m123:val
27
-
28
-
DID:method:val
29
-
did:method:
30
-
31
-
did:method:val/two
32
-
33
-
did:method:val?two
34
-
35
-
did:method:val#two"""
36
-
37
-
testMethods["did"] = did.DID
38
-
39
-
40
-
testStrings[
41
-
"handle"
42
-
43
-
] = """jay.bsky.social
44
-
45
-
8.cn
46
-
47
-
name.t--t
48
-
49
-
XX.LCS.MIT.EDU
50
-
a.co
51
-
52
-
xn--notarealidn.com
53
-
54
-
xn--fiqa61au8b7zsevnm8ak20mc4a87e.xn--fiqs8s
55
-
56
-
xn--ls8h.test
57
-
example.t
58
-
59
-
jo@hn.test
60
-
61
-
💩.tes
62
-
t
63
-
john..test
64
-
65
-
xn--bcher-.tld
66
-
67
-
john.0
68
-
69
-
cn.8
70
-
71
-
www.masełkowski.pl.com
72
-
73
-
org
74
-
75
-
name.org.
76
-
77
-
2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion
78
-
laptop.local
79
-
80
-
blah.arpa"""
81
-
82
-
testMethods["handle"] = handle.Handle
83
-
84
-
85
-
testStrings[
86
-
"nsid"
87
-
88
-
] = """com.example.fooBar
89
-
90
-
net.users.bob.ping
91
-
92
-
a-0.b-1.c
93
-
94
-
a.b.c
95
-
96
-
com.example.fooBarV2
97
-
98
-
cn.8.lex.stuff
99
-
100
-
com.exa💩ple.thin
101
-
com.example
102
-
103
-
com.example.3"""
104
-
105
-
testMethods["nsid"] = nsid.NSID
106
-
107
-
108
-
testStrings[
109
-
110
-
"rkey"
111
-
112
-
] = """3jui7kd54zh2y
113
-
self
114
-
example.com
115
-
116
-
~1.2-3_
117
-
118
-
dHJ1ZQ
119
-
pre:fix
120
-
121
-
_
122
-
123
-
alpha/beta
124
-
.
125
-
..
126
-
127
-
#extra
128
-
129
-
@handle
130
-
131
-
any space
132
-
133
-
any+space
134
-
135
-
number[3]
136
-
137
-
number(3)
138
-
139
-
"quote"
140
-
141
-
dHJ1ZQ=="""
142
-
143
-
testMethods["rkey"] = rKey.RKey
144
-
145
-
146
-
testStrings[
147
-
"uri"
148
-
149
-
] = """at://foo.com/com.example.foo/123
150
-
151
-
at://foo.com/example/123
152
-
153
-
at://computer
154
-
155
-
at://example.com:3000
156
-
157
-
at://foo.com/
158
-
159
-
at://user:pass@foo.com"""
160
-
161
-
testMethods["uri"] = uri.URI
162
-
163
-
164
-
for item in testMethods:
165
-
166
-
print(f"START TEST {item}")
167
-
168
-
for value in testStrings[item].splitlines():
169
-
170
-
print(f"Value: {value}")
171
-
172
-
try:
173
-
174
-
print(f"str(): {str(testMethods[item](value))}")
175
-
176
-
except Exception as e:
177
-
178
-
print(f"× {e}")
179
-